@moku-labs/game
v0.11.0
Published
2D puzzle game engine on TypeScript and PixiJS, built on @moku-labs/core.
Readme
@moku-labs/game
A 2D puzzle engine where the game is a graph, and the screen is just an opinion about it.
You write small nodes and edge tables. The engine runs them, saves on the edges and replays the whole game without a screen. It ships no genre rules.
Why · Status · Install · Quick start · Game shell · How it works · Plugins · Docs
Why
- The game is a graph. Flows are edge tables. Nodes are small functions with plain
awaitbodies. One node is active at a time. - State commits only on edges. A node works on drafts. The runner commits them when the node returns an outcome. A node that throws changes nothing.
- Position is data. Node path, input and state describe the whole game. Checkpoints, rollback, bookmarks and repro runs come for free.
- The screen is a projection. Rendering reads committed state and never owns it. The same game plays whole in plain Bun, with no screen at all.
- Deterministic by construction. Time is an input named
now. Randomness is a savedrngstream. A lint rule refusesDate.nowandMath.randomin game logic. - Data, not classes. Animations, strings, styles, scenes and effects are frozen data. An AI agent can write them. So can you, on a bad day.
Status
[!NOTE]
0.4: working, still young. It runs on our machines, two emulators and one very patient fixture game. The API may still change before1.0.
| Milestone | What it added |
|---|---|
| V1 | The logic: time, lifecycle, model, clock, flow. A game plays to the end headless. |
| V2 | The screen: an own small ECS, a lazy Pixi v8 renderer, gestures, typed assets, scenes. |
| V3 | The interface: animations, strings, MSDF text, screens in JSX, sound. |
| V4 | Two doors for the editor: read a running game, drive a dev build. The editor itself lives elsewhere. |
| V5 | Particles, WGSL filters, sprite frames, the production asset pack, a text field, visual tests. |
| V6 | platform: Back button, haptics, keep-awake. Filters on WebGL. The game as a Tauri app on iOS and Android. |
Strings are ICU messages with durations, translator notes, a pseudo-locale, and export and import for translators.
Rendering is WebGPU first, with Pixi's WebGL fallback. A device with neither gets an honest "unsupported device" screen.
Install
bun add @moku-labs/game pixi.js[!IMPORTANT] Bun only. ESM only. There is no CJS build.
pixi.js^8.0.0is a peer dependency. Screens are.tsx, so a game sets"jsx": "react-jsx"and"jsxImportSource": "@moku-labs/game".
A game in a native shell adds the optional peers: bun add @moku-labs/system for the system plugins and the store save, bun add -d @moku-labs/native for the native build. A web-only game installs neither.
Quick start
A dice game in a folder: one rest node, one working node, one flow, then the game and its page as data.
// state.ts: bind the helpers to the types of the game
import { defineGame } from "@moku-labs/game";
export const { defineNode, defineFlow } = defineGame<{
player: { coins: number };
session: { rolls: number };
assets: string;
strings: Record<string, unknown>;
}>();// flow.ts: nodes and the edge table, checked by the compiler
import { type } from "@moku-labs/game";
import { defineFlow, defineNode } from "./state";
const home = defineNode({ outcomes: { roll: type() }, rest: true, checkpoint: true });
const roll = defineNode({
outcomes: { done: type() },
run: ({ player, session, rng, out }) => {
player.coins += rng.stream("dice").range(1, 6);
session.rolls += 1;
return out.done();
}
});
export const mainFlow = defineFlow("main", {
nodes: { home, roll },
start: "home",
edges: { home: { roll: "roll" }, roll: { done: "home" } }
});A game in two files: index.ts is the game as one data object, config.ts the page as plain data.
// index.ts: the game as one data object
import { defineGameApp } from "@moku-labs/game/app";
import { mainFlow } from "./flow";
export default defineGameApp({
flow: mainFlow,
safeNode: "home",
player: { coins: 0 },
session: { rolls: 0 }
});// config.ts: the page, as plain data
import type { GameConfig } from "@moku-labs/game/app";
export default { page: { title: "Dice" } } satisfies GameConfig;Tests make the app with game.headless() or game.screen(), not started, on a fake clock and an in-memory save:
// game.test.ts: play it with no screen
import { createHeadless } from "@moku-labs/game/testing";
import game from "./index";
const { app } = game.headless();
const run = await createHeadless(app);
await run.walk([{ at: "home", intent: "roll" }, { at: "home", intent: "roll" }]);
app.model.store.snapshot().session; // { rolls: 2 }bunx moku-game dev serves it with hot reload on http://localhost:3000/. The full version, with a reset node and a vitest file, is in docs/quick-start.md. A bigger game, the merge game, lives in moku-labs/demos with its tests.
A game with a screen gets two more things from the package. The body font fonts/font-body.* (Pangolin, SIL OFL 1.1) is the default of the built-in text style body once copied into features/ui/assets/. moku-game keys writes the typed asset keys, the manifest and the strings. Both are in docs/quick-start.md.
Game shell
The engine bin moku-game serves, builds and packs a game folder. A game writes no page, no server, no bridge and no bunfig.toml.
| Path | Holds |
|---|---|
| index.ts | export default defineGameApp({ ... }) |
| config.ts | page, native, system, save, assets: plain data that satisfies GameConfig |
| core/, shared/, features/, plugins/ | The kit, the shared layer, the features, the game's plugins |
| tests/ | The tests. tests/scenarios/<name>.ts are the saves of ?player=<name> on the dev page |
| .moku/ | What moku-game writes. Add .moku/ to .gitignore |
{
"scripts": {
"dev": "moku-game dev",
"build": "moku-game build",
"native:ios-sim": "moku-game native build ios --simulator"
}
}| Command | Does |
|---|---|
| moku-game dev [--port 3000] [--packed] | Serves the game with hot reload. Prints the bound URL, also for --port 0 |
| moku-game build [--out dist/web] | Packs the assets and builds the production page |
| moku-game native build <target> [--simulator] | Builds the native app: ios, macos, android |
| moku-game native dev <target> | Runs the native shell on the dev server |
| moku-game native doctor, native clean | Checks or removes the native project |
| moku-game keys [--check] | Writes generated/assets.ts, the strings and manifest.json |
| moku-game pack [--no-cache] | Packs the assets into dist/assets |
Every command takes --root <dir>, --preload <path> and --serve-plugin <path>.
config.ts names the system plugins the page wires, system: ["lifecycle", "back", "haptics", "keepAwake"], and the save, save: "memory" | "local" | "store". A system list and the "store" save need the optional peer @moku-labs/system; moku-game native needs @moku-labs/native. The game imports neither. The whole shell is in docs/shell.md.
How it works
flowchart LR
P["Player answer<br/>gate.answer"] --> R["Rest node<br/>waits"]
W["World event<br/>inbox.post"] --> R
R --> N["Transit node<br/>works on drafts"]
N --> E["Edge<br/>commit, journal, flow:edge"]
E --> R
E --> M["model<br/>committed state and save"]
M --> S["Screen<br/>projection, JSX"]
classDef u fill:#0b7285,stroke:#08525f,color:#fff;
classDef m fill:#1864ab,stroke:#0d3d6e,color:#fff;
class P,W,S u
class R,N,E,M mA rest node waits. A player answer comes through the gate. A world event comes through the inbox. Nothing else moves the graph.
| Term | Meaning |
|---|---|
| Node | defineNode({ input?, outcomes, run }). Returns out.name(data). |
| Rest node | rest: true. The graph waits here. Entering it marks a rest point. |
| checkpoint | A rest node where the journal is compacted. safeNode points at one. |
| barrier | The save is written durably after this node. Rollback cannot cross it. |
| Flow | defineFlow(id, { nodes, start, edges }). A flow with outcomes is a node of another flow. |
| Edge target | A node name, exit("outcome") to leave a sub-flow, or to("node", mapper). |
| Slot | slot("name"). Features contribute sub-flows to it. |
| Feature | defineFeature(name, { nodes?, flows?, contribute? }). A plugin that adds to the graph. |
| Effect | await fx(descriptor) for awaited effects, fx.emit(hint(...)) for cosmetic ones. |
| Transition | Rest node to rest node, as one transaction. An error rolls back and retries. |
The full contract, with the node context, over and inbox, is in docs/quick-start.md.
Plugins
Five logic plugins run in every app. The nine screen plugins come as one list, screen. Three more are opt-in.
createApp({ plugins: [...screen, effectsPlugin, audioPlugin, platformPlugin] });| Plugin | Set | What it does |
|---|---|---|
| time | logic | One frame loop, six phases, step for tests |
| lifecycle | logic | A stack of pause reasons |
| model | logic | Player and session state, the save, rng streams, migrations |
| clock | logic | Trusted now() and timers stored as moments |
| flow | logic | The graph: runner, gate, inbox, effects, features |
| world | screen | A small ECS and projections from committed state |
| renderer | screen | Pixi v8, loaded lazily, synced from the ECS |
| input | screen | Gestures as data: tap, press, drag, swipe |
| assets | screen | Manifest, load tiers, typed keys, the production pack |
| scenes | screen | A scene as a declaration: bundle, layers, music |
| anim | screen | Choreography as data: tweens, timelines, motions |
| i18n | screen | Strings as data, ICU messages, compiled per locale |
| text | screen | MSDF text, styles, inline tags and icons |
| ui | screen | Screens in JSX, Yoga layout, popups, styles |
| effects | opt-in | Particles and filters as components, WGSL and GLSL |
| audio | opt-in | Music and sound effects on three buses |
| platform | opt-in | The phone: Back button, haptics, keep-awake |
log and env come from @moku-labs/common. The dependency graph and every root export are in docs/plugins.md.
Entry points
| Import | Runs in | For |
|---|---|---|
| @moku-labs/game | anywhere | The engine: plugins, helpers, components |
| @moku-labs/game/app | anywhere | defineGameApp, startMoment and the types of index.ts and config.ts. See The game shell |
| @moku-labs/game/app/page | the browser | startPage, the page the generated .moku/main.ts calls |
| @moku-labs/game/app/system | the browser, the native shell | The system shell over the optional peer @moku-labs/system |
| @moku-labs/game/cli | Node and Bun | runCli, the bin moku-game; preparePage, the dev page for the editor |
| @moku-labs/game/testing | anywhere | Headless and isolated games, the fake clock, the in-memory save |
| @moku-labs/game/visual | Node and Bun | Visual tests: baselines of state, describe and pixels |
| @moku-labs/game/assets | Node and Bun | Asset keys, the manifest, strings, the pack |
| @moku-labs/game/inspect | anywhere | Read a running game |
| @moku-labs/game/control | dev builds | Drive a running game |
| @moku-labs/game/hot | the Bun dev server | The Bun plugin that hot swaps views. moku-game dev lists it. See Hot swap |
| @moku-labs/game/lint | oxlint | The game lint rules, as an oxlint JS plugin |
| @moku-labs/game/project | Node and Bun | Where every engine id of a game is defined, line fresh at the call. The bin moku-game-index. See Project index |
| @moku-labs/game/jsx-runtime, /jsx-dev-runtime | anywhere | The JSX runtime. A game never imports it by hand. |
Lint for games
@moku-labs/game/lint is an oxlint JS plugin, moku-game. It checks the engine rules in a game: lazy Pixi and Yoga, no native package in the logic, the editor only in dev files, no module-scope state, determinism, pure rules and JSX keys the project index can follow. Three layout rules check a game on the layered layout: the order of the layers, the doors of the features and the suffix of each test file.
// .oxlintrc.json
{
"jsPlugins": ["@moku-labs/game/lint"],
"rules": {
"moku-game/lazy-imports": "error",
"moku-game/native-imports": "error",
"moku-game/dev-imports": "error",
"moku-game/no-module-state": "error",
"moku-game/determinism": "error",
"moku-game/rules-siblings": "error",
"moku-game/static-keys": "error",
"moku-game/layer-imports": "error",
"moku-game/feature-door": "error",
"moku-game/test-suffix": "error"
}
}Each rule takes { "files": [...], "ignores": [...] }. layer-imports, feature-door and rules-siblings also take root and tsconfig; test-suffix takes root and suffixes. The defaults follow the layout of a game. See Lint for games.
Docs
| Page | What is inside |
|---|---|
| Quick start | The full dice game, step by step |
| The game shell | defineGameApp, config.ts, the page, the save, system and native, the moku-game commands |
| Plugins | The dependency graph, the plugin table, every root export |
| Interface in JSX | Tags, components, styles, popups |
| Doors for the editor | /inspect and /control, base sources and commands |
| Hot swap | Save a view, see it in the running page, no reload |
| Events | Every event and its payload |
| Configuration | Every config field and its default |
| Lint for games | The moku-game oxlint rules, their options and defaults |
| Project index | openProject, the key scheme, find and watch, the moku-game-index bin |
| Testing | game.headless() and game.screen(), headless, isolated and visual tests, all scripts, test layout, lint rules |
| llms.txt | The engine in one page, for an AI that writes a game |
Each plugin has its own README, linked in the table above. The kernel is specified in the Moku Core specification.
Development
bun run build # tsdown into dist/
bun run typecheck # tsc --noEmit
bun run lint # biome check . && eslint .
bun run test # vitest, unit and integration
bun run test:coverage # with the 90% threshold
bun run validate # publint and attw
bun run mini:dev # moku-game dev of the mini game of tests/fixtures
bun run mini:pack # moku-game pack of the mini game
bun run mini:visual # visual tests of the mini game
bun run release # moku-releaseEvery script is in docs/testing.md.
Hot swap
A save of a view file swaps it in the running page, with the same state. moku-game dev lists the plugin in the bunfig it writes, so a game sets nothing. A game with a server of its own lists it in its bunfig.toml:
[serve.static]
plugins = ["@moku-labs/game/hot"]A save of a logic file reloads the page. What swaps and what reloads: docs/hot-swap.md.
Requirements
- Node
>= 24and Bun>= 1.3.14. Usebunonly. - TypeScript in strict mode, with
exactOptionalPropertyTypesandnoUncheckedIndexedAccess. pixi.js^8.0.0as a peer dependency.playwright-corefor visual tests,sharpfor the asset pack,typescriptfor the project index,@moku-labs/systemfor the system shell and@moku-labs/nativefor the native build are optional peers.yoga-layoutis loaded lazily byui. The string compiler uses@formatjs/icu-messageformat-parserat build time only.@moku-labs/coreis the kernel.@moku-labs/commonbringslogandenv.
