@moku-labs/game 0.8.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/README.md +88 -26
  2. package/bin/moku-game.mjs +4 -0
  3. package/dist/app/page.d.mts +41 -0
  4. package/dist/app/page.mjs +376 -0
  5. package/dist/app/system.d.mts +129 -0
  6. package/dist/app/system.mjs +307 -0
  7. package/dist/app.d.mts +37 -0
  8. package/dist/app.mjs +235 -0
  9. package/dist/assets-BHwTLVta.mjs +7391 -0
  10. package/dist/assets.d.mts +97 -33
  11. package/dist/assets.mjs +1 -7150
  12. package/dist/{capture-BX5Gfoty.mjs → capture-fpd83Mi5.mjs} +384 -1
  13. package/dist/cli.d.mts +70 -0
  14. package/dist/cli.mjs +1650 -0
  15. package/dist/{commands-C1kIfOG5.d.mts → commands-C4QlqijJ.d.mts} +5 -6
  16. package/dist/config-CdARrrhU.mjs +81 -0
  17. package/dist/control.d.mts +11 -10
  18. package/dist/control.mjs +3 -3
  19. package/dist/{define-BPpZcpxW.d.mts → define-CCc0ozKe.d.mts} +2 -2
  20. package/dist/{dev-wKLgOw7c.mjs → dev-Cc50Dk7H.mjs} +1 -1
  21. package/dist/{memory-CvgdnsQO.mjs → drafts-BawpSaVp.mjs} +1 -104
  22. package/dist/fake-MpzwZhIZ.mjs +87 -0
  23. package/dist/{headless-CvamCUcR.mjs → headless-BsNdJs9W.mjs} +1 -1
  24. package/dist/{headless-q98SwADR.d.mts → headless-G4nuZ-xz.d.mts} +1 -1
  25. package/dist/hot.d.mts +8 -8
  26. package/dist/hot.mjs +15 -14
  27. package/dist/index-xxSxKJJW.d.mts +1611 -0
  28. package/dist/index.d.mts +6 -1597
  29. package/dist/index.mjs +4 -34343
  30. package/dist/inspect-la_KkjCS.mjs +1 -0
  31. package/dist/inspect.d.mts +7 -8
  32. package/dist/inspect.mjs +3 -2
  33. package/dist/jsx-dev-runtime.d.mts +1 -1
  34. package/dist/jsx-runtime.d.mts +1 -1
  35. package/dist/lint.d.mts +55 -4
  36. package/dist/lint.mjs +999 -42
  37. package/dist/memory-CWlqhS7l.mjs +136 -0
  38. package/dist/memory-DwDhfFWK.d.mts +50 -0
  39. package/dist/project.d.mts +29 -13
  40. package/dist/project.mjs +1120 -631
  41. package/dist/{run-CUPSFM85.mjs → run-wYty_Lwo.mjs} +4 -4
  42. package/dist/{runtime-CvDZZg1S.d.mts → runtime-DOBYM6g4.d.mts} +1 -1
  43. package/dist/{input-z_35Jwfn.mjs → session-DgukhR_7.mjs} +20 -20
  44. package/dist/{sources-BBUd15vQ.mjs → sources-Duzj6lhV.mjs} +3 -3
  45. package/dist/src-DSl1eNB1.mjs +34366 -0
  46. package/dist/testing.d.mts +115 -34
  47. package/dist/testing.mjs +175 -70
  48. package/dist/{types-DlgsfOmW.d.mts → types-BM4shh8K.d.mts} +300 -263
  49. package/dist/{types-DgdMLZnV.d.mts → types-BUwDHKXm.d.mts} +1372 -1373
  50. package/dist/{types-DF5kkW-C.d.mts → types-CefgYzFt.d.mts} +4 -5
  51. package/dist/{types-CDSKa0vl.d.mts → types-DAdwEJMp.d.mts} +2 -2
  52. package/dist/{types-BWzCSRM2.d.mts → types-ZQLYXo5X.d.mts} +2983 -20
  53. package/dist/types-hQgI_wrq.d.mts +544 -0
  54. package/dist/{visual-DUvvPPCt.mjs → visual-dMW8iqAE.mjs} +8 -4
  55. package/dist/visual.d.mts +10 -11
  56. package/dist/visual.mjs +5 -5
  57. package/llms.txt +170 -57
  58. package/package.json +30 -2
  59. package/dist/registry-DlpRCibU.mjs +0 -384
  60. package/dist/types-ERb2Y9U5.d.mts +0 -2967
package/llms.txt CHANGED
@@ -1,20 +1,24 @@
1
1
  # @moku-labs/game
2
2
 
3
- > 2D puzzle game engine on TypeScript, built as a Layer-2 framework on @moku-labs/core. The game is a deterministic graph of business logic. State commits only on edges. Position is data. Player input and world events are answers a node waits for. The screen is a projection of committed state. V1 is the logic plugins `time`, `lifecycle`, `model`, `clock`, `flow` and a headless testing entry; V2 adds the screen plugins `world`, `renderer`, `input`, `assets`, `scenes`, a Pixi v8 renderer loaded lazily, and the `@moku-labs/game/assets` entry that generates typed asset keys. V3 adds the interface plugins `anim`, `i18n`, `text`, `ui` (`screen` lists all nine) and the opt-in `audio`: choreographies as data, strings as data, text from MSDF fonts, screens in JSX laid out by Yoga, sound as an effect a node awaits. V4 adds two doors for the editor: `@moku-labs/game/inspect` reads a running game, `@moku-labs/game/control` drives a dev build. V5 adds the opt-in plugin `effects`: particles and WGSL/GLSL filters as components. V6 adds the opt-in plugin `platform`: the Back button, haptics and keep-awake through a provider the game passes. The package is published on npm as `@moku-labs/game`, and this file ships inside it.
3
+ > 2D puzzle game engine on TypeScript, built as a Layer-2 framework on @moku-labs/core. The game is a deterministic graph of business logic. State commits only on edges. Position is data. Player input and world events are answers a node waits for. The screen is a projection of committed state. V1 is the logic plugins `time`, `lifecycle`, `model`, `clock`, `flow` and a headless testing entry; V2 adds the screen plugins `world`, `renderer`, `input`, `assets`, `scenes`, a Pixi v8 renderer loaded lazily, and the `@moku-labs/game/assets` entry that generates typed asset keys. V3 adds the interface plugins `anim`, `i18n`, `text`, `ui` (`screen` lists all nine) and the opt-in `audio`: choreographies as data, strings as data, text from MSDF fonts, screens in JSX laid out by Yoga, sound as an effect a node awaits. V4 adds two doors for the editor: `@moku-labs/game/inspect` reads a running game, `@moku-labs/game/control` drives a dev build. V5 adds the opt-in plugin `effects`: particles and WGSL/GLSL filters as components. V6 adds the opt-in plugin `platform`: the Back button, haptics and keep-awake through a provider the game passes. The game shell makes a game a folder of data: `@moku-labs/game/app` (`defineGameApp`, `config.ts` as `GameConfig`), the page `@moku-labs/game/app/page`, the system shell `@moku-labs/game/app/system` and the bin `moku-game` that serves, builds, packages and packs the folder. The package is published on npm as `@moku-labs/game`, and this file ships inside it.
4
4
 
5
- Facts: Bun only. ESM only. `"sideEffects": false`. `pixi.js` `^8.0.0` is a peer dependency, no V1 code imports it. `playwright-core` (visual tests), `sharp` (the asset pack) and `typescript` (the project index) are optional peers. Node >= 24, Bun >= 1.3.14. TypeScript strict with `exactOptionalPropertyTypes` and `noUncheckedIndexedAccess`. The engine ships no genre rules: no merge, no match-3, no physics. A game brings its own pure rule functions.
5
+ Facts: Bun only. ESM only. `"sideEffects": false`. `pixi.js` `^8.0.0` is a peer dependency, no V1 code imports it. `playwright-core` (visual tests), `sharp` (the asset pack), `typescript` (the project index), `@moku-labs/system` (the system shell and the store save) and `@moku-labs/native` (`moku-game native`) are optional peers. Node >= 24, Bun >= 1.3.14. TypeScript strict with `exactOptionalPropertyTypes` and `noUncheckedIndexedAccess`. The engine ships no genre rules: no merge, no match-3, no physics. A game brings its own pure rule functions.
6
6
 
7
7
  ## Entry points
8
8
 
9
9
  - `@moku-labs/game`: everything a game imports.
10
- - `@moku-labs/game/testing`: headless play, repro runs, fake clock, in-memory save provider. No `node:` import.
10
+ - `@moku-labs/game/app`: `defineGameApp`, `startMoment` and the types of a game's `index.ts` and `config.ts`. No `node:` import, no system or native package. See "The game shell".
11
+ - `@moku-labs/game/app/page` (the browser): `startPage`, the page the generated `.moku/main.ts` calls.
12
+ - `@moku-labs/game/app/system` (the browser, the native shell): `systemShell`, `fromSystem`, `storeSave`, `createSystemApp` over the optional peer `@moku-labs/system`. The one entry that reaches it.
13
+ - `@moku-labs/game/cli` (node and bun only): `runCli`, the bin `moku-game`, and `preparePage`, the editor's dev page.
14
+ - `@moku-labs/game/testing`: headless play, repro runs, isolated feature tests (`isolate`, `stub`), fake clock, in-memory save provider. No `node:` import.
11
15
  - `@moku-labs/game/visual` (node and bun only): the visual tests `defineVisualTest`, `runVisualTests`, `parseVisualArgv`.
12
16
  - `@moku-labs/game/assets` (node and bun only): the asset key scanner, the production packer and the string compiler. The package bin `moku-game-assets` runs it.
13
17
  - `@moku-labs/game/inspect`: the read door of the editor, safe in a production build. See "Doors for the editor".
14
18
  - `@moku-labs/game/control`: the write door of the editor, dev builds only. See "Doors for the editor".
15
- - `@moku-labs/game/hot` (the Bun dev server only): the `bunfig.toml` plugin that hot swaps views in a running page. See "Hot swap".
19
+ - `@moku-labs/game/hot` (the Bun dev server only): the Bun plugin that hot swaps views in a running page; `moku-game dev` lists it. See "Hot swap".
16
20
  - `@moku-labs/game/lint`: the oxlint JS plugin `moku-game` with the game lint rules. See "Lint for games".
17
- - `@moku-labs/game/project` (node and bun only): the project index, where every engine id of a game is defined, line read fresh at the call. The package bin `moku-game-index` runs it. See "Project index".
21
+ - `@moku-labs/game/project` (node and bun only): the project index, where every engine id of a game is defined, line read fresh at the call. It follows the tsconfig `paths` of the game. The package bin `moku-game-index` runs it. See "Project index".
18
22
  - `@moku-labs/game/fonts/*`: the shipped MSDF body font and its licence. See "Fonts".
19
23
  - `@moku-labs/game/jsx-runtime` and `@moku-labs/game/jsx-dev-runtime`: what `"jsxImportSource": "@moku-labs/game"` resolves to. A game never imports them by hand.
20
24
 
@@ -23,11 +27,11 @@ Root exports:
23
27
  - Class: `SaveUnreadableError`.
24
28
  - Plugin instances: `timePlugin`, `lifecyclePlugin`, `modelPlugin`, `clockPlugin`, `flowPlugin`, `worldPlugin`, `rendererPlugin`, `inputPlugin`, `assetsPlugin`, `scenesPlugin`, `animPlugin`, `i18nPlugin`, `textPlugin`, `uiPlugin`, `audioPlugin`, `effectsPlugin`, `platformPlugin`; the list `screen` = `world`, `renderer`, `input`, `assets`, `scenes`, `anim`, `i18n`, `text`, `ui`. `effects`, `audio` and `platform` are opt-in: `plugins: [...screen, effectsPlugin, audioPlugin, platformPlugin]`, `platform` last.
25
29
  - Screen helpers from the root: `component`, `tag`, `resource`, `mut`, `system`, `projection`, `Layer`, `Order`, `Exiting`, `Tree`, `Transform`, `Sprite`, `NineSlice`, `Shape`, `Parent`, `Display`, `sprite`, `Tappable`, `Pressable`, `Draggable`, `DropTarget`, `Swipeable`, `Traceable`, `Touchable`, `Held`, `Hovered`, `PointerOver`, `Pressed`, `Pointer`, `Traced`, `defineBundles`, `load`, `defineScene`.
26
- - Interface helpers from the root (V3): `defineAnimation`, `sequence`, `parallel`, `stagger`, `tween`, `set`, `wait`, `mark`, `frames`, `sfx`, `haptic`, `use`, `spawn`, `spawned`, `play`, `external`, `defineMotion`, `Animation`, `Frames`; `tr`; `Text`, `label`, `defineTextStyles`, `bind`, `Countdown` and the types `BindOptions`, `CountdownValue`, `TextFormat`; `defineComponent`, `popup`, `defineStyle`, `defineTokens`, `resolve`, `Box`, `LocalWrite`; `music`.
30
+ - Interface helpers from the root (V3): `defineAnimation`, `sequence`, `parallel`, `stagger`, `tween`, `set`, `wait`, `mark`, `frames`, `sfx`, `haptic`, `use`, `spawn`, `spawned`, `play`, `external`, `defineMotion`, `Animation`, `Frames`, the resource `AnimPlayer`; `tr`; `Text`, `label`, `defineTextStyles`, `bind`, `Countdown` and the types `BindOptions`, `CountdownValue`, `TextFormat`; `defineComponent`, `popup`, `defineStyle`, `defineTokens`, `resolve`, `Box`, `LocalWrite`; `music`.
27
31
  - Effects helpers from the root (V5): `defineEmitter`, `Emitter`, `defineFilter`, `Glow`, `Outline`, `Blur`, `ColorMatrix`, `Noise`, `Displacement`, `Alpha`.
28
32
  - Platform from the root (V6): the types `PlatformProvider`, `BackResult`, `HapticKind` and the frozen list `HAPTIC_KINDS`.
29
33
  - `defineGame<Types>()` returns, typed by the game's `player`, `session`, `assets`, `bundles`, `scenes`, `strings` and `textStyles`: `defineNode`, `defineFlow`, `defineFeature`, `projection`, `sprite`, `Sprite`, `NineSlice`, `defineBundles`, `load`, `defineScene`, `defineAnimation`, `frames`, `sfx`, `play`, `tr`, `label`, `defineTextStyles`, `defineComponent`, `defineStyle`, `defineTokens`, `popup`, `music`, `Frames`, `defineEmitter`, `Emitter`, `Displacement`. `Types` is `{ player; session; assets: string; bundles?: string; scenes?: string; strings: Record<string, unknown>; textStyles?: string; emitters?: string }`.
30
- - `@moku-labs/game/assets`: `scanAssets(options)`, `emitKeys`, `emitManifest`, `compileStrings(root, out, options?)`, `checkStrings`, `packAssets(options)`, `exportStrings`, `importStrings`, `runCli(argv, strings)` with `strings: StringsTools` = `{ compile: compileStrings, exportStrings, importStrings }`. Audio files are `.mp3` or `.m4a`. Asset key = feature + "." + path inside `assets/`. `runCli` (a game's `bun run assets:keys`) scans the assets and compiles `features/*/strings/<locale>.json` into `generated/strings.ts` (the `Strings` type for `defineGame`) and one `generated/strings.<locale>.ts` per locale; `--check` fails when any output is out of date; `--pack <dir>` writes the production build. `--pseudo` also writes the pseudo-locale `en-XA`. `--export <dir>` and `--import <dir>` exchange strings with translators; `--source <locale>` names the locale they read from, `"en"` by default. The package bin `moku-game-assets` is this CLI: a game's script is `"assets:keys": "moku-game-assets --root src --manifest public/assets/manifest.json --keys src/generated/assets.ts"`.
34
+ - `@moku-labs/game/assets`: `scanAssets(options)`, `emitKeys`, `emitManifest`, `compileStrings(root, out, options?)`, `checkStrings`, `packAssets(options)`, `exportStrings`, `importStrings`, `runCli(argv, strings)` with `strings: StringsTools` = `{ compile: compileStrings, exportStrings, importStrings }`. Audio files are `.mp3` or `.m4a`. Asset key = feature (or layer name) + "." + path inside `assets/`. `runCli` (a game's `bun run assets:keys`) scans the assets and compiles `features/*/strings/<locale>.json`, and the `strings/` of every layer, into `generated/strings.ts` (the `Strings` type for `defineGame`) and one `generated/strings.<locale>.ts` per locale; `--check` fails when any output is out of date; `--pack <dir>` writes the production build. `--layer <folder>[=<name>]`, repeatable and good with every mode, scans `<root>/<folder>/` like one more feature under `<name>` (`<folder>` alone maps to itself): its `assets.ts`, `assets/` and `strings/<locale>.json`. A game on the layered layout passes `--layer shared=ui`, so `shared/assets/*` keeps the `ui.*` keys. A missing layer folder is a warning. `scanAssets`, `compileStrings`, `exportStrings` and `importStrings` take the same map as `layers: { shared: "ui" }`. `--pseudo` also writes the pseudo-locale `en-XA`. `--export <dir>` and `--import <dir>` exchange strings with translators; `--source <locale>` names the locale they read from, `"en"` by default. The package bin `moku-game-assets` is this CLI: a script is `"assets:keys": "moku-game-assets --root src --manifest public/assets/manifest.json --keys src/generated/assets.ts"`. A game with `config.ts` runs `moku-game keys` and `moku-game pack` instead: they pass its paths and its layers.
31
35
  - Type namespaces: `Time`, `Lifecycle`, `Model`, `Clock`, `Flow`, `World`, `Renderer`, `Input`, `Assets`, `Scenes`, `Anim`, `I18n`, `TextTypes`, `Ui`, `Audio`, `Effects`, `Platform`. Use `import type { Flow } from "@moku-labs/game"` and then `Flow.RouteStep`, `Flow.NodeContext`, `Model.Json`, `Model.PlayerStateProvider`, `Clock.ClockSource`, `Time.Phase`, `Lifecycle.PauseReason`, `Anim.Target`, `I18n.Message`. `Text` is the component, so the text types are `TextTypes`.
32
36
  - `defineNode` and `defineFlow` are NOT root exports. They come from `defineGame<Types>()`.
33
37
 
@@ -136,7 +140,7 @@ type NodeContext = {
136
140
 
137
141
  ## Lint for games
138
142
 
139
- `.oxlintrc.json` of a game (oxlint 1.86.0 or later):
143
+ `.oxlintrc.json` of a game (oxlint 1.86.0 or later). The engine ships no default severity: a game turns each rule on.
140
144
 
141
145
  ```json
142
146
  {
@@ -148,20 +152,28 @@ type NodeContext = {
148
152
  "moku-game/no-module-state": "error",
149
153
  "moku-game/determinism": "error",
150
154
  "moku-game/rules-siblings": "error",
151
- "moku-game/static-keys": "error"
155
+ "moku-game/static-keys": "error",
156
+ "moku-game/layer-imports": "error",
157
+ "moku-game/feature-door": "error",
158
+ "moku-game/test-suffix": "error"
152
159
  }
153
160
  }
154
161
  ```
155
162
 
156
163
  - `moku-game/lazy-imports` (L2): no static value import of `pixi.js` or `yoga-layout`. `import type` and `import()` pass. Default files `**`.
157
- - `moku-game/native-imports` (L13): no `@moku-labs/system`, `@moku-labs/native`, `@tauri-apps/*` in the logic, `kit.ts` and `game.ts`. Use `platform-bridge.ts`.
158
- - `moku-game/dev-imports`: `@moku-labs/editor` and `@moku-labs/game/control` only in `web/main.ts`, `web/dev*.ts`, `web/editor*.ts`, `*.dev.ts(x)` and tests.
164
+ - `moku-game/native-imports` (L13): no `@moku-labs/system`, `@moku-labs/native`, `@tauri-apps/*` in the logic, the root `index.ts` and `config.ts`, `kit.ts` and `plugins/`. A game imports no native package: it names the capability in `config.ts` `system`, and the engine page wires it.
165
+ - `moku-game/dev-imports`: `@moku-labs/editor` and `@moku-labs/game/control` only in `*.dev.ts(x)`, tests and the generated `.moku/` page (and `web/main.ts`, `web/dev*.ts`, `web/editor*.ts` of a game that still has `web/`).
159
166
  - `moku-game/no-module-state` (L5): no module-scope `let` or `var`, no module-scope `new Map/Set/WeakMap/WeakSet`. Default files `**`.
160
- - `moku-game/determinism` (L3): no `Math.random`, `Date.now`, `performance.now`, `new Date()`, `setTimeout`, `setInterval` in the logic. `new Date(now)` passes.
161
- - `moku-game/rules-siblings` (L4): a file under `rules/` imports only `./` siblings.
167
+ - `moku-game/determinism` (L3): no `Math.random`, `Date.now`, `performance.now`, `new Date()`, `setTimeout`, `setInterval` in the logic. `new Date(now)` passes. Plugin folders, `**/{plugin,plugins}/**`, are the effect side and are skipped: a plugin answers `schedule` with a timer.
168
+ - `moku-game/rules-siblings` (L4): a file under `rules/` imports only `./` siblings, `@core/types` and `@shared/rules`, by alias or by relative path. `@core/kit`, `../tables` and `import type … from "@moku-labs/game"` are reported.
162
169
  - `moku-game/static-keys`: a JSX `key` is a literal, a template of such parts, `props.id` or `props.<name>Key`, a call, or a `const` of one. `tabKeys[props.tab]`, `a ?? b`, `a || b`, `a ? b : c` and `props.tab` are reported: pass the key in as a prop, `<TabButton id="tabSound" />`. Default files `**/*.tsx`.
163
- - Logic = `**/state.ts`, `**/tables.ts`, `**/{nodes,flows,rules,features}/**`. Tests (`tests/**`, `**/__tests__/**`, `*.test.ts`) are skipped by every rule.
164
- - Every rule takes `["error", { "files": [...], "ignores": [...] }]`. Globs are relative to the directory oxlint runs in; a key given replaces the default.
170
+ - `moku-game/layer-imports`: the order is core ← shared ← features ← `game.ts`; plugins import core and shared; every layer may import `generated/`. An import, export-from or `import()` that reaches a layer above its own is reported, `import type` too: `@features/home` from `shared/`. Only `game.ts` imports `game.ts`.
171
+ - `moku-game/feature-door`: another feature is imported only through its `index.ts`, `@features/orders`, never `@features/orders/flow/merge`. Only `game.ts` imports the barrels `@features` and `@plugins`. A feature does not import its own `index.ts`. A relative import that leaves the feature is written as an alias: `@features/<f>`, `@shared`, `@core/<file>`. Inside one feature any relative import passes.
172
+ - `moku-game/test-suffix`: a file in a test kind folder ends with its suffix. `tests/e2e/` `.e2e.ts`, `tests/visual/` `.visual.ts`, `tests/editor/` `.editor.ts`, `__tests__/`, `__tests__/unit/` and `__tests__/integration/` `.test.ts`, `__tests__/isolated/` `.isolated.ts`, `__tests__/visual/` `.visual.ts`; a `.tsx` file adds an `x`. Helpers go to `tests/helpers/` or `__tests__/fixtures/`.
173
+ - Layers under the game root: `core/**`, `shared/**`, `features/<f>/**`, `plugins/<p>/**`, `generated/**`, `game.ts`, `tests/**`. A file outside them (`kit.ts`, `nodes/`, `web/`) keeps the layout rules quiet, so a game on the template layout lints as before.
174
+ - Aliases: `layer-imports`, `feature-door` and `rules-siblings` read the `compilerOptions.paths` of `tsconfig.json` (comments, trailing commas and relative `extends` included), the way the project index does. Without `paths` they read the v15 table: `@core/*` → `core/*`, `@shared` → `shared/index.ts`, `@shared/rules` → `shared/rules/index.ts`, `@features` → `features/index.ts`, `@features/*` → `features/*/index.ts`, `@plugins` → `plugins/index.ts`, `@generated/*` → `generated/*`, `@tests/*` → `tests/*`. `aliasTargetsOf(tsconfig, root, specifier)` from `@moku-labs/game/lint` answers what they read: for a game without a tsconfig, `aliasTargetsOf("/game/tsconfig.json", "/game", "@features/orders")` is `["/game/features/orders/index.ts"]`.
175
+ - Logic = the root `index.ts`, `**/state.ts`, `**/tables.ts`, `**/game.ts`, `**/{core,nodes,flows,rules,features,shared}/**`. Tests (`tests/**`, `**/__tests__/**`, `*.test.ts`) are skipped by every rule but `test-suffix`.
176
+ - Every rule takes `["error", { "files": [...], "ignores": [...] }]`. Globs are relative to the directory oxlint runs in; a key given replaces the default. `layer-imports`, `feature-door` and `rules-siblings` also take `root` (the folder of `core/`, `shared/`, `features/`, `plugins/` and `game.ts`, default `.`) and `tsconfig` (default `tsconfig.json`); `test-suffix` takes `root` and `suffixes` (kind folder to suffix, replaces the table whole).
165
177
 
166
178
  ## Minimal game
167
179
 
@@ -220,25 +232,96 @@ export const mainFlow = defineFlow("main", {
220
232
  ```
221
233
 
222
234
  ```ts
223
- // game.ts
224
- import { createApp } from "@moku-labs/game";
235
+ // index.ts: the game as one data object
236
+ import { defineGameApp } from "@moku-labs/game/app";
225
237
  import { mainFlow } from "./flow";
226
238
 
227
- export const createGame = (seed: "from-save" | number = "from-save") =>
228
- createApp({
229
- pluginConfigs: {
230
- model: { initialPlayer: { coins: 0, lastRoll: 0 }, initialSession: { rolls: 0 }, seed },
231
- flow: { mainFlow, safeNode: "home" }
232
- },
233
- onStart: ctx => {
234
- ctx.flow.run().catch((error: unknown) => {
235
- ctx.log.error("game: the graph failed", { error });
236
- });
237
- }
238
- });
239
+ export default defineGameApp({
240
+ flow: mainFlow,
241
+ safeNode: "home",
242
+ player: { coins: 0, lastRoll: 0 },
243
+ session: { rolls: 0 }
244
+ });
245
+ ```
246
+
247
+ ```ts
248
+ // config.ts: the page, as plain data
249
+ import type { GameConfig } from "@moku-labs/game/app";
250
+
251
+ export default { page: { title: "Dice" } } satisfies GameConfig;
252
+ ```
253
+
254
+ The game never calls `flow.run()`. The page runs the graph after `app.start()`; a test hands the app to `createHeadless`, which runs it. The screen sends answers with `app.flow.gate.answer({ intent: "roll" })`.
255
+
256
+ ## The game shell
257
+
258
+ A game is a folder: `index.ts` (`export default defineGameApp({ ... })`), `config.ts` (plain data, `satisfies GameConfig`), `core/`, `shared/`, `features/`, `plugins/`, `generated/`, `tests/` with `tests/scenarios/<name>.ts`. No `web/`, no `native.ts`, no `platform-bridge.ts`, no `bunfig.toml`, no `createApp` call. `.moku/` holds what `moku-game` writes; the game's `.gitignore` lists it. Scripts of the game's `package.json`: `"dev": "moku-game dev"`, `"build": "moku-game build"`, `"native:ios-sim": "moku-game native build ios --simulator"`.
259
+
260
+ - `defineGameApp(definition)` returns the game, `{ headless(seams?), screen(seams?) }`, and creates no app. Every type comes from the object; no generic at the call site. Keys: `flow` (required), `safeNode` (default: the start of the main flow), `player` and `session` (required), `referenceLong` (1920), `seed` (42), `shared` (composed first among the features), `features`, `plugins` (their APIs land on `screen().app`), `headless: { features?, plugins? }`, `pluginConfigs`.
261
+ - `pluginConfigs` takes every plugin of the screen app but the keys the shell writes. A seam-owned key is a compile error: `model.playerProvider`, `initialPlayer`, `initialSession`, `seed` (the game keeps `schemaVersion`, `migrations`); all of `clock` and `platform`; `flow.mainFlow`, `safeNode`; `renderer.mount`; `assets.manifest`, `io`; `audio.context`.
262
+ - `game.headless(seams?)` composes the core plugins, `logicOnly` of `headless.features`, then `headless.plugins`. `game.screen(seams?)` composes the screen set, `audio`, `effects`, `platform`, then `shared`, the features and the game's plugins. Both return `{ app, clock, provider }`, the app not started; each call is fresh. Defaults: `fakeClock(startMoment)` (`startMoment` is `1_000_000`), a fresh `memory()` save, the seed of the definition or 42.
263
+ - Seams of both: `seed`, `clock`, `provider`, `player`, `session`. Screen only: `manifest` (a URL or the parsed file), `io`, `platform`, `keepAwake` (false), `audio: { context?, journal? }`, `renderer: { mount?, loadPixi?, preference? }`. Without a mount the renderer is inert, so `game.screen()` runs in plain Bun.
264
+ - `defineGameApp` throws `[game] defineGameApp needs flow.` and `[game] defineGameApp: features[2] is not a feature.` for what TypeScript cannot stop.
265
+ - A game's exit plugin calls `ctx.require(platformPlugin).exit()` itself.
266
+
267
+ ```ts
268
+ // a test of the dice game
269
+ import { startMoment } from "@moku-labs/game/app";
270
+ import { createHeadless } from "@moku-labs/game/testing";
271
+ import game from "./index";
272
+
273
+ const { app, clock, provider } = game.headless();
274
+ const run = await createHeadless(app);
275
+
276
+ run.state().path; // "home"
277
+ clock.now() === startMoment; // true
278
+ provider.calls.map(call => call.method); // ["load", "commit"]: a new player
279
+ await run.walk([{ at: "home", intent: "roll" }, { at: "home", intent: "roll" }]);
280
+ app.model.store.snapshot().session; // { rolls: 2 }
281
+ await run.stop();
239
282
  ```
240
283
 
241
- The game calls `flow.run()` itself, once, from `onStart`. Do not await it there: it resolves only when the app stops. The screen sends answers with `app.flow.gate.answer({ intent: "roll" })`.
284
+ `config.ts` keys and defaults: `page.title` (required, never empty), `page.lang` (`"en"`), `page.background` (`"#000000"`: the page behind the canvas, the `theme-color` meta and the native window; the canvas clear colour stays `pluginConfigs.renderer.background`), `page.orientation` (`"portrait"`, or `"landscape"`, `"any"`: the native build locks it), `page.icons: { favicon?, appleTouch? }` (files relative to the game, `<link>` tags), `page.head` (raw tags, verbatim), `native: { name, identifier, icon?, targets? }` (absent: no native build), `system` (`[]`; names `lifecycle`, `back`, `haptics`, `keepAwake`, `store`), `save` (`"memory"`, or `"local"`, `"store"`), `assets.layers` (`{}`; `{ shared: "ui" }` becomes `--layer shared=ui`). A refused value throws, for example `[game] config.save is "disk".` or `[game] config.system names "tray", which the game shell does not wire.`
285
+
286
+ ```ts
287
+ // config.ts of a game in a native shell
288
+ import type { GameConfig } from "@moku-labs/game/app";
289
+
290
+ export default {
291
+ page: { title: "Лесной городок", lang: "ru", background: "#10161d", orientation: "portrait", icons: { favicon: "assets/icon.png" } },
292
+ native: { name: "Лесной городок", identifier: "com.mokulabs.timber", icon: "assets/icon.png" },
293
+ system: ["lifecycle", "back", "haptics", "keepAwake"],
294
+ save: "memory",
295
+ assets: { layers: { shared: "ui" } }
296
+ } satisfies GameConfig;
297
+ ```
298
+
299
+ The page. `moku-game dev` and `build` write a `main.ts` that calls `startPage(game, config, { scenarios?, system?, agents?, devModules? })`. In order it resolves the config; picks the start; builds the system shell when the page has one and picks the save; makes `game.screen()` with the canvas on `#game`, `manifest.json` next to the page, the device clock and the shell's platform; sets `globalThis.game` (the app), `globalThis.system` (the system app or `undefined`) and `globalThis.doors` (`read`, `watch`, `sources`; `run`, `commands` in dev); follows reduced motion; starts the shell, the app, and runs the graph (a stop is logged `[game] The graph stopped.`); in dev starts the agents with `{ app, name, modules }`.
300
+ - `?player=<name>` starts from the scenario `tests/scenarios/<name>.ts` on a fresh memory save, never on the stored one. An unknown name logs `[game] No scenario "<name>".` and starts from the starting player. A scenario is `Scenario<Player>`: `(now) => ({ player, session? })`, its default export.
301
+ - `?renderer=webgl` asks Pixi for WebGL.
302
+ - Save kinds: `"memory"` lives for the page only; `"local"` is `localStorage` under `<native.identifier>:save` or `moku-game:save`, and falls back to memory with a warning where storage is refused; `"store"` is the system store (idb on the web, the Tauri store native), needs the shell, rejects a load it cannot read instead of starting a new player.
303
+ - System: the generated `main.ts` imports `systemShell` of `@moku-labs/game/app/system` only when `system` is not empty or `save` is `"store"`, so a web-only page bundles no system code. The shell loads each named `@moku-labs/system` plugin with its own `import()`. Not installed: `[game] config.system needs @moku-labs/system.` with `bun add @moku-labs/system@^0.3.1`.
304
+
305
+ ```ts
306
+ // .moku/main.ts of the engine's mini game, written by moku-game dev
307
+ // Written by moku-game dev. Do not edit.
308
+ import "./dev.ts";
309
+ import { startPage } from "@moku-labs/game/app/page";
310
+ import game from "../index.ts";
311
+ import config from "../config.ts";
312
+ import scenario0 from "../tests/scenarios/ready.ts";
313
+
314
+ await startPage(game, config, {
315
+ scenarios: { "ready": scenario0 }
316
+ });
317
+ ```
318
+
319
+ Commands of the bin `moku-game` (bun). Every command takes `--root <dir>` (default `.`), `--preload <path>` and `--serve-plugin <path>` (both repeat), before or after the command word. Exit 0 on success, else 1 with a `[game] …` line.
320
+ - `moku-game dev [--port 3000] [--packed]`: writes `.moku/index.html`, `dev.ts`, `main.ts`, `bunfig.toml` (the hot plugin first, `__MOKU_GAME_DEV__` defined `true`), runs Bun again under that bunfig in the game folder and serves `/`. It prints `› <folder>: dev server, raw assets. Ctrl+C stops it.` and then the bound URL alone on its own line, `http://localhost:3000/`. `--port 0` takes a free port. `--packed` serves `dist/assets`. A new or removed file in `tests/scenarios/` rewrites `main.ts`. A taken port: `[game] dev: port 3000 is in use.` with `Pass --port 0 for a free port.`
321
+ - `moku-game build [--out dist/web]`: packs the assets, bundles the page minified with `__MOKU_GAME_DEV__` defined `false` (no scenario, no agent, no `.dev` module, no `/control`), every link `./`, and copies the pack beside it with `manifest.json` next to `index.html`. Runs from any http sub-path and from Tauri, not from `file://`.
322
+ - `moku-game native build <target> [--simulator]`, `native dev <target>`, `native doctor [<target>]`, `native clean [<target>]`: one verb of `@moku-labs/native` (a dev dependency, `^0.3.2`) over `config.ts`. App name, identifier and icon from `native`, orientation and background colour from `page`; the web build is `moku-game build`, the dev server `moku-game dev --port 5173`; system rows for `back`, `haptics`, `store`; the Tauri project in `.moku/tauri`, the apps in `dist-native`. `[game] config.ts has no native section.` without `native`.
323
+ - `moku-game keys [--check]`: `generated/assets.ts`, the compiled strings and `manifest.json`. `moku-game pack [--no-cache]`: `dist/assets`. Both pass the layers of `config.ts`.
324
+ - `preparePage(root, { agents?, preload?, servePlugins? })` of `@moku-labs/game/cli` writes the dev page of `moku-game dev` and resolves `{ html, bunfig }`, both absolute, for a server the caller starts: `bun --config=<bunfig>`. With agents the page also imports the game's `**/*.dev.ts` and hands them to the agents. The editor calls it: `await preparePage("games/timber", { agents: ["@moku-labs/editor/agent/page"] })`.
242
325
 
243
326
  ## More patterns
244
327
 
@@ -396,6 +479,7 @@ The graph. Modules `runner`, `gate`, `inbox`, `fx`, `features`. Runner methods s
396
479
 
397
480
  `world`, `renderer`, `input`, `assets`, `scenes`. Each has a README at `src/plugins/<name>/README.md`; the root README lists their APIs, events and config. Rendering: WebGPU preferred, with Pixi's WebGL fallback.
398
481
  - `app.scenes`: `current()` the mounted scene id, `expect(id)` the scene the next rest node without its own `scene` mounts (the restore door calls it; an unknown id throws).
482
+ - `resource(name, defaults)` clones plain JSON defaults into every world. `resource(name, () => value)` calls the factory once per world on first read, so the value may hold a `Map` or a function: `const Looks = resource("boardLooks", () => ({ shown: new Map<World.Entity, { look: string }>() }))`. A system keeps its own state there; `snapshot()` leaves a value that is not JSON out.
399
483
  - `app.world.projection.replace(spec)` swaps the spec of a registered projection; a mounted one runs its new `view` on the next frame. An unknown name throws. The caller is the dev hot swap.
400
484
 
401
485
  ### Interface plugins (V3, built)
@@ -407,7 +491,7 @@ A screen is a projection whose `view` returns JSX. The game's `tsconfig.json` se
407
491
  - `defineComponent(name, { local?, outcomes?, view(props, local) })`. A component with `outcomes` is a popup: `await fx(popup(Component, props))` opens the gate for its outcomes and resolves with `{ intent, payload }` of the pressed button. A button with `local` writes into the nearest component instance; it never reaches the graph.
408
492
  - `tr(key, params?)` returns a frozen `Message` `{ key, params }`; no locale is read at the call site. Strings live in `features/<feature>/strings/<locale>.json` as ICU MessageFormat 1 (`{n, plural, one {# order} other {# orders}}`, `select`, `selectordinal`, `number`, `date`, `time`; no skeletons, no `currency`). `bun run assets:keys` compiles them. `app.i18n`: `locale()`, `setLocale(locale)` (async, emits `i18n:locale-changed`), `format(message, locale?)` returns `Part[]`, `plain(message)` returns a string, `has(key)`, `locales()`, `replace(locale, messages)` (dev hot swap of a generated strings file). Config: `locale: "en"`, `fallback: "en"`, `locales: {}`.
409
493
  - Text: `Text` component, `label({ text, style, at, anchor? })` for board text in a projection view, `defineTextStyles({ "hud.digits": { font, size, fill, bold?, italic?, stroke?, strokeWidth?, letterSpacing?, align?, wrap?, digits? } })` under the feature's `textStyles` key. Tags inside a string: `<b>`, `<i>`, `<color=#rrggbb>`, `<icon=key>`, `\<`, `\n`. Built-in styles `body` and `digits` from config `fonts: { body: "ui.font-body", digits: "ui.font-digits" }`. `app.text.measure(content, style)` gives the same numbers in a browser and in Bun. `app.text.replaceStyles(styles)` is the dev hot swap of a saved `defineTextStyles` value.
410
- - Animation is data: `defineAnimation(id, { slots: { name: type<Anim.Target>() }, build: (slots, { at }) => Step })`. Steps: `sequence(...)`, `parallel(...)`, `stagger(items, ms, item => Step)`, `tween(target, Component, to, { ms, ease?, delayMs?, additive? })`, `set(target, Component, patch)`, `wait(ms)`, `mark(name)`, `frames(target, { keys, fps, loop? })`, `sfx(key, { bus? })`, `haptic(kind)`, `use(animation, slots, tools?)`, `spawn` (a temporary entity, flying coins, that the timeline despawns when it ends; see the anim README). `Frames({ keys, fps, loop, playing })` is a component that walks a sprite through its textures for the entity's whole life. A target is `{ projection, key }` or an entity. A node plays it with `await fx(play(animation, slots))`; in fast mode it finishes at once. `defineMotion({ states, transition?, on: { enter?, exit?, change? } })` builds the enter, exit and change hooks for a projection or the `motion` prop of a tag. Events `anim:mark` `{ animation, mark }` and `anim:finished` `{ animation }`. `app.anim.replace(definition)` is the dev hot swap: the next `play` builds the new tree. Config `maxTracks: 2000`.
494
+ - Animation is data: `defineAnimation(id, { slots: { name: type<Anim.Target>() }, build: (slots, { at }) => Step })`. Steps: `sequence(...)`, `parallel(...)`, `stagger(items, ms, item => Step)`, `tween(target, Component, to, { ms, ease?, delayMs?, additive? })`, `set(target, Component, patch)`, `wait(ms)`, `mark(name)`, `frames(target, { keys, fps, loop? })`, `sfx(key, { bus? })`, `haptic(kind)`, `use(animation, slots, tools?)`, `spawn` (a temporary entity, flying coins, that the timeline despawns when it ends; see the anim README). `Frames({ keys, fps, loop, playing })` is a component that walks a sprite through its textures for the entity's whole life. A target is `{ projection, key }` or an entity. A node plays it with `await fx(play(animation, slots))`; in fast mode it finishes at once. `defineMotion({ states, transition?, on: { enter?, exit?, change? } })` builds the enter, exit and change hooks for a projection or the `motion` prop of a tag. Events `anim:mark` `{ animation, mark }` and `anim:finished` `{ animation }`. `app.anim.replace(definition)` is the dev hot swap: the next `play` builds the new tree. A system cannot `require` a plugin, so it plays through the resource `AnimPlayer`: `res(AnimPlayer).play(animation, slots)` returns the same handle as `app.anim.play`, and the registered animation of `animation.id` wins, so a hot swap reaches the system. Play on a change, never every frame; from phase `input` the tree moves in `animate` of the same frame. Before `anim` starts it throws `[game] AnimPlayer is not ready: anim has not started.` Config `maxTracks: 2000`.
411
495
  - Audio is opt-in: `plugins: [...screen, audioPlugin]`. `fx(sfx("orders.complete"))` resolves when the sound started. `await fx(music("board.theme"))` cross-fades; `music(null)` fades out; a scene's `music` key switches the track on `scenes:changed`. Buses `master`, `music`, `sfx`. Volumes come from the committed player: config `volumes: player => (player as Player).settings.audio`, applied on every `model:committed`. `app.audio`: `setVolume(bus, value)`, `volume(bus)`, `mute(bus, on)`, `muted(bus)`, `unlocked()`, `journal()` (the last started sounds, empty unless config `journal` is above 0). Config: `buses: { master: 1, music: 0.6, sfx: 1 }`, `musicFadeMs: 600`, `volumes`, `context`, `journal: 0`, `music: "decode" | "stream"` default `"decode"` (gapless buffer; `"stream"` is an `<audio>` element, less memory, a gap at every loop), `session: "ambient" | "playback" | "auto"` default `"ambient"` (iOS only: ambient mixes with other apps and obeys the silent switch). Without `audio` every `sfx` and `music` effect resolves `undefined` at once, so the same node runs in a game with no sound.
412
496
  - `app.ui`: `tree()` the live screen as plain data (a text node carries `content`, the words it draws after `tr()` and `bind`; a text field carries `value`), `find(key)` the entity of a keyed element, `fill(key, value)` types into the text field with that `key`, `lint()` tap targets under `tapTargetPt` (default `44`), text that overflows in some locale, absolute elements without `reason`. Config: `tapTargetPt: 44`, `breakpoints: { tall: 2, wide: 1.5 }`. `ui` emits only the dev global event `ui:hot-swap`, see "Hot swap".
413
497
 
@@ -484,7 +568,7 @@ Cosmetic rendering extras as data, drawn by `effectsPlugin`: `plugins: [...scree
484
568
 
485
569
  ### platform (V6, Standard, opt-in)
486
570
 
487
- The phone as a provider, last in the array: `plugins: [...screen, effectsPlugin, audioPlugin, platformPlugin]` with `pluginConfigs: { platform: { provider, keepAwake: true } }`. The engine never imports a native package: the game builds the provider in its own layer, usually from `@moku-labs/system`. The full reference is `src/plugins/platform/README.md`.
571
+ The phone as a provider, last in the array: `plugins: [...screen, effectsPlugin, audioPlugin, platformPlugin]` with `pluginConfigs: { platform: { provider, keepAwake: true } }`. `game.screen()` composes it and takes the provider as its `platform` seam. A game imports no native package: the engine page builds the provider from the `system` list of `config.ts` through `@moku-labs/game/app/system`, and turns keep-awake on when the list names `keepAwake`. The full reference is `src/plugins/platform/README.md`.
488
572
  - `PlatformProvider` has six members: `onPause(fn)`, `onResume(fn)`, `onBack(fn)` (each returns its remover), `haptic(kind)`, `keepAwake(on)`, `exit()`. Pause and resume push and pop the `"background"` reason of `lifecycle`. A provider method that throws is logged, never thrown into the frame.
489
573
  - Back chain, `app.platform.back(): BackResult`. First an Escape press: a popup with an `escape` button closes, `"popup"`. Then `flow.gate.answer({ intent: "back" })` when the resting node lists `back`, `"intent"`. Then `provider.exit()`, `"exit"`. Without a provider `"none"`.
490
574
  - Haptics: `await fx(haptic("success"))` in a node, or `haptic("light")` as a timeline step. The kinds are `HAPTIC_KINDS`: `light`, `medium`, `heavy`, `selection`, `success`, `warning`, `error`. A fast walk is silent.
@@ -503,9 +587,9 @@ Two subpaths, neither on the root (V4). An editor, MCP tools and e2e scripts rea
503
587
 
504
588
  - `@moku-labs/game/inspect`: `read(app, source, input?)` reads once; `watch(app, source, input, fn)` reads on the `signals` phase when the source changes and returns the stop function; `defineSource`; the frozen catalogue `sources`; the types `Source`, `InputSchema`, `InputOf`. Every source only reads, so the door is safe in a production build. Keys of `sources`: `graph`, `position`, `history`, `tainted`, `cheats`, `model`, `entities`, `projections`, `ui`, `locate`, `render`, `sounds`, `audioMuted`, `effects`, `assets`, `log`, `explain`, `diff`, `schema`, `at`; their ids are `game.<key>`. `locate` takes `{ key }` (a ui element) or `{ target }` (a view by projection key) and answers its box in CSS px, the numbers Playwright clicks. `at` takes `{ x, y }` in CSS px and lists the views under that point, topmost first. `explain` takes `{ entity }` and answers that entity in full, `undefined` for a stale id. `diff` takes `{ from, to }` frames and answers what changed per entity; it throws in a production build and for a frame outside the last 120. `schema` lists every component and tag type the world met, with the JSON kind of each field. `audioMuted` answers whether the master bus is muted. `sounds` throws `[game] The source game.sounds needs audioPlugin.`, `audioMuted` throws `[game] The source game.audioMuted needs audioPlugin.` and `effects` throws `[game] The source game.effects needs effectsPlugin.` when that plugin is not installed; the editor lists such a source as not installed.
505
589
  - `@moku-labs/game/control`: `run(app, command, input?)` resolves `{ value, state }`, `state` being `{ path, frame, tainted }`; `defineCommand`; `controlRefused`; the frozen catalogue `commands`; the types `Command`, `Ran`. Keys of `commands`: `answer`, `tap`, `drag`, `key`, `fill`, `walk`, `bookmark`, `restore`, `step`, `pause`, `resume`, `capture`, `debug`, `reducedMotion`, `timeScale`, `mute`, `trace`. `capture` resolves `{ png }`, and `{ png, legend }` with `legend: true`; `undefined` while the renderer is inert. `timeScale` takes `{ scale }`; 0 freezes game time while the frames keep running. `mute` takes `{ muted }`, mutes the master bus and answers the flag. Without `audioPlugin` it throws `[game] The command game.mute needs audioPlugin.` `trace` takes `{ path }`, a list of views by projection key, and answers whether the gate took the answer.
506
- - Commands run in dev builds only. Only `__MOKU_GAME_DEV__ === true` turns the dev build on: a bundler `define` (`true` in dev, `false` in production), `globalThis.__MOKU_GAME_DEV__ = true` in the dev entry, or `vi.stubGlobal("__MOKU_GAME_DEV__", true)` in a test. Otherwise `run` throws `[game] Control commands run in dev builds only.`
590
+ - Commands run in dev builds only. Only `__MOKU_GAME_DEV__ === true` turns the dev build on: `moku-game dev` defines it `true` and sets the global in `.moku/dev.ts`, `moku-game build` defines it `false`; a game without the shell uses a bundler `define`; a test uses `vi.stubGlobal("__MOKU_GAME_DEV__", true)`. Otherwise `run` throws `[game] Control commands run in dev builds only.`
507
591
  - A command's effect is `read`, `route`, `cosmetic`, `cheat` or `raw`. A `cheat` or `raw` command taints the session and is journaled; read it with `sources.tainted` and `sources.cheats`. Of the base commands only `restore` is `raw`. `bookmark` answers `flow.bookmark()` plus `scene`, the mounted scene; `restore` calls `scenes.expect(scene)` before `flow.restore`, so a bookmark at a popup restores the scene under it.
508
- - A game's own: `defineSource({ id, title, input, changes, read })` with `changes` `"frame"`, `"commit"` or `"edge"`, and `defineCommand({ id, title, input, effect, run })`. The id is camelCase words joined by dots, at least two: `dice.rolls`. An input schema maps field names to `"string"`, `"number"`, `"boolean"` or `"json"`; a trailing `?` makes one optional. A command body starts with `if (typeof __MOKU_GAME_DEV__ === "undefined" || !__MOKU_GAME_DEV__) throw controlRefused();`. Keep them in `.dev` modules that only the dev entry and the tests import.
592
+ - A game's own: `defineSource({ id, title, input, changes, read })` with `changes` `"frame"`, `"commit"` or `"edge"`, and `defineCommand({ id, title, input, effect, run })`. The id is camelCase words joined by dots, at least two: `dice.rolls`. An input schema maps field names to `"string"`, `"number"`, `"boolean"` or `"json"`; a trailing `?` makes one optional. A command body starts with `if (typeof __MOKU_GAME_DEV__ === "undefined" || !__MOKU_GAME_DEV__) throw controlRefused();`. Keep them in `.dev` modules that only the dev page and the tests import: the editor's dev page (`preparePage` with agents) imports every `**/*.dev.ts`, `moku-game build` none.
509
593
 
510
594
  ## Project index
511
595
 
@@ -521,30 +605,31 @@ stop();
521
605
  project.close();
522
606
  ```
523
607
 
524
- - `openProject({ root, manifest?, debounceMs? })` resolves `ProjectApi`: `index` (frozen, replaced per batch), `find(key)` (a `Found[]`: the anchor plus `line`, `range` `[startLine, startCol, endLine, endCol]` 1-based with an exclusive end column, `hash`, and `broken: true` when the file does not parse now), `watch(onIndex)` (one call per batch that changed bytes, with `(index, change)`; returns the stop function), `changed(path)` (re-index one root-relative path now, no watch call), `close()`. Without `typescript` it rejects with `[game] The project index needs the "typescript" package.`
608
+ - `openProject({ root, manifest?, tsconfig?, debounceMs? })` resolves `ProjectApi`: `index` (frozen, replaced per batch), `find(key)` (a `Found[]`: the anchor plus `line`, `range` `[startLine, startCol, endLine, endCol]` 1-based with an exclusive end column, `hash`, and `broken: true` when the file does not parse now), `watch(onIndex)` (one call per batch that changed bytes, with `(index, change)`; returns the stop function), `changed(path)` (re-index one root-relative path now, no watch call), `close()`. Without `typescript` it rejects with `[game] The project index needs the "typescript" package.`
609
+ - Aliases: the index follows imports through the `compilerOptions.paths` of `tsconfig.json` at the root (or the root-relative `tsconfig`), `extends` included, as TypeScript does, so `@core/kit`, `@shared` and `@features/home` reach their files. `index.tsconfig` names that file when it holds `paths`. `@moku-labs/game` stays a package. A tsconfig edit rebuilds the aliases in the next watch batch.
525
610
  - Keys: `flow:<id>`, `node:<flow>/<node>` (every entry of a `nodes` table: node, sub-flow, slot), `feature:<id>`, `scene:<id>`, `projection:<name>`, `emitter:<id>`, `textStyle:<key>`, `style:<path>#<binding>` (a module-level `const` of `defineStyle`), `style:<path>#<function>` (every `defineStyle` call inside a module-level function or function-valued const; `find` answers each call) and `style:<path>#<function>.<property>` (a call under an object property there; a style in a class, an object const or at module scope stays unresolved), `component:<Name>` (in a `.tsx` file a module-level upper-case `function Name` or function-valued `const Name`; anywhere `defineComponent("Name", …)`, bound to its const; `uses` are the files that render `<binding>`), `jsx:<key>`. Definers are recognised by binding from the kit's `defineGame()` destructure, renamed imports too. A non-literal id goes to `index.unresolved` with the reason, never guessed.
526
611
  - JSX keys are patterns: literal text stays, same-file consts and functions are followed, a key-carrying prop (`id`, or a name that ends in `Key`) becomes a hole of its name, `props.id` reads `{id}` and `props.amountKey` reads `{amountKey}`, any other hole `*`. `` key={`${id}Picture`} `` reads `jsx:card*Picture` (stem `card`); `id="settingsBoard"` on `<Signboard>` reads `jsx:settingsBoard` (kind `idProp`, component `Signboard`, prop `id`); `amountKey="giftReward"` on `<Amount>` reads `jsx:giftReward` (prop `amountKey`). `find("jsx:settingsBoardClose")`, a key `game.locate` reports, answers the `{id}Close` pattern of the kit and the `id=` prop that fills it; a hole takes only the props of its own name.
527
612
  - `ProjectChange` is `{ revision, files, moved: [{ key, from, to }], removed }`. A broken file keeps its last good keys and gets `files[path].state === "broken"`. Paths are root-relative POSIX; a path that leaves the root is refused.
528
- - CLI: `moku-game-index --root <dir> where <key>` prints `<path>:<line>` per place (exit 1 for an unknown key); `--json` prints the index; `--check` exits 1 on a broken file or a key in conflict and lists the unresolved items as info. `--root` is required, `--manifest <path>` is root-relative.
613
+ - CLI: `moku-game-index --root <dir> where <key>` prints `<path>:<line>` per place (exit 1 for an unknown key); `--json` prints the index; `--check` exits 1 on a broken file or a key in conflict and lists the unresolved items as info. With a tsconfig, `--check` also prints the aliases line, `aliases: tsconfig.json, 8 patterns`, or `aliases: none (no tsconfig paths)`. `--root` is required, `--manifest <path>` and `--tsconfig <path>` are root-relative.
529
614
 
530
615
  ## Hot swap
531
616
 
532
- Dev only. A save of a view file swaps the module in the running page: same state, same node, no reload. The game's `bunfig.toml` lists the plugin; the dev server runs in the game root, and a production `Bun.build` never loads it:
617
+ Dev only. A save of a view file swaps the module in the running page: same state, same node, no reload. `moku-game dev` lists the plugin first in its `.moku/bunfig.toml` and runs the server in the game root, so a game sets nothing; `moku-game build` never loads it. A game with a server of its own lists it in its own `bunfig.toml`:
533
618
 
534
619
  ```toml
535
620
  [serve.static]
536
621
  plugins = ["@moku-labs/game/hot"]
537
622
  ```
538
623
 
539
- - View files swap: `.tsx`, `styles.ts`, `view.ts`, `animations.ts`, `effects.ts`, `generated/strings.<locale>.ts`. Styles, components, function components, projections of a registered name, animations, text styles, emitters and strings. After a strings JSON edit run `bun run assets:keys`; its output is what swaps.
540
- - Logic files reload the page: `rules/`, `nodes/`, `flows/`, `state.ts`, `tables.ts`, `kit.ts`, a feature `index.ts`. A view file that exports a scene, system, ECS component, filter, flow, node, feature, plugin, a new projection or a new animation is refused: `ui` logs `ui:hot-refused` and throws, and Bun reloads the page.
624
+ - View files swap: `.tsx`, `styles.ts`, `view.ts`, `animations.ts`, `effects.ts`, `generated/strings.<locale>.ts`. On the layered layout also any `.ts` directly in a kind folder: `styles/`, `motion/`, `effects/`, `views/`, `world/projections/`, `world/layout/`, in a feature or in `shared/`. Styles, components, function components, projections of a registered name, animations, text styles, emitters and strings. After a strings JSON edit run `bun run assets:keys`; its output is what swaps.
625
+ - Logic files reload the page: `rules/`, `nodes/`, `flows/`, `state.ts`, `tables.ts`, `kit.ts`, a feature `index.ts`, `world/components/`, `world/systems/`. A view file that exports a scene, system, ECS component, filter, flow, node, feature, plugin, a new projection or a new animation is refused: `ui` logs `ui:hot-refused` and throws, and Bun reloads the page.
541
626
  - A swap logs `ui:hot-swap` and emits the global event `ui:hot-swap` `{ file, module }`.
542
- - Another layout: `export default hot({ include, exclude })` from the game's own `hot.ts`, listed as `plugins = ["./hot.ts"]`.
627
+ - Another layout, on a server of the game's own: `export default hot({ include, exclude })` from the game's `hot.ts`, listed as `plugins = ["./hot.ts"]`.
543
628
  - Keep a view helper in the module of the projection that uses it. A function another module stored into an object at load time keeps the old version.
544
629
 
545
630
  ## Fonts
546
631
 
547
- The package ships one MSDF body font: `fonts/font-body.fnt` and `fonts/font-body.png` (Pangolin Regular) with `fonts/LICENSE.txt` (SIL Open Font License 1.1). Copy it into the game; a path in `node_modules` is not an asset key, the scanner reads keys only from `features/<feature>/assets/`. The licence goes next to `assets/`, not inside it.
632
+ The package ships one MSDF body font: `fonts/font-body.fnt` and `fonts/font-body.png` (Pangolin Regular) with `fonts/LICENSE.txt` (SIL Open Font License 1.1). Copy it into the game; a path in `node_modules` is not an asset key, the scanner reads keys only from `features/<feature>/assets/` and from the layers of `--layer`. The licence goes next to `assets/`, not inside it.
548
633
 
549
634
  ```bash
550
635
  mkdir -p src/features/ui/assets
@@ -557,7 +642,7 @@ The scanner keys it `ui.font-body`, which is the `text` config default `fonts.bo
557
642
 
558
643
  ## Testing entry
559
644
 
560
- Exports of `@moku-labs/game/testing`: `createHeadless`, `runRepro`, `stepFrames`, `fakeClock`, `memory`, `saveOf`. The visual tests `defineVisualTest`, `runVisualTests`, `parseVisualArgv` come from `@moku-labs/game/visual`, node and bun only (see `docs/testing.md` in the engine repository).
645
+ Exports of `@moku-labs/game/testing`: `createHeadless`, `runRepro`, `stepFrames`, `fakeClock`, `memory`, `saveOf`, `isolate`, `stub`. The visual tests `defineVisualTest`, `runVisualTests`, `parseVisualArgv` come from `@moku-labs/game/visual`, node and bun only (see `docs/testing.md` in the engine repository).
561
646
 
562
647
  - `createHeadless(app): Promise<HeadlessGame>`. Pass an app that is not started. It sets mode `"fast"`, awaits `app.start()`, starts `flow.run()` unless the app's `onStart` already did, and resolves when the loop rests for the first time: `game.state().path` already names the first rest node. A fatal error of `run()` rejects the call.
563
648
  - `HeadlessGame`: `walk(route): Promise<FlowState>`, `answer(answer): boolean`, `state()`, `history()`, `stop()`. `walk` and `stop` throw a fatal error of the loop again when the headless runner owns `run()`.
@@ -571,36 +656,64 @@ Exports of `@moku-labs/game/testing`: `createHeadless`, `runRepro`, `stepFrames`
571
656
  import type { Flow } from "@moku-labs/game";
572
657
  import { createHeadless } from "@moku-labs/game/testing";
573
658
  import { expect, it } from "vitest";
574
- import { createGame } from "./game";
659
+ import game from "./index";
575
660
 
576
661
  const rollOnce: Flow.RouteStep = { at: "home", intent: "roll" };
577
662
 
578
663
  it("plays two rolls without a screen", async () => {
579
- const app = createGame(42);
580
- const game = await createHeadless(app);
664
+ const { app } = game.headless({ seed: 42 });
665
+ const run = await createHeadless(app);
581
666
 
582
- const state = await game.walk([rollOnce, rollOnce]);
667
+ const state = await run.walk([rollOnce, rollOnce]);
583
668
 
584
669
  expect(state.path).toBe("home");
585
670
  expect(app.model.store.snapshot().session).toEqual({ rolls: 2 });
586
671
 
587
- await game.stop();
672
+ await run.stop();
588
673
  });
589
674
  ```
590
675
 
591
- For timers, pass `clock: { source: fakeClock(1_000_000) }` in `pluginConfigs`, call `clock.advance(ms)`, then yield the microtask queue before reading the state. For a fixed game, pass `model: { seed: 42 }`. In tests, compose features as `plugins: [feature.logicOnly]`.
676
+ Isolated feature tests play one feature without the rest of the game:
677
+
678
+ - `isolate(feature, options): (seams?) => App`. It composes `options.shared` and the feature as `logicOnly`, then `options.plugins`. The feature's `flow` becomes the node `as` (default `flow.id`) of a harness main flow `isolated`, so paths read as in the game. Options: `flow`, `player` (both required), `as?`, `shared?`, `stubs?`, `plugins?`, `session?` (`{}`), `seed?` (`1`). The factory takes the seams of one app, `player`, `session`, `seed`, `clock`; they win over the options. Pass the app to `createHeadless`.
679
+ - `stub(outcome, payload?)`: `stub("closed")` is `{ kind: "stub", outcome: "closed" }`. `stubs: { <node name>: stub(...) }` replaces that node or sub-flow of `flow`; it ends at once with the outcome.
680
+ - A node that is not stubbed runs for real, the flow of another feature too.
681
+ - A stub is a sub-flow, so a route may still substitute it for one visit: `{ at: "board/energy", result: { outcome: "watch" } }`.
682
+ - A checkpoint compacts `history()`: assert on `state()` or on `flow:edge` events.
683
+ - Every exit of a flow with outcomes lands on the rest node `exited`; `{ at: "exited", intent: "again" }` enters the flow once more.
684
+ - A wrong stub key or outcome does not compile, and throws at run time: `[game] isolate: no node "enrgy" in flow "board".`
685
+
686
+ ```ts
687
+ import { createHeadless, isolate, stub } from "@moku-labs/game/testing";
688
+ import { homeFeature } from "./features/home";
689
+ import { mainFlow } from "./flows/main";
690
+
691
+ // The mini game: home opens the popup info, here stubbed with its outcome ok.
692
+ const homeOnly = isolate(homeFeature, { flow: mainFlow, stubs: { info: stub("ok") }, player: { count: 0 } });
693
+ const game = await createHeadless(homeOnly());
694
+
695
+ game.state().path; // "main/home": the harness holds the flow under its id
696
+ await game.walk([{ at: "main/home", intent: "info" }]); // the stub ends with ok at once
697
+ game.state().path; // "main/home"
698
+ await game.stop();
699
+ ```
700
+
701
+ For timers, take the clock of `game.headless()`, a `fakeClock(startMoment)`: call `clock.advance(ms)`, then yield the microtask queue before reading the state. The seed is the definition's, else 42; `game.headless({ seed: 7 })` pins another. The headless app composes `logicOnly` of `headless.features`. An app made with `createApp` by hand passes `clock: { source: fakeClock(1_000_000) }`, `model: { seed: 42 }` and `plugins: [feature.logicOnly]` itself.
592
702
 
593
703
  ## File layout of a game
594
704
 
595
705
  ```
596
- src/
597
- state.ts Player and Session types, starting state, the defineGame call
598
- tables.ts balance data as plain objects
599
- rules/ pure functions, no engine import, no clock, no random
600
- nodes/ one file per node: boot.ts, home.ts, await-intent.ts
601
- flows/ main.ts, one file per sub-flow, one file per feature
602
- game.ts the single createApp call
603
- tests/ headless scenarios with createHeadless and walk
706
+ index.ts export default defineGameApp({ ... }): the game as one data object
707
+ config.ts export default { page, native?, system?, save?, assets? } satisfies GameConfig
708
+ core/ the kit (the defineGame call) and its types
709
+ shared/ the shared layer
710
+ features/<f>/ one feature: flow/, rules/, views/, styles/, motion/, world/, assets/, strings/
711
+ plugins/ the game's own plugins
712
+ generated/ what moku-game keys writes
713
+ tests/ the tests; tests/scenarios/<name>.ts are the saves of ?player=<name>
714
+ .moku/ what moku-game writes, ignored by git
604
715
  ```
605
716
 
606
- This is the layout of the worked example, the merge game in moku-labs/demos (https://github.com/moku-labs/demos): `kit.ts`, `state.ts`, `tables.ts`, `rules/`, `nodes/`, `flows/`, `game.ts`, written on the public API only, with its e2e and visual tests. The engine repository keeps a smaller fixture, `tests/fixtures/mini-game/`, which is not in the npm package. A game with a screen adds `features/<name>/view.tsx` (projections and components), `features/<name>/animations.ts`, `features/<name>/strings/<locale>.json` and the `generated/` folder `bun run assets:keys` writes. Each plugin has a README: `src/plugins/<name>/README.md` for `time`, `lifecycle`, `model`, `clock`, `flow`, `world`, `renderer`, `input`, `assets`, `scenes`, `anim`, `i18n`, `text`, `ui`, `audio`, `effects`, `platform`. Moku Core specification: https://github.com/moku-labs/core/tree/main/specification
717
+ Rules are pure functions in `rules/`: no engine import, no clock, no random. The tsconfig aliases are `@core/*`, `@shared`, `@features/*`. The lint rules `layer-imports`, `feature-door` and `test-suffix`, the hot kind folders, the `assets.layers` of `config.ts` and the project index follow the layers `core/`, `shared/`, `features/` and `plugins/`.
718
+
719
+ The worked example is the merge game in moku-labs/demos (https://github.com/moku-labs/demos), written on the public API only, with its e2e and visual tests. The engine repository keeps a smaller fixture, `tests/fixtures/mini-game/` (`index.ts`, `config.ts`, `tests/scenarios/ready.ts`, one rest node, one popup flow, two features), which is not in the npm package. A feature with a screen adds `view.tsx` (projections and components), `animations.ts` and `strings/<locale>.json`. Each plugin has a README: `src/plugins/<name>/README.md` for `time`, `lifecycle`, `model`, `clock`, `flow`, `world`, `renderer`, `input`, `assets`, `scenes`, `anim`, `i18n`, `text`, `ui`, `audio`, `effects`, `platform`. Moku Core specification: https://github.com/moku-labs/core/tree/main/specification
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@moku-labs/game",
3
- "version": "0.8.0",
3
+ "version": "0.10.0",
4
4
  "description": "2D puzzle game engine on TypeScript and PixiJS, built on @moku-labs/core.",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -15,6 +15,22 @@
15
15
  "types": "./dist/testing.d.mts",
16
16
  "default": "./dist/testing.mjs"
17
17
  },
18
+ "./app": {
19
+ "types": "./dist/app.d.mts",
20
+ "default": "./dist/app.mjs"
21
+ },
22
+ "./app/page": {
23
+ "types": "./dist/app/page.d.mts",
24
+ "default": "./dist/app/page.mjs"
25
+ },
26
+ "./app/system": {
27
+ "types": "./dist/app/system.d.mts",
28
+ "default": "./dist/app/system.mjs"
29
+ },
30
+ "./cli": {
31
+ "types": "./dist/cli.d.mts",
32
+ "default": "./dist/cli.mjs"
33
+ },
18
34
  "./visual": {
19
35
  "types": "./dist/visual.d.mts",
20
36
  "default": "./dist/visual.mjs"
@@ -54,6 +70,7 @@
54
70
  "./fonts/*": "./fonts/*"
55
71
  },
56
72
  "bin": {
73
+ "moku-game": "./bin/moku-game.mjs",
57
74
  "moku-game-assets": "./bin/moku-game-assets.mjs",
58
75
  "moku-game-index": "./bin/moku-game-index.mjs"
59
76
  },
@@ -88,7 +105,8 @@
88
105
  "test:integration": "bun --bun vitest run --project integration",
89
106
  "test:coverage": "bun --bun vitest run --project unit --project integration --coverage",
90
107
  "build": "tsdown",
91
- "mini:pack": "bun src/assets.ts --root tests/fixtures/mini-game --keys tests/fixtures/mini-game/generated/assets.ts --pack tests/fixtures/mini-game/dist/assets",
108
+ "mini:pack": "bun tests/fixtures/moku-game.ts pack --root tests/fixtures/mini-game",
109
+ "mini:dev": "bun tests/fixtures/moku-game.ts dev --root tests/fixtures/mini-game",
92
110
  "mini:visual": "bun tests/visual/run.ts",
93
111
  "validate": "publint && attw --pack . --profile esm-only",
94
112
  "release:setup": "moku-release setup",
@@ -103,6 +121,8 @@
103
121
  "peerDependencies": {
104
122
  "@moku-labs/common": "^0.3.4",
105
123
  "@moku-labs/core": "^1.7.1",
124
+ "@moku-labs/native": "^0.3.2",
125
+ "@moku-labs/system": "^0.3.1",
106
126
  "pixi.js": "^8.0.0",
107
127
  "playwright-core": ">=1.50.0",
108
128
  "sharp": "^0.34.0",
@@ -112,6 +132,12 @@
112
132
  "access": "public"
113
133
  },
114
134
  "peerDependenciesMeta": {
135
+ "@moku-labs/native": {
136
+ "optional": true
137
+ },
138
+ "@moku-labs/system": {
139
+ "optional": true
140
+ },
115
141
  "playwright-core": {
116
142
  "optional": true
117
143
  },
@@ -130,6 +156,8 @@
130
156
  "@moku-labs/ci": "1.2.6",
131
157
  "@moku-labs/common": "0.3.4",
132
158
  "@moku-labs/core": "1.7.1",
159
+ "@moku-labs/native": "0.3.2",
160
+ "@moku-labs/system": "0.3.1",
133
161
  "@types/bun": "1.3.14",
134
162
  "@vitest/coverage-istanbul": "4.0.18",
135
163
  "eslint": "9.39.3",