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.

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.