ink-engine
Date Created: 2026-09-15
Last Updated: 2026-09-20
Last Reviewed: 2026-09-20
A standalone Ink interactive-fiction interpreter and plugin engine, with no web framework or database of its own.
ink-engine runs compiled Ink stories (.ink.json/.inkj) and provides a minimal
plugin contract for applications (games) to extend the interpreter with their
own stateful mechanics — inventory, scheduling, character occupancy, and so on —
without the engine itself knowing anything about how it is embedded:
trust and security gating, storage, or any particular application's user
interface conventions.
This is a library for developers, not an app for players. It has no
user interface and nothing to run on its own — ink-engine is the
interpreter an application embeds to add Ink support to itself.
If you're looking to actually play an Ink story, or ship one:
- if_player — a standalone, native desktop player built
on this engine, for playing
.inkjgame folders directly (with image/video support), no server or account required. - QuickBBS — a self-hosted gallery/file-browser app that also plays Ink stories online, through an ordinary browser, for multiple users.
Both are real applications built on top of ink-engine's public API;
this repository is where you'd start if you're building a third one, or
contributing to the interpreter itself.
Installation
pip install ink-engine
Python 3.12 to 3.14. This installs the ink_engine and if_session
packages and the ink-bundle command.
What you get
| Ink support | Compiled Ink ("inkVersion": 21) — knots, stitches, weave, tunnels, threads, LISTs, functions, sequences, glue and tags |
| Plugins | 8 shipped, publishing 61 Ink-callable bindings: inventory, quests, skills, characters, character occupancy, a location graph, costs, and a clock with scheduled effects |
| Plugin helpers | 3 more modules a plugin calls rather than a story: containers.py (allows items in the world to hold inventory items, e.g. a chest, a glass box, a locker, a shed), item_text.py (which description an item shows), schedule_rules.py ("if this holds, they are here" rules, checked in order). They publish no bindings of their own |
| Saves | Whole-playthrough save and restore, including mid-tunnel and mid-function positions, the RNG seed, and every plugin's state |
| Distribution | ink-bundle packs a game folder into one .zip the engine plays without unpacking, with three integrity hashes |
| Dependencies | One: pyyaml. The interpreter itself is stdlib-only |
| Tests | 1065, at 93% coverage, with expected output captured from real inklecate runs |
Design
- The only third-party dependency is
pyyaml. The interpreter and the plugin contract are pure Python stdlib:ink_engine.engine,pluginanddiscoveryimport with nothing installed.pyyamlis needed only to read a game'smanifest.yaml—game_folder.py,game_source.py,bundler.pyandbundle_integrity.py. - You decide what gets loaded.
ink-enginescans exactly the sources you pass todiscover_plugins(sources)— it never searches for plugins on its own. Which sources are safe to load is a policy decision that belongs to your application, not to a library that cannot know your users. - The plugin contract is small.
Plugindescribes a discoverable unit of InkEXTERNALbindings, optionally with a private per-session state slice.discover_plugins()finds them;resolve_bindings()builds the real bindings dict for one game session.
Using the engine
The core interpreter is InkRuntimeState. Build one from a compiled story's
parsed JSON, then alternate continue_story() (runs until the next choice
point or the story ends) with choose(index) (picks one of the choices just
offered):
import json
from ink_engine.engine import InkRuntimeState, load_story_root, load_list_defs
with open("hello.ink.json") as f:
compiled = json.load(f)
root = load_story_root(compiled)
list_defs = load_list_defs(compiled)
state = InkRuntimeState(root, list_defs)
state.continue_story() # run the opening turn
print(state.last_turn_text) # the text just produced
for i, choice in enumerate(state.current_choices):
print(i, choice.text)
state.choose(0) # pick the first choice
state.continue_story() # run to the next stopping point
print(state.last_turn_text)
print(state.done) # True once the story reaches -> END
A fresh InkRuntimeState produces no text until the first continue_story()
call — the constructor only runs the story's own global-variable
initialization (VAR declarations), matching the real Ink engine's own
ResetGlobals behavior.
Saving and resuming: state.to_dict() returns a plain, JSON-safe dict of
everything needed to resume later; InkRuntimeState.from_dict(root, data, list_defs, engine_bindings=...) rebuilds an equivalent state from it. root/
list_defs come from re-loading the same compiled story JSON — they are not
themselves part of the saved data, since they never change for a given
compiled story.
Adding EXTERNAL bindings: pass engine_bindings={"function_name": callable, ...} to the constructor (or from_dict) to make Python callables
reachable from EXTERNAL function_name(...) calls in the Ink source. See
ink_engine/plugin.py and ink_engine/discovery.py for the higher-level
Plugin/discover_plugins() machinery that assembles this dict from an
application's own stateful plugins (inventory, quests, skills, etc. —
see ink_engine/engine_plugins/), rather than building engine_bindings by
hand.
Documentation
Start with whichever matches what you are doing.
| Guide | For |
|---|---|
| Plugin and binding guide | Calling Python from a story, and writing plugins. The main guide for embedding the engine. |
| Writing a game manifest | Every manifest.yaml field, and what each one does. |
| Building a game bundle | Packing a finished game into a distributable .zip. |
| ink_engine vs. standard Ink | What this engine adds and where it diverges, for anyone coming from inkle's Ink. |
| Ink: when it compiles but is wrong | Ink that compiles cleanly and does not do what you meant. |
| Miscellaneous notes | Runtime facts about the interpreter, for working on it rather than with it. |
Also: examples/ for a runnable story,
if_session/ for the session library, and
tests/ for how the suite is built.
inkle's own Ink documentation is vendored under
docs/inkles-ink-standard/ as a
pinned snapshot, and is not edited here.
Limitations
docs/ink_engine_vs_standard_ink.md covers what this engine adds and
where it diverges, for anyone coming from inkle's Ink. The list below is
the short form.
-
Compiled Ink only — this library has no Ink compiler.
ink-enginereads the JSON thatinklecateor Inky produces, and has no.inkparser. A.inkfile with no compiled counterpart is not playable here, and there is no way around that short of running a real Ink compiler yourself first (see Compiling stories below). A compiler will not be added; compile withinklecateor Inky and ship the result. -
.inkjand.ink.jsonare the same thing. Both name a compiled Ink story; only the filename differs.inklecate's default output extension is.ink.json, and this project's game-folder layout prefers the shorter.inkj— so that is what the documentation and examples use, and what a game folder is expected to ship.Neither is enforced. The interpreter (
load_story_root()/load_list_defs()) takes parsed JSON and never sees a filename at all.find_main_story_file()returns whateverMAIN_STORY_FILEnames, checking only that the file exists —.ink.json,.inkjor any other name loads identically. The.inkjsuffix appears in one error message and in bundle fingerprinting, never as a check on what may play. -
One playthrough per
InkRuntimeState. The object is plain Python with ordinary mutable attributes and no internal locking, so give each player their own. A turn entered while another is still running on the same state raisesConcurrentPlaythroughErrorrather than corrupting it; running turns on it one at a time from different threads is fine. A compiled story root is read-only and may be shared by any number of states.
Compiling stories
Ink source (.ink) must be compiled to JSON before ink-engine can play it.
Two ways to do that:
-
Inky — the official Ink editor, with a built-in compiler and live preview. The easiest path for authoring and testing a story before shipping it.
-
inklecate— the command-line compiler from inkle/ink (MIT licensed), for scripting a build step:inklecate -o story.ink.json story.ink
inklecate -p story.inkalso runs the story directly in a terminal play-mode, useful for a quick sanity check independent of this engine entirely.
Either way, the compiled JSON is what this library reads — check that
output into your game folder (or generate it as part of your own build), not
the .ink source.
Distributing a game
A game is a folder, and ink-bundle packs one into a single .zip that
the engine plays without unpacking:
ink-bundle inspect my-game # report what would ship; writes no files
ink-bundle build my-game # write the .zip
ink-bundle verify my-game.zip
docs/building_a_game_bundle.md walks through it, and
docs/game_manifest_guide.md documents the manifest fields that decide
what ships.
Quickstart: a tiny example story
Both files below are also checked in under examples/
(hello.ink and its compiled hello.ink.json), ready to run without
compiling anything yourself first.
Save this as hello.ink:
You wake in a small stone room. A single door stands to the north.
* [Try the door]
The door creaks open. Morning light spills in.
-> ending
* [Look around first]
Dust, cobwebs, an old chest in the corner.
-> ending
=== ending ===
You step outside, blinking in the sun.
-> END
Compile it:
inklecate -o hello.ink.json hello.ink
Play it with the engine:
import json
from ink_engine.engine import InkRuntimeState, load_story_root, load_list_defs
with open("hello.ink.json") as f:
compiled = json.load(f)
state = InkRuntimeState(load_story_root(compiled), load_list_defs(compiled))
state.continue_story()
print(state.last_turn_text)
# You wake in a small stone room. A single door stands to the north.
for i, choice in enumerate(state.current_choices):
print(i, choice.text)
# 0 Try the door
# 1 Look around first
state.choose(0)
state.continue_story()
print(state.last_turn_text)
# The door creaks open. Morning light spills in.
# You step outside, blinking in the sun.
print(state.done)
# True
Development
git clone https://github.com/bschollnick/ink_engine.git
cd ink_engine
poetry install
Run the suite:
poetry run python -m pytest tests/ -q
1064 passed, 1 skipped, 270 subtests passed
One test is skipped by default: it asks whether inkle has revised the documentation this engine was written against, which needs the network. Two are opt-in:
# Has inkle's documentation moved since the pinned snapshot?
INK_ENGINE_CHECK_UPSTREAM=1 poetry run python -m pytest \
tests/test_vendored_standard_is_current.py -q
# Sweep container paths over a large story of your own
INK_ENGINE_SWEEP_STORY=/path/to/story.inkj poetry run python -m pytest \
tests/test_engine_path_sweep.py -q
Coverage, type checking and linting:
poetry run python -m pytest tests/ --cov=ink_engine --cov=if_session
poetry run mypy ink_engine/
poetry run python -m pylint ink_engine/
tests/README.md describes how the suite is built and
what each file protects.
A note on this project
I am not an Ink author. Any mistakes in the code or the documentation are mine, not the language's. Please feel free to submit documentation and code errors.
This library has no connection to inkle. It is entirely reverse engineered from the inkle standard documentation and from comparing the results of compiled Ink run through both engines.
Licence
BSD 3-Clause. See LICENSE.
inkle's own Ink documentation is vendored under
docs/inkles-ink-standard/ as a pinned snapshot, under inkle's own MIT
licence, which travels with it.
Metadata
Release files for ink-engine 1.0.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | Actions |
|---|---|---|---|---|
| ink_engine-1.0.0.tar.gz | 155.9 kB | Without provenance | Details for ink_engine-1.0.0.tar.gz |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| ink_engine-1.0.0-py3-none-any.whl | Python 3 | none | any | Details for ink_engine-1.0.0-py3-none-any.whl |
Total release size: 328.8 kB
Release files / ink_engine-1.0.0.tar.gz
| Download URL | ink_engine-1.0.0.tar.gz |
|---|---|
| Size | 155.9 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
1cb1693028507d5622e07fd9f1c49bfdae269e401ba4ced020869452b47568b6
|
|
BLAKE2b-256 checksum How to use checksums |
c3292bbb2540c3be600a5fce2c00a43986cae2406ac57650548bec31cd0ef305
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
poetry/2.4.1 CPython/3.14.6 Darwin/25.6.0
|
Release files / ink_engine-1.0.0-py3-none-any.whl
| Download URL | ink_engine-1.0.0-py3-none-any.whl |
|---|---|
| Size | 172.9 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
d5484f5a6cc942b83ead0aaac2a2eb9f40f4b4d47899d575a135f41662648a80
|
|
BLAKE2b-256 checksum How to use checksums |
4ce6be30fad46940644610d30d3476033e06cd6a2a0393728ea0af1363e49221
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
poetry/2.4.1 CPython/3.14.6 Darwin/25.6.0
|
Security for ink-engine 1.0.0
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
No provenance found
None of the files in this release were uploaded with provenance metadata.
A note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.