Quick start
The dice game end to end: the kit, the nodes, the flow, the page, a headless test and the build.
On this page
A dice game in eight steps
One rest node, two working nodes, one flow. The game rolls a die, adds the face to the coins, and can reset them.
-
Create the folder and install.
pixi.jsis a peer dependency. The test below runs on Vitest.install mkdir dice && cd dice bun init -y bun add @moku-labs/game pixi.js bun add -d vitest
-
Point JSX at the engine. Screens are
.tsx. The engine expects strict TypeScript withexactOptionalPropertyTypesandnoUncheckedIndexedAccess.tsconfig.json { "compilerOptions": { "strict": true, "exactOptionalPropertyTypes": true, "noUncheckedIndexedAccess": true, "jsx": "react-jsx", "jsxImportSource": "@moku-labs/game" } } -
Bind the kit to the types of the game:
state.ts. -
Write the nodes:
nodes.ts. -
Wire the flow:
flow.ts. -
Define the game as one data object:
index.ts. Name the page:config.ts. -
Play it headless with a test.
-
Serve and build with the engine bin
moku-game.
The kit
defineNode and defineFlow come from defineGame. They are not root exports. The kit binds
them to the player and the session, so a node that writes a field the game does not have does not
compile.
import { defineGame } from "@moku-labs/game";
export type Player = { coins: number; lastRoll: number };
export type Session = { rolls: number };
export const { defineNode, defineFlow, defineFeature } = defineGame<{
player: Player;
session: Session;
assets: string;
strings: Record<string, unknown>;
}>();- The player tree is saved. Plain JSON only: no class, no
Map, noDate. - The session tree lives for one run and is never saved.
The nodes
A rest node without a body is a pure wait. The intent of the player's answer names the outcome.
A working node gets drafts of the state and returns an outcome.
import { type } from "@moku-labs/game";
import { defineNode } from "./state";
export const home = defineNode({
outcomes: { roll: type(), reset: type() },
rest: true,
checkpoint: true
});
export const roll = defineNode({
outcomes: { done: type() },
run: ({ player, session, rng, out }) => {
const face = rng.stream("dice").range(1, 6);
player.coins += face;
player.lastRoll = face;
session.rolls += 1;
return out.done();
}
});
export const reset = defineNode({
outcomes: { done: type() },
run: ({ player, out }) => {
player.coins = 0;
return out.done();
}
});- The graph waits here for an answer whose intent is
rollorreset. - The journal is compacted here. The safe node of a game is a checkpoint.
- One stream per source. Its state is part of the save.
The flow
The edge table names the next node for every outcome of every node. A missing edge or an unknown target is a compile error with a sentence.
import { home, reset, roll } from "./nodes";
import { defineFlow } from "./state";
export const mainFlow = defineFlow("main", {
nodes: { home, roll, reset },
start: "home",
edges: {
home: { roll: "roll", reset: "reset" },
roll: { done: "home" },
reset: { done: "home" }
}
});The game and its page
index.ts is the whole game as one data object. defineGameApp creates no app: the page and the
tests do, with game.screen() and game.headless(). The graph is run by the page or the test,
never by the game.
import { defineGameApp } from "@moku-labs/game/app";
import { mainFlow } from "./flow";
export default defineGameApp({
flow: mainFlow,
safeNode: "home",
player: { coins: 0, lastRoll: 0 },
session: { rolls: 0 }
});- The checkpoint the runner enters after a transition failed its retries.
config.ts is plain data: the page, and later the native app, the system plugins and the save.
Every field but the title has a default.
import type { GameConfig } from "@moku-labs/game/app";
export default {
page: { title: "Dice", background: "#10161d" }
} satisfies GameConfig;- The colour behind the canvas, the
theme-colormeta and the native window.
Play it headless
game.headless() makes the logic app, not started, on a fake clock and an in-memory save.
createHeadless starts it and resolves once the graph rests at its first rest node. walk plays a
route.
import type { Flow } from "@moku-labs/game";
import { createHeadless } from "@moku-labs/game/testing";
import { expect, it } from "vitest";
import game from "./index";
const rollOnce: Flow.RouteStep = { at: "home", intent: "roll" };
it("plays two rolls and a reset without a screen", async () => {
const { app } = game.headless();
const run = await createHeadless(app);
const state = await run.walk([rollOnce, rollOnce]);
expect(state.path).toBe("home");
expect(app.model.store.snapshot().session).toEqual({ rolls: 2 });
await run.walk([{ at: "home", intent: "reset" }]);
expect(app.model.store.snapshot().player).toMatchObject({ coins: 0 });
await run.stop();
});atis the path of a rest node,intentthe answer given there.
bunx vitest runIn a live game the same answer comes from the screen: app.flow.gate.answer({ intent: "roll" }).
Serve and build
Add the scripts and ignore what moku-game writes. A game writes no page, no server and no
bunfig.toml.
{
"scripts": {
"dev": "moku-game dev",
"build": "moku-game build"
}
}echo ".moku/" >> .gitignore bun run dev # http://localhost:3000/, with hot reload bun run build # the production page in dist/web
moku-game dev writes the dev page into .moku/ and prints the bound URL on its own line.
--port 0 takes a free port. Until .gitignore lists .moku/, it warns: [game] dev: add ".moku/" to .gitignore. moku-game writes its dev page there.
The folder
| Path | Holds |
|---|---|
index.ts | export default defineGameApp({ ... }): the game as one data object |
config.ts | The page, the native app, the system plugins and the save, as plain data |
state.ts, nodes.ts, flow.ts | The kit, the nodes and the flow of this small game |
tests/ | The tests. tests/scenarios/<name>.ts are the saves of ?player=<name> on the dev page |
.moku/ | What moku-game writes. Git ignores it |
dist/web/ | The production page of moku-game build |
A bigger game splits into core/, shared/, features/ and plugins/. See
Features and The game shell.
defineNodeis not a root export. Import it from your kit, the result ofdefineGame<Types>().- A node returns
out.<outcome>(). A bare string is not accepted. - The game never calls
flow.run(). The page runs the graph afterapp.start(); a test hands the app tocreateHeadless, which runs it. - No
Date.now()and noMath.random()in a node. Usenowandrngfrom the node context. The lint rules refuse both. - A hook that throws never stops the game. The engine writes the error entry
"game: a hook failed"to the log. Read it withapp.log.trace().
Next
- How it works: what happens between a rest node and the next one.
- Nodes & flows: sub-flows, slots, popups, timers.
- Screens: projections, JSX, scenes.
- Testing: headless, isolated and visual tests.