Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Web & WASM

brink-web compiles the toolchain — compiler, runtime, and IDE queries — to WebAssembly and exposes it to JavaScript. It is the foundation every browser client builds on: the Studio authoring app, the embedded Playground, and any React/web front-end you write yourself all sit on this surface.

Building the package

brink-web is built with wasm-pack:

wasm-pack build crates/brink-web --target web --out-dir www/pkg

This emits an npm package at crates/brink-web/www/pkg/ — the glue JS (brink_web.js), TypeScript types (brink_web.d.ts), and the .wasm binary. The package must be built before the JS workspace installs, because the ergonomic wrapper @brink-lang/web depends on it via a file: path.

// raw module
import init, { EditorSession, StoryRunner, compile } from "brink-web";
await init();                       // one-time async load of the wasm

// or the ergonomic wrapper (parses the JSON envelopes for you)
import { EditorSessionHandle, StoryRunnerHandle, compile } from "@brink-lang/web";

What it exposes

Three things cross the boundary: a stateless compile function, a runtime runner, and a stateful editor session.

compile(source) → CompileResult

One-shot, single-file compilation. Returns { ok, story_bytes?, warnings, error? }story_bytes is the compiled .inkb as a byte array, ready to hand to a StoryRunner.

StoryRunner — running a story

MethodDescription
new StoryRunner(bytes: Uint8Array)decode + link compiled bytes into a runnable instance
continue_story()run to the next choice/end; returns all Lines produced
continue_single()produce one Line (typewriter reveal)
choose(index)select a choice by 0-based index
reset()return to the start without recompiling
bind_external(name, fn)bind an ink EXTERNAL to a synchronous JS callback
unbind_external(name)remove a previously bound external
set_lenient_unbound(bool)unbound externals resolve to null instead of using the ink fallback / erroring
get_var(name) / set_var(name, value)read/write a global ink variable by name
set_seed(n)set the RNG seed for reproducible RANDOM/shuffle (re-applied across reset)
save() / save_bytes()capture durable game state — JSON string (dev) or MessagePack bytes (release)
load(json) / load_bytes(bytes)reconcile a save back in; returns a LoadReport of anything dropped
call_function(name, ...args)evaluate an ink function from the host (engine→ink); returns its value

Line mirrors the native runtime: { type: "text"|"choices"|"done"|"end", text, tags, choices? }. This is the same execution model as the toolchain runtime, surfaced to JS.

External functions

When a story calls EXTERNAL roll(sides), the runner asks any binding registered under that name to resolve it. Arguments arrive as native JS values (number / boolean / string / null) and the return is read back the same way — an integer-valued number becomes an ink int, otherwise a float:

const runner = new StoryRunnerHandle(bytes);
runner.bindExternal("roll", (sides) => 1 + Math.floor(Math.random() * Number(sides)));
runner.bindExternal("play_sound", (id) => { audio.play(String(id)); }); // fire-and-forget

An external with no binding falls through to its ink fallback body (erroring if none exists), unless setLenientUnbound(true) is set — then it resolves to null, so content can call host verbs a given build doesn’t know without dead-ending. A binding that throws resolves to null (the exception is not propagated into the VM).

Async bindings. A binding may return a Promise — the story suspends until it resolves (inline timing like ~ camera("bow") ~ wait(2.0) ~ wreck(), a targeting UI awaiting a click, a fetch). Drive such a story with the async continue methods, which await and resume transparently:

runner.bindExternal("wait", (secs) => new Promise((r) => setTimeout(r, Number(secs) * 1000)));
const lines = await runner.continueStoryAsync(); // suspends across the wait

A rejected Promise unsticks the flow (resolves null) and rethrows. The synchronous continueStory/continueSingle error on a suspending binding — use continueStoryAsync/continueSingleAsync when bindings may be async.

Host-directed entry

runner.goToPath(path, ...args) moves the play head to a knot or stitch by name, optionally passing arguments to a parameterized one — the JS face of the runtime’s host-directed entry. programChecksum(bytes) returns a stable checksum of a compiled story, for detecting when a save was made against a different build.

Speculation

runner.speculate() forks a SpeculationHandle — a throwaway run over the story’s current state that never touches the live runner. It’s the JS binding for the runtime’s speculation primitive: drive it with the same verbs (advance, choose, goToPath, evalFunction), read what it produces, and drop it.

// "What lines would jumping to `cellar` produce, without going there?"
const spec = runner.speculate();
spec.goToPath("cellar");
const preview = spec.advance();   // a Line; the live runner is untouched

For the common “evaluate this fragment” case, runner.evaluate(source) composes speculate() with a compile step so you can run an arbitrary expression or snippet against current state and read back its value and output in one call.

Sessions

StorySessionHandle wraps a story with a journal — the JS binding for sessions & replay. It drives like the runner but records every input, so you can snapshot and diff state, persist a save, and restore or replay it.

const session = new StorySessionHandle(bytes, 42);   // optional seed

const before = session.snapshot();
session.continueSingle();
const delta = session.diff(before, session.snapshot());   // typed StateDiff

const journal = session.exportJournal();   // serde-serializable save artifact
// later: StorySessionHandle.restore(bytes, journal) → { session, outcome }

session.snapshot() returns the typed StateSnapshot (globals with real values, visit counts, call stack) for serialize-and-compare; session.debugSnapshot() returns a name-resolved DebugState for a live inspector UI — the same two-view distinction the sessions chapter draws. onJournalDirty registers a listener for debounced persistence.

EditorSession — IDE queries

A stateful, multi-file project session that powers an editor: diagnostics, semantic highlighting, navigation, and structural refactors. You feed it source and it answers queries against cached analysis.

GroupMethods
Filesupdate_file(path, src), set_active_file(path), remove_file, list_files, …
Compile & structurecompile_project(entry), project_outline, document_symbols
Highlightingsemantic_tokens, token_type_names, token_modifier_names
Navigationgoto_definition, find_references, hover, completions, signature_help
Refactorsprepare_rename, rename, code_actions, convert_element
Editing aidsfolding_ranges, inlay_hints, line_contexts, format_document
Structural editsreorder_stitch, move_stitch, promote_stitch, demote_knot, reorder_knot

View context. set_view_context(start, end) scopes every query to a byte range — line numbers and offsets become relative to that fragment. This is how a client edits one knot/stitch in isolation. clear_view_context() returns to full-file mode.

Conventions

  • JSON envelopes. Every query returns a JSON string (serde_json); the JS side parses it. The @brink-lang/web wrapper does this for you and returns typed objects (@brink/wasm-types).
  • UTF-8 byte offsets. Positions are UTF-8 byte offsets into the source, not UTF-16 char indices or line/column. When a view context is active they are relative to the view start; the session translates transparently.
  • Lifecycle. Construct → feed source / step → dispose. wasm-bindgen objects free automatically, or call .free() explicitly via the wrapper handles.

A minimal client

import init, { EditorSession, StoryRunner } from "brink-web";

await init();

const session = new EditorSession();
session.update_file("main.ink", source);
const result = JSON.parse(session.compile_project("main.ink"));

if (result.ok) {
  const runner = new StoryRunner(new Uint8Array(result.story_bytes));
  let lines = JSON.parse(runner.continue_story());
  // render lines; on a choices line, call runner.choose(i) and continue
}

The Studio is the full-featured reference build of exactly this loop — see Studio.