Skip to content
Moku Labs
@moku-labs/game pages

Doors

/inspect reads a running game, /control drives a dev build: the doors the editor, MCP tools and e2e scripts use.

On this page

Two doors

An outside tool reaches a running game through two subpaths. Neither is on the root.

SubpathExportsIn a production build
@moku-labs/game/inspectread, watch, defineSource, the catalogue sourcesSafe. Every source only reads.
@moku-labs/game/controlrun, defineCommand, controlRefused, controlReadOnly, the catalogue commandsDev and debug builds only.

A source or a command is data: an id, a title, an input schema and one function. sources and commands are frozen objects keyed by a short name, so a tool registers Object.values(sources) and Object.values(commands). The page hands both to the browser as globalThis.doors.

CallDoes
read(app, source, input?)Reads once.
watch(app, source, input, fn)Reads on the first frame, in the signals phase, and again when the source changes. Returns the stop function.
run(app, command, input?)Resolves { value, state }. state is { path, frame, tainted }, read after the command.

Drive the dice game

tests/doors.test.ts
import { commands, run } from "@moku-labs/game/control";
import { read, sources, watch } from "@moku-labs/game/inspect";
import { createHeadless } from "@moku-labs/game/testing";
import { expect, it, vi } from "vitest";
import dice from "../index";

it("rolls, bookmarks and restores through the doors", async () => {
  vi.stubGlobal("__MOKU_GAME_DEV__", true);
  const { app } = dice.headless({ seed: 42 });
  const game = await createHeadless(app);

  expect(read(app, sources.position)).toEqual({ path: "home", flow: "main", node: "home", waiting: ["roll", "reset"] });

  const seen: string[] = [];
  const stop = watch(app, sources.position, undefined, position => {
    seen.push(position.path);
  });
  app.time.step(16);
  expect(seen).toEqual(["home"]);

  const walked = await run(app, commands.walk, { route: [{ at: "home", intent: "roll" }] });
  expect(walked.state.tainted).toBe(false);
  expect(read(app, sources.model).session).toEqual({ rolls: 1 });

  const { value: mark } = await run(app, commands.bookmark);
  await run(app, commands.restore, { bookmark: mark });
  expect(read(app, sources.tainted)).toBe(true);

  stop();
  await game.stop();
  vi.unstubAllGlobals();
});
  1. Commands run only in a dev build. A test turns it on like this.
  2. watch runs on the frame loop. A headless test steps the frames itself.
  3. A walk goes through the graph: the session stays clean.
  4. A restore replaces the state outside the graph: it taints the session.

A source that needs the screen names the app it needs: read(app, sources.locate, { key: "play" }) on an app without ui, renderer and world is a compile error.

Sources

Their ids are game.<key>. Changes is when watch reads the source again: edge when the flow state is a new object, commit when the snapshot is, frame every frame.

KeyChangesReads
graphedgeflow.describe(): flows, nodes, outcomes, edges, slots.
positionedge{ path, flow, node, waiting }. waiting lists the intents the gate waits for.
historyedgeThe edges since the last checkpoint, or the last last.
tainted, cheatsframeWhether a cheat or raw command ran, and their journal.
modelcommitThe committed { player, session, rng }.
entities, projections, schema, explainframe, commitThe ECS: entities, projection keys, component kinds, one entity in full.
uiframeui.tree(): the live screen as plain data.
locateframeWhere a ui element (key) or a view (target) is, { x, y, w, h } in CSS px.
atframeWhat lies under a page point, topmost first.
render, effects, assetsframeRenderer, effects and asset numbers.
sounds, audioMutedframeThe audio journal and the master mute. Need audioPlugin.
filtersframeThe filter kinds and the ones switched off. Needs effectsPlugin.
logframelog.trace(), all entries or those at level and above.
diffframeWhat changed between two frames. Dev builds only, the last 120 frames.

Commands

Every command leaves a moku:dev debug entry in the log. Its effect says what it may change.

KeyEffectDoes
answerrouteflow.gate.answer({ intent, payload }).
tap, drag, tracerouteInput on a ui element by key or a view by { projection, key }.
key, fill, scrollrouteA key press, text into an input, a scroll to an offset.
walkrouteflow.walk(route).
bookmarkreadflow.bookmark(), plus the mounted scene.
restorerawA bookmark, or a /testing repro.
saveExport, saveImportread, rawThe committed save out, and back in at the main flow's start.
step, pause, resume, timeScale, frameRatecosmeticTime: frames, the devtools pause reason, the scale, the frame rate.
captureread{ png } of the canvas after the next drawn frame. { legend: true } numbers the keyed views.
debug, reducedMotion, mute, filtercosmeticDebug drawing, reduced motion, the master mute, a filter kind on or off.
EffectMeans
readTakes something out and changes nothing.
routeGoes through the graph or the input, the way a player does. The session stays clean.
cosmeticChanges how the game runs or looks. The session stays clean.
cheatChanges the state outside the rules. Taints the session and is journaled.
rawReplaces the state outside the graph. Taints the session and is journaled.

Turn the dev build on

The engine reads three globals and never sets them.

Global trueWho sets itCommands that run
__MOKU_GAME_DEV__moku-game dev; a test with vi.stubGlobalEvery command.
__MOKU_GAME_CONTROL__moku-game build --debugEvery command.
__MOKU_GAME_AGENT__ alonemoku-game build --debug=readread and cosmetic only; the others throw controlReadOnly.

With none of them, run throws [game] Control commands run in dev builds only. moku-game build defines all three false, so the minifier drops every command body. A game without the shell uses a bundler define the same way.

A game's own sources and commands

defineSource and defineCommand take the shape of the base catalogue. The id is camelCase words joined by dots, at least two: dice.rolls. Keep them in .dev modules: moku-game build imports none, and the editor's dev page imports every **/*.dev.ts.

dice.dev.ts
import { controlRefused, defineCommand } from "@moku-labs/game/control";
import { defineSource } from "@moku-labs/game/inspect";
import type dice from "./index";

export const rolls = defineSource({
  id: "dice.rolls",
  title: "Rolls",
  input: {},
  changes: "commit",
  read: (app: ReturnType<typeof dice.headless>["app"]) => app.model.store.snapshot().session
});

export const rollTwice = defineCommand({
  id: "dice.rollTwice",
  title: "Roll twice",
  input: {},
  effect: "route",
  run: app => {
    if (typeof __MOKU_GAME_DEV__ === "undefined" || !__MOKU_GAME_DEV__) throw controlRefused();
    return app.flow.walk([{ at: "home", intent: "roll" }, { at: "home", intent: "roll" }]);
  }
});

export const giveCoins = defineCommand({
  id: "dice.giveCoins",
  title: "Give coins",
  input: { coins: "number" },
  effect: "cheat",
  run: (app, { coins }) => {
    if (typeof __MOKU_GAME_DEV__ === "undefined" || !__MOKU_GAME_DEV__) throw controlRefused();
    return app.flow.restore({ ...app.flow.bookmark(), player: { coins, lastRoll: 0 } });
  }
});
  1. Write the guard inline: a define of false folds it and the minifier drops the body.
  2. Kinds are "string", "number", "boolean" and "json"; a trailing "?" makes a field optional.

A command meant for a --debug page writes the agent form of the guard, with __MOKU_GAME_AGENT__ for a read or cosmetic effect and __MOKU_GAME_CONTROL__ for the others. overlay: "toggle" or overlay: "button" lists a command in the editor's overlay.

  • /control never ships. run throws in a production build; keep game commands in .dev files that only the dev page, a debug page and the tests import.
  • Do not re-declare __MOKU_GAME_DEV__. The package ships its declaration.
  • A headless watch needs frames. Call app.time.step(16); nothing runs on its own in plain Bun.
  • diff is dev only. A production build throws [game] game.diff needs a dev build.
  • Testing: the headless apps the doors run on.
  • Hot swap: the editor restores a checkpoint after a reload.
  • flow plugin: bookmarks and restore in full.