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’s compiler is under active development. Its correctness is measured against a corpus of golden episodes generated by inkle’s reference C# runtime (the “oracle”): every story is played through thousands of choice sequences and compared turn-by-turn, and a ratchet test keeps each change byte-identical or better. See Test Corpus for how that works.

Installation

CLI

Install the brink binary from crates.io:

cargo install brink-cli

The crate is named brink-cli; the command it installs is brink.

brink --help

Prebuilt binaries are not published yet. The release pipeline (cargo-dist, targeting macOS on Apple Silicon and Intel, Linux x86-64, and Windows x86-64) is configured but runs only on a manual dispatch, so for now every install builds from source.

To track the development branch instead of a release:

cargo install --git https://github.com/Syynth/brink brink-cli

Rust library

Add the runtime to your project:

[dependencies]
brink-runtime = "0.0.9"

brink-runtime is the primary library interface. It depends only on brink-format — the binary interface between the two halves of the toolchain — and pulls in no compiler code, which is what keeps embedded builds small. See Architecture & the Firewall.

If you also need to compile .ink source (at build time, or at runtime for a live-reloading editor), add the compiler alongside it:

[dependencies]
brink-compiler = "0.0.9"
brink-runtime = "0.0.9"

The brink crate on crates.io is a name reservation and ships no code. Depend on brink-runtime and brink-compiler directly.

For the Bevy integration, see Bevy:

[dependencies]
bevy-brink = "0.0.9"

JavaScript packages

The browser toolchain is published to npm. See Web & WASM for what each one exposes.

npm install @brink-lang/web       # compiler + runtime + IDE queries, via WASM
npm install @brink-lang/editor    # the CodeMirror 6 ink editor

@brink-lang/studio is the reference authoring app rather than a library — see Studio for how to run it.

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

extern crate brink_compiler;
extern crate brink_runtime;
use std::path::Path;
use std::sync::Arc;
use brink_compiler::compile_path;
use brink_runtime::{Step, 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 `Step`; the variant tells you what to do.
    let mut story: Story = Story::new(Arc::new(program), line_tables);

    loop {
        match story.continue_single()? {
            // Mid-stream content — keep going.
            Step::Line(line) => print!("{}", line.text),
            // This turn's output is complete; the story isn't over.
            Step::Done => {}
            Step::Choices(choices) => {
                for choice in &choices {
                    println!("  {}. {}", choice.index + 1, choice.text);
                }
                // Select the first choice (replace with real input).
                story.choose(choices[0].index)?;
            }
            Step::End => break,
            // Reserved for flow suspension; not yet emitted.
            Step::Suspended => break,
        }
    }

    Ok(())
}

If you already have a compiled .inkb file, decode it directly instead of compiling:

extern crate brink_format;
extern crate brink_runtime;
fn main() -> Result<(), Box<dyn std::error::Error>> {
use std::sync::Arc;
use brink_runtime::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 = Story::new(Arc::new(program), line_tables);
// ... step loop as above
let _ = &mut story;
Ok(())
}

Project Settings (brink.toml)

Every surface that compiles the same project — brink compile, brink ide, an embedded editor session, the Studio player — chooses a dialect and a type policy (gradual or strict). Before brink.toml, each mount picked its own default independently: brink compile had --dialect/--types flags, brink ide had none at all, and an embedder set the wasm editor session’s dialect with setLanguageDialect/setTypePolicy calls hardcoded in its own lens code. Two mounts compiling the same project could silently disagree about which syntax/typing surface it’s written in — an author writes syntax one surface accepts and another rejects.

brink.toml, at the project root beside the root .ink file, is the one config every mount reads.

Schema

[project]
dialect = "brink"      # "brink" | "strict-ink" (default: "strict-ink")
types   = "gradual"    # "gradual" | "strict"   (default: dialect-keyed —
                       # strict for "brink", gradual for "strict-ink")
entry = "story.ink"    # a project-relative path to the project's entry
                       # file, superseding the embedder's own
                       # constructor-time entry-file argument (issue
                       # #2331, ruled 2026-08-07). Honored only by the
                       # wasm editor session (`ProjectSession`) today — see
                       # "Per mount" below; every other mount parses the
                       # key but does not act on it.
unprune-dirs = ["node_modules"]  # directory names a native (.brink) compile
                                 # must not prune from discovery, on top of
                                 # the default target/.git/node_modules list
                                 # — see "Directory discovery pruning" below
                                 # (issue #1407)
drafts = ["scratch/**", "*.draft.ink"]  # path globs naming deliberately
                                       # unfinished work — see "Drafts"
                                       # below (issue #3145)
conventions = "conventions.brink"  # a project-relative path, or a bare
                                   # built-in preset name (e.g.
                                   # "screenplay"), pointing at the
                                   # project's conventions module (issue
                                   # #1844; see the dialect spec's
                                   # "Where conventions live" section)

[lints]
deny-warnings = true   # promote every Warning-severity diagnostic to
                       # Error (the `-D warnings` equivalent; issue #1160)
E014 = "deny"          # per-code severity override:
                       # "allow" | "warn" | "deny" | "info" | "hint"
                       # ("info"/"hint" down-level to an advisory tier below
                       # Warning — issue #1162)

[fix]
E033 = "auto"   # promote a Suggested fixer to batch (fix-all, on-save) for
                # this project — see "Fix policy" below (issue #3419)
E014 = "off"    # never offer this code's fixer here
                # absent ⇒ "ask": offered per click only (Suggested) /
                # already batchable (Safe)

[project] elements is a deprecated alias for conventions (issue #2180): it still sets the same value, but emits a ConfigWarning naming the rename — migrate to conventions at your own pace.

All keys are optional. An empty or absent [project]/[lints] table — or no brink.toml at all — changes nothing on a first apply: a missing file is exactly today’s behavior, no regression. For a long-lived caller that re-applies brink.toml on every change (the wasm editor session, see below), [lints] is the one exception: each apply replaces the resolved lint policy wholesale from whatever the file currently says (issue #1397), so an empty or absent [lints] table on a later apply reverts any codes a previous, non-empty [lints] table had set.

Unknown keys — a stray top-level table, a key inside [project], or a [lints] entry naming a code this version of brink doesn’t recognize (or one whose default severity is Error, so it isn’t overridable at all — see Lint severity below) — are reported as warnings, never compile failures. This is a forward-compatibility guarantee: a brink.toml written against a newer schema still compiles with an older brink binary, just with a warning about the keys it didn’t understand.

Drafts

[project] drafts names work the author has deliberately not wired into the story — scratch scenes, cut material, notes-to-self. A file is a draft when both halves hold:

draft(file) := matches(file, drafts) && !reachable_from_entry(file)

Reachability wins (ruled 2026-08-27). A file that matches a glob but the entry still INCLUDEs is not a draft: it compiles normally, with no special treatment. There is deliberately no “marked draft but included” state to diagnose, which is what makes draft status unable to break a story — the only files it can touch are files compilation never reached anyway.

Being a draft means:

  • no “not included in the project” banner in the editor;
  • the file is marked as a draft wherever the studio names it — the Binder row, the Continuous view’s section heading, the Single File header, and the Code view’s tab. (The rule is that a file’s name and its draft status never appear apart, so a naming surface added later inherits it.)

Glob syntax

Patterns match the whole project-relative, /-separated path, and are case-sensitive.

TokenMatches
?exactly one character, never /
*any run of characters (including none), never /
**any run of characters, / included
anything elseitself, literally

A trailing / is sugar for /**, so scratch/ and scratch/** are the same. Note that a bare directory name does not cover its contentsscratch matches a file called scratch, and nothing else. Write scratch/** for everything under a directory. (This is the one place the dialect departs from .gitignore, whose bare-name rule is a common source of surprise matches; in a short, hand-written list, saying what you mean is cheaper than a silent over-match.)

A pattern that is absolute (/tmp/**) or escapes the project (../**) parses but never matches anything, and is reported as a warning.

Lint severity

[lints] (issue #1160) is shaped like Rust’s own [lints] table, but is not a semantic drop-in for it. Each key other than the reserved deny-warnings names a diagnostic code ("E014") mapped to a severity:

  • deny — always Error, regardless of deny-warnings.
  • warn — the code’s ordinary behavior: Warning, promoted to Error by deny-warnings like any other unconfigured warning.
  • allowunlike Rust’s allow, this does not remove the diagnostic. It only buys immunity from deny-warnings; the diagnostic still resolves to Warning and is still reported. To actually suppress a diagnostic at a specific site, use a //brink-disable comment, or — in a .brink file — an @[allow(…)] annotation on the declaration. Both are per-site mechanisms, not project-wide policy knobs.
  • info / hint (issue #1162) — down-level the diagnostic to the Info or Hint severity tier respectively, below Warning. Like allow, both are immune to deny-warnings (escalating a deliberate downgrade back up would defeat the point of it). These map to the LSP client’s Information/Hint DiagnosticSeverity — the tier IDE conventions use for advisory findings that would be too loud as a Warning squiggle (e.g. unused-symbol dimming). Only E157 defaults to either tier (Info); a project opts any other non-Error-default code into one explicitly, per code.

A source-level @[allow] wins

In a .brink file, @[allow(E151)] written above a declaration removes that diagnostic for the declaration’s whole span, and it beats this table — including E151 = "deny" and deny-warnings = true. The annotation names one declaration and was written deliberately; brink.toml cannot be that specific. What the annotation cannot do is widen the suppressible set: it accepts only codes whose default severity is not Error, so no [lints] entry can make an error-tier code suppressible, and none can make a non-error-tier code unsuppressible. Naming an unknown code (E153) or an error-tier one (E154) is itself a compile error — a suppression that silently does nothing is never allowed.

Only codes whose default severity is not Error are overridable at all — a diagnostic that is a hard error by default (e.g. a parse error) can never be downgraded through [lints]; the table is never even consulted for it. E063 (annotation-vs-inference mismatch) is a special case worth knowing: its own base severity is types-policy-dependent (Error under types = strict), so a [lints] entry for it is only ever consulted under types = gradual.

A key that isn’t a real diagnostic code, or names a non-overridable one, is never merged into the resolved policy — it’s reported as a warning (the same channel unknown top-level/[project] keys use), never silently dropped.

Every mount now has a CLI/API override tier for [lints]/deny-warnings, same as dialect/types below — always winning over the same code in a discovered brink.toml (see Precedence below):

  • brink compile and brink ide (issue #1373, extended to brink ide by #1417): repeatable --deny/--warn/--allow <CODE> flags, plus -D warnings (mirroring rustc’s own flag) for deny-warnings. See brink compile / brink ide.
  • bevy-brink’s dev-mode InkLoader, via BrinkPlugin::with_config(ProjectConfig { lints, deny_warnings, .. }) (issue #1394) — the same override also reaches compile_story_inline (issue #1380), as long as it’s called after the BrinkPlugin/BrinkAssetsPlugin that carries the override has been added to the app.
  • brink-lsp (issue #1417), via initializationOptions.lints/.denyWarnings — see Per mount below.
  • The wasm editor session (issue #1417), via EditorSessionHandle.setLintOverrides(json)/ .setDenyWarningsOverride(bool)/.clearDenyWarningsOverride() — see Per mount below.

Fix policy

[fix] (docs/autofix-spec.md §6.1, issue #3419) is shaped exactly like [lints]: each key names a diagnostic code, mapped to one of three policies, least to most aggressive:

  • off — never offer or batch a fixer for this code in this project.
  • ask — the code’s ordinary behavior when [fix] doesn’t mention it: a Safe fixer is batchable (fix-all, brink fix, on-save); a Suggested fixer is offered only per explicit click.
  • auto — promote a Suggested fixer to batchable here too. (A Safe fixer is already batchable regardless of [fix].)

Same dependency-free split as [lints]: brink-project-config validates the value (a wrong TOML type, or a spelling outside the three above, is a compile error — never a panic) but not the code. For [lints], a downstream crate raises that diagnostic (validate_lint_code, in brink-analyzer); as of issue #3447, [fix] gets the same treatment from a validate_fix_code sibling in the same crate. An unrecognized [fix] code is a warning, never a compile failure or a panic — [fix] E9999 is not a recognized diagnostic code; ignored — surfaced through AnalysisOptions::apply_project_config’s returned warnings on both of [lints]’s own reader roads: brink_environment::resolve_options (the compile road) and EditorSession::apply_parsed_config (the studio/db road, @brink-lang/web’s Problems panel).

An app (the Studio, an embedder) may pass its own ceiling — a personal “how far may fix-on-save go” setting, in the same three-way space — which only ever narrows the project’s [fix] entry, never widens it: a team promoting E033 to auto in brink.toml does not force it onto an author whose app ceiling says ask, and an author’s auto ceiling cannot make a project-off code run. ProjectConfig::effective_fix_policy(code, app_ceiling) is the one function both the project entry and the ceiling resolve through.

Edited in the Studio’s Settings → Diagnostics section as a Fix column beside severity, through the same write path the severity picker already uses for [lints] — a different table, same file, same code.

Discovery

A mount discovers brink.toml by walking up from the entry .ink file’s directory through each ancestor, stopping at the first brink.toml it finds. The file doesn’t have to sit directly beside the entry point — a multi-file project with story.ink in src/chapters/ and brink.toml at the repo root still finds it.

For the real-filesystem mounts — brink compile, brink ide, and brink-lsp — the walk is bounded two ways, either of which stops it. It never climbs past a directory containing a .git entry (an ordinary repository’s .git/ directory, or a linked worktree’s .git pointer file); and, independent of that, it never climbs more than a fixed number of ancestor directories, so a non-repository tree (no VCS at all, hence no .git boundary to stop at) doesn’t climb all the way to the filesystem root either. Either way, a brink.toml that lives outside the bound is never picked up by these mounts, even by accident — but it isn’t treated as silently as if it didn’t exist: discovery reports it back as a warning (logged by brink-lsp; returned alongside the result by brink_project_config::load_from_entry), naming the skipped file so an author can tell why it wasn’t applied.

The virtual mounts have no filesystem or .git to bound against, so each is bounded at its own tree instead: the wasm editor session’s discoverProjectConfig never looks past the document tree’s own root (see below), and bevy-brink’s dev-mode InkLoader never climbs past the asset source root it was loaded from.

my-project/
├── brink.toml          ← found even though the entry is nested
└── src/
    └── chapters/
        └── story.ink    ← brink compile src/chapters/story.ink

Directory discovery pruning

A native (.brink) compile’s discovery walk enumerates every .brink file under the project root — but never descends into a directory named target, .git, or node_modules. These are build output and VCS/dependency metadata, never a valid source location, and can be enormous; pruning them is the default with no opt-in required.

Before issue #1407, that pruning was absolute: a project that legitimately kept .brink sources under one of those names got no file and no error — the source was silently invisible to every compile. Three things changed:

  • An escape hatch. [project] unprune-dirs (the Schema block above) names directories that should not be pruned, on top of the default list. Only entries that are actually one of target/.git/node_modules have any effect — a value outside that set is a no-op (nothing was ever pruned there) and is reported as a warning, the same “unknown key” channel described above, on the theory it’s more likely a typo than a deliberate no-op.
  • A diagnostic. When discovery prunes a directory that, within a bounded scan of itself, contains a .brink file — the shape of “an author probably meant for this to be found” — it’s reported as a warning naming the directory and the unprune-dirs fix, rather than saying nothing. The scan is bounded by depth and by a total-entry budget (not a full recursive descent), deep enough to catch the node_modules/<package>/lib.brink shape an npm-style dependency tree actually uses, but never turning a cheap prune into an expensive walk of the very tree being skipped. A directory named by unprune-dirs is, naturally, never reported this way — it wasn’t pruned in the first place.
  • .gitignore is deliberately not consulted, and that’s a decision, not a gap. Discovery is a deterministic-compilation input: the same tree, compiled by anyone, must discover the same files. .gitignore resolution depends on more than a repository’s tracked content — a local uncommitted edit, a per-clone .git/info/exclude, a user’s global core.excludesFile — any of which could make two checkouts of byte-identical tracked source compile differently. unprune-dirs avoids exactly that: it lives in brink.toml, itself tracked, versioned source, so it resolves the same way on every clone.

Both the escape hatch and the diagnostic live once, as opt-in builders (Walk::allow, Walk::warn_on_pruned_sources) on the shared recursive walk every native discovery traversal goes through — so a new traversal never has to reimplement the pruning policy itself. But each builder is still opt-in per traversal: today only the brink compile / brink ide path (brink-driver’s RealFs::list) wires them up. brink-lsp’s own workspace-scan walk calls the shared Walk unadorned, so an LSP-open project honors neither unprune-dirs nor the silent-skip diagnostic yet — tracked as a follow-up to wire both into brink-lsp.

Precedence: the file is the default, code wins

An explicit API call or CLI flag always overrides brink.toml. The file supplies the default for a project; an author who reaches for --dialect/--types on a single invocation, or an embedder that calls setLanguageDialect/setTypePolicy explicitly, is making a deliberate one-off choice that the file must not silently overrule.

[project] entry (issue #2331, ruled 2026-08-07 “[project] entry beats mountStudio’s entryFile”) is the one key that inverts this rule, on the one mount that honors it: a brink.toml naming a valid entry wins over the wasm editor session’s/ProjectSession’s own constructor-time entry-file argument, not the other way around. The argument is only the fallback for a configless project (no brink.toml, or one that doesn’t set entry) — see “Per mount” below.

SourceWins over
--dialect brink / --types strict (CLI flag actually passed)brink.toml, defaults
--deny/--warn/--allow <CODE> / -D warnings (brink compile/brink ide, CLI flag actually passed)brink.toml, defaults
initializationOptions.lints/.denyWarnings (brink-lsp, key actually set at initialize)brink.toml, defaults
setLanguageDialect(...) / setTypePolicy(...) (explicit call)brink.toml, defaults
setLintOverrides(...) / setDenyWarningsOverride(...) (wasm editor session, explicit call)brink.toml, defaults
BrinkPlugin::with_config(...) / BrinkAssetsPlugin::with_config(...) (bevy-brink, field actually set — reaches InkLoader and compile_story_inline)brink.toml, defaults
brink.toml’s [project] dialect/typesdefaults only
brink.toml’s [lints]/deny-warnings (for a code without a CLI/API override above)defaults only
brink.toml’s [project] entry, when it resolves to a real project file (wasm editor session / ProjectSession only — the one row in this table that inverts the rule above it)the embedder’s constructor-time entry-file argument
Dialect-keyed default (brinkstrict, strict-inkgradual)

Per mount

  • brink compile discovers brink.toml from the entry file you pass it. --dialect/--types, when actually given, override the file field-by-field (setting only --dialect leaves the file’s types, if any, in effect). [lints]/deny-warnings apply too — a build that previously succeeded with a warning can now fail. --deny/--warn/--allow <CODE> and -D warnings override the file the same way, per code (issue #1373): passing --allow E014 wins over a brink.toml E014 = "deny" for that code, while any other code in the file’s [lints] table still applies. See brink compile.

  • brink ide has no --dialect/--types flags of its own — the file (or the plain defaults, absent one) is the only source for those two. It does have a --deny/--warn/--allow <CODE> / -D warnings tier for [lints]/deny-warnings (issue #1417), identical to brink compile’s and applied the same way — an explicit flag wins over the file for that code, every other code in the file’s [lints] table still applies. Every subcommand that loads a project honors it. See brink ide.

  • brink-lsp discovers brink.toml from the workspace roots the client declares at initialize, resolving [project] dialect/types and [lints]/deny-warnings into its shared LanguageOptions. A later workspace/didChangeConfiguration notification or a watched edit to brink.toml re-resolves and re-stores the policy (reload_brink_toml), so published diagnostic severity picks up a [lints] change without a client restart. initializationOptions.lints (issue #1417) is an object { "<CODE>": "deny" | "warn" | "allow" | "info" | "hint" } (the last two added by issue #1162), and initializationOptions.denyWarnings a boolean — both resolved once at initialize (mirroring initializationOptions.dialect/.types) and applied last, so they always win over the same code in the discovered brink.toml. An unrecognized per-code level string, or an unrecognized/ non-overridable code, is reported through the server’s usual tracing::warn! channel, never silently dropped. A second, independent mechanism also dims text in the client (issue #1618): E033 (unreachable code after a divert) and E095 (#@was self-alias) publish with LSP’s DiagnosticTag::UNNECESSARY, which VS Code and similar clients render as faded/dimmed rather than underlined. This tag is orthogonal to severity — it rides alongside whatever severity the code is published at (including the Warning default these two carry today), not another tier like Info/Hint above.

  • The wasm editor session (@brink-lang/web’s EditorSessionHandle) has no filesystem of its own — but it is inherently virtual, so it discovers brink.toml the same way brink compile/brink ide do: by walking its own document tree, not a real filesystem (issue #1414). Serve brink.toml as an ordinary document — updateFile("brink.toml", text), at the entry’s directory or any ancestor of it — and call discoverProjectConfig(entry). [project] entry is honored only here (issue #2331, ruled 2026-08-07): a valid, resolvable entry supersedes the argument ProjectSession/mountStudio were constructed with — see “Precedence” above. EditorSessionHandle.getConfiguredEntry() returns the discovered value (null if unset or unresolved); ProjectSession is the layer that validates it against the session’s actual file set and applies it to getEntryFile()/compileProject(). Every other mount in this section — brink compile, brink ide, brink-lsp, bevy-brink — parses entry as a recognized key (no “unknown key” warning) but does not act on it; it is inert there today. It applies [project] dialect/types and [lints]/deny-warnings (issue #1366) — diagnostic severity rendered through this surface now reflects the file the same way brink compile, brink ide, and brink-lsp already did. Because this session is long-lived, a repeated applyProjectConfig/discoverProjectConfig call fully re-resolves [lints] from the file each time rather than merging onto the previous result (issue #1397) — a code or deny-warnings present in an earlier brink.toml but absent from the current one reverts to its default severity:

    import { EditorSessionHandle } from "@brink-lang/web";
    
    const handle = new EditorSessionHandle();
    const toml = await readProjectFile("brink.toml"); // your own host API
    if (toml !== null) {
      handle.updateFile("brink.toml", toml);
    }
    handle.updateFile("story.ink", await readProjectFile("story.ink"));
    const warnings = handle.discoverProjectConfig("story.ink");
    for (const w of warnings) console.warn(w);
    

    Call discoverProjectConfig once, after the project’s files are loaded and before any explicit setLanguageDialect/setTypePolicy call — a field the session already has an explicit value for is left untouched, so a later explicit call always wins over an earlier discoverProjectConfig, matching the CLI’s flag precedence. Returns [] (never throws) when no brink.toml is found anywhere from the entry’s directory up to the tree root.

    entry must use the same root-relative spelling (no leading /) as every document path given to updateFile/updateSource — the walk-up matches keys by exact string equality. Mixing a /-prefixed path with unprefixed ones is a silent no-op: discovery finds nothing and discoverProjectConfig returns [] exactly as if no brink.toml existed, with no warning.

    If your embedder reads brink.toml’s text with its own host file API (Node fs, the browser File System Access API, a bundler import, …) and would rather hand that text in directly than load it as a document, use applyProjectConfig(toml) instead — the same application/precedence rules apply, just without the discovery step.

    An embedder that wants to set [lints]/deny-warnings policy programmatically — without shipping a brink.toml at all, or to override one it doesn’t control — calls setLintOverrides(json) (issue #1417): a JSON object { "<CODE>": "deny" | "warn" | "allow" | "info" | "hint" } (the last two added by issue #1162) that replaces the session’s explicit override map ("{}" clears it), plus setDenyWarningsOverride(bool)/clearDenyWarningsOverride() for the blanket flag. Both always win over the same code in an applied brink.toml’s [lints] table, in either call order — a later applyProjectConfig/discoverProjectConfig re-applies the explicit overrides on top of whatever it just resolved from the file, so a brink.toml reload can never silently drop a previously-set override. Returns the unrecognized-level/unrecognized-code warnings as JSON (a string[]), the same channel applyProjectConfig uses.

Driving the compiler as a library

AnalysisOptions itself has no notion of a config file — it’s the plain input every mount eventually builds. If you’re driving brink-compiler directly (not through the CLI), read and apply brink.toml with brink-project-config:

#![allow(unused)]
fn main() {
extern crate brink_compiler;
extern crate brink_project_config;
use std::path::Path;
use brink_compiler::{AnalysisOptions, compile_path_with_options};

let entry = Path::new("story.ink");
let mut options = AnalysisOptions::default();
let (loaded, discovery_warnings) = brink_project_config::load_from_entry(entry)?;
// A `brink.toml` the bounded discovery walk stepped over (a workspace/git
// boundary, or the ancestor-depth cap for a VCS-less tree) is reported here
// rather than silently ignored — never applied, but worth telling the
// author about (issue #1435).
for warning in &discovery_warnings {
    eprintln!("{warning}");
}
if let Some(loaded) = loaded {
    for warning in &loaded.warnings {
        eprintln!("{warning}");
    }
    // `false, false`: no explicit override in this example — an embedder
    // with its own flags would pass `true` for any field it's setting itself.
    options.apply_project_config(&loaded.config, false, false);
}
let output = compile_path_with_options(entry, options)?;
Ok::<(), Box<dyn std::error::Error>>(())
}

dialect/types remain mount-time-only: never embedded in .inkb, never delivered to the runtime, exactly as before brink.toml existed (see Enabling the Dialect).

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 (.inkb, .inkt)
playPlay an ink story interactively or in batch mode
debugStep through a story: breakpoints, stepping, locals, call stack
ideScriptable IDE queries & refactors (navigation, references, rename, structural refactors)
fixApply automatic fixes to a project’s diagnostics to a fixpoint
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>] [--dialect <strict-ink|brink>] [--types <gradual|strict>] [-D <CODE>]... [--warn <CODE>]... [--allow <CODE>]... [--debug-info]

Options

FlagDefaultDescription
--output <FILE> / -ostdoutOutput file path. Format inferred from extension.
--dialect <DIALECT>strict-ink (or a discovered brink.toml)strict-ink rejects brink-dialect extension syntax (~ { … } blocks, #[…]/#{…} literals, indexing) with a targeted diagnostic; brink accepts it. Mount-time only — never embedded in the compiled output.
--types <POLICY>gradual (or a discovered brink.toml)gradual is today’s behavior; strict requires --dialect brink and makes Unknown/Conflicted-escaping inference a compile error. Mount-time only.
--deny <CODE> / -D <CODE>— (repeatable)Promote diagnostic CODE to a hard compile error. Only codes whose default severity is not Error are overridable — see Lint severity. The special code warnings (-D warnings, mirroring rustc) is deny-warnings: promote every otherwise-Warning diagnostic to Error.
--warn <CODE>— (repeatable)Force CODE to Warning, still promotable by -D warnings/a project’s deny-warnings.
--allow <CODE>— (repeatable)Force CODE to stay Warning even under -D warnings/deny-warnings.
--debug-infooffEmit the DebugInfo section (.inkb tag 0x11) mapping bytecode offsets to source ranges — a dev/studio-compile debug flag (docs/debugger-spec.md §1.2/§2). Off by default: a release compile omits the section entirely and the output stays byte-identical to a pre-D6 compile. Mount-time only — no brink.toml spelling.

--dialect/--types/--deny/--warn/--allow/-D warnings are the highest-priority source: any of these, when actually passed, wins over a project’s brink.toml, which in turn wins over the plain defaults above. See Project Settings for the file’s discovery rule and precedence.

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

# Fail the compile if E014 (an ordinarily-Warning code) fires
brink compile story.ink -D E014

# Fail the compile on ANY diagnostic that would otherwise be a warning
brink compile story.ink -D warnings

# ...but keep E063 as a warning even under -D warnings
brink compile story.ink -D warnings --allow E063

brink convert

Convert a compiled story between brink’s own formats — binary (.inkb) and textual disassembly (.inkt). It also accepts raw .ink source, which is compiled in-memory first (equivalent to brink compile).

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
.inkink sourceCompiled in-memory via the native pipeline (input only)
.inkbBinary bytecodebrink’s native binary format
.inktTextual bytecodeHuman-readable disassembly

Examples

# Disassemble binary to readable bytecode (stdout)
brink convert story.inkb

# Round-trip textual bytecode back to binary
brink convert story.inkt -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 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.

brink debug

Step through a story: breakpoints, stepping, locals, and the call stack, from the terminal.

brink debug [--script <FILE>] <FILE>

Accepts raw .ink or .brink source, or a compiled story (.inkb, .inkt). Both source surfaces are debuggable.

Source entries are compiled with debug info automatically — you do not pass brink compile’s --debug-info flag here. Without that section there is nothing to map a bytecode position back to a line, so breakpoints could not bind and stepping could not tell when it had crossed a line; a debugger that offered to run without it would only be offering a debugger that does not work.

A prebuilt .inkb/.inkt is taken as it is, since whether it carries the section was decided when it was built. One built without --debug-info still runs, it just cannot say where it is: breakpoints refuse to bind (and say why), and stepping reports <no source position>.

Verbs

VerbMeaning
break <file>:<line>Arm a breakpoint. Lines are 1-based, as your editor shows them.
run, continueAdvance until a breakpoint, a choice, or the story ends.
step into|over|outAdvance one source line. next is step over.
stepi into|over|outAdvance one VM instruction.
localsNamed locals in the innermost frame.
stackThe call stack, innermost first.
list, lSource around the current line, with the stopped line marked. (interactive only)
help, ? / quit, q(interactive only)

The two granularities are deliberate: step is what an author wants, stepi is what you want when you are reading the compiled .inkt beside the source and need to see a single line’s worth of bytecode go by.

step out in the outermost frame reports nostepouttarget and stays where it is: there is no caller to return to, so the honest answer is to refuse rather than to run somewhere and call it a return.

A breakpoint that cannot bind is an error, not a silent no-op: a breakpoint you believe is armed and that can never hit is worse than no breakpoint at all.

Interactive

$ brink debug story.ink
brink debug — `help` for verbs, `quit` to leave
(brink) break story.ink:7
(brink) run -> breakpoint story.ink:7
  at story.ink:7
(brink) list
      4
      5 === start ===
      6 ~ temp who = greet("vendor")
->    7 ~ temp n = 2
      8 Hello {who}, {n}.
      9 -> END
     10
(brink) step over -> step
  at story.ink:8
(brink) locals
  who = "hi vendor"
  n = 2
(brink) quit

Scripted

--script runs a .dbg file instead of prompting, and prints the transcript. # starts a comment; blank lines are skipped.

# session.dbg
break story.ink:7
run
expect-line 7
step over
locals
expect-local who = "hi vendor"
brink debug story.ink --script session.dbg

The expect-* verbs — expect-line, expect-local, expect-stack, expect-terminal — are assertions: a violated one fails the process, so a .dbg script is usable as a CI test. An unknown verb is an error rather than a skipped line, so a typo can never quietly turn an assertion off.

This is the same script format, and the same verb implementations, that the compiler’s own debug-session goldens run. There is one definition of “step over” behind the terminal, the studio, and the test harness rather than three that can drift apart.

brink ide

brink ide exposes the same query-and-refactor engine the language server and Studio use — navigation, outlines, hover, diagnostics, renames, and structural refactors — as scriptable, one-shot commands. There is no server: each invocation discovers the project from an entry .ink file (following INCLUDEs, exactly like brink compile), answers one question or applies one change, and exits.

This makes it the tool of choice for scripts and coding agents: “is this variable referenced?”, “where is this knot defined?”, “rename this symbol across the project and give me a patch”, “list the dead code”, “what’s the story flow graph” — each is a single command with a machine-readable --format json mode and a stable exit-code contract.

brink ide --help
brink ide <COMMAND> --help    # per-command help with examples

Anatomy of a command

brink ide <command> [TARGET] --entry <FILE> [--format text|json] [--deny/--warn/--allow CODE] [options]
  • --entry <FILE> / -e — required on every command. The project’s entry point; its INCLUDEs are followed to build the whole project. (Identical discovery to brink compile.) brink ide has no --dialect/--types flags of its own — it discovers a brink.toml starting from the entry file’s directory and analyzes under whatever [project] dialect/types it declares (or the plain strict-ink/gradual defaults, unchanged, if none exists).
  • TARGET — what the command operates on: a qualified symbol name, or a cursor position via --at (see Addressing). Read queries that operate on a whole file take --file instead; cursor-only commands (signature, actions, refactor convert-line) take --at.
  • --format text|json — output mode (default text). See Output & exit codes.
  • -D/--deny, --warn, --allow <CODE> (repeatable; issue #1417, mirroring brink compile) — CLI overrides for a diagnostic code’s severity, on every command. The special code warnings (-D warnings) promotes every Warning-severity diagnostic to Error (the brink.toml deny-warnings = true equivalent). Always wins over the same code in a discovered brink.toml’s [lints] table — see Lint severity and Precedence.

Addressing

Most commands address a symbol. You can name it, or point at it.

By qualified name

Names use the same dotted paths ink itself uses:

FormResolves to
introa knot, or a top-level VAR/CONST/LIST/EXTERNAL named intro
intro.evidencethe stitch evidence in knot intro
Colors.Redthe list item Red of list Colors
intro.evidence.found / intro.foundthe label found (ink’s knot.stitch.label / knot.label path)
damage(weapon)the parameter weapon of knot/function damage

When a bare name matches more than one kind, the command errors and asks you to disambiguate with --kind:

--kind valueSymbol kind
knot, stitch, variable, constant, list, list-item, external, label, param, tempthe corresponding declaration

By cursor — --at FILE:LINE:COL

--at takes a 1-based line and column (the file may contain :; the two numeric fields are split off the right). It resolves to the symbol under that position — and if the position is a use, it resolves through to the definition. This is the editor-integration / disambiguation fallback.

brink ide def intro -e main.ink                 # by name
brink ide def --at main.ink:7:5 -e main.ink     # by cursor

Output & exit codes

  • --format text (default) — concise, human-readable. Locations render as path:line:col.
  • --format json — a stable shape for jq and programmatic use (see JSON output & stability). Locations become { "path", "line", "col", "byte_start", "byte_end" }.

Exit codes are a contract — they compose in CI and agent loops:

CodeMeaning
0success / query true
1query false, lint hit, diagnostics present, or a mutation refused by the safety gate
2usage error (unknown symbol, bad --at, ambiguous name, file not in project)

Examples of the 1 contract: references --exists exits 1 if the symbol is not referenced; unused exits 1 if it finds any dead symbols; check exits 1 if the project has any error.


Read queries

def — where a symbol is defined

brink ide def intro -e main.ink
brink ide def intro.evidence -e main.ink --format json
brink ide def --at main.ink:7:5 -e main.ink

JSON: { "name", "kind", "location" }.

references — every use across the project

brink ide references gold -e main.ink
brink ide references gold --include-decl -e main.ink     # also count the declaration
brink ide references intro --exists -e main.ink          # exit 0 if used, 1 if not
brink ide references gold --count -e main.ink            # print just the number
FlagEffect
--include-declinclude the declaration site in the results
--existsprint nothing; exit 0 if referenced, 1 if not
--countprint only the number of references

JSON: { "name", "kind", "count", "references": [location, …] }.

brink ide symbols -e main.ink                       # outline of the entry file
brink ide symbols --file scenes/intro.ink -e main.ink
brink ide symbols --search gold -e main.ink         # project-wide name search (flat)
brink ide symbols --kind knot -e main.ink           # filter the search by kind

Without --search, prints the file’s hierarchical outline (knots with nested stitches; globals at the top). With --search, prints a flat project-wide list filtered by substring (and optionally --kind).

JSON: an array of { "name", "kind", "location", "detail"?, "children"? } (detail and children are omitted when empty).

unused — declared but never referenced

The scriptable inverse of references --exists: lists every declared symbol with no references (dead knots, unused vars/lists/externals). Exits 1 if any are found.

brink ide unused -e main.ink
brink ide unused --kind variable -e main.ink

Note: this is reference-based, not reachability-based. A knot reached implicitly by fall-through (no ->) can appear here.

check — project diagnostics

Reports all diagnostics (errors and warnings) with their E-codes. Exits 1 if there is any error (warnings alone still exit 0).

brink ide check -e main.ink
brink ide check -e main.ink --format json

# Promote an ordinarily-Warning code to a hard error (issue #1417)
brink ide check -e main.ink --deny E014

# Fail on ANY diagnostic that would otherwise be a warning
brink ide check -e main.ink -D warnings

JSON: an array of { "severity", "code", "message", "location" }. severity is one of error | warning | info | hint — a [lints] code down-leveled per project-config.md#lint-severity renders at its advisory tier at this surface.

hover — kind, signature, and docs

brink ide hover gold -e main.ink
brink ide hover --at main.ink:5:5 -e main.ink

JSON: { "content" (markdown), "location" }.

signature — the call at a cursor

Position-only (you are mid-call): pass --at inside the call’s parentheses.

brink ide signature --at main.ink:9:10 -e main.ink

JSON: { "label", "documentation", "parameters": [string, …], "activeParameter" }.

graph — the story flow graph

Knots/stitches as nodes; diverts, choices, tunnels, and threads as edges.

brink ide graph -e main.ink
brink ide graph -e main.ink --format json
brink ide graph --dot -e main.ink | dot -Tsvg -o story.svg

--dot emits Graphviz DOT. JSON: { "nodes": [{ "id", "name", "kind", "parent" }], "edges": [{ "from", "to", "kind" }] }. Node kinds: knot, stitch, end, done. Edge kinds: divert, choice, tunnel, thread.

lines — per-line structural classification

brink ide lines -e main.ink
brink ide lines --file scenes/intro.ink -e main.ink --format json

JSON: an array of { "line", "element", "depth" } (one per source line).

actions — code actions at a cursor

Lists the refactors applicable at a position (each runnable via the refactor verbs below).

brink ide actions --at main.ink:7:5 -e main.ink
brink ide actions --at main.ink:7:5 -e main.ink --format json

JSON: an array of { "title", "kind" } (kind: quickfix, refactor, source).

effects-diff — how a change moved the inferred effect rows

Every knot/stitch has an inferred effect row — the cells it reads and writes plus the externals it calls (see Effects). effects-diff compares those rows against a baseline and prints a CI-comment-friendly Markdown summary. It is visibility, not a gate: effect rows are inference output, not a checked-in artifact, so there is no lockfile to drift against — this just shows what your edit did.

The baseline is either a git revision of the same project (--rev, read via git show) or a second entry file (--base):

brink ide effects-diff --rev HEAD -e main.ink          # working tree vs HEAD
brink ide effects-diff --rev main -e main.ink          # vs another branch
brink ide effects-diff --base ../old/main.ink -e main.ink
brink ide effects-diff --rev HEAD -e main.ink --exit-code   # exit 1 if rows moved

--exit-code makes the command exit 1 when any row changed (0 otherwise) — for wiring into a CI check. Without it, exit is always 0 on success. JSON: { "changed", "added", "removed", "entries": [{ "def", "change", "base"?, "head"? }] }, where each base/head is { "reads": [name, …], "writes": […], "calls": […], "opaque": bool }.


Mutations

rename, move-file, and every refactor share one model. They compute the edits, then what is done with them is chosen by mutually-exclusive mode flags:

ModeFlagBehavior
preview(default)Print what would change — a unified diff (or rename’s per-edit list) plus any diagnostics the change would introduce. Touches nothing.
patch--patch [FILE]Emit a git apply-able unified diff to stdout, or to FILE. Disk-safe — never writes the target files.
write--writeApply the edits to the project files in place.

The safety gate

A refactor can be structurally legal yet still break the project — a rename that shadows another symbol, a promote that leaves a now-unresolvable reference. Every mutation re-analyzes the edited sources and diffs the diagnostics to find what it would introduce (errors and warnings — a collision surfaces as a warning).

  • preview is never gated: it always prints the edits and the newly-introduced diagnostics, and exits 0. A “show me what would happen, including what would break” view.
  • --patch / --write are safe by default: if the change introduces any new diagnostic, they abort — print the diagnostics, produce nothing, exit 1. Pass --unsafe (alias --force) to proceed anyway.

Pure-text refactors that cannot change resolution (reorder-*, sort-*, format) never trip the gate. move-file treats “the project still analyzes clean after the INCLUDE rewrite” as its safety condition.

In --format json, mutations always include { …, "introducedDiagnostics", "safe": bool }, so a script can decide for itself regardless of exit code.

rename — a symbol and all its references

brink ide rename gold --to coins -e main.ink            # preview the edits
brink ide rename gold --to coins --patch -e main.ink    # git-applyable diff to stdout
brink ide rename gold --to coins --patch out.diff -e main.ink
brink ide rename gold --to coins --write -e main.ink
brink ide rename --at main.ink:5:5 --to newname --write -e main.ink

The new name is the --to flag. Preview JSON: { "edits": [{ "location", "old", "new" }], "introducedDiagnostics", "safe" }.

move-file — relocate a file, rewriting INCLUDEs

Paths are project-relative (as they appear in INCLUDEs). Rewrites both inbound INCLUDEs (other files that pointed at the old path) and the moved file’s own outbound relative INCLUDEs. On --write, missing destination directories are created.

brink ide move-file scenes/intro.ink scenes/act1/intro.ink -e main.ink
brink ide move-file old.ink new.ink --patch -e main.ink
brink ide move-file old.ink new.ink --write -e main.ink

Errors (exit 2) on a missing source or an occupied destination. Preview JSON: { "diff", "files": [path, …], "introducedDiagnostics", "safe" }.

refactor — structural edits

brink ide refactor <operation> [args] -e main.ink [--patch|--write] [--unsafe]
OperationSynopsisWhat it does
sort-knots[--file F]Alphabetize top-level knots (preamble preserved).
sort-stitches<KNOT>Alphabetize a knot’s stitches.
format<KNOT[.STITCH]>Reformat just that knot or stitch.
reorder-knot<KNOT> <up|down>Move a knot up/down (pure text).
reorder-stitch<KNOT.STITCH> <up|down>Move a stitch up/down within its knot.
reorder-knots<A,B,C> [--file F]Reorder all knots to an explicit permutation.
reorder-stitches<KNOT> <A,B,C>Reorder a knot’s stitches to a permutation.
move-stitch<KNOT.STITCH> --to <DEST>Move a stitch into another knot, re-qualifying references.
promote-stitch<KNOT.STITCH>= s=== s ===; references knot.s → bare s.
demote-knot<KNOT> --to <DEST>=== k ==== k under DEST. Rejects if k has stitches.
convert-line--at FILE:L:C <TARGET>Convert a line’s structural type, preserving weave depth.

convert-line targets: narrative, choice, sticky-choice, gather, choice-body.

Structural-op preview JSON: { "diff", "files", "introducedDiagnostics", "safe" } (a no-op reports { "changed": false, … }).

# Alphabetize a file's knots, review the diff, then apply if it looks right.
brink ide refactor sort-knots -e main.ink                 # preview
brink ide refactor sort-knots --write -e main.ink         # apply

# Move a stitch between knots, capturing a patch for review.
brink ide refactor move-stitch intro.evidence --to clues --patch -e main.ink

Same-file references in promote-stitch / demote-knot. These currently do not rewrite references within the same file to the moved symbol, so the promotion/demotion can leave a dangling divert. The safety gate catches this: --write refuses (and preview shows the would-be breakage). Use --unsafe only if you intend to fix the references yourself.


JSON output & stability

Every command supports --format json. The shapes are intended to be stable: fields will be added but not renamed or removed within a major version, so scripts that read known keys keep working. Agents should depend on named keys, not field order or absence.

Locations are always { "path", "line" (1-based), "col" (1-based), "byte_start", "byte_end" }.

CommandJSON shape
def{ name, kind, location }
references{ name, kind, count, references: [location] }
symbols[{ name, kind, location, detail?, children? }]
unused[{ name, kind, location }]
check[{ severity, code, message, location }]
hover{ content, location }
signature{ label, documentation, parameters: [string], activeParameter }
graph{ nodes: [{ id, name, kind, parent }], edges: [{ from, to, kind }] }
lines[{ line, element, depth }]
actions[{ title, kind }]
effects-diff{ changed, added, removed, entries: [{ def, change, base?, head? }] }
rename (preview){ edits: [{ location, old, new }], introducedDiagnostics, safe }
move-file / refactor (preview){ diff, files: [path], introducedDiagnostics, safe }

introducedDiagnostics is an array of { severity, code, message, location }; safe is true when it is empty. severity is one of error | warning | info | hint — a [lints] code down-leveled per project-config.md#lint-severity renders at its advisory tier at this surface.


Recipes for agents

# Is a symbol used anywhere? (exit code, no parsing)
brink ide references my_var --exists -e main.ink && echo "used" || echo "dead"

# Fail CI if there is any dead code.
brink ide unused -e main.ink

# Fail CI if a refactor would break the project (no write, just the gate).
brink ide rename old --to new --write -e main.ink   # exit 1 = unsafe

# Where is this knot defined? (just the path:line:col)
brink ide def my_knot -e main.ink

# Count references with jq.
brink ide references gold -e main.ink --format json | jq .count

# Produce a reviewable patch without touching the tree.
brink ide rename gold --to coins --patch rename.diff -e main.ink
git apply rename.diff

brink fix

brink fix is cargo fix/eslint --fix for brink diagnostics: it applies every fixer the project’s policy admits, re-analyzes, and repeats until the project reaches a fixpoint (or a round cap). It shares its batching engine (brink_ide::fix) with the Studio’s “Fix all safe” and the LSP’s source.fixAll.brink, so the three surfaces never disagree about what a batch does — see docs/autofix-spec.md for the full model (tiers, batching algorithm, policy).

brink fix <PATH> [--dry-run] [--diff [FILE]] [--suggested [CODES]] \
                  [--placeholder] [--code CODES] [--max-rounds N]

PATH is an entry file — an .ink or .brink source — addressed exactly like brink compile: a brink.toml is discovered from its directory and INCLUDEs (or the native module graph) are followed to build the whole project. A bare file with no discovered brink.toml is the same code path, not a separate mode — it just resolves every fixer’s policy to its tier default (see Tiers below).

Tiers

Every fixer declares a tier, and the tier decides whether brink fix may apply it without being told to:

TierMeaningDefault policy
SafeObservably equivalent to the original — batched unconditionallyAlways applied
SuggestedProbably what the author meant, but changes meaning or loses textApplied only when the project (or --suggested) promotes the code
PlaceholderLeaves a hole the author must fill by handNever applied — --placeholder only lists these

A project’s brink.toml [fix] table promotes or withdraws individual codes:

[fix]
E033 = "auto"   # promote a Suggested fixer to batch in this project
E014 = "off"    # never offer this fixer here

Options

FlagDefaultDescription
--dry-runoffPrint the report; write nothing to disk.
--diff [FILE]offEmit a git apply-able unified diff instead of writing — to stdout, or to FILE if given. Implies no disk write, like --dry-run. Composes with --dry-run rather than one silently overriding the other: the diff still goes to its destination, and the report is printed to stderr (never stdout, which must stay a clean patch). A capped run (exit 1) always prints the report to stderr too, with or without --dry-run, so the exit code is never unexplained.
--suggested [CODES]offPromote the Suggested tier to batchable for this run only. Bare, it promotes every Suggested-tier fixer except one the project’s [fix] table set to "off" (off still means off — a codeless flag isn’t the explicit action that widens it); --suggested E025,E080 names codes explicitly and so wins over [fix] for those, even over an "off" entry (CLI beats file, like -D/--warn/--allow beat [lints]).
--code CODESevery codeRestrict the run to these diagnostic codes (comma-separated, e.g. E025,E080). An unrecognized code is a hard error, not a silent no-op.
--placeholderoffAlso report every Placeholder-tier fix available, on stderr — never applied, since a Placeholder fix always leaves a hole. Written to stderr (not stdout) so it never lands inside a --diff patch piped to git apply. Useful with --dry-run to see where an author still needs to fill something in.
--max-rounds N5Round cap for the fixpoint loop. A fixer that never discharges its own diagnostic surfaces as a cap breach naming it, rather than looping forever.

With none of --dry-run/--diff given, brink fix writes every file the batch actually changed and prints a short report.

Exit codes

CodeMeaning
0The fixpoint was reached — every admitted fix converged within the round cap.
1The round cap was hit, or a fixer failed to discharge its own diagnostic (the report names it either way).
2Usage error (a bad path, an unrecognized --code/--suggested code, an I/O failure).

Examples

# Apply every Safe fix (and anything the project's [fix] table promotes),
# write the files.
brink fix story.ink

# See what would change, without writing anything.
brink fix story.ink --dry-run

# Get a patch instead of a write — pipe straight into `git apply`.
brink fix story.ink --diff | git apply

# Preview the patch AND see the report, without piping into git apply —
# --diff composes with --dry-run: stdout stays a clean patch, the report
# (fix count, and why a capped run exited 1) goes to stderr.
brink fix story.ink --diff --dry-run

# Promote every Suggested fixer for one run, without editing brink.toml.
brink fix story.ink --suggested

# Promote just E025 (missing-import) for one run.
brink fix story.ink --suggested E025

# Restrict the batch to one code, e.g. while triaging a specific diagnostic.
brink fix story.ink --code E025

# See where an author still has to fill in a required attribute by hand.
brink fix story.ink --dry-run --placeholder

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 holds an Arc<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.

#![allow(unused)]
fn main() {
extern crate brink_format;
extern crate brink_runtime;
use brink_format::StoryData;
use brink_runtime::{RuntimeError, Story};
fn demo(story_data: StoryData) -> Result<(), RuntimeError> {
use std::sync::Arc;

let (program, line_tables) = brink_runtime::link(&story_data)?;
let mut story: Story = Story::new(Arc::new(program), line_tables);
let _ = &mut story;
Ok(())
}
}

Story owns a refcount, not a borrow, so it carries no lifetime — it can be moved into a thread, stored in a struct, or held in an ECS component without threading a 'p parameter through your types. To fan out playthroughs, clone the Arc (cheap) and give each Story its own line tables:

#![allow(unused)]
fn main() {
extern crate brink_format;
extern crate brink_runtime;
use std::sync::Arc;
use brink_format::LineEntry;
use brink_runtime::{Program, Story};
fn demo(program: Program, line_tables: Vec<Vec<LineEntry>>) {
let program = Arc::new(program);
let mut a: Story = Story::new(Arc::clone(&program), line_tables.clone());
let mut b: Story = Story::new(Arc::clone(&program), line_tables);
let _ = (&mut a, &mut b);
}
}

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 Step. The loop, the Step 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.
  5. Sessions & Replay — journal a playthrough for a save file, deterministic replay, and state snapshots/diffs.
  6. Speculation — run the story forward from its current state without committing to it, then discard the run.

A minimal driver looks like this — see the execution-model page for what each arm means:

#![allow(unused)]
fn main() {
extern crate brink_runtime;
use brink_runtime::{Step, RuntimeError, Story};
fn demo(story: &mut Story) -> Result<(), RuntimeError> {
loop {
    match story.continue_single()? {
        Step::Line(line) => print!("{}", line.text),
        Step::Done => {}
        Step::Choices(choices) => {
            story.choose(/* player's pick */ choices[0].index)?;
        }
        Step::End => break,
        // Reserved for flow suspension; not yet emitted.
        Step::Suspended => break,
    }
}
Ok(())
}
}

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.

#![allow(unused)]
fn main() {
extern crate brink_compiler;
fn demo() -> Result<(), Box<dyn std::error::Error>> {
use std::path::Path;
let output = brink_compiler::compile_path(Path::new("story.ink"))?;
let story_data = output.data;
let _ = story_data;
Ok(())
}
}

From .inkb bytes (pre-compiled binary):

#![allow(unused)]
fn main() {
extern crate brink_format;
fn demo() -> Result<(), Box<dyn std::error::Error>> {
let bytes = std::fs::read("story.inkb")?;
let story_data = brink_format::read_inkb(&bytes)?;
let _ = story_data;
Ok(())
}
}

Linking

#![allow(unused)]
fn main() {
extern crate brink_format;
extern crate brink_runtime;
use brink_format::StoryData;
fn demo(story_data: StoryData) -> Result<(), Box<dyn std::error::Error>> {
let (program, line_tables) = brink_runtime::link(&story_data)?;
let _ = (program, line_tables);
Ok(())
}
}

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

#![allow(unused)]
fn main() {
extern crate brink_format;
extern crate brink_runtime;
use brink_format::{LineEntry, StoryData};
use brink_runtime::Program;
fn demo(program: Program, line_tables: Vec<Vec<LineEntry>>) {
use std::sync::Arc;
use brink_runtime::Story;

let mut story: Story = Story::new(Arc::new(program), line_tables);
let _ = &mut story;
}
}

Story holds an Arc<Program> and owns the line tables it renders with. Because the handle is refcounted rather than borrowed, a Story has no lifetime parameter and can be moved or stored freely. You can create multiple stories from the same program — clone the Arc — 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:

#![allow(unused)]
fn main() {
extern crate brink_format;
extern crate brink_runtime;
use brink_format::Value;
use brink_runtime::{ExternalFnHandler, ExternalResult};

struct Dice;

impl ExternalFnHandler for Dice {
    fn call(&self, name: &str, args: &[Value]) -> ExternalResult {
        match name {
            "roll" => ExternalResult::Resolved(Value::Int(4)),
            // Unknown name — let the story's own fallback body answer.
            _ => ExternalResult::Fallback,
        }
    }
}
}

ExternalResult has three variants:

#![allow(unused)]
fn main() {
extern crate brink_format;
use brink_format::Value;
#[allow(dead_code)]
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, which take &dyn ExternalFnHandler:

#![allow(unused)]
fn main() {
extern crate brink_format;
extern crate brink_runtime;
use brink_runtime::{FallbackHandler, RuntimeError, Story};
fn demo(story: &mut Story) -> Result<(), RuntimeError> {
let handler = FallbackHandler;
let lines = story.continue_maximally_with(&handler)?;
// or, one line at a time:
let line = story.continue_single_with(&handler)?;
let _ = (lines, line);
Ok(())
}
}

Resolution modes

  • Resolved(value) — the common case. You computed the answer; the VM pushes it and keeps going. Value::Null is valid for fire-and-forget calls.
  • 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, freezing with the call frame intact. Supply the result later with story.resolve_external(value) and resume stepping.

resolve_external returns (), not a Result — resolving a value that nothing is waiting for is a no-op, so there is nothing to handle:

#![allow(unused)]
fn main() {
extern crate brink_format;
extern crate brink_runtime;
use brink_format::Value;
use brink_runtime::{FallbackHandler, RuntimeError, Story};
fn demo(story: &mut Story) -> Result<(), RuntimeError> {
let handler = FallbackHandler;
// The handler returned `Pending` somewhere inside this step, so the story
// is now parked on the deferred call.
let _line = story.continue_single_with(&handler)?;

// Later, once you have the answer:
story.resolve_external(Value::Int(42));
let _line = story.continue_single_with(&handler)?;
Ok(())
}
}

While a flow is parked on an unresolved external, jumping elsewhere with choose_path_string fails with JumpWhileAwaitingExternal — a pending host call can’t be silently abandoned.

If you have no externals to provide, pass &brink_runtime::FallbackHandler and every call uses its ink-side fallback.

Orchestration layers that need to surface a deferred external rather than block on it should drive FlowInstance::advance(), which returns StepOutcome::AwaitingExternal instead of erroring. See Runtime API.

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.

#![allow(unused)]
fn main() {
extern crate brink_format;
extern crate brink_runtime;
use brink_format::DefinitionId;
use brink_runtime::{RuntimeError, Story};
fn demo(story: &mut Story, entry_point: DefinitionId, index: usize) -> Result<(), RuntimeError> {
story.spawn_flow("background", entry_point)?;              // 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
let _ = lines;
Ok(())
}
}

spawn_flow takes a DefinitionId — the compiled address of a knot or stitch, not a name. 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.

Sessions & Replay

A Story runs a story; a StorySession wraps a Story and remembers how it was run. It journals every input that entered the VM — the start, each choice, each external result, each host mutation — as durable, serializable data. From that journal you get three things the bare Story can’t offer: a save file, deterministic replay, and the ability to detect when a story edit has invalidated an old save.

The journaling lives at the session boundary, not in the VM. The step loop never learns it’s being recorded — the session observes inputs at the same seam the VM receives them, so this composes over Story rather than threading an if recording branch through the hot path.

Creating and driving a session

Wrap a Story, optionally with a seed for reproducible RNG, and drive it with the same verbs — the session records as it goes:

#![allow(unused)]
fn main() {
extern crate brink_runtime;
use brink_runtime::{Step, RuntimeError, Story, StorySession};
fn demo(story: Story) -> Result<(), RuntimeError> {
let mut session = StorySession::new(story, Some(42));

loop {
    match session.continue_single()? {
        Step::Choices(choices) => {
            session.choose(choices[0].index)?;   // journaled
        }
        Step::End => break,
        _ => {}                                  // Line / Done — keep going
    }
}
Ok(())
}
}

continue_single, continue_to_pause, choose, advance, and resolve_external all mirror their Story counterparts and journal their inputs. story() / story_mut() expose the wrapped Story as a deliberate escape hatch — anything done through them bypasses the journal.

Host mutations — set_var, go_to_path, load_state — are turn-boundary only. Called mid-turn (while more content is pending) they return SessionError::MutationMidTurn rather than being silently queued, which keeps the journal’s event order unambiguous. Drain the current turn to a Done/Choices/End before mutating.

The journal as a save file

The journal is the durable save artifact. It serializes to JSON via serde — values are tagged, so a List or divert survives the round-trip without a lossy collapse to null.

#![allow(unused)]
fn main() {
extern crate brink_runtime;
use brink_runtime::StorySession;
fn demo(session: &mut StorySession) {
let journal = session.export_journal();   // serde-serializable; write it to disk
let _ = journal;
}
}

To load, hand a fresh Story and the journal back to StorySession::restore. It fast-paths through an embedded checkpoint when the program is unchanged, and falls back to full replay otherwise. The journal is capped (SESSION_JOURNAL_CAP) so a pathologically long session degrades honestly — past the cap, appends drop and restore leans on the checkpoint — rather than growing without bound.

Replay and divergence

StorySession::replay re-runs a journal against a Story from a fresh start, consuming the recorded inputs event by event. Its ReplayOutcome is where the “did my edit break this save?” answer lives:

  • Replayed — the whole prefix applied cleanly (with soft warnings like ChoiceLabelDrift, when a choice still replays by index but its text has changed).
  • Diverged { at_event, expected, found } — a recorded event no longer applies to the current program: a choice index that’s now out of range, a path that no longer resolves. The journal is truncated at that point and the session parks at the position it reached.
  • Failed — replay stopped for a non-divergence reason.

ExternalReplayMode picks whether externals are served from the journal (Recorded) or called live (Live) during replay. Live replay that hits a deferred external parks, retaining the un-replayed tail; resolve it and resume with continue_replay.

Snapshots and diffs

For inspecting state rather than replaying inputs, a session takes a typed StateSnapshot — globals with their real Values (list membership included), visit counts and turn counts by resolved path, a call-stack summary, and status. diff compares two of them:

#![allow(unused)]
fn main() {
extern crate brink_runtime;
use brink_runtime::{diff, RuntimeError, StorySession};
fn demo(session: &mut StorySession) -> Result<(), RuntimeError> {
let before = session.snapshot();
session.continue_single()?;
let after = session.snapshot();

let delta = diff(&before, &after);   // a is "before", b is "after"
if !delta.is_empty() {
    // delta.changed_globals: name -> (before, after)
    // delta.list_deltas, delta.pushed_frames, delta.popped_frames, …
}
Ok(())
}
}

StateSnapshot is a typed serialization path — its globals keep their Values, so it round-trips losslessly and a diff can report (before, after) pairs. It has one known projection limit: visit/turn counts for anonymous counted containers (gathers, choice points with no author path) are omitted from the path-keyed view; the full id-keyed counts remain in save_state.

Live inspection

For a “state view” UI — a running debugger panel showing where the story is right now — a Story gives you a DebugSnapshot directly, no session required:

#![allow(unused)]
fn main() {
extern crate brink_runtime;
use brink_runtime::Story;
fn demo(story: &Story) {
let snap = story.debug_snapshot();
// snap.current_location, snap.position, snap.globals, snap.call_stack,
// snap.visit_counts, snap.pending_choices, snap.rng
let _ = snap;
}
}

DebugSnapshot is deliberately not StateSnapshot. It’s a read-only, name-resolved view for display: values are formatted to strings, frames and visit counts resolve to author-facing knot/stitch paths, and the whole thing is built on demand off any hot path. Use DebugSnapshot to show current state to a developer; use StateSnapshot + diff to serialize and compare state programmatically. This is the surface the Studio’s state view is built on — see Studio.

Sessions vs. speculation

A session records and reproduces the real playthrough — the moves that actually happened, replayable and diffable. Speculation runs a throwaway branch off the present and discards it. Use a session to save, replay, and inspect what happened; use a speculation to preview what would happen without it happening.

Speculation

A speculation runs the story forward from its current state without committing to it — “what lines would this choice produce?”, “what does this function return right now?” — and then throws the run away. The live story is never touched.

This is the runtime primitive under a live inspector’s watch expressions, an editor’s scratch evaluation, and any “preview this branch” UI. It’s built directly on the sandbox mode from The State Model: a Speculation is a Mode::Sandbox fork of the current state. It reads live values, but every write it makes is diverted into a private throwaway layer and discarded when the Speculation drops. Discarding is the entire cleanup — a drop, nothing to roll back.

Starting one

For the common case — speculate from a Story’s main flow — call speculate(). It takes &self, not &mut self: a speculation clones a snapshot of current state, so starting one can’t disturb the story you started it from.

#![allow(unused)]
fn main() {
extern crate brink_format;
extern crate brink_runtime;
use brink_format::Value;
use brink_runtime::{Budget, FallbackHandler, RuntimeError, SpeculationStep, Story};
fn demo(story: &Story) -> Result<(), RuntimeError> {
let mut spec = story.speculate();

// Drive it with the same verbs you'd drive a story with — advance, choose,
// go_to_path, eval_function — then let it drop.
let handler = FallbackHandler;
loop {
    match spec.advance(Budget::default(), &handler)? {
        SpeculationStep::Step(step) if step.is_terminal() => break,
        SpeculationStep::Step(_) => {}          // a produced line; keep going
        SpeculationStep::AwaitingExternal => {
            // A deferred external — resolve it and advance again.
            spec.resolve_external(Value::Null);
        }
    }
}
Ok(())
}
}

advance returns a SpeculationStep — either a Step (including a terminal Done/Choices/End) or AwaitingExternal, the same pause-and-resume shape the external functions chapter describes. go_to_path, choose, and eval_function mirror their Story/FlowInstance counterparts, but act only on the sandboxed copy.

For orchestration layers juggling many flows over distinct worlds (bevy-brink), Speculation::fork_from(program, &world, &local, &flow, &line_tables) is the flow-level constructor speculate() wraps.

Budgets

A production story runs under generous hardcoded ceilings — a million VM steps, ten thousand lines a turn. A speculative probe should fail fast on possibly-malformed or adversarial content instead of burning the full production budget before giving up, so every advance, eval_function, and resume_function_eval call takes an explicit Budget:

#![allow(unused)]
fn main() {
extern crate brink_runtime;
use brink_runtime::Budget;

let budget = Budget { steps: 10_000, lines: 50 };
let _ = budget;
}

steps caps a single call’s inner VM loop — advance, eval_function, and resume_function_eval each get their own fresh allowance; lines caps the total lines the speculation may ever produce across all its advance calls. The Default (100,000 steps, 1,000 lines) sits well under the production ceilings. Exhausting either is an Err (StepLimitExceeded / LineLimitExceeded), not a SpeculationStep variant.

Externals inside a speculation

Diverting writes is only half of side-effect safety. If the speculated content calls play_sound() or deal_damage(), sandboxing its state won’t stop the sound or the damage — those effects live in your engine, on the far side of an EXTERNAL. A speculation resolves externals through whatever handler you pass to advance, so the gating happens there.

KindTieredHandler is a composable handler that wraps your real bindings and tiers each external by a PolicyKind:

  • PolicyKind::Query — read-only, no side effects. Always delegated live, so a watch expression can call enemy_count() and see the true answer.
  • PolicyKind::Effect — state-changing. Allowed through only when the evaluation regime is EvalContext::Eval and effects are explicitly armed; otherwise it resolves to the ink fallback body. A name you don’t classify is treated as Effect — conservative by default.
#![allow(unused)]
fn main() {
extern crate brink_runtime;
use std::collections::HashMap;
use brink_runtime::{EvalContext, FallbackHandler, KindTieredHandler, PolicyKind};

let bindings = FallbackHandler; // your real &dyn ExternalFnHandler
let kinds = HashMap::from([
    ("enemy_count".to_string(), PolicyKind::Query),
    ("play_sound".to_string(), PolicyKind::Effect),
]);

// Watch regime: queries run live, effects never fire.
let handler = KindTieredHandler::new(&bindings, kinds, EvalContext::Watch, false);
// pass `&handler` to spec.advance(...)
let _ = handler.report();
}

The runtime stays manifest-blindPolicyKind is plain data. The consumer maps its own external classification (the analyzer’s ExternalKind, a host capability manifest, whatever it has) onto the two-way split and hands over the name → PolicyKind table. EvalContext::Watch is the conservative regime where no effect ever fires; EvalContext::Eval with live_effects armed is the deliberate two-key gate for the rarer case where an engine→ink evaluation is permitted to actually do something. handler.report() returns which externals ran live versus fell back, for diagnostics.

Speculation vs. sessions

Speculation and sessions both let you run a story non-destructively, but for different purposes. A speculation is a throwaway branch off the present — evaluate, observe, discard, live state untouched. A session records the real playthrough so you can snapshot it, diff two points, and replay it deterministically. Reach for speculation to preview; reach for a session to inspect and reproduce what actually happened.

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 — those files are kept only for oracle regeneration.

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, regenerate-xliff, and migrate-xliff, and brink-intl exposes the same operations as a library (generate_locale, compile_locale_xliff, regenerate_locale, migrate_unit_ids).

<unit id> is keyed on the scope’s DefinitionId (e.g. 0x0100000000000001:0), not its display name — this is a canonical, NMTOKEN-safe identifier decoupled from the mutable, non-unique-across-scopes display name, matching the format brink:scope-id and IntlError::InvalidUnitId already documented. Unit ids are not literally stable across renames: a DefinitionId is itself a hash of the scope’s (qualified) name/path, so renaming or moving a knot/stitch assigns it a new DefinitionId and every unit id beneath it changes. The human-readable scope name still rides along as the name attribute on <unit> ({scope_name}:{line_index}) and as the id attribute on the containing <file>, for translator context.

That churn no longer costs you translations, as long as the rename is declared. Annotate it with #@was(old_name) and both regenerate-xliff and compile-locale follow the compiled alias table to rebind the moved scope onto its old translations, instead of treating it as a brand-new scope (or, for compile-locale, failing outright). You do not need to run migrate-xliff after a rename — rebinding is automatic, and migrate-xliff exists only for the one-off migration of .xlf files exported before unit ids moved off display names.

#@was on a knot or stitch records an alias for the whole renamed subtree, not just the declaration itself: every stitch and label re-keyed only because its parent’s name changed (its qualified name contains the parent’s name) gets its own compiled alias entry too, so its translations rebind right alongside the renamed container’s. See the scope-matching rules in docs/intl-spec.md for the full set.

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

Migrating archived .xlf files

.xlf files exported before the scope-id-based unit id scheme landed carry display-name-based unit ids (e.g. intro:0 instead of 0x0100000000000001:0). brink regenerate-xliff already re-keys them for free the next time you recompile (it rebuilds the document from the fresh export and overlays translations by content hash, never by unit id). If you need to re-key an archived .xlf without recompiling — for example to push it back through a TMS that indexes on unit id before your next source change — use migrate-xliff:

brink migrate-xliff story.es.xlf -o story.es.xlf

This only rewrites the id attribute on each <unit>; <source>, <target>, state, and every brink:* extension attribute are left untouched, so no translation is lost. It’s idempotent — running it on a file that’s already on the new scheme is a no-op.

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:

#![allow(unused)]
fn main() {
extern crate brink_format;
use brink_format::{PluralCategory, PluralResolver};

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.
#![allow(unused)]
fn main() {
extern crate brink_intl;
fn demo() -> Result<(), brink_intl::IntlError> {
use brink_intl::IcuPluralResolver;
let resolver = IcuPluralResolver::new("en")?;   // BCP 47 locale tag
// pass `Some(&resolver)` to render_transcript / apply_locale rendering
let _ = resolver;
Ok(())
}
}

The Brink Dialect

Vanilla ink is a weave language: knots, stitches, choices, diverts, gathers, and a thin logic layer (~ lines, conditionals, VAR/temp) for steering that weave. It has no arrays, no maps, no loops, no multi-line logic. Real stories often want those things anyway — tracking an inventory, iterating a quest list, building up a piece of interpolated text — and authors have been faking them with lists and stringly-typed hacks since ink shipped.

The brink dialect is a superset of ink that adds them properly: multi-line logic blocks, array/map literals, indexing, and a small mutating stdlib. It sits entirely inside the existing ~ logic channel — narrative, choices, and diverts are untouched — and it compiles to the same bytecode, run by the same runtime, as everything else.

This chapter covers:

  • Enabling the Dialect — the --dialect flag, why strict-ink is the default, and what changes when you opt in.
  • Logic Blocks~ { … }, the pure-logic fence, and what it deliberately can’t do.
  • Collections#[…]/#{…} sigil literals, and why they only work in expression position.
  • Indexing & Mutationa[i], a[i] = v, and the runtime faults that replace ink’s usual silent tolerance.
  • Standard Librarylen/keys/values/contains and the mutating push/insert/remove/remove_at.
  • Types — gradual vs. strict, inline annotations, structs, and the visit-count idiom that survives strict mode unchanged.
  • Conformance — how the dialect coexists with the oracle-anchored core, and what “authoring-time only” actually means.

The shape of the design

Three decisions run through every page in this chapter, so it’s worth naming them up front:

  1. One grammar, two dialects. brink-syntax always parses the full superset — the dialect extensions included — so the parser, IDE, and formatter never need to know which dialect a project has chosen. Whether a construct is allowed is decided later, during analysis.
  2. The extensions are pure logic. Everything new is data manipulation inside ~ — no new narrative, choice, or flow-control surface. A block computes; it never weaves.
  3. Sigils, not new keywords. Collection literals use #[…]/#{…} precisely because # cannot begin an ordinary ink expression — the new syntax is unambiguous with existing ink wherever it’s legal to write.

The full ruling — including the alternatives that were considered and rejected — lives in docs/t1b-surface-spec.md. This chapter is the author-facing account of the same surface; when in doubt, that spec is the tie-breaker.

Enabling the Dialect

The default is strict-ink

If you don’t ask for anything, brink compile compiles plain ink. Every construct this chapter describes — ~ { … } blocks, #[…]/#{…} literals, postfix indexing, push/insert/remove/remove_at — is a compile error under the default dialect, strict-ink:

brink compile story.ink -o story.inkb

If story.ink contains, say, ~ x = #[1, 2, 3], that command fails and the CLI reports:

ERROR brink: story.ink:6..16 [E051] `#[…]` array literal is a brink extension — this project compiles strict ink (dialect = brink to enable)
ERROR brink: 1 diagnostic(s) prevented compilation

The CLI renders every resolved diagnostic — the source path, its byte range, the [CODE], and the message — with the count still printing as a trailing summary underneath. Driving the compiler as a library and reading CompileError::Diagnostics (see below), or using brink ide / @brink-lang/web, gets you the same resolved set programmatically.

Opt in explicitly with --dialect brink:

brink compile story.ink -o story.inkb --dialect brink

With that flag, the same source compiles, and the extension surface described in the rest of this chapter is available.

Why strict by default

This is a deliberate choice, not an oversight. Two things are true about brink at once:

  • Its correctness is anchored to a C# ink oracle — thousands of golden transcripts generated by the reference inklecate/ink-engine implementation. A story that only uses plain ink constructs can be checked, mechanically, against what real ink does.
  • The dialect extensions have no such oracle. Nothing in the ink ecosystem has ever run a multi-line ~ { … } block or a #[…] array literal, so there is no reference implementation to diff against — only the spec’s own hand-derived expected output (see Conformance).

Defaulting to strict-ink means a project has to make a visible, one-time choice to leave the oracle-anchored subset. You don’t fall out of conformance by accident because you typed # in the wrong place; you fall out of it by passing --dialect brink (or setting the equivalent brink.toml [project] dialect), and that’s exactly the choice this flag exists to make explicit.

The compiler’s own test suite follows the same rule: the entire oracle corpus — every .ink file with a golden C# transcript — compiles under strict-ink. If a dialect extension ever leaked into that corpus, or if strict-ink ever started accepting extension syntax, the CI gate that pins 5,598 passing oracle episodes would fail immediately.

What doesn’t change

The dialect is a compile-time-only setting:

  • One grammar regardless of dialect. brink-syntax always parses the full superset. The dialect only changes whether analysis accepts or rejects the extension constructs it finds — parsing itself never fails on them. This is why the IDE, formatter, and diagnostics don’t need a second grammar mode: they see the same tree either way.
  • Never embedded in the compiled story. Dialect is an AnalysisOptions input, consumed entirely by the compiler pipeline. It has no representation in .inkb, and the runtime has no concept of it — a compiled brink-dialect story and a compiled strict-ink story are indistinguishable bytecode as far as brink-runtime is concerned. Loading and playing back .inkb never depends on which dialect produced it.
  • Per-compile, not per-file. There’s one dialect setting for the whole compilation (entry point plus every INCLUDEd file). You can’t mix strict-ink and brink-dialect files in the same build.

If you’re driving the compiler as a library rather than through the CLI, the equivalent is AnalysisOptions::dialect:

#![allow(unused)]
fn main() {
extern crate brink_compiler;
use std::path::Path;
use brink_compiler::{AnalysisOptions, Dialect, compile_path_with_options};

let options = AnalysisOptions {
    dialect: Dialect::Brink,
    ..AnalysisOptions::default()
};
let output = compile_path_with_options(Path::new("story.ink"), options)?;
Ok::<(), Box<dyn std::error::Error>>(())
}

AnalysisOptions::default() — and therefore plain compile_path / compile — always means Dialect::StrictInk.

Logic Blocks

Plain ink’s logic layer is line-oriented: one ~ line is one statement, and control flow across lines happens by weaving through choices and diverts. The brink dialect adds a second shape: a multi-line logic block, opened by a ~ line whose expression is {.

VAR items = 0
VAR total = 0

~ {
    items = #[10, 20, 30]
    total = items[0] + items[1] + items[2]
}

Total is {total}.
-> END

Inside the braces, statements are newline-terminated and don’t repeat the ~ sigil — you write total = total + item, not ~ total = total + item, on every line.

What a block can do

  • Assignment, including indexed lvalues (grid[y][x] = v — see Indexing & Mutation).

  • temp declarations, block-scoped (see below).

  • if / else if / else, braced:

    ~ {
        temp score = 72
        if score >= 90 {
            label = "A"
        } else if score >= 80 {
            label = "B"
        } else {
            label = "F"
        }
    }
    
  • while cond { … } and for name in expr { … }. for x in arr iterates array values; for k in map iterates map keys, in deterministic insertion order (never hash order — the value model guarantees this). There’s no index/pair destructuring in this slice of the dialect: if you need the index too, keep a counter temp.

  • break / continue, only inside an enclosing while/for — using either outside a loop is a compile error (E057).

  • return / return expr — the only flow-control construct a block is allowed to contain.

  • Expression statements — a function or external call used for its side effect (including the stdlib mutators, Standard Library).

while/for bodies run under the same VM step budget as every other bytecode path, so a runaway loop fails loudly (a step-limit fault) instead of hanging the story.

The pure-logic fence

This is the load-bearing rule of the whole feature: a block computes; it never weaves. Text output of any kind, choices, gathers, diverts (->), tunnels, and threads are all rejected inside ~ { … } — not with a parse error (the grammar accepts the shape), but with a targeted compile error at lowering time. return is the only flow construct a block may contain.

Put differently: no weave concept is allowed to appear in an expression or statement position. This mirrors a hygiene rule that already exists deeper in the compiler, between the “logic” and “narrative” halves of the low-level IR — blocks just extend that same seam up to the surface language.

Why draw the line here, and not let a block -> knot or present a choice? Two reasons:

  • It keeps the seam legible. The moment logic can jump the story around, “what does this block do” stops being a local question — you have to trace where control goes. Ink’s existing weave (choices, gathers, diverts) is already the right tool for that; a block staying pure logic means you never have two competing ways to express the same flow-control idea.
  • Loosening it later is safe; tightening it wouldn’t be. Shipping a narrow, purely-computational block now and adding weave capability to it in a later round is an additive change with no existing programs to break. Shipping the wide version first and discovering it needs to be narrowed would be a breaking change to every story that used the wide surface.

temp scoping and shadowing

A temp declared inside a block is block-scoped: it’s visible for the rest of that block (and any nested if/while/for body within it), and it goes out of scope at the closing }. It may shadow an already-visible outer temp — either a classic non-block ~ temp or a temp from an enclosing block scope — but doing so emits a warning, E054 (block-scoped temp shadows an already-visible temp), not an error. Classic, non-block ~ temp semantics outside blocks are unchanged by any of this.

~ {
    temp x = 1
    if true {
        temp x = 2   // warns: E054, shadows the outer `x`
        x = x + 1
    }
}

Shadowing a loop variable follows the same rule — a for/while body that redeclares the loop’s own name, or an outer visible name, warns rather than fails.

Collections: Arrays, Maps, and Ranges

The Last Light inn keeps its accounts the way it keeps its guests — in order, by name, and without losing anybody:

~ temp tab = #[4, 7, 2, 5]
~ temp rooms = #{"Mira": 3, "Old Tom": 1}
~ temp owed = 0
~ {
    for coins in tab {
        owed = owed + coins
    }
}
The innkeeper runs a finger down the ledger: {len(tab)} nights on the tab, {owed} coins in all.
For the magistrate's copy, in order: {sorted(tab)}.
Mira keeps room {rooms["Mira"]}; Old Tom, room {rooms["Old Tom"]}.
-> END
The innkeeper runs a finger down the ledger: 4 nights on the tab, 18 coins in all.
For the magistrate's copy, in order: [2, 4, 5, 7].
Mira keeps room 3; Old Tom, room 1.

Two collections carry that scene: the tab is an array — a sequence, ordered by position — and the register is a map — a lookup, ordered by when each guest signed in. The third kind this chapter covers, the range, is a span of integers (0..10, 1..=6) held as a value. All three are values in the full sense the Values & Types chapter established: assigning one copies it, passing one to a function copies it, and no mutation ever reaches back through a copy. What that buys you — and what each kind’s contracts are when you read, write, grow, shrink, and sort them — is the subject of this chapter.

Current spelling — examples in this chapter compile in today’s brink dialect: collection literals carry the #[…]/#{…} sigils, type ascriptions spell Array<T>/Map<K, V>, mutating verbs are free calls (push(tab, 5)), and function values are #fn(name) references. The ruled native .brink spellings — bare […] literals, Map { k: v } construction, [T]/[K: V] type notation, method-position calls with auto-ref (tab.push(5)), and |a, b| … lambdas — arrive with the native frontend, and this chapter’s examples will be respelled then.

The three kinds

  • An array answers “what’s at position i?” Elements share one type; order is the author’s, kept exactly.
  • A map answers “what’s filed under k?” Keys are scalars (int, string, bool); entries keep the order they were inserted in.
  • A range answers “the integers from here to there” — 0..n (half-open) or 0..=n (inclusive) — without materializing them. It behaves like a read-only array of its integers: it has a len, indexes, iterates, and compares.

There is a fourth collection-shaped kind in the language — flags, an ordered domain of named symbols with subset-valued variables (ink’s LIST lineage). It is a domain first and a collection second, so it is taught with enums and structs in that chapter of the book’s reorganization, not here.

Writing one down

The dialect’s literal forms are sigils — a leading # marks them as extension syntax before the parser has to decide anything else:

  • Array: #[expr, expr, …] — trailing comma allowed, #[] for empty.
  • Map: #{key: expr, key: expr, …} — trailing comma allowed, #{} for empty.

Nesting is unrestricted — #[#{a: 1}, #{a: 2}] is a two-element array of one-entry maps, and a ragged 2-D grid is exactly what it looks like:

VAR grid = #[#[1, 2], #[3]]
Row lengths: {len(grid[0])} and {len(grid[1])}.
-> END
Row lengths: 2 and 1.

As that example shows, a collection literal is legal as a VAR/CONST declaration default — nesting included — provided everything inside it is a compile-time constant (literals and CONST references fold; a declaration default is data, not code). The rule is enforced as real errors, never silent nulls: a non-constant element or map value inside the literal is E077, a map key that doesn’t fold to a scalar is E076, and a default that computes at the top level (a function call, a reference to another VAR) is the general E083 every declaration obeys.

Literals live in expression position only~ lines, block statements, call arguments, condition expressions. You cannot write Loot: #[10, 20]. as narrative text, and the restriction is forced by ink’s own grammar, not taste: in prose, # opens a tag (Some text # a_tag), and tags legally contain {} interpolation, so #{…} mid-prose is genuinely ambiguous with tag syntax. Expression position has no such clash — # can never begin an ordinary ink expression there — so that’s the honest scope of “collision-free.” The idiom that follows: compute first, narrate second. Build the collection in logic, then interpolate the variable — interpolation was never restricted, because a variable reference isn’t literal syntax:

VAR arr = 0

~ {
    arr = #[]
    push(arr, 1)
    push(arr, 2)
    push(arr, 3)
}

Arr is {arr}.
-> END
Arr is [1, 2, 3].

(Under strict-ink — a project that never opted into the dialect — every form on this page is rejected whole with a targeted E051: “brink extension used under strict-ink dialect.” Parse never fails; analysis refuses. See Enabling the Dialect.)

A bare # in prose swallows the rest of the line. The tag grammar that forces the expression-position rule has a sharper edge worth knowing: because # opens a tag anywhere in prose, a literal # in narrative text — a hashtag, a shorthand for “number” — silently turns everything to the next # or end of line into tag data. `This costs

5 dollars.printsThis costsand files5 dollars.` as a tag. This

is stock ink behavior, byte-identical to the reference implementation (verified against inklecate, issue #858) — not a brink bug, but a trap the sigil forms sit next to. Relatedly, trailing whitespace on a printed line — including whitespace after a final interpolation — is stripped before output, also matching the reference exactly.

Typing a collection

Collections are statically homogeneous: one element type per array, one key type and one value type per map. In an annotation the spellings are Array<T> and Map<K, V>; almost everywhere, though, you write nothing and inference reads the literal:

  • #[1, 2, 3] is an Array<int>.
  • #[1, 2.5] is an Array<float> — the one implicit numeric promotion (int → float, see Values & Types) joins elements before homogeneity is judged.
  • #{"Mira": 3} is a Map<string, int>.

Elements that can’t unify make the collection’s type Conflicted, and under strict types (the brink dialect’s default) the binding holding it fails with E066 — “pantry’s temp tray is Conflicted under strict types — its uses disagree on its type”:

-> pantry

=== pantry ===
~ temp tray = #[3, "brandy"]
{len(tray)} things on the tray.
-> DONE

No annotation fixes a Conflicted collection — declaring the tray Array<int> doesn’t make "brandy" a number. Either the elements agree, or they were never one collection.

The empty-literal rule

#[] and #{} are the interesting case, and worth being precise about, because every inventory, queue, and memo table starts empty. An empty literal carries no evidence — no element to read a type from — so its type must arrive from context: an ascribed binding, or an already-typed slot it’s being assigned into. If nothing constrains it, inference runs out of evidence and strict mode reports the escape as E065 — “stock’s temp crates escapes strict inference as Unknown — annotate or restructure”:

-> stock

=== stock ===
~ temp crates = #[]
{len(crates)} crates in the yard.
-> DONE

Note what is not evidence: len(crates) accepts every collection, and even a later push narrows the element only once the array’s type is known — use is not ascription. The fix is the one the message names. Ascribe the binding, and the literal takes its type from the declaration:

~ temp cellar: Array<string> = #[]
~ push(cellar, "amber ale")
~ push(cellar, "black cider")
The cellar ledger holds {len(cellar)} casks: {cellar}.
-> END
The cellar ledger holds 2 casks: [amber ale, black cider].

This is one instance of a language-wide posture rather than a special collection rule: a value born without a type must be told one at birth. The same rule types a bare none (E107 — see below), and under types = gradual the same empty literal simply stays Unknown and defers to runtime behavior, unchecked.

An index is a claim

Reading and writing elements uses postfix indexing, in expression position, chaining as deep as the data goes:

VAR data = 0
VAR result = 0

~ {
    data = #[#{"a": #[1, 2, 3], "b": #[4, 5, 6]}, #{"a": #[7, 8, 9], "b": #[10, 11, 12]}]
    data[0]["a"][2] = 30
    result = data[0]["a"][2] + data[1]["b"][0] + data[0]["b"][1]
}

Result is {result}.
-> END
Result is 45.

The contracts behind that syntax are few, and each is a doctrine line you can carry around:

a[i] is a claim. Writing an index asserts “a valid element lives at i.” Out of bounds — reading or writing — is a turn-terminating runtime fault, not a null, not silent growth. An index you computed wrong is a bug, and the fault surfaces it where it happened.

A write never grows the array. a[len(a)] = v doesn’t append; it faults. Growth has its own verbs — push and insert — which say so in their names.

m[k] read is a claim; m[k] = v write is not. Reading a missing key faults — “I expect it there” is the faulting read’s contract. Writing a missing key inserts the pair; writing an existing key overwrites in place:

VAR memo = 0

~ {
    memo = #{}
    memo["a"] = 1
    memo["a"] = 2
    memo["b"] = 3
}

fresh_a={memo["a"]}, fresh_b={memo["b"]}, size={len(memo)}
-> END
fresh_a=2, fresh_b=3, size=2

The full fault roster for indexing:

SituationWhat happens
a[i] / a[i] = v with i outside [0, len(a))Fault — index out of bounds
m[k] read with k not presentFault — no such key
m[k] = v with k not presentInserts the pair
Indexing a value that isn’t indexableFault — not indexable
An array index that isn’t an intFault — invalid index
A map key outside the key domainFault — invalid key type

Faults are deterministic and total in the value-model sense — recorded in the transcript, reproduced identically on replay, with no in-story try/catch (v1 scripts are infallible from the inside; what a host does about a fault is host policy). And because collections are values, every indexed write lowers to a read-modify-write on the root variable — observable behavior is always “as if” a fresh copy, with the runtime sharing storage until an owner actually writes. The mechanics of that lowering, chained writes included, live in Indexing & Mutation.

Maps: the key domain and insertion order

Map keys are scalars — int, string, or bool — the same ratified key domain every map-keyed operation in the value model uses. A key literal that is statically outside the domain (a float, an array, a map) gets a compile-time warning, E106 (“map-literal key is outside the int/string/bool key domain”); a dynamic key expression that turns out bad at runtime is the corresponding turn-terminating construction fault. One domain, checked early where visible, enforced at runtime always. The same key domain governs contains(m, needle): under types = strict, when the map and the needle’s out-of-domain type are both statically visible, E152 flags the call at compile time — contains itself stays total and never faults (see contains is total), so E152 is a warning about a call that’s always false, not a fault report.

Order is worth trusting, because it’s guaranteed:

  • Iteration order is insertion order. for k in m visits keys in the order they were inserted; keys(m) and values(m) reify the same order as eager array snapshots.
  • Overwriting keeps position; removing shifts survivors down. Writing an existing key never moves it; a re-inserted key goes to the end only if it was actually removed first.
  • Equality ignores order. #{"a": 1, "b": 2} == #{"b": 2, "a": 1} is true — equality compares content (key → value pairs), never construction history. Only equality ignores order; iteration and serialization keep it.
VAR m = 0

~ {
    m = #{"z": 1, "a": 2, "m": 3}
}

Keys is {keys(m)}. Values is {values(m)}.
-> END
Keys is [z, a, m]. Values is [1, 2, 3].

Determinism here is a language guarantee, not an implementation accident: a story that iterates a map prints the same lines on every run and every platform, and a seeded replay reproduces them byte-for-byte.

The verb surface

Beyond literals and indexing, collections are worked through a small family of free-function verbs. Two conventions organize the whole surface, and they’re worth learning as rules because every future verb obeys them:

Imperative verbs mutate in place; past-participle verbs return a new collection. sort(a) sorts a; sorted(a) hands back a sorted copy and leaves a alone. The verb carries the mutation signal, and the confusion lattice is closed from both sides: an in-place verb returns nothing (using one in expression position is E056), and a functional verb doesn’t touch its argument.

Mutating verbs demand a place, not a value. The first argument of push/insert/remove/remove_at/clear/sort/sort_by must be an lvalue — a variable, temp, or indexed path — because the mutated collection has to be written back somewhere. Handing one a temporary is E055 — “push mutates its first argument — bind it to a variable first”:

~ push(#["ale"], "cider")
-> DONE

Mutating a temporary would mutate nothing; the error refuses the lost write. A wrong argument count on these verbs is its own targeted error (E058), naming the expected signature.

The reading verbs, at a glance — signatures in the standard display notation (display notation; T is not writable in source):

VerbSignatureNotes
lenlen(x: [T] | [K: V] | string | range): intelement / entry / character / span count
containscontains(a: [T], x: T): boolelement scan (arrays); key test on maps
contains_valuecontains_value(m: [K: V], v: V): boolcontent-equality scan over values; O(n) and honest about it
keys / valueskeys(m: [K: V]): [K]eager snapshots, insertion order
index_ofindex_of(a: [T], x: T): Option<int>first match, or none
first / lastfirst(a: [T]): Option<T>none on empty
min / maxmin(a: [T]): Option<T>doctrine order; none on empty
getget(m: [K: V], k: K): Option<V>the non-faulting map read
sorted / sorted_bysorted(a: [T]): [T]functional twins of sort/sort_by

And the mutators — statement-only, lvalue-first:

VerbSignatureNotes
pushpush(a: [T], x: T)append
insertinsert(a: [T], i: int, x: T)insert at i, 0 ≤ i ≤ len — the one array write allowed to reach the end
insert (map)insert(m: [K: V], k: K, v: V)today’s spelling; m[k] = v is the ruled one — see below
remove_atremove_at(a: [T], i: int)remove at i; out of bounds faults
removeremove(m: [K: V], k: K)total — removing an absent key is a no-op
clearclear(m: [K: V])empty in place
sort / sort_bysort(a: [T])in-place ordering — next section
poppop(a: [T]): Option<T>the hybrid: removes and returns the last element — the one mutator legal in expression position

Two postures hiding in that table deserve their doctrine lines:

A deletion is a wish; an index is a claim. remove(m, k) on an absent key is a no-op — you wished the key gone, and gone it is, idempotently. remove_at(a, i) out of bounds faults — you claimed an element existed at i, and it didn’t. Both postures are correct for their domain, but they used to share one verb name — remove covered both, which was an accident, not a decision (issue #1484 caught it: nothing about a map-key removal implies an array-index removal, or vice versa). The fix is naming, not flattening: remove_at joins the _at faulting-index family with char_at, leaving remove to mean exactly one thing — identity-based, idempotent-total removal (map keys today; flags values once flags land). There is no compatibility shim: a pre-#1484 remove(array, i) call site is E149 (issue #1532) under types = strict, the brink dialect’s own implicit default, when the receiver’s array type is statically known — provable from its own body-local uses (a temp/param). A VAR-held array has no Array/Map representation in the checker’s static typing today, so that idiom still faults only at runtime, as NotIndexable; under types = gradual, every case stays a runtime NotIndexable fault.

One spelling per concept, eventually. Today’s dialect ships insert(m, k, v) as a slice-1 free function, but the ruled native surface reserves the map-insert verb: m[k] = v is insertion, and one concept gets one spelling. Prefer the indexed write; the free-call form is a compat spelling with an expiry date. (Array insert — a genuinely distinct concept, positional insertion — stays.)

Effects, briefly, since every verb carries an inferred row (see Effects): the mutators write their receiver’s root variable, the readers read; none of them emits content; and any verb that can fault (wrong container type, out-of-bounds index, unorderable elements) carries that fault in its row. You never annotate any of this — the compiler harvests it from the call.

When the world doesn’t have one

Half the verbs above return Option<T>, and the reason is the language’s absence doctrine in one line: a fault says “your program is wrong”; Option says “the world didn’t have one.” An out-of-bounds index is the first kind — a bug, surfaced loudly. An empty array’s max, a search that found nothing, a key that was never filed — those are the second kind: honest answers to reasonable questions, and they come back as a value you can test.

An Option<T> is either some(x) — the world had one, here it is — or none. some(x) always renders as some(x); a bare none at the final value of an interpolation renders as nothing at all — absence rendering as absence (Option and Absence has the full display rule). You test it with explicit equality:

~ temp tab = #[4, 7, 2, 5]
~ temp rooms = #{"Mira": 3, "Old Tom": 1}
Heaviest night on the tab: {max(tab)}.
Edda's room: {get(rooms, "Edda")}. Mira's room: {get(rooms, "Mira")}.
~ temp settled = pop(tab)
Settled the last entry, {settled} — {len(tab)} remain.
{index_of(tab, 7) == some(1): The seven-coin night is still second in the ledger.}
{get(rooms, "Edda") == none: No Edda on the register tonight.}
-> END
Heaviest night on the tab: some(7).
Edda's room: . Mira's room: some(3).
Settled the last entry, some(5) — 3 remain.
The seven-coin night is still second in the ledger.
No Edda on the register tonight.

(Edda’s room prints nothing, not the word none — that’s the interpolation boundary at work, not a rendering bug.)

Two fences keep the doctrine honest. First, Option<T> has no truthiness{first(tab): …} is not “is there a first element”, it is a compile error under strict (E116: “an Option[T] has no truthiness — test == none / == some(x) in the condition (F27, docs/stdlib-spec.md §1.6)”) and a runtime fault under gradual:

-> ledger

=== ledger ===
~ temp tab = #[4, 7, 2]
{first(tab): Somebody still owes.}
-> DONE

A truthiness test is a quiet coercion of exactly the kind Option<T> ≠ T exists to ban — it blurs “the world had one” into “the value was truthy.” Second, a bare none carries no element type, so a fresh un-annotated VAR gap = none is E107 (“bare none needs a type from context”) — the empty-literal rule again, wearing its Option hat.

Option<T> appears in this chapter’s signatures and diagnostics but is display notation — you cannot write it in an annotation today. The full doctrine — x or default coalescing, the display-boundary forgiveness, filter_map — belongs to the Option chapter of the book’s reorganization; this chapter uses only what you’ve just seen: constructors some(x)/none, and explicit == tests.

Sorting the ledger

The ordering family is four verbs in the two-convention grid: sort(a) / sorted(a) order by the language’s doctrine order; sort_by(a, cmp) / sorted_by(a, cmp) order by a comparator you supply.

~ temp tab = #[3, 1, 2, 1]
~ temp fair = sorted(tab)
For the magistrate: {fair}; the ledger itself still reads {tab}.
~ sort(tab)
Now the ledger agrees: {tab}.
~ temp words = #["pear", "apple", "fig"]
Alphabetical stock: {sorted(words)}.
-> END
For the magistrate: [1, 1, 2, 3]; the ledger itself still reads [3, 1, 2, 1].
Now the ledger agrees: [1, 1, 2, 3].
Alphabetical stock: [apple, fig, pear].

What orders: ints and floats together (the numeric join); bools (false < true); strings, lexicographic by Unicode scalar value (locale collation is the intl pipeline’s business, like casing); and arrays, lexicographic element-wise, recursively. What doesn’t: maps, divert targets, and anything else without a defined order — sorting those is a fault, not a shrug. Structs and enums order only via an explicit compare protocol impl — field declaration order is never silently promoted to semantics.

Sorting is stable. Equal elements keep their input order — sort the tab by amount and two 4-coin nights stay in chronological order. Stable, deterministic, replay-identical: sorting is part of the determinism contract, not an exception to it.

Sort never implies dedup. Ordering and equality are separate questions; a sort with ties drops nothing.

Comparators are a contract

A comparator is an ordinary function of two elements returning an int — negative for “a first”, zero for “tie”, positive for “b first”:

~ temp owed = #[3, 9, 5]
~ sort_by(owed, #fn(largest_first))
Calling in debts from the top: {owed}.
-> END

=== function largest_first(a: int, b: int): int ===
~ return b - a
Calling in debts from the top: [9, 5, 3].

The contract: a comparator must be pure and silent — no writes, no output, no drawing randomness, and no reading story state either, because the order must depend only on the two comparands — and it must describe a consistent total order. A comparator the compiler can prove breaks that contract is E119 — “sort_by’s comparator counting reads tally; writes tally — a comparator must be a pure, silent fn(T, T): int (stdlib-spec §4b: the order must depend only on the two comparands)”:

VAR tally = 0

~ temp order = #[3, 1, 2]
~ sort_by(order, #fn(counting))
{order}
-> END

=== function counting(a: int, b: int): int ===
~ tally = tally + 1
~ return a - b

The check is exceedance-only — it fires on what it can prove, never on what it can’t. An inconsistent comparator (one that contradicts itself) can’t always be caught statically; the runtime may fault on detected inconsistency, and the guarantee floor is “the result is some permutation of the input, never worse.” A comparator that faults mid-sort faults the turn, like any other fault.

NaN, dev, and prod

Floats bring one genuinely awkward guest to the sorting table: NaN, which IEEE comparison refuses to order. The language’s answer is a doctrine with a mode knob, and it’s worth knowing as an author even though you’ll mostly meet it in a debug session:

  • Arithmetic is NaN-total. sqrt(-1.0) is NaN, flows through + and * freely, and never faults. Ordering contexts are where it stops.
  • Dev mode faults, loudly. A NaN operand reaching sort/sorted/min/max is a turn-terminating fault — the upstream arithmetic bug surfaces at its first ordering consumption, where you can still find it. Dev is the default.
  • Prod mode keeps going. The same NaN is placed by a pinned total order — NaN sorts greater than everything, NaN ties with NaN, -0 ties with +0 — and execution continues. Placement is deterministic and save/replay-safe; no data is fabricated, every element survives.

On NaN-free data the two modes agree exactly, and both cohere with < and == — turning prod on never changes a clean story’s output. The dev/prod split is fenced to exactly this kind of case: it exists only where the prod behavior is total and fabricates nothing. Placement qualifies; fabrication never does — int("potato") and out-of-bounds indexing fault in every mode, forever. The knob’s home is project configuration with a host-API override (Story::set_exec_mode / FlowInstance::set_exec_mode — see Runtime API); the mode is a host/build knob, never story state, never saved.

One asymmetry worth knowing: sort_by is not in the dev NaN-fault list. Your comparator owns the element semantics — NaN never reaches the ordering machinery as a comparison result, so sort_by faults only on its own terms (comparator dispatch, a non-int return, whatever the comparator’s body faults on, detected inconsistency).

The deeper doctrine — comparison operators staying frozen IEEE, the compare protocol, heaps — belongs to the Ordering chapter of the book’s reorganization; this is the working author’s share of it.

Ranges

A range is a span of integers as a value: 0..10 is 0 through 9 (half-open), 1..=6 is 1 through 6 (inclusive). Ranges are real values — they store, print, compare, save, and restore like any other — and they behave as the read-only array of their integers:

~ temp die = 1..=6
~ temp span = 0..10
The dice corner chalks its spans: {string(die)} and {string(span)}.
Six faces: {len(die)}; first step {span[0]}, last step {span[9]}.
Same span, either spelling: {die == 1..7}.
~ temp calls = ""
~ {
    for n in 2..=4 {
        calls = calls + " " + string(n)
    }
}
The crier calls{calls}.
{0..0: An empty span fires.|An empty span never fires.}
-> END
The dice corner chalks its spans: 1..=6 and 0..10.
Six faces: 6; first step 0, last step 9.
Same span, either spelling: true.
The crier calls 2 3 4.
An empty span never fires.

The contracts, each an echo of one you’ve already met:

  • Indexing is the same claim. span[i] is start + i, and out of bounds faults exactly like an array.
  • Equality is content. 1..=6 == 1..7 is true — a range is the integer sequence it denotes, and all empty ranges are equal to each other. Display and the wire keep the written form (1..=6 prints as you spelled it); equality doesn’t care.
  • Emptiness is legal and load-bearing. 0..n with n = 0 iterates zero times and is false in condition position — that’s what makes for i in 0..len(a) safe on an empty array, no guard needed.

Where emptiness is not acceptable, the language says so in the type: drawing a die roll with int(1..=6) requires an inhabited range, the language’s first value refinement — a statically-empty literal is a compile error, and computed bounds pass through the non_empty(r) validator, which returns Option (E117 is strict mode’s enforcement; gradual faults at runtime). That story — parse-don’t-validate, and every draw being an effect — is the Randomness chapter’s; iteration in full (for, and the fn-value verb family) is the Iteration chapter’s.

Views — a performance contract, ruled ahead of its verbs. Slicing verbs (slice, split, trim) haven’t landed in the dialect yet, but their semantics are already ruled: when they arrive they return views — O(1), non-allocating windows onto the original storage. A view is a representation, not a type: sharing is unobservable (values are values; every observation behaves as a copy), saves and the wire always materialize, and the O(1) promise is a regression-guarded contract, not an optimization that may quietly vanish. Nothing to do today — this sidebar exists so “slice is cheap” is a fact you can plan around, not folklore.

Reference: the diagnostics in this chapter

CodeFires whenPolicy
E051collection syntax in a strict-ink projectdialect gate
E055a mutator’s first argument is not an lvalueboth
E056a statement-only mutator used in expression positionboth
E058mutator argument count mismatchboth
E065an unconstrained empty literal escapes inference as Unknownstrict
E066collection elements can’t unify — the type is Conflictedstrict
E076a map key in a declaration default doesn’t fold to a scalarboth
E077a non-constant element/value inside a declaration-default literalboth
E083a declaration default that isn’t compile-time constantboth
E106a statically-visible map-literal key outside int/string/boolboth (warning)
E107a bare none with no type from contextboth
E116an Option<T> used as a condition — no truthinessstrict (runtime fault under gradual)
E117int(r) over a range not proven inhabitedstrict (runtime fault under gradual)
E119a sort_by/sorted_by comparator — or a map/filter/fold callback — provably exceeds pure·silentboth
E149remove on a statically-known array — use remove_atstrict (runtime fault under gradual)
E152a statically non-key-domain needle in contains(m, …) on a statically-known mapstrict (warning; runtime returns false under gradual)

Where this is ruled

  • Collection surface: literals, indexing, stdlib slice 1docs/t1b-surface-spec.md §§3–5; value semantics and fault posture, docs/value-model-spec.md (ratified).
  • The indexing contract (m[k] read-faults / write-inserts) — decision log 2026-07-15 (#856).
  • The Option package and the absence flips (find/index_of/ get/first/last/min/max/popOption) — docs/stdlib-spec.md §§1.1/1.4, §§4–5; decision log 2026-07-18 (“Option pulled forward”); F27 no-truthiness ruled 2026-07-19.
  • Mutation posture and the naming law (imperative in-place / past-participle functional, lvalue receivers) — docs/stdlib-spec.md §4; decision log 2026-07-18 (“Mutation posture”).
  • Maps (key domain, insertion order, contains_value, insert reserved, remove-total) — docs/stdlib-spec.md §5; decision log 2026-07-18 (“Maps ruled”); order-insensitive equality, decision log 2026-07-18 (“Map/record equality is insertion-order-insensitive”).
  • The remove/remove_at split (seq remove-by-index renamed remove_at, remove narrowed to identity-based idempotent-total removal) — issue #1484; decision log 2026-07-26 (“Quick-docket closures”).
  • The ordering doctrine and the sort family (doctrine order, stable sort, dev/prod NaN posture, comparator contract) — docs/stdlib-spec.md §4b; decision log 2026-07-18 (“The ordering doctrine”); F0 (sort_by in-place) and the knob’s config home ruled 2026-07-19; F14/F29 as-built amendments, NS-A4 (#1110).
  • Ranges as real values; content equality; the inhabited-range refinementdocs/stdlib-spec.md §7; F7/F8 ruled 2026-07-19; F30 content equality ratified 2026-07-19 (delegated batch); NS-A5 (#1136).
  • Viewsdocs/stdlib-spec.md §3b.

Indexing & Mutation

Reading and writing an element

Postfix indexing works in expression position on either collection kind, and chains:

a[0]
m["k"]
grid[y][x]

Indexed assignment is a statement (a ~ line, or a block statement) and chains the same way:

a[0] = v
m["k"] = v
grid[y][x] = v
VAR grid = 0
VAR result = 0

~ {
    grid = #[#[1, 2], #[3, 4]]
    grid[0][1] = 99
    grid[1][0] = grid[1][0] + 100
    result = grid[0][1] + grid[1][0]
}

Result is {result}.
-> END
Result is 202.

How a write reaches the collection

An indexed write never mutates “in place” from the language’s point of view — brink collections are values, not references, and the compiler doesn’t special-case indexing to pretend otherwise. a[0] = v (and, one level deeper, grid[y][x] = v) lowers to a read → modify → write-back sequence on the root variable: read the container out, apply the change, write the whole container back to a. A chained path like grid[y][x] = v lowers to nested read-modify-write, one level at a time — never to an interior reference into the array. This is exactly the discipline the value model specifies for the language generally, so the fact that a write is efficient in practice (the runtime shares the underlying storage until something actually forks it, and mutates in place once the last owner is doing the writing) is never something a brink program needs to reason about; the observable behavior is always “as if” a fresh copy.

There’s a limit to how deep this goes in this round of the dialect: a chain like grid[y][x] is nested indexing, lowered to nested read-modify-write — there’s no way yet to take a standalone reference into the middle of a collection and hand it around (that’s a later, separate piece of the language).

Faults

Plain ink is famous for tolerating a lot — a missing content path doesn’t crash the story. Indexing breaks from that: out-of-bounds access, and missing-key reads, are turn-terminating runtime faults, not values that quietly become null or an empty result. Every row below ends the current turn, the same way dividing by zero already does — except the map-write row, which inserts instead of faulting (see below):

SituationWhat happens
a[i] / a[i] = v with i outside [0, len(a))Fault — array index out of bounds
m[k] with k not already a key in mFault — map has no such key
m[k] = v with k not already a key in mInserts the key-value pair
Indexing into a value that isn’t an array or a mapFault — not indexable
An array index expression that isn’t an IntFault — invalid array index
A map key expression outside the key domain (not int/string/bool)Fault — invalid map key type

Two points worth being explicit about:

  • A write never grows the array. a[i] = v requires i to already be a valid, in-bounds index — writing one past the end doesn’t append; it faults. If you want to grow a collection, use the stdlib mutators (Standard Library) — push/insert are the only operations that add elements, and they say so in their name.
  • An indexed map write inserts on a missing key. m["new_key"] = v inserts the key-value pair if the key isn’t already present. Reading a missing key, however, still faults; m["new_key"] (without the assignment) requires the key to already exist.

These are total operations with a well-defined failure outcome, not undefined behavior — a fault is deterministic, gets recorded in the transcript/journal like anything else the runtime does, and a replay reproduces it identically. What a host does in response (abort the turn, show a debug message, roll back to a snapshot) is a host policy question, not something the ink script has any way to catch — v1 scripts are infallible from the inside; there’s no in-language try/catch for this.

Option and Absence

The Last Light’s register knows who signed in tonight — and, just as usefully, who didn’t:

~ temp rooms = #{"Mira": 3, "Old Tom": 1}
~ temp mira = get(rooms, "Mira")
~ temp edda = get(rooms, "Edda")
The innkeeper runs a finger down the register.
Mira: {mira}. Edda: {string(edda)}.
{mira == some(3): Mira is upstairs in room 3, same as always.}
{edda == none: No Edda tonight — the road must have kept her.}
-> END
The innkeeper runs a finger down the register.
Mira: some(3). Edda: none.
Mira is upstairs in room 3, same as always.
No Edda tonight — the road must have kept her.

Nothing went wrong in that scene. Asking the register about Edda was a perfectly reasonable question, and the register answered it: none. The answer is a real value — it printed, it compared, it could have been stored or passed along — and the story kept running. That value’s type is Option<T>, this chapter’s subject, and the doctrine it carries is one line long: a fault says “your program is wrong”; Option says “the world didn’t have one.”

(That scene prints Edda’s line through string(edda) rather than a bare {edda} — a deliberate choice, not an oversight. A bare {edda} would print nothing at all: How Option prints, later in this chapter, is where that rule belongs.)

Current spelling — examples in this chapter compile in today’s brink dialect: collection literals carry the #[…]/#{…} sigils and the Option verbs are free calls (get(rooms, "Edda"), find(s, sub)). The ruled native .brink spelling for method-position calls (rooms.get("Edda")) and the as-binding (B1b, issue #1475 — see the callout later in this chapter) have both landed on the native surface; this chapter’s own respell to that surface is separate, later work. The x or default coalescing form has landed (B1, issue #1460) — but only on the native .brink surface: or in the brink dialect this chapter’s examples use (the ~-prefixed, #[…]-sigil syntax above) is still ink’s boolean or (an alias for ||), unchanged and oracle-frozen; a native-surface .brink file can already write get(rooms, "Edda") or "no one".

Absence is a value, not a fault

The Collections chapter drew the line this chapter stands on: an out-of-bounds index faults, because an index is a claim — you asserted an element existed and you were wrong, and that’s a bug the runtime surfaces where it happened. But an empty array’s max, a search that found nothing, a guest who never signed the register — those aren’t bugs. They’re honest answers to reasonable questions, and a language that can’t say them politely forces authors into one of two old traps:

  • The sentinel. Return -1 for “not found”, 0 for “empty”, and hope nobody ever does arithmetic on the flag value. The bug this breeds is silent and famous: the sentinel is a valid-looking number, so absence quietly becomes data.
  • The fault. Treat “the world didn’t have one” like “your program is wrong” and end the turn. Now every lookup needs a defensive guard, and expected absence — the bread and butter of narrative state — is indistinguishable from a genuine bug.

Brink shipped neither. find and index_of were headed for -1 sentinels and the empty-array extremums for faults when the Option ruling caught them — the sentinels died unshipped, and every absence-shaped verb was flipped to return Option<T> before any story could depend on the bad answers.

An Option<T> is one of exactly two things:

  • some(x) — the world had one, and here it is.
  • none — the world didn’t have one. Not zero, not -1, not a fault: a first-class value that says absence, and says nothing else.

The doctrine cuts both ways, and the faults are still there for the bugs. Asking a malformed questionfind on a number, min over a map, get with a key outside the key domain — is a turn-terminating fault, never a none. So is an out-of-bounds index, in every mode, forever. The line to carry around: absence never faults; malformed questions always do.

SituationAnswer
max of an empty arraynone — absence
get of a key never filednone — absence
find of a substring that isn’t therenone — absence
a[i] out of boundsfault — an index is a claim
min over a map (wrong container)fault — malformed question
min over unorderable elementsfault — malformed question

The verbs that answer with Option

Ten verbs across the stdlib return Option<T> today — every place the language asks the world a question the world might honestly have no answer to. Signatures in the standard display notation (display notation; T is not writable in source):

VerbSignaturenone means
findfind(s: string, sub: string): Option<int>substring absent (USV index when present)
index_ofindex_of(a: [T], x: T): Option<int>no element equal to x
firstfirst(a: [T]): Option<T>array empty
lastlast(a: [T]): Option<T>array empty
minmin(a: [T]): Option<T>array empty
maxmax(a: [T]): Option<T>array empty
poppop(a: [T]): Option<T>array empty — nothing removed
getget(m: [K: V], k: K): Option<V>key absent
pickpick(x: [T] | range): Option<T>nothing to draw from
non_emptynon_empty(r: range): Option<NonEmptyRange>the range was empty

Three of these deserve a word beyond the table. pop is the hybrid the Collections chapter flagged — it removes the last element and hands it back, so an empty array means “nothing removed, and here’s the none to prove it.” pick draws a random element, so its none (an empty array or range has nothing to draw) travels with the whole randomness story — seeds, draws-as-writes — in the Randomness chapter. And non_empty is the validator for the inhabited-range refinement: its some payload is the proven-inhabited range that int(r) demands. Also that chapter’s story.

Every verb here carries its inferred effect row like any other (Effects): the readers read, pop writes its receiver, and the fault possibility for malformed questions rides in the row. You annotate none of it.

Constructing and comparing

You’ll mostly receive Options from the verbs, but both shapes are writable directly: some(expr) wraps any value, and none is the absence literal. Equality is structural and total over both — none == none; some(x) == some(y) exactly when x == y — and ==/!= are the only operators defined on Options. There is no < between them (Options don’t order — sorting an array of them is a malformed question), and no arithmetic, which is the next section’s subject.

~ temp stock = "ale, cider, bread"
~ temp ale = find(stock, "ale")
~ temp gin = find(stock, "gin")
ale: {ale}, gin: {gin}
{ale == some(0): The ale is first on the slate.}
{ale != gin: One of these is on the shelf and one is not.}
~ gin = some(99)
{gin == some(99): The gin arrived on the evening cart.}
-> END
ale: some(0), gin:
The ale is first on the slate.
One of these is on the shelf and one is not.
The gin arrived on the evening cart.

(gin’s line ends right after the colon — a bare {gin} is a none at the interpolation boundary, and renders as nothing; How Option prints explains why.)

Note the reassignment: an Option variable moves freely between some(…) and none over its life — “what the register currently says about the gin” is exactly the kind of state Options are for. Options nest, too: some(none) is a real value and it is not equal to none — a box with an empty box inside is not an empty shelf. You’ll rarely want that, but equality won’t blur it for you.

One birth rule, which you already met wearing its empty-collection hat in Collections: a value born without a type must be told one at birth. A bare none carries no element type, so a fresh un-annotated declaration initialized from one has nothing to be — that’s E107, in both dialects and under both type policies:

VAR reservation = none

The book lies open on the counter.
-> END

The message names the fix: “reservation is declared from a bare none, which carries no element type — initialize from some(x) or an Option-returning verb (find/get/pop/…) instead.” Every other none position — an assignment to an existing Option slot, a comparison operand, a call argument — has context by construction and is fine.

Option<T> is not T

The type Option<int> and the type int are different types, and the checker holds that line everywhere: no implicit unwrap, no coercion, no “it’s probably some, treat it as the number.” The strictness is the whole point — if Option<T> quietly became T wherever convenient, the sentinel bug would be back with better manners, and absence would go silent again exactly where it matters.

Under strict types (the brink dialect’s default), mixing the two is the familiar Conflicted diagnosis from Values & Types — the slot is being used as two irreconcilable types. This fails with E066, “ledger’s temp floor is Conflicted under strict types — its uses disagree on its type”:

-> ledger

=== ledger ===
~ temp rooms = #{"Mira": 3, "Old Tom": 1}
~ temp floor = get(rooms, "Mira")
~ floor = 2
Mira sleeps on floor {floor}.
-> DONE

get answered Option<int>; the assignment insists on int; no annotation reconciles them. The same collision caught anywhere else — an Option operand in arithmetic, an Option passed where a plain value is declared — is the same conflict. Under types = gradual the checker lets the unknown ride and the runtime holds the line instead: some(1) == 1 is a turn-terminating type fault, not true and not a coercion. Either way, no policy blurs some(1) into 1.

Options flow through your own functions like any other value, and inference handles the signatures exactly as Values & Types described — from the body, bottom-up:

VAR rooms = #{"Mira": 3, "Old Tom": 1}

Mira: {room_of("Mira")}. Edda: {string(room_of("Edda"))}.
-> END

=== function room_of(name: string) ===
~ return get(rooms, name)
Mira: some(3). Edda: none.

room_of settles as (string) -> Option<int> with no annotation — the body’s get is all the evidence needed.

Option<T> is both inferable and annotatable (issue #1552): it appears in inferred signatures, in diagnostics, and throughout this chapter’s tables, and you can also write it yourself — ~ temp best: Option<int> = none resolves exactly like int or Array<int> would. Weighted<T> gained the same annotation spelling in the same change. range is the one remaining construction-only builtin: no annotation form yet, pending demonstrated demand.

No truthiness

The oldest ink idiom — a bare value in condition position, nonzero meaning true — does not extend to Option, on purpose. {get(rooms, "Edda"): …} reads like “is Edda registered”, but a truthiness test is a quiet coercion of exactly the kind Option<T> ≠ T exists to ban: it blurs “the world had one” into “the value was truthy,” and it reintroduces the silent-absence bug class one condition at a time. So Option has no truthiness, anywhere, and the condition-position error tells you the honest spelling. This fails with E116, “an Option[T] has no truthiness — test == none / == some(x) in the condition (F27, docs/stdlib-spec.md §1.6)”:

-> register

=== register ===
~ temp rooms = #{"Mira": 3, "Old Tom": 1}
{get(rooms, "Edda"): Somebody named Edda is upstairs.}
-> DONE

The rule covers every condition position — if/while conditions, {cond: …} conditional branches and their inline forms, choice guards, await conditions — and negation doesn’t launder it: {!opt: …} is the same error. Under strict types it’s the compile error above, for every condition the checker can statically classify; a condition it can’t see through stays silently unchecked at compile time. Under gradual there is no compile-time check at all. Both residues meet the same runtime backstop: an Option reaching a truthiness evaluation is a turn-terminating fault (“an Option has no truthiness — test == none / == some(x) explicitly”), never a silent false.

The contrast with the idiom that does survive strict is worth holding side by side. A visit count in condition position ({market: …}) is a plain int with a deliberately preserved, scoped truthiness — see Values & Types. An Option in condition position is an error in every mode. The difference isn’t taste: the visit count is a number being tested as a number, while an Option in a condition is a category mistake the explicit forms fix for the cost of one comparison.

Getting the value out

Here is the honest state of the surface: today you can make Options, print them, and compare them — and comparing is the only door from Option<T> back to T-shaped decisions. There is no unwrap verb, no default-extraction verb, no per-verb get_or family. That narrowness is deliberate (the ruling that shipped Option explicitly folded get_or into the coalescing form to come — one spelling per concept, and no stopgap verbs that would outlive their excuse), but it is real, and until the planned ergonomics land (the callout at the end of this section) you should know the three working idioms.

Compare against the candidates. When the interesting values are few, explicit equality is the whole job — {ale == some(0): …}, {edda == none: …} — as every example so far has done.

Ask, then claim. The Option verbs have faulting siblings that return plain Tget(m, k) pairs with the claiming read m[k], first(a) with a[0]. Establish presence with the Option (or with contains), then make the claim, which is now justified:

~ temp rooms = #{"Mira": 3, "Old Tom": 1}
{get(rooms, "Mira") != none:
    Mira's key hangs on hook {rooms["Mira"]}.
- else:
    No key to fetch tonight.
}
-> END
Mira's key hangs on hook 3.

This is the two contracts from Collections composed, not a trick: the question that tolerates absence gets the Option; the read that asserts presence gets the fault-backed claim; the guard is what turns the first into a license for the second.

Fold your own. Some verbs have no claiming sibling — there is no total min to graduate to once you’ve checked the array is non-empty. When you need the value of an extremum rather than a comparison against it, write the loop; it’s four lines, and it says exactly what it computes:

~ temp tab = #[4, 7, 2, 5]
~ temp heaviest = 0
~ {
    for coins in tab {
        if coins > heaviest {
            heaviest = coins
        }
    }
}
The heaviest night on the tab: {heaviest} coins — the ledger says {max(tab)}.
-> END
The heaviest night on the tab: 7 coins — the ledger says some(7).

Landed on the native surface, still planned for this (brink-dialect) chapter (B1/B1b, issues #1460 and #1475). The ruled Option package includes its ergonomics, and both halves now compile on the native .brink surface. The coalescing form x or default collapses an Option into a value (get(rooms, "Edda") or 0), chaining left-to-right and staying optional until the final non-Option fallback (get(m, k) or get(m, k2) or 0). It short-circuits (ruled, issue #1471): a fallback is evaluated only when everything to its left came back none, so x or expensive() never pays for expensive() when x is already some(_). The as-binding tests and unwraps in one move, and it is one construct in both of the language’s condition positions — the statement form and the template form, riding the ruled {if …} spelling:

if get(rooms, "Edda") as r {
    // `r` is a plain `int` here — the Option is already unwrapped
}
{if get(rooms, "Edda") as r: room {r} it is else: no room tonight}

The binding is immutable, typed T from the condition’s Option<T>, scoped strictly to the success arm (an else never sees it), and rebinds every iteration in while. For v1 the binding must be the entire condition — composing it with &&/|| is an error (E145); let-chains can land later, additively. An as in a choice guard is ruled (capture-at-presentation, by value) and now implemented too — the guard’s binding captures into the same frame slot the pending choice’s thread-fork snapshot already carries across selection, so the picked body sees the value the player saw (E146, “not yet supported”, is retired). Nothing in this chapter changes either way — these examples are brink-dialect, where or stays ink’s boolean or and there is no as binding at all; the chapter’s own respell to the native surface is separate, later work.

How Option prints

some(v) is total and boring forever, by ruling (F28): it renders some(<v>) everywhere it appears — in interpolation, and identically through string(x), which keeps its everything-in, never-fails contract. none is where the two consumers diverge, by a second, later ruling (§1.6b, Track B4): string(none) still renders the total, boring "none" forever — F28’s totality, preserved for that one intrinsic — but a none that is the final value of an interpolation renders as nothing at all. Absence rendering as absence is the honest narrative meaning; a none that leaked into your prose as the word “none” was always a debugging artifact, not something a player should read.

~ temp tab = #[4, 7, 2]
~ temp empty: Array<int> = #[]
Tonight's best: {max(tab)}, same as {string(max(tab))} via string().
Empty ledger, interpolated: "{max(empty)}".
Empty ledger, via string(): {string(max(empty))}.
-> END
Tonight's best: some(7), same as some(7) via string().
Empty ledger, interpolated: "".
Empty ledger, via string(): none.

some(7) prints identically both ways — the top line never diverges. The bottom two lines are the same none value, read through the two consumers: interpolated, it vanishes (the quotes above are there so an empty result is visible on the page, not part of the rendering rule); through string(), it still spells out "none".

The forgiveness is cut by position, not by type or dialect: it only ever applies to an interpolation’s own final, top-level value. Compose an Option with anything else — {mood.first() + 1}, an Option operand in concatenation — and Option<T> ≠ T strictness still holds; that stays the ordinary type error from Option<T> is not T, never silently forgiven. And the forgiveness never destroys information: every None-render is traceable in the transcript (the runtime’s append-only output log records the real Value::OptionVal(None), not the empty text it happened to render as), so tooling built on the transcript can always tell “a None rendered here” apart from “nothing was ever emitted here.”

Two loose threads, named rather than pretended away: an always-None-interpolation lint (catching a slot that can only ever render blank) and how choice text and tags should treat a forgiven none (an accidentally blank choice differs from an author’s deliberate * []) are both still open questions, not yet answered by an implementation.

Option and the collections

You’ve already met most of this chapter in Collections without the theory: half its verb table returns Option<T>, its when-the-world-doesn't-have-one section is this doctrine in miniature, and its empty-literal rule is E107’s twin (a value born without a type must be told one at birth). What this chapter adds is the frame those verbs sit in — why get and m[k] both exist (a question and a claim, not a redundancy), why pop on empty is none while remove_at out of bounds is a fault (absence versus a wrong claim), and why none of these answers will ever swap categories: the fault-vs-absence line is a ruling, not a convention.

The two chapters that follow lean on this one harder. Iteration’s fn-value verbs include filter_map — the Option-aware mapper that transforms and drops nones in one pass — taught alongside the rest of that family there. Ordering has to answer what min/max mean when the elements themselves misbehave (NaN), and its dev/prod doctrine is the other half of the fault story. Both chapters assume you read this one.

Reference: the diagnostics in this chapter

CodeFires whenPolicy
E066an Option and its element type collide — the slot is Conflictedstrict (runtime type fault under gradual)
E107a fresh un-annotated declaration initialized from a bare noneboth dialects, both policies
E116an Option<T> used as a condition — no truthinessstrict (runtime fault under gradual)

The gradual-mode runtime backstops, for completeness: an Option reaching a truthiness evaluation is the no-truthiness fault; an Option meeting a plain value in ==/arithmetic is a type fault; and the malformed-question faults (find on a non-string, min on a non-array or unorderable elements, get on a non-map) fire in every mode — those were never absence.

Where this is ruled

  • The Option package and the absence doctrine (“a fault says ‘your program is wrong’; Option says ‘the world didn’t have one’”) — docs/stdlib-spec.md §1.1/§1.4/§1.6; decision log 2026-07-18 (“Option pulled forward as a compiler-known builtin; the absence doctrine”).
  • The verb flips (find/index_of/first/last/min/max/pop/ getOption; the sentinels dying unshipped) — docs/stdlib-spec.md §§3–5; same 2026-07-18 ruling; NS-A1 implementation (#1107). pick and non_empty joined with NS-A6 (#1112) and NS-A5 (#1136).
  • F27: no truthiness — decision log 2026-07-19 (“F27/F28 ruled”); docs/stdlib-spec.md §1.6. Supersedes the briefly-shipped falsy-none.
  • F28: string()’s totality is forever; the interpolation boundary forgives none — same 2026-07-19 ruling; the display-boundary forgiveness (position-cut, nested never forgiven, traceable) is Track B4, docs/stdlib-spec.md §1.6b, SHIPPED (issue #1463). It turned out not to need the native surface — interpolation and Option<T> already exist on the current brink dialect, so it landed as a brink-runtime display change (docs/stdlib-sequencing.md’s Wave B4 entry has the as-built note).
  • x or default — part of the 2026-07-18 package ruling; the typing substrate follows finding F19 (docs/stdlib-phase-c-findings.md). Surface spelling landed on the native .brink frontend in B1 (issue #1460): InfixOp::Coalesce, distinct from the brink dialect’s oracle-frozen InfixOp::Or (ink’s boolean ||).
  • The as-binding — named as the post-B1 condition-position spelling by F27 (below), then ruled in full by the decision log’s 2026-07-26 entry “The as binding: one construct, both condition positions, {if} spelling” (immutable, typed T from Option<T>, scoped to the success arm, rebinding per iteration in while, whole-condition-only for v1). Landed on the native .brink surface in B1b (issue #1475). Choice-guard as was ruled separately the same day (“Choice-guard as un-deferred: capture-at-presentation, by-value (COW), rides v6”) and now lands too (issue #1508): the guard’s binding captures into the same frame slot the pending choice’s thread-fork snapshot already carries across selection — no new wire-format field needed (E146, “not yet supported”, is retired).
  • Bare none needs a type from contextdocs/stdlib-spec.md §1.4; E107’s declaration rule (#1107).
  • Option<T> in the static type languagedocs/stdlib-spec.md §1.4. Originally inferable-only, with annotatability slated for the native surface; landed generally instead as part of the 2026-07-27 type-name conformance sweep (issue #1552) — Option<T>/Weighted<T> are annotatable on the ink/brink dialect today, not just the native frontend.

Iteration

Closing time at the Last Light. The innkeeper works down the register one name at a time, and the slate settles itself:

VAR owed = 0
VAR roll = ""

~ {
    temp tab = #{"Mira": 4, "Old Tom": 7, "Edda": 2}
    for guest in tab {
        owed = owed + tab[guest]
        roll = roll + " " + guest
    }
}
Closing time. The innkeeper reads the slate:{roll}.
{owed} coins still owed between them.
-> END
Closing time. The innkeeper reads the slate: Mira Old Tom Edda.
13 coins still owed between them.

One loop, three guests, two facts computed — and then the prose reads the results. That shape is the whole chapter in miniature: loops live in logic blocks and compute; the narration comes after, and reads what they computed. What remains is the contracts — what for accepts, what exactly it iterates when the collection changes under it, what while, break, and continue add, why a runaway loop fails loudly instead of hanging your story, and where the ruled-but-unlanded pieces of the iteration surface currently stand.

Current spelling — examples in this chapter compile in today’s brink dialect: collection literals carry the #[…]/#{…} sigils, verbs are free calls (push(tab, 5), len(tab)), and loops live inside ~ { … } logic blocks. The loop syntax itself — for name in expr { … }, while cond { … }, break, continue — is already the ruled shape; what changes with the native frontend is the spelling around it (bare […] literals, method calls, lambdas). The ruled two-binding form for k, v in m now parses and lowers on the native .brink surface; this chapter’s ~ { … } brink-dialect examples still have no two-binding spelling (see below).

Loops compute; prose narrates

Narrative iteration is rarer than programming instinct suggests, and the language is shaped around that honestly. Prose cannot loop: a for or while is a statement, legal only inside a ~ { … } logic block, and a block is fenced to pure computation — no text output, no choices, no diverts ever appear inside one (see Logic Blocks). You will never write “for each guest, print a paragraph” as a loop around narration.

What loops are for is the step before narration: totalling a ledger, scanning an inventory, updating every entry of a grid, finding the first thing that matters. The working idiom is the one the opening scene used — compute first, narrate second: run the loop in a block, accumulate into a variable (a number, a built-up string, a collection), then interpolate the result. And for the most common narrative “iteration” of all — a line that varies each time the story passes through it — the brace alternations ({& …}, {! …} and kin) are the right tool, not a loop at all; they belong to the prose dialect and its own chapter.

for and the closed iterable set

for name in expr { … } walks a sequence, binding each element to name and running the body once per element. What can stand after in is a closed set — exactly three kinds, each with a defined element:

IterableElementsOrder
array [T]its valuesthe array’s own order
map [K: V]its keysinsertion order
rangeits integersascending

Nothing else iterates — not strings, not Option, not a number. Iterating over anything outside the set is a malformed question in the Option chapter’s sense: a turn-terminating “not indexable” fault at runtime, never a silent zero-times loop. Under strict types the mistake usually surfaces earlier and indirectly — a loop variable with no element type to be escapes inference as Unknown, the familiar E065 from Values & Types.

The iterable is a full expression, evaluated once, at loop entry. Iterating a function’s result or a functional verb’s copy is ordinary:

VAR order = ""

~ {
    temp tab = #[4, 7, 2]
    for coins in sorted(tab) {
        order = order + " " + coins
    }
}
Debts called in, smallest first:{order}.
-> END
Debts called in, smallest first: 2 4 7.

Map iteration order is a language guarantee, not an accident: keys come back in insertion order, deterministically, on every platform, every run — the same promise keys(m)/values(m) make in Collections, and part of the same replay-stability contract everything else obeys.

The loop variable

The loop variable is a fresh, block-scoped binding — it exists only inside the body, takes the iterable’s element type under strict (for coins in tab makes coins an int when tab is an Array<int>), and does not leak. It may share a name with an outer temp; that’s shadowing, and it’s legal but flagged with the E054 warning (“shadows an already-visible temp”), because it is almost always either deliberate or a bug — never innocuous:

VAR log = ""

~ {
    temp round = 999
    log = log + "before=" + round
    for round in #[1, 2, 3] {
        log = log + " round=" + round
    }
    log = log + " after=" + round
}

{log}
-> END
before=999 round=1 round=2 round=3 after=999

The outer round is untouched — the loop wrote its own round, three times, and the outer binding was waiting, intact, at the closing brace. (The example compiles; the compiler just makes you look at it.)

One thing the loop variable is not: a handle back into the collection. Assigning to it changes the binding, never the array element it was copied from — collections are values, and the loop hands you copies. The blessed way to mutate elements in place is the range-index idiom below.

What the loop actually iterates

Here is the contract worth carrying around: a for loop iterates the value its iterable expression produced at entry — not the variable it came from. The sequence is fixed once, before the first pass; nothing the body does to the source variable changes how many times the loop runs or what it sees.

For arrays this falls straight out of the value model. The loop reads tab once and holds that value; a push(tab, …) in the body writes a new value into the variable, and the loop’s copy never sees it:

VAR seen = 0
VAR grown = 0

~ {
    temp tab = #[4, 7, 2]
    for coins in tab {
        push(tab, coins)
        seen = seen + 1
    }
    grown = len(tab)
}
The loop visited {seen} entries; the tab ended the night at {grown}.
-> END
The loop visited 3 entries; the tab ended the night at 6.

Three passes, not six, and certainly not forever — appending while iterating can never turn a loop infinite, by construction.

For maps the same guarantee is a deliberate ruling rather than a free consequence, and it has a name: the key set is snapshotted eagerly at loop entry (F10). The loop walks the keys the map had when it started; structural changes mid-loop — inserting, removing — affect the live map immediately but never the walk:

VAR knocked = ""
VAR listed = 0

~ {
    temp rooms = #{"Mira": 3, "Old Tom": 1}
    for guest in rooms {
        rooms["Edda"] = 7
        knocked = knocked + " " + guest
    }
    listed = len(rooms)
}
The innkeeper knocked for{knocked} — yet the register now lists {listed} guests.
-> END
The innkeeper knocked for Mira Old Tom — yet the register now lists 3 guests.

The honest edge of the snapshot: only the keys are snapshotted. A rooms[guest] read in the body is a live read of the current map — which is exactly what you want (you see the values as they now are), with one sharp corner: if the body removes a key the snapshot still holds, a later rooms[that_key] read is the ordinary faulting missing-key read from Collections. The snapshot never invents an entry to hide the removal — a removed key reads as exactly what it is.

Reading keys and values together is the everyday map loop:

~ temp rooms = #{"Mira": 3, "Old Tom": 1, "Edda": 7}
~ temp doors = ""
~ {
    for guest in rooms {
        doors = doors + guest + " is in room " + rooms[guest] + ". "
    }
}
{doors}
-> END
Mira is in room 3. Old Tom is in room 1. Edda is in room 7.

Landed on the native surface — for k, v in m (B2). The ruled pair spelling is the two-binding loop: for guest, room in rooms { … }, defined as exactly the desugar you just wrote by hand — key iteration plus a room = rooms[guest] read at the top of each pass, total by construction, no pair value ever materializing. The ruling landed 2026-07-19 (with F10) and the two-binding form now parses and lowers on the native .brink parser (#1461). This chapter’s ~ { … } brink-dialect surface has no two-binding spelling yet, so for k in m

  • m[k] is still the pair story for the examples above.

Counting with ranges

Ranges earned their chapter in Collections as values; here is where they earn their keep. for i in a..b walks the integers ascending — and because an empty range is legal and iterates zero times, the bounds never need guarding:

VAR doubled = ""

~ {
    temp tab = #[4, 7, 2]
    for i in 0..len(tab) {
        tab[i] = tab[i] * 2
    }
    doubled = string(tab)
}
Every debt doubles after midnight: {doubled}.
-> END
Every debt doubles after midnight: [8, 14, 4].

That for i in 0..len(a) shape is doing two jobs worth naming:

  • It’s safe on empty. len(tab) of an empty array makes 0..0, which runs zero times. Emptiness is load-bearing, not an edge case to guard — the same posture that makes pick(0..n) answer none rather than fault.
  • It’s the mutation idiom. The loop variable is a copy, but an index is a place — tab[i] = … writes through to the real array, one in-bounds claim at a time. (If the body shrinks the array while the range marches on, the out-of-bounds write faults exactly as an index claim should. The range was sized at entry; the array is live.)

Two mechanical facts, so you can plan around them: a range in a for never materializes its integers — for i in 0..1000000 walks bounds, it does not allocate a million-element array — and a range only counts up: 5..2 is empty, not a countdown. To walk backwards, count up and index from the far end.

A dedicated mutating-iteration form (for ref x in xs { … }) is under design in the stdlib spec — proposed, not ruled, not taught here. The index idiom is the current answer, and an honest one.

while, break, and continue

for is for “each of these”; while cond { … } is for “as long as this holds” — re-testing its condition before every pass:

VAR pot = 0

~ {
    temp round = 1
    while round <= 5 {
        pot = pot + round
        round = round + 1
    }
}
Five rounds at the dice table and the pot holds {pot} coins.
-> END
Five rounds at the dice table and the pot holds 15 coins.

Inside either loop, two statements steer: break leaves the innermost enclosing loop immediately; continue abandons the rest of the current pass and goes straight to the next test. The classic combined shape:

VAR sum = 0

~ {
    temp i = 0
    while true {
        i = i + 1
        if i > 10 {
            break
        }
        if i mod 2 == 0 {
            continue
        }
        sum = sum + i
    }
}
The dice corner's odd throws come to {sum}.
-> END
The dice corner's odd throws come to 25.

Both words are meaningful only inside a loop. Outside one there is nothing to leave, and the compiler refuses rather than guessing — E057, “break/continue outside a loop: break used outside any enclosing while/for loop”:

~ {
    break
}
-> END

In nested loops, break/continue always bind to the innermost loop; there are no loop labels. If you need to leave two loops at once, the clean spelling is usually a function and a return — which is also this chapter’s next idiom.

The step budget, or why nothing hangs

while true with no break is a real program, and the runtime has a ruled answer to it: every turn runs under a step budget. The VM counts opcode steps; a turn that exceeds the limit (one million steps by default — narrative-scale loops use a tiny fraction of it) stops with a StepLimitExceeded error instead of freezing the player’s game. A sibling cap bounds runaway output — a turn that emits thousands of lines without reaching a choice or an end hits the line limit (10,000 by default) the same way.

Know the category, because it is deliberately harsher than a fault. A turn-terminating fault — an out-of-bounds index, a malformed question — is a recorded, deterministic, replayable part of the story’s history. A safety-limit error is not: it aborts mid-step, the story is left partway through a turn, and the host is told to treat the instance as spent and restart from a snapshot (see Errors). The budget is not a semantic boundary you tune your story against; it is the fence at the cliff edge.

The author’s mental model, in two lines: loops are for bounded, narrative-scale data — tabs, inventories, registers, grids — and every loop should have its bound visible in its shape (for over a collection is bounded by construction; a while should wear its exit condition plainly). If a loop is doing so much work that the budget is in sight, the story is simulating, not narrating, and the simulation belongs on the host side of the seam.

Finding things: loops that answer with Option

The Option chapter taught the fold-your-own idiom: when no verb answers your exact question, the loop is four honest lines. Its natural companion is the find-shaped loop — walk until something qualifies, and answer like the stdlib verbs do: some(hit) or none, never a sentinel. Wrap it in a function and let return be the early exit:

First night over 5 coins: {first_over(#[4, 7, 2, 9], 5)}.
First night over 10: {string(first_over(#[4, 7, 2, 9], 10))}.
-> END

=== function first_over(tab: Array<int>, floor: int) ===
~ {
    for coins in tab {
        if coins > floor {
            return some(coins)
        }
    }
}
~ return none
First night over 5 coins: some(7).
First night over 10: none.

The shape earns its keep three ways: the return inside the loop is the cleanest multi-level exit the language has; the two returns hand inference everything it needs (first_over settles as (Array<int>, int) -> Option<int>, no annotation); and the caller gets a value in the full absence doctrine — testable with == none, equal to some(7) exactly when the world had one. The fn-value verbs below collapse a loop shaped exactly like this one into a single filter_map call — the doctrine they answer with does not change.

Iteration across a pause — the current posture

Today, a loop always runs to completion within a single step of the story, and nothing can interrupt it from the inside — the logic-block fence keeps choices and diverts out of loop bodies, and the await suspension surface is fenced off entirely (E052, “brink extension not yet implemented”) until the flow-suspension slice lands. There is no compiling program in which a loop is half-done while the player thinks. This section is posture, not practice — flagged honestly as such.

The design has already been settled for that future, though, and it explains a choice you can otherwise only take on faith: the iterate protocol is ruled pull-shaped (a next(ref Self): Option<T> step, “every element exactly once; none is terminal and sticky”) precisely so that for desugars inline and an in-flight iteration can park inside a suspended flow and resume after it wakes. Ranges became real, serializable values (F7) for the same reason — a parked loop’s cursor has to survive a save. When suspension ships, loops will cross it without this chapter changing its contracts.

The fn-value verbs: map, filter, fold, filter_map, each, map_each

The ruled iteration surface is larger than for. Its centerpiece is a family of six verbs, every one taking a function value as its last argument — a pure quartet plus a deliberately-ugly effectful pair:

  • The pure quartet requires pure·silent callbacks. map (transform each), filter (keep some), fold (combine into one), and filter_map (transform-and-drop — see below) accept only callbacks whose effect rows are pure and silent — reading story state is legal (filtering on state is the bread-and-butter case), writing, emitting, and drawing randomness are not. Faulting is permitted; a callback that can fault makes the whole call able to fault, rows composing as usual (Effects). A callback whose origin is not statically visible — routed through a variable rather than written inline as #fn(target) — is not provably pure, so the compile-time check cannot fire for it; the VM’s own output isolation and its dev-mode world-write guard are the runtime backstop either way.
  • “One logical pass, order unobservable.” Because callbacks are pure, whether stages interleave or fuse is unobservable by construction — the eager-versus-lazy question is dissolved, not deferred, and the implementation may fuse freely, forever.
  • filter_map(a, f) — the Option-aware mapper: f returns Option<U>, some(v) is kept and unwrapped, none drops the element. It is the ruled bridge to the Option chapter, and the one-line form of the find-shaped loop above: filter_map(coins, #fn(over_amount)) in place of the hand-rolled loop, same doctrine.
  • Effectful iteration is a different concept with different spellings: each (do something per element, no result) and map_each (the effectful transform — sequential, in iteration order, element by element, never fused). The standing naming law was ruled with them: the weird thing gets the ugly method. Convenience is spent on the pure spelling; the friction in map_each is the speed bump. A write or emission that would be an E119 compile error (or a dev-mode runtime fault, for an opaque callback) inside map’s callback is legal inside each’s or map_each’s — that is the entire reason the pair exists. They are deliberately not gated by E119.
VAR seen = 0
~ temp coins = #[3, 12, 7, 20, 1]

Doubled big coins: {filter_map(coins, #fn(double_if_big))}.
~ each(coins, #fn(tally))
Total seen: {seen}.
-> END

=== function double_if_big(n: int) ===
~ {
    if n > 10 {
        return some(n * 2)
    }
}
~ return none

=== function tally(n: int) ===
~ seen = seen + n
Doubled big coins: [24, 40].
Total seen: 43.

for is not a lesser substitute for any of these — it is the same closed iterable set, the same snapshot contracts, and every one of these verbs answers a computation shaped exactly like a loop you already know how to write.

Reference: the diagnostics in this chapter

CodeFires whenPolicy
E051loop syntax (a ~ { … } block) in a strict-ink projectdialect gate
E052an await form (including while await) — suspension not yet implementedboth
E054a loop variable or block temp shadows an already-visible tempboth (warning)
E057break/continue outside any enclosing while/forboth
E065a loop variable with no element type (e.g. iterating a non-iterable) escapes as Unknownstrict

And the runtime’s side of the line: iterating a value outside the closed set is a turn-terminating “not indexable” fault in every mode; a mid-loop faulting read (m[k] on a key removed after the snapshot, a[i] past a shrunk array) is the ordinary indexing fault it always was; and a loop that exhausts the step or line budget is a safety-limit error — mid-step, instance spent, restart from a snapshot.

Where this is ruled

  • The loop surface (while/for/break/continue inside ~ { … } blocks; the pure-logic fence; block scoping and the E054 shadow warning; E057) — docs/t1b-surface-spec.md §2; the T1b landing (#577).
  • One closed iterable set; sequences & iteration as one designdocs/stdlib-spec.md §4; decision log 2026-07-18.
  • The iterate protocol: pull-shaped, laws attached (“every element exactly once; none terminal and sticky”; pull chosen so iterators park across suspensions) — docs/stdlib-spec.md §9.6; decision log 2026-07-18 (protocol registry); NS-A3 (#1109).
  • F10: the map key set snapshots eagerly at loop entry (maps’ for is a deliberate exception to live pull; removed-key reads fault honestly) — decision log 2026-07-19 (Phase C findings ruling); docs/stdlib-spec.md §5. The for k, v in m desugar ruled with it; the native .brink surface landed with Track B2 (#1461); the ~ { … } brink dialect this chapter teaches has no two-binding spelling yet.
  • F7/F8: ranges as a real Value kind; empty ranges iterate zero times; refinements inert under gradual — decision log 2026-07-19; docs/stdlib-spec.md §7; NS-A5 (#1136).
  • The fn-value verbs: pure-quartet-required; eager/lazy dissolved; each/map_each; “the weird thing gets the ugly method” — decision log 2026-07-18; docs/stdlib-spec.md §4. Landed 2026-07-28, issue #1679: map/filter/fold (slice 1), then filter_map/each/ map_each (slice 2), same day.
  • Mutating iteration for ref xdocs/stdlib-spec.md §4, marked proposed (🔶): under design, not ruled, not taught.
  • The step budget (“guard against unbounded growth”; safety-limit errors abort mid-step, restart from snapshot) — the runtime’s standing posture; see Errors and Speculation for the host-facing view.

Ordering, Sorting, and Comparing

Closing the ledger at the Last Light, three questions get asked of the same night’s numbers — put them in order, put them in my order, and name the extremes:

~ temp tab = #[4, 7, 2, 5]
The night's takings, in the order they landed: {tab}.
For the magistrate's fair copy: {sorted(tab)}.
~ sort_by(tab, #fn(largest_first))
Calling in debts from the top: {tab}.
Lightest night: {min(tab)}. Heaviest: {max(tab)}.
-> END

=== function largest_first(a: int, b: int): int ===
~ return b - a
The night's takings, in the order they landed: [4, 7, 2, 5].
For the magistrate's fair copy: [2, 4, 5, 7].
Calling in debts from the top: [7, 5, 4, 2].
Lightest night: some(2). Heaviest: some(7).

The Collections chapter introduced these verbs as part of the working surface; this chapter is the doctrine underneath them. One pinned order serves every ordering verb in the language — the sort family, min/max, and the heap verbs — and one line of doctrine governs its only hard case: dev mode changes where execution stops, never what values appear. What follows is the roster of what orders, the contract your comparators owe, the NaN story in full, the priority queue in the corner, and the deliberate gap between ordering and equality.

Current spelling — examples in this chapter compile in today’s brink dialect: collection literals carry the #[…] sigil, the verbs are free calls (sort(tab), heap_push(open, 3)), and a comparator is a function value spelled #fn(name) over a declared function (see Function Values). The ruled native spellings — method position (tab.sort(), with UFCS auto-ref on the receiver) and lambda comparators (tab.sort_by(|a, b| b - a)) — arrive with the native frontend; the semantics taught here do not change with them.

What orders

The ordering verbs share one doctrine order, a single total order over a closed roster of types:

TypeOrder
int, floatnumeric, cross-comparing — 1 < 1.5 < 2 in one array
boolfalse < true
stringlexicographic by Unicode scalar value
arrayslexicographic element-wise, recursively — first differing element decides; a full prefix ties to the shorter array
~ temp mixed = #[2, 1.5, 1]
~ sort(mixed)
mixed numerics: {mixed}.
~ temp words = #["pear", "apple", "fig"]
words: {sorted(words)}.
~ temp nested = #[#[2], #[1, 5], #[1]]
lex: {sorted(nested)}.
-> END
mixed numerics: [1, 1.5, 2].
words: [apple, fig, pear].
lex: [[1], [1, 5], [2]].

Two of those rows carry footnotes worth knowing. String order is by Unicode scalar value — a deterministic, locale-free order that puts "Zebra" before "apple"; proper locale-aware collation is the intl pipeline’s business, like casing. And the array row is recursive with a depth cap: an array nested past 64 levels faults rather than chase a pathological self-referential structure down the stack.

Everything else is not orderable, and asking is a malformed question in the Option chapter’s sense — a turn-terminating fault (“sort cannot order element of type map”), never a shrug and never a guessed order:

  • maps — no defined order between entries;
  • Option<T>some/none don’t order (ch. 14 drew this line);
  • flags subsets — a partial order, deliberately not forced total;
  • divert targets, function values, ranges, Weighted<T> tables;
  • the numeric tower (vec2mat4) — vectors have no one honest lexicographic order, so tower kinds are not orderable, and the compiler refuses even a compare protocol registration for them (E118);
  • structs and enums — today. The ruled path for records is an explicit compare protocol impl (fn(T, T): int, and nothing else: field declaration order is never silently promoted to ordering semantics). The impl-block spelling hasn’t reached the dialect yet, so until it lands, sorting records is simply a fault.

A cross-type pair of individually-orderable elements (#[1, "x"]) is just as malformed — the doctrine order crosses int/float and stops there. The fault names the offending element’s type, and in an extremum or sort walk it fires the moment the pair is compared.

The doctrine order’s smallest consumers are min and max: least and greatest element by exactly this order, none on an empty array (the Option chapter’s absence doctrine — an empty extremum is an honest answer, not a bug), and on ties they keep the first occurrence, deterministically. min(a) always agrees with first(sorted(a)), by construction — one order, every verb.

The four sort verbs

The sort family is four verbs on a two-way grid, and the grid is the naming law doing its job — the verb carries the mutation signal:

doctrine orderyour comparator
imperative, in-placesort(a)sort_by(a, cmp)
past-participle, functionalsorted(a): [T]sorted_by(a, cmp): [T]

The imperative pair mutates in place and returns nothing, so it is statement-only and its receiver must be an lvalue — the confusion lattice from Collections closes over the whole family. Using one as an expression fails with E056, “sort mutates its first argument and returns nothing — it can only be used as a statement, not an expression”:

~ temp tab = #[3, 1, 2]
~ temp tidy = sort(tab)
{tidy}
-> END

The fix is one edit in either direction: sorted(tab) if you wanted a copy, or sort(tab) on its own line if you wanted the ledger itself reordered. Handing the imperative form a temporary (sort(#[3, 1])) is the familiar E055 lost-write refusal, and a wrong argument count is E058, naming the expected signature (sort(array), sort_by(array, comparator)).

Two properties hold across all four verbs, in every mode, forever:

Sorting is stable. Equal elements keep their input order. Sort the tab by amount and two 4-coin nights stay in chronological order — which is what makes sorting by one aspect of a value trustworthy:

~ temp stock = #["cider", "ale", "bread", "gin"]
~ sort_by(stock, #fn(shortest_first))
Shelved by label width: {stock}.
-> END

=== function shortest_first(a: string, b: string): int ===
~ return len(a) - len(b)
Shelved by label width: [ale, gin, cider, bread].

ale and gin tie at three characters and keep their shelf order; cider and bread tie at five and do the same. A second sort by a second aspect refines the first instead of scrambling it.

Sort never implies dedup. A sort with ties drops nothing — ordering and equality are separate questions (the last section returns to this), and no ordering verb ever removes, merges, or invents an element. The guarantee floor, straight from the ruling: the result is some permutation of the input, never worse — even a comparator that faults mid-sort leaves nothing fabricated behind it.

Comparators are a contract

sort_by/sorted_by hand ordering to your function, and the shape of that function is ruled: fn(T, T): int — negative means “a first”, zero means “tie”, positive means “b first”. Any int does; b - a is the classic descending one-liner. A comparator must also be:

  • Pure and silent. No writes, no content output, no tags, no randomness — and no reads of story state either. The order must be a function of the two comparands and nothing else, because how many times the sort calls your comparator, and in what order, is an implementation detail you can’t observe on a clean comparator and must never be able to observe on a dirty one.
  • A consistent total order. If cmp(a, b) says a first, cmp(b, a) must say b first, and ties must behave like ties. The implementation is permitted to fault on a detected inconsistency; what it will never do is loop or lose data (the permutation floor above).
  • Allowed to fault. Purity here is deliberately not totality — a comparator that faults on bad data is honest, and its fault ends the turn like any other.

Where the compiler can prove a violation — the comparator is a named #fn(target) whose inferred effect row shows a read, write, emission, or external call — it refuses at compile time with E119. This fails with “sort_by’s comparator luckiest_first reads lucky — a comparator must be a pure, silent fn(T, T): int (stdlib-spec §4b: the order must depend only on the two comparands)”:

VAR lucky = 4

~ temp dice = #[3, 6, 2]
~ sort_by(dice, #fn(luckiest_first))
{dice}
-> END

=== function luckiest_first(a: int, b: int): int ===
~ return (a - lucky) * (a - lucky) - (b - lucky) * (b - lucky)

Sorting by distance-from-a-favorite is a perfectly good idea — but this spelling makes the order depend on lucky, which is exactly what the contract bans. Today’s honest fix is to restructure so the sort key is in the data (build the array of distances and sort that); when lambdas land, capturing lucky by value into the comparator will be the one-line spelling of the same fix.

The check is exceedance-only: it fires on what it can prove, never on what it can’t. A comparator that arrives as an opaque value — a variable, a parameter, a bind(…) result — passes the gate, and the runtime holds the residual line instead. During the sort, each comparator call runs isolated: anything it prints is captured and discarded, and misbehavior the VM can observe is a turn-terminating fault with a taxonomy of its own:

FaultFires when
ComparatorNotAFunctionthe second argument isn’t a function value — “sort_by comparator must be a function value fn(T, T): int, got int”
ComparatorReturnTypethe comparator returned a non-int — “got string”, never a silent coercion
ComparatorEscapedthe comparator presented a choice, reached -> DONE/-> END, called an external function, exceeded the nested evaluation step budget, or recursed past the nesting depth limit — “comparators must be pure, silent functions”

The budgets behind that last row exist so a divergent comparator can never hang the story: each comparator call runs under its own million-step budget inside the sort’s single VM step, and comparator-inside-comparator nesting (a comparator that itself sorts) is capped at depth 8. Two honest footnotes on the current machinery: calling a comparator counts a visit on that function, exactly like any other in-story function call; and a contract violation that slips both the static gate and the VM’s observation — an opaque comparator that writes a global, say — is not currently intercepted, so its writes really happen, at an unspecified number of invocations in an unspecified order. That is a contract violation, not a technique; whether the runtime should grow its own write-guard is an open design question, on the docket, not ruled.

One more thing sort_by deliberately does not do — see the next section for why that’s interesting.

NaN, dev, and prod

Floats bring one lawless guest to every ordering table: NaN, which IEEE comparison refuses to place. The language’s arithmetic is NaN-total0.0 / 0.0 is NaN, and it flows through +, *, and every math verb without a fault, by ruling. Ordering contexts are where the flow stops, and what happens there is the doctrine this chapter is named for.

Dev mode — the default — faults, loudly. A NaN comparand reaching sort/sorted/min/max/heap_push is a turn-terminating fault:

`sort` reached a NaN comparand — NaN cannot be ordered (dev-mode fault; prod mode places NaN by the pinned total order)

The point is bug archaeology: the NaN was born upstream, in some arithmetic that went wrong, and dev mode surfaces it at its first ordering consumption — while you can still find the cause. The check is about the operand, not the comparison: min of the one-element array #[0.0 / 0.0] faults in dev even though there is nothing to compare it with, because the NaN in an ordering context is the bug. The scan is recursive — a NaN inside a nested array is found the same way.

Prod mode keeps moving. The same NaN is placed by the pinned total order: ordinary IEEE order where IEEE has an answer, -0 tying with +0, NaN greater than everything, NaN tying with NaN. Sorting #[1.0, 0.0 / 0.0, -1.0] in prod yields [-1, 1, NaN] — every element preserved, deterministically placed, save- and replay-safe. (This is deliberately not IEEE’s totalOrder, which splits -0 from +0 and would make sorting disagree with == on perfectly clean data.)

On NaN-free data the two modes agree exactly, and both cohere with < and == — flipping a clean story to prod changes nothing. That’s the author-level line to carry: the mode changes where execution stops, never what values appear. No mode fabricates, coerces, or drops anything; dev halts at the bug, prod files it at the end of the array.

And the split is fenced to exactly this kind of case. The dev/prod knob exists only where the prod behavior is defined, total, and fabricates no data. Placement qualifies. Fabrication never does: int("potato"), an out-of-bounds index, a malformed question — those fault in every mode, forever, and no future knob will soften them. There will never be a “prod mode” that invents a value to keep the story moving.

Three practical corollaries:

  • sort_by is not on the dev fault list — deliberately. Your comparator owns the element semantics; a NaN never reaches the ordering machinery as a comparison result, so sort_by faults only on its own terms (dispatch, return type, whatever the comparator’s body does). If your comparator wants NaN hygiene, it implements it.
  • Effect rows don’t know modes exist. An ordering verb over [float] carries the fault possibility in its row unconditionally — the conservative union — while [int]/[string]/[bool] orderings are provably total and their fault charge is discharged. A totality-gated position (a wake condition, say) therefore rejects a float ordering in both modes, which is correct: a NaN-able wake condition is a landmine regardless of build profile.
  • The knob is a host/build setting, not story state. The runtime API is Story::set_exec_mode / FlowInstance::set_exec_mode with ExecMode::Dev as the default (see Runtime API); a shipping host flips to ExecMode::Prod for release builds. The ruled home for the setting is project configuration (a brink.toml profile) with the host API as override — the config side isn’t wired yet, so today the host API is the whole surface. The mode is never embedded in compiled .inkb, never persisted in a save, and never consulted by the checker. (What the default should be for engine integrations like bevy is a pending question on the docket — not ruled, so not taught.)

The heap in the corner

The dice corner keeps a queue: whoever’s owed the smallest debt gets paid first. The language’s priority queue is deliberately humble — three verbs over an ordinary array, not a new type:

VerbSignatureNotes
heap_pushheap_push(a: [T], x: T)statement-only mutator; add x, restore the invariant
heap_popheap_pop(a: [T]): Option<T>remove and return the minimum; none on empty
heap_peekheap_peek(a: [T]): Option<T>read the minimum without removing; none on empty

The heap is a min-heap over the doctrine order — the same comparison core as sort and min, one order for the whole language — and its contract is the invariant: an array built through heap_push always pops in ascending order, no matter how the pushes interleaved:

~ temp open = #[5, 9, 8]
~ heap_push(open, 3)
~ heap_push(open, 7)
peek: {heap_peek(open)}
~ temp first = heap_pop(open)
~ temp second = heap_pop(open)
popped: {first} then {second}
drain: {heap_pop(open)} {heap_pop(open)} {heap_pop(open)} {string(heap_pop(open))}
-> END
peek: some(3)
popped: some(3) then some(5)
drain: some(7) some(8) some(9) none

Everything in that transcript is doctrine you’ve already met. The pops come back as Option<T> because an empty heap is absence, not a bug — the Option chapter’s line verbatim, and the final none is the drain loop’s natural stopping signal (spelled string(…) on that last pop only, so the signal stays visible on the page — a bare {heap_pop(open)} on the exhausted heap would print nothing at all, per How Option prints). heap_push is a statement-only, lvalue-first mutator (E055/E056/E058, exactly the sort family’s manners); heap_pop is a hybrid like pop — it mutates and answers, so it’s legal in expression position, with the same bare-variable receiver rule. And the §4b NaN doctrine applies at the door: heap_push checks the entering element (dev faults on a NaN anywhere inside it; prod places it by the pinned order), and because every element arrived through that check, a clean heap stays clean by induction — heap_pop and heap_peek never re-scan.

The bounded-priority-queue loop, today’s spelling:

~ temp bell: Array<int> = #[]
~ heap_push(bell, 12)
~ heap_push(bell, 3)
~ heap_push(bell, 7)
~ heap_push(bell, 1)
~ temp calls = ""
~ {
    while len(bell) > 0 {
        temp room = heap_pop(bell)
        calls = calls + " " + string(room)
    }
}
Rooms answered in order:{calls}.
-> END
Rooms answered in order: some(1) some(3) some(7) some(12).

(The some(…) in the prose is the total Option render from ch. 14, kept honest on purpose; the ruled as-binding will make while heap_pop(…) as room { … } the clean drain loop when it lands.)

The humility of the design has one sharp edge to respect: the array is just an array. Nothing marks it as a heap, nothing stops you indexing it, printing it, or sorting it — and nothing verifies it. The verbs maintain the invariant over arrays built through them; feed heap_pop an arbitrary array and it will treat element 0 as the minimum and dutifully re-sift, garbage in, garbage out. The middle of a heap array is not sorted — only the root is special — so read it only through heap_peek/heap_pop, and build it only through heap_push (starting from empty, or from an array you know satisfies the invariant). If real projects show this shape-confusion biting, a sealed Heap<T> type is the recorded upgrade path — designed, not built, waiting on evidence.

One neighbor deliberately not in this chapter: the dice corner’s weighted table. Weighted<T> and roll live with the randomness story — a draw writes the RNG cell, and the table’s evidence-by-construction contract belongs beside seeds and replay in the Randomness chapter. The heap lives here because ordering is its engine; Weighted merely lives near it in std::collections.

Ordering is not equality

The last piece of the doctrine is a deliberate separation. The language has two comparison surfaces, and they answer different questions:

The operators stay frozen IEEE. <, <=, >, >=, ==, != on floats behave exactly as ink and IEEE defined them: NaN < x is false, NaN > x is false, and NaN == NaN is false — NaN is not equal to itself, and no operator ever consults the pinned total order:

~ temp broken = 0.0 / 0.0
The chalk reads {broken}. Equal to itself: {broken == broken}. Less than one: {broken < 1.0}.
-> END
The chalk reads NaN. Equal to itself: false. Less than one: false.

Only the verbs carry the ordering doctrine — the third application of the language’s standing two-surface pattern (operators keep their inherited, oracle-guarded meaning; verbs carry the ruled semantics). The practical reading: x != x remains the honest NaN test, an if a < b in your own code never faults and never uses the pinned order, and the doctrine only engages when you hand data to an ordering verb.

Compare and equality are allowed to disagree. When the compare protocol opens to user types, it is ruled as ordering only: equality stays structural, always, and a compare impl that calls two values a tie does not make them ==. Sort the guest ledger by family name and two Coopers tie for placement while remaining distinct guests — which is also why sort never dedups: ties are an ordering fact, not an identity claim. (The protocol’s contract is pure·silent·total — stricter than a sort_by comparator, which may fault — because a registered order speaks for the type everywhere, sight unseen.)

Reference: the diagnostics in this chapter

CodeFires whenPolicy
E055an ordering mutator’s first argument isn’t an lvalue (sort(#[2, 1]), heap_push(sorted(a), x))both
E056sort/sort_by/heap_push used in expression position — they return nothingboth
E058wrong argument count on sort/sort_by/heap_push — names the expected signatureboth
E118a protocol impl registration names a numeric-tower kind — tower kinds are compiler-known and not orderable (registration is a programmatic surface today; no source spelling reaches it)both
E119a provably impure/unsilent #fn comparator on sort_by/sorted_by — exceedance-only (the same code also gates the fn-value verbs map/filter/fold)both

And the runtime’s side of the line, all turn-terminating faults: NotOrderable (an element outside the roster, or a cross-type pair); UnorderedComparand (dev mode only — a NaN comparand at sort/sorted/min/max/heap_push); the comparator taxonomy (ComparatorNotAFunction, ComparatorReturnType, ComparatorEscaped); and the ordinary wrong-container fault (sort on a map, heap_pop on an int). min/max/heap_pop/heap_peek on an empty array are none of these — that’s absence, and it answers none.

Where this is ruled

  • The ordering doctrine (the pinned total order and its roster; the dev-fault/prod-placement split; the fence — “placement qualifies; fabrication never does”; mode-independent rows; operators frozen IEEE; the comparator law) — docs/stdlib-spec.md §4b; decision log 2026-07-18 (“The ordering doctrine: NaN faults at ordering contexts in dev, pinned placement in prod”). Implementation NS-A4 (#1110, PR #1149).
  • F0: the four-verb gridsort_by in-place, sorted_by the functional twin, per the naming law — decision log 2026-07-19 (“Findings batch 1 ruled”).
  • F14: sort_by off the dev NaN-fault list (the comparator owns the element semantics) — docs/stdlib-spec.md §4b, as-built amendment dated 2026-07-19 with NS-A4.
  • The knob’s home (project config profile + host-API override; today the host API is the implemented leg) and compare/equality coherence (compare is ordering only; sort never implies dedup) — decision log 2026-07-19 (“Tower mini-spec ruled (T1-T5)…; knob home; compare coherence”). Tower kinds not orderable: tower-mini-spec T4; E118 with NS-A8 (#1114).
  • The comparator contract (pure·silent — reads included — plus the consistent-total-order law; E119 exceedance-only; the runtime residual taxonomy) — docs/stdlib-spec.md §4b; NS-A4 (#1110). The possible runtime write-guard is an open docket question (F34) — not ruled, not taught.
  • F29(a): refined fault discharge (provably NaN-free orderings don’t carry the conservative faults bit) — docs/stdlib-spec.md §4b, ruled by delegation 2026-07-19 (not fully reviewed).
  • The humble heap (verbs over plain arrays in std::collections; min-heap on the doctrine order; entry check at heap_push; Option on empty; sealed Heap<T> recorded as the upgrade path) — docs/stdlib-spec.md §8; decision log 2026-07-18 (“Collections+ ruled”). Implementation NS-A7 (#1113, PR #1156). The bevy-facing ExecMode default is the docket’s F35 — pending.
  • The absence returns (min/max/heap_pop/heap_peekOption on empty) — the 2026-07-18 absence doctrine; Option and Absence.

Standard Library

The first stdlib slice ships four pure functions and four mutators, all lowercase free functions — no method-call syntax (a.push(v) isn’t supported; it collides with ink’s dotted knot/stitch paths).

FunctionKindSignatureResult
len(x)purearray or map → Intelement/entry count
keys(m)puremap → Arraykeys, insertion order
values(m)puremap → Arrayvalues, insertion order
contains(x, v)purearray or map, any vBoolarray: element membership; map: key membership
push(a, v)mutatorarray, any vappends v
insert(x, k_or_i, v)mutatorarray or map, key/index, valuearray: insert at index (shifts right); map: insert-or-overwrite
remove(m, k)mutatormap, keyremove key (no-op if absent)
remove_at(a, i)mutatorarray, indexremove at index (shifts left); out of bounds faults
VAR arr = 0
VAR m = 0

~ {
    arr = #[10, 20, 30]
    m = #{"a": 1, "b": 2}
}

len(arr) = {len(arr)}, len(m) = {len(m)},
contains(arr, 20) = {contains(arr, 20)}, contains(arr, 99) = {contains(arr, 99)},
contains(m, "a") = {contains(m, "a")}, contains(m, "z") = {contains(m, "z")}
-> END
len(arr) = 3, len(m) = 2,
contains(arr, 20) = true, contains(arr, 99) = false,
contains(m, "a") = true, contains(m, "z") = false

push(a, v) is exactly insert(a, len(a), v) — it’s a named shorthand for “append,” not a separate operation with its own semantics. On an array, the one write insert is allowed to reach one past the current end is index == len — that’s what makes appending well-defined without silent growth anywhere else: any other out-of-range index still faults, matching the indexing rule in Indexing & Mutation.

contains is total

Unlike indexed reads, contains never faults — it always answers true or false, including on inputs that would fault elsewhere:

  • On an array, contains(arr, v) is a structural-equality scan; v can be any value at all, including another collection.

  • On a map, contains(m, v) checks key membership. If v is outside the ratified key domain (a float, an array, a map, …), the answer is simply false — not a fault. A value that can never be a key isn’t a member; there’s no “the key isn’t there” failure mode to escalate to a runtime fault the way an indexed read (m[v]) legitimately does for a present-but-wrong-typed key. This makes contains behaviorally uniform across both container kinds — you don’t need to already know a map’s key domain just to test membership without risking a crash.

    Under types = strict, though, when both the map and the needle’s out-of-domain type are already statically visible, contains(m, v) is flagged at compile time by E152 (Warning severity) — the call is always false, and the diagnostic exists to catch what’s usually a typo or a stale key type rather than intentional code. E152 is strict-mode only (it stays silent under types = gradual, where the runtime’s total false above remains the only behavior), and like other warnings it can be re-leveled or suppressed via [lints] (e.g. E152 = "deny" or E152 = "allow"), a //brink-disable comment, or (native dialect only) a declaration-scoped @[allow(E152)] annotation.

Mutators require an lvalue

push/insert/remove/remove_at mutate their first argument, so that argument has to be a place to write the mutated container back into: a bare variable or temp, or an (arbitrarily chained) indexed path rooted in one — grid[1] is a valid mutator target, a bare literal or call result is not. Passing anything else is a compile error (E055, “collection mutator’s first argument is not an lvalue”):

~ push(#[1, 2, 3], 4)
// E055: `push` mutates its first argument — bind it to a variable first

The rule exists so the surface never implies reference semantics that the value model doesn’t have. A collection is a value; “mutating” it always means “compute the new value and write it back somewhere” — the lvalue rule just makes that “somewhere” explicit and mandatory instead of silently discarding the result.

Because they mutate, the four mutators lower through the identical take → make_mut → write-back path indexed assignment uses, and a nested target works the same way an indexed assignment’s chain does:

VAR grid = 0

~ {
    grid = #[#[1, 2], #[3]]
    push(grid[1], 4)
    insert(grid[0], 0, 0)
}

Grid is {grid}.
-> END
Grid is [[0, 1, 2], [3, 4]].

Mutators also return nothing — they’re statement-only. Using one in expression position (~ x = push(a, v)) is a compile error, E056 (collection mutator used in expression position).

Wrong argument count is a compile error

Calling a mutator with the wrong number of arguments — push(arr), insert(m, "k"), remove_at(arr, 0, 1) — is a targeted, error-severity compile error (E058, collection mutator argument count mismatch) naming the expected signature. push(arr)’s diagnostic message (as returned by ResolvedDiagnostic — see Enabling the Dialect for how the CLI vs. library API surface it) reads:

collection mutator argument count mismatch: `push` expects 2 argument(s),
got 1 — expected signature: `push(container, value)`

This is stricter than ordinary function-call arity checking (E031), which is only a warning and still compiles — a pure stdlib function (len/keys/values/contains) called with the wrong arity keeps using E031, unchanged. Mutators are held to the harder standard because a malformed mutator statement has no fallback value to silently produce: a push/insert/remove/remove_at call that doesn’t lower to anything is a read-modify-write that never happened, which is exactly the kind of silent data-drop this project treats as a bug rather than a warning.

Author-defined functions shadow the builtins

These eight names live in the brink dialect only — a strict-ink project never sees them as reserved words, so plain ink content that happens to define a knot or function called len keeps working unmodified even after a project turns the dialect on. If an author defines a function with the same name as a stdlib builtin, the author’s definition wins, with a warning (E035, the same “name shadows a built-in function” diagnostic ordinary built-ins already use):

VAR arr = 0

~ {
    arr = #[1, 2, 3]
}

len is {len(arr)}.
-> END

=== function len(x: Array<int>)
~ return 999
len is 999.

Values & Types

The Last Light inn charges by the night, and the innkeeper does not haggle:

VAR gold: int = 12
VAR room_rate: float = 3.5

The innkeeper chalks the rate on the slate: {room_rate} coins a night.
With {gold} coins, you can stay {nights_affordable(gold, room_rate)} nights.
-> END

=== function nights_affordable(purse: int, rate: float): int ===
~ return int(float(purse) / rate)
The innkeeper chalks the rate on the slate: 3.5 coins a night.
With 12 coins, you can stay 3 nights.

Everything in that scene has a type: gold is an int, room_rate is a float, nights_affordable takes one of each and gives an int back. The compiler checked all of it before the story ran — the division that needed both sides to be floats, the conversion back down to whole nights, the interpolations. If any of it had been wrong, you’d have gotten a compile error naming the exact spot, not a story that quietly printed nonsense.

That checking posture — strict by default, inferred almost everywhere, declared at the edges — is the subject of this chapter.

Current spelling — examples in this chapter compile in today’s brink dialect: functions are ink-style === function … === knots and collection literals carry the #[…]/#{…} sigils. The ruled native .brink spellings — fn declarations, bare […] literals, [T]/[K: V] type notation — arrive with the native frontend, and this chapter’s examples will be respelled then.

One checker, two policies

Every brink-dialect project has a type checker running underneath it, whether or not it ever shows up. types = strict and types = gradual are two policies over the same checker — not two type systems — so the policy never changes what a program means, only whether the compiler insists on proving more about it before it’s allowed to run.

Strict is the language’s posture. Since the 2026-07-19 typing-posture ruling, a brink-dialect project with no types setting is strict; the native .brink surface, when it lands, is strict-only. You opt out per project, in brink.toml:

[project]
dialect = "brink"
types   = "gradual"   # explicit opt-out; omitting `types` means strict here

Gradual is the compat floor, and it is permanent. It is the strict-ink dialect’s default forever — the mode every plain-ink project is in. Types are still inferred internally for tooling’s benefit (hover, inlay hints, advisory diagnostics), but nothing you write is required to resolve to a concrete type: a slot the checker can’t pin down stays Unknown, and Unknown defers to the runtime’s usual coercion behavior, unchanged from how ink has always worked. Adding type annotations under gradual doesn’t opt you into anything stricter by itself.

Turning strict on changes three things, and only these three:

  • An Unknown that would otherwise have escaped inference becomes a compile error (E065, “annotate or restructure”) instead of a silent fallback.
  • The coercion lattice narrows (see below) — most cross-type operations that gradual mode quietly tolerates become errors.
  • Every variable becomes mono-typed, and collection element types must unify per collection (#[1, 2.0] is fine — see the note on int → float below — but #[1, "a"] is a compile error).

Strict mode requires the brink dialect (its annotation syntax is dialect extension syntax, same as blocks and collection literals). Asking for types = strict under strict-ink is a targeted config error, not a silent no-op:

error[E064]: types = strict requires dialect = brink — strict typing's
annotation syntax is a brink-dialect extension (docs/typed-mode-spec.md §1);
set `dialect = brink` or drop back to `types = gradual`

The oracle-anchored strict-ink subset — the plain-ink corpus this whole compiler is validated against — is untouched by construction: a strict-ink project always resolves to gradual, so strict typing only ever applies to a project that has already opted into the brink dialect.

The value kinds

Every value in a running story is one of a small, closed set of kinds. The first four are the ones every scene touches:

  • int — whole numbers: gold coins, visit counts, dice pips. Ink’s native integer, 32-bit.
  • float — fractional numbers: rates, weights, distances.
  • booltrue/false. Conditions want one (with one deliberate exception — see the visit-count idiom below).
  • string — text as a value: names, keys, anything you compare or store rather than merely print.

Beyond the scalars: divert (a knot/stitch target held as a value, -> market in value position), arrays and maps (Collections and Indexing & Mutation), LISTs (ink’s flag-set type, nominal per declaration), structs (declared shapes, below), function values (Function Values), ranges, the numeric tower (vec2mat4), handles (host-owned resources, typed by the engine’s manifest), and content (a first-class, fragment-capture-backed value — the type of a captured prose run, e.g. an annotated handler’s text: content parameter).

When you write a type — in any annotation position — primitives are lowercase and every other type name is Uppercase:

Written asMeaning
int, float, bool, stringthe scalar kinds
contenta captured prose run (fragment-capture-backed)
diverta divert target as a value
voidreturn position only: “this function returns nothing”
List<L>L names a declared LIST
Array<T>, Map<K, V>typed collections
Option<T>, Weighted<T>see below
fn(T…): Ra function value (Function Values)
vec2 vec3 vec4 quat mat2 mat3 mat4the numeric tower
Handle<K>K names a handle kind from the host manifest
a declared STRUCT namethat struct’s shape

A name outside this vocabulary is E061 (“ is not a recognized type”), which lists exactly the table above. Option<T> appears in inferred types, diagnostics, and the Standard Library’s signatures (find, map get), and — like Weighted<T> — is now annotatable too (issue #1552); a bare none with no surrounding context to type it is still its own error (E107, “bare none needs a type from context”). range stays construction-only for now: no annotation spelling yet.

Inferred inside, declared at the edges

You will write far fewer annotations than the previous sections might suggest, because the compiler reads your function bodies and works signatures out bottom-up through the call graph:

-> ledger

=== function tab(nights) ===
~ return nights * 4

=== ledger ===
Three nights at four coins each: {tab(3)} coins.
-> DONE
Three nights at four coins each: 12 coins.

tab carries no annotations, and under strict that’s fine: the body multiplies its parameter by a whole number, and that’s evidence enough — inference settles the signature as (int) -> int on its own. Mutually recursive helpers are solved together as a group, and each definition gets exactly one concrete signature, never a generic one.

Note where the evidence came from: the body, only ever the body. The call tab(3) is checked against the settled signature; it never feeds into it. This directionality is deliberate — a definition’s meaning can’t be changed at a distance by whoever happens to call it — and it has one consequence you’ll meet in practice: a parameter whose body-uses are all type-neutral (interpolation, passing it along to another unconstrained slot) has no evidence to settle on, no matter how plainly its call sites type it. That’s what the Unknown escape below is about.

Annotations are required at exactly one place: boundaries. A boundary is any signature the compiler can’t see through from the call graph alone — host-callable entry points, externals crossing to the engine, anything whose callers aren’t all visible. Internal helper functions never require an annotation. There is no separate “you forgot to annotate a boundary” diagnostic; the requirement enforces itself through inference: a boundary parameter no body-use pins down escapes as Unknown, and strict mode turns that escape into E065 at the definition — annotate it and the error goes away.

The practical corollary: an inferred signature ripples to its callers when the body changes. That’s an accepted, on-the-record cost — a body bug can surface as an error at a caller rather than at its own definition — in exchange for never forcing annotations onto code that doesn’t cross a boundary.

Annotations

There is exactly one way to write a type: name: type after the thing it types, and ): type in a function header’s return position. It works on parameters, return types, VAR and CONST declarations, and ~ temp ascriptions:

VAR gold: int = 100
CONST RATE: float = 1.5

~ temp name: string = "hero"
~ gold = heal(gold, 10)

{name} has {gold} gold at rate {RATE}.
-> END

=== function heal(hp: int, amount: int): int ===
~ return hp + amount
hero has 110 gold at rate 1.5.

Annotations are optional almost everywhere — the ones on heal above are documentation more than necessity, since inference would have found the same signature. But an annotation is never just a comment. It is a firewall: the declared type is what callers see, whatever the body does, and the checker then verifies the body against the declaration. When the two disagree, that’s E063 — “annotated type string disagrees with the type inferred from usage (int)”:

-> tally("a quiet night")

=== tally(count: string) ===
{count > 2:
    A crowd tonight.
}
-> DONE

Under types = gradual, E063 stays a warning — advisory seasoning. Under types = strict it is promoted to a hard error: a signature that lies about its body is exactly the kind of latent bug strict mode exists to catch.

E063 isn’t only about the param/return firewall above. The same code fires for a VAR/CONST/~ temp declaration initializer that disagrees with its own annotation (VAR v: int = "hi"), and for a plain assignment that disagrees with its target’s already-known declared type — a VAR/CONST (annotated or not: an unannotated VAR v = 5’s declared type is still the initializer’s own inferred type) or an annotated ~ temp. A Param target is the one exception: a param disagreement is always caught by the firewall check above instead, at the annotation itself.

It also fires for a UFCS-desugared call’s arguments, on the native (.brink) surface: recv.name(args) desugars to name(recv, args), and both the receiver (standing in for the desugar’s first argument) and every written argument are checked against name’s already-known declared param types — g.greet(3) reports E063 when greet’s declared first param disagrees with g’s own type, exactly like a direct call to greet would.

A function that returns nothing annotates void (or simply never returns a value — inference treats the two identically). Assigning the result of a void call is a strict-mode error, E067 (“f returns void — its result cannot be assigned”): there’s nothing there to assign.

When inference can’t answer: Unknown and Conflicted

Strict mode’s two signature errors are worth telling apart, because they ask for different fixes.

Unknown means inference ran out of evidence. Nothing the body does pins the slot down — and note that printing a value is not evidence, because interpolation accepts every type. This fails with E065, “serve’s parameter dish escapes strict inference as Unknown — annotate or restructure”:

-> serve

=== serve(dish) ===
The innkeeper slides {dish} across the bar.
-> DONE

The fix is in the message: either annotate (=== serve(dish: string) === — the annotation supplies exactly the fact inference couldn’t find) or restructure so the body genuinely uses the value.

Conflicted means the body disagrees with itself. The slot isn’t unconstrained — it’s over-constrained, used as two irreconcilable types. This fails with E066, “haggle’s parameter offer is Conflicted under strict types — its uses disagree on its type”:

-> haggle

=== haggle(offer) ===
{offer > 10:
    The trader whistles.
}
{offer == "generous":
    He bows.
}
-> DONE

No annotation can fix a Conflicted slot — declaring offer: int doesn’t make offer == "generous" sensible; it just moves where the contradiction is reported. The only fix is to make the body agree with itself.

Under gradual, both cases fall back silently to runtime coercion behavior. That’s the whole policy difference in one sentence: gradual defers these questions to the runtime; strict refuses to compile until they’re answered.

The coercion lattice, under strict

Gradual mode’s coercions are exactly what ink has always done. Strict mode narrows the lattice to one rule and two escape hatches.

int → float is the one implicit, directional promotion. An int is welcome anywhere a float is expected — #[1, 2.5] is a well-typed Array<float>, VAR rate: float = 1 is legal, and an int argument promotes to match a float parameter:

-> weigh

=== function heft(w: float): string ===
{ w > 2.0:
    ~ return "heavy"
- else:
    ~ return "light"
}

=== weigh ===
The innkeeper's ledger calls it a {heft(3)} purse.
-> DONE
The innkeeper's ledger calls it a heavy purse.

There is no float → int direction; that would silently lose precision.

Everything else is explicit, through the pure conversion intrinsics int(x), float(x), and string(x):

VAR fare: float = 2.5

~ temp coins: int = int("12")
You hand over {coins} coins; the ferryman counts {float(coins) * fare} in value.
-> END
You hand over 12 coins; the ferryman counts 30 in value.

int and float accept numbers, bools, and strings (a string is parsed; a string that doesn’t parse is a turn-terminating fault, never a silent zero). Anything outside that domain — a divert, a LIST, a collection — is a compile error under strict (E078) and a runtime fault under gradual. string(x) accepts every value and never fails: it’s the same display form interpolation uses.

Interpolation is universal, not a coercion. {x} accepts every type under both policies — display was never part of the type lattice to begin with.

The idiom that survives strict: visit counts in condition position

Ink’s oldest idiom is checking a knot’s visit count directly in a condition:

-> market ->
{market: The stalls are familiar now.|A first look at the market.}
-> END

=== market ===
Fruit, dice, gossip.
->->
Fruit, dice, gossip.
The stalls are familiar now.

market’s visit count is a plain int; used bare in condition position, nonzero means true. Nothing about strict typing touches this — it’s scoped deliberately: condition position only. Elsewhere, an int used where a bool is expected is still an error under strict (VAR ready: bool = 3 never becomes legal), because that’s not the idiom being preserved — this is. A strict mode that broke visit-count conditionals would be unusable for real ink content, so this is a floor: turning strict on should produce errors only where types genuinely conflict, never on ordinary visit-count logic.

One neighboring idiom does not survive, on purpose: an Option in condition position. {mood.first(): …} is not “is there a first mood” — Option<T> has no truthiness, ever. The condition-position error (E116) tells you the honest spelling: test == none / == some(x) explicitly. A fault says “your program is wrong”; none says “the world didn’t have one” — and a bare truthiness test blurs exactly that line.

Collections and the empty-literal rule

Under strict, element types unify per collection: every element in an #[…] (after the int → float join) must agree on one type, or the collection’s type is Conflicted and the binding holding it fails with E066:

-> count_loot

=== count_loot ===
~ temp loot = #[1, "pearl"]
{len(loot)} treasures.
-> DONE

An empty literal (#[], #{}) takes its type from surrounding context — an already-typed binding, a typed argument position. If nothing constrains it, that’s an Unknown escape (E065): annotate the binding.

-> pack

=== pack ===
~ temp satchel: Array<int> = #[]
~ push(satchel, 3)
{len(satchel)} item in the satchel.
-> DONE
1 item in the satchel.

Map keys type as the built-in key domain (int, string, or bool) as a single key sort, not a general union — brink has no user-facing union types in this version. The full indexing and mutation contracts live in Indexing & Mutation.

Structs

This section will move to its own chapter (structs, enums, and flags) as the book’s reorganization proceeds; it lives here until that chapter exists.

Structs are a closed-shape, flat-field value type. Declaring one mirrors how you construct one: the STRUCT body is the same braced shape as a construction literal, with types where the construction literal has values.

STRUCT Point = #{
    x: float,
    y: float,
}

VAR p = 0

~ {
    p = Point#{x: 1.0, y: 2.0}
    p.x = 9.0
}

{p.x} {p.y}
-> DONE
9 2

Field access (p.x) resolves through the same fallback rule brink uses for direct-call syntax elsewhere: ink’s own static dotted paths (knot.stitch, List.Item) are tried first and win; .x is field access only once the head resolves to a plain variable. Under strict the shape is known at compile time, so field reads and writes compile to static offsets; under gradual, an Unknown-typed head defers the lookup to runtime, by name.

Structs work under either policy, with different failure timing for the same mistake. A construction literal is checked against the declared shape — missing a field is E069, supplying an undeclared one is E070, a field value of the wrong type is E071, and naming the same field twice is E084 (under both policies — a duplicate’s initializer is never silently dropped). Under strict these are compile errors; under gradual the malformed construction is a runtime fault that ends the story turn instead of producing a half-built value. Neither policy silently accepts a malformed construction — only when the mismatch is caught differs.

A construction literal is a legal VAR/CONST declaration default, so a struct-typed global can be given its real starting value where it is declared. The literal has to be well-formed there: a declaration default is baked into the compiled story, with no runtime construction step left to fault at, so a mismatched one is a compile error under either policy rather than a gradual-mode runtime fault — E075 under gradual, or the same missing/extra-field E069/E070 the analyzer already reports for a mismatched construction under strict (structs::check blocks the compile before LIR lowering, so E075’s declaration-default path is never reached). One thing to keep in mind either way: initializers in a construction literal always evaluate in the order you wrote them, never the shape’s declared order.

A struct passed to or returned from a function behaves like any other value: the callee gets its own independent copy, and mutating it never reaches back to the caller’s — the same value semantics arrays and maps have.

Types at the seams

Inside one project, inference sees everything. At the project’s edges it can’t, and each edge has its own answer:

  • Engine functions (EXTERNAL) have bare, untyped parameters in ink source — their types come from the host’s binding manifest (see External Functions). A registered external whose manifest types resolve is checked at every call site like any other function; an external with no registered signature stays deliberately unchecked. Under strict, a manifest type that fails to resolve is a real E065 escape at the declaration — the seam must be typed, or it’s an error, never a shrug.
  • The ink seamplanned. When the native .brink surface lands, ink-authored symbols will enter native code as Unknown, and strict’s existing escape rule does the rest: annotate at the seam, exactly the boundary doctrine above. Today there are no mixed ink/brink trees, so this seam exists as a ruling, not yet as tooling; the compat posture is described in Conformance.

Reference: the diagnostics in this chapter

CodeFires whenPolicy
E061annotation names an unrecognized typeboth
E063annotated type disagrees with the type inferred from usage, or a VAR/CONST/~ temp declaration initializer or a plain assignment disagrees with its target’s already-known declared type, or (native surface) a UFCS-desugared call’s receiver or written argument disagrees with the desugared function’s declared param typewarning under gradual, error under strict
E064types = strict without dialect = brinkconfig
E065a type escapes strict inference as Unknownstrict
E066a type is Conflicted — its uses disagreestrict
E067assigning the result of a void functionstrict
E069/E070/E071struct construction missing / extra / mistyped fieldstrict (runtime fault under gradual)
E075struct construction literal in a VAR/CONST default doesn’t match its declared shapegradual (strict reports E069/E070 first)
E078int()/float() argument outside the numeric+bool+string domainstrict (runtime fault under gradual)
E084duplicate field in a struct construction literalboth
E107bare none with no type from contextboth
E116Option<T> used as a condition — no truthinessstrict (runtime fault under gradual)

Where this is ruled

  • Typed modedocs/typed-mode-spec.md §§1–5 (policy, inference, annotation syntax, coercion lattice, collections); §6 (structs).
  • Strict as the default; native strict-only — decision log 2026-07-19, “Typing posture ruled” (NS-A9); the dialect-keyed default in resolve_type_policy.
  • Option, absence, and no truthinessdocs/stdlib-spec.md §1 (Phase A postures; F27 ruled 2026-07-19).
  • Struct construction order and duplicate fields — decision log 2026-07-14 (#675/#676).

Function values

Ink has no lexical free variables — a knot or stitch never closes over anything in its surrounding scope — so brink’s function values are partial application over named functions, not closures. The author-facing name is deliberate: “function value,” never “closure” or “lambda.” In this dialect there are no anonymous functions; every function value starts from a statically-named === function name ===.

The native .brink surface does spell a lambda (|x| x * 2), and it is lifted to exactly the function value described here — a synthesized target plus a bound prefix, with captures taken by value at the point the lambda is written. So the model below is the whole model there too; only the spelling differs.

A function value is three things: the target’s identity, a prefix of its declared parameters bound at creation, and an effect row (reserved for a future milestone, always present, currently the conservative “may do anything” placeholder). That’s the whole model — everything below is consequences of it.

Creating one: #fn(name, args…)

#fn joins the #[…] / #{…} sigil family. It takes a statically-named function and binds a prefix of its declared parameters:

VAR player_hp = 10

~ temp healer = #fn(heal, player_hp)
~ temp healed = healer(5)
Healed to {healed}.
HP cell is now {player_hp}.
-> END

=== function heal(ref hp: int, amount: int): int ===
~ hp = hp + amount
~ return hp
Healed to 15.
HP cell is now 15.

Capture mode isn’t something you choose at the creation site — it comes from the target’s own signature. heal’s first parameter is ref hp, so binding it captures the durable cell player_hp itself: every call through healer reads and writes the same player_hp, exactly like calling heal(player_hp, …) directly would. A val parameter, bound the same way, snapshots the argument’s value at creation instead.

Every ref parameter must be bound at creation, and only to a durable cell — a VAR (including a flow-private #@local one), never a temp, a CONST, or an expression. temps and params die with their frame; a function value that outlived one holding a dangling ref would be exactly the kind of silent misbinding brink’s value model refuses to produce. The checker enforces this at the #fn site itself: an unbound ref parameter, or one bound to something that isn’t a durable cell, is a compile error before you ever get to call anything. Once every ref parameter is bound, every parameter after it is val-only by construction — a function value you can pass around and call dynamically is always values-only from that point on.

#fn’s target is a name, never an expression — #fn(heal, …), not #fn(some_expr, …). Binding more arguments than the target declares, or pointing #fn at something that isn’t a statically-named function (a variable, a plain knot, a builtin), is a compile error too.

Calling: two forms

A function value can be called directly, or through the call() intrinsic when the callee itself needs to be an expression rather than a bare name in call position:

~ temp adder = #fn(add, 10)
~ temp total = call(adder, 7)
Total: {total}.
-> END

=== function add(a: int, b: int): int ===
~ return a + b
Total: 17.

adder(7) would have produced the same 17call(f, args…) exists for the shapes where the direct-call form f(args…) isn’t accepted (a function value stored behind an index expression, a field, or handed back from another expression), not as a second calling convention with different semantics. Direct-call syntax only ever binds a bare variable/temp/param name; writing handlers[state](event) or obj.field() in its place is a compile error (E104) naming call(f, args…) as the fix, not a silent no-op — see issue #869.

bind(): currying an existing function value

Where #fn creates a function value from a name, bind() curries an existing one — it consumes the head of whatever parameters remain unbound and returns a new function value with those filled in:

~ temp f = #fn(combine)
~ temp g = bind(f, 1)
~ temp h = bind(g, 2)
~ temp result = h(3)
Result: {result}.
-> END

=== function combine(a: int, b: int, c: int): int ===
~ return a + b + c
Result: 6.

bind chains compose: g binds one more parameter onto f, h binds one more onto g, and by the time every declared parameter is filled the result is callable with zero further arguments. bind’s appended arguments are always val — the remaining parameters after a #fn creation site are val-only by construction, so there’s no ref capture decision left to make by the time bind runs.

Display form

string(f) — and interpolation, which routes through the same display machinery — renders a function value as a signature, with bound arguments shown as defaults:

VAR world_hp = 10

~ temp healer = #fn(heal, world_hp)
Display: {healer}.
-> END

=== function heal(ref hp: int, amount: int): int ===
~ hp = hp + amount
~ return hp
Display: fn heal(ref hp = world_hp, amount).

A bound ref parameter shows the captured cell’s name (hp = world_hp, not the cell’s current value — the binding is to the cell, not a snapshot); a bound val parameter shows its value; an unbound parameter shows bare. This form is permanently observable surface, not a debug aid that might change shape later — treat it the same as any other stable display rule.

What can go wrong, and when

Two failure classes exist, and they land at different times depending on whether the calling code is typed:

  • Under types = strict, calling through a function value with a known fn(T…): R type is checked statically — a wrong argument count or a wrong argument type is a compile error, exactly like calling an ordinary function. An escape to Unknown at a call site is the same strict-mode escape error every other call gets.

  • Under types = gradual (the strict-ink dialect’s default — since the 2026-07-19 ruling the brink dialect defaults to types = strict — and the mode strict static checks fall back to when a type can’t be pinned down), calling a non-function value, calling with the wrong number of arguments, or passing a wrong-typed argument is a turn-terminating runtime fault — never silent garbage, never a partially-applied call that quietly does the wrong thing.

    call() and bind() are strict-mode gradual-typed for now: their result type isn’t yet threaded through the fn(T…): R lattice the way a direct #fn-created value’s type is, so a mistake reaches them as the same runtime fault gradual mode always has, even in an otherwise-strict project. Direct calls (f(args…)) get the full strict-mode static treatment already.

One fault is specific to function values: invoking one that ref-binds a flow-private (#@local) cell from outside the flow that created it. brink doesn’t yet track which flow created a given function value, so rather than risk a silent cross-flow misbinding, invoking a closure over a #@local ref binding is a defined fault. A ref-bound ordinary VAR (shared across every flow on a World) has no such restriction — it’s only the flow-private storage class this applies to.

Persistence

A function value saves like any other value — there’s no special case in SaveState for one sitting in a VAR, inside an array or map, or live on the stack mid-turn when a save happens. Loading it back re-validates the saved parameter names and modes against the current compiled signature: if a recompile has since renamed, reordered, or re-moded a parameter the saved value referenced, invoking it after load is a defined fault rather than a silent misbinding against the wrong slot. This is a best-effort check, not a cross-version compatibility guarantee — a function value is as long-lived as the story build it was saved against.

Effects

Every knot, stitch, and function in a brink-dialect project has an effect row — a static summary of what it touches when it runs: the world cells it reads, the world cells it writes, and the engine functions it calls. You never write a row; the compiler infers it by looking at the body. Rows exist for one reason: a host engine has to make decisions before it runs your story — which flows can advance in parallel, what to prefetch, when a sleeping flow should wake — and the only thing it can know before running is what a row tells it.

This chapter is about the authoring side of that: what a row is, how it’s inferred, where its boundaries are, and the one place you can pin one down with an assertion. The host side — how bevy-brink actually schedules on these rows — lives in the Bevy integration.

The three layers

Effects are described at three layers, and they are never conflated:

  • Atomic effects are what expressions emit when they run: read this cell, write this cell, call this external. Data never has effects — code does. gold is just a number; ~ gold = gold - 1 is a read and a write.
  • Rows are the static summary: {reads, writes, calls} as unordered sets. Ordering is the journal’s concern, not the row’s. Every atom an expression emits is absorbed into the enclosing definition’s row.
  • Types carry rows only in one place: a function value. fn(int): int can carry ⟨reads: gold⟩, meaning calling it reads gold. A collection of function values carries rows through its element type. Reading the collection is still pure — holding a piece of pending computation is not the same as performing it (see Function Values).

Rows are inferred

A definition’s row is built exactly the way its type is: walk the body, collect the atoms, take the union. A pure function touches nothing, and its row is empty:

-> start

=== function double(n) ===
~ return n * 2

=== start ===
Twice three is {double(3)}.
-> END
Twice three is 6.

Reading and writing a global VAR puts that cell in the reads and writes sets:

VAR gold = 10
-> shop

=== shop ===
~ gold = gold - 3
You have {gold} gold.
-> END
You have 7 gold.

A direct call pulls in the callee’s whole row — transitively. If outer calls inner, and inner writes gold, then outer writes gold too, even though outer’s own body never mentions it. Mutual recursion is handled by the same per-SCC fixpoint the type checker already runs, so a cycle of functions all converge on the union of everything the cycle touches. Nothing special is needed from you; it falls out of following the call graph.

There is one place inference has to give up precision: a call through a function value. When you dispatch through a #fn value — ~ temp x = f() — the compiler generally can’t see which concrete function f holds at that moment, so the row becomes opaque: the conservative “touches everything” row. That is always sound (it can never under-report), just coarse. Concrete functions called directly stay fully precise; only the indirect hop widens. A traced callback is the exception: a #fn literal passed straight into a fn-typed parameter, or a local whose every write traces back to one, is followed through instead — only dispatch through something genuinely untraceable (a host callback, a value loaded from the heap, or a param forwarded on into another higher-order call) still widens to opaque.

Boundaries: what ships in a row

A shipped row contains only what a host can act on:

  • World cells — global VAR/CONST — one entry per cell.
  • External call kinds — the engine functions the definition calls.

Everything else is deliberately excluded. A ~ temp dies inside the frame and a #@local cell is flow-private by construction — neither can matter to a scheduler, so neither appears in a shipped row. (Internal inference keeps full per-cell precision regardless; the trimming is only at the boundary.)

Every knot and stitch ships a row — there is no #@entry marker, because “play from here” already makes any knot a possible host entry point. If you want a definition to not be a host entry, #@private opts it out: its row stays internal and a host lookup for it fails at load. (The full visibility story is the Modules round; effects just ride on it.)

Soundness: over-report, never under-report

The one rule a row must obey is directional: it may claim more than the code does, never less. An over-report costs a missed parallelization or a spurious wakeup — wasteful, but safe. An under-report would let the host run two flows concurrently that actually race on the same cell — a real bug. So when inference is unsure, it widens (that opaque row above), and “no answer” is never an option: the pessimal touches-everything row is always available and always sound.

A practical corollary: strict mode buys scheduler precision. The more your types resolve, the tighter your rows, the more the host can overlap. Gradual Unknowns widen rows the same way they defer type checks.

The @[effects] assertion

Rows are inferred, deterministic, and shipped in the compiled .inkb — so there is nothing for a lockfile to pin, and brink has none. The only contract you can write is an optional inline upper bound: an @[effects(…)] annotation line at the top of a knot or stitch body.

The older #@effects(reads: …) tag spelling is a frozen, deprecated alias — it still compiles but warns (E110). New code writes the annotation form below; clauses are parenthesized (reads(gold), never reads: gold).

On the native .brink surface the same annotation attaches Rust-style — on the line directly above the flow/fn head rather than inside its body. The arguments, the pure sugar, and the exceedance-only checking below are identical.

VAR gold = 10
-> shop

=== shop ===
@[effects(reads(gold), writes(gold))]
~ gold = gold - 3
You have {gold} gold.
-> END
You have 7 gold.

The clauses name world cells (reads(…), writes(…)) and externals (calls(…)). @[effects(pure)] is sugar for the empty row — the “this stays pure, hold me to it” case:

-> greet

=== greet ===
@[effects(pure)]
Hello, traveler.
-> END
Hello, traveler.

An assertion is an upper bound, and the only thing it can do is fail: if the inferred row is not covered by what you declared — the body reads, writes, or calls something the assertion didn’t list — that’s a compile error (E103, “inferred effects exceed the declared bound”):

VAR gold = 0
EXTERNAL play_sfx(x)
-> shop

=== shop ===
@[effects(pure)]
~ gold = gold + 1
~ play_sfx(1)
Spent.
-> END

Declaring a bound wider than the inferred row is silent — there is no drift policy, because there is nothing to drift against. Over-declaring never warns; only exceedance errors. The assertion is a tripwire you set deliberately (a function you promise to keep pure, a knot whose world footprint you want frozen), not a running commentary on your code.

The directive is runtime-inert — advisory metadata, checked at compile time and then invisible. A program with a satisfied @[effects] bound produces the exact same output as the same program without it.

Seeing your rows

Two tools surface inferred rows so you don’t have to guess.

Hover is keyed to a definition, not a call site: hovering a reference resolves it to its target and shows that target’s effect row on a stable line — reads: …; writes: …; calls: …, or pure. If a definition calls through a fn-typed parameter, its own row is opaque — the conservative floor — regardless of what any caller passes in; a hole is only discharged in the caller, never in the callee’s own row. So if a fn-typed parameter receives a traced callback — a #fn literal or a local whose every write traces back to one — the concrete effects show up folded into the enclosing caller’s row, not at the call site itself. To see them, hover the caller knot or stitch’s own name.

brink ide effects-diff compares every row against a baseline — a git revision (--rev HEAD for working-tree-vs-HEAD) or a second entry file (--base) — and prints a CI-comment-friendly Markdown summary of what moved:

brink ide effects-diff --rev HEAD -e main.ink

This is drift visibility, not a gate: it shows what your edit did to the shipped rows. Add --exit-code to make it fail a CI check when any row changed. See The CLI › effects-diff for the full flag set and JSON shape.

What effects are not

Interior effects are always inferred, never spelled. There is no monad, no effect handler, no function coloring, no async/await-style annotation creeping through your call sites — the entry-point row and the optional @[effects] bound are the whole author-facing surface. Everything else about effects — parallel scheduling, prefetch, reactive sleep, the capability manifest that maps calls to engine components — is the host’s job, and lives in the Bevy integration.

Path projections

ref npc.hp, ref inventory[idx], ref party[leader].hp — a path projection is a ref argument that names a path into a durable cell instead of the whole cell. It’s the same ref you already know (a function’s ref parameter binds the caller’s cell, not a copy), extended so the bound path can reach inside a struct field or a collection element instead of stopping at the cell’s name.

A path projection is a value with an identity — (root cell, path segments) — never a pointer into memory. Reads walk the path against the root’s current value; writes are a read-modify-write on the root cell. That framing explains everything below: why the index in ref arr[i] is fixed the moment the ref is created, why a path that stops resolving is a defined error rather than undefined behavior, and why two overlapping projections into the same cell never need an aliasing check to stay deterministic.

Creating one: ref in argument position

A path projection is created only where ref already exists — a direct argument of a call, #fn(…), or bind(…). There’s no standalone projection expression (temp r = ref a[0] is a compile error): projections exist only at the boundary where a ref parameter is being bound.

STRUCT NPC = #{hp: int, name: string}
VAR npc = 0

~ npc = NPC#{hp: 10, name: "Aeris"}
~ heal(ref npc.hp, 5)
{npc.name} has {npc.hp} HP.
-> END

=== function heal(ref hp: int, amount: int) ===
~ hp = hp + amount
Aeris has 15 HP.

heal’s ref hp parameter is unchanged from ordinary ref — it binds whatever cell the caller names. What’s new is that the caller can name a path into a cell (npc.hp) rather than only the bare cell itself. Inside heal, hp behaves exactly like it always has: reading it walks the path to get the current field value, writing it walks the path and stores back into npc.

The root of a projection’s path must always be a durable cell — a VAR (including a flow-private #@local) — the same rule ordinary ref arguments already follow. A temp, a CONST, or an expression result can’t anchor a projection, for the same reason they can’t anchor a plain ref: they don’t outlive the call.

Index expressions snapshot at creation

The segments of a path projection — including any [index] subexpression — are evaluated once, when the ref argument is created. Reassigning the variable that produced an index afterward never retargets an already-created projection:

VAR inventory = 0

~ inventory = #[10, 20, 30]
~ temp idx = 0
~ bump(ref inventory[idx], 100)
~ idx = 2
{inventory[0]} {inventory[1]} {inventory[2]}
-> END

=== function bump(ref x: int, k: int) ===
~ x = x + k
110 20 30

ref inventory[idx] captured idx == 0 at the moment bump was called. Reassigning idx to 2 right after has no effect on the projection bump is already holding — only inventory[0] moved.

Overlapping projections write through immediately

Two separate projections into the same root cell never need reconciling against each other — every write lands on the root cell the instant it happens, so a read through one projection always sees whatever the most recent write through any projection left behind:

STRUCT NPC = #{hp: int, name: string}
VAR npc = 0

~ npc = NPC#{hp: 0, name: "Aeris"}
~ heal(ref npc.hp, 5)
~ heal(ref npc.hp, 7)
{npc.hp}
-> END

=== function heal(ref hp: int, k: int) ===
~ hp = hp + k
12

Nothing about this needs aliasing analysis: heal(ref npc.hp, 5) reads npc.hp, adds 5, and stores back into npc before heal(ref npc.hp, 7) ever creates its own projection — the second call’s read already sees 5.

When a path stops resolving

A projection’s path is only guaranteed to resolve against the root’s value at the moment it created — the value can change shape before the projection is read or written (the array shrinks below the snapshotted index, a map loses the snapshotted key, a struct field the projection names gets removed). When that happens, the read or write that discovers it is a defined, turn-terminating runtime fault — never a silent clamp, never undefined behavior. There’s nothing to catch in-story; treat it the same as any other fault your host-integration error handling already covers.

Through #fn

A path projection can be the bound ref argument of #fn, exactly like a bare-cell ref can (see Function Values):

STRUCT NPC = #{hp: int, name: string}
VAR npc = 0

~ npc = NPC#{hp: 5, name: "Aeris"}
~ temp healer = #fn(heal, ref npc.hp)
~ temp result = healer(9)
{result}
-> END

=== function heal(ref hp: int, amount: int): int ===
~ hp = hp + amount
~ return hp
14

healer closes over the path npc.hp, not a snapshot of its value at creation — calling healer later still reads and writes through to whatever npc.hp holds at call time.

Display form

string(p) — and interpolation, which routes through the same display machinery — renders a path projection as ref followed by the root cell’s name and its path segments: a dotted field renders .field, an index renders [value]. This is the same display convention #fn’s bound-ref rendering already uses for a bare cell (ref hp = player_hp) — a projection-bound parameter shows its path in that same slot instead of a bare name:

STRUCT NPC = #{hp: int, name: string}
VAR npc = 0

~ npc = NPC#{hp: 5, name: "Aeris"}
~ temp healer = #fn(heal, ref npc.hp)
{healer}
-> END

=== function heal(ref hp: int, amount: int) ===
~ hp = hp + amount
fn heal(ref hp = npc.hp, amount)

This form is deliberately boring and stable — it names the root and the path, never the current value at the root (the binding is to the path, not a snapshot of what it currently holds).

What can’t happen: a path never crosses to the host

An EXTERNAL function’s declared parameters have no ref grammar at all — only a knot or function header can mark a parameter ref. That means a path projection can never be the argument bound to an EXTERNAL call: heal(ref npc.hp, 5) only ever type-checks against a target whose own signature declares a ref parameter, and no EXTERNAL declaration can. Passing ref npc.hp to an EXTERNAL function is a compile error, not a runtime concern.

That’s a deliberate consequence of the value model, not an incidental gap: the whole point of “a projection is (root cell, path), never an interior pointer” is that only ink bytecode — which knows how to walk a path back to the root cell — ever handles the unresolved form. Reading a projection-bound parameter inside an ordinary ink function (the only way to ever observe one) always resolves it to a plain value first, so whatever your engine’s external-function bindings receive is always an ordinary snapshot — an int, a string, a struct, whatever the path resolved to — never the path itself. The host side of a binding facility never needs to know path projections exist.

Persistence

A path projection saves like a plain ref binding always has — there’s no special case in SaveState for one sitting mid-call. Loading it back re-validates the root cell the same way an ordinary ref parameter’s saved binding already does; a recompile that renamed or removed the root is the same defined fault a dangling ordinary ref binding would produce, not a silent misbind.

Modules

By default a brink project is one flat namespace: every .ink file glued in with INCLUDE contributes its knots, functions, and globals to a single shared pool, and any name can reach any other. That is exactly strict ink, and it stays byte-for-byte unchanged. Modules are the opt-in layer on top: a way to draw boundaries so a large story’s parts can name things freely inside themselves without colliding with — or accidentally depending on — everything else.

Nothing on this page turns on until you write a #@module directive. A project that never mentions one behaves precisely as it always has.

The module unit: #@module

Every file is already a module, named by its file stem: quest_3.ink is the module quest_3. That is the zero-ceremony default and it is undeclared — a permeable member of the legacy pool.

Writing #@module(name) at the top of a file declares the module:

#@module(quest)

=== ambush ===
The bandits spring from the treeline.
-> DONE

Declaring a module does two things. It names the module explicitly (so several files can share one name — see below), and it opts the file into the declared-module defaults, the most important of which is that definitions become private unless you say otherwise (covered under Visibility).

A multi-file module is always deliberate. Either every file carries the same #@module(name):

// quest_intro.ink
#@module(quest)

// quest_boss.ink
#@module(quest)

…or an included file inherits its includer’s module. A file with no #@module of its own that is INCLUDEd under a declaring head file joins that head’s module and its visibility default.

One footgun is closed by a hard error: an undeclared file whose stem happens to collide with a declared module’s name (shared.ink next to a #@module(shared)) is a compile error. Accidental membership with mismatched defaults is the one dangerous case, and a single diagnostic kills it.

Imports

Once a module is declared, its names stop leaking across the boundary. A name crosses into another module only through an IMPORT. Inside a module, everything stays bare — you never qualify a same-module reference.

There are two spellings.

Bare import brings specific names into local scope, optionally binding an extra local name to it with AS:

IMPORT { ambush, guard_talk AS gt } FROM quest_3

=== square ===
-> ambush          // used bare
{ gt() }

AS is additive, not a rename: guard_talk stays resolvable under its own name alongside the alias gt — unlike Rust’s use … as, which drops the original binding.

Qualified import brings the module in under its own name; its exports are then reached through a dotted path:

IMPORT quest_3

=== square ===
-> quest_3.ambush.start

The importable set is every top-level public definition: knots, functions, VARs, CONSTs, LISTs, and STRUCTs. Stitches are not directly importable — they are reachable only through the qualified form (quest_3.ambush.start).

A few rules keep imports unambiguous:

  • No globs. IMPORT * does not exist.
  • Ambiguity is an error. If x names both an imported module and a visible definition, a qualified x.y is a compile error. Resolve it with an alias — brink never silently picks a winner.
  • A dotted a.b is module-qualified only if a was imported as a module in this file. The reader checks the file’s own header; it never guesses from elsewhere in the project.

Visibility: public and private

Who may reference a name across a module boundary is its visibility. The default flips on whether the module is declared:

Module kindDefault visibility
Undeclared stem-module (legacy pool)public
Declared #@moduleprivate

That flip is what keeps the pre-modules world unchanged — every legacy definition is public — while making a freshly declared module encapsulated by default. Override the default per definition with #@public / #@private, written just under the header:

#@module(quest)

=== ambush ===
#@public
The bandits spring from the treeline.
-> DONE

=== plan_ambush ===
#@private          // internal helper, never imported
~ return roll_initiative()

Restating the default (a #@public on an already-public definition, or #@private in a declared module on something already private) is a redundant override and draws a warning — the directive is there to change the default, not to decorate it.

The host — the engine embedding the runtime — sits outside every module, so it sees only public names (with a development-time override for debugging).

Renames and #@was

Module and definition names are identity: a compiled story, a save file, or a late-loaded chunk refers to a knot by a name-derived id. Renaming a public name would ordinarily break every one of those references. #@was is the migration door:

#@module(quest)
#@was(quest_three)          // this module used to be `quest_three`

=== ambush ===
#@public
#@was(the_ambush)           // this knot used to be `the_ambush`
-> DONE

A #@was(old_name) records a former name. The alias travels into the compiled artifact, so a save or a dynamic link that still names the_ambush rehydrates onto ambush instead of faulting. #@was takes exactly one non-empty old-name argument, and it must differ from the definition’s current name (naming yourself migrates nothing — that’s a diagnostic).

The editor’s Rename refactor writes #@was for you: renaming a knot, stitch, VAR, CONST, or LIST — from the CLI (brink ide rename), the LSP (F2), or the studio’s rename-safe path — stamps #@was(old_name) onto the declaration automatically, in the same edit set as the rename itself. It only fires under dialect = brink (#@was is itself a brink extension — under strict ink it would be rejecting its own migration door), and it never overwrites an existing #@was, so re-renaming an already-migrated declaration keeps its original record rather than losing the chain back to the name a save might still carry.

A rename that never goes through that machinery — a hand edit, a sed, a merge — still needs #@was added by hand, same as before. The editor helps here too: it diffs each file’s declared names against the previous compile, and when a name disappears while exactly one same-kind name appears in its place, it surfaces a hint — hub disappeared and plaza appeared — did you rename it?” — pointing at the exact #@was to add. This is not the fuzzy load-time rematching this page’s alias table deliberately avoids: it never resolves anything on its own, it only asks, at authoring time, while you still remember what you meant. A rename that never passes through brink tooling at all (so nothing is there to diff) stays undetected — that residual gap isn’t solved, only narrowed.

Editor support

Modules come with IDE guarantees so the boundaries help rather than nag:

  • Auto-import quick-fix. Reference a public name that lives in another module without importing it, and the out-of-scope diagnostic offers a one-click “Import name from module fix that inserts the IMPORT line in the right place — below any existing import block, else below the INCLUDE block, else at the top under the #@module header.
  • Rename writes #@was. See above — every rename surface stamps the migration directive automatically.
  • Undeclared-rename hint. See above — a same-kind name that vanishes and reappears prompts a quiet, non-blocking question rather than staying silent.
  • Folding. A run of two or more leading IMPORT statements folds into a single IMPORT … (N modules) region, mirroring the INCLUDE block fold.
  • Formatting. brink fmt canonicalizes IMPORT spacing — IMPORT { a , b AS c } FROM m becomes IMPORT { a, b AS c } FROM m.

Compatibility

Every trigger on this page — #@module, #@public, #@private, IMPORT, #@was — is a construct no strict-ink or existing brink story contains. Import enforcement only ever adds diagnostics, and it keys off a declared target module, which the entire pre-modules corpus lacks. A plain multi-file INCLUDE project with no #@module anywhere remains one big public pool that resolves exactly as it did before modules existed.

Inline Markup

Markup is an optional layer for decorating prose with semantic meaning. A story author can wrap spans of text in tags to label them with meaning — <wave>text</wave> for visual effects, <item id="key">thing</item> to link an object — and the runtime delivers those tags to the host engine so it can render them however it wants.

Markup is freeform by default: an author can use any tags without declaring them, and the compiler never complains. Optionally, the host engine can declare a markup vocabulary in its capability manifest to validate tags and attributes — turning what was freeform into a tightly-checked surface.

This chapter covers both sides: the syntax story authors write, and how integrators declare and validate a markup vocabulary.

Inline markup is a native .brink-surface feature. It is not available on the ink surface, even with dialect = "brink" enabled on an .ink source: writing <wave>x</wave> in an .ink file produces literal text, with no diagnostic.

Author syntax

Basic spans

A span is XML-shaped: a tag with a name, optional attributes, and text content.

flow scene() {
  He hands you <item id="lantern">the old lantern</item>.
  The sign flickers <wave amount="3">in the corner</wave>.
}

Attributes are name="value" pairs (the quotes are required). The value is always literal text — there is no type system or interpolation inside an attribute value.

Hyphenated tag names

A tag name may contain - as a separator between words — useful for kebab-case vocabularies borrowed from XML/HTML custom elements:

flow scene() {
  The screen goes dark. <fade-in>A new day begins.</fade-in>
}

The hyphen is only legal between two name segments — never as the first or last character (<-x> and <x-> are both errors).

Self-closing tags

For markers that carry no content — a sound effect, a visual cue — use self-closing syntax:

flow scene() {
  The bell tolls again. <sfx name="bell"/> Somewhere a door slams.
}

Nesting

Spans can nest, and they can contain interpolation:

flow dialogue() {
  var name = "Kestrel"
  <b>Hello, {name}!</b>
}

Important: A tag must open and close in the same scope. You can’t have a tag open in one branch of a conditional and close in another:

{if tired: <i>yawn</i> else: ready}          // Correct: span is entirely in the branch

<i>hello {if tired: world</i> else: friend}  // Nesting violation

This constraint is enforced by the compiler. It exists because markup is line-scoped: the runtime treats each line as a complete unit for translation purposes, so spans cannot leak across line boundaries either.

Escaping special characters

Four characters have special meaning in markup and interpolation:

CharacterEscapeWhen to use
<\<When you need a literal < (e.g., \<3)
{\{When you need a literal { in prose (e.g., \{HP: 5})
#\#When you need a literal # as line-start text
\\\When you need a literal backslash
flow scene() {
  The code is: `\<vector>` to declare a pointer.
  Health: \{HP}
}

If you write a backslash followed by anything else — like \n or \t — the compiler reports an error. The escape set is final and small.

For integrators: the host manifest

Why a manifest?

By default, any tag is allowed. This is great for iteration — authors can try new markup without asking permission. But once a project stabilizes, you probably want to lock down the vocabulary: “these are the valid tags and their attributes, and anything else is a mistake.”

The host engine declares this vocabulary in its capability manifest, the same place it declares external functions. The compiler then validates tags against the vocabulary and reports problems.

Declaring a markup vocabulary

The manifest’s markup section is an array of span kinds:

{
  "markup": [
    { "name": "wave", "attrs": [{ "name": "amount" }] },
    { "name": "item", "attrs": [{ "name": "id", "required": true }] },
    { "name": "sfx", "attrs": [{ "name": "name" }, { "name": "volume", "required": true }] },
    { "name": "b" },
    { "name": "i" }
  ]
}

Each span kind has:

  • name (required) — the tag name
  • attrs (optional) — an array of attribute declarations this kind accepts

Each attribute declaration has:

  • name (required) — the attribute name
  • required (optional, defaults to false) — whether every span of this kind must carry this attribute

If a span kind has no attrs, it accepts no attributes (like the <b> and <i> examples above).

Validation

Once the manifest declares at least one span kind, the compiler validates all markup in the project:

  • An undeclared tag (e.g., <glitch> when only wave is declared) reports E164.
  • An undeclared attribute on a declared kind (e.g., <wave speed="2"> when only amount is declared) reports E165.
  • A missing required attribute on a declared kind (e.g., <item>the lantern</item> when item’s id is declared required) reports E173.

Attribute values are never checked — they are always plain text, so there is nothing to type-check. Only the attribute name, and now whether it is required, is part of the declared vocabulary.

Freeform markup (no validation)

To go back to freeform markup:

  1. Remove the markup section from the manifest entirely, or
  2. Use [lints] configuration to suppress the diagnostics:
[lints]
E164 = "allow"
E165 = "allow"
E173 = "allow"

You can also suppress markup validation for a single tag or line:

@[allow(E164)]
<custom>This tag is not declared, but we allow it.</custom>

// brink-disable E164
<another>Also allowed.</another>

Severity control

E164, E165, and E173 all default to Warning severity, which means they don’t break the build. You can make them stricter:

[lints]
E164 = "deny"
E165 = "deny"
E173 = "deny"

Now undeclared tags, undeclared attributes, and missing required attributes are hard errors. You can also suppress them selectively with @[allow(…)] or line-level // brink-disable.

How the runtime sees markup

The runtime delivers markup to the host engine through the Line enum. When the engine receives a line, it can inspect the markup and render it however it wants — a text-effect system might animate <wave>, a dialogue system might color-code <speaker> tags, and so on.

Markup is presentation only: it has no effect on game logic or control flow. The same game state and branching structure exists whether markup is present or not.

Conformance

Two kinds of “correct”

brink’s compiler and runtime are checked against real ink two different ways, and it matters which one a given piece of content is subject to:

  • Vanilla ink — everything this chapter doesn’t describe — is correctness-checked against a C# ink oracle: thousands of golden transcripts produced by the reference inklecate/ink-engine implementation, diffed episode-for-episode against what brink produces. This is what “the ratchet” means elsewhere in this project’s tooling: a running count of oracle episodes that currently match byte-for-byte.
  • The brink dialect has no such oracle — there has never been a reference ink implementation with multi-line ~ { … } blocks, sigil collection literals, or postfix indexing, so there’s nothing for the reference toolchain to generate golden transcripts from. Dialect correctness is instead checked against hand-derived expected output, written straight from the ruled semantics in docs/t1b-surface-spec.md — a separate corpus (tests/tier1-brink/), exercised the same way but without an external authority to diff against.

Neither corpus is optional or secondary to the other; they check different claims. The oracle corpus proves brink reproduces real ink. The tier-1 brink corpus proves the dialect extensions do what their own spec says they do.

Strict-ink keeps the oracle’s meaning intact

This is why strict-ink is the default: the oracle comparison is only meaningful for programs the reference implementation can also run, and the reference implementation has never seen dialect syntax. If dialect extensions could leak into oracle-anchored content silently, the oracle count would stop meaning what it says it means.

So the compiler’s own conformance testing enforces the boundary mechanically, not by convention: the entire oracle corpus compiles under strict-ink. Every .ink file with a golden C# transcript is required to be plain ink — if dialect syntax ever appeared in one, or if strict-ink ever started accepting extension constructs, that’s a hard CI failure, not a lint warning.

The dialect is authoring-time only

The choice of dialect never reaches the runtime. It’s an input to analysisAnalysisOptions::dialect, set by the CLI’s --dialect flag or an equivalent library call — consumed entirely inside the compiler pipeline. Two consequences follow directly:

  • Compiled output carries no trace of it. A .inkb file produced from a brink-dialect source and one produced from strict-ink source are indistinguishable bytecode to brink-runtime. There is no dialect flag, version marker, or feature bit in the format for the runtime to consult.
  • The runtime has no dialect concept at all. Loading and executing a story never depends on which dialect compiled it — by the time bytecode exists, “which surface syntax produced this” is a question that has already been answered and discarded.

This mirrors an existing precedent elsewhere in the toolchain (the dialogue-dialect authoring convention used by the editor): a project-level, authoring/tooling-time setting that shapes what the compiler accepts, but that the shipped, running story never has to know existed.

What this means for an author

If you’re writing plain ink and never intend to use blocks, sigils, or the stdlib, nothing in this chapter changes your workflow — strict-ink is already what you’re compiling under, and the oracle-anchored guarantees apply to your content in full.

If you do want the dialect, turning it on is a project-wide, visible decision (see Enabling the Dialect) — and from that point, the dialect-extension parts of your story are checked against the tier-1 brink corpus’s semantics, not the C# oracle, because there is nothing else to check them against. That’s not a lesser guarantee, just a different one: it’s “matches the spec’s ruled behavior” rather than “matches what real ink does,” because for this surface, real ink has no opinion.

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 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.
  • The State Model — where a running story’s state lives: the World/FlowLocal split, per-unit world/local scoping, and the sandbox primitive behind speculative evaluation.
  • 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 Execution Model

A compiled story runs as a synchronous step function: it executes bytecode until it reaches a yield point, then hands back a Step. The variant of that Step 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.

#![allow(unused)]
fn main() {
extern crate brink_format;
extern crate brink_runtime;
use brink_format::{LineEntry, StoryData};
use brink_runtime::{Program, RuntimeError};
fn demo(program: Program, line_tables: Vec<Vec<LineEntry>>) -> Result<(), RuntimeError> {
let chosen_index = 0usize;
use std::sync::Arc;
use brink_runtime::{Step, Story};

let mut story: Story = Story::new(Arc::new(program), line_tables);

loop {
    match story.continue_single()? {
        // Mid-stream content; more may follow this turn.
        Step::Line(line) => print!("{}", line.text),
        // This turn's output is complete (`-> DONE`); keep stepping.
        // Terminal steps carry no payload — the text already printed.
        Step::Done => {}
        Step::Choices(choices) => {
            // Present `choices`, get the player's selection...
            story.choose(chosen_index)?;
        }
        Step::End => break,
        // The flow parked on a wake condition (flow suspension). Reserved:
        // not emitted by the runtime until the suspension milestone lands.
        Step::Suspended => break,
    }
}
Ok(())
}
}

Story is the mutable half of the two-object model: it holds an Arc<Program> and carries all the execution state. How that state is partitioned — and how it can be shared across flows or kept private — is the subject of The State Model.

Step variants

VariantMeaningNext action
Line(OutputLine)One line of content (text, tags, block_id). More may follow this turn.Call continue_single() again.
DoneThe turn’s output is complete (ink done). The story is not over. Carries no payload.Call continue_single() again for the next turn.
Choices(Vec<Choice>)The story is waiting for a choice.Call story.choose(index), then continue.
EndThe story reached -> END. Permanently finished. Carries no payload.Stop stepping.
SuspendedThe flow parked on a wake condition (brink flow suspension). Reserved — not yet emitted by the runtime. Carries no payload.Stop driving; resume when the host’s wake surface reports the flow runnable.

Only Step::Line carries a payload — the OutputLine’s text and any ink tags (# tag) attached to it, plus a block_id identifying the run of adjacent content this line belongs to (see OutputLine::block_id). The terminal variants (Done, Choices, End, Suspended) carry no text or tags of their own — any text produced before the boundary was already delivered in a preceding Step::Line. The helpers step.text(), step.tags(), and step.is_terminal() work across variants: text()/tags() return empty for every variant but Line, and is_terminal() is true for anything but Line.

continue_single vs continue_maximally

  • continue_single() -> Step produces one step — ideal for typewriter UIs that reveal content a line at a time.
  • continue_maximally() -> Vec<Step> runs until a terminal step and returns every step produced along the way; the last element is always a terminal variant (Done, Choices, End, or — once flow suspension lands — Suspended). Ideal for click-to-continue UIs that show a whole passage at once.
#![allow(unused)]
fn main() {
extern crate brink_runtime;
use brink_runtime::{Step, RuntimeError, Story};
fn demo(story: &mut Story) -> Result<(), RuntimeError> {
loop {
    let steps = story.continue_maximally()?;
    for step in &steps {
        print!("{}", step.text());
    }
    match steps.last() {
        Some(Step::Choices(choices)) => story.choose(choices[0].index)?,
        Some(Step::End) | None => break,
        _ => {} // Done — loop again for the next turn.
    }
}
Ok(())
}
}

Both have _with(&handler) variants (continue_single_with, continue_maximally_with) that take a custom ExternalFnHandler for external functions.

Choices

When the story yields Step::Choices, execution is blocked until you select one with story.choose(index):

#![allow(unused)]
fn main() {
extern crate brink_runtime;
use brink_runtime::{Step, RuntimeError, Story};
fn demo(story: &mut Story, step: Step, selected: usize) -> Result<(), RuntimeError> {
match step {
Step::Choices(choices) => {
    for choice in &choices {
        println!("{}: {}", choice.index + 1, choice.text);
    }
    story.choose(choices[selected].index)?;
}
_ => {}
}
Ok(())
}
}

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 Step::Line steps (and turn boundaries in between) before reaching Choices or End. Each continue_single() carries only the text since the previous yield, and terminal steps carry none of their own. If your application needs the full passage, accumulate text across Line steps until a Choices or End arrives — or use continue_maximally(), which batches a whole passage for you.

The State Model

A running story has two kinds of mutable state, and they have different owners.

  • Execution-local state — the call stack, threads, output buffer, pending choices, and program counter. A flow is these; nothing ever shares them. They live on FlowInstance.
  • Story-state — global variables, visit counts, turn counts, the turn index, and the RNG stream. This is the state ink lets content read and write: VAR gold = 10, {shrine} (a visit count), RANDOM(1, 6).

The interesting design is in the second kind. Story-state lives in a World, and every unit of it can be homed to the shared world or made private to one flow, under a single policy. That one idea explains named flows, per-entity NPC dialogue, and the side-effect-proof “what would happen if…” evaluation the Speculation chapter covers.

This is a deeper layer than The Execution Model, which is about stepping a story. Here we’re concerned with where the state lives while it steps. If you only ever drive a single Story, the model collapses to “one bag of globals” and you can skip ahead — but the moment you run more than one flow, or want to preview a branch without committing to it, this is the chapter that makes those features make sense.

World and FlowLocal

Story-state is split across two types:

  • World — the shared layer: globals, visit_counts, turn_counts, turn_index, and the RNG (rng_seed + previous_random). A write to a world-scoped unit is visible to every flow sharing that world immediately.
  • FlowLocal — a per-flow override layer. It holds this flow’s private values for the units the policy homes to Local, and falls through to the World for everything else.

A read walks a chain — the flow’s own overrides first, then (for a forked flow) a frozen snapshot of its parent, then the World, and ultimately the program’s declared defaults. The first hit wins. A FlowLocal that overrides nothing (the common case) contributes no reads, so every access falls straight through to the World.

The runtime’s step functions take &mut impl ContextAccess and never touch these fields directly. A ContextView — built transiently for each step over (&mut World, &mut FlowLocal) — implements that trait and does the routing: on every get_global / set_global / visit_count / increment_visit / draw_random, it consults the policy and sends the access to the right layer.

The policy

Which units are shared and which are private is declared by a WorldPolicy:

#![allow(unused)]
fn main() {
extern crate brink_runtime;
use std::collections::BTreeMap;
use brink_runtime::{Scope, WorldPolicy};

// A per-entity NPC: everything private by default, but `gold` and the
// `shrine` knot's visit count are shared world state.
let policy = WorldPolicy {
    default: Scope::Local,
    overrides: BTreeMap::from([
        ("gold".to_string(), Scope::World),
        ("shrine".to_string(), Scope::World),
    ]),
    turn_index: Scope::Local,
    rng: Scope::Local,
};
let _ = policy;
}

Scope is just World or Local. The default field is the whole global-by-default vs local-by-default dial; overrides names the exceptions — each name is matched first as a global variable, then as a knot/stitch path. turn_index and rng are scoped as single scalar units of their own.

Four policies cover the useful cases:

Use casePolicy
Single-flow storydefault: World, no overrides — identical to a classic single Context
ink concurrent flowsdefault: World, no overrides — flows share everything, writes visible across
Per-entity NPCdefault: Local, a few World overrides for shared save data
Fully isolated branchdefault: Local, no overrides — nothing shared

The policy comes from the host, not from ink. The same .ink file can be instanced single-flow by one program and per-entity by another; the compiled artifact carries no policy. It is resolved once, when the World is created, against the program’s symbol table — an override naming a variable or knot the program doesn’t declare fails right there, as a PolicyError, not at runtime:

#![allow(unused)]
fn main() {
extern crate brink_runtime;
use brink_runtime::{Program, World, WorldPolicy, PolicyError};
fn demo(program: &Program) -> Result<(), PolicyError> {
// Resolves the policy against `program`'s symbols; bad names fail here, once.
let world = World::new(program, &WorldPolicy::default())?;
let _ = world;
Ok(())
}
}

WorldPolicy::default() is all-World — the degenerate policy where every unit is shared and there is no local layer. That is byte-for-byte the behavior of the old single Context, and it is the one the oracle corpus runs against, so it is the anchor the whole model is built not to disturb. Story::new uses it, which is why an ordinary single-story consumer never sees any of this machinery.

A determinism caveat, stated plainly. A World-scoped RNG stream interleaves draws from every flow sharing the world in execution order, so its output depends on how flows are scheduled. Flow-local RNG has no such dependency. World-scoped RNG is expressible — a shared “deck” mechanic wants it — but scoping it that way is opting into scheduling-order semantics.

Sharing a world, or not

Multiple FlowInstances can run against one World — this is how ink’s concurrent flows work, where one flow’s writes are immediately visible to the others. Or each flow can hold its own World, for independent playthroughs or branch-and-rollback. The runtime doesn’t prescribe which; the step functions take &mut World wherever it lives.

This is exactly the difference between the two named-flow spawners in Named Flows: a shared flow is a FlowLocal over the story’s default World; an isolated flow owns a separate World.

Because the VM steps one flow at a time, &mut World + &mut FlowLocal for the step is exclusive access with no locks. Flows sharing a world serialize by construction — which is correct, since concurrent writes to a shared world variable would race. Flows that want real parallelism use an isolated policy, so there is no shared mutable state to contend over.

Fork and sandbox

A FlowLocal can be forked: the child gets an empty override map over a frozen, structurally-shared snapshot of the parent as of the fork. The snapshot is O(1) — nothing is eagerly copied — so the child sees the parent’s state at fork time, the parent’s later writes don’t leak in, and discarding the child is just a drop.

Forking takes a Mode:

  • Mode::Normal routes by policy, exactly as above.
  • Mode::Sandbox treats every unit as local: the shared World becomes a read-only base. Reads still fall through to the world’s live values, but writes — even to world-scoped units — land only in this flow’s own overrides. Nothing outside the flow is touched, so you can run it against current state, observe what it produces, and throw it away.

Mode::Sandbox is the side-effect-proof primitive behind Speculation — evaluating “what would this choice do?” without the doing. Every construction path that predates forking produces Mode::Normal, which is what keeps the default single-flow story unchanged.

What’s implemented

The split, the policy, policy-aware routing, copy-on-write flow-local storage, fork, and sandbox mode are all live. One piece is a deliberate seam: commit (folding a fork’s private writes back into its parent) is defined but not yet implemented — it returns a CommitError. Nothing in the current release needs it: root flows persist their local state for their lifetime and never commit, and sandboxed evaluation always discards. See the scoped-flow-state spec for the full design, including the commit-conflict policy that a future release will settle.

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)

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(Arc::new(program), line_tables).

#![allow(unused)]
fn main() {
extern crate brink_format;
extern crate brink_runtime;
use brink_format::StoryData;
use brink_runtime::{RuntimeError, Story};
fn demo(story_data: StoryData) -> Result<(), RuntimeError> {
use std::sync::Arc;

let (program, line_tables) = brink_runtime::link(&story_data)?;
let mut story: Story = Story::new(Arc::new(program), line_tables);
let _ = &mut story;
Ok(())
}
}

Story takes the program by Arc, not by reference, so it carries no lifetime parameter — clone the Arc to run many stories against one Program.

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

Execution model

The step function executes opcodes in a loop until reaching a yield point: Done, End, or choice presentation. Each yield produces a Step (Line/Done/Choices/End) — only Line carries a payload (the output text accumulated since the last yield); the terminal variants carry none — continue_single returns one, continue_maximally returns a Vec<Step> 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 Step::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,
    scope_id: DefinitionId,       // Enclosing knot/stitch; == id for scopes
    name: Option<NameId>,         // Set for root/knot/stitch, None for children
    bytecode: Vec<u8>,
    counting_flags: CountingFlags,
    path_hash: i32,               // Seed for shuffle RNG
    param_count: u8,              // Declared parameters (0 for most containers)
}

scope_id names the lexical scope a container belongs to. For a scope-owning container (root, knot, stitch) it equals id; for a child container (a gather, a choice target, a sequence) it points at the enclosing scope. Line tables are keyed by scope, so this is what lets a locale overlay swap the rendering data for one knot without touching its children’s bytecode — see Line Templates.

param_count is the number of parameters the container declares — two, for === call(action, present) ===. The container’s prologue binds them with that many leading DeclareTemps, and the runtime uses the count to arity-check a host-directed entry (choose_path_string_with_args) or a call_function. It is 0 for the vast majority of containers. (Historical: .inkb files built by the retired converter reference pipeline always left it 0, because inklecate’s JSON didn’t expose it.)

content_hash is a free function in brink-format (content_hash(&str) -> u64), used to derive DefinitionIds from ink paths. It is not a field on ContainerDef.

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

Function evaluation & host-directed entry

Raised by the engine→ink direction: calling an ink function from host code (call_function, begin_function_eval), and jumping the story to a path (choose_path_string). See Runtime API.

VariantWhen
FunctionNotFoundcall_function named something that isn’t a function or knot
ArgCountMismatchWrong number of arguments for the target’s declared parameters
FunctionYieldedA host-called function tried to present choices or end the story
AlreadyEvaluatingFunctionA function evaluation is already in progress on this flow
NotEvaluatingFunctionresume_function_eval called with no evaluation in progress
AsyncExternalInCallA function called from the synchronous call_function path hit a deferred external
UnknownPathchoose_path_string given a path matching no knot, stitch, or label
JumpWhileAwaitingExternalTried to jump while the flow is parked on an unresolved external

A function evaluated from host code must run to a return value. FunctionYielded means the ink you called wanted to become the story — present choices, or hit -> END — which the isolated evaluation path cannot honor.

Safety limits

The VM caps anything that accumulates, so malformed bytecode or a runaway loop in the story fails loudly instead of hanging.

VariantWhen
StepLimitExceededOpcode budget exhausted — likely an infinite loop in the story
LineLimitExceededA single turn produced more lines than continue_maximally allows

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
RanOutOfContentExecution fell off the end of a knot — usually a missing -> DONE or -> END
UnimplementedThe story uses an opcode not yet supported by the VM

Locale errors

Raised by apply_locale() when a .inkl overlay doesn’t match the program it’s applied to. See Localization.

VariantWhen
LocaleChecksumMismatchThe overlay was compiled against different bytecode — recompile it
LocaleScopeNotInBaseThe overlay carries a scope the base program doesn’t have
LocaleScopeMissingLocaleMode::Strict and the overlay omits a scope the base requires

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. Function-evaluation and locale errors are recoverable in the same sense: the story state is untouched, so correct the call (or recompile the overlay) and try again. Story errors may be recoverable depending on context.

Safety-limit errors abort partway through a turn, leaving the story mid-step; treat the instance as spent and restart it from a snapshot rather than continuing. 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:

#![allow(unused)]
fn main() {
extern crate bevy_app;
extern crate bevy_brink;
use bevy_app::App;
use bevy_brink::BrinkPlugin;
fn demo(app: &mut 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 private FlowLocal override layer, 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
            .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.

Spawning a flow takes no seed/policy parameter: its FlowLocal always starts fresh and empty. What’s shared vs. private is a property of the world, set up once — see below.

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 private FlowLocal (.inner) — overrides for whatever units the policy homes to Local
BrinkProgram<M>ComponentHandle<ProgramAsset> the flow runs against
BrinkLocale<M>ComponentHandle<LineTablesAsset> the flow renders with
BrinkGlobals<M>Resourcethe one shared World for marker M — globals, visit/turn counts, RNG; auto-inserted on first fulfillment

World vs. Local: one shared World, opt-in private state

Every flow spawned under a marker advances against the same BrinkGlobals<M> World. By default every unit of story-state (VARs, visit/turn counts, RNG) is World-scoped — reads and writes are immediately visible to every flow sharing it, with no “commit” step, because nothing was ever forked. This is byte-identical to plain ink and is almost certainly what you want for a single-flow game or for genuinely shared globals (inventory, quest flags) across concurrent NPC conversations.

For per-entity private state (an NPC’s own mood, its own “have I greeted them before” history), install a policy at plugin setup naming exactly the VARs and knots that should be private — everything else stays shared:

    let mut policy = WorldPolicy::default(); // default: every unit World-scoped
    policy.overrides.insert("mood".to_string(), Scope::Local); // this VAR is private per flow
    policy
        .overrides
        .insert("greeting".to_string(), Scope::Local); // this knot's visit count too

    let mut app = App::new();
    app.add_plugins((
        AssetPlugin::default(),
        BrinkPlugin::<()>::default().with_policy(policy),
    ));

A knot override covers its own visit/turn count and everything nested under it, so sequence/cycle/stopping content ({ Hello | Welcome back }) inside a Local-scoped knot varies per flow too. There is no “commit private state back to shared” verb — if a private counter should eventually raise a shared flag, write that promotion in ink, where it’s visible (~ if mood > 10: ~ reputation += 1), not as a Bevy-side merge helper.

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), a &mut routing view built with flow_context_view over the entity’s BrinkContext and the marker’s shared BrinkGlobals, an ExternalFnHandler, the entity, and Commands. They return Advance:

AdvanceMeaning
Step(Step)a step 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<()>,
    )>,
    globals: Option<ResMut<BrinkGlobals<()>>>,
    programs: Res<Assets<ProgramAsset>>,
    tables: Res<Assets<LineTablesAsset>>,
    bindings: Res<BrinkBindings<()>>,
    mut commands: Commands,
) {
    let Some(mut globals) = globals else {
        return; // no flow fulfilled yet
    };
    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();
        // World-scoped units (the default) route to the shared `globals`;
        // Local-scoped units (opted into via a policy override) route to
        // this flow's own `ctx`.
        let mut view = flow_context_view(&mut globals, &mut ctx);
        let _ = flow.advance_until_terminal(
            &p.program,
            &t.tables,
            &mut view,
            &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 per call (FlowInstance::LINE_LIMIT).

If you have no bindings, pass &bevy_brink::FallbackHandler instead of building one from BrinkBindings.

From an exclusive system — advance_flow

advance_flow::<M>(&mut World, entity) -> Result<Step, 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 Step::Choices (or a BrinkChoicesPresented event) means the flow is waiting for a pick. Select with choose:

    let mut view = flow_context_view(globals, ctx);
    flow.choose(&mut view, 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()) {
        let mut view = flow_context_view(globals, ctx);
        flow.choose(&mut view, idx)?;
    }

Observer events

step_one/advance_until_terminal fire one EntityEvent per produced step, targeted at the flow entity, so observers react to exactly the situation they care about (no match on a Step):

EventFires forCarries
BrinkLineDelivered<M>Step::Line (mid-stream)text, tags
BrinkChoicesPresented<M>Step::Choicestext, tags (always empty), choices: Vec<Choice>
BrinkTurnDone<M>Step::Done (turn complete, -> DONE)text, tags (always empty)
BrinkStoryEnded<M>Step::End (-> END)text, tags (always empty)
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, from a system that reads the component:
    let text = transcript.text(); // all lines joined with '\n'
    let lines = &transcript.lines; // Vec<(String, Vec<String>)> — (text, tags)
    render_conversation(&text, lines);

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>:

#![allow(unused)]
fn main() {
extern crate bevy_app;
extern crate bevy_brink;
use bevy_app::App;
use bevy_brink::{BrinkBindingsAppExt, Value};
fn demo(app: &mut App) {
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:

#![allow(unused)]
fn main() {
extern crate bevy_app;
extern crate bevy_brink;
extern crate bevy_ecs;
use bevy_app::App;
use bevy_ecs::prelude::{Event, On};
use bevy_brink::{BrinkBindingsAppExt, BrinkCommand};
fn demo(app: &mut App) {
#[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:

#![allow(unused)]
fn main() {
extern crate bevy_app;
extern crate bevy_brink;
extern crate bevy_ecs;
use bevy_app::App;
use bevy_ecs::prelude::{Component, In, Query};
use bevy_brink::{BrinkBindingsAppExt, BrinkQueryInput, Value};
#[derive(Component)]
struct Enemy;
fn demo(app: &mut App) {
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:

#![allow(unused)]
fn main() {
extern crate bevy_app;
extern crate bevy_brink;
extern crate bevy_ecs;
use bevy_app::App;
use bevy_ecs::prelude::{Commands, On};
use bevy_brink::{
    BrinkBindingsAppExt, BrinkExternalAwaited, BrinkResolveExternalExt, Value,
};
fn demo(app: &mut App) {
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):

#![allow(unused)]
fn main() {
extern crate bevy_app;
extern crate bevy_brink;
use bevy_app::App;
use bevy_brink::{BrinkBindingsAppExt, Value};
async fn compute_roll(sides: i32) -> i32 { sides }
fn demo(app: &mut App) {
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 — or bind_brink_command bindings, which fire their event once the call completes.

From an exclusive system — call_ink_function

#![allow(unused)]
fn main() {
extern crate bevy_brink;
extern crate bevy_ecs;
use bevy_ecs::prelude::{Entity, World};
use bevy_brink::{call_ink_function, BrinkCallError};
fn demo(world: &mut World, flow_entity: Entity) -> Result<(), BrinkCallError> {
let can_advance = call_ink_function::<()>(world, flow_entity, "can_advance", &[])?;
let _ = can_advance;
Ok(())
}
}

Synchronous; resolves world-access query bindings inline because it holds &mut World. A bind_brink_command-bound external reached this way buffers its trigger the same way normal playback does, then fires the event against the World once the call completes — it does not fall through to the in-story fallback the way an unbound external would.

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:

#![allow(unused)]
fn main() {
extern crate bevy_brink;
extern crate bevy_ecs;
use bevy_ecs::prelude::{Commands, Entity, On};
use bevy_brink::{BrinkCallCommandsExt, BrinkCallResolved};
fn demo(commands: &mut Commands, flow_entity: Entity, in_combat: bool) {
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

#![allow(unused)]
fn main() {
extern crate bevy_app;
extern crate bevy_brink;
extern crate bevy_ecs;
use bevy_app::App;
use bevy_ecs::prelude::{Component, Event, In, Query};
use bevy_brink::{BrinkBindingsAppExt, BrinkCommand, BrinkQueryInput, Value};
#[derive(Component)]
struct Enemy;
#[derive(Event, BrinkCommand)]
struct PlaySound { name: String }
fn clamp01(args: &[Value]) -> f32 {
    args.first().and_then(Value::as_float).unwrap_or(0.0).clamp(0.0, 1.0)
}
fn enemy_count(In((_flow, _args)): In<BrinkQueryInput>, q: Query<&Enemy>) -> Value {
    Value::Int(q.iter().count() as i32)
}
async fn expensive_roll(args: Vec<Value>) -> Value {
    Value::Int(args.first().and_then(Value::as_int).unwrap_or(6))
}
fn demo(app: &mut App) {
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

Three bevy-brink features build on the split between the immutable program and per-flow/shared story state: runtime locale switching (swap the rendering data), .brkt transcript persistence (save and re-render the visible history), and per-entity SaveState durability (save and restore game state — the section below). Locale switching and transcripts 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:

#![allow(unused)]
fn main() {
extern crate bevy_asset;
extern crate bevy_brink;
extern crate bevy_ecs;
use bevy_asset::{AssetServer, Handle};
use bevy_ecs::prelude::Commands;
use bevy_brink::{LocaleAsset, SetBrinkLocale};
fn demo(commands: &mut Commands, assets: &AssetServer) {
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:

#![allow(unused)]
fn main() {
extern crate bevy_asset;
extern crate bevy_brink;
extern crate bevy_ecs;
extern crate brink_runtime;
use bevy_asset::Assets;
use bevy_ecs::prelude::{Commands, Entity};
use bevy_brink::{
    apply_locale_overlay, BrinkLocaleOverride, LineTablesAsset, LocaleAsset,
    LocaleMode, ProgramAsset,
};
use brink_runtime::RuntimeError;
fn demo(
    commands: &mut Commands,
    npc_flow: Entity,
    program: &ProgramAsset,
    base_tables: &LineTablesAsset,
    locale_asset: &LocaleAsset,
    mut line_tables: Assets<LineTablesAsset>,
) -> Result<(), RuntimeError> {
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`
let _ = handle;
Ok(())
}
}

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

#![allow(unused)]
fn main() {
extern crate bevy_brink;
use bevy_brink::{capture_transcript, BrinkFlow, ProgramAsset};
fn demo(flow: &BrinkFlow<()>, program: &ProgramAsset) {
let bytes: Vec<u8> = capture_transcript::<()>(flow, program);
// write `bytes` into your save file
let _ = bytes;
}
}

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:

#![allow(unused)]
fn main() {
extern crate bevy_brink;
use bevy_brink::{
    render_transcript_asset, LineTablesAsset, ProgramAsset, TranscriptAsset,
    TranscriptError,
};
fn demo(
    transcript_asset: TranscriptAsset,
    program: &ProgramAsset,
    line_tables: &LineTablesAsset,
) -> Result<(), TranscriptError> {
let lines = render_transcript_asset(
    &transcript_asset, program, line_tables, /* plural resolver */ None,
)?;
// each entry is (text, tags) for one resolved line
let _ = lines;
Ok(())
}
}

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.

Game-state saves (SaveState)

.brkt (above) saves the visible history of a playthrough. This is the other half: game state — globals, visit/turn counts, turn index, RNG — the values a restored entity needs to behave correctly, independent of what was ever printed. It’s the same [SaveState]/save_state/load_state mechanism Story uses on the non-Bevy path (see docs/scoped-flow-state-spec.md’s F6 amendment), lifted to work over any flow’s context.

A save is one SaveState for the shared World, plus one per entity flow — composed by you. bevy-brink doesn’t invent a save-file format or a bundled “all saves” type; it hands back plain SaveState values (they #[derive(Serialize, Deserialize)]) and you collect them into whatever your game already uses for persistence — a HashMap, a save-slot struct, rows in a database.

State-only — not execution position. A loaded entity does not resume mid-line: its call stack and program counter are never captured. You re-enter it at a knot of your choosing (typically FlowStart::Address on a fresh BrinkFlowRequest), and the restored state — a private “have I greeted them” visit count, a private mood variable — is what makes that re-entry pick up where the entity left off.

ItemRole
BrinkGlobals::save_state / load_statethe shared World, direct — no routing view needed
save_flow_state / load_flow_stateone flow, routed through its ContextViewLocal-scoped entries land in that flow’s own BrinkContext; World-scoped entries land in the shared World
LoadReportwhat a load couldn’t apply (e.g. a saved VAR the current program no longer declares) — surfaced, not silently dropped

Saving

Save the world once, then each entity you want to persist:

    let world_save = globals.save_state(program);
        let entity_save = save_flow_state(&mut globals, &mut ctx, program);

Compose the results into whatever your save file looks like — a plain HashMap<String, SaveState> keyed by "world" plus an id per entity is enough to round-trip through serde_json (or any other serde format).

Loading

Load the world first, then each entity through its own view. Every entity snapshot taken at the same save moment carries identical World-scoped values, so loading them one after another idempotently rewrites the same shared values — not a conflict:

    let report: LoadReport = globals.load_state(program, world_save);
        let report = load_flow_state(&mut globals, &mut ctx, program, entity_save);

Each load_flow_state call returns a [LoadReport] — check report.is_clean() and surface report.unknown_globals if not (a story patch that renamed/removed a VAR since the save was taken).

Re-entering after load

Spawn a fresh flow at the knot you want the restored entity to resume from — the same request-component pattern as any other flow (see Spawning & Driving Flows):

    commands.spawn(
        BrinkFlowRequest::<()>::builder()
            .story(story)
            .start(FlowStart::Address("greet".to_string()))
            .build(),
    );

Runnable end-to-end demo (drives two flows, saves world + both entities to a JSON-round-tripped map, loads into a fresh App, and re-enters each at a knot): cargo run --example book_saves.

Handles (T1d)

An ink Value::Handle is an opaque {kind, id} token — a name for a host resource (an entity, a timer, an audio instance), never a live pointer. The script world holds only the token; dereferencing happens host-side, inside a binding, against a registry bevy-brink owns. See docs/t1d-spec.md §4 for the full design; this page covers the bevy-brink side of it.

HandleKind — the two-halved trait

Each kind of host resource a story can hold a handle to implements one trait, split by which side of the save boundary the knowledge lives on:

#![allow(unused)]
fn main() {
extern crate bevy_ecs;
extern crate bevy_brink;
extern crate serde;
use bevy_ecs::world::World;
use bevy_brink::HandleKind;
use serde::{Serialize, Deserialize};
#[derive(Serialize, Deserialize, Clone)]
struct TimerSaveKey { remaining_secs: f32 }

struct TimerState { remaining_secs: f32 }

struct Timer;

impl HandleKind for Timer {
    const KIND: &'static str = "Timer";
    type Resource = TimerState;
    type SaveKey = TimerSaveKey;

    fn save_key(&self, _world: &World, res: &TimerState) -> Option<TimerSaveKey> {
        Some(TimerSaveKey { remaining_secs: res.remaining_secs })
    }

    fn resolve(&self, _world: &mut World, key: &TimerSaveKey) -> Option<TimerState> {
        // Timers are resumable: `resolve` doesn't look one up, it rebuilds
        // one from the recipe `save_key` captured.
        Some(TimerState { remaining_secs: key.remaining_secs })
    }
}
}

SaveKey is a reconstruction recipe, not just a foreign key — pick a point on the spectrum per kind: identity lookup (an NPC GUID, an asset path), reconstruction (the timer above — resumable), or deliberate ephemerality. Returning None from save_key for a particular live resource means “this one is meaningless across sessions” — an implementor choice, never a category the spec assigns. resolve returning None means the recipe no longer names anything live (e.g. the NPC despawned) — a normal, expected outcome, never a fault.

Registering a kind

#![allow(unused)]
fn main() {
extern crate bevy_app;
extern crate bevy_ecs;
extern crate bevy_brink;
extern crate serde;
use bevy_app::App;
use bevy_ecs::world::World;
use bevy_brink::{HandleKind, BrinkHandleAppExt};
use serde::{Serialize, Deserialize};
#[derive(Serialize, Deserialize, Clone)]
struct TimerSaveKey { remaining_secs: f32 }
struct TimerState { remaining_secs: f32 }
struct Timer;
impl HandleKind for Timer {
    const KIND: &'static str = "Timer";
    type Resource = TimerState;
    type SaveKey = TimerSaveKey;
    fn save_key(&self, _world: &World, res: &TimerState) -> Option<TimerSaveKey> {
        Some(TimerSaveKey { remaining_secs: res.remaining_secs })
    }
    fn resolve(&self, _world: &mut World, key: &TimerSaveKey) -> Option<TimerState> {
        Some(TimerState { remaining_secs: key.remaining_secs })
    }
}
fn demo(app: &mut App) {
app.register_handle_kind::<(), Timer>(Timer);
}
}

This inserts Timer’s HandleRegistry<Timer> resource (opaque u64 token ids, live-resource storage) and indexes it under "Timer" in the app’s type-erased HandleKinds<()> — the index everything below (is_valid, save/load, GC) dispatches through when it only has a runtime Value::Handle to work from, not a static K.

Mint a token from inside any binding that has the resource to hand and the loaded Program (to resolve Timer’s name id):

let value = registry.mint_value(program, TimerState { remaining_secs: 30.0 });

mint_value returns None if this compile never interned "Timer" — a kind name only exists in a program’s name table if the source graph actually references Handle<Timer> somewhere (a typed binding signature or annotation).

is_valid — the standard world-query binding

bevy-brink registers is_valid(h) automatically on every BrinkPlugin<M> — no wiring needed. It’s an ordinary bind_brink_query binding (per spec, not a language intrinsic): call it from ink like any other EXTERNAL. Dead, unregistered-kind, and non-handle arguments all just return falseis_valid never faults.

Dead handles and declared failure values

Dereferencing a dead handle is never UB and never a turn fault. A binding looks its token up in the typed HandleRegistry<K> and, on a miss, returns whatever failure value it has chosen to declare — Value::Null, a sentinel int, whatever fits the binding’s contract:

fn npc_name(In((flow, args)): In<BrinkQueryInput>, registry: Res<HandleRegistry<Npc>>, mut commands: Commands) -> Value {
    let Some((_, id)) = args.first().and_then(Value::as_handle) else { return Value::Null };
    match registry.get_or_dead::<()>(id, &mut commands, flow) {
        Some(npc) => Value::String(npc.name.clone().into()),
        None => Value::Null, // the declared failure value for this binding
    }
}

get_or_dead is opt-in telemetry: a dead lookup fires BrinkDeadHandleDeref<M> (entity, kind, id) at the flow entity, so a host can log/count misses without that changing what the binding returns. Use plain HandleRegistry::get for a silent lookup.

Save/load and the rehydration report

bevy-brink persists the token → SaveKey table beside the ink SaveState, and keeps token ids stable across a load — the ink state (which only ever holds {kind, id}) is untouched; only the registry’s right-hand side (the live resources) rebinds:

// Alongside BrinkGlobals::save_state:
let handle_save = save_handles::<()>(app.world());

// Alongside BrinkGlobals::load_state, after the ink SaveState is loaded:
let report = load_handles::<()>(
    app.world_mut(),
    &program,
    &script_save_state,
    &handle_save,
    RehydrationPolicy::Lenient,
)?;

load_handles walks every Value::Handle reachable from script_save_state (including tokens nested in arrays/maps/records/closures) and buckets each one into the returned RehydrationReport:

BucketMeaning
reboundresolved to a live resource, same token id
dead_by_resolvea registered kind, a persisted recipe, but resolve returned None — normal
dead_ephemerala registered kind with no persisted entry — the kind chose ephemerality for this token
dead_by_unregistered_kindthe kind isn’t registered at all — suspicious (integration drift)

Never-fail-load holdsload_handles always returns Ok under the production-default [RehydrationPolicy::Lenient], even with unregistered kinds present (they land in dead_by_unregistered_kind for you to log). RehydrationPolicy::StrictKinds is the dev/CI knob: an unregistered kind fails the whole call loudly instead, so a registration that drifted out of sync with a save file surfaces immediately rather than silently dropping state.

Registry GC at -> DONE

Script state is fully enumerable, so bevy-brink computes the live handle-token set — every token reachable from the shared World’s globals plus every flow’s own local state — at every -> DONE a flow reaches, and drops each registered kind’s unreachable registry entries. No script-side destructors exist or are needed; this is wired automatically by BrinkPlugin<M> (see gc_on_turn_done). A dev-only HandleRetentionMetrics<M> resource tracks each kind’s live count and last-sweep drop count — a diagnostic, not a semantic.

EntityMapper integration

For a kind whose Resource is Entity, cross-references inside a SaveKey may point at entities by their old session’s id. resolve can consult and populate the app-wide HandleEntityRemap resource (implements bevy_ecs::entity::EntityMapper, reset at the start of every load_handles call) to translate between old and new Entity ids as it reconstructs scene-based resources.

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.

Host-directed entry

runner.goToPath(path, ...args) moves the play head to a knot or stitch by name, optionally passing arguments to a parameterized one — the JS face of the runtime’s host-directed entry. programChecksum(bytes) returns a stable checksum of a compiled story, for detecting when a save was made against a different build.

Speculation

runner.speculate() forks a SpeculationHandle — a throwaway run over the story’s current state that never touches the live runner. It’s the JS binding for the runtime’s speculation primitive: drive it with the same verbs (advance, choose, goToPath, evalFunction), read what it produces, and drop it.

// "What lines would jumping to `cellar` produce, without going there?"
const spec = runner.speculate();
spec.goToPath("cellar");
const preview = spec.advance();   // a Line; the live runner is untouched

For the common “evaluate this fragment” case, runner.evaluate(source) composes speculate() with a compile step so you can run an arbitrary expression or snippet against current state and read back its value and output in one call.

Sessions

StorySessionHandle wraps a story with a journal — the JS binding for sessions & replay. It drives like the runner but records every input, so you can snapshot and diff state, persist a save, and restore or replay it.

const session = new StorySessionHandle(bytes, 42);   // optional seed

const before = session.snapshot();
session.continueSingle();
const delta = session.diff(before, session.snapshot());   // typed StateDiff

const journal = session.exportJournal();   // serde-serializable save artifact
// later: StorySessionHandle.restore(bytes, journal) → { session, outcome }

session.snapshot() returns the typed StateSnapshot (globals with real values, visit counts, call stack) for serialize-and-compare; session.debugSnapshot() returns a name-resolved DebugState for a live inspector UI — the same two-view distinction the sessions chapter draws. onJournalDirty registers a listener for debounced persistence.

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.

The Editor

@brink-lang/editor is the ink editor itself — a CodeMirror 6 layer that turns a text box into an ink IDE: diagnostics, semantic highlighting, completions with auto-import, hover, go-to-definition, find-references, inline rename with a live breakage badge, code actions, folding, signature help, inlay hints, and a screenplay dialect. It’s the same editor the Studio app is built from — Studio is one consumer, and your tool is on equal footing.

It sits on top of @brink-lang/web: the editor owns the UX, and you supply thin callbacks that bridge each feature to a @brink-lang/web op. The compiler and runtime stay in the WASM module.

Two entry points

  • brinkStudio(options) returns a CodeMirror Extension — the editor. You add it to an EditorView and pass callbacks in options.
  • ProjectSession is the reusable session/file/dirty/conflict layer. It owns the wasm EditorSessionHandle, tracks unsaved buffers, applies cross-file edits, and detects external-change conflicts. Studio uses this same class.

Features light up per callback

brinkStudio needs only three things to render a working editor — a compile bridge and the two semantic-token accessors. Every other feature turns on only when you provide its callback: no getCodeActions, no code-actions menu; no getHover, no hover. You opt into exactly the surface your tool wants.

import { EditorView } from "@codemirror/view";
import { brinkStudio } from "@brink-lang/editor";
import { compile } from "@brink-lang/web";

const view = new EditorView({
  parent: document.body,
  extensions: [
    brinkStudio({
      compile,                         // the @brink-lang/web bridge
      getSemanticTokens: () => [],     // wire to a session's semantic_tokens
      getTokenTypeNames: () => [],     // …and its token type names
      // getHover, getCompletions, getCodeActions, prepareRename, … as needed
    }),
  ],
});

The full per-callback contract — every option, the @brink-lang/web op behind it, and the host hooks — is in the editor consumer guide.

The dialogue dialect

Screenplay-style cues (@Name: line) are handled by a configurable dialect, not a hardcoded mode. The dialect option defaults to the AT_CUE_DIALECT preset; pass your own DialogueDialect to change the convention, or null to tear the whole screenplay layer down for a plain editor.

import { EditorView } from "@codemirror/view";
import { brinkStudio, AT_CUE_DIALECT } from "@brink-lang/editor";
import { compile } from "@brink-lang/web";

const view = new EditorView({
  extensions: [
    brinkStudio({
      compile,
      getSemanticTokens: () => [],
      getTokenTypeNames: () => [],
      dialect: AT_CUE_DIALECT,   // or a custom DialogueDialect, or null
    }),
  ],
});

The dialect is tooling only — it drives classification, hidden sigils, and the screenplay transitions in the editor, and never reaches the runtime. Use setDialect(view, d) to reconfigure a mounted editor live.

Headless and host-styled

Pass theme: false for a headless editor — no CodeMirror theme at all. brink still emits a documented class taxonomy and data attributes (choice/body lines carry data-option-path with their full weave lineage, for example), and your host stylesheet owns the appearance. This is how a tool matches its own design system instead of inheriting Studio’s skin.

Beyond the editor extension, the package also ships small boundary helpers for building chrome around it — sortDiagnostics to order a diagnostics list, lineColAt to turn a byte offset into a line/column — plus standalone versions of individual features (foldingExtension, hostGutterExtension, renameExtension, findPanel) for hosts that compose extensions directly instead of going through brinkStudio.

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.

Conventions for Your Engine

A project declares its dialogue conventions once, in brink.toml:

[dialogue]
preset = "at-cue"            # the shipped cue preset: `@NAME: <>` + chained dialogue
run-ends-at = ["character", "action"]

[[dialogue.elements]]
kind = "action"
prefix = ">"                 # `> text` is an action paragraph

The studio reads that declaration to classify lines while you write and to fold delivered lines into speaker runs in its Player. Your engine can read the very same conventions — without depending on the editor — because brink compile writes the resolved dialect beside the compiled story:

story.inkb
story.dialect.json     # the preset merged, affix sugar expanded

story.dialect.json is a derived product, like story.inkb: never hand-edit it. The source of truth is brink.toml. (The desktop app’s Export Story writes the same pair.)

Reading it in a game

Install the pure-TypeScript package (no runtime dependencies):

npm install @brink-lang/dialect

Then parse each line your story runtime emits and fold the lines into runs:

import { DialectParser, runsOf, type DialogueDialect } from "@brink-lang/dialect";
import raw from "./story.dialect.json";

const dialect = raw as DialogueDialect;
const parser = new DialectParser(dialect);

// `delivered` is whatever your runtime printed, one entry per line.
const lines = delivered.map((text, i) => ({
  segments: parser.parseEmitted(text),
  boundary: choicesWerePresentedBefore(i), // a turn boundary, if you track one
}));

for (const run of runsOf(lines, dialect)) {
  // run.kind   — "character" for a speaker run, "action", or null (narrative)
  // run.attrs  — carried groups, e.g. { speaker: "GRISWOLD" }
  // run.lines  — indices into `delivered` that belong to this run
}

parseEmitted splits one emitted line into segments (@GRISWOLD: cue, (quietly) parenthetical, remaining text); runsOf applies the dialect’s run_ends_at rule so a cue-less line after a cue is attributed to the last speaker until an action, the next cue, or a choice boundary ends the run — exactly the rule the studio Player uses, from the same file.

The ink text itself is untouched: the dialect never reaches the runtime, and a project that declares no [dialogue] prints plain lines everywhere.

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 dialect — character cues (@Name: line) render with hidden sigils, name coloring, and depth indicators. This is the configurable dialogue dialect, defaulting to the @Name: convention; it’s editor tooling and never reaches the runtime.
  • 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 renders the structured, name-resolved DebugState snapshot: status, current location, globals (with changed values highlighted), call stack, visit counts, and pending choices, refreshed as the story advances.
  • Line-element switching — convert a line between narrative, choice, sticky choice, gather, and divert via keyboard or UI.
  • Auto-fix — one-click and batch fixes for diagnostics, from the Problems panel, the editor, and the command palette, plus a fix-on-save setting.

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 / documents / binder / session / search / conflict / output / symbol-menu slices
@brink-lang/editorthe CodeMirror 6 editor, IDE extensions, and the dialogue dialect
@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-lang/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 packages must exist first — @brink-lang/web resolves brink-web through a file: path to crates/brink-web/www/pkg, and the studio resolves brink-prose (the prose checker, loaded on demand) the same way to crates/brink-prose/www/pkg. pnpm install:checked refuses to install until both are built:

# 1. build the wasm packages (see Web & WASM)
wasm-pack build crates/brink-web --target web --out-dir www/pkg
wasm-pack build crates/brink-prose --target web --out-dir www/pkg

# 2. install + run the studio
pnpm install:checked
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.

Auto-fix

The studio can fix a diagnostic for you instead of asking you to hand-edit the source. This page is the author-facing tour; the full design (the Fixer model, the batching algorithm, the policy layering) is docs/autofix-spec.md, and the command-line equivalent is brink fix.

What a fix is

A fix is a small, targeted edit that discharges one diagnostic — the same kind of change you’d make by hand, computed for you. Diagnostics stay exactly what they always were (a squiggle, a Problems-panel row); a fix is just an optional extra action attached to one.

Every fix is labeled with a tier, so you know how far it’s allowed to go before you click it:

  • Safe — the result behaves exactly the same as before; only the redundant or already-inert text changes. Safe fixes are the ones “Fix all safe” batches for you without asking case by case.
  • Suggested — probably what you meant, but it changes what the story does or removes text you wrote (for example, trimming an extra argument from a call). Each one is a deliberate, one-at-a-time click unless your project has explicitly promoted that diagnostic code to batch automatically (see Policy below).
  • Placeholder — the fix can’t complete the thought for you; it removes the ambiguity and drops your cursor where you still need to type something. Placeholder fixes are never batched. The studio’s tier button doesn’t show this wire spelling to authors — it reads Needs input instead (see Where fixes appear below).

A fix never appears for a diagnostic it can’t actually clear, and applying one always re-runs analysis — if a fix doesn’t make its own diagnostic go away, that’s treated as a bug in the fixer, not something the studio hides.

Where fixes appear

All of these are different doors onto the same underlying fix — pressing “Fix” on a Problems row and picking the identical entry from the editor’s context menu produce the same edit.

  • Problems panel — a row with a fix available shows a Fix button (labeled with its tier) beside the diagnostic; right-clicking a row lists every fix offered for it alongside the existing suppress actions. The panel’s header shows a Fix all safe (N) button once at least one Safe fix is available anywhere in the project — N is an exact count, not an estimate, so the button never promises more than clicking it will do.
  • Editor context menu — right-click a squiggle to get the same fix entries (each still labeled with its tier) for the diagnostic under the pointer, plus a trailing Fix all safe in this file entry that only appears alongside at least one offered fix.
  • Code-actions menu — the lightbulb-style menu at the cursor lists the fixes for diagnostics whose own range covers the cursor position (not just any diagnostic nearby); choosing one applies it immediately, and a Placeholder fix moves your cursor into the hole it left.
  • Command palette — “Fix: Fix all safe in project” and “Fix: Fix all safe in this file” run the same Safe-tier batch as the Problems panel’s header button, scoped to the whole project or to whichever file is focused.
  • Fix on save — Settings ▸ Saving ▸ Fix on save applies fixes automatically each time you save a file: Off, Safe fixes only, or Everything the project allows (which also includes any Suggested-tier code your project’s brink.toml has promoted to "auto", see Policy). This setting is a personal ceiling, not a project-wide switch — it can only make what happens on save more conservative than what the project allows, never less; a project promoting a code to "auto" doesn’t force it onto an author who has chosen “Safe fixes only”.
  • Other editors, via the LSPbrink-lsp offers each fix as a standard quickfix code action tied to its diagnostic, plus the source.fixAll.brink action that VS Code (and any client supporting fix-on-save code actions) runs automatically on save — the same Safe-tier batch the studio’s own on-save setting runs.

Policy: the [fix] table in brink.toml

A project can adjust the default tier behavior per diagnostic code with a [fix] table:

[fix]
E025 = "auto"   # promote a Suggested fix to batch automatically in this project
E014 = "off"    # never offer this fixer here
# a code left out of the table keeps its tier's default:
#   Safe -> batched automatically, Suggested -> offered per click, Placeholder -> never batched

The three values:

ValueMeaning
"auto"Batch this code’s fixes automatically wherever a batch runs — this is how a Suggested-tier fixer gets included in “Fix all safe” and fix-on-save. (A Placeholder-tier code can never be batched, no matter what the table says.)
"ask"The default: offered as a one-click fix, but not swept up by a batch unless it’s already Safe-tier.
"off"Withdraw the fix entirely — it stops being offered on any surface, even a single click.

[fix] travels with the project like [lints] does, so the CLI, the LSP, and the studio all read the same policy. In Settings, the same lint table that shows each diagnostic’s severity also carries a Fix column with these three values — editing it writes straight into brink.toml’s [fix] table, the same way changing a severity writes into [lints]. A code can have a [fix] entry independently of whether it’s configurable in [lints]; the two tables are keyed by the same diagnostic code but are otherwise unrelated.

Today’s Safe fixers

These are the diagnostic codes that currently ship a Safe-tier fixer — the ones “Fix all safe”, fix-on-save, and source.fixAll.brink will batch without asking. (Suggested-tier fixers exist too — E025, E063, E080, and E081 today — but a one-at-a-time click is expected for those, so they’re left out of this list; no Placeholder-tier fixer ships yet. See docs/autofix-spec.md §9 for the full registry.)

CodeWhat it fixes
E014Removes an effect-free ~ logic line — one that does nothing at all.
E031Trims a function call’s extra arguments down to the number its declaration actually accepts.
E092Removes a redundant #@public/#@private directive that only restates the module’s own default.
E095Removes a stale #@was tag that already names the definition’s current name — there’s nothing left to migrate.
E110Rewrites the deprecated #@effects(…) directive spelling to the current @[effects(…)] annotation.
E176Trims a divert-with-args site’s (-> knot(args), tunnel call, thread-start) extra arguments down to its resolved target’s declared parameter count — E031’s sibling for the divert-call shape.

This list will grow as more codes get Safe fixers; it’s read out of the FIXERS registry in crates/internal/brink-ide/src/fix.rs on main, so check there (or brink fix --dry-run on your own project) for the current truth rather than trusting a page that can drift.

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
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, replay, ide, export-xliff, compile-locale, regenerate-xliff, fmt
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.19 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-formatcrates/internal/brink-format/Binary interface between compiler and runtime
brink-dbcrates/internal/brink-db/Incremental project database, file discovery
brink-source-treecrates/internal/brink-source-tree/SourceTree trait: host-agnostic seam for enumerating/reading .brink source files
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.

crates/brink/ is an empty umbrella crate — it holds the brink name on crates.io and ships no code. There is no facade re-exporting the compiler and runtime; depend on brink-compiler and brink-runtime directly.

Editor support

Editor integration ships as the brink-lsp server (above), not as per-editor plugin crates. A Zed extension crate lived at crates/zed-brink/ until 2026-08-01; it was ink-only, unpublished, and its tree-sitter grammar pointed at an absolute local path that never existed in the repo, so it could not be built. It was removed rather than carried. A future editor plugin should target the native (.brink) surface — see the NS-T track.

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 (kept only for oracle regeneration)
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 compiles the .ink source with the native compiler, replays each episode, and compares the output turn-by-turn against the recording.

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.

The inkjs sanction

The C# oracle needs dotnet, which cloud sessions do not have. tools/inkjs-oracle is the same crawler ported onto inkjs 2.4.0 (with .NET’s System.Random installed in place of inkjs’s own generator, so shuffles and RANDOM draw the reference’s sequence). It never writes next to a golden — its job is to be checked against them:

cd tools/inkjs-oracle && npm ci && node --test && cd ../..
# every C# golden in the corpus, replayed through inkjs, must match
BRINK_INKJS_ORACLE=1 cargo test -p brink-test-harness --test inkjs_sanction -- --nocapture
BRINK_CASE=shuffle BRINK_INKJS_ORACLE=1 cargo test -p brink-test-harness --test inkjs_sanction -- --nocapture
# generated stories: brink vs inkjs (the nightly lane; PROPTEST_CASES raises the count)
BRINK_INKJS_ORACLE=1 cargo test -p brink-gen --test inkjs_differential -- --nocapture

The sanction compares the raw episode JSON after two normalisations (the C# tool’s error-message wrapper and source paths; double- vs single-precision float printing — see brink_test_harness::inkjs’s header for the measurement behind each). KNOWN_DIVERGENCES in inkjs_sanction.rs lists any case where the two reference runtimes genuinely disagree, with a reason, checked both ways like expected_mismatch; it is empty. A one-off comparison of a single story is node tools/inkjs-oracle/oracle.mjs path/to/story.ink --output-dir /tmp/out, then diff -r against the case’s oracle/.

The capture tier: tests/tier4-generated/

Stories that came out of the generator (issue #3380, docs/program-generator-spec.md §5) — a shrunk proptest counterexample, or a hand-minimised probe against the reference — live in their own tier, one directory per case:

FileDescription
story.inkThe shrunk story
oracle/*.oracle.jsonGolden episodes, same shape as the C# oracle’s
case.toml[provenance]: source (proptest/probe), property, optional seed, oracle-source (inkjs/csharp), optional issue; plus [source] expected_mismatch when the case pins a known open divergence

The tier is not part of RATCHET_EPISODE_COUNT — the shared corpus walk prunes the directory, so nothing here reaches oracle_snapshots, the inkjs sanction, or the respell sweep. Its own must-pass target is cargo test -p brink-test-harness --test tier4_generated: every case matches its golden (or is flagged expected_mismatch, checked both ways), and GENERATED_CASE_COUNT there only moves through a promotion. corpus_report prints the tier in its own section.

Promote with the script, which refuses a story brink cannot compile or the oracle cannot golden, writes the case, and bumps the count:

pnpm promote:generated -- --name glue-space-after-interpolation --story shrunk.ink \
    --property inkjs_differential --issue "#3507"
# from a saved failing run of a brink-gen property (the `--- source ---` block)
pnpm promote:generated -- --name my-case --from-log run.log --property inkjs_differential --seed "cc …"
# a hand-minimised probe pinning an open bug
pnpm promote:generated -- --name empty-then-branch-else --story probe.ink --source probe \
    --property "probe: #3507 shapes against inkjs" --issue "#3510" --expected-mismatch "#3510"
# maintainer-local: re-bless an existing case with the C# oracle (dotnet) and flip oracle-source
pnpm promote:generated -- --name my-case --rebless-csharp

The golden comes from tools/inkjs-oracle (npm ci there first); only the C# oracle is on the trust hierarchy, so a case blessed by inkjs is evidence at the sanction’s strength, and --rebless-csharp is how it graduates.

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.

Probe-found edge cases

Per maintainer directive (2026-09-02, docs/decision-log.md), a hand-minimized edge case discovered by the gen-expressions generator or by reference-differential probing against the C# ink runtime becomes a permanent corpus case, not a one-off fix. Cases added this way carry origin = "brink" in metadata.toml; a case documenting a known mismatch (brink diverges from the C# oracle) is still added with a real oracle golden and an expected_mismatch flag (see below) recording the gap. The first batch of these covers nested-gather fallback-choice semantics (#3383), multi-conditional lifting (#3386), sequence sharing across lifted branches (#3275), and lift-order of function-call side effects (#3395).

expected_mismatch: pinning a known divergence mechanically

A case that is added knowing it currently mismatches the C# oracle — to lock in the oracle-correct golden as a permanent regression target while the underlying bug stays open — sets expected_mismatch in its metadata.toml’s [source] table, naming the tracking issue:

[source]
origin = "brink"
original_id = "lift-order-seq-fn-cond"
expected_mismatch = "#3395"

The harness (brink_test_harness::corpus::expected_mismatch_issue and mismatch_flag_verdict, issue #3402) reads this field in both oracle_snapshots.rs and corpus_report.rs, rather than the two trusting a hand-maintained doc-comment enumeration of which cases are the expected failures:

  • An unflagged case that mismatches or is missing episodes fails exactly as it always has — the corpus summary snapshot changes, and corpus_report reports it under its category.
  • A flagged case that still mismatches or is missing episodes is the expected, steady state — corpus_report’s “EXPECTED-MISMATCH CASES” section lists it (with its issue number) as “still mismatching, as expected”, and its episodes are not required to clear RATCHET_EPISODE_COUNT.
  • A flagged case whose episodes all now match the oracle — the underlying bug got fixed — makes oracle_snapshots fail outright, naming the case and its issue: the flag must be removed from metadata.toml and RATCHET_EPISODE_COUNT raised in the same change that fixed it, not left to drift silently. corpus_report marks the same case “⚠ NOW MATCHES THE ORACLE”.

This means a fix landing for a flagged case can never leave the ratchet and the flag disagreeing about whether the case counts. The two cases that carried the flag first — tests/tier2/evaluation/lift-order-seq-fn-cond and tests/tier2/evaluation/lift-order-fn-then-cond (both #3395, the snippet above is the former’s) — went through exactly that cycle: added as known mismatches, then flipped to passing by the #3395 fix (the lift-order hoist, 2026-09-04), which removed their flags and raised RATCHET_EPISODE_COUNT in the same change. No case carries the flag today. A notes field alongside expected_mismatch is still welcome for human-readable context, but only expected_mismatch is read by the harness.

A follow-up case, tests/tier2/sequences/sequence-leads-multi-construct-line (#3401), covers a stateful sequence leading a multi-construct line (sequence, then inline conditional, then a second sequence): across three views the oracle advances apc, bpd, bpe. It was added as a known mismatch (brink advanced apc, bpc, bpd) and flipped to passing with the #3401 fix, which is the intended life cycle of such a case — the golden was correct from day one, and the ratchet rose when brink caught up. The fix landed with two more cases pinning the shapes it had to get right: sequence-cloned-into-glued-line ({a|b}{c|d|e} <>, where trailing glue keeps the line off the variant path) and sequence-shared-across-mixed-claim-branches (one lifted branch claims as a variant line, the other cannot, and both must advance one counter).