Introduction
brink is a toolchain for inkle’s ink
narrative scripting language, written in Rust. It compiles .ink source to a
compact bytecode format and executes it in a stack-based VM — as a CLI tool, an
embeddable Rust library, or a WASM module behind a web app.
Where to start
The book is in two halves: the toolchain (the engine-neutral core — compile and run stories) and integrations & clients (the things built on top). Jump to what you’re doing:
| You want to… | Start at |
|---|---|
| Write ink and play it from the terminal | Installation → The CLI |
| Drive stories from a Rust program | Your First Story → Embedding the Runtime |
| Ship a story in a Bevy game | Bevy Integration |
| Build a web front-end or editor | Web & WASM · Studio |
| Translate a story | Localization |
| Understand how it works, or hack on it | Concepts · Contributing |
Features
- Full ink language support: choices, gathers, weave, variables, lists, sequences, tunnels, threads, external functions
- Bytecode compiler with multi-file support (
INCLUDEresolution) - Stack-based VM with multi-instance execution (one compiled program, many story instances)
- Localization-ready format with line templates, interpolation slots, and plural categories
- Language server (LSP) and WASM bindings for editor and web integration
- No unsafe code, no panics — strict lint policy
Learning ink
brink implements the ink language as designed by inkle. To learn the language itself, see inkle’s Writing with Ink. This book documents brink — the compiler, runtime, and the things you build with them.
A note on maturity
brink’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
brinkcrate on crates.io is a name reservation and ships no code. Depend onbrink-runtimeandbrink-compilerdirectly.
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.
| Token | Matches |
|---|---|
? | exactly one character, never / |
* | any run of characters (including none), never / |
** | any run of characters, / included |
| anything else | itself, literally |
A trailing / is sugar for /**, so scratch/ and scratch/** are the
same. Note that a bare directory name does not cover its contents —
scratch 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— alwaysError, regardless ofdeny-warnings.warn— the code’s ordinary behavior:Warning, promoted toErrorbydeny-warningslike any other unconfigured warning.allow— unlike Rust’sallow, this does not remove the diagnostic. It only buys immunity fromdeny-warnings; the diagnostic still resolves toWarningand is still reported. To actually suppress a diagnostic at a specific site, use a//brink-disablecomment, or — in a.brinkfile — 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 theInfoorHintseverity tier respectively, belowWarning. Likeallow, both are immune todeny-warnings(escalating a deliberate downgrade back up would defeat the point of it). These map to the LSP client’sInformation/HintDiagnosticSeverity— the tier IDE conventions use for advisory findings that would be too loud as aWarningsquiggle (e.g. unused-symbol dimming). OnlyE157defaults 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 compileandbrink ide(issue #1373, extended tobrink ideby #1417): repeatable--deny/--warn/--allow <CODE>flags, plus-D warnings(mirroringrustc’s own flag) fordeny-warnings. Seebrink compile/brink ide.bevy-brink’s dev-modeInkLoader, viaBrinkPlugin::with_config(ProjectConfig { lints, deny_warnings, .. })(issue #1394) — the same override also reachescompile_story_inline(issue #1380), as long as it’s called after theBrinkPlugin/BrinkAssetsPluginthat carries the override has been added to the app.brink-lsp(issue #1417), viainitializationOptions.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 oftarget/.git/node_moduleshave 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
.brinkfile — the shape of “an author probably meant for this to be found” — it’s reported as a warning naming the directory and theunprune-dirsfix, 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 thenode_modules/<package>/lib.brinkshape 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 byunprune-dirsis, naturally, never reported this way — it wasn’t pruned in the first place. .gitignoreis 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..gitignoreresolution depends on more than a repository’s tracked content — a local uncommitted edit, a per-clone.git/info/exclude, a user’s globalcore.excludesFile— any of which could make two checkouts of byte-identical tracked source compile differently.unprune-dirsavoids exactly that: it lives inbrink.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.
| Source | Wins 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/types | defaults 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 (brink → strict, strict-ink → gradual) | — |
Per mount
-
brink compilediscoversbrink.tomlfrom the entry file you pass it.--dialect/--types, when actually given, override the file field-by-field (setting only--dialectleaves the file’stypes, if any, in effect).[lints]/deny-warningsapply too — a build that previously succeeded with a warning can now fail.--deny/--warn/--allow <CODE>and-D warningsoverride the file the same way, per code (issue #1373): passing--allow E014wins over abrink.tomlE014 = "deny"for that code, while any other code in the file’s[lints]table still applies. Seebrink compile. -
brink idehas no--dialect/--typesflags 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 warningstier for[lints]/deny-warnings(issue #1417), identical tobrink 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. Seebrink ide. -
brink-lspdiscoversbrink.tomlfrom the workspace roots the client declares atinitialize, resolving[project] dialect/typesand[lints]/deny-warningsinto its sharedLanguageOptions. A laterworkspace/didChangeConfigurationnotification or a watched edit tobrink.tomlre-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), andinitializationOptions.denyWarningsa boolean — both resolved once atinitialize(mirroringinitializationOptions.dialect/.types) and applied last, so they always win over the same code in the discoveredbrink.toml. An unrecognized per-code level string, or an unrecognized/ non-overridable code, is reported through the server’s usualtracing::warn!channel, never silently dropped. A second, independent mechanism also dims text in the client (issue #1618):E033(unreachable code after a divert) andE095(#@wasself-alias) publish with LSP’sDiagnosticTag::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 theWarningdefault these two carry today), not another tier likeInfo/Hintabove. -
The wasm editor session (
@brink-lang/web’sEditorSessionHandle) has no filesystem of its own — but it is inherently virtual, so it discoversbrink.tomlthe same waybrink compile/brink idedo: by walking its own document tree, not a real filesystem (issue #1414). Servebrink.tomlas an ordinary document —updateFile("brink.toml", text), at the entry’s directory or any ancestor of it — and calldiscoverProjectConfig(entry).[project] entryis honored only here (issue #2331, ruled 2026-08-07): a valid, resolvableentrysupersedes the argumentProjectSession/mountStudiowere constructed with — see “Precedence” above.EditorSessionHandle.getConfiguredEntry()returns the discovered value (nullif unset or unresolved);ProjectSessionis the layer that validates it against the session’s actual file set and applies it togetEntryFile()/compileProject(). Every other mount in this section —brink compile,brink ide,brink-lsp,bevy-brink— parsesentryas a recognized key (no “unknown key” warning) but does not act on it; it is inert there today. It applies[project] dialect/typesand[lints]/deny-warnings(issue #1366) — diagnostic severity rendered through this surface now reflects the file the same waybrink compile,brink ide, andbrink-lspalready did. Because this session is long-lived, a repeatedapplyProjectConfig/discoverProjectConfigcall fully re-resolves[lints]from the file each time rather than merging onto the previous result (issue #1397) — a code ordeny-warningspresent in an earlierbrink.tomlbut 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
discoverProjectConfigonce, after the project’s files are loaded and before any explicitsetLanguageDialect/setTypePolicycall — a field the session already has an explicit value for is left untouched, so a later explicit call always wins over an earlierdiscoverProjectConfig, matching the CLI’s flag precedence. Returns[](never throws) when nobrink.tomlis found anywhere from the entry’s directory up to the tree root.entrymust use the same root-relative spelling (no leading/) as every document path given toupdateFile/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 anddiscoverProjectConfigreturns[]exactly as if nobrink.tomlexisted, with no warning.If your embedder reads
brink.toml’s text with its own host file API (Nodefs, the browser File System Access API, a bundler import, …) and would rather hand that text in directly than load it as a document, useapplyProjectConfig(toml)instead — the same application/precedence rules apply, just without the discovery step.An embedder that wants to set
[lints]/deny-warningspolicy programmatically — without shipping abrink.tomlat all, or to override one it doesn’t control — callssetLintOverrides(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), plussetDenyWarningsOverride(bool)/clearDenyWarningsOverride()for the blanket flag. Both always win over the same code in an appliedbrink.toml’s[lints]table, in either call order — a laterapplyProjectConfig/discoverProjectConfigre-applies the explicit overrides on top of whatever it just resolved from the file, so abrink.tomlreload can never silently drop a previously-set override. Returns the unrecognized-level/unrecognized-code warnings as JSON (astring[]), the same channelapplyProjectConfiguses.
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
| Command | Description |
|---|---|
compile | Compile .ink source to .inkb or .inkt |
convert | Convert between ink formats (.inkb, .inkt) |
play | Play an ink story interactively or in batch mode |
debug | Step through a story: breakpoints, stepping, locals, call stack |
ide | Scriptable IDE queries & refactors (navigation, references, rename, structural refactors) |
fix | Apply automatic fixes to a project’s diagnostics to a fixpoint |
export-xliff | Export a story’s line tables as an XLIFF 2.0 file for translation |
compile-locale | Compile a translated XLIFF into a .inkl locale overlay |
regenerate-xliff | Update an XLIFF after recompilation, preserving translations |
fmt | Format .ink source files (--check, --stdin) |
replay | Re-render a saved .brkt transcript against a story (optionally a locale) |
brink compile
Compile .ink source files to bytecode. The input file is the story’s entry point; INCLUDE directives are resolved automatically.
brink compile <INPUT> [--output <OUTPUT>] [--dialect <strict-ink|brink>] [--types <gradual|strict>] [-D <CODE>]... [--warn <CODE>]... [--allow <CODE>]... [--debug-info]
Options
| Flag | Default | Description |
|---|---|---|
--output <FILE> / -o | stdout | Output 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-info | off | Emit 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:
| Extension | Format |
|---|---|
.inkb | Binary bytecode (production format) |
.inkt | Human-readable text dump (debugging) |
When no -o flag is given, .inkt is printed to stdout.
Examples
# Compile to binary
brink compile story.ink -o story.inkb
# Debug dump to file
brink compile story.ink -o story.inkt
# Debug dump to stdout
brink compile story.ink
# 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
| Flag | Default | Description |
|---|---|---|
--output <FILE> / -o | stdout (.inkt) | Output file path. Format inferred from extension. |
Supported formats
| Extension | Format | Description |
|---|---|---|
.ink | ink source | Compiled in-memory via the native pipeline (input only) |
.inkb | Binary bytecode | brink’s native binary format |
.inkt | Textual bytecode | Human-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
| Flag | Default | Description |
|---|---|---|
--speed <N> / -s | 30 | Typewriter speed in characters per second (0 = instant) |
--input <FILE> / -i | — | Read choice inputs from a file (batch mode) |
--locale <FILE> | — | Locale overlay (.inkl) to make available; repeatable. Switch at runtime with the l key. |
--save-transcript <FILE> | — | Write the playthrough’s .brkt transcript after the session ends. |
A saved .brkt can be re-rendered later (in any locale) with brink replay <TRANSCRIPT> --story <FILE> [--locale <FILE>].
Interactive mode
When run in a terminal, brink play launches a TUI with typewriter text reveal and arrow-key choice selection.
Key bindings
| Key | Story panel | Choice panel |
|---|---|---|
Space | Skip typewriter | Skip typewriter |
Up/Down | Scroll history | Select choice |
Enter | — | Confirm choice |
Tab | Focus choices | Focus story |
q | Quit | Quit |
Batch mode
When stdin is piped or --input is provided, the TUI is bypassed and choices are read as line-delimited 1-indexed integers.
# Pipe choices
printf "1\n3\n" | brink play story.inkb
# Read choices from a file
brink play story.inkb -i choices.txt
In batch mode, story text and choices are printed to stdout as plain text.
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
| Verb | Meaning |
|---|---|
break <file>:<line> | Arm a breakpoint. Lines are 1-based, as your editor shows them. |
run, continue | Advance until a breakpoint, a choice, or the story ends. |
step into|over|out | Advance one source line. next is step over. |
stepi into|over|out | Advance one VM instruction. |
locals | Named locals in the innermost frame. |
stack | The call stack, innermost first. |
list, l | Source 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; itsINCLUDEs are followed to build the whole project. (Identical discovery tobrink compile.)brink idehas no--dialect/--typesflags of its own — it discovers abrink.tomlstarting from the entry file’s directory and analyzes under whatever[project] dialect/typesit declares (or the plainstrict-ink/gradualdefaults, 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--fileinstead; cursor-only commands (signature,actions,refactor convert-line) take--at.--format text|json— output mode (defaulttext). See Output & exit codes.-D/--deny,--warn,--allow <CODE>(repeatable; issue #1417, mirroringbrink compile) — CLI overrides for a diagnostic code’s severity, on every command. The special codewarnings(-D warnings) promotes everyWarning-severity diagnostic toError(thebrink.tomldeny-warnings = trueequivalent). Always wins over the same code in a discoveredbrink.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:
| Form | Resolves to |
|---|---|
intro | a knot, or a top-level VAR/CONST/LIST/EXTERNAL named intro |
intro.evidence | the stitch evidence in knot intro |
Colors.Red | the list item Red of list Colors |
intro.evidence.found / intro.found | the 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 value | Symbol kind |
|---|---|
knot, stitch, variable, constant, list, list-item, external, label, param, temp | the 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 aspath:line:col.--format json— a stable shape forjqand 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:
| Code | Meaning |
|---|---|
0 | success / query true |
1 | query false, lint hit, diagnostics present, or a mutation refused by the safety gate |
2 | usage 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
| Flag | Effect |
|---|---|
--include-decl | include the declaration site in the results |
--exists | print nothing; exit 0 if referenced, 1 if not |
--count | print only the number of references |
JSON: { "name", "kind", "count", "references": [location, …] }.
symbols — file outline or project search
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:
| Mode | Flag | Behavior |
|---|---|---|
| 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 | --write | Apply 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/--writeare safe by default: if the change introduces any new diagnostic, they abort — print the diagnostics, produce nothing, exit1. 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]
| Operation | Synopsis | What 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:--writerefuses (and preview shows the would-be breakage). Use--unsafeonly 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" }.
| Command | JSON 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:
| Tier | Meaning | Default policy |
|---|---|---|
| Safe | Observably equivalent to the original — batched unconditionally | Always applied |
| Suggested | Probably what the author meant, but changes meaning or loses text | Applied only when the project (or --suggested) promotes the code |
| Placeholder | Leaves a hole the author must fill by hand | Never 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
| Flag | Default | Description |
|---|---|---|
--dry-run | off | Print the report; write nothing to disk. |
--diff [FILE] | off | Emit 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] | off | Promote 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 CODES | every code | Restrict the run to these diagnostic codes (comma-separated, e.g. E025,E080). An unrecognized code is a hard error, not a silent no-op. |
--placeholder | off | Also 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 N | 5 | Round 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
| Code | Meaning |
|---|---|
0 | The fixpoint was reached — every admitted fix converged within the round cap. |
1 | The round cap was hit, or a fixer failed to discharge its own diagnostic (the report names it either way). |
2 | Usage 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 vialink(), shareable across threads.Story— all the mutable state: operand stack, call stack, globals, visit counts, output buffer, and the line tables it renders with. It holds anArc<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
- Loading & Linking — produce
StoryData(compile.inkor read.inkb) andlink()it into aProgram+ line tables. - Drive it — step the story and react to each
Step. The loop, theStepvariants, and choice handling all live in The Execution Model. - External Functions — let the story call back
into your code (
EXTERNALfunctions), synchronously or deferred. - Named Flows — run parallel execution contexts within one story.
- Sessions & Replay — journal a playthrough for a save file, deterministic replay, and state snapshots/diffs.
- 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.inkbfile (wrong magic, bad checksum, truncated data)UnresolvedDefinition— a container references aDefinitionIdthat doesn’t exist in the story dataNoRootContainer— the story has no entry point container
External Functions
Ink stories can call functions the host provides — EXTERNAL fn_name(args) in
ink source. When the VM hits such a call, it asks your handler for a value.
Implement the ExternalFnHandler trait:
#![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::Nullis valid for fire-and-forget calls.Fallback— defer to the ink-side fallback body declared for that external (if the story provides one). ReturningFallbackfor an unknown name is how stories stay runnable without every binding present.Pending— you can’t answer synchronously (waiting on input, a network call, the game world). The story pauses on the deferred external, freezing with the call frame intact. Supply the result later withstory.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 returnsStepOutcome::AwaitingExternalinstead of erroring. See Runtime API.
The
bevy-brinkintegration builds a far richer binding facility on top of this — pure / command / world-query / async bindings, plus engine→ink calls. See Bevy › External Functions.
Named Flows
A single Story can run several independent execution contexts at once —
named flows. Each flow has its own position, call stack, and output, while
sharing the story’s globals and visit counts. They’re how you model a background
conversation, a parallel subplot, or a side channel that advances on its own.
#![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.FlowAlreadyExists—spawn_flowwith a name that’s already in use.
See Reference › Errors for the full list.
For engine integration,
bevy-brinkexposes each flow as an entity with its own components rather than name-keyed lookups — see Bevy › Spawning & Driving Flows.
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 softwarningslikeChoiceLabelDrift, 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 callenemy_count()and see the true answer.PolicyKind::Effect— state-changing. Allowed through only when the evaluation regime isEvalContext::Evaland effects are explicitly armed; otherwise it resolves to the ink fallback body. A name you don’t classify is treated asEffect— 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-blind — PolicyKind 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;.inklloading + plural-aware rendering are inbrink-runtime(apply_locale, thePluralResolvertrait); the CLI exposesexport-xliff/compile-locale/regenerate-xliff;brink-intlprovides the library API andIcuPluralResolver.bevy-brinkadds runtime locale switching — see Bevy › Localization & Saves.
Design principles
- Bytecode is locale-independent.
EmitLine(2)always means “line 2 of this scope’s table” — the VM never sees text directly. - Text lives in line tables, not the instruction stream, so content can be replaced without recompiling bytecode.
.inkloverlays replace line content per scope, never control flow.- Plural and gender logic lives in the line template, not the VM — translators can restructure sentences, reorder slots, and change plural forms per locale.
- Voice acting and text localization share one
LineIdaddressing scheme.
How the pieces fit
| You want to… | See |
|---|---|
| Extract, translate, and compile a locale | XLIFF Workflow |
| Understand plural categories and resolvers | Plurals |
| Know what a line template can express | Reference › Line Templates |
Read the .inkl byte layout | Reference › Binary Format |
The translation pipeline is always .ink → compile → .inkb →
export-xliff → .xlf. Never feed inklecate .ink.json into the intl
tooling — 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 → .inkb → export-xliff → .xlf.
(Always start from a compiled .inkb; never feed inklecate’s .ink.json into
the intl tooling.)
-
Export: extract every translatable line from a compiled story into an XLIFF file, organized by scope with context for translators.
brink export-xliff story.inkb --src-lang en --trg-lang es -o story.es.xlf -
Translate: work in the
.xlfdirectly or import it into a TMS (Lokalise, Crowdin, …). Translation state rides XLIFF’sstateattribute (initial/translated/reviewed/final). -
Compile: turn the translated XLIFF into a binary
.inkloverlay.brink compile-locale --base story.inkb --xliff story.es.xlf --locale es -o story.es.inkl -
Regenerate: after the source changes and you recompile, diff the new
.inkbagainst the existing XLIFF — preserving human translations while updating machine-managed fields (original text, context). Content-hash changes flag entries whose source moved.brink regenerate-xliff --base story.inkb --existing story.es.xlf -o story.es.xlf
Load the resulting .inkl at runtime with brink_runtime::apply_locale, or in
Bevy via the locale-switching API (see the Bevy Integration section).
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
--dialectflag, whystrict-inkis 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 & Mutation —
a[i],a[i] = v, and the runtime faults that replace ink’s usual silent tolerance. - Standard Library —
len/keys/values/containsand the mutatingpush/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:
- One grammar, two dialects.
brink-syntaxalways 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. - 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. - 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-syntaxalways 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.
Dialectis anAnalysisOptionsinput, 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 asbrink-runtimeis concerned. Loading and playing back.inkbnever 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). -
tempdeclarations, 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 { … }andfor name in expr { … }.for x in arriterates array values;for k in mapiterates 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 countertemp. -
break/continue, only inside an enclosingwhile/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 spellArray<T>/Map<K, V>, mutating verbs are free calls (push(tab, 5)), and function values are#fn(name)references. The ruled native.brinkspellings — 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) or0..=n(inclusive) — without materializing them. It behaves like a read-only array of its integers: it has alen, 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 costs5 dollars.
printsThis costsand files5 dollars.` as a tag. Thisis 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 anArray<int>.#[1, 2.5]is anArray<float>— the one implicit numeric promotion (int → float, see Values & Types) joins elements before homogeneity is judged.#{"Mira": 3}is aMap<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:
| Situation | What happens |
|---|---|
a[i] / a[i] = v with i outside [0, len(a)) | Fault — index out of bounds |
m[k] read with k not present | Fault — no such key |
m[k] = v with k not present | Inserts the pair |
| Indexing a value that isn’t indexable | Fault — not indexable |
An array index that isn’t an int | Fault — invalid index |
| A map key outside the key domain | Fault — 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 mvisits keys in the order they were inserted;keys(m)andvalues(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):
| Verb | Signature | Notes |
|---|---|---|
len | len(x: [T] | [K: V] | string | range): int | element / entry / character / span count |
contains | contains(a: [T], x: T): bool | element scan (arrays); key test on maps |
contains_value | contains_value(m: [K: V], v: V): bool | content-equality scan over values; O(n) and honest about it |
keys / values | keys(m: [K: V]): [K] | eager snapshots, insertion order |
index_of | index_of(a: [T], x: T): Option<int> | first match, or none |
first / last | first(a: [T]): Option<T> | none on empty |
min / max | min(a: [T]): Option<T> | doctrine order; none on empty |
get | get(m: [K: V], k: K): Option<V> | the non-faulting map read |
sorted / sorted_by | sorted(a: [T]): [T] | functional twins of sort/sort_by |
And the mutators — statement-only, lvalue-first:
| Verb | Signature | Notes |
|---|---|---|
push | push(a: [T], x: T) | append |
insert | insert(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_at | remove_at(a: [T], i: int) | remove at i; out of bounds faults |
remove | remove(m: [K: V], k: K) | total — removing an absent key is a no-op |
clear | clear(m: [K: V]) | empty in place |
sort / sort_by | sort(a: [T]) | in-place ordering — next section |
pop | pop(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)isNaN, flows through+and*freely, and never faults. Ordering contexts are where it stops. - Dev mode faults, loudly. A
NaNoperand reachingsort/sorted/min/maxis 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
NaNis placed by a pinned total order —NaNsorts greater than everything,NaNties withNaN,-0ties 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]isstart + i, and out of bounds faults exactly like an array. - Equality is content.
1..=6 == 1..7is 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..=6prints as you spelled it); equality doesn’t care. - Emptiness is legal and load-bearing.
0..nwithn = 0iterates zero times and is false in condition position — that’s what makesfor 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
| Code | Fires when | Policy |
|---|---|---|
E051 | collection syntax in a strict-ink project | dialect gate |
E055 | a mutator’s first argument is not an lvalue | both |
E056 | a statement-only mutator used in expression position | both |
E058 | mutator argument count mismatch | both |
E065 | an unconstrained empty literal escapes inference as Unknown | strict |
E066 | collection elements can’t unify — the type is Conflicted | strict |
E076 | a map key in a declaration default doesn’t fold to a scalar | both |
E077 | a non-constant element/value inside a declaration-default literal | both |
E083 | a declaration default that isn’t compile-time constant | both |
E106 | a statically-visible map-literal key outside int/string/bool | both (warning) |
E107 | a bare none with no type from context | both |
E116 | an Option<T> used as a condition — no truthiness | strict (runtime fault under gradual) |
E117 | int(r) over a range not proven inhabited | strict (runtime fault under gradual) |
E119 | a sort_by/sorted_by comparator — or a map/filter/fold callback — provably exceeds pure·silent | both |
E149 | remove on a statically-known array — use remove_at | strict (runtime fault under gradual) |
E152 | a statically non-key-domain needle in contains(m, …) on a statically-known map | strict (warning; runtime returns false under gradual) |
Where this is ruled
- Collection surface: literals, indexing, stdlib slice 1 —
docs/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/pop→Option) —docs/stdlib-spec.md§§1.1/1.4, §§4–5; decision log 2026-07-18 (“Optionpulled 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,insertreserved, 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_atsplit (seq remove-by-index renamedremove_at,removenarrowed 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_byin-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
refinement —
docs/stdlib-spec.md§7; F7/F8 ruled 2026-07-19; F30 content equality ratified 2026-07-19 (delegated batch); NS-A5 (#1136). - Views —
docs/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):
| Situation | What 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 m | Fault — map has no such key |
m[k] = v with k not already a key in m | Inserts the key-value pair |
| Indexing into a value that isn’t an array or a map | Fault — not indexable |
An array index expression that isn’t an Int | Fault — 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] = vrequiresito 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/insertare 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"] = vinserts 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.brinkspelling for method-position calls (rooms.get("Edda")) and theas-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. Thex or defaultcoalescing form has landed (B1, issue #1460) — but only on the native.brinksurface:orin 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.brinkfile can already writeget(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
-1for “not found”,0for “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 question — find 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.
| Situation | Answer |
|---|---|
max of an empty array | none — absence |
get of a key never filed | none — absence |
find of a substring that isn’t there | none — absence |
a[i] out of bounds | fault — an index is a claim |
min over a map (wrong container) | fault — malformed question |
min over unorderable elements | fault — 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):
| Verb | Signature | none means |
|---|---|---|
find | find(s: string, sub: string): Option<int> | substring absent (USV index when present) |
index_of | index_of(a: [T], x: T): Option<int> | no element equal to x |
first | first(a: [T]): Option<T> | array empty |
last | last(a: [T]): Option<T> | array empty |
min | min(a: [T]): Option<T> | array empty |
max | max(a: [T]): Option<T> | array empty |
pop | pop(a: [T]): Option<T> | array empty — nothing removed |
get | get(m: [K: V], k: K): Option<V> | key absent |
pick | pick(x: [T] | range): Option<T> | nothing to draw from |
non_empty | non_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 T — get(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
.brinksurface. The coalescing formx or defaultcollapses 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 backnone, sox or expensive()never pays forexpensive()whenxis alreadysome(_). Theas-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
Tfrom the condition’sOption<T>, scoped strictly to the success arm (anelsenever sees it), and rebinds every iteration inwhile. For v1 the binding must be the entire condition — composing it with&&/||is an error (E145); let-chains can land later, additively. Anasin 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, whereorstays ink’s boolean or and there is noasbinding 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
| Code | Fires when | Policy |
|---|---|---|
E066 | an Option and its element type collide — the slot is Conflicted | strict (runtime type fault under gradual) |
E107 | a fresh un-annotated declaration initialized from a bare none | both dialects, both policies |
E116 | an Option<T> used as a condition — no truthiness | strict (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 (“Optionpulled forward as a compiler-known builtin; the absence doctrine”). - The verb flips (
find/index_of/first/last/min/max/pop/get→Option; the sentinels dying unshipped) —docs/stdlib-spec.md§§3–5; same 2026-07-18 ruling; NS-A1 implementation (#1107).pickandnon_emptyjoined 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 forgivesnone— 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 andOption<T>already exist on the current brink dialect, so it landed as abrink-runtimedisplay 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.brinkfrontend in B1 (issue #1460):InfixOp::Coalesce, distinct from the brink dialect’s oracle-frozenInfixOp::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 “Theasbinding: one construct, both condition positions,{if}spelling” (immutable, typedTfromOption<T>, scoped to the success arm, rebinding per iteration inwhile, whole-condition-only for v1). Landed on the native.brinksurface in B1b (issue #1475). Choice-guardaswas ruled separately the same day (“Choice-guardasun-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
noneneeds a type from context —docs/stdlib-spec.md§1.4; E107’s declaration rule (#1107). Option<T>in the static type language —docs/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 formfor k, v in mnow parses and lowers on the native.brinksurface; 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:
| Iterable | Elements | Order |
|---|---|---|
array [T] | its values | the array’s own order |
map [K: V] | its keys | insertion order |
| range | its integers | ascending |
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 aroom = 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.brinkparser (#1461). This chapter’s~ { … }brink-dialect surface has no two-binding spelling yet, sofor 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 makes0..0, which runs zero times. Emptiness is load-bearing, not an edge case to guard — the same posture that makespick(0..n)answernonerather 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), andfilter_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:freturnsOption<U>,some(v)is kept and unwrapped,nonedrops 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) andmap_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 inmap_eachis the speed bump. A write or emission that would be anE119compile error (or a dev-mode runtime fault, for an opaque callback) insidemap’s callback is legal insideeach’s ormap_each’s — that is the entire reason the pair exists. They are deliberately not gated byE119.
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
| Code | Fires when | Policy |
|---|---|---|
E051 | loop syntax (a ~ { … } block) in a strict-ink project | dialect gate |
E052 | an await form (including while await) — suspension not yet implemented | both |
E054 | a loop variable or block temp shadows an already-visible temp | both (warning) |
E057 | break/continue outside any enclosing while/for | both |
E065 | a loop variable with no element type (e.g. iterating a non-iterable) escapes as Unknown | strict |
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/continueinside~ { … }blocks; the pure-logic fence; block scoping and theE054shadow warning;E057) —docs/t1b-surface-spec.md§2; the T1b landing (#577). - One closed iterable set; sequences & iteration as one design —
docs/stdlib-spec.md§4; decision log 2026-07-18. - The iterate protocol: pull-shaped, laws attached (“every element
exactly once;
noneterminal 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’
foris 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. Thefor k, v in mdesugar ruled with it; the native.brinksurface 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), thenfilter_map/each/map_each(slice 2), same day. - Mutating iteration
for ref x—docs/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-refon 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:
| Type | Order |
|---|---|
int, float | numeric, cross-comparing — 1 < 1.5 < 2 in one array |
bool | false < true |
string | lexicographic by Unicode scalar value |
| arrays | lexicographic 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/nonedon’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 (
vec2…mat4) — vectors have no one honest lexicographic order, so tower kinds are not orderable, and the compiler refuses even acompareprotocol registration for them (E118); - structs and enums — today. The ruled path for records is an
explicit
compareprotocol 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 order | your comparator | |
|---|---|---|
| imperative, in-place | sort(a) | sort_by(a, cmp) |
| past-participle, functional | sorted(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)saysafirst,cmp(b, a)must saybfirst, 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:
| Fault | Fires when |
|---|---|
ComparatorNotAFunction | the second argument isn’t a function value — “sort_by comparator must be a function value fn(T, T): int, got int” |
ComparatorReturnType | the comparator returned a non-int — “got string”, never a silent coercion |
ComparatorEscaped | the 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-total — 0.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_byis not on the dev fault list — deliberately. Your comparator owns the element semantics; a NaN never reaches the ordering machinery as a comparison result, sosort_byfaults 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_modewithExecMode::Devas the default (see Runtime API); a shipping host flips toExecMode::Prodfor release builds. The ruled home for the setting is project configuration (abrink.tomlprofile) 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:
| Verb | Signature | Notes |
|---|---|---|
heap_push | heap_push(a: [T], x: T) | statement-only mutator; add x, restore the invariant |
heap_pop | heap_pop(a: [T]): Option<T> | remove and return the minimum; none on empty |
heap_peek | heap_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
| Code | Fires when | Policy |
|---|---|---|
E055 | an ordering mutator’s first argument isn’t an lvalue (sort(#[2, 1]), heap_push(sorted(a), x)) | both |
E056 | sort/sort_by/heap_push used in expression position — they return nothing | both |
E058 | wrong argument count on sort/sort_by/heap_push — names the expected signature | both |
E118 | a 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 |
E119 | a 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 grid —
sort_byin-place,sorted_bythe functional twin, per the naming law — decision log 2026-07-19 (“Findings batch 1 ruled”). - F14:
sort_byoff 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 atheap_push;Optionon empty; sealedHeap<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-facingExecModedefault is the docket’s F35 — pending. - The absence returns (
min/max/heap_pop/heap_peek→Optionon 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).
| Function | Kind | Signature | Result |
|---|---|---|---|
len(x) | pure | array or map → Int | element/entry count |
keys(m) | pure | map → Array | keys, insertion order |
values(m) | pure | map → Array | values, insertion order |
contains(x, v) | pure | array or map, any v → Bool | array: element membership; map: key membership |
push(a, v) | mutator | array, any v | appends v |
insert(x, k_or_i, v) | mutator | array or map, key/index, value | array: insert at index (shifts right); map: insert-or-overwrite |
remove(m, k) | mutator | map, key | remove key (no-op if absent) |
remove_at(a, i) | mutator | array, index | remove 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;vcan be any value at all, including another collection. -
On a map,
contains(m, v)checks key membership. Ifvis outside the ratified key domain (a float, an array, a map, …), the answer is simplyfalse— 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 makescontainsbehaviorally 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 byE152(Warningseverity) — the call is alwaysfalse, and the diagnostic exists to catch what’s usually a typo or a stale key type rather than intentional code.E152is strict-mode only (it stays silent undertypes = gradual, where the runtime’s totalfalseabove remains the only behavior), and like other warnings it can be re-leveled or suppressed via[lints](e.g.E152 = "deny"orE152 = "allow"), a//brink-disablecomment, 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.brinkspellings —fndeclarations, 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
Unknownthat 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 onint → floatbelow — 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.bool—true/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 (vec2…mat4), 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 as | Meaning |
|---|---|
int, float, bool, string | the scalar kinds |
content | a captured prose run (fragment-capture-backed) |
divert | a divert target as a value |
void | return 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…): R | a function value (Function Values) |
vec2 vec3 vec4 quat mat2 mat3 mat4 | the numeric tower |
Handle<K> | K names a handle kind from the host manifest |
a declared STRUCT name | that 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 realE065escape at the declaration — the seam must be typed, or it’s an error, never a shrug. - The ink seam — planned. When the native
.brinksurface lands, ink-authored symbols will enter native code asUnknown, 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
| Code | Fires when | Policy |
|---|---|---|
E061 | annotation names an unrecognized type | both |
E063 | annotated 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 type | warning under gradual, error under strict |
E064 | types = strict without dialect = brink | config |
E065 | a type escapes strict inference as Unknown | strict |
E066 | a type is Conflicted — its uses disagree | strict |
E067 | assigning the result of a void function | strict |
E069/E070/E071 | struct construction missing / extra / mistyped field | strict (runtime fault under gradual) |
E075 | struct construction literal in a VAR/CONST default doesn’t match its declared shape | gradual (strict reports E069/E070 first) |
E078 | int()/float() argument outside the numeric+bool+string domain | strict (runtime fault under gradual) |
E084 | duplicate field in a struct construction literal | both |
E107 | bare none with no type from context | both |
E116 | Option<T> used as a condition — no truthiness | strict (runtime fault under gradual) |
Where this is ruled
- Typed mode —
docs/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 truthiness —
docs/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
.brinksurface 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 17 — call(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 knownfn(T…): Rtype is checked statically — a wrong argument count or a wrong argument type is a compile error, exactly like calling an ordinary function. An escape toUnknownat 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 totypes = 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()andbind()are strict-mode gradual-typed for now: their result type isn’t yet threaded through thefn(T…): Rlattice 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.
goldis just a number;~ gold = gold - 1is 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): intcan carry⟨reads: gold⟩, meaning calling it readsgold. 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), neverreads: gold).
On the native
.brinksurface the same annotation attaches Rust-style — on the line directly above theflow/fnhead rather than inside its body. The arguments, thepuresugar, 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
xnames both an imported module and a visible definition, a qualifiedx.yis a compile error. Resolve it with an alias — brink never silently picks a winner. - A dotted
a.bis module-qualified only ifawas 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 kind | Default visibility |
|---|---|
| Undeclared stem-module (legacy pool) | public |
Declared #@module | private |
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
namefrommodule” fix that inserts theIMPORTline in the right place — below any existing import block, else below theINCLUDEblock, else at the top under the#@moduleheader. - 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
IMPORTstatements folds into a singleIMPORT … (N modules)region, mirroring theINCLUDEblock fold. - Formatting.
brink fmtcanonicalizesIMPORTspacing —IMPORT { a , b AS c } FROM mbecomesIMPORT { 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:
| Character | Escape | When 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 nameattrs(optional) — an array of attribute declarations this kind accepts
Each attribute declaration has:
name(required) — the attribute namerequired(optional, defaults tofalse) — 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 onlywaveis declared) reportsE164. - An undeclared attribute on a declared kind (e.g.,
<wave speed="2">when onlyamountis declared) reportsE165. - A missing required attribute on a declared kind (e.g.,
<item>the lantern</item>whenitem’sidis declaredrequired) reportsE173.
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:
- Remove the
markupsection from the manifest entirely, or - 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 indocs/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
analysis — AnalysisOptions::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
.inkbfile produced from a brink-dialect source and one produced from strict-ink source are indistinguishable bytecode tobrink-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/Storysplit, 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/FlowLocalsplit, 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
.inksource 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(())
}
}
Storyis the mutable half of the two-object model: it holds anArc<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
| Variant | Meaning | Next action |
|---|---|---|
Line(OutputLine) | One line of content (text, tags, block_id). More may follow this turn. | Call continue_single() again. |
Done | The 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. |
End | The story reached -> END. Permanently finished. Carries no payload. | Stop stepping. |
Suspended | The 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() -> Stepproduces 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:
| Field | Type | Description |
|---|---|---|
text | String | display text for this choice |
index | usize | the value to pass to story.choose() |
tags | Vec<String> | tags attached to this choice |
Ink defines several choice kinds, but they’re resolved by the compiler and VM
— the runtime always hands you a flat Vec<Choice> of the ones currently
selectable:
- Once-only (
*) — the default; disappears after it’s taken. - Sticky (
+) — stays available on later visits. - Fallback — has no display text; auto-selected when nothing else is
available, and never appears in the
choicesvec. - Conditional — guarded by a condition; only present when the guard is true.
Choice-related errors (InvalidChoiceIndex, NotWaitingForChoice) are listed in
Reference › Errors.
StoryStatus
You can query story.status() at any time:
| Status | Meaning |
|---|---|
Active | Ready to step. |
WaitingForChoice | Must call choose() before stepping. |
Done | Hit a done opcode. Can resume with continue_single(). |
Ended | Hit -> END. Cannot step further. |
Text accumulation
A story may produce several 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 toLocal, and falls through to theWorldfor 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 case | Policy |
|---|---|
| Single-flow story | default: World, no overrides — identical to a classic single Context |
| ink concurrent flows | default: World, no overrides — flows share everything, writes visible across |
| Per-entity NPC | default: Local, a few World overrides for shared save data |
| Fully isolated branch | default: 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::Normalroutes by policy, exactly as above.Mode::Sandboxtreats every unit as local: the sharedWorldbecomes 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 side —
brink-runtime, which only understands compiled bytecode. It depends exclusively onbrink-format.
This split has real, practical consequences:
- The runtime never links the compiler. Shipping
brink-runtimedoes not pull in the parser, analyzer, or codegen — the embeddable binary stays small. brink-formatdefines everything that crosses the boundary —StoryData,ContainerDef,AddressDef,Opcode,Value,DefinitionId, and the rest. Source-level types (AST, HIR, symbols) never leak through it.- Hot-reload works — because the runtime loads bytecode without the compiler
present, new
StoryDatacan be swapped in at runtime. - Save-file portability — the runtime speaks only in
DefinitionIds (stable hashes), so save state isn’t tied to a particular compilation.
The same firewall is why the LSP depends on the analyzer but not the compiler: editor features need parse-through-analysis, not codegen.
For the full crate inventory, paths, and the exact dependency rules, see Contributing › Crate Layout. For how source becomes bytecode, see The Compilation Pipeline.
Compilation Pipeline
The compiler transforms .ink source files into bytecode through six phases:
Phase 1: Discovery + Parse (brink-db, brink-syntax) per-file -> CST -> AST
Phase 2: HIR Lowering (brink-ir::hir) per-file -> HIR
Phase 3: Analysis (brink-analyzer) cross-file -> symbol resolution, types
Phase 4: LIR Lowering (brink-ir::lir) cross-file -> unified LIR program
Phase 5: Bytecode Codegen (brink-codegen-inkb) per-container -> StoryData
Phase 6: Output (brink-format) -> .inkb / .inkt
The LSP runs phases 1-3. The compiler runs all phases.
Phase 1: Discovery + Parse
ProjectDb::discover() finds all .ink files starting from the entry point, following INCLUDE directives. Each file is parsed by brink-syntax into a lossless CST (via rowan) and then into a typed AST. The parser uses error recovery and always produces output, even for malformed input.
Phase 2: HIR Lowering
The AST is lowered to HIR (High-level Intermediate Representation) per-file. This phase handles weave folding — converting the flat sequence of choices and gathers in ink source into a container tree. Implicit structure like root containers and auto-entering the first stitch is materialized here.
Phase 3: Analysis
Cross-file semantic analysis merges per-file symbol manifests into a unified symbol index, resolves names, and performs type checking. The project database (brink-db) supports incremental updates for the LSP.
Phase 4: LIR Lowering
Per-file HIR plus analysis resolutions are lowered into a unified LIR (Low-level IR) program. The LIR is a flat, container-oriented representation ready for bytecode emission. Container planning, label allocation, and instruction selection happen here.
Phase 5: Bytecode Codegen
brink-codegen-inkb walks the LIR and emits bytecode per container, producing the StoryData structure: containers with bytecode, line tables, variable definitions, list definitions, address labels, and external function declarations. All cross-definition references use DefinitionId, resolved at link time by the runtime.
Phase 6: Output
StoryData can be serialized to .inkb (binary format for production) or .inkt (human-readable text dump for debugging).
Entry points
| Function | Description |
|---|---|
compile_path(path) | Full pipeline from a file path |
compile(entry, read_file) | Full pipeline with a custom file reader (for WASM, tests) |
Reference
Look-it-up material for the toolchain. You don’t read these front to back — you jump in when you need the exact opcode, byte layout, or error variant.
- Runtime API — the
brink-runtimepublic surface:link,Program,Story, statistics, RNG. - Bytecode & Opcodes — the full opcode set executed by the VM.
- Binary Format — the
.inkb/.inkt/.inklfile layouts. - Containers & DefinitionId — the identity scheme and the container/address model.
- Line Templates — the localizable line content types (slots, selects, plural keys).
- Errors — every
RuntimeErrorvariant and what causes it.
For the concepts behind these — how the VM steps, why the format is split — see Concepts.
Runtime API
The public surface of brink-runtime — the crate that executes compiled
stories. For how these pieces fit together conceptually, see
The Execution Model; for a worked walkthrough,
see Embedding the Runtime.
Core API
| Item | Kind | Description |
|---|---|---|
link() | Function | Link StoryData into (Program, line_tables) |
Program | Struct | Immutable, shareable compiled story |
Story | Struct | Per-instance mutable execution state |
Line | Enum | Yielded by continue_single() / continue_maximally(): Text, Done, Choices, End |
Choice | Struct | A single choice — text, index, tags |
StoryStatus | Enum | Active, WaitingForChoice, Done, Ended |
RuntimeError | Enum | All runtime errors (see Errors) |
link() returns the immutable Program and the story’s line tables
(Vec<Vec<LineEntry>>) — the swappable rendering data. Hand both to
Story::new(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
| Method | Returns | Use |
|---|---|---|
continue_single() | Line | one line at a time (typewriter UIs) |
continue_maximally() | Vec<Line> | a whole passage, last element terminal |
continue_single_with(&h) / continue_maximally_with(&h) | as above | same, with an ExternalFnHandler |
choose(index) | () | select a choice when WaitingForChoice |
status() | StoryStatus | query state at any time |
See The Execution Model for the step loop, the
Line variants, and StoryStatus, and
External Functions for the _with forms.
Named flows
spawn_flow, continue_flow_maximally, choose_flow, destroy_flow,
flow_names — parallel execution contexts within one story. See
Named Flows.
Statistics
story.stats() returns execution counters: opcodes executed, steps, threads
created/completed, frames pushed/popped, choices presented/selected, snapshot
cache hits/misses, and materializations. Useful for profiling and for asserting
on VM behavior in tests.
RNG
The VM uses FastRng (a simple LCG) by default. DotNetRng reproduces the C#
reference implementation’s random behavior — use it when you need bit-for-bit
parity with inklecate. Implement the StoryRng trait for a custom source.
Bytecode VM
The runtime is a stack-based bytecode VM.
Design properties
- Stack-based: operands pushed/popped from a value stack
- Jump offsets are container-relative
- Cross-definition references use
DefinitionId, resolved to compact indices at link time - Short-circuit
and/orcompiled to conditional jumps, not handled by the VM
Value types
enum Value {
Int(i32),
Float(f32),
Bool(bool),
String(Arc<str>), // Refcounted for cheap cloning
List(Arc<ListValue>), // Refcounted
DivertTarget(DefinitionId), // Target address for diverts
VariablePointer(DefinitionId), // Reference to a global variable
TempPointer { slot, frame_depth }, // Reference to a local variable
Null,
FragmentRef(u32), // Index into the output fragment store
}
String and List are Arc-wrapped so cloning is O(1), matching C# reference semantics and making call-frame forking cheap. (Atomic refcounts, so a Value can flow through Bevy’s parallel scheduler.) FragmentRef points at a captured run of structural output parts, kept intact so it can be re-rendered in another locale.
Opcode reference
The VM’s full instruction set is listed below — around 70 opcodes. Each is encoded as a single discriminant byte followed by zero or more operand bytes.
Stack and literals
| Opcode | Operands | Description |
|---|---|---|
PushInt | i32 | Push an integer constant |
PushFloat | f32 | Push a float constant |
PushBool | u8 | Push a boolean (0 = false, 1 = true) |
PushString | u16 | Push a string by line table index |
PushList | u16 | Push a list literal by index |
PushDivertTarget | DefinitionId | Push a divert target address |
PushNull | — | Push null |
Pop | — | Discard the top value |
Duplicate | — | Duplicate the top value |
Arithmetic
| Opcode | Description |
|---|---|
Add | Pop two values, push their sum (also concatenates strings) |
Subtract | Pop two values, push their difference |
Multiply | Pop two values, push their product |
Divide | Pop two values, push their quotient |
Modulo | Pop two values, push the remainder |
Negate | Pop one value, push its negation |
Comparison
| Opcode | Description |
|---|---|
Equal | Pop two values, push whether they are equal |
NotEqual | Pop two values, push whether they differ |
Greater | Pop two values, push whether left > right |
GreaterOrEqual | Pop two values, push whether left >= right |
Less | Pop two values, push whether left < right |
LessOrEqual | Pop two values, push whether left <= right |
Logic
| Opcode | Description |
|---|---|
Not | Pop one value, push its logical negation |
And | Pop two values, push logical AND |
Or | Pop two values, push logical OR |
Variables
| Opcode | Operands | Description |
|---|---|---|
GetGlobal | DefinitionId | Push the value of a global variable |
SetGlobal | DefinitionId | Pop a value and assign it to a global variable |
DeclareTemp | u16 (slot) | Declare a temp variable in the current frame |
GetTemp | u16 (slot) | Push the value of a temp (auto-dereferences pointers) |
SetTemp | u16 (slot) | Pop a value and assign it to a temp slot |
GetTempRaw | u16 (slot) | Push a temp’s raw value without auto-dereference |
PushVarPointer | DefinitionId | Push a pointer to a global variable |
PushTempPointer | u16 (slot) | Push a pointer to a temp variable |
Control flow
| Opcode | Operands | Description |
|---|---|---|
Jump | i32 (offset) | Unconditional relative jump within the current container |
JumpIfFalse | i32 (offset) | Pop a value; jump if falsy |
Goto | DefinitionId | Absolute jump to a named address |
GotoIf | DefinitionId | Pop a value; goto the address if truthy |
GotoVariable | — | Pop a DivertTarget from the stack and goto it |
Container flow
| Opcode | Operands | Description |
|---|---|---|
EnterContainer | DefinitionId | Push a container onto the container stack (updates visit counts) |
ExitContainer | — | Pop the current container from the container stack |
Functions and tunnels
| Opcode | Operands | Description |
|---|---|---|
Call | DefinitionId | Call a function — pushes a new call frame with fresh temp storage |
Return | — | Return from a function call |
TunnelCall | DefinitionId | Tunnel into a knot — pushes a return address, shares the output stream |
TunnelReturn | — | Return from a tunnel |
TunnelCallVariable | — | Pop a DivertTarget and tunnel to it |
CallVariable | — | Pop a DivertTarget and call it as a function |
Threads
| Opcode | Operands | Description |
|---|---|---|
ThreadCall | DefinitionId | Fork execution to explore a choice branch |
ThreadStart | — | Mark the beginning of a forked thread’s code |
ThreadDone | — | Mark the end of a forked thread |
Thread forking clones the current VM state (call stack, variable state) to explore choice branches in isolation. Each choice’s thread is evaluated independently to determine its display text and conditions.
Output
| Opcode | Operands | Description |
|---|---|---|
EmitLine | u16 (index), u8 (slot count) | Emit a line from the scope’s line table; slot count interpolation slots are popped from the stack |
EmitValue | — | Pop a value and emit its string representation |
EmitNewline | — | Emit a newline character |
Spring | — | Word break — renders as a single space between content parts |
Glue | — | Suppress the previous newline (joins lines) |
BeginTag | — | Begin capturing tag content |
EndTag | — | End tag capture and attach to current output |
EvalLine | u16 (index), u8 (slot count) | Evaluate an interpolated line template with slot count popped slots |
BeginFragment | — | Begin capturing output into a fragment |
EndFragment | — | End fragment capture; store the parts and push a FragmentRef |
Choices
| Opcode | Operands | Description |
|---|---|---|
BeginChoice | flags: u8, DefinitionId | Begin a choice with flags and a target address |
EndChoice | — | Finalize the current choice |
BeginChoice flags (packed into a single byte):
- Bit 0:
has_condition— choice has a conditional guard - Bit 1:
has_start_content— choice has text before[ - Bit 2:
has_choice_only_content— choice has text inside[] - Bit 3:
once_only— choice can only be selected once - Bit 4:
is_invisible_default— fallback choice when no others are available
Sequences
| Opcode | Operands | Description |
|---|---|---|
Sequence | kind: u8, count: u8 | Begin a sequence (kind: 0=cycle, 1=stopping, 2=once-only, 3=shuffle) |
SequenceBranch | i32 (offset) | Jump offset for a sequence branch |
Intrinsics
| Opcode | Description |
|---|---|
VisitCount | Pop a DivertTarget, push its visit count |
CurrentVisitCount | Push the visit count of the current container |
TurnsSince | Pop a DivertTarget, push turns since last visit (-1 if never) |
TurnIndex | Push the current turn index |
ChoiceCount | Push the number of currently available choices |
Random | Pop max and min, push a random integer in [min, max] |
SeedRandom | Pop a seed value and set the RNG seed |
Casts and math
| Opcode | Description |
|---|---|
CastToInt | Pop a value, push it as an integer |
CastToFloat | Pop a value, push it as a float |
Floor | Pop a float, push its floor as an integer |
Ceiling | Pop a float, push its ceiling as an integer |
Pow | Pop exponent and base, push base^exponent |
Min | Pop two values, push the smaller |
Max | Pop two values, push the larger |
External functions
| Opcode | Operands | Description |
|---|---|---|
CallExternal | DefinitionId, u8 (arg count) | Call an externally-bound function |
List operations
| Opcode | Description |
|---|---|
ListContains | Pop item and list, push whether the list contains the item |
ListNotContains | Pop item and list, push whether the list does not contain the item |
ListIntersect | Pop two lists, push their intersection |
ListAll | Pop a list, push all possible items from its origin lists |
ListInvert | Pop a list, push the complement (all origin items not in the list) |
ListCount | Pop a list, push its item count |
ListMin | Pop a list, push its minimum item |
ListMax | Pop a list, push its maximum item |
ListValue | Pop a list, push its integer value (ordinal of single item) |
ListRange | Pop max, min, and list; push items within the ordinal range |
ListFromInt | Pop an integer and list origin, push the item with that ordinal |
ListRandom | Pop a list, push a random item from it |
String evaluation
| Opcode | Description |
|---|---|
BeginStringEval | Begin capturing output as a string value (for string interpolation) |
EndStringEval | End string capture and push the result onto the stack |
Lifecycle
| Opcode | Description |
|---|---|
Done | Yield — the story pauses and can be resumed (marks a safe exit) |
Yield | Pause for choice presentation — like Done but does not mark a safe exit |
End | Permanent end — the story is finished |
Nop | No operation |
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
| Extension | Format | Description |
|---|---|---|
.inkb | Binary | Compiled bytecode with definition tables, line tables, and metadata |
.inkt | Textual | Human-readable disassembly (like WAT for WASM) |
.inkl | Locale overlay | Per-scope replacement line tables for a specific locale |
.inkb format
Header (16 bytes)
| Offset | Size | Field |
|---|---|---|
| 0 | 4 | Magic: INKB |
| 4 | 2 | Version: u16 LE (currently 2) |
| 6 | 1 | Section count: u8 (10) |
| 7 | 1 | Reserved: 0x00 |
| 8 | 4 | File size: u32 LE |
| 12 | 4 | Content checksum: u32 LE (CRC-32) |
Offset table
Immediately after the 16-byte preamble. Each entry is 8 bytes:
Offset Size Field
------ ----- ------
0 1 SectionKind: u8 tag
1 3 Reserved: 0x00 0x00 0x00
4 4 Offset: u32 LE (byte offset from start of file to section data)
With 10 sections, the offset table occupies 80 bytes (10 x 8). The total header size is 96 bytes (16 + 80). Each section’s size is computed from the difference between its offset and the next section’s offset (or the file size for the last section).
Sections
| Tag | SectionKind | Contents |
|---|---|---|
0x01 | NameTable | Interned name strings. Each entry is a length-prefixed UTF-8 string (u16 LE byte count + bytes). Referenced by NameId(u16) indices throughout other sections. |
0x02 | Variables | Global variable definitions. Each entry: DefinitionId + NameId + ValueType tag + encoded default value + mutability flag. |
0x03 | ListDefs | List (enum) type definitions. Each entry: DefinitionId + NameId + item count + (NameId, i32 ordinal) pairs. |
0x04 | ListItems | Individual list item definitions. Each entry: DefinitionId + origin DefinitionId + i32 ordinal + NameId. |
0x05 | Externals | External function declarations. Each entry: DefinitionId + NameId + u8 arg count + optional fallback DefinitionId. |
0x06 | Containers | Bytecode containers. Each entry: DefinitionId + scope DefinitionId + optional NameId + CountingFlags byte + i32 path hash + u8 declared-parameter count + u32 bytecode length + raw bytecode bytes. |
0x07 | LineTables | Per-scope line tables for output text (one per knot/stitch/root). Each scope’s table: DefinitionId (scope) + line count + encoded line entries (plain strings or interpolation templates). |
0x08 | Labels | Address definitions (divert targets). Each entry: DefinitionId (address) + DefinitionId (container) + u32 byte offset. |
0x09 | ListLiterals | Pre-computed list literal values used by PushList instructions. Each entry: item count + DefinitionId items + origin count + DefinitionId origins. |
0x0A | AddressPaths | Maps qualified author paths (knot, knot.stitch, knot.stitch.label) to DefinitionIds, so Program::find_address can resolve a name to a starting position. |
Encoding conventions
- All multi-byte integers are little-endian.
DefinitionIdvalues are encoded as rawu64LE (8 bytes).- Strings in the name table are length-prefixed:
u16LE byte count followed by UTF-8 bytes. - Sections are self-contained — the runtime can deserialize them independently. The
read_inkbfunction parses all sections into a completeStoryDatafor linking.
Versioning & compatibility
The header carries a u16 version, and the reader rejects any version it doesn’t recognize rather than guessing — every change to the byte layout bumps it.
.inkb and .inkl are build artifacts: regenerated from .ink on every compile, not meant to be hand-edited or shipped independently of the compiler that produced them. So the toolchain keeps a single current version and recompiles on mismatch — there are no multi-version readers. If you bundle compiled bytes with a game, treat them as version-locked to the brink release you built with, and recompile when you upgrade.
This is separate from save files, which are designed to survive toolchain upgrades: loading a save reports what it couldn’t apply (e.g. a variable a newer story removed) rather than failing outright — see the Runtime API. Program metadata like container layout never affects save compatibility, since saves reference variables and visit counts by definition id, not by byte offset.
.inkt format
The textual format is a human-readable disassembly of .inkb. Container paths appear as labels, opcodes as mnemonics with operands. Useful for debugging compiler output and diffing two compilations side-by-side.
=== container $01_abcdef1234567 (my_knot) ===
0000: PushInt 42
0004: SetGlobal $02_1234567abcdef
000c: EmitLine 0
000e: Done
.inkl format
Locale overlays replace per-scope line tables without touching bytecode. A
decoded .inkl is a LocaleData:
- BCP 47
locale_tagand the base.inkbchecksum (base_checksum), so a mismatched overlay is rejected before it can render garbage. line_tables: per-scope replacement tables (LocaleScopeTable) keyed by scopeDefinitionId.- Only scopes present in the
.inklare replaced; the rest fall back to base text underLocaleMode::Overlay(or error underLocaleMode::Strict).
The runtime applies an overlay with brink_runtime::apply_locale. Build .inkl
files with brink compile-locale (see the Localization section).
Containers & DefinitionId
DefinitionId
All named things in brink use a single DefinitionId(u64) type. The high 8 bits are a type tag; the low 56 bits are a hash of the fully qualified ink path.
DefinitionId (u64):
+-----------+------------------------------------------------------+
| tag (8) | hash (56) |
+-----------+------------------------------------------------------+
Serialized as $tt_hhhhhhhhhhhhhh (tag hex + underscore + 56-bit hash hex).
Definition tags
| Tag | Kind | Description |
|---|---|---|
0x01 | Address | Knot, stitch, gather, or intra-container label |
0x02 | Global variable | Name, type, default value |
0x03 | List definition | Enum-like type with named items |
0x04 | List item | Individual member of a list definition |
0x05 | External function | Host-provided function binding |
0x07 | Local variable | Temp/param (not serialized, compile-time only) |
The uniform ID scheme provides stability across recompilation (same ink path always produces the same ID), a simple linker (all references are ID lookups), and save file portability (IDs don’t depend on compilation order).
Containers
Containers are the fundamental unit of bytecode execution. At the source level, ink has knots, stitches, gathers, and labeled choice targets. At the bytecode level, these are all containers: a DefinitionId, a block of bytecode, and metadata.
struct ContainerDef {
id: DefinitionId,
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_hashis a free function inbrink-format(content_hash(&str) -> u64), used to deriveDefinitionIds from ink paths. It is not a field onContainerDef.
Container hierarchy
Containers form a logical hierarchy that mirrors the ink source structure:
- The root container holds the top-level flow (content before the first knot).
- Knots are top-level containers.
- Stitches may be sub-containers within a knot, or addresses within the knot’s bytecode.
- Gathers and labeled choice targets may become addresses within their parent container.
The compiler decides which source constructs become their own container vs. being inlined as addresses within a parent container. This is determined during the LIR planning phase.
Addresses
An AddressDef names a location within a container:
struct AddressDef {
id: DefinitionId, // The address's own ID
container_id: DefinitionId, // Which container it lives in
byte_offset: u32, // Position within the container's bytecode
}
The primary address of a container has byte_offset == 0 and id == container_id — it is the container’s entry point. Non-primary addresses (stitches within a knot, gathers, labels) have distinct IDs and non-zero offsets.
Counting flags
CountingFlags is a bitfield (u8) that controls visit and turn tracking for a container:
bitflags! {
pub struct CountingFlags: u8 {
const VISITS = 0x01;
const TURNS = 0x02;
const COUNT_START_ONLY = 0x04;
}
}
- VISITS (
0x01) — the VM increments a counter each time the container is entered. Used byVISITS()and conditional logic that depends on how many times content has been seen. - TURNS (
0x02) — the VM records the turn number when the container is entered. Used byTURNS_SINCE(). - COUNT_START_ONLY (
0x04) — only count the visit/turn when the container is entered at its first instruction (byte offset 0), not when re-entered mid-way via a divert.
These flags are set by the compiler based on whether the ink source uses VISITS(), TURNS_SINCE(), or similar intrinsics that reference the container.
What is NOT a definition
Several important types are scoped more narrowly and do not get DefinitionIds in the binary format:
- Temp variables — identified by slot index (
u16) within a call frame. TheLocalVartag (0x07) exists for compiler-internal use but temps are not serialized as definitions in the bytecode. NameId— au16index into the story’s name table. Stores human-readable names for variables, list items, and externals. Names are for display and host binding only; the runtime identifies definitions byDefinitionId, not by name.LineId— a(container: DefinitionId, index: u16)pair that references a specific line entry within a container’s line table. Lines hold output text and are emitted byEmitLine(index), not addressed as definitions.
Line Templates
Lines in brink can be plain strings or templates with interpolation slots and plural/gender selects.
LineContent
enum LineContent {
Plain(String),
Template(LineTemplate),
}
struct LineTemplate {
parts: Vec<LinePart>,
}
Template parts
enum LinePart {
Literal(String),
Slot(u8),
Select {
slot: u8,
variants: Vec<(SelectKey, String)>,
default: String,
},
}
- Literal — static text fragments between dynamic parts.
- Slot — runtime value interpolation. The
u8is an index into the evaluation stack snapshot captured when the line is emitted. For example,"You have {0} gold"becomes[Literal("You have "), Slot(0), Literal(" gold")]. - Select — plural/keyword branching. Selects a variant string based on the runtime value at the given slot, using a
SelectKeyto match. Falls back todefaultif no variant matches.
Select keys
enum SelectKey {
Cardinal(PluralCategory),
Ordinal(PluralCategory),
Exact(i32),
Keyword(String),
}
- Cardinal — CLDR cardinal plural categories (zero, one, two, few, many, other). Used for “1 apple” vs “2 apples”.
- Ordinal — CLDR ordinal categories. Used for “1st”, “2nd”, “3rd”.
- Exact — matches a specific integer value. Useful for special-casing “0 items” or “exactly 1”.
- Keyword — matches a named string key. Used for gender or custom grammatical categories.
Line tables
Line tables are stored per-scope (one per knot/stitch/root) in the .inkb format. Each scope has a sequence of LineEntry values referenced by index from EmitLine opcodes. The EvalLine opcode handles templates with interpolation, evaluating slots from the current stack state.
Choice text decomposition
Ink choices have up to three text parts: start content (before [), choice-only content (inside []), and output-only content (after ]). The compiler decomposes each choice into two independent lines:
- Display line = start + choice-only (what the player sees in the choice list)
- Output line = start + output-only (what appears in the narrative after selection)
This decomposition allows translators to localize each line independently — the target language can use completely different grammatical constructions for the prompt and the narrative output.
Error Handling
All runtime operations that can fail return Result<T, RuntimeError>.
RuntimeError variants
Host errors
These indicate a bug in your code — the host called the API incorrectly.
| Variant | When |
|---|---|
InvalidChoiceIndex | choose() called with an index outside the valid range |
NotWaitingForChoice | choose() called when story isn’t in WaitingForChoice status |
StoryEnded | Tried to continue a story that has permanently ended |
UnknownFlow | Referenced a named flow that doesn’t exist |
FlowAlreadyExists | Tried to spawn a flow with a name that’s already active |
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.
| Variant | When |
|---|---|
FunctionNotFound | call_function named something that isn’t a function or knot |
ArgCountMismatch | Wrong number of arguments for the target’s declared parameters |
FunctionYielded | A host-called function tried to present choices or end the story |
AlreadyEvaluatingFunction | A function evaluation is already in progress on this flow |
NotEvaluatingFunction | resume_function_eval called with no evaluation in progress |
AsyncExternalInCall | A function called from the synchronous call_function path hit a deferred external |
UnknownPath | choose_path_string given a path matching no knot, stitch, or label |
JumpWhileAwaitingExternal | Tried 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.
| Variant | When |
|---|---|
StepLimitExceeded | Opcode budget exhausted — likely an infinite loop in the story |
LineLimitExceeded | A single turn produced more lines than continue_maximally allows |
Story errors
These indicate a problem in the ink source or an unsupported feature.
| Variant | When |
|---|---|
TypeError | Type mismatch in an ink expression (e.g., adding a string to a list) |
DivisionByZero | Division or modulo by zero in an ink expression |
UnresolvedExternalCall | Story calls an external function with no handler provided |
RanOutOfContent | Execution fell off the end of a knot — usually a missing -> DONE or -> END |
Unimplemented | The 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.
| Variant | When |
|---|---|
LocaleChecksumMismatch | The overlay was compiled against different bytecode — recompile it |
LocaleScopeNotInBase | The overlay carries a scope the base program doesn’t have |
LocaleScopeMissing | LocaleMode::Strict and the overlay omits a scope the base requires |
Internal errors
These typically indicate a compiler bug — the bytecode is malformed.
| Variant | When |
|---|---|
Decode | Corrupt or incompatible .inkb file |
UnresolvedDefinition | Linker can’t find a referenced definition |
NoRootContainer | Story has no entry point |
StackUnderflow | Value stack empty when an operand was expected |
CallStackUnderflow | No call frame to return to |
ContainerStackUnderflow | No container to pop from the container stack |
UnresolvedGlobal | Global variable lookup failed |
CaptureUnderflow | Output capture stack mismatch |
Recovery
Host errors are recoverable — fix the calling code and retry. 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:
| Asset | Holds | Notes |
|---|---|---|
BrinkStoryAsset | handles to the two below | what you load() and hand to a request |
ProgramAsset | the immutable bytecode Program + initial_context | what the VM executes; initial_context is the fresh “new game” state |
LineTablesAsset | the localizable line tables | the swappable rendering data (base language, or a locale overlay) |
The split exists so line tables can be swapped (locale changes, hot-reload)
without touching the immutable program. You rarely touch the sub-assets
directly — spawn a request with a Handle<BrinkStoryAsset> and let the
fulfillment system wire everything up.
Loaders and file types
| Extension | Loader | Produces | Feature |
|---|---|---|---|
.inkb | InkbLoader | BrinkStoryAsset (+ labeled #program, #line_tables) | always |
.ink | InkLoader | BrinkStoryAsset, compiled at load time, hot-reloads on source change | dev |
.inkl | InklLoader | LocaleAsset (a locale overlay) | always |
.brkt | BrktLoader | TranscriptAsset (a saved playthrough) | always |
The dev cargo feature (on by default) adds the .ink source loader, which
compiles ink at load time and hot-reloads the program when any file in the
INCLUDE graph changes. Ship a release build without it to drop the compiler
and load only pre-compiled .inkb/.inkl assets:
bevy-brink = { version = "*", default-features = false }
Where to go next
- Spawning & Driving Flows — the request-component pattern, the flow components/resources, the step loop, observer events, transcripts.
- External Functions — bind ink
EXTERNALs to engine code (pure / command / world-query / async / task) and call ink from the engine. - Localization & Saves — runtime locale switching and
.brkttranscript persistence.
Spawning & Driving Flows
A flow is one live conversation — a FlowInstance and its 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
| Variant | Meaning |
|---|---|
Root (default) | The file’s root container. Fine for demos/tests; does not auto-enter a named knot. |
Address(String) | Start at a knot/stitch by name. If the name is unknown, the request is dropped at fulfillment. |
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:
| Type | Kind | Holds |
|---|---|---|
BrinkFlow<M> | Component | the FlowInstance (.inner) — call stacks, output buffer, pending choices, transcript |
BrinkContext<M> | Component | this flow’s private FlowLocal (.inner) — overrides for whatever units the policy homes to Local |
BrinkProgram<M> | Component | Handle<ProgramAsset> the flow runs against |
BrinkLocale<M> | Component | Handle<LineTablesAsset> the flow renders with |
BrinkGlobals<M> | Resource | the 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:
Advance | Meaning |
|---|---|
Step(Step) | a step was produced and its observer event fired |
AwaitingQuery | the flow paused on a world-access binding; the plugin resolver handles it — skip this flow and resume next frame |
fn drive(
mut flows: Query<(
Entity,
&mut BrinkFlow<()>,
&mut BrinkContext<()>,
&BrinkProgram<()>,
&BrinkLocale<()>,
)>,
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_oneproduces one line — for typewriter UIs that animate fragments.advance_until_terminalruns until a terminal line (Done/Choices/End), firing events for every line along the way — for click-to-continue dialogue. It’s bounded by a 10,000-line safety cap 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):
| Event | Fires for | Carries |
|---|---|---|
BrinkLineDelivered<M> | Step::Line (mid-stream) | text, tags |
BrinkChoicesPresented<M> | Step::Choices | text, 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 flow | entity |
app.add_observer(|on: On<BrinkChoicesPresented<()>>| {
for (i, choice) in on.event().choices.iter().enumerate() {
println!(" [{}] {}", i + 1, choice.text);
}
});
Terminal lines bundle their accumulated text in their own text field — a
Choices/Done/End event already contains the passage text leading up to
it, so a click-to-continue UI can render from terminal events alone.
Transcripts
For a “show the whole conversation so far” view rather than per-event reaction,
add a BrinkTranscript<M> component (opt-in) to a flow entity. The plugin
re-renders it whenever the flow grows, the locale changes, or line tables
hot-reload:
commands
.entity(flow)
.insert(BrinkTranscript::<()>::default());
// later, 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
| Verb | For | Resolution |
|---|---|---|
bind_brink_fn | pure compute, no World | inline while the VM steps |
bind_brink_command | fire-and-forget event (with optional return) | buffered during the step, flushed after |
bind_brink_query | read live World state | flow pauses; a resolver runs the binding system, then resumes |
bind_brink_async | multi-frame World interaction (UI, input) | flow parks; BrinkExternalAwaited fires; you resolve when ready |
bind_brink_task | off-thread compute / IO | flow parks; the future runs on the task pool; resolved on completion |
bind_brink_fn — pure functions
A side-effect-free function of the ink args, resolved inline (no World, no
latency). The return type is anything Into<Value>:
#![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.
| Item | Role |
|---|---|
BrinkCurrentLocale<M> | resource holding the active locale (None = base/source language) |
commands.set_brink_locale::<M>(handle) | set the locale and fire BrinkLocaleChanged<M> |
BrinkLocaleChanged<M> | event; an observer reconciles every flow’s BrinkLocale |
BrinkLocaleOverride<M> | marker that opts a flow out of global switching |
A .inkl overlay loads as a LocaleAsset. Switch with the command:
#![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.
| Item | Role |
|---|---|
BrinkGlobals::save_state / load_state | the shared World, direct — no routing view needed |
save_flow_state / load_flow_state | one flow, routed through its ContextView — Local-scoped entries land in that flow’s own BrinkContext; World-scoped entries land in the shared World |
LoadReport | what 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 false —
is_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:
| Bucket | Meaning |
|---|---|
rebound | resolved to a live resource, same token id |
dead_by_resolve | a registered kind, a persisted recipe, but resolve returned None — normal |
dead_ephemeral | a registered kind with no persisted entry — the kind chose ephemerality for this token |
dead_by_unregistered_kind | the kind isn’t registered at all — suspicious (integration drift) |
Never-fail-load holds — load_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
| Method | Description |
|---|---|
new StoryRunner(bytes: Uint8Array) | decode + link compiled bytes into a runnable instance |
continue_story() | run to the next choice/end; returns all Lines produced |
continue_single() | produce one Line (typewriter reveal) |
choose(index) | select a choice by 0-based index |
reset() | return to the start without recompiling |
bind_external(name, fn) | bind an ink EXTERNAL to a synchronous JS callback |
unbind_external(name) | remove a previously bound external |
set_lenient_unbound(bool) | unbound externals resolve to null instead of using the ink fallback / erroring |
get_var(name) / set_var(name, value) | read/write a global ink variable by name |
set_seed(n) | set the RNG seed for reproducible RANDOM/shuffle (re-applied across reset) |
save() / save_bytes() | capture durable game state — JSON string (dev) or MessagePack bytes (release) |
load(json) / load_bytes(bytes) | reconcile a save back in; returns a LoadReport of anything dropped |
call_function(name, ...args) | evaluate an ink function from the host (engine→ink); returns its value |
Line mirrors the native runtime: { type: "text"|"choices"|"done"|"end", text, tags, choices? }. This is the same execution model as
the toolchain runtime, surfaced to JS.
External functions
When a story calls EXTERNAL roll(sides), the runner asks any binding
registered under that name to resolve it. Arguments arrive as native JS values
(number / boolean / string / null) and the return is read back the same way —
an integer-valued number becomes an ink int, otherwise a float:
const runner = new StoryRunnerHandle(bytes);
runner.bindExternal("roll", (sides) => 1 + Math.floor(Math.random() * Number(sides)));
runner.bindExternal("play_sound", (id) => { audio.play(String(id)); }); // fire-and-forget
An external with no binding falls through to its ink fallback body (erroring if
none exists), unless setLenientUnbound(true) is set — then it resolves to
null, so content can call host verbs a given build doesn’t know without
dead-ending. A binding that throws resolves to null (the exception is not
propagated into the VM).
Async bindings. A binding may return a Promise — the story suspends
until it resolves (inline timing like ~ camera("bow") ~ wait(2.0) ~ wreck(),
a targeting UI awaiting a click, a fetch). Drive such a story with the async
continue methods, which await and resume transparently:
runner.bindExternal("wait", (secs) => new Promise((r) => setTimeout(r, Number(secs) * 1000)));
const lines = await runner.continueStoryAsync(); // suspends across the wait
A rejected Promise unsticks the flow (resolves null) and rethrows. The
synchronous continueStory/continueSingle error on a suspending binding — use
continueStoryAsync/continueSingleAsync when bindings may be async.
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.
| Group | Methods |
|---|---|
| Files | update_file(path, src), set_active_file(path), remove_file, list_files, … |
| Compile & structure | compile_project(entry), project_outline, document_symbols |
| Highlighting | semantic_tokens, token_type_names, token_modifier_names |
| Navigation | goto_definition, find_references, hover, completions, signature_help |
| Refactors | prepare_rename, rename, code_actions, convert_element |
| Editing aids | folding_ranges, inlay_hints, line_contexts, format_document |
| Structural edits | reorder_stitch, move_stitch, promote_stitch, demote_knot, reorder_knot |
View context. set_view_context(start, end) scopes every query to a byte
range — line numbers and offsets become relative to that fragment. This is how a
client edits one knot/stitch in isolation. clear_view_context() returns to
full-file mode.
Conventions
- JSON envelopes. Every query returns a JSON string (
serde_json); the JS side parses it. The@brink-lang/webwrapper does this for you and returns typed objects (@brink/wasm-types). - UTF-8 byte offsets. Positions are UTF-8 byte offsets into the source, not UTF-16 char indices or line/column. When a view context is active they are relative to the view start; the session translates transparently.
- Lifecycle. Construct → feed source / step → dispose. wasm-bindgen objects
free automatically, or call
.free()explicitly via the wrapper handles.
A minimal client
import init, { EditorSession, StoryRunner } from "brink-web";
await init();
const session = new EditorSession();
session.update_file("main.ink", source);
const result = JSON.parse(session.compile_project("main.ink"));
if (result.ok) {
const runner = new StoryRunner(new Uint8Array(result.story_bytes));
let lines = JSON.parse(runner.continue_story());
// render lines; on a choices line, call runner.choose(i) and continue
}
The Studio is the full-featured reference build of exactly this loop — see Studio.
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 CodeMirrorExtension— the editor. You add it to anEditorViewand pass callbacks inoptions.ProjectSessionis the reusable session/file/dirty/conflict layer. It owns the wasmEditorSessionHandle, 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
EditorSessionquery wired to the editor. - Live player — step through the compiled story with choice selection;
playback state persists to
localStorageand 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
DebugStatesnapshot: 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.
| Package | Responsibility |
|---|---|
@brink-lang/studio | app shell + entry point (Vite) |
@brink/studio-ui | React components: layout, activity bar, binder, state view, player, tabs, status bar |
@brink/studio-store | Zustand store — editor / compile / documents / binder / session / search / conflict / output / symbol-menu slices |
@brink-lang/editor | the CodeMirror 6 editor, IDE extensions, and the dialogue dialect |
@brink/ink-operations | pure line-editing functions (no CM6, React, or wasm) |
@brink-lang/web | ergonomic wrappers over the brink-web FFI |
@brink/wasm-types | shared TypeScript interfaces (zero runtime) — decouples everything from the FFI |
The dependency flow is one-directional: @brink/wasm-types is depended on by
all; @brink-lang/web wraps the raw brink-web module; @brink-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
| Command | Purpose |
|---|---|
pnpm dev | dev server (port 5180) |
pnpm build | production build |
pnpm typecheck | TypeScript, no emit |
pnpm test | Vitest unit tests (jsdom) |
pnpm test:e2e | Playwright end-to-end suite |
Tech stack
React 19 · TypeScript 5.7 · Vite 6 · Zustand 5 · CodeMirror 6 ·
react-resizable-panels for layout · Vitest + Playwright for tests. The editor
talks to the toolchain entirely through @brink-lang/web, so it stays a pure
front-end with the compiler and runtime living in the WASM module.
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 —
Nis 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.tomlhas 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 LSP —
brink-lspoffers each fix as a standard quickfix code action tied to its diagnostic, plus thesource.fixAll.brinkaction 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:
| Value | Meaning |
|---|---|
"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.)
| Code | What it fixes |
|---|---|
E014 | Removes an effect-free ~ logic line — one that does nothing at all. |
E031 | Trims a function call’s extra arguments down to the number its declaration actually accepts. |
E092 | Removes a redundant #@public/#@private directive that only restates the module’s own default. |
E095 | Removes a stale #@was tag that already names the definition’s current name — there’s nothing left to migrate. |
E110 | Rewrites the deprecated #@effects(…) directive spelling to the current @[effects(…)] annotation. |
E176 | Trims 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
| Crate | Path | Purpose |
|---|---|---|
brink-compiler | crates/brink-compiler/ | Pipeline driver: .ink to StoryData |
brink-runtime | crates/brink-runtime/ | Bytecode VM for executing compiled stories |
brink-cli | crates/brink-cli/ | CLI tool: compile, convert, play, replay, ide, export-xliff, compile-locale, regenerate-xliff, fmt |
brink-lsp | crates/brink-lsp/ | Language server for ink files |
brink-web | crates/brink-web/ | WASM bindings for the IDE + runtime; powers the web playground |
bevy-brink | crates/bevy-brink/ | Bevy 0.19 integration: plugin, assets, components, external-function bindings |
Internal crates
| Crate | Path | Purpose |
|---|---|---|
brink-syntax | crates/internal/brink-syntax/ | Lexer, parser, lossless CST, typed AST |
brink-ir | crates/internal/brink-ir/ | HIR + LIR intermediate representations, lowering |
brink-analyzer | crates/internal/brink-analyzer/ | Cross-file semantic analysis, symbol resolution |
brink-driver | crates/internal/brink-driver/ | Pipeline orchestration: file discovery + cross-file analysis |
brink-codegen-inkb | crates/internal/brink-codegen-inkb/ | Bytecode codegen: LIR to StoryData |
brink-format | crates/internal/brink-format/ | Binary interface between compiler and runtime |
brink-db | crates/internal/brink-db/ | Incremental project database, file discovery |
brink-source-tree | crates/internal/brink-source-tree/ | SourceTree trait: host-agnostic seam for enumerating/reading .brink source files |
brink-fmt | crates/internal/brink-fmt/ | .ink source formatter (powers brink fmt) |
brink-intl | crates/internal/brink-intl/ | Internationalization tooling: line export, XLIFF round-trip, .inkl compile, ICU plurals |
xliff2 | crates/internal/xliff2/ | General-purpose XLIFF 2.0 read/write library |
brink-ide | crates/internal/brink-ide/ | Protocol-agnostic IDE query library (shared by the LSP/web) |
bevy-brink-derive | crates/internal/bevy-brink-derive/ | Derive macros for bevy-brink (#[derive(BrinkCommand)]) |
brink-test-harness | crates/internal/brink-test-harness/ | Episode-based behavioral testing (oracle corpus) |
Internal crates have publish = false and are not published to crates.io.
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
brink-runtimedepends ONLY onbrink-format— keeps the runtime minimal and embeddablebrink-lspdepends onbrink-analyzer, NOT onbrink-compiler— the LSP needs parse through validation, not codegenbrink-formathas no brink-internal dependencies — it is the stable interface layerbrink-formatis the firewall — source-level concepts never leak into the runtime
These rules enable hot-reload (runtime loads new bytecode without the compiler), compile-time isolation (changing compiler internals doesn’t rebuild the runtime), and small runtime binaries for embedding.
Workspace conventions
- Dependencies are declared in
[workspace.dependencies]in the rootCargo.tomland referenced viadep.workspace = truein each crate - Lints are configured in
[workspace.lints]and inherited via[lints] workspace = true - Edition, license, repository are set in
[workspace.package]and inherited withfield.workspace = true
Development Workflow
Building
cargo check --workspace # type-check
cargo build --workspace # full build
Testing
cargo test --workspace # run all tests
Episode corpus
The episode corpus is the primary correctness tool — it runs brink against golden episodes generated by the C# ink runtime and asserts the behavior matches. The commands, the ratchet, and the per-case diagnostics are documented in Test Corpus.
Linting
cargo clippy --workspace --all-targets -- -D warnings # lint
cargo fmt --all -- --check # format check
cargo fmt --all # format fix
Lint policy
unsafe_code,unwrap_used,expect_used,panic,todo,print_stdout,print_stderrare denied in library crates- Clippy pedantic is enabled (with targeted allows for noise)
- Tests are exempt from unwrap/expect/dbg/print restrictions (via
clippy.toml)
Determinism
Never iterate HashMap keys/values where order affects output. Sort or use BTreeMap. This applies to all output-producing code paths — bytecode emission, line table construction, name table serialization, and test output.
Test Corpus
The repository includes a test corpus at tests/ organized into tiers.
Corpus structure
tests/
tier1/ # Basic ink features (text, choices, diverts, knots, variables)
tier2/ # Intermediate features (tunnels, threads, lists, logic)
tier3/ # Advanced features (complex weave, edge cases)
tests_github/ # Real-world .ink files from open-source projects
tests_patched/ # Modified tests for edge cases
Test case format
Each test case is a directory containing:
| File | Description |
|---|---|
story.ink | The ink source file (ground truth) |
story.ink.json | Inklecate-compiled JSON output (kept only for oracle regeneration) |
episodes/*.episode.json | Recorded play-throughs with expected output |
An episode records a sequence of continues and choice selections with the expected text output at each step. The test harness 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:
| File | Description |
|---|---|
story.ink | The shrunk story |
oracle/*.oracle.json | Golden 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_reportreports 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 clearRATCHET_EPISODE_COUNT. - A flagged case whose episodes all now match the oracle — the underlying bug got fixed — makes
oracle_snapshotsfail outright, naming the case and its issue: the flag must be removed frommetadata.tomlandRATCHET_EPISODE_COUNTraised in the same change that fixed it, not left to drift silently.corpus_reportmarks 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).