Introduction
brink is a toolchain for inkle’s ink
narrative scripting language, written in Rust. It compiles .ink source to a
compact bytecode format and executes it in a stack-based VM — as a CLI tool, an
embeddable Rust library, or a WASM module behind a web app.
Where to start
The book is in two halves: the toolchain (the engine-neutral core — compile and run stories) and integrations & clients (the things built on top). Jump to what you’re doing:
| You want to… | Start at |
|---|---|
| Write ink and play it from the terminal | Installation → The CLI |
| Drive stories from a Rust program | Your First Story → Embedding the Runtime |
| Ship a story in a Bevy game | Bevy Integration |
| Build a web front-end or editor | Web & WASM · Studio |
| Translate a story | Localization |
| Understand how it works, or hack on it | Concepts · Contributing |
Features
- Full ink language support: choices, gathers, weave, variables, lists, sequences, tunnels, threads, external functions
- Bytecode compiler with multi-file support (
INCLUDEresolution) - Stack-based VM with multi-instance execution (one compiled program, many story instances)
- Localization-ready format with line templates, interpolation slots, and plural categories
- Language server (LSP) and WASM bindings for editor and web integration
- No unsafe code, no panics — strict lint policy
Learning ink
brink implements the ink language as designed by inkle. To learn the language itself, see inkle’s Writing with Ink. This book documents brink — the compiler, runtime, and the things you build with them.
A note on maturity
brink has two ways to produce a runnable story: the native compiler (the
normal path, reads .ink) and a converter that ingests inklecate’s output
as a known-good reference. The native compiler is under active development and
validated against the converter; until it reaches full parity, the converter is
available for stories you already have as .ink.json. See
The Two Pipelines for which to use.
Installation
CLI
Install from source using Cargo:
cargo install --git https://github.com/Syynth/brink brink-cli
This builds and installs the brink binary. No prebuilt binaries are available yet.
Library
Add brink-runtime to your project. Since brink is not yet published to crates.io, use a git dependency:
[dependencies]
brink-runtime = { git = "https://github.com/Syynth/brink" }
If you also need the compiler (to compile .ink source at build time or runtime):
[dependencies]
brink-compiler = { git = "https://github.com/Syynth/brink" }
brink-runtime = { git = "https://github.com/Syynth/brink" }
The brink-runtime crate is the primary library interface. It depends only on brink-format (the binary interface) and has no compiler dependencies.
Quick Start
Playing a story from the command line
# Compile an ink story to binary
brink compile story.ink -o story.inkb
# Play it interactively
brink play story.inkb
Embedding the runtime in Rust
use std::path::Path;
use brink_compiler::compile_path;
use brink_runtime::{Line, Story};
fn main() -> Result<(), Box<dyn std::error::Error>> {
// Compile .ink source. `compile_path` returns a `CompileOutput`;
// its `.data` field is the `StoryData`.
let output = compile_path(Path::new("story.ink"))?;
// Link into an immutable `Program` plus its line tables.
let (program, line_tables) = brink_runtime::link(&output.data)?;
// Create a story instance and run it. `continue_single` returns the
// next `Line`; the variant tells you what to do.
let mut story = Story::new(&program, line_tables);
loop {
match story.continue_single()? {
// Mid-stream content — keep going.
Line::Text { text, .. } | Line::Done { text, .. } => print!("{text}"),
Line::Choices { text, choices, .. } => {
print!("{text}");
for choice in &choices {
println!(" {}. {}", choice.index + 1, choice.text);
}
// Select the first choice (replace with real input).
story.choose(choices[0].index)?;
}
Line::End { text, .. } => {
print!("{text}");
break;
}
}
}
Ok(())
}
If you already have a compiled .inkb file, decode it directly instead of
compiling:
use brink_runtime::{Line, Story};
let bytes = std::fs::read("story.inkb")?;
let story_data = brink_format::read_inkb(&bytes)?;
let (program, line_tables) = brink_runtime::link(&story_data)?;
let mut story = Story::new(&program, line_tables);
// ... step loop as above
The CLI
brink-cli (the brink binary) provides commands for compiling, playing,
localizing, and formatting ink stories.
brink --help
Commands
| Command | Description |
|---|---|
compile | Compile .ink source to .inkb or .inkt |
convert | Convert between ink formats (.ink.json, .inkb, .inkt) |
play | Play an ink story interactively or in batch mode |
export-xliff | Export a story’s line tables as an XLIFF 2.0 file for translation |
compile-locale | Compile a translated XLIFF into a .inkl locale overlay |
regenerate-xliff | Update an XLIFF after recompilation, preserving translations |
fmt | Format .ink source files (--check, --stdin) |
replay | Re-render a saved .brkt transcript against a story (optionally a locale) |
brink compile
Compile .ink source files to bytecode. The input file is the story’s entry point; INCLUDE directives are resolved automatically.
brink compile <INPUT> [--output <OUTPUT>]
Options
| Flag | Default | Description |
|---|---|---|
--output <FILE> / -o | stdout | Output file path. Format inferred from extension. |
Output format is determined by the file extension:
| Extension | Format |
|---|---|
.inkb | Binary bytecode (production format) |
.inkt | Human-readable text dump (debugging) |
When no -o flag is given, .inkt is printed to stdout.
Examples
# Compile to binary
brink compile story.ink -o story.inkb
# Debug dump to file
brink compile story.ink -o story.inkt
# Debug dump to stdout
brink compile story.ink
brink convert
Convert between ink formats. This uses the converter pipeline (brink-converter), which processes inklecate’s JSON output rather than compiling from .ink source. Use brink compile for native compilation — see The Two Pipelines for which you want.
Input format is inferred from the file extension; output defaults to .inkt on stdout.
brink convert <INPUT> [--output <OUTPUT>]
Options
| Flag | Default | Description |
|---|---|---|
--output <FILE> / -o | stdout (.inkt) | Output file path. Format inferred from extension. |
Supported formats
| Extension | Format | Description |
|---|---|---|
.ink.json | inklecate JSON | Output from the reference ink compiler |
.inkb | Binary bytecode | brink’s native binary format |
.inkt | Textual bytecode | Human-readable disassembly |
Examples
# Disassemble ink.json to readable bytecode (stdout)
brink convert story.ink.json
# Convert ink.json to binary
brink convert story.ink.json -o story.inkb
# Disassemble binary to text
brink convert story.inkb -o story.inkt
brink play
Play an ink story interactively in the terminal.
brink play [OPTIONS] <FILE>
Accepts a compiled story (.inkb, .ink.json, or .inkt) or raw .ink source — .ink files are compiled in-memory via the native pipeline, so brink play story.ink works without a separate brink compile step.
Options
| Flag | Default | Description |
|---|---|---|
--speed <N> / -s | 30 | Typewriter speed in characters per second (0 = instant) |
--input <FILE> / -i | — | Read choice inputs from a file (batch mode) |
--locale <FILE> | — | Locale overlay (.inkl) to make available; repeatable. Switch at runtime with the l key. |
--save-transcript <FILE> | — | Write the playthrough’s .brkt transcript after the session ends. |
A saved .brkt can be re-rendered later (in any locale) with brink replay <TRANSCRIPT> --story <FILE> [--locale <FILE>].
Interactive mode
When run in a terminal, brink play launches a TUI with typewriter text reveal and arrow-key choice selection.
Key bindings
| Key | Story panel | Choice panel |
|---|---|---|
Space | Skip typewriter | Skip typewriter |
Up/Down | Scroll history | Select choice |
Enter | — | Confirm choice |
Tab | Focus choices | Focus story |
q | Quit | Quit |
Batch mode
When stdin is piped or --input is provided, the TUI is bypassed and choices are read as line-delimited 1-indexed integers.
# Pipe choices
printf "1\n3\n" | brink play story.inkb
# Read choices from a file
brink play story.inkb -i choices.txt
In batch mode, story text and choices are printed to stdout as plain text.
Embedding the Runtime
brink-runtime is the bytecode VM. Embed it to drive ink stories from a Rust
program — a game, a tool, a custom engine. It depends only on brink-format, so
pulling it in doesn’t drag the compiler along.
This section is the hands-on path. For the mental model behind it, read The Execution Model; for the exhaustive API surface, see Reference › Runtime API.
The two-object model
The runtime keeps compiled data and execution state in separate objects — this is the one structural idea to internalize:
Program— the immutable bytecode, variable defaults, and metadata. Built once vialink(), shareable across threads.Story— all the mutable state: operand stack, call stack, globals, visit counts, output buffer, and the line tables it renders with. It borrows from aProgram.
Because Program is immutable, many Story instances can run concurrently
against one Program — parallel playthroughs, or replaying with different
choices, share the compiled data for free.
let (program, line_tables) = brink_runtime::link(&story_data)?;
let mut story = Story::new(&program, line_tables);
The shape of embedding
- Loading & Linking — produce
StoryData(compile.inkor read.inkb) andlink()it into aProgram+ line tables. - Drive it — step the story and react to each
Line. The loop, theLinevariants, and choice handling all live in The Execution Model. - External Functions — let the story call back
into your code (
EXTERNALfunctions), synchronously or deferred. - Named Flows — run parallel execution contexts within one story.
A minimal driver looks like this — see the execution-model page for what each arm means:
loop {
match story.continue_single()? {
Line::Text { text, .. } | Line::Done { text, .. } => print!("{text}"),
Line::Choices { text, choices, .. } => {
print!("{text}");
story.choose(/* player's pick */ choices[0].index)?;
}
Line::End { text, .. } => { print!("{text}"); break; }
}
}
Loading & Linking
Before running a story, you need to produce StoryData and link it into a Program.
Producing StoryData
There are two paths:
From .ink source (native compiler): compile_path returns a
CompileOutput; its .data field is the StoryData.
use std::path::Path;
let output = brink_compiler::compile_path(Path::new("story.ink"))?;
let story_data = output.data;
From .inkb bytes (pre-compiled binary):
let bytes = std::fs::read("story.inkb")?;
let story_data = brink_format::read_inkb(&bytes)?;
Linking
let (program, line_tables) = brink_runtime::link(&story_data)?;
The linker resolves all DefinitionId references to compact runtime indices, validates the container graph, and initializes global variable defaults. It returns the immutable Program together with the story’s line tables (Vec<Vec<LineEntry>>) — the localizable rendering data, kept separate so it can be swapped for a locale overlay or hot-reloaded without rebuilding the program.
Creating stories
let mut story = Story::new(&program, line_tables);
Story borrows from Program and owns the line tables it renders with. You can create multiple stories from the same program for parallel execution or replaying with different choices.
Error cases
Decode— corrupt or incompatible.inkbfile (wrong magic, bad checksum, truncated data)UnresolvedDefinition— a container references aDefinitionIdthat doesn’t exist in the story dataNoRootContainer— the story has no entry point container
External Functions
Ink stories can call functions the host provides — EXTERNAL fn_name(args) in
ink source. When the VM hits such a call, it asks your handler for a value.
Implement the ExternalFnHandler trait:
trait ExternalFnHandler {
fn call(&self, name: &str, args: &[Value]) -> ExternalResult;
}
enum ExternalResult {
Resolved(Value), // return a value immediately
Fallback, // run the ink-defined fallback body, if any
Pending, // defer resolution — supply the value later
}
Step with handler support using the _with entry points:
let lines = story.continue_maximally_with(&handler)?;
// or, one line at a time:
let line = story.continue_single_with(&handler)?;
Resolution modes
Resolved(value)— the common case. You computed the answer; the VM pushes it and keeps going.Fallback— defer to the ink-side fallback body declared for that external (if the story provides one). ReturningFallbackfor an unknown name is how stories stay runnable without every binding present.Pending— you can’t answer synchronously (waiting on input, a network call, the game world). The story pauses on the deferred external. Supply the result later withstory.resolve_external(value)and resume stepping.
match story.continue_single_with(&handler)? {
// … handler returned Pending somewhere inside this step …
_ => {
// later, once you have the answer:
story.resolve_external(Value::Int(42))?;
let line = story.continue_single_with(&handler)?;
}
}
If you have no externals to provide, pass &brink_runtime::FallbackHandler and
every call uses its ink-side fallback.
The
bevy-brinkintegration builds a far richer binding facility on top of this — pure / command / world-query / async bindings, plus engine→ink calls. See Bevy › External Functions.
Named Flows
A single Story can run several independent execution contexts at once —
named flows. Each flow has its own position, call stack, and output, while
sharing the story’s globals and visit counts. They’re how you model a background
conversation, a parallel subplot, or a side channel that advances on its own.
story.spawn_flow("background", entry_point_id)?; // start a flow at an address
let lines = story.continue_flow_maximally("background")?; // -> Vec<Line>
story.choose_flow("background", index)?; // pick a choice in that flow
story.destroy_flow("background")?; // tear it down
flow_names() lists the currently active named flows.
The default, unnamed flow is the one driven by continue_single /
continue_maximally / choose. The *_flow variants target a flow by name and
otherwise behave identically — same Line results, same choice protocol.
Errors
UnknownFlow— referenced a flow name that isn’t active.FlowAlreadyExists—spawn_flowwith a name that’s already in use.
See Reference › Errors for the full list.
For engine integration,
bevy-brinkexposes each flow as an entity with its own components rather than name-keyed lookups — see Bevy › Spawning & Driving Flows.
Localization
brink separates executable logic from localizable text. The bytecode is
locale-independent — all user-visible text is referenced by a
(DefinitionId, u16) pair: a scope-relative index into a lexical scope’s
(knot / stitch / root) line table. Locale-specific content lives in .inkl
overlay files that replace line content per scope, without touching bytecode.
Status: shipped end-to-end. Line templates, plural categories, and select keys live in
brink-format;.inklloading + plural-aware rendering are inbrink-runtime(apply_locale, thePluralResolvertrait); the CLI exposesexport-xliff/compile-locale/regenerate-xliff;brink-intlprovides the library API andIcuPluralResolver.bevy-brinkadds runtime locale switching — see Bevy › Localization & Saves.
Design principles
- Bytecode is locale-independent.
EmitLine(2)always means “line 2 of this scope’s table” — the VM never sees text directly. - Text lives in line tables, not the instruction stream, so content can be replaced without recompiling bytecode.
.inkloverlays replace line content per scope, never control flow.- Plural and gender logic lives in the line template, not the VM — translators can restructure sentences, reorder slots, and change plural forms per locale.
- Voice acting and text localization share one
LineIdaddressing scheme.
How the pieces fit
| You want to… | See |
|---|---|
| Extract, translate, and compile a locale | XLIFF Workflow |
| Understand plural categories and resolvers | Plurals |
| Know what a line template can express | Reference › Line Templates |
Read the .inkl byte layout | Reference › Binary Format |
The translation pipeline is always .ink → compile → .inkb →
export-xliff → .xlf. Never feed inklecate .ink.json into the intl tooling
(see The Two Pipelines).
XLIFF Workflow
Localization source files use XLIFF 2.0 — one file per locale. Lexical scopes (knots/stitches/root) map to <file> elements within the XLIFF document. brink-specific metadata (content hashes for change tracking) uses XLIFF’s custom namespace extension (brink:, see BRINK_NS in brink-intl), which conforming tools preserve across round-trips.
The workflow is shipped end-to-end: the brink CLI exposes export-xliff, compile-locale, and regenerate-xliff, and brink-intl exposes the same operations as a library (generate_locale, compile_locale_xliff, regenerate_locale).
Why XLIFF
Every major translation management platform (Lokalise, Crowdin, etc.) natively imports/exports XLIFF, and the spec requires tools to preserve unknown extensions — brink-specific metadata survives round-trips through external tooling.
Workflow
The translation pipeline is .ink → compile → .inkb → export-xliff → .xlf.
(Always start from a compiled .inkb; never feed inklecate’s .ink.json into
the intl tooling.)
-
Export: extract every translatable line from a compiled story into an XLIFF file, organized by scope with context for translators.
brink export-xliff story.inkb --src-lang en --trg-lang es -o story.es.xlf -
Translate: work in the
.xlfdirectly or import it into a TMS (Lokalise, Crowdin, …). Translation state rides XLIFF’sstateattribute (initial/translated/reviewed/final). -
Compile: turn the translated XLIFF into a binary
.inkloverlay.brink compile-locale --base story.inkb --xliff story.es.xlf --locale es -o story.es.inkl -
Regenerate: after the source changes and you recompile, diff the new
.inkbagainst the existing XLIFF — preserving human translations while updating machine-managed fields (original text, context). Content-hash changes flag entries whose source moved.brink regenerate-xliff --base story.inkb --existing story.es.xlf -o story.es.xlf
Load the resulting .inkl at runtime with brink_runtime::apply_locale, or in
Bevy via the locale-switching API (see the Bevy Integration section).
Plural Resolution
brink uses CLDR plural categories for locale-aware text. The runtime itself ships no locale data — consumers provide a resolver via the PluralResolver trait.
PluralCategory
enum PluralCategory {
Zero,
One,
Two,
Few,
Many,
Other,
}
These correspond to the six CLDR plural categories. Different languages use different subsets — English uses One and Other, Arabic uses all six, Japanese uses only Other.
The PluralResolver trait
trait PluralResolver {
fn cardinal(&self, n: i64, locale_override: Option<&str>) -> PluralCategory;
fn ordinal(&self, n: i64) -> PluralCategory;
}
cardinal()— determines the plural form for cardinal numbers. “1 apple” vs “2 apples” in English; more complex rules in other languages.ordinal()— determines the plural form for ordinal numbers. “1st”, “2nd”, “3rd”, “4th” in English.locale_override— allows per-call locale switching for mixed-language stories.
No resolver (fallback)
Stories without localization don’t need a resolver. When no resolver is provided, all plural selects fall back to PluralCategory::Other, and Select parts in line templates use their default variant.
Custom implementation
Implement PluralResolver for your own type to provide locale-aware plural handling:
struct EnglishPlurals;
impl PluralResolver for EnglishPlurals {
fn cardinal(&self, n: i64, _locale: Option<&str>) -> PluralCategory {
if n == 1 { PluralCategory::One } else { PluralCategory::Other }
}
fn ordinal(&self, n: i64) -> PluralCategory {
match n % 10 {
1 if n % 100 != 11 => PluralCategory::One,
2 if n % 100 != 12 => PluralCategory::Two,
3 if n % 100 != 13 => PluralCategory::Few,
_ => PluralCategory::Other,
}
}
}
Batteries-included resolvers
The brink-intl crate ships two ready-made resolvers so you don’t have to hand-write CLDR rules:
IcuPluralResolver— backed by ICU4X with CLDR baked data (~50 KB), correct for every CLDR locale.DefaultPluralResolver— a minimal English-only resolver for stories that don’t localize.
use brink_intl::IcuPluralResolver;
let resolver = IcuPluralResolver::new();
// pass `Some(&resolver)` to render_transcript / apply_locale rendering
Concepts
These pages explain how brink works — the mental model behind the API, not a how-to. Read them once and the guides and reference will make more sense; skip them and you can still get a story running, you just won’t know why.
- The Two Pipelines — why there’s a native compiler and a converter, and which one you want.
- The Execution Model — how a compiled story runs:
the
Program/Storysplit, the step loop,Line, and choices. The shared foundation every client (raw Rust, Bevy, web) builds on. - Architecture & the Firewall — how the crates are split so the runtime never links the compiler.
- The Compilation Pipeline — the six phases that turn
.inksource into bytecode.
The Two Pipelines
brink can produce a runnable story two ways. You will almost always want the
first; the second exists mainly as a correctness reference. Both emit the same
StoryData, so everything downstream — linking, the VM, localization — is
identical regardless of which produced it.
Native compiler — what you use
.ink source → parse → HIR → analyze → LIR → bytecode codegen → StoryData
This is the real compiler: it reads .ink source directly. The CLI’s
brink compile and the library’s brink_compiler::compile_path both drive it.
If you are writing ink and shipping a game, this is your path — you never touch
the converter.
Converter — the reference pipeline
.ink.json (inklecate output) → parse → convert → StoryData
inklecate is inkle’s reference C# compiler. The converter takes its JSON
output and turns it into brink StoryData. It exists for one reason:
it is known-good. Because inklecate is the canonical ink implementation, a
story routed through the converter behaves exactly as inkle’s tools intend.
That makes the converter the yardstick the native compiler is measured against. The test corpus compiles each story both ways and compares the runtime behavior episode-by-episode (see Test Corpus). The native compiler is correct to the extent it matches the converter.
Which should I use?
| You have… | Use |
|---|---|
.ink source | brink compile (native) — the normal case |
only inklecate .ink.json output | brink convert (converter) |
| a need to diff brink against inklecate | both, and compare the .inkt dumps |
The native compiler is under active development and not every ink feature is
fully supported yet. Until it reaches parity, the converter is available as a
production-ready path for stories you already have as .ink.json. For new work,
write .ink and use brink compile.
The translation pipeline (
export-xliff,compile-locale) only accepts compiled.inkb— never feed inklecate.ink.jsoninto the intl tooling. See Localization.
The Execution Model
A compiled story runs as a synchronous step function: it executes bytecode
until it reaches a yield point, then hands back a Line. The variant of that
Line tells you what just happened and what to do next. This loop is the shared
foundation under every client — raw Rust, Bevy, the web runner all express the
same model.
use brink_runtime::{Line, Story};
let mut story = Story::new(&program, line_tables);
loop {
match story.continue_single()? {
// Mid-stream content; more may follow this turn.
Line::Text { text, tags } => print!("{text}"),
// This turn's output is complete (`-> DONE`); keep stepping.
Line::Done { text, tags } => print!("{text}"),
Line::Choices { text, tags, choices } => {
print!("{text}");
// Present `choices`, get the player's selection...
story.choose(chosen_index)?;
}
Line::End { text, tags } => {
print!("{text}");
break;
}
}
}
Storyis the mutable half of the two-object model: it borrows an immutableProgramand carries all the execution state.
Line variants
| Variant | Meaning | Next action |
|---|---|---|
Text { text, tags } | One line of content. More may follow this turn. | Call continue_single() again. |
Done { text, tags } | The turn’s output is complete (ink done). The story is not over. | Call continue_single() again for the next turn. |
Choices { text, tags, choices } | The story is waiting for a choice. | Call story.choose(index), then continue. |
End { text, tags } | The story reached -> END. Permanently finished. | Stop stepping. |
Every variant carries the text produced since the last yield point and any ink
tags (# tag) attached to it. The helpers line.text(), line.tags(), and
line.is_terminal() work across variants (is_terminal() is true for anything
but Text).
continue_single vs continue_maximally
continue_single() -> Lineproduces one line — ideal for typewriter UIs that reveal content a line at a time.continue_maximally() -> Vec<Line>runs until a terminal line and returns every line produced along the way; the last element is always a terminal variant (Done,Choices, orEnd). Ideal for click-to-continue UIs that show a whole passage at once.
loop {
let lines = story.continue_maximally()?;
for line in &lines {
print!("{}", line.text());
}
match lines.last() {
Some(Line::Choices { choices, .. }) => story.choose(choices[0].index)?,
Some(Line::End { .. }) | None => break,
_ => {} // Done — loop again for the next turn.
}
}
Both have _with(&handler) variants (continue_single_with,
continue_maximally_with) that take a custom ExternalFnHandler for
external functions.
Choices
When the story yields Line::Choices, execution is blocked until you select one
with story.choose(index):
Line::Choices { text, choices, .. } => {
for choice in &choices {
println!("{}: {}", choice.index + 1, choice.text);
}
story.choose(choices[selected].index)?;
}
Each Choice carries:
| Field | Type | Description |
|---|---|---|
text | String | display text for this choice |
index | usize | the value to pass to story.choose() |
tags | Vec<String> | tags attached to this choice |
Ink defines several choice kinds, but they’re resolved by the compiler and VM
— the runtime always hands you a flat Vec<Choice> of the ones currently
selectable:
- Once-only (
*) — the default; disappears after it’s taken. - Sticky (
+) — stays available on later visits. - Fallback — has no display text; auto-selected when nothing else is
available, and never appears in the
choicesvec. - Conditional — guarded by a condition; only present when the guard is true.
Choice-related errors (InvalidChoiceIndex, NotWaitingForChoice) are listed in
Reference › Errors.
StoryStatus
You can query story.status() at any time:
| Status | Meaning |
|---|---|
Active | Ready to step. |
WaitingForChoice | Must call choose() before stepping. |
Done | Hit a done opcode. Can resume with continue_single(). |
Ended | Hit -> END. Cannot step further. |
Text accumulation
A story may produce several Text/Done lines before reaching Choices or
End. Each continue_single() carries only the text since the previous yield.
If your application needs the full passage, accumulate text across lines until a
Choices or End arrives — or use continue_maximally(), which batches a whole
passage for you.
Architecture & the Firewall
brink is a workspace of focused crates with strict dependency rules. The central
design principle is the firewall: brink-format is the only crate shared
between the compiler and the runtime, so the runtime has zero knowledge of
source-level concepts.
The firewall principle
The crate graph is split into two halves by brink-format:
- Compiler side — every crate that understands ink source code (syntax, IR, analysis, codegen). Internal; may change without notice.
- Runtime side —
brink-runtime, which only understands compiled bytecode. It depends exclusively onbrink-format.
This split has real, practical consequences:
- The runtime never links the compiler. Shipping
brink-runtimedoes not pull in the parser, analyzer, or codegen — the embeddable binary stays small. brink-formatdefines everything that crosses the boundary —StoryData,ContainerDef,AddressDef,Opcode,Value,DefinitionId, and the rest. Source-level types (AST, HIR, symbols) never leak through it.- Hot-reload works — because the runtime loads bytecode without the compiler
present, new
StoryDatacan be swapped in at runtime. - Save-file portability — the runtime speaks only in
DefinitionIds (stable hashes), so save state isn’t tied to a particular compilation.
The same firewall is why the LSP depends on the analyzer but not the compiler: editor features need parse-through-analysis, not codegen.
For the full crate inventory, paths, and the exact dependency rules, see Contributing › Crate Layout. For how source becomes bytecode, see The Compilation Pipeline.
Compilation Pipeline
The compiler transforms .ink source files into bytecode through six phases:
Phase 1: Discovery + Parse (brink-db, brink-syntax) per-file -> CST -> AST
Phase 2: HIR Lowering (brink-ir::hir) per-file -> HIR
Phase 3: Analysis (brink-analyzer) cross-file -> symbol resolution, types
Phase 4: LIR Lowering (brink-ir::lir) cross-file -> unified LIR program
Phase 5: Bytecode Codegen (brink-codegen-inkb) per-container -> StoryData
Phase 6: Output (brink-format) -> .inkb / .inkt
The LSP runs phases 1-3. The compiler runs all phases.
Phase 1: Discovery + Parse
ProjectDb::discover() finds all .ink files starting from the entry point, following INCLUDE directives. Each file is parsed by brink-syntax into a lossless CST (via rowan) and then into a typed AST. The parser uses error recovery and always produces output, even for malformed input.
Phase 2: HIR Lowering
The AST is lowered to HIR (High-level Intermediate Representation) per-file. This phase handles weave folding — converting the flat sequence of choices and gathers in ink source into a container tree. Implicit structure like root containers and auto-entering the first stitch is materialized here.
Phase 3: Analysis
Cross-file semantic analysis merges per-file symbol manifests into a unified symbol index, resolves names, and performs type checking. The project database (brink-db) supports incremental updates for the LSP.
Phase 4: LIR Lowering
Per-file HIR plus analysis resolutions are lowered into a unified LIR (Low-level IR) program. The LIR is a flat, container-oriented representation ready for bytecode emission. Container planning, label allocation, and instruction selection happen here.
Phase 5: Bytecode Codegen
brink-codegen-inkb walks the LIR and emits bytecode per container, producing the StoryData structure: containers with bytecode, line tables, variable definitions, list definitions, address labels, and external function declarations. All cross-definition references use DefinitionId, resolved at link time by the runtime.
Phase 6: Output
StoryData can be serialized to .inkb (binary format for production) or .inkt (human-readable text dump for debugging).
Entry points
| Function | Description |
|---|---|
compile_path(path) | Full pipeline from a file path |
compile(entry, read_file) | Full pipeline with a custom file reader (for WASM, tests) |
compile_to_json(entry, read_file) | Stop at LIR, emit .ink.json format (for diffing against inklecate) |
compile_string_to_json(source) | Quick JSON emit from a source string |
Converter pipeline
A separate pipeline exists for processing inklecate’s output: .ink.json -> parse (brink-json) -> convert (brink-converter) -> StoryData. This is the known-good reference used for validating the native compiler’s output.
Reference
Look-it-up material for the toolchain. You don’t read these front to back — you jump in when you need the exact opcode, byte layout, or error variant.
- Runtime API — the
brink-runtimepublic surface:link,Program,Story, statistics, RNG. - Bytecode & Opcodes — the full opcode set executed by the VM.
- Binary Format — the
.inkb/.inkt/.inklfile layouts. - Containers & DefinitionId — the identity scheme and the container/address model.
- Line Templates — the localizable line content types (slots, selects, plural keys).
- Errors — every
RuntimeErrorvariant and what causes it.
For the concepts behind these — how the VM steps, why the format is split — see Concepts.
Runtime API
The public surface of brink-runtime — the crate that executes compiled
stories. For how these pieces fit together conceptually, see
The Execution Model; for a worked walkthrough,
see Embedding the Runtime.
Core API
| Item | Kind | Description |
|---|---|---|
link() | Function | Link StoryData into (Program, line_tables) |
Program | Struct | Immutable, shareable compiled story |
Story | Struct | Per-instance mutable execution state |
Line | Enum | Yielded by continue_single() / continue_maximally(): Text, Done, Choices, End |
Choice | Struct | A single choice — text, index, tags |
StoryStatus | Enum | Active, WaitingForChoice, Done, Ended |
RuntimeError | Enum | All runtime errors (see Errors) |
link() returns the immutable Program and the story’s line tables
(Vec<Vec<LineEntry>>) — the swappable rendering data. Hand both to
Story::new(&program, line_tables).
let (program, line_tables) = brink_runtime::link(&story_data)?;
let mut story = Story::new(&program, line_tables);
Stepping
| Method | Returns | Use |
|---|---|---|
continue_single() | Line | one line at a time (typewriter UIs) |
continue_maximally() | Vec<Line> | a whole passage, last element terminal |
continue_single_with(&h) / continue_maximally_with(&h) | as above | same, with an ExternalFnHandler |
choose(index) | () | select a choice when WaitingForChoice |
status() | StoryStatus | query state at any time |
See The Execution Model for the step loop, the
Line variants, and StoryStatus, and
External Functions for the _with forms.
Named flows
spawn_flow, continue_flow_maximally, choose_flow, destroy_flow,
flow_names — parallel execution contexts within one story. See
Named Flows.
Statistics
story.stats() returns execution counters: opcodes executed, steps, threads
created/completed, frames pushed/popped, choices presented/selected, snapshot
cache hits/misses, and materializations. Useful for profiling and for asserting
on VM behavior in tests.
RNG
The VM uses FastRng (a simple LCG) by default. DotNetRng reproduces the C#
reference implementation’s random behavior — use it when you need bit-for-bit
parity with inklecate. Implement the StoryRng trait for a custom source.
Bytecode VM
The runtime is a stack-based bytecode VM.
Design properties
- Stack-based: operands pushed/popped from a value stack
- Jump offsets are container-relative
- Cross-definition references use
DefinitionId, resolved to compact indices at link time - Short-circuit
and/orcompiled to conditional jumps, not handled by the VM
Value types
enum Value {
Int(i32),
Float(f32),
Bool(bool),
String(Arc<str>), // Refcounted for cheap cloning
List(Arc<ListValue>), // Refcounted
DivertTarget(DefinitionId), // Target address for diverts
VariablePointer(DefinitionId), // Reference to a global variable
TempPointer { slot, frame_depth }, // Reference to a local variable
Null,
FragmentRef(u32), // Index into the output fragment store
}
String and List are Arc-wrapped so cloning is O(1), matching C# reference semantics and making call-frame forking cheap. (Atomic refcounts, so a Value can flow through Bevy’s parallel scheduler.) FragmentRef points at a captured run of structural output parts, kept intact so it can be re-rendered in another locale.
Opcode reference
The VM’s full instruction set is listed below — around 70 opcodes. Each is encoded as a single discriminant byte followed by zero or more operand bytes.
Stack and literals
| Opcode | Operands | Description |
|---|---|---|
PushInt | i32 | Push an integer constant |
PushFloat | f32 | Push a float constant |
PushBool | u8 | Push a boolean (0 = false, 1 = true) |
PushString | u16 | Push a string by line table index |
PushList | u16 | Push a list literal by index |
PushDivertTarget | DefinitionId | Push a divert target address |
PushNull | — | Push null |
Pop | — | Discard the top value |
Duplicate | — | Duplicate the top value |
Arithmetic
| Opcode | Description |
|---|---|
Add | Pop two values, push their sum (also concatenates strings) |
Subtract | Pop two values, push their difference |
Multiply | Pop two values, push their product |
Divide | Pop two values, push their quotient |
Modulo | Pop two values, push the remainder |
Negate | Pop one value, push its negation |
Comparison
| Opcode | Description |
|---|---|
Equal | Pop two values, push whether they are equal |
NotEqual | Pop two values, push whether they differ |
Greater | Pop two values, push whether left > right |
GreaterOrEqual | Pop two values, push whether left >= right |
Less | Pop two values, push whether left < right |
LessOrEqual | Pop two values, push whether left <= right |
Logic
| Opcode | Description |
|---|---|
Not | Pop one value, push its logical negation |
And | Pop two values, push logical AND |
Or | Pop two values, push logical OR |
Variables
| Opcode | Operands | Description |
|---|---|---|
GetGlobal | DefinitionId | Push the value of a global variable |
SetGlobal | DefinitionId | Pop a value and assign it to a global variable |
DeclareTemp | u16 (slot) | Declare a temp variable in the current frame |
GetTemp | u16 (slot) | Push the value of a temp (auto-dereferences pointers) |
SetTemp | u16 (slot) | Pop a value and assign it to a temp slot |
GetTempRaw | u16 (slot) | Push a temp’s raw value without auto-dereference |
PushVarPointer | DefinitionId | Push a pointer to a global variable |
PushTempPointer | u16 (slot) | Push a pointer to a temp variable |
Control flow
| Opcode | Operands | Description |
|---|---|---|
Jump | i32 (offset) | Unconditional relative jump within the current container |
JumpIfFalse | i32 (offset) | Pop a value; jump if falsy |
Goto | DefinitionId | Absolute jump to a named address |
GotoIf | DefinitionId | Pop a value; goto the address if truthy |
GotoVariable | — | Pop a DivertTarget from the stack and goto it |
Container flow
| Opcode | Operands | Description |
|---|---|---|
EnterContainer | DefinitionId | Push a container onto the container stack (updates visit counts) |
ExitContainer | — | Pop the current container from the container stack |
Functions and tunnels
| Opcode | Operands | Description |
|---|---|---|
Call | DefinitionId | Call a function — pushes a new call frame with fresh temp storage |
Return | — | Return from a function call |
TunnelCall | DefinitionId | Tunnel into a knot — pushes a return address, shares the output stream |
TunnelReturn | — | Return from a tunnel |
TunnelCallVariable | — | Pop a DivertTarget and tunnel to it |
CallVariable | — | Pop a DivertTarget and call it as a function |
Threads
| Opcode | Operands | Description |
|---|---|---|
ThreadCall | DefinitionId | Fork execution to explore a choice branch |
ThreadStart | — | Mark the beginning of a forked thread’s code |
ThreadDone | — | Mark the end of a forked thread |
Thread forking clones the current VM state (call stack, variable state) to explore choice branches in isolation. Each choice’s thread is evaluated independently to determine its display text and conditions.
Output
| Opcode | Operands | Description |
|---|---|---|
EmitLine | u16 (index), u8 (slot count) | Emit a line from the scope’s line table; slot count interpolation slots are popped from the stack |
EmitValue | — | Pop a value and emit its string representation |
EmitNewline | — | Emit a newline character |
Spring | — | Word break — renders as a single space between content parts |
Glue | — | Suppress the previous newline (joins lines) |
BeginTag | — | Begin capturing tag content |
EndTag | — | End tag capture and attach to current output |
EvalLine | u16 (index), u8 (slot count) | Evaluate an interpolated line template with slot count popped slots |
BeginFragment | — | Begin capturing output into a fragment |
EndFragment | — | End fragment capture; store the parts and push a FragmentRef |
Choices
| Opcode | Operands | Description |
|---|---|---|
BeginChoice | flags: u8, DefinitionId | Begin a choice with flags and a target address |
EndChoice | — | Finalize the current choice |
BeginChoice flags (packed into a single byte):
- Bit 0:
has_condition— choice has a conditional guard - Bit 1:
has_start_content— choice has text before[ - Bit 2:
has_choice_only_content— choice has text inside[] - Bit 3:
once_only— choice can only be selected once - Bit 4:
is_invisible_default— fallback choice when no others are available
Sequences
| Opcode | Operands | Description |
|---|---|---|
Sequence | kind: u8, count: u8 | Begin a sequence (kind: 0=cycle, 1=stopping, 2=once-only, 3=shuffle) |
SequenceBranch | i32 (offset) | Jump offset for a sequence branch |
Intrinsics
| Opcode | Description |
|---|---|
VisitCount | Pop a DivertTarget, push its visit count |
CurrentVisitCount | Push the visit count of the current container |
TurnsSince | Pop a DivertTarget, push turns since last visit (-1 if never) |
TurnIndex | Push the current turn index |
ChoiceCount | Push the number of currently available choices |
Random | Pop max and min, push a random integer in [min, max] |
SeedRandom | Pop a seed value and set the RNG seed |
Casts and math
| Opcode | Description |
|---|---|
CastToInt | Pop a value, push it as an integer |
CastToFloat | Pop a value, push it as a float |
Floor | Pop a float, push its floor as an integer |
Ceiling | Pop a float, push its ceiling as an integer |
Pow | Pop exponent and base, push base^exponent |
Min | Pop two values, push the smaller |
Max | Pop two values, push the larger |
External functions
| Opcode | Operands | Description |
|---|---|---|
CallExternal | DefinitionId, u8 (arg count) | Call an externally-bound function |
List operations
| Opcode | Description |
|---|---|
ListContains | Pop item and list, push whether the list contains the item |
ListNotContains | Pop item and list, push whether the list does not contain the item |
ListIntersect | Pop two lists, push their intersection |
ListAll | Pop a list, push all possible items from its origin lists |
ListInvert | Pop a list, push the complement (all origin items not in the list) |
ListCount | Pop a list, push its item count |
ListMin | Pop a list, push its minimum item |
ListMax | Pop a list, push its maximum item |
ListValue | Pop a list, push its integer value (ordinal of single item) |
ListRange | Pop max, min, and list; push items within the ordinal range |
ListFromInt | Pop an integer and list origin, push the item with that ordinal |
ListRandom | Pop a list, push a random item from it |
String evaluation
| Opcode | Description |
|---|---|
BeginStringEval | Begin capturing output as a string value (for string interpolation) |
EndStringEval | End string capture and push the result onto the stack |
Lifecycle
| Opcode | Description |
|---|---|
Done | Yield — the story pauses and can be resumed (marks a safe exit) |
Yield | Pause for choice presentation — like Done but does not mark a safe exit |
End | Permanent end — the story is finished |
Nop | No operation |
Debug
| Opcode | Operands | Description |
|---|---|---|
SourceLocation | u32 (line), u32 (col) | Record source location for debugging |
Execution model
The step function executes opcodes in a loop until reaching a yield point: Done, End, or choice presentation. Each yield produces a Line (Text/Done/Choices/End) carrying the output text accumulated since the last yield — continue_single returns one, continue_maximally returns a Vec<Line> ending in a terminal variant.
Call stack: Function and tunnel calls push frames onto the call stack. Each frame has its own local variable storage (temp slots). Return and TunnelReturn pop frames.
Container stack: Each call frame tracks which containers are currently active. EnterContainer pushes, ExitContainer pops. This drives visit counting and turn tracking.
Thread forking: ThreadCall forks the current execution state (stacks, globals, output) to explore a choice branch. All threads run within the same step. At yield, threads are merged: each live thread contributes its choices to the final Line::Choices.
Binary Format
brink-format defines the binary interface between compiler and runtime. It is the ONLY dependency of brink-runtime.
File formats
| Extension | Format | Description |
|---|---|---|
.inkb | Binary | Compiled bytecode with definition tables, line tables, and metadata |
.inkt | Textual | Human-readable disassembly (like WAT for WASM) |
.inkl | Locale overlay | Per-scope replacement line tables for a specific locale |
.inkb format
Header (16 bytes)
| Offset | Size | Field |
|---|---|---|
| 0 | 4 | Magic: INKB |
| 4 | 2 | Version: u16 LE (currently 2) |
| 6 | 1 | Section count: u8 (10) |
| 7 | 1 | Reserved: 0x00 |
| 8 | 4 | File size: u32 LE |
| 12 | 4 | Content checksum: u32 LE (CRC-32) |
Offset table
Immediately after the 16-byte preamble. Each entry is 8 bytes:
Offset Size Field
------ ----- ------
0 1 SectionKind: u8 tag
1 3 Reserved: 0x00 0x00 0x00
4 4 Offset: u32 LE (byte offset from start of file to section data)
With 10 sections, the offset table occupies 80 bytes (10 x 8). The total header size is 96 bytes (16 + 80). Each section’s size is computed from the difference between its offset and the next section’s offset (or the file size for the last section).
Sections
| Tag | SectionKind | Contents |
|---|---|---|
0x01 | NameTable | Interned name strings. Each entry is a length-prefixed UTF-8 string (u16 LE byte count + bytes). Referenced by NameId(u16) indices throughout other sections. |
0x02 | Variables | Global variable definitions. Each entry: DefinitionId + NameId + ValueType tag + encoded default value + mutability flag. |
0x03 | ListDefs | List (enum) type definitions. Each entry: DefinitionId + NameId + item count + (NameId, i32 ordinal) pairs. |
0x04 | ListItems | Individual list item definitions. Each entry: DefinitionId + origin DefinitionId + i32 ordinal + NameId. |
0x05 | Externals | External function declarations. Each entry: DefinitionId + NameId + u8 arg count + optional fallback DefinitionId. |
0x06 | Containers | Bytecode containers. Each entry: DefinitionId + scope DefinitionId + optional NameId + CountingFlags byte + i32 path hash + u8 declared-parameter count + u32 bytecode length + raw bytecode bytes. |
0x07 | LineTables | Per-scope line tables for output text (one per knot/stitch/root). Each scope’s table: DefinitionId (scope) + line count + encoded line entries (plain strings or interpolation templates). |
0x08 | Labels | Address definitions (divert targets). Each entry: DefinitionId (address) + DefinitionId (container) + u32 byte offset. |
0x09 | ListLiterals | Pre-computed list literal values used by PushList instructions. Each entry: item count + DefinitionId items + origin count + DefinitionId origins. |
0x0A | AddressPaths | Maps qualified author paths (knot, knot.stitch, knot.stitch.label) to DefinitionIds, so Program::find_address can resolve a name to a starting position. |
Encoding conventions
- All multi-byte integers are little-endian.
DefinitionIdvalues are encoded as rawu64LE (8 bytes).- Strings in the name table are length-prefixed:
u16LE byte count followed by UTF-8 bytes. - Sections are self-contained — the runtime can deserialize them independently. The
read_inkbfunction parses all sections into a completeStoryDatafor linking.
Versioning & compatibility
The header carries a u16 version, and the reader rejects any version it doesn’t recognize rather than guessing — every change to the byte layout bumps it.
.inkb and .inkl are build artifacts: regenerated from .ink on every compile, not meant to be hand-edited or shipped independently of the compiler that produced them. So the toolchain keeps a single current version and recompiles on mismatch — there are no multi-version readers. If you bundle compiled bytes with a game, treat them as version-locked to the brink release you built with, and recompile when you upgrade.
This is separate from save files, which are designed to survive toolchain upgrades: loading a save reports what it couldn’t apply (e.g. a variable a newer story removed) rather than failing outright — see the Runtime API. Program metadata like container layout never affects save compatibility, since saves reference variables and visit counts by definition id, not by byte offset.
.inkt format
The textual format is a human-readable disassembly of .inkb. Container paths appear as labels, opcodes as mnemonics with operands. Useful for debugging compiler output and diffing two compilations side-by-side.
=== container $01_abcdef1234567 (my_knot) ===
0000: PushInt 42
0004: SetGlobal $02_1234567abcdef
000c: EmitLine 0
000e: Done
.inkl format
Locale overlays replace per-scope line tables without touching bytecode. A
decoded .inkl is a LocaleData:
- BCP 47
locale_tagand the base.inkbchecksum (base_checksum), so a mismatched overlay is rejected before it can render garbage. line_tables: per-scope replacement tables (LocaleScopeTable) keyed by scopeDefinitionId.- Only scopes present in the
.inklare replaced; the rest fall back to base text underLocaleMode::Overlay(or error underLocaleMode::Strict).
The runtime applies an overlay with brink_runtime::apply_locale. Build .inkl
files with brink compile-locale (see the Localization section).
Containers & DefinitionId
DefinitionId
All named things in brink use a single DefinitionId(u64) type. The high 8 bits are a type tag; the low 56 bits are a hash of the fully qualified ink path.
DefinitionId (u64):
+-----------+------------------------------------------------------+
| tag (8) | hash (56) |
+-----------+------------------------------------------------------+
Serialized as $tt_hhhhhhhhhhhhhh (tag hex + underscore + 56-bit hash hex).
Definition tags
| Tag | Kind | Description |
|---|---|---|
0x01 | Address | Knot, stitch, gather, or intra-container label |
0x02 | Global variable | Name, type, default value |
0x03 | List definition | Enum-like type with named items |
0x04 | List item | Individual member of a list definition |
0x05 | External function | Host-provided function binding |
0x07 | Local variable | Temp/param (not serialized, compile-time only) |
The uniform ID scheme provides stability across recompilation (same ink path always produces the same ID), a simple linker (all references are ID lookups), and save file portability (IDs don’t depend on compilation order).
Containers
Containers are the fundamental unit of bytecode execution. At the source level, ink has knots, stitches, gathers, and labeled choice targets. At the bytecode level, these are all containers: a DefinitionId, a block of bytecode, and metadata.
struct ContainerDef {
id: DefinitionId,
bytecode: Vec<u8>,
content_hash: u64,
counting_flags: CountingFlags,
path_hash: i32, // Seed for shuffle RNG
}
Container hierarchy
Containers form a logical hierarchy that mirrors the ink source structure:
- The root container holds the top-level flow (content before the first knot).
- Knots are top-level containers.
- Stitches may be sub-containers within a knot, or addresses within the knot’s bytecode.
- Gathers and labeled choice targets may become addresses within their parent container.
The compiler decides which source constructs become their own container vs. being inlined as addresses within a parent container. This is determined during the LIR planning phase.
Addresses
An AddressDef names a location within a container:
struct AddressDef {
id: DefinitionId, // The address's own ID
container_id: DefinitionId, // Which container it lives in
byte_offset: u32, // Position within the container's bytecode
}
The primary address of a container has byte_offset == 0 and id == container_id — it is the container’s entry point. Non-primary addresses (stitches within a knot, gathers, labels) have distinct IDs and non-zero offsets.
Counting flags
CountingFlags is a bitfield (u8) that controls visit and turn tracking for a container:
bitflags! {
pub struct CountingFlags: u8 {
const VISITS = 0x01;
const TURNS = 0x02;
const COUNT_START_ONLY = 0x04;
}
}
- VISITS (
0x01) — the VM increments a counter each time the container is entered. Used byVISITS()and conditional logic that depends on how many times content has been seen. - TURNS (
0x02) — the VM records the turn number when the container is entered. Used byTURNS_SINCE(). - COUNT_START_ONLY (
0x04) — only count the visit/turn when the container is entered at its first instruction (byte offset 0), not when re-entered mid-way via a divert.
These flags are set by the compiler based on whether the ink source uses VISITS(), TURNS_SINCE(), or similar intrinsics that reference the container.
What is NOT a definition
Several important types are scoped more narrowly and do not get DefinitionIds in the binary format:
- Temp variables — identified by slot index (
u16) within a call frame. TheLocalVartag (0x07) exists for compiler-internal use but temps are not serialized as definitions in the bytecode. NameId— au16index into the story’s name table. Stores human-readable names for variables, list items, and externals. Names are for display and host binding only; the runtime identifies definitions byDefinitionId, not by name.LineId— a(container: DefinitionId, index: u16)pair that references a specific line entry within a container’s line table. Lines hold output text and are emitted byEmitLine(index), not addressed as definitions.
Line Templates
Lines in brink can be plain strings or templates with interpolation slots and plural/gender selects.
LineContent
enum LineContent {
Plain(String),
Template(LineTemplate),
}
struct LineTemplate {
parts: Vec<LinePart>,
}
Template parts
enum LinePart {
Literal(String),
Slot(u8),
Select {
slot: u8,
variants: Vec<(SelectKey, String)>,
default: String,
},
}
- Literal — static text fragments between dynamic parts.
- Slot — runtime value interpolation. The
u8is an index into the evaluation stack snapshot captured when the line is emitted. For example,"You have {0} gold"becomes[Literal("You have "), Slot(0), Literal(" gold")]. - Select — plural/keyword branching. Selects a variant string based on the runtime value at the given slot, using a
SelectKeyto match. Falls back todefaultif no variant matches.
Select keys
enum SelectKey {
Cardinal(PluralCategory),
Ordinal(PluralCategory),
Exact(i32),
Keyword(String),
}
- Cardinal — CLDR cardinal plural categories (zero, one, two, few, many, other). Used for “1 apple” vs “2 apples”.
- Ordinal — CLDR ordinal categories. Used for “1st”, “2nd”, “3rd”.
- Exact — matches a specific integer value. Useful for special-casing “0 items” or “exactly 1”.
- Keyword — matches a named string key. Used for gender or custom grammatical categories.
Line tables
Line tables are stored per-scope (one per knot/stitch/root) in the .inkb format. Each scope has a sequence of LineEntry values referenced by index from EmitLine opcodes. The EvalLine opcode handles templates with interpolation, evaluating slots from the current stack state.
Choice text decomposition
Ink choices have up to three text parts: start content (before [), choice-only content (inside []), and output-only content (after ]). The compiler decomposes each choice into two independent lines:
- Display line = start + choice-only (what the player sees in the choice list)
- Output line = start + output-only (what appears in the narrative after selection)
This decomposition allows translators to localize each line independently — the target language can use completely different grammatical constructions for the prompt and the narrative output.
Error Handling
All runtime operations that can fail return Result<T, RuntimeError>.
RuntimeError variants
Host errors
These indicate a bug in your code — the host called the API incorrectly.
| Variant | When |
|---|---|
InvalidChoiceIndex | choose() called with an index outside the valid range |
NotWaitingForChoice | choose() called when story isn’t in WaitingForChoice status |
StoryEnded | Tried to continue a story that has permanently ended |
UnknownFlow | Referenced a named flow that doesn’t exist |
FlowAlreadyExists | Tried to spawn a flow with a name that’s already active |
StepLimitExceeded | Safety limit hit — possible infinite loop in the story |
Story errors
These indicate a problem in the ink source or an unsupported feature.
| Variant | When |
|---|---|
TypeError | Type mismatch in an ink expression (e.g., adding a string to a list) |
DivisionByZero | Division or modulo by zero in an ink expression |
UnresolvedExternalCall | Story calls an external function with no handler provided |
Unimplemented | The story uses an opcode not yet supported by the VM |
Internal errors
These typically indicate a compiler bug — the bytecode is malformed.
| Variant | When |
|---|---|
Decode | Corrupt or incompatible .inkb file |
UnresolvedDefinition | Linker can’t find a referenced definition |
NoRootContainer | Story has no entry point |
StackUnderflow | Value stack empty when an operand was expected |
CallStackUnderflow | No call frame to return to |
ContainerStackUnderflow | No container to pop from the container stack |
UnresolvedGlobal | Global variable lookup failed |
CaptureUnderflow | Output capture stack mismatch |
Recovery
Host errors are recoverable — fix the calling code and retry. Story errors may be recoverable depending on context. Internal errors generally indicate broken bytecode and are not recoverable.
Bevy Integration
bevy-brink exposes the brink runtime as a Bevy plugin: compiled stories load
as Assets, each live conversation is an entity with flow Components, and
story-wide save state lives in a Resource. Output is delivered through
observer events, and ink EXTERNAL functions bind to engine systems.
cargo add bevy-brink
use bevy::prelude::*;
use bevy_brink::{BrinkPlugin, BrinkFlowRequest};
fn main() {
App::new()
.add_plugins((DefaultPlugins, BrinkPlugin::<()>::default()))
.add_systems(Startup, start_story)
.run();
}
fn start_story(mut commands: Commands, assets: Res<AssetServer>) {
commands.spawn(
BrinkFlowRequest::<()>::builder()
.story(assets.load("dialogue.inkb"))
.build(),
);
}
The plugin
BrinkPlugin<M> registers everything for one story instance: the fulfillment
system that turns requests into live flows, the transcript refresher, the
external-binding resolvers, and the locale machinery. It does not add an
auto-advance system — most games drive advancement from input or game state,
not every tick, so you write that step loop yourself (see
Spawning & Driving Flows).
Adding BrinkPlugin<M> also pulls in BrinkAssetsPlugin (once) for the
marker-free asset types and loaders. You can add BrinkAssetsPlugin on its own
if you want the asset machinery without any marker plumbing (e.g. a headless
asset-processing binary).
Markers: multiple concurrent stories
Every type is generic over a Send + Sync + 'static marker M (default
()). The marker monomorphizes the resources and components to distinct Bevy
types with no runtime cost, so independent stories coexist in one app:
struct MainStory;
struct DreamSequence;
app.add_plugins((
BrinkPlugin::<MainStory>::default(),
BrinkPlugin::<DreamSequence>::default(),
));
Each marker gets its own BrinkGlobals<M> resource and
BrinkFlow<M>/BrinkContext<M>/BrinkLocale<M> components. Use () unless
you actually need this.
The story-asset bundle
A loaded story is a thin bundle (BrinkStoryAsset) of two labeled sub-assets:
| Asset | Holds | Notes |
|---|---|---|
BrinkStoryAsset | handles to the two below | what you load() and hand to a request |
ProgramAsset | the immutable bytecode Program + initial_context | what the VM executes; initial_context is the fresh “new game” state |
LineTablesAsset | the localizable line tables | the swappable rendering data (base language, or a locale overlay) |
The split exists so line tables can be swapped (locale changes, hot-reload)
without touching the immutable program. You rarely touch the sub-assets
directly — spawn a request with a Handle<BrinkStoryAsset> and let the
fulfillment system wire everything up.
Loaders and file types
| Extension | Loader | Produces | Feature |
|---|---|---|---|
.inkb | InkbLoader | BrinkStoryAsset (+ labeled #program, #line_tables) | always |
.ink | InkLoader | BrinkStoryAsset, compiled at load time, hot-reloads on source change | dev |
.inkl | InklLoader | LocaleAsset (a locale overlay) | always |
.brkt | BrktLoader | TranscriptAsset (a saved playthrough) | always |
The dev cargo feature (on by default) adds the .ink source loader, which
compiles ink at load time and hot-reloads the program when any file in the
INCLUDE graph changes. Ship a release build without it to drop the compiler
and load only pre-compiled .inkb/.inkl assets:
bevy-brink = { version = "*", default-features = false }
Where to go next
- Spawning & Driving Flows — the request-component pattern, the flow components/resources, the step loop, observer events, transcripts.
- External Functions — bind ink
EXTERNALs to engine code (pure / command / world-query / async / task) and call ink from the engine. - Localization & Saves — runtime locale switching and
.brkttranscript persistence.
Spawning & Driving Flows
A flow is one live conversation — a FlowInstance and its Context, attached
to an entity. You spawn flows with a request component and advance them from
your own systems.
The request-component pattern
You don’t construct a flow directly. You spawn an entity carrying a
BrinkFlowRequest<M> (a bon-built builder) pointing at a story handle, and
the plugin’s fulfill_flow_requests system materializes the flow once the
assets finish loading — no polling, no readiness latch:
commands.spawn(
BrinkFlowRequest::<()>::builder()
.story(assets.load("dialogue.inkb"))
.start(FlowStart::Address("intro_scene".into())) // optional
.seed(ContextSeed::FromGlobals) // optional
.build(),
);
On fulfillment the request component is removed and replaced with the live flow components (below). Re-inserting the request afterward is a no-op (a debug build warns); to restart, despawn the entity and spawn a fresh request.
FlowStart — where execution begins
| Variant | Meaning |
|---|---|
Root (default) | The file’s root container. Fine for demos/tests; does not auto-enter a named knot. |
Address(String) | Start at a knot/stitch by name. If the name is unknown, the request is dropped at fulfillment. |
ContextSeed — how the flow’s state is seeded
| Variant | Meaning |
|---|---|
FromGlobals (default) | Clone the shared BrinkGlobals<M> “save data”. |
FromInitial | Use the program’s fresh starting state — an independent flow that ignores the shared save. |
Custom(Context) | A caller-supplied Context (e.g. a mid-game branch from a snapshot). |
Flow components & resources
After fulfillment the entity carries:
| Type | Kind | Holds |
|---|---|---|
BrinkFlow<M> | Component | the FlowInstance (.inner) — call stacks, output buffer, pending choices, transcript |
BrinkContext<M> | Component | this flow’s in-flight Context (.inner) — globals, visit/turn counts, RNG |
BrinkProgram<M> | Component | Handle<ProgramAsset> the flow runs against |
BrinkLocale<M> | Component | Handle<LineTablesAsset> the flow renders with |
BrinkGlobals<M> | Resource | the shared “save data” Context; auto-inserted on first fulfillment |
Each flow has its own Context — globals are not auto-shared between
concurrent flows. When a side conversation should contribute its changes back
to the shared save, commit explicitly:
globals.commit_from(&flow_ctx); // wholesale "save everything"
globals.commit_progress(&flow_ctx); // globals replace; visit/turn counts take the max
globals.commit_globals_only(&flow_ctx); // just variables; leave counts/RNG alone
// "new game" reset:
globals.commit_from(&program.initial_context);
Driving a flow
Two ways to advance, depending on whether you have &mut World.
From a normal system — step_one / advance_until_terminal
These take the program + line tables (looked up from the assets via the
entity’s handles), the flow’s &mut Context, an ExternalFnHandler, the
entity, and Commands. They return Advance:
Advance | Meaning |
|---|---|
Line(Line) | a line was produced and its observer event fired |
AwaitingQuery | the flow paused on a world-access binding; the plugin resolver handles it — skip this flow and resume next frame |
fn drive(
mut flows: Query<(Entity, &mut BrinkFlow<()>, &mut BrinkContext<()>,
&BrinkProgram<()>, &BrinkLocale<()>)>,
programs: Res<Assets<ProgramAsset>>,
tables: Res<Assets<LineTablesAsset>>,
bindings: Res<BrinkBindings<()>>,
mut commands: Commands,
) {
for (entity, mut flow, mut ctx, prog, loc) in &mut flows {
if flow.inner.has_pending_external() { continue; } // paused; resolver will resume it
let (Some(p), Some(t)) = (programs.get(&prog.handle), tables.get(&loc.handle))
else { continue; };
let handler = bindings.handler();
let _ = flow.advance_until_terminal(
&p.program, &t.tables, &mut ctx.inner, &handler, entity, &mut commands,
);
handler.flush(&mut commands); // emit any buffered command events
}
}
step_oneproduces one line — for typewriter UIs that animate fragments.advance_until_terminalruns until a terminal line (Done/Choices/End), firing events for every line along the way — for click-to-continue dialogue. It’s bounded by a 10,000-line safety cap.
If you have no bindings, pass &brink_runtime::FallbackHandler instead of
building one from BrinkBindings.
From an exclusive system — advance_flow
advance_flow::<M>(&mut World, entity) -> Result<Line, BrinkCallError> is the
counterpart for &mut World contexts. It resolves world-access query bindings
inline (so a line like Enemies near: {enemy_count()}. works in one frame)
and never yields AwaitingQuery. See External Functions.
Choices
A Line::Choices (or a BrinkChoicesPresented event) means the flow is waiting
for a pick. Select with choose:
flow.choose(&mut ctx.inner, index)?;
For keyboard UIs, digit_key_to_choice_index(&keys, choices.len()) maps
Digit1..=Digit9 to a 0-based choice index:
if let Some(idx) = digit_key_to_choice_index(&keys, choices.len()) {
flow.choose(&mut ctx.inner, idx)?;
}
Observer events
step_one/advance_until_terminal fire one EntityEvent per produced line,
targeted at the flow entity, so observers react to exactly the situation they
care about (no match on a Line):
| Event | Fires for | Carries |
|---|---|---|
BrinkLineDelivered<M> | Line::Text (mid-stream) | text, tags |
BrinkChoicesPresented<M> | Line::Choices | text, tags, choices: Vec<Choice> |
BrinkTurnDone<M> | Line::Done (turn complete, -> DONE) | text, tags |
BrinkStoryEnded<M> | Line::End (-> END) | text, tags |
BrinkFlowReset<M> (dev) | a hot-reload is about to rebuild the flow | entity |
app.add_observer(|on: On<BrinkChoicesPresented<()>>| {
for (i, choice) in on.event().choices.iter().enumerate() {
println!(" [{}] {}", i + 1, choice.text);
}
});
Terminal lines bundle their accumulated text in their own text field — a
Choices/Done/End event already contains the passage text leading up to
it, so a click-to-continue UI can render from terminal events alone.
Transcripts
For a “show the whole conversation so far” view rather than per-event reaction,
add a BrinkTranscript<M> component (opt-in) to a flow entity. The plugin
re-renders it whenever the flow grows, the locale changes, or line tables
hot-reload:
commands.entity(flow).insert(BrinkTranscript::<()>::default());
// later:
let text = transcript.text(); // all lines joined with '\n'
let lines = &transcript.lines; // Vec<(String, Vec<String>)> — (text, tags)
Hot-reload (dev)
With the dev feature, flows fulfilled from a .ink source carry a
BrinkReplayLog<M>. When the source changes, the plugin rebuilds the flow
against the new program, fires BrinkFlowReset<M> (clear your UI), and replays
recorded choices to restore position. Record choices with choose_recording
instead of choose to feed that log.
External Functions (ink ↔ engine)
The binding facility connects ink EXTERNAL functions to engine code, and lets
engine code call ink functions. The boundary is split by World access, not
by sync/async return shape: bindings that need no World resolve inline; those
that do (or that take time) pause the flow and resume out-of-band.
Register bindings at app-build time via BrinkBindingsAppExt (they live in a
BrinkBindings<M> resource). Each verb takes the marker M as its first
explicit type parameter — use () for the default single-story case.
ink → engine: the five kinds
| Verb | For | Resolution |
|---|---|---|
bind_brink_fn | pure compute, no World | inline while the VM steps |
bind_brink_command | fire-and-forget event (with optional return) | buffered during the step, flushed after |
bind_brink_query | read live World state | flow pauses; a resolver runs the binding system, then resumes |
bind_brink_async | multi-frame World interaction (UI, input) | flow parks; BrinkExternalAwaited fires; you resolve when ready |
bind_brink_task | off-thread compute / IO | flow parks; the future runs on the task pool; resolved on completion |
bind_brink_fn — pure functions
A side-effect-free function of the ink args, resolved inline (no World, no
latency). The return type is anything Into<Value>:
app.bind_brink_fn::<(), _, _>("clamp01", |args| {
args.first().and_then(Value::as_float).unwrap_or(0.0).clamp(0.0, 1.0)
});
bind_brink_command — fire-and-forget events
Parse the ink args into a Bevy Event and trigger it. Derive BrinkCommand
for structs whose fields are i32/f32/bool/String; react with a normal
observer:
#[derive(Event, BrinkCommand)]
struct PlaySound { name: String }
app.bind_brink_command::<(), PlaySound>("play_sound")
.add_observer(|on: On<PlaySound>| { /* play on.event().name */ });
The event is buffered while the VM steps (the handler can’t touch the World
mid-step) and emitted when the flow’s handler is flushed. To return a value to
ink, hand-implement BrinkCommand and override reply().
bind_brink_query — read the World
A Bevy system with arbitrary SystemParams that reads the World and returns a
Value. It takes In<BrinkQueryInput> — (Entity, Vec<Value>), the calling
flow plus the ink args — so a binding can query anything, with no upfront
declaration:
fn enemy_count(In((_flow, _args)): In<BrinkQueryInput>, q: Query<&Enemy>) -> Value {
Value::Int(q.iter().count() as i32)
}
app.bind_brink_query::<(), _, _>("enemy_count", enemy_count);
Resolving a query needs World access, so it can’t run inline. The flow pauses
(ExternalResult::Pending); from a normal system it yields Advance::AwaitingQuery
and the plugin’s resolve_pending_externals system (gated on
any_flow_awaiting_external) runs the binding via run_system_with and resumes
it. From an exclusive &mut World context, advance_flow resolves it inline in
one frame.
ink → engine: async (defer-across-frames) bindings
Some externals can’t resolve in one pass — a targeting UI that waits for a click, or a network round-trip. The flow parks on the pending external and is frozen until resolved, so the flow entity itself is the correlation key (no per-call id needed).
Async bindings are a step-loop feature only: the one-pass exclusive drivers
(advance_flow, call_ink_function) return
BrinkCallError::AsyncExternalUnsupported on them.
bind_brink_async — the event primitive (World interaction)
When ink calls the external, the flow parks and BrinkExternalAwaited<M> (an
EntityEvent carrying name + args) fires once at the flow entity. Do your
multi-frame work and resolve via resolve_brink_external whenever ready:
app.bind_brink_async::<()>("pick_target");
app.add_observer(|on: On<BrinkExternalAwaited<()>>, mut commands: Commands| {
if on.event().name == "pick_target" {
// … open a targeting UI; many frames later, when the player clicks:
commands.resolve_brink_external::<()>(on.event().entity, Value::Int(7));
}
});
resolve_brink_external is guarded by has_pending_external, so a stale or
double resolve is a safe no-op. Runnable demo:
cargo run --example async_external.
bind_brink_task — off-thread compute
Sugar over AsyncComputeTaskPool. You hand it an async closure; bevy-brink
spawns the future, parks a BrinkPendingTask<M>, and resolves the flow with the
output once it completes (polled each frame by poll_brink_tasks):
app.bind_brink_task::<(), _, _>("expensive_roll", |args: Vec<Value>| async move {
let sides = args.first().and_then(Value::as_int).unwrap_or(6);
Value::Int(compute_roll(sides).await)
});
The future is Send + 'static and runs off the main thread, so it cannot
access the World — it computes from the ink args only. For World-dependent
async, use bind_brink_async. Runnable demo:
cargo run --example async_task.
engine → ink: calling ink functions
Evaluate an ink function from engine code, out-of-band (output isolated, transcript untouched, visit counts not bumped). The function may itself call world-access query bindings — they resolve as part of the call.
From an exclusive system — call_ink_function
let can_advance = call_ink_function::<()>(world, flow_entity, "can_advance", &[])?;
Synchronous; resolves world-access query bindings inline because it holds
&mut World.
From a normal system — commands.brink_call(...).observe(...)
A normal (non-exclusive) system can’t call call_ink_function directly, so it
requests a deferred call. The result is delivered to an observer scoped to a
unique per-call entity — it can never be mis-correlated with another call:
commands
.brink_call::<()>(flow_entity, "can_advance", (in_combat,))
.observe(|on: On<BrinkCallResolved<()>>| {
let result = on.event().value.as_bool();
// …
});
brink_call accepts (), tuples of Into<Value> (up to 4), Vec<Value>, or
&[Value] as args. The plugin’s resolver fires BrinkCallResolved<M> (with
value) or BrinkCallFailed<M> (with an error string) at the call entity, then
despawns it. Runnable demo: cargo run --example engine_bindings.
Wiring summary
app.bind_brink_fn::<(), _, _>("clamp01", clamp01)
.bind_brink_command::<(), PlaySound>("play_sound")
.bind_brink_query::<(), _, _>("enemy_count", enemy_count)
.bind_brink_async::<()>("pick_target")
.bind_brink_task::<(), _, _>("expensive_roll", expensive_roll);
Unknown EXTERNAL names fall through to ExternalResult::Fallback, so the
story’s in-ink fallback body (if any) runs.
Localization & Saves
Two bevy-brink features build on the program/line-tables split: runtime locale
switching (swap the rendering data) and .brkt transcript persistence (save and
re-render the visible history). Both rely on line tables being independent of the
immutable program — see the Overview.
Locale switching
Switching is global and event-driven: one resource is the source of truth, and a single command changes it everywhere.
| Item | Role |
|---|---|
BrinkCurrentLocale<M> | resource holding the active locale (None = base/source language) |
commands.set_brink_locale::<M>(handle) | set the locale and fire BrinkLocaleChanged<M> |
BrinkLocaleChanged<M> | event; an observer reconciles every flow’s BrinkLocale |
BrinkLocaleOverride<M> | marker that opts a flow out of global switching |
A .inkl overlay loads as a LocaleAsset. Switch with the command:
let spanish: Handle<LocaleAsset> = assets.load("dialogue.es.inkl");
commands.set_brink_locale::<()>(Some(spanish)); // switch
commands.set_brink_locale::<()>(None); // revert to base
Every non-override flow’s BrinkLocale is reconciled to point at the localized
line tables (built by applying the overlay to the base tables, cached/shared per
(base, locale) so flows don’t each rebuild it). Any BrinkTranscript<M>
re-renders automatically via the locale change. New flows read the current
locale at spawn; a catch-up reader handles .inkls that finish loading after
a switch — so there’s no per-frame polling.
The plugin retains each flow’s canonical base tables in BrinkBaseLocale<M>, so
overlays always apply to the base (never to an already-localized table) and
reverting restores it exactly.
Per-flow locale (polyglot NPCs)
Add BrinkLocaleOverride<M> to exclude a flow from the global switch, then set
its BrinkLocale manually with the apply_locale_overlay helper:
commands.entity(npc_flow).insert(BrinkLocaleOverride::<()>::default());
let handle = apply_locale_overlay(
program, base_tables, locale_asset, LocaleMode::Overlay, &mut line_tables,
)?;
// point that flow's BrinkLocale at `handle`
LocaleMode::Overlay falls back to base text for untranslated lines;
LocaleMode::Strict requires a full translation.
.brkt transcript persistence
A .brkt is the serialized output history of a playthrough — an append-only log
of structural parts (line refs, values, glue, tags), not resolved strings.
Because it stores structure, a saved transcript re-renders against any matching
program + locale without re-running the story. Uses: a story-log mechanic, QA
capture, and the visible-history half of a save file.
Capturing
let bytes: Vec<u8> = capture_transcript::<()>(flow, program);
// write `bytes` into your save file
The bytes embed the program’s source_checksum, so a later load can detect a
mismatched story version.
Re-rendering
Load saved bytes through the .brkt asset loader (→ TranscriptAsset) or
brink_runtime::transcript::read_transcript, then re-render against a program +
locale — checksum-validated, so a wrong-story render errors instead of producing
garbage:
let lines = render_transcript_asset(
&transcript_asset, program, line_tables, /* plural resolver */ None,
)?;
// each entry is (text, tags) for one resolved line
Pass any locale’s line tables and the saved history localizes too — capture in
English, re-render in Spanish. Runnable demos:
cargo run --example locale_switch and cargo run --example transcript_save.
Web & WASM
brink-web compiles the toolchain — compiler, runtime, and IDE queries — to
WebAssembly and exposes it to JavaScript. It is the foundation every browser
client builds on: the Studio authoring app, the embedded
Playground, and any React/web front-end you write yourself
all sit on this surface.
Building the package
brink-web is built with wasm-pack:
wasm-pack build crates/brink-web --target web --out-dir www/pkg
This emits an npm package at crates/brink-web/www/pkg/ — the glue JS
(brink_web.js), TypeScript types (brink_web.d.ts), and the .wasm binary.
The package must be built before the JS workspace installs, because the
ergonomic wrapper @brink-lang/web depends on it via a file: path.
// raw module
import init, { EditorSession, StoryRunner, compile } from "brink-web";
await init(); // one-time async load of the wasm
// or the ergonomic wrapper (parses the JSON envelopes for you)
import { EditorSessionHandle, StoryRunnerHandle, compile } from "@brink-lang/web";
What it exposes
Three things cross the boundary: a stateless compile function, a runtime runner, and a stateful editor session.
compile(source) → CompileResult
One-shot, single-file compilation. Returns
{ ok, story_bytes?, warnings, error? } — story_bytes is the compiled
.inkb as a byte array, ready to hand to a StoryRunner.
StoryRunner — running a story
| Method | Description |
|---|---|
new StoryRunner(bytes: Uint8Array) | decode + link compiled bytes into a runnable instance |
continue_story() | run to the next choice/end; returns all Lines produced |
continue_single() | produce one Line (typewriter reveal) |
choose(index) | select a choice by 0-based index |
reset() | return to the start without recompiling |
bind_external(name, fn) | bind an ink EXTERNAL to a synchronous JS callback |
unbind_external(name) | remove a previously bound external |
set_lenient_unbound(bool) | unbound externals resolve to null instead of using the ink fallback / erroring |
get_var(name) / set_var(name, value) | read/write a global ink variable by name |
set_seed(n) | set the RNG seed for reproducible RANDOM/shuffle (re-applied across reset) |
save() / save_bytes() | capture durable game state — JSON string (dev) or MessagePack bytes (release) |
load(json) / load_bytes(bytes) | reconcile a save back in; returns a LoadReport of anything dropped |
call_function(name, ...args) | evaluate an ink function from the host (engine→ink); returns its value |
Line mirrors the native runtime: { type: "text"|"choices"|"done"|"end", text, tags, choices? }. This is the same execution model as
the toolchain runtime, surfaced to JS.
External functions
When a story calls EXTERNAL roll(sides), the runner asks any binding
registered under that name to resolve it. Arguments arrive as native JS values
(number / boolean / string / null) and the return is read back the same way —
an integer-valued number becomes an ink int, otherwise a float:
const runner = new StoryRunnerHandle(bytes);
runner.bindExternal("roll", (sides) => 1 + Math.floor(Math.random() * Number(sides)));
runner.bindExternal("play_sound", (id) => { audio.play(String(id)); }); // fire-and-forget
An external with no binding falls through to its ink fallback body (erroring if
none exists), unless setLenientUnbound(true) is set — then it resolves to
null, so content can call host verbs a given build doesn’t know without
dead-ending. A binding that throws resolves to null (the exception is not
propagated into the VM).
Async bindings. A binding may return a Promise — the story suspends
until it resolves (inline timing like ~ camera("bow") ~ wait(2.0) ~ wreck(),
a targeting UI awaiting a click, a fetch). Drive such a story with the async
continue methods, which await and resume transparently:
runner.bindExternal("wait", (secs) => new Promise((r) => setTimeout(r, Number(secs) * 1000)));
const lines = await runner.continueStoryAsync(); // suspends across the wait
A rejected Promise unsticks the flow (resolves null) and rethrows. The
synchronous continueStory/continueSingle error on a suspending binding — use
continueStoryAsync/continueSingleAsync when bindings may be async.
EditorSession — IDE queries
A stateful, multi-file project session that powers an editor: diagnostics, semantic highlighting, navigation, and structural refactors. You feed it source and it answers queries against cached analysis.
| Group | Methods |
|---|---|
| Files | update_file(path, src), set_active_file(path), remove_file, list_files, … |
| Compile & structure | compile_project(entry), project_outline, document_symbols |
| Highlighting | semantic_tokens, token_type_names, token_modifier_names |
| Navigation | goto_definition, find_references, hover, completions, signature_help |
| Refactors | prepare_rename, rename, code_actions, convert_element |
| Editing aids | folding_ranges, inlay_hints, line_contexts, format_document |
| Structural edits | reorder_stitch, move_stitch, promote_stitch, demote_knot, reorder_knot |
View context. set_view_context(start, end) scopes every query to a byte
range — line numbers and offsets become relative to that fragment. This is how a
client edits one knot/stitch in isolation. clear_view_context() returns to
full-file mode.
Conventions
- JSON envelopes. Every query returns a JSON string (
serde_json); the JS side parses it. The@brink-lang/webwrapper does this for you and returns typed objects (@brink/wasm-types). - UTF-8 byte offsets. Positions are UTF-8 byte offsets into the source, not UTF-16 char indices or line/column. When a view context is active they are relative to the view start; the session translates transparently.
- Lifecycle. Construct → feed source / step → dispose. wasm-bindgen objects
free automatically, or call
.free()explicitly via the wrapper handles.
A minimal client
import init, { EditorSession, StoryRunner } from "brink-web";
await init();
const session = new EditorSession();
session.update_file("main.ink", source);
const result = JSON.parse(session.compile_project("main.ink"));
if (result.ok) {
const runner = new StoryRunner(new Uint8Array(result.story_bytes));
let lines = JSON.parse(runner.continue_story());
// render lines; on a choices line, call runner.choose(i) and continue
}
The Studio is the full-featured reference build of exactly this loop — see Studio.
Playground
The full brink Studio, running live in your browser — no install. Pick a demo from the binder, edit the ink on the left, and play it on the right. It’s the real authoring app (binder, screenplay editor, IDE features, live player), built on the Web & WASM bindings and compiled to WebAssembly.
Studio
brink-studio is the reference web authoring app for ink — a browser IDE built
on the Web & WASM bindings. It’s both a usable editor and a
worked example of how to assemble a full client: editor, live preview, project
navigation, and screenplay mode, all driven by @brink-lang/web.
What it does
- Multi-file ink editor (CodeMirror 6) with diagnostics, semantic
highlighting, completions, hover, go-to-definition, find-references, rename,
code actions, folding, signature help, and inlay hints — every
EditorSessionquery wired to the editor. - Live player — step through the compiled story with choice selection;
playback state persists to
localStorageand replays on reload. - Screenplay mode — character lines (
@Name:<>) and parentheticals ((text)<>) render with hidden sigils, name coloring, and depth indicators. - Project navigation — a binder tree of knots and stitches (function knots marked with a distinct icon) with drag-to-reorder and structural edits, plus file tabs (pinned/unpinned) and symbol tabs.
- Activity-bar sidebar — a VS Code-style icon column that swaps the left dock between views; the binder is the first view, the state view the second.
- State view — a read-only runtime debugger that surfaces the running story’s status, current position, call stack, globals, and pending choices, refreshed as the story advances. (Interim raw dump; a structured, name-resolved view is planned — see issue #62.)
- Line-element switching — convert a line between narrative, choice, sticky choice, gather, and divert via keyboard or UI.
Architecture
The studio is a pnpm workspace of focused packages. The app shell is thin; the capability lives in libraries, each independently testable.
| Package | Responsibility |
|---|---|
@brink-lang/studio | app shell + entry point (Vite) |
@brink/studio-ui | React components: layout, activity bar, binder, state view, player, tabs, status bar |
@brink/studio-store | Zustand store — editor / compile / tabs / player / binder / layout slices |
@brink/ink-editor | the CodeMirror 6 editor, state management, IDE extensions, screenplay sigils |
@brink/ink-operations | pure line-editing functions (no CM6, React, or wasm) |
@brink-lang/web | ergonomic wrappers over the brink-web FFI |
@brink/wasm-types | shared TypeScript interfaces (zero runtime) — decouples everything from the FFI |
The dependency flow is one-directional: @brink/wasm-types is depended on by
all; @brink-lang/web wraps the raw brink-web module; @brink/ink-editor consumes
@brink-lang/web + @brink/ink-operations; @brink/studio-store orchestrates
editor, compile, and player state; @brink-lang/studio assembles the lot.
Running it
The WASM package must exist first — @brink-lang/web resolves brink-web through a
file: path to crates/brink-web/www/pkg:
# 1. build the wasm package (see Web & WASM)
wasm-pack build crates/brink-web --target web --out-dir www/pkg
# 2. install + run the studio
pnpm install
pnpm dev # Vite dev server on http://localhost:5180
| Command | Purpose |
|---|---|
pnpm dev | dev server (port 5180) |
pnpm build | production build |
pnpm typecheck | TypeScript, no emit |
pnpm test | Vitest unit tests (jsdom) |
pnpm test:e2e | Playwright end-to-end suite |
Tech stack
React 19 · TypeScript 5.7 · Vite 6 · Zustand 5 · CodeMirror 6 ·
react-resizable-panels for layout · Vitest + Playwright for tests. The editor
talks to the toolchain entirely through @brink-lang/web, so it stays a pure
front-end with the compiler and runtime living in the WASM module.
Crate Layout
brink is organized as a Cargo workspace with strict dependency rules. The central design principle is the firewall: brink-format is the only crate shared between the compiler and runtime.
Published crates
| Crate | Path | Purpose |
|---|---|---|
brink | crates/brink/ | Public API — re-exports from compiler and runtime |
brink-compiler | crates/brink-compiler/ | Pipeline driver: .ink to StoryData |
brink-runtime | crates/brink-runtime/ | Bytecode VM for executing compiled stories |
brink-cli | crates/brink-cli/ | CLI tool: compile, convert, play, export-xliff, compile-locale, regenerate-xliff, fmt, replay |
brink-lsp | crates/brink-lsp/ | Language server for ink files |
brink-web | crates/brink-web/ | WASM bindings for the IDE + runtime; powers the web playground |
bevy-brink | crates/bevy-brink/ | Bevy 0.18 integration: plugin, assets, components, external-function bindings |
Internal crates
| Crate | Path | Purpose |
|---|---|---|
brink-syntax | crates/internal/brink-syntax/ | Lexer, parser, lossless CST, typed AST |
brink-ir | crates/internal/brink-ir/ | HIR + LIR intermediate representations, lowering |
brink-analyzer | crates/internal/brink-analyzer/ | Cross-file semantic analysis, symbol resolution |
brink-driver | crates/internal/brink-driver/ | Pipeline orchestration: file discovery + cross-file analysis |
brink-codegen-inkb | crates/internal/brink-codegen-inkb/ | Bytecode codegen: LIR to StoryData |
brink-codegen-json | crates/internal/brink-codegen-json/ | JSON codegen: LIR to .ink.json (for diffing) |
brink-format | crates/internal/brink-format/ | Binary interface between compiler and runtime |
brink-db | crates/internal/brink-db/ | Incremental project database, file discovery |
brink-json | crates/internal/brink-json/ | Parser for inklecate .ink.json output |
brink-converter | crates/internal/brink-converter/ | Reference pipeline: .ink.json to StoryData |
brink-fmt | crates/internal/brink-fmt/ | .ink source formatter (powers brink fmt) |
brink-intl | crates/internal/brink-intl/ | Internationalization tooling: line export, XLIFF round-trip, .inkl compile, ICU plurals |
xliff2 | crates/internal/xliff2/ | General-purpose XLIFF 2.0 read/write library |
brink-ide | crates/internal/brink-ide/ | Protocol-agnostic IDE query library (shared by the LSP/web) |
bevy-brink-derive | crates/internal/bevy-brink-derive/ | Derive macros for bevy-brink (#[derive(BrinkCommand)]) |
brink-test-harness | crates/internal/brink-test-harness/ | Episode-based behavioral testing (oracle corpus) |
Internal crates have publish = false and are not published to crates.io.
Editor plugins
| Crate | Path | Purpose |
|---|---|---|
zed-brink | crates/zed-brink/ | Zed editor extension |
Key dependency rules
brink-runtimedepends ONLY onbrink-format— keeps the runtime minimal and embeddablebrink-lspdepends onbrink-analyzer, NOT onbrink-compiler— the LSP needs parse through validation, not codegenbrink-formathas no brink-internal dependencies — it is the stable interface layerbrink-formatis the firewall — source-level concepts never leak into the runtime
These rules enable hot-reload (runtime loads new bytecode without the compiler), compile-time isolation (changing compiler internals doesn’t rebuild the runtime), and small runtime binaries for embedding.
Workspace conventions
- Dependencies are declared in
[workspace.dependencies]in the rootCargo.tomland referenced viadep.workspace = truein each crate - Lints are configured in
[workspace.lints]and inherited via[lints] workspace = true - Edition, license, repository are set in
[workspace.package]and inherited withfield.workspace = true
Development Workflow
Building
cargo check --workspace # type-check
cargo build --workspace # full build
Testing
cargo test --workspace # run all tests
Episode corpus
The episode corpus is the primary correctness tool — it runs brink against golden episodes generated by the C# ink runtime and asserts the behavior matches. The commands, the ratchet, and the per-case diagnostics are documented in Test Corpus.
Linting
cargo clippy --workspace --all-targets -- -D warnings # lint
cargo fmt --all -- --check # format check
cargo fmt --all # format fix
Lint policy
unsafe_code,unwrap_used,expect_used,panic,todo,print_stdout,print_stderrare denied in library crates- Clippy pedantic is enabled (with targeted allows for noise)
- Tests are exempt from unwrap/expect/dbg/print restrictions (via
clippy.toml)
Determinism
Never iterate HashMap keys/values where order affects output. Sort or use BTreeMap. This applies to all output-producing code paths — bytecode emission, line table construction, name table serialization, and test output.
Test Corpus
The repository includes a test corpus at tests/ organized into tiers.
Corpus structure
tests/
tier1/ # Basic ink features (text, choices, diverts, knots, variables)
tier2/ # Intermediate features (tunnels, threads, lists, logic)
tier3/ # Advanced features (complex weave, edge cases)
tests_github/ # Real-world .ink files from open-source projects
tests_patched/ # Modified tests for edge cases
Test case format
Each test case is a directory containing:
| File | Description |
|---|---|
story.ink | The ink source file (ground truth) |
story.ink.json | Inklecate-compiled JSON output (reference) |
episodes/*.episode.json | Recorded play-throughs with expected output |
An episode records a sequence of continues and choice selections with the expected text output at each step. The test harness runs both pipelines (native compiler and converter) against each episode and compares results.
Running corpus tests
# Corpus report -- per-category pass/fail breakdown (run first for triage)
cargo test -p brink-test-harness --test corpus_report -- --nocapture
# All episodes (insta snapshots vs C# oracle)
cargo test -p brink-test-harness --test oracle_snapshots -- --nocapture
# Single case with diagnostics
BRINK_CASE=I002 cargo test -p brink-test-harness --test oracle_snapshots -- --nocapture
# Accept snapshot changes after intentional behavioral changes
INSTA_UPDATE=always cargo test -p brink-test-harness --test oracle_snapshots
Each case has a per-case snapshot in crates/internal/brink-test-harness/tests/snapshots/. Failing episodes are listed with step-by-step diffs against the oracle.
The ratchet
RATCHET_EPISODE_COUNT in oracle_snapshots.rs is the minimum number of passing episodes. It only goes up — the test fails if the pass count drops below it. If a correct fix reveals previously-false passes, the ratchet can be lowered with an explanation.
GitHub corpus
The tests_github/ directory contains real-world .ink files from open-source projects. These are used for parser smoke tests (zero panics on any input) and lossless roundtrip validation.