Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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 terminalInstallationThe CLI
Drive stories from a Rust programYour First StoryEmbedding the Runtime
Ship a story in a Bevy gameBevy Integration
Build a web front-end or editorWeb & WASM · Studio
Translate a storyLocalization
Understand how it works, or hack on itConcepts · Contributing

Features

  • Full ink language support: choices, gathers, weave, variables, lists, sequences, tunnels, threads, external functions
  • Bytecode compiler with multi-file support (INCLUDE resolution)
  • 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

CommandDescription
compileCompile .ink source to .inkb or .inkt
convertConvert between ink formats (.ink.json, .inkb, .inkt)
playPlay an ink story interactively or in batch mode
export-xliffExport a story’s line tables as an XLIFF 2.0 file for translation
compile-localeCompile a translated XLIFF into a .inkl locale overlay
regenerate-xliffUpdate an XLIFF after recompilation, preserving translations
fmtFormat .ink source files (--check, --stdin)
replayRe-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

FlagDefaultDescription
--output <FILE> / -ostdoutOutput file path. Format inferred from extension.

Output format is determined by the file extension:

ExtensionFormat
.inkbBinary bytecode (production format)
.inktHuman-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

FlagDefaultDescription
--output <FILE> / -ostdout (.inkt)Output file path. Format inferred from extension.

Supported formats

ExtensionFormatDescription
.ink.jsoninklecate JSONOutput from the reference ink compiler
.inkbBinary bytecodebrink’s native binary format
.inktTextual bytecodeHuman-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

FlagDefaultDescription
--speed <N> / -s30Typewriter speed in characters per second (0 = instant)
--input <FILE> / -iRead 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

KeyStory panelChoice panel
SpaceSkip typewriterSkip typewriter
Up/DownScroll historySelect choice
EnterConfirm choice
TabFocus choicesFocus story
qQuitQuit

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 via link(), 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 a Program.

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

  1. Loading & Linking — produce StoryData (compile .ink or read .inkb) and link() it into a Program + line tables.
  2. Drive it — step the story and react to each Line. The loop, the Line variants, and choice handling all live in The Execution Model.
  3. External Functions — let the story call back into your code (EXTERNAL functions), synchronously or deferred.
  4. 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 .inkb file (wrong magic, bad checksum, truncated data)
  • UnresolvedDefinition — a container references a DefinitionId that doesn’t exist in the story data
  • NoRootContainer — 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). Returning Fallback for 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 with story.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-brink integration 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.
  • FlowAlreadyExistsspawn_flow with a name that’s already in use.

See Reference › Errors for the full list.

For engine integration, bevy-brink exposes 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; .inkl loading + plural-aware rendering are in brink-runtime (apply_locale, the PluralResolver trait); the CLI exposes export-xliff / compile-locale / regenerate-xliff; brink-intl provides the library API and IcuPluralResolver. bevy-brink adds 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.
  • .inkl overlays 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 LineId addressing scheme.

How the pieces fit

You want to…See
Extract, translate, and compile a localeXLIFF Workflow
Understand plural categories and resolversPlurals
Know what a line template can expressReference › Line Templates
Read the .inkl byte layoutReference › Binary Format

The translation pipeline is always .ink → compile → .inkbexport-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 → .inkbexport-xliff.xlf. (Always start from a compiled .inkb; never feed inklecate’s .ink.json into the intl tooling.)

  1. 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
    
  2. Translate: work in the .xlf directly or import it into a TMS (Lokalise, Crowdin, …). Translation state rides XLIFF’s state attribute (initial/translated/reviewed/final).

  3. Compile: turn the translated XLIFF into a binary .inkl overlay.

    brink compile-locale --base story.inkb --xliff story.es.xlf --locale es -o story.es.inkl
    
  4. Regenerate: after the source changes and you recompile, diff the new .inkb against 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/Story split, 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 .ink source 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 sourcebrink compile (native) — the normal case
only inklecate .ink.json outputbrink convert (converter)
a need to diff brink against inklecateboth, 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.json into 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;
        }
    }
}

Story is the mutable half of the two-object model: it borrows an immutable Program and carries all the execution state.

Line variants

VariantMeaningNext 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() -> Line produces 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, or End). 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:

FieldTypeDescription
textStringdisplay text for this choice
indexusizethe value to pass to story.choose()
tagsVec<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 choices vec.
  • 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:

StatusMeaning
ActiveReady to step.
WaitingForChoiceMust call choose() before stepping.
DoneHit a done opcode. Can resume with continue_single().
EndedHit -> 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 sidebrink-runtime, which only understands compiled bytecode. It depends exclusively on brink-format.

This split has real, practical consequences:

  • The runtime never links the compiler. Shipping brink-runtime does not pull in the parser, analyzer, or codegen — the embeddable binary stays small.
  • brink-format defines everything that crosses the boundaryStoryData, 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 StoryData can 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

FunctionDescription
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-runtime public surface: link, Program, Story, statistics, RNG.
  • Bytecode & Opcodes — the full opcode set executed by the VM.
  • Binary Format — the .inkb / .inkt / .inkl file layouts.
  • Containers & DefinitionId — the identity scheme and the container/address model.
  • Line Templates — the localizable line content types (slots, selects, plural keys).
  • Errors — every RuntimeError variant 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

ItemKindDescription
link()FunctionLink StoryData into (Program, line_tables)
ProgramStructImmutable, shareable compiled story
StoryStructPer-instance mutable execution state
LineEnumYielded by continue_single() / continue_maximally(): Text, Done, Choices, End
ChoiceStructA single choice — text, index, tags
StoryStatusEnumActive, WaitingForChoice, Done, Ended
RuntimeErrorEnumAll 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

MethodReturnsUse
continue_single()Lineone 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 abovesame, with an ExternalFnHandler
choose(index)()select a choice when WaitingForChoice
status()StoryStatusquery 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/or compiled 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

OpcodeOperandsDescription
PushInti32Push an integer constant
PushFloatf32Push a float constant
PushBoolu8Push a boolean (0 = false, 1 = true)
PushStringu16Push a string by line table index
PushListu16Push a list literal by index
PushDivertTargetDefinitionIdPush a divert target address
PushNullPush null
PopDiscard the top value
DuplicateDuplicate the top value

Arithmetic

OpcodeDescription
AddPop two values, push their sum (also concatenates strings)
SubtractPop two values, push their difference
MultiplyPop two values, push their product
DividePop two values, push their quotient
ModuloPop two values, push the remainder
NegatePop one value, push its negation

Comparison

OpcodeDescription
EqualPop two values, push whether they are equal
NotEqualPop two values, push whether they differ
GreaterPop two values, push whether left > right
GreaterOrEqualPop two values, push whether left >= right
LessPop two values, push whether left < right
LessOrEqualPop two values, push whether left <= right

Logic

OpcodeDescription
NotPop one value, push its logical negation
AndPop two values, push logical AND
OrPop two values, push logical OR

Variables

OpcodeOperandsDescription
GetGlobalDefinitionIdPush the value of a global variable
SetGlobalDefinitionIdPop a value and assign it to a global variable
DeclareTempu16 (slot)Declare a temp variable in the current frame
GetTempu16 (slot)Push the value of a temp (auto-dereferences pointers)
SetTempu16 (slot)Pop a value and assign it to a temp slot
GetTempRawu16 (slot)Push a temp’s raw value without auto-dereference
PushVarPointerDefinitionIdPush a pointer to a global variable
PushTempPointeru16 (slot)Push a pointer to a temp variable

Control flow

OpcodeOperandsDescription
Jumpi32 (offset)Unconditional relative jump within the current container
JumpIfFalsei32 (offset)Pop a value; jump if falsy
GotoDefinitionIdAbsolute jump to a named address
GotoIfDefinitionIdPop a value; goto the address if truthy
GotoVariablePop a DivertTarget from the stack and goto it

Container flow

OpcodeOperandsDescription
EnterContainerDefinitionIdPush a container onto the container stack (updates visit counts)
ExitContainerPop the current container from the container stack

Functions and tunnels

OpcodeOperandsDescription
CallDefinitionIdCall a function — pushes a new call frame with fresh temp storage
ReturnReturn from a function call
TunnelCallDefinitionIdTunnel into a knot — pushes a return address, shares the output stream
TunnelReturnReturn from a tunnel
TunnelCallVariablePop a DivertTarget and tunnel to it
CallVariablePop a DivertTarget and call it as a function

Threads

OpcodeOperandsDescription
ThreadCallDefinitionIdFork execution to explore a choice branch
ThreadStartMark the beginning of a forked thread’s code
ThreadDoneMark 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

OpcodeOperandsDescription
EmitLineu16 (index), u8 (slot count)Emit a line from the scope’s line table; slot count interpolation slots are popped from the stack
EmitValuePop a value and emit its string representation
EmitNewlineEmit a newline character
SpringWord break — renders as a single space between content parts
GlueSuppress the previous newline (joins lines)
BeginTagBegin capturing tag content
EndTagEnd tag capture and attach to current output
EvalLineu16 (index), u8 (slot count)Evaluate an interpolated line template with slot count popped slots
BeginFragmentBegin capturing output into a fragment
EndFragmentEnd fragment capture; store the parts and push a FragmentRef

Choices

OpcodeOperandsDescription
BeginChoiceflags: u8, DefinitionIdBegin a choice with flags and a target address
EndChoiceFinalize 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

OpcodeOperandsDescription
Sequencekind: u8, count: u8Begin a sequence (kind: 0=cycle, 1=stopping, 2=once-only, 3=shuffle)
SequenceBranchi32 (offset)Jump offset for a sequence branch

Intrinsics

OpcodeDescription
VisitCountPop a DivertTarget, push its visit count
CurrentVisitCountPush the visit count of the current container
TurnsSincePop a DivertTarget, push turns since last visit (-1 if never)
TurnIndexPush the current turn index
ChoiceCountPush the number of currently available choices
RandomPop max and min, push a random integer in [min, max]
SeedRandomPop a seed value and set the RNG seed

Casts and math

OpcodeDescription
CastToIntPop a value, push it as an integer
CastToFloatPop a value, push it as a float
FloorPop a float, push its floor as an integer
CeilingPop a float, push its ceiling as an integer
PowPop exponent and base, push base^exponent
MinPop two values, push the smaller
MaxPop two values, push the larger

External functions

OpcodeOperandsDescription
CallExternalDefinitionId, u8 (arg count)Call an externally-bound function

List operations

OpcodeDescription
ListContainsPop item and list, push whether the list contains the item
ListNotContainsPop item and list, push whether the list does not contain the item
ListIntersectPop two lists, push their intersection
ListAllPop a list, push all possible items from its origin lists
ListInvertPop a list, push the complement (all origin items not in the list)
ListCountPop a list, push its item count
ListMinPop a list, push its minimum item
ListMaxPop a list, push its maximum item
ListValuePop a list, push its integer value (ordinal of single item)
ListRangePop max, min, and list; push items within the ordinal range
ListFromIntPop an integer and list origin, push the item with that ordinal
ListRandomPop a list, push a random item from it

String evaluation

OpcodeDescription
BeginStringEvalBegin capturing output as a string value (for string interpolation)
EndStringEvalEnd string capture and push the result onto the stack

Lifecycle

OpcodeDescription
DoneYield — the story pauses and can be resumed (marks a safe exit)
YieldPause for choice presentation — like Done but does not mark a safe exit
EndPermanent end — the story is finished
NopNo operation

Debug

OpcodeOperandsDescription
SourceLocationu32 (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

ExtensionFormatDescription
.inkbBinaryCompiled bytecode with definition tables, line tables, and metadata
.inktTextualHuman-readable disassembly (like WAT for WASM)
.inklLocale overlayPer-scope replacement line tables for a specific locale

.inkb format

Header (16 bytes)

OffsetSizeField
04Magic: INKB
42Version: u16 LE (currently 2)
61Section count: u8 (10)
71Reserved: 0x00
84File size: u32 LE
124Content 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

TagSectionKindContents
0x01NameTableInterned name strings. Each entry is a length-prefixed UTF-8 string (u16 LE byte count + bytes). Referenced by NameId(u16) indices throughout other sections.
0x02VariablesGlobal variable definitions. Each entry: DefinitionId + NameId + ValueType tag + encoded default value + mutability flag.
0x03ListDefsList (enum) type definitions. Each entry: DefinitionId + NameId + item count + (NameId, i32 ordinal) pairs.
0x04ListItemsIndividual list item definitions. Each entry: DefinitionId + origin DefinitionId + i32 ordinal + NameId.
0x05ExternalsExternal function declarations. Each entry: DefinitionId + NameId + u8 arg count + optional fallback DefinitionId.
0x06ContainersBytecode containers. Each entry: DefinitionId + scope DefinitionId + optional NameId + CountingFlags byte + i32 path hash + u8 declared-parameter count + u32 bytecode length + raw bytecode bytes.
0x07LineTablesPer-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).
0x08LabelsAddress definitions (divert targets). Each entry: DefinitionId (address) + DefinitionId (container) + u32 byte offset.
0x09ListLiteralsPre-computed list literal values used by PushList instructions. Each entry: item count + DefinitionId items + origin count + DefinitionId origins.
0x0AAddressPathsMaps 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.
  • DefinitionId values are encoded as raw u64 LE (8 bytes).
  • Strings in the name table are length-prefixed: u16 LE byte count followed by UTF-8 bytes.
  • Sections are self-contained — the runtime can deserialize them independently. The read_inkb function parses all sections into a complete StoryData for 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_tag and the base .inkb checksum (base_checksum), so a mismatched overlay is rejected before it can render garbage.
  • line_tables: per-scope replacement tables (LocaleScopeTable) keyed by scope DefinitionId.
  • Only scopes present in the .inkl are replaced; the rest fall back to base text under LocaleMode::Overlay (or error under LocaleMode::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

TagKindDescription
0x01AddressKnot, stitch, gather, or intra-container label
0x02Global variableName, type, default value
0x03List definitionEnum-like type with named items
0x04List itemIndividual member of a list definition
0x05External functionHost-provided function binding
0x07Local variableTemp/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 by VISITS() 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 by TURNS_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. The LocalVar tag (0x07) exists for compiler-internal use but temps are not serialized as definitions in the bytecode.
  • NameId — a u16 index 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 by DefinitionId, 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 by EmitLine(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 u8 is 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 SelectKey to match. Falls back to default if 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.

VariantWhen
InvalidChoiceIndexchoose() called with an index outside the valid range
NotWaitingForChoicechoose() called when story isn’t in WaitingForChoice status
StoryEndedTried to continue a story that has permanently ended
UnknownFlowReferenced a named flow that doesn’t exist
FlowAlreadyExistsTried to spawn a flow with a name that’s already active
StepLimitExceededSafety limit hit — possible infinite loop in the story

Story errors

These indicate a problem in the ink source or an unsupported feature.

VariantWhen
TypeErrorType mismatch in an ink expression (e.g., adding a string to a list)
DivisionByZeroDivision or modulo by zero in an ink expression
UnresolvedExternalCallStory calls an external function with no handler provided
UnimplementedThe story uses an opcode not yet supported by the VM

Internal errors

These typically indicate a compiler bug — the bytecode is malformed.

VariantWhen
DecodeCorrupt or incompatible .inkb file
UnresolvedDefinitionLinker can’t find a referenced definition
NoRootContainerStory has no entry point
StackUnderflowValue stack empty when an operand was expected
CallStackUnderflowNo call frame to return to
ContainerStackUnderflowNo container to pop from the container stack
UnresolvedGlobalGlobal variable lookup failed
CaptureUnderflowOutput 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:

AssetHoldsNotes
BrinkStoryAssethandles to the two belowwhat you load() and hand to a request
ProgramAssetthe immutable bytecode Program + initial_contextwhat the VM executes; initial_context is the fresh “new game” state
LineTablesAssetthe localizable line tablesthe 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

ExtensionLoaderProducesFeature
.inkbInkbLoaderBrinkStoryAsset (+ labeled #program, #line_tables)always
.inkInkLoaderBrinkStoryAsset, compiled at load time, hot-reloads on source changedev
.inklInklLoaderLocaleAsset (a locale overlay)always
.brktBrktLoaderTranscriptAsset (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 .brkt transcript 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

VariantMeaning
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

VariantMeaning
FromGlobals (default)Clone the shared BrinkGlobals<M> “save data”.
FromInitialUse 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:

TypeKindHolds
BrinkFlow<M>Componentthe FlowInstance (.inner) — call stacks, output buffer, pending choices, transcript
BrinkContext<M>Componentthis flow’s in-flight Context (.inner) — globals, visit/turn counts, RNG
BrinkProgram<M>ComponentHandle<ProgramAsset> the flow runs against
BrinkLocale<M>ComponentHandle<LineTablesAsset> the flow renders with
BrinkGlobals<M>Resourcethe 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:

AdvanceMeaning
Line(Line)a line was produced and its observer event fired
AwaitingQuerythe 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_one produces one line — for typewriter UIs that animate fragments.
  • advance_until_terminal runs 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):

EventFires forCarries
BrinkLineDelivered<M>Line::Text (mid-stream)text, tags
BrinkChoicesPresented<M>Line::Choicestext, 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 flowentity
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

VerbForResolution
bind_brink_fnpure compute, no Worldinline while the VM steps
bind_brink_commandfire-and-forget event (with optional return)buffered during the step, flushed after
bind_brink_queryread live World stateflow pauses; a resolver runs the binding system, then resumes
bind_brink_asyncmulti-frame World interaction (UI, input)flow parks; BrinkExternalAwaited fires; you resolve when ready
bind_brink_taskoff-thread compute / IOflow 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.

ItemRole
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

MethodDescription
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.

GroupMethods
Filesupdate_file(path, src), set_active_file(path), remove_file, list_files, …
Compile & structurecompile_project(entry), project_outline, document_symbols
Highlightingsemantic_tokens, token_type_names, token_modifier_names
Navigationgoto_definition, find_references, hover, completions, signature_help
Refactorsprepare_rename, rename, code_actions, convert_element
Editing aidsfolding_ranges, inlay_hints, line_contexts, format_document
Structural editsreorder_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/web wrapper 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 EditorSession query wired to the editor.
  • Live player — step through the compiled story with choice selection; playback state persists to localStorage and 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.

PackageResponsibility
@brink-lang/studioapp shell + entry point (Vite)
@brink/studio-uiReact components: layout, activity bar, binder, state view, player, tabs, status bar
@brink/studio-storeZustand store — editor / compile / tabs / player / binder / layout slices
@brink/ink-editorthe CodeMirror 6 editor, state management, IDE extensions, screenplay sigils
@brink/ink-operationspure line-editing functions (no CM6, React, or wasm)
@brink-lang/webergonomic wrappers over the brink-web FFI
@brink/wasm-typesshared 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
CommandPurpose
pnpm devdev server (port 5180)
pnpm buildproduction build
pnpm typecheckTypeScript, no emit
pnpm testVitest unit tests (jsdom)
pnpm test:e2ePlaywright 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

CratePathPurpose
brinkcrates/brink/Public API — re-exports from compiler and runtime
brink-compilercrates/brink-compiler/Pipeline driver: .ink to StoryData
brink-runtimecrates/brink-runtime/Bytecode VM for executing compiled stories
brink-clicrates/brink-cli/CLI tool: compile, convert, play, export-xliff, compile-locale, regenerate-xliff, fmt, replay
brink-lspcrates/brink-lsp/Language server for ink files
brink-webcrates/brink-web/WASM bindings for the IDE + runtime; powers the web playground
bevy-brinkcrates/bevy-brink/Bevy 0.18 integration: plugin, assets, components, external-function bindings

Internal crates

CratePathPurpose
brink-syntaxcrates/internal/brink-syntax/Lexer, parser, lossless CST, typed AST
brink-ircrates/internal/brink-ir/HIR + LIR intermediate representations, lowering
brink-analyzercrates/internal/brink-analyzer/Cross-file semantic analysis, symbol resolution
brink-drivercrates/internal/brink-driver/Pipeline orchestration: file discovery + cross-file analysis
brink-codegen-inkbcrates/internal/brink-codegen-inkb/Bytecode codegen: LIR to StoryData
brink-codegen-jsoncrates/internal/brink-codegen-json/JSON codegen: LIR to .ink.json (for diffing)
brink-formatcrates/internal/brink-format/Binary interface between compiler and runtime
brink-dbcrates/internal/brink-db/Incremental project database, file discovery
brink-jsoncrates/internal/brink-json/Parser for inklecate .ink.json output
brink-convertercrates/internal/brink-converter/Reference pipeline: .ink.json to StoryData
brink-fmtcrates/internal/brink-fmt/.ink source formatter (powers brink fmt)
brink-intlcrates/internal/brink-intl/Internationalization tooling: line export, XLIFF round-trip, .inkl compile, ICU plurals
xliff2crates/internal/xliff2/General-purpose XLIFF 2.0 read/write library
brink-idecrates/internal/brink-ide/Protocol-agnostic IDE query library (shared by the LSP/web)
bevy-brink-derivecrates/internal/bevy-brink-derive/Derive macros for bevy-brink (#[derive(BrinkCommand)])
brink-test-harnesscrates/internal/brink-test-harness/Episode-based behavioral testing (oracle corpus)

Internal crates have publish = false and are not published to crates.io.

Editor plugins

CratePathPurpose
zed-brinkcrates/zed-brink/Zed editor extension

Key dependency rules

  1. brink-runtime depends ONLY on brink-format — keeps the runtime minimal and embeddable
  2. brink-lsp depends on brink-analyzer, NOT on brink-compiler — the LSP needs parse through validation, not codegen
  3. brink-format has no brink-internal dependencies — it is the stable interface layer
  4. brink-format is 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 root Cargo.toml and referenced via dep.workspace = true in each crate
  • Lints are configured in [workspace.lints] and inherited via [lints] workspace = true
  • Edition, license, repository are set in [workspace.package] and inherited with field.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_stderr are 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:

FileDescription
story.inkThe ink source file (ground truth)
story.ink.jsonInklecate-compiled JSON output (reference)
episodes/*.episode.jsonRecorded 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.