@moku-labs/game 0.14.11 → 0.14.13
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.
- package/README.md +13 -12
- package/dist/app/page.d.mts +8 -3
- package/dist/app/page.mjs +57 -12
- package/dist/app/system.d.mts +5 -3
- package/dist/app/system.mjs +2 -0
- package/dist/app.d.mts +15 -7
- package/dist/app.mjs +131 -27
- package/dist/{assets-BrKSg1O6.mjs → assets-CEMfBogh.mjs} +471 -120
- package/dist/assets.d.mts +30 -13
- package/dist/assets.mjs +1 -1
- package/dist/cli.d.mts +105 -8
- package/dist/cli.mjs +1036 -128
- package/dist/{commands-d5Zlh9hf.d.mts → commands-DAETmK86.d.mts} +43 -7
- package/dist/{config-CdARrrhU.mjs → config-B87aFTW-.mjs} +25 -4
- package/dist/control.d.mts +130 -17
- package/dist/control.mjs +4 -4
- package/dist/{define-BN15lAWb.d.mts → define-BASd9ZUh.d.mts} +32 -4
- package/dist/dev-DbHgx-R-.mjs +89 -0
- package/dist/{fake-MpzwZhIZ.mjs → fake-6mazmHK3.mjs} +20 -2
- package/dist/features-DowEilZm.mjs +266 -0
- package/dist/{headless-CrhefcLu.d.mts → headless-cFveciuY.d.mts} +1 -1
- package/dist/hot.d.mts +4 -2
- package/dist/hot.mjs +7 -4
- package/dist/{index-0GIP3tgm.d.mts → index-DedbED3T.d.mts} +317 -69
- package/dist/index.d.mts +5 -6
- package/dist/index.mjs +3 -3
- package/dist/inspect.d.mts +12 -7
- package/dist/inspect.mjs +2 -2
- package/dist/jsx-dev-runtime.d.mts +1 -1
- package/dist/jsx-runtime.d.mts +1 -1
- package/dist/lint.d.mts +8 -3
- package/dist/lint.mjs +432 -20
- package/dist/project.d.mts +15 -1
- package/dist/project.mjs +1 -1
- package/dist/{run-Bf2hKyjZ.mjs → run-BbipFRc6.mjs} +250 -51
- package/dist/{runtime-aLkoeoGj.d.mts → runtime-DoSlRx8X.d.mts} +1 -1
- package/dist/{session-DgukhR_7.mjs → session-CeKgO2hy.mjs} +30 -2
- package/dist/{sources-Duzj6lhV.mjs → sources-Cdvb0rOS.mjs} +33 -6
- package/dist/{src-Do697seR.mjs → src-BEbJ_sSx.mjs} +29154 -11121
- package/dist/testing.d.mts +119 -14
- package/dist/testing.mjs +97 -7
- package/dist/{types-BZa187kz.d.mts → types-7GXcXxxv.d.mts} +196 -242
- package/dist/{types-BT9S_Lo2.d.mts → types-BGcda5Le.d.mts} +4464 -735
- package/dist/{types-yiHtzg1Y.d.mts → types-C73hbmxi.d.mts} +129 -17
- package/dist/{types-Bc9AvZVO.d.mts → types-DngFQJro.d.mts} +43 -5
- package/dist/{types-kNiV7iFj.d.mts → types-GHE4J5Uf.d.mts} +1027 -192
- package/dist/{visual-dMW8iqAE.mjs → visual-2m4wJL_z.mjs} +111 -7
- package/dist/visual-_XhoGeIC.d.mts +345 -0
- package/dist/visual.d.mts +1 -332
- package/dist/visual.mjs +130 -20
- package/dist/{watch-CWI_on4T.mjs → watch-BEdrp80i.mjs} +149 -30
- package/llms.txt +103 -51
- package/package.json +8 -6
- package/dist/dev-Cc50Dk7H.mjs +0 -47
- package/dist/memory-DR1ibF_s.d.mts +0 -50
- package/dist/types-DMoi447V.d.mts +0 -656
package/llms.txt
CHANGED
|
@@ -10,8 +10,8 @@ Facts: Bun only. ESM only. `"sideEffects"` lists the root entry `./dist/index.mj
|
|
|
10
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
11
|
- `@moku-labs/game/app/page` (the browser): `startPage`, the page the generated `.moku/main.ts` calls.
|
|
12
12
|
- `@moku-labs/game/app/system` (the browser, the native shell): `systemShellOf`, `fromSystem`, `storeSave`, `createSystemApp`, the type `SystemModules`, over the optional peer `@moku-labs/system`. The one entry that names it, in types only: the page hands it the loaders.
|
|
13
|
-
- `@moku-labs/game/cli` (node and bun only): `runCli`, the bin `moku-game`, `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.
|
|
13
|
+
- `@moku-labs/game/cli` (node and bun only): `runCli`, the bin `moku-game`, `preparePage`, the editor's dev page, `watchKeys`, the watch that keeps `generated/` fresh next to it, and `watchEngine`, the watch that tells when the installed engine changed.
|
|
14
|
+
- `@moku-labs/game/testing`: headless play, repro runs, isolated feature tests (`isolate`, `stub`), fake clock, the `fx` of a node for a unit test (`fakeFx`), in-memory save provider. No `node:` import.
|
|
15
15
|
- `@moku-labs/game/visual` (node and bun only): the visual tests `defineVisualTest`, `runVisualTests`, `parseVisualArgv`. The bin runs them: `moku-game visual`.
|
|
16
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.
|
|
17
17
|
- `@moku-labs/game/inspect`: the read door of the editor, safe in a production build. See "Doors for the editor".
|
|
@@ -23,15 +23,15 @@ Facts: Bun only. ESM only. `"sideEffects"` lists the root entry `./dist/index.mj
|
|
|
23
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.
|
|
24
24
|
|
|
25
25
|
Root exports:
|
|
26
|
-
- Functions: `createApp`, `createPlugin`, `defineGame`, `defineFeature`, `type`, `exit`, `to`, `slot`, `schedule`, `guide`, `hint`.
|
|
26
|
+
- Functions: `createApp`, `createPlugin`, `defineGame`, `defineFeature`, `flattenFeatures`, `type`, `exit`, `to`, `slot`, `schedule`, `guide`, `hint`, `defineEffect`.
|
|
27
27
|
- Class: `SaveUnreadableError`.
|
|
28
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.
|
|
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`.
|
|
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`, `messageArgument`, `messageDuration`; `Text`, `label`, `defineTextStyles`, `bind`, `Countdown` and the types `BindOptions`, `CountdownValue`, `TextFormat`; `defineComponent`, `popup`, `defineStyle`, `defineTokens`, `resolve`, `Box`, `LocalWrite`; `music`.
|
|
29
|
+
- Screen helpers from the root: `component`, `tag`, `resource`, `mut`, `system`, `projection`, `Layer`, `Order`, `Exiting`, `Tree`, `Now`, `Transform`, `Sprite`, `NineSlice`, `Shape`, `Parent`, `Display`, `Overlay`, `HudPart`, `sprite`, `Tappable`, `Pressable`, `Draggable`, `DropTarget`, `Swipeable`, `Traceable`, `Touchable`, `Held`, `Hovered`, `PointerOver`, `Pressed`, `Pointer`, `Traced`, `defineBundles`, `load`, `defineScene`.
|
|
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`, `messageArgument`, `messageDuration`; `Text`, `label`, `defineTextStyles`, `bind`, `Countdown` and the types `BindOptions`, `CountdownValue`, `TextFormat`; `defineComponent`, `popup`, `defineStyle`, `defineTokens`, `resolve`, `Box`, `LocalWrite`, `DebugTrigger`; `music`.
|
|
31
31
|
- Effects helpers from the root (V5): `defineEmitter`, `Emitter`, `defineFilter`, `Glow`, `Outline`, `Blur`, `ColorMatrix`, `Noise`, `Displacement`, `Alpha`.
|
|
32
32
|
- Platform from the root (V6): the types `PlatformProvider`, `BackResult`, `HapticKind` and the frozen list `HAPTIC_KINDS`.
|
|
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
|
|
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
|
|
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`, and `system` (its `ctx.snapshot` typed by `player` and `session`), `volumes` (the `audio` config's reader of the typed player), `key` (string keys from data, below). `Types` is `{ player; session; assets: string; bundles?: string; scenes?: string; strings: Record<string, unknown>; textStyles?: string; emitters?: string }`.
|
|
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/`; a sub-feature's own name, `features/episodes/ep01/assets/ward/room.png` → `ep01.ward.room`. `runCli` (a game's `bun run assets:keys`) scans the assets and compiles the `strings/<locale>.json` of every feature and sub-feature, and the `strings/` of every layer, into `generated/strings.ts` (the `Strings` type for `defineGame`), one `generated/strings.<locale>.ts` per locale for the top-level features and the layers, and `generated/strings.<feature>.<locale>.ts` per sub-feature with string files, its keys only, each starting with `<feature>.` (it imports `messageArgument` and `messageDuration` from `@moku-labs/game` when a message needs them, no helper is copied in); `--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.
|
|
35
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`.
|
|
36
36
|
- `defineNode` and `defineFlow` are NOT root exports. They come from `defineGame<Types>()`.
|
|
37
37
|
|
|
@@ -43,18 +43,19 @@ Root exports:
|
|
|
43
43
|
- Transit node: has `run`, no `rest`. Rest node: `rest: true`. The graph waits there. Without `run` it is a pure wait.
|
|
44
44
|
- A pure wait ends in two ways. A gate answer whose `intent` is an outcome name: the answer payload becomes the outcome payload. Or a world event whose `type` is listed in the node's `inbox`: the event payload becomes the outcome payload.
|
|
45
45
|
- Transition: rest node to rest node. It is a transaction. On an error the runner discards, rolls back to the last rest point, retries `retries` times, then enters `safeNode`. A failure inside `safeNode` rejects `run()`.
|
|
46
|
-
- Three ways to run an effect in a node. `await fx(descriptor)`: the node waits for it. `fx.start(descriptor)`: it starts now and the node goes on; write it in place of `void fx(...)`. A failure of a started effect is the log entry `flow:fx-start-failed` and never fails the node
|
|
46
|
+
- Three ways to run an effect in a node. `await fx(descriptor)`: the node waits for it. `fx.start(descriptor)`: it starts now and the node goes on; write it in place of `void fx(...)`. A failure of a started effect is the log entry `flow:fx-start-failed` and never fails the node. A `cosmetic` descriptor (`sfx`, `play`, `haptic`, `music`) that fails, awaited or started, is the warn entry `flow:fx-cosmetic-failed` with `{ kind, message }` and resolves `undefined`; a descriptor with `answers` and a `guide` are refused, they have to be awaited. `fx.emit(hint(...))`: released after the commit of the edge, dropped in fast mode.
|
|
47
|
+
- `await fx(descriptor)` is typed by the descriptor (`Flow.Descriptor<Result>`): `schedule`, `guide`, `music`, `sfx`, `haptic`, `play` resolve `void`; `load` resolves `Assets.LoadResult | undefined` (`undefined` without `assets`, as in `game.headless()`); `popup(Component, props)` resolves `Flow.Answer<outcome names>`, so comparing it with an intent the component does not declare is a compile error; a descriptor written by hand resolves `unknown`. A game declares its own: `const envRead = defineEffect<{ notice: boolean }, EnvRead>("view-env.read", { fast: "run" })`, then `await fx(envRead({ notice: true }))` is `EnvRead` and `envRead.handle(ctx.require(flowPlugin).fx, payload => ...)` registers the one handler (returns the remover). Only `{ fast: "run" }` without `cosmetic` resolves `Result`; any other declaration resolves `Result | undefined`. `NodeFx` is generic: a hand-written fake is typed `as NodeFx`, or use `fakeFx`.
|
|
47
48
|
- Every failure is written into the log by the engine: an `error` entry `flow:error` with `{ path, rolledBackTo, retry, error: { message, stack } }`. Read it with `app.log.trace()`. A game needs no plugin for it.
|
|
48
49
|
- `recover` on a rest node: `recover: async ({ player, session, rng, fx, signal, now }) => { ... }`. It runs only when the runner gave up on a transition and enters this node as `safeNode`, before the node is entered. The rollback left the state of the failed rest point; `recover` writes the drafts so the safe node can draw itself. What it wrote commits as one edge with `outcome: "recover"` and becomes the rest point. A `recover` that throws rejects `run()`. A retry does not run it. So the safe node stays a pure wait: no `run` body, no extra outcome, no self edge.
|
|
49
50
|
- Entering a rest node calls `model.store.markRest()`: the save provider gets the patches. Leaving a `barrier` node calls `markBarrier(txId)`: a durable write, and rollback cannot cross it.
|
|
50
51
|
- `checkpoint: true` on a rest node: the journal is compacted there. `safeNode` must be a checkpoint.
|
|
51
|
-
- The gate keeps an answer it refused while closed and offers it once to the next gate that opens. Config `flow.holdMs`, in milliseconds of game time: `0` (default) keeps it for one frame and the first answer wins; above `0` the last answer wins and it is dropped when older than `holdMs`; `Infinity` keeps it until the next gate opens. Set it above the longest transit animation a tap should survive. A gate that does not list the intent drops it. Nothing is kept in a fast walk or while time does not run. A held answer is dropped when the runner gives up and enters `safeNode`, when a bookmark is entered and when the graph stops.
|
|
52
|
+
- The gate keeps an answer it refused while closed and offers it once to the next gate that opens. Config `flow.holdMs`, in milliseconds of game time: `0` (default) keeps it for one frame and the first answer wins; above `0` the last answer wins and it is dropped when older than `holdMs`; `Infinity` keeps it until the next gate opens. Set it above the longest transit animation a tap should survive. A gate that does not list the intent drops it. Nothing is kept in a fast walk or while time does not run. A held answer is dropped when the runner gives up and enters `safeNode`, when a bookmark is entered and when the graph stops. The gate keeps one answer, never a queue; for a flight that must not close the gate, commit at once and `fx.start(play(...))`, or draw the flight from state with a `change` hook.
|
|
52
53
|
- `over: true`: entering the node waits while a pointer is down.
|
|
53
54
|
- Path: node names joined by `/` through sub-flows and slots. Examples: `"home"`, `"board/awaitIntent"`, `"afterBonus/thanks"`.
|
|
54
55
|
- Edge target: a node name, `exit(...)` or `to(...)`. A payload must fit the input of the target. A node without input accepts any payload.
|
|
55
56
|
- Sub-flow: a flow that declares `outcomes` is used as a node of another flow. `exit("name")` leaves it with that outcome.
|
|
56
57
|
- Slot: `slot("name")` is a node with the single outcome `done`. Features contribute sub-flows to it. They run in `order`. `when(snapshot)` can skip one; it always reads the committed state, also what an earlier contribution of the same slot wrote. A contribution may hold a slot of its own. The slot ends with its own `done` edge after the last contribution.
|
|
57
|
-
- Feature: `defineFeature(name, description)` makes an ordinary plugin that registers into `flow.features` in `onInit`. Pass it in `createApp({ plugins: [...] })`. `feature.logicOnly` is the same plugin with the V1 keys only, for headless tests.
|
|
58
|
+
- Feature: `defineFeature(name, description)` makes an ordinary plugin that registers into `flow.features` in `onInit`. Pass it in `createApp({ plugins: [...] })`. `feature.logicOnly` is the same plugin with the V1 keys only, for headless tests. `features: [episodesKitFeature, ep01Feature, ep02Feature]` declares sub-features, each in a folder of its own inside the parent's (`features/episodes/ep01/`); `flattenFeatures([homeFeature, episodesFeature])` gives the flat plugin list, a parent before its children, depth first, and a parent's `onInit` throws when a declared sub-feature is not composed. `flow.features.all()` gives `{ name, description, parent? }` per feature.
|
|
58
59
|
- Modes: `"live"` and `"fast"`. In fast mode effects answer instantly, hints are dropped and only `runInFast` handlers run. All node logic runs for real.
|
|
59
60
|
|
|
60
61
|
## Authoring helpers, with signatures
|
|
@@ -98,6 +99,7 @@ function defineFeature(name: string, description: FeatureDescription): FeaturePl
|
|
|
98
99
|
// nodes?: readonly AnyNode[];
|
|
99
100
|
// flows?: readonly AnyFlow[];
|
|
100
101
|
// contribute?: Record<string, { flow: AnyFlow; order: number; when?: (snapshot: Snapshot) => boolean }>;
|
|
102
|
+
// features?: readonly FeaturePlugin[]; // sub-features, composed right after this one
|
|
101
103
|
// projections?, systems?, components?, scenes?, assets?; // V2 keys, read by world, scenes and assets
|
|
102
104
|
// animations?: readonly AnimationDefinition[]; // V3, read by anim
|
|
103
105
|
// ui?: readonly ComponentDefinition[]; // V3, read by ui (defineComponent results)
|
|
@@ -159,6 +161,7 @@ type NodeContext = {
|
|
|
159
161
|
"moku-game/static-keys": "error",
|
|
160
162
|
"moku-game/layer-imports": "error",
|
|
161
163
|
"moku-game/feature-door": "error",
|
|
164
|
+
"moku-game/sub-features": "error",
|
|
162
165
|
"moku-game/test-suffix": "error"
|
|
163
166
|
}
|
|
164
167
|
}
|
|
@@ -172,12 +175,13 @@ type NodeContext = {
|
|
|
172
175
|
- `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.
|
|
173
176
|
- `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`.
|
|
174
177
|
- `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`.
|
|
175
|
-
- `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.
|
|
178
|
+
- `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. At any depth: the unit of a file is the deepest feature unit that holds it, a feature owner (a folder of `features/`, or a folder inside an owner with a door `index.ts` and `assets/`, `assets.ts` or `strings/` of its own) or a code-only sub-feature its parent's door imports from `./<folder>` and lists in `features: [...]`. A sub-feature never imports a feature it lives in, door or file, by alias or by path: `Door: a sub-feature does not import the feature it lives in: features/episodes/ep01 imports features/episodes. Move what they share into a sibling sub-feature, such as features/episodes/episodes-kit, and import it from its door.` A parent reaches a child's door by `./ep01` from its door, `../ep01` from its own files, or by alias; a sibling only by alias, `@features/episodes/episodes-kit`. A flat game lints as before: a folder with an `index.ts` that no door lists in `features: [...]` stays part of its feature.
|
|
179
|
+
- `moku-game/sub-features`: the door of a feature imports every sub-feature owner from `./<folder>` and lists it in `features: [...]`, lists only names imported from a sub-feature folder (a child folder with a door, not a kind folder), and the door of a sub-feature, owner or code-only, registers a feature named like its folder: a call whose first argument is the literal folder name passes, so a factory such as `defineEpisode("ep03", { … })` does too. A code-only sub-feature declares no sub-features of its own. Default files `**/features/**/index.{ts,tsx}`; options as the layout rules. A flat game reports nothing.
|
|
176
180
|
- `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`. An `index.ts` of a kind folder passes, such as `tests/visual/index.ts` of `moku-game visual`. Helpers go to `tests/helpers/` or `__tests__/fixtures/`.
|
|
177
|
-
- 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.
|
|
178
|
-
- 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"]`.
|
|
181
|
+
- Layers under the game root: `core/**`, `shared/**`, `features/<f>/**` and `features/<f>/<sub>/**`, `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.
|
|
182
|
+
- 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"]`. The `*` of `@features/*` crosses `/`: `@features/episodes/ep01` is the door of the sub-feature, `features/episodes/ep01/index.ts`.
|
|
179
183
|
- 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`.
|
|
180
|
-
- 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).
|
|
184
|
+
- 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`, `sub-features` 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).
|
|
181
185
|
|
|
182
186
|
## Minimal game
|
|
183
187
|
|
|
@@ -259,13 +263,13 @@ The game never calls `flow.run()`. The page runs the graph after `app.start()`;
|
|
|
259
263
|
|
|
260
264
|
## The game shell
|
|
261
265
|
|
|
262
|
-
A game is a folder: `index.ts` (`export default defineGameApp({ ... })`), `config.ts` (plain data, `satisfies GameConfig`), `core/`, `shared/`, `features
|
|
266
|
+
A game is a folder: `index.ts` (`export default defineGameApp({ ... })`), `config.ts` (plain data, `satisfies GameConfig`), `core/`, `shared/`, `features/` (features, and inside a feature its sub-features: `features/<parent>/<child>/`), `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"`.
|
|
263
267
|
|
|
264
|
-
- `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
|
|
268
|
+
- `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), `orientation` (`"portrait"`), `referenceSide` (1080), `referenceLong` (1920), `seed` (42), `shared` (composed first among the features), `features` (the top-level features only: a parent brings the sub-features it declares, and the shell composes the tree depth first, a parent before its children), `plugins` (their APIs land on `screen().app`), `headless: { features?, plugins? }` (`features` left out takes the game's `features`, `[]` means none, `shared` is not composed; flattened the same way, and it may name a sub-feature alone), `pluginConfigs`.
|
|
265
269
|
- `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`.
|
|
266
|
-
- `game.headless(seams?)` composes the core plugins, `logicOnly` of `headless.features` (of `features` when left out), then `headless.plugins`. `game.screen(seams?)` composes the screen set, `audio`, `effects`, `platform`, then `shared`, the features and the game's plugins
|
|
270
|
+
- `game.headless(seams?)` composes the core plugins, `logicOnly` of `headless.features` (of `features` when left out), then `headless.plugins`. `game.screen(seams?)` composes the screen set, `audio`, `effects`, `platform`, then `shared`, the features with their sub-features and the game's plugins; `[homeFeature, episodesFeature]` composes `home`, `episodes`, `episodes-kit`, `ep01`, `ep02`. 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.
|
|
267
271
|
- 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.
|
|
268
|
-
- `defineGameApp` throws `[game] defineGameApp needs flow.` and `[game] defineGameApp: features[2] is not a feature.` for what TypeScript cannot stop.
|
|
272
|
+
- `defineGameApp` throws `[game] defineGameApp needs flow.` and `[game] defineGameApp: features[2] is not a feature.` (at any depth: `features[1].features[0]`) for what TypeScript cannot stop. A feature in the tree twice, listed by the game and by its parent, or by two parents, is `[game] defineGameApp: the feature "ep01" is listed twice: features[1].features[1] and features[2].`; a `shared` with sub-features is `[game] defineGameApp: shared declares sub-features.`
|
|
269
273
|
- A game's exit plugin calls `ctx.require(platformPlugin).exit()` itself.
|
|
270
274
|
|
|
271
275
|
```ts
|
|
@@ -285,7 +289,7 @@ app.model.store.snapshot().session; // { rolls: 2 }
|
|
|
285
289
|
await run.stop();
|
|
286
290
|
```
|
|
287
291
|
|
|
288
|
-
`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.`
|
|
292
|
+
`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`), `debug.overlay` (`"closed"`, or `"pill"`, `"open"`: how the editor's overlay shows when a debug page starts; the page calls `app.debug.overlay.open({ mode })` right after the agents started; only a page with the agent reads it). A refused value throws, for example `[game] config.save is "disk".` or `[game] config.system names "tray", which the game shell does not wire.`
|
|
289
293
|
|
|
290
294
|
```ts
|
|
291
295
|
// config.ts of a game in a native shell
|
|
@@ -300,7 +304,7 @@ export default {
|
|
|
300
304
|
} satisfies GameConfig;
|
|
301
305
|
```
|
|
302
306
|
|
|
303
|
-
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 }
|
|
307
|
+
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 }`; last, on `pagehide` it flushes the save, and stops the app when the page is really left (`persisted` false); a page kept in the back/forward cache keeps its app running.
|
|
304
308
|
- `?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.
|
|
305
309
|
- `?renderer=webgl` asks Pixi for WebGL.
|
|
306
310
|
- 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.
|
|
@@ -322,18 +326,21 @@ await startPage(game, config, {
|
|
|
322
326
|
```
|
|
323
327
|
|
|
324
328
|
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.
|
|
325
|
-
- `moku-game dev [--port 3000] [--packed]`: writes `.moku/index.html`, `dev.ts`, `main.ts`, `assets-stamp.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`. On the raw files it keeps `generated/` fresh: it runs the scan of `keys` once before the page is written, and a save of an asset file (`.png`, `.webp`, `.fnt`, `.mp3`, `.m4a`) or of a `strings/<locale>.json` runs it again 100 ms after the last file event and prints `› keys: <path>` or `› keys: <path> and <n> more`; a changed or new string hot swaps through `generated/strings.<locale>.ts`, an asset file saved with new bytes swaps in the running page and a new or removed one reloads it, both through `.moku/assets-stamp.ts`, which `main.ts` imports (see "Hot swap"), and files answer `Cache-Control: no-store`. A failed scan is one warning `[game] keys: <message>` and the server runs on. A changed layer in `config.ts` needs a restart; `--packed` watches nothing. A taken port: `[game] dev: port 3000 is in use.` with `Pass --port 0 for a free port.`
|
|
326
|
-
- `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
|
|
327
|
-
- `moku-game
|
|
328
|
-
- `moku-game
|
|
329
|
+
- `moku-game dev [--port 3000] [--packed]`: writes `.moku/index.html`, `script.ts` (the `moku:script` startup mark), `dev.ts` (sets `__MOKU_GAME_BUILD__` `"dev"`, the engine id `__MOKU_GAME_ENGINE__`, `__MOKU_GAME_CONTROL__`, `__MOKU_GAME_AGENT__` and last `__MOKU_GAME_DEV__`), `main.ts`, `assets-stamp.ts`, `bunfig.toml` (the hot plugin first, `__MOKU_GAME_DEV__`, `__MOKU_GAME_AGENT__` and `__MOKU_GAME_CONTROL__` defined `true`), runs Bun again under that bunfig in the game folder and serves `/`. It prints `› engine <id>` (and the patch file when there is one), then `› <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`. On the raw files it keeps `generated/` fresh: it runs the scan of `keys` once before the page is written, and a save of an asset file (`.png`, `.webp`, `.fnt`, `.mp3`, `.m4a`) or of a `strings/<locale>.json` runs it again 100 ms after the last file event and prints `› keys: <path>` or `› keys: <path> and <n> more`; a folder that becomes or stops being a sub-feature (it gains or loses its door, `assets/`, `assets.ts` or `strings/`) scans too, `› keys: features/episodes/ep03/`; a changed or new string hot swaps through `generated/strings.<locale>.ts`, an asset file saved with new bytes swaps in the running page and a new or removed one reloads it, both through `.moku/assets-stamp.ts`, which `main.ts` imports (see "Hot swap"), and files answer `Cache-Control: no-store`. A failed scan is one warning `[game] keys: <message>` and the server runs on. A changed layer in `config.ts` needs a restart; `--packed` watches nothing. A taken port: `[game] dev: port 3000 is in use.` with `Pass --port 0 for a free port.` It watches the installed engine: 300 ms after a new version or dist it warns `[game] dev: the engine changed (<old id> -> <new id>), restarting the dev server.`, stops the child with SIGTERM, writes the page again with the new id and starts a new child (a fixed port keeps its URL, `--port 0` prints a new one); a patch file that changed while the engine did not is one warning that asks for `bun install`.
|
|
330
|
+
- `moku-game build [--out dist/web]`: packs the assets, bundles the page minified with `__MOKU_GAME_DEV__`, `__MOKU_GAME_AGENT__` and `__MOKU_GAME_CONTROL__` defined `false` (no scenario, no agent, no `.dev` module, no `/control`, no engine id), every link `./`, and copies the pack beside it with `manifest.json` next to `index.html`. Every page marks `moku:script` first and `moku:main` first after its imports. A release page whose `.js` carries `"/__editor/hello"` or `"[moku-editor]"` (the editor's agent) fails with `[game] build: the release page carries the editor agent (…)` and its output folder is removed. Runs from any http sub-path and from Tauri, not from `file://`.
|
|
331
|
+
- `moku-game build --debug[=full] [--editor host:port]`: a debug page, never shipped: the release page plus `@moku-labs/editor/agent/page` (resolved from the game, else `[game] build --debug needs @moku-labs/editor in the game.`) and the game's `.dev` modules, started after the app; DEV `false`, AGENT `true`, CONTROL `false` (`--debug`, read and cosmetic commands) or `true` (`--debug=full`, every command); `__MOKU_GAME_BUILD__` `debug-<UTC stamp>` or `debug-full-<stamp>`, `__MOKU_GAME_ENGINE__` the engine id (`<version>+<dist hash>[.patch.<hash>]`, `+src` from sources), `__MOKU_GAME_EDITOR__` the `--editor` address (default `127.0.0.1:47600`); the title reads `<title> Debug`; no hot swap, no frame recorder, no audio journal, no scenario. It prints one warning naming the profile, the build id, the engine and the editor. `MOKU_NATIVE_PROFILE=store` with `--debug` is refused.
|
|
332
|
+
- `moku-game native build <target> [--simulator] [--debug[=full] [--editor host:port]]`, `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`; an Android app composes native's `shell` system first. `native build android --debug` builds a debug app (`debug: true` to native, which installs it beside the store app; the web build is `moku-game build --debug` with the same profile and `--editor`); other verbs and targets refuse `--debug`. `[game] config.ts has no native section.` without `native`.
|
|
333
|
+
- `moku-game keys [--check]`: `generated/assets.ts`, the compiled strings (`strings.ts`, `strings.<locale>.ts` for the top-level features and the layers, and `strings.<feature>.<locale>.ts` for every sub-feature with string files) and the dev manifest `generated/manifest.json` (a sub-feature's bundle carries its `dir` and `parent`); `dev` runs the same scan at start and on save and serves the manifest on `/manifest.json`, `visual` stops without it. `moku-game pack [--no-cache]`: `dist/assets`. Both pass the layers of `config.ts`.
|
|
329
334
|
- `moku-game visual [--update] [--only <name>] [--no-pixels | --pixels] [--webgl] [--dir <path>] [--tests <file>] [--url <url>]`: runs `runVisualTests` with the default export of `tests/visual/index.ts`, `{ app, tests }` (`app` is a `VisualSetup` `{ app, page? }`: `export default { app: { app: fixtureApp }, tests }`), against `tests/visual/baselines/`. The flags go through `parseVisualArgv`. The pixel leg runs on a Mac only unless `--pixels`/`--no-pixels`; without `--url` the command writes its own page into `.moku/visual/` (the page of `dev` and of the editor in `.moku/` stays), runs Bun again under `.moku/visual/bunfig.toml` as `dev` does (hot plugin, `--serve-plugin`, `--preload`), and the child serves the page on a free port, then stops it. No `manifest.json`: `[game] visual: no manifest.json in "<game>".` Exit 0 when the report is ok, else 1. `[game] visual: <file> must export default { app, tests }.` for a missing module or another shape, a bare app factory too.
|
|
330
|
-
- `preparePage(root, { agents?, preload?, servePlugins? })` of `@moku-labs/game/cli` writes the dev page of `moku-game dev` and resolves `{ html, bunfig }
|
|
331
|
-
- `watchKeys(root, { onError? })` of `@moku-labs/game/cli` is the keys watch of `moku-game dev` for a server the caller runs: it runs the scan of `keys` once, resolves `{ close() }` after it, then scans again on a save of an asset or a strings file. A failed scan calls `onError(message)` (default: a console warning `[game] keys: <message>`) and the watch goes on. The editor calls it next to `preparePage`: `const keys = await watchKeys("games/timber", { onError: message => log.warn(message) })`, then `keys.close()`. It rejects with the `[game]` errors of `preparePage` for a folder that is not a game.
|
|
335
|
+
- `preparePage(root, { agents?, preload?, servePlugins? })` of `@moku-labs/game/cli` writes the dev page of `moku-game dev` and resolves `{ html, bunfig, engine }` (both paths absolute; `engine` the `EngineFingerprint` whose `id` the page shows in `globalThis.__MOKU_GAME_ENGINE__`), 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. It also writes the first, empty `.moku/assets-stamp.ts` when the game has none. The editor calls it: `await preparePage("games/timber", { agents: ["@moku-labs/editor/agent/page"] })`.
|
|
336
|
+
- `watchKeys(root, { onError? })` of `@moku-labs/game/cli` is the keys watch of `moku-game dev` for a server the caller runs: it runs the scan of `keys` once, resolves `{ close() }` after it, then scans again on a save of an asset or a strings file, and when a folder becomes or stops being a sub-feature. A failed scan calls `onError(message)` (default: a console warning `[game] keys: <message>`) and the watch goes on. The editor calls it next to `preparePage`: `const keys = await watchKeys("games/timber", { onError: message => log.warn(message) })`, then `keys.close()`. It rejects with the `[game]` errors of `preparePage` for a folder that is not a game.
|
|
337
|
+
- `watchEngine(root, { onChange?, onError? })` of `@moku-labs/game/cli` is the engine watch of `moku-game dev` for a server the caller runs: sync, it returns `{ close() }` and calls `onChange({ before, after, restart })` 300 ms after the installed engine changed, `restart: true` for a new version or dist (restart the page server), `restart: false` for a changed patch alone (run `bun install`); an engine from its sources is not watched. The editor calls it next to `preparePage`: `const engine = watchEngine("games/timber", { onChange: change => change.restart && restartPageServer() })`, then `engine.close()`.
|
|
332
338
|
|
|
333
339
|
## More patterns
|
|
334
340
|
|
|
335
341
|
```ts
|
|
336
|
-
import { exit, hint, schedule, slot, to, type } from "@moku-labs/game";
|
|
342
|
+
import { exit, hint, play, schedule, slot, to, type } from "@moku-labs/game";
|
|
343
|
+
import { mergeFlight, mergeItems } from "./board";
|
|
337
344
|
import { defineFeature, defineFlow, defineNode } from "./state";
|
|
338
345
|
|
|
339
346
|
// Awaited effect with answers. The gate opens for these intents. The promise resolves with the answer.
|
|
@@ -399,6 +406,18 @@ export const main = defineFlow("main", {
|
|
|
399
406
|
}
|
|
400
407
|
});
|
|
401
408
|
|
|
409
|
+
// A flight that does not close the gate: the flight starts, the merge commits on this edge, the next drop is
|
|
410
|
+
// taken in the next frame. The timeline keeps the view it started with, so the item flies on after the commit.
|
|
411
|
+
export const merge = defineNode({
|
|
412
|
+
input: type<{ from: string; to: string }>(),
|
|
413
|
+
outcomes: { done: type() },
|
|
414
|
+
run: ({ input, player, fx, out }) => {
|
|
415
|
+
fx.start(play(mergeFlight, { item: { projection: "board.items", key: input.from } }));
|
|
416
|
+
mergeItems(player.board, input.from, input.to);
|
|
417
|
+
return out.done();
|
|
418
|
+
}
|
|
419
|
+
});
|
|
420
|
+
|
|
402
421
|
// Feature: contributes a sub-flow to the slot. A feature name must differ from every flow id.
|
|
403
422
|
const thanks = defineNode({ outcomes: { ok: type() }, rest: true, over: true });
|
|
404
423
|
const thanksFlow = defineFlow("thanksPopup", {
|
|
@@ -431,15 +450,17 @@ A tutorial step is `await fx(guide({ allow: { intent: "roll" }, hand: "tap", tex
|
|
|
431
450
|
|
|
432
451
|
## Plugins
|
|
433
452
|
|
|
434
|
-
Registered in this order: `time`, `lifecycle`, `model`, `clock`, `flow`; then the list `screen` a game spreads in: `world`, `renderer`, `input`, `assets`, `scenes`, `anim`, `i18n`, `text`, `ui`; then the opt-in `effects`, `audio` and `platform` when the game adds them, `platform` last. `log` and `env` from `@moku-labs/common` are on every context as `ctx.log` and `ctx.env`. Configure with `createApp({ pluginConfigs: { <name>: { ... } } })`. Global config: `createApp({ config: { orientation, referenceSide } })`, defaults `"portrait"` and `1080`.
|
|
453
|
+
Registered in this order: `time`, `lifecycle`, `model`, `clock`, `flow`; then the list `screen` a game spreads in: `world`, `renderer`, `input`, `assets`, `scenes`, `anim`, `i18n`, `text`, `ui`; then the opt-in `effects`, `audio` and `platform` when the game adds them, `platform` last. `log` and `env` from `@moku-labs/common` are on every context as `ctx.log` and `ctx.env`. Configure with `createApp({ pluginConfigs: { <name>: { ... } } })`. Global config: `createApp({ config: { orientation, referenceSide } })`, defaults `"portrait"` and `1080`; a game passes them through `defineGameApp({ orientation, referenceSide, referenceLong })`.
|
|
435
454
|
|
|
436
|
-
### time (Standard, no dependencies,
|
|
455
|
+
### time (Standard, no dependencies, emits `time:thermal` and `time:rate`)
|
|
437
456
|
|
|
438
457
|
The single `requestAnimationFrame` loop. Six phases in this order: `input`, `animate`, `layout`, `sync`, `signals`, `render`.
|
|
439
|
-
- API: `onFrame(phase, callback): () => void`, `snapshot(): Readonly<Time>`, `setScale(scale)`, `pause()`, `resume()`, `isPaused()`, `isRunning()`, `step(deltaMs)`, `wake()
|
|
458
|
+
- API: `onFrame(phase, callback): () => void`, `snapshot(): Readonly<Time>`, `setScale(scale)`, `pause()`, `resume()`, `isPaused()`, `isRunning()`, `step(deltaMs)`, `wake()`, and for an agent build (dev pages and `--debug` builds) `measure(listener): () => void` (every frame's `FrameMeasure`: gap, skipped ticks, the six phase durations, work, `rateMs`), `agentClock()` (the page clock, `undefined` without the flag) and `chargeAgent(ms)` (for engine plugins: the editor's own work left out of a frame). Every build: `rate(): Readonly<Time.FrameRate>` (`{ fps, mode, reason, displayHz, ladder, thermal }`, one frozen object until it changes), `setRate(choice: "auto" | 30 | 60 | 120)` (a number forces the cap, never above `maxFps`; `"auto"` gives it back; answers `rate()`), and for engine plugins `frameDrawn()` and `frameProbed()` (the renderer reports a drawn frame and a warm-up frame).
|
|
459
|
+
- Adaptive frame rate: `time: { maxFps: 120, adaptive: { floor: 60 } }` runs at the highest divisor of the display rate at or below `maxFps` (120 on a 120 Hz panel, 60 on a 60 Hz one, 72 on 144 Hz), drops one step when at least `max(8, 10 %)` of the last second's drawn intervals came late, returns one step after the dwell (5 s, doubling up to 120 s after a failed return) with 90 steady drawn frames in 3 s, never below `floor`; thermal `moderate` drops a step, `severe` to the floor. Only drawn frames count. Every change emits `time:rate` (`Time.RateChange`: `from`, `to`, `reason`, `mode`, `displayHz`, `thermal`, `window`, `dwellMs`) and logs `time: frame rate`. Off by default, in a headless app and on a fake clock.
|
|
460
|
+
- Native frame rate: on a page with `__MOKU_NATIVE__` of version 1, `time` asks the panel for the loop's rate at start and on every change, and hands it back at stop, every build; with `adaptive` the answer's `maxHz` is the display rate. In an agent build or with `adaptive` its one `moku-native:thermal` listener emits `time:thermal` with `Time.ThermalReading` (`{ status, headroom, at, android }`).
|
|
440
461
|
- `Time` is `{ delta, elapsed, scale, frame, idle }` in scaled milliseconds.
|
|
441
462
|
- Idle cap: after `idleAfterMs` unscaled milliseconds without a `wake()` the loop runs at `idleFps` and `snapshot().idle` is true. `wake()` lifts it at once; `input`, `anim`, `flow`, `assets`, `scenes`, `text` and `ui` call it. `step(deltaMs)` is never capped.
|
|
442
|
-
- Config: `maxFps: 30 | 60 | 120` default `60`, `maxDeltaMs: number` default `50`, `idleFps: 0 | 30` default `30` (`0` turns the idle cap off), `idleAfterMs: number` default `2000`.
|
|
463
|
+
- Config: `maxFps: 30 | 60 | 120` default `60`, `maxDeltaMs: number` default `50`, `idleFps: 0 | 30` default `30` (`0` turns the idle cap off), `idleAfterMs: number` default `2000`, `adaptive: false | { floor: 30 | 60 }` default `false`.
|
|
443
464
|
- In plain Bun there is no frame source. `isRunning()` is false and tests drive frames with `step`.
|
|
444
465
|
|
|
445
466
|
### lifecycle (Standard, depends on time)
|
|
@@ -470,6 +491,11 @@ Trusted time as an input. Monotonic `now()` and one `elapsed` signal at the next
|
|
|
470
491
|
- Config: `source: ClockSource | undefined` default `undefined` which means the system clock. `ClockSource` is `{ now(), setTimer(callback, delayMs), clearTimer(handle) }`.
|
|
471
492
|
- A node never touches the clock. It awaits `fx(schedule(moment))`. `flow` posts `{ type: "elapsed", payload: { now } }` into its inbox when the moment arrives and when the game resumes.
|
|
472
493
|
|
|
494
|
+
### debug (Micro, no dependencies, no events)
|
|
495
|
+
|
|
496
|
+
The editor's overlay from the game, in every app. The plugin name `debug` is reserved.
|
|
497
|
+
- API: `app.debug.overlay.open({ mode?: "pill" | "card" })` (the card by default), `close()`, `toggle()` (hidden and the last visible mode), `isOpen()`. Each reads `globalThis.__MOKU_GAME_OVERLAY__` (set by the editor's page agent) at call time, else the 0.10 `editor.overlay`, and answers `true` when an overlay took the call; without the agent flag (a release build, a test) every member is a no-op answering `false`. A throwing channel logs `debug:overlay-failed`.
|
|
498
|
+
|
|
473
499
|
### flow (Very Complex, depends on time, lifecycle, model, clock)
|
|
474
500
|
|
|
475
501
|
The graph. Modules `runner`, `gate`, `inbox`, `fx`, `features`. Runner methods sit on the plugin root.
|
|
@@ -483,6 +509,7 @@ The graph. Modules `runner`, `gate`, `inbox`, `fx`, `features`. Runner methods s
|
|
|
483
509
|
- After a rollback (a node failed) a view of a projection that the node moved and that still stands off its rest pose is settled, also when its item did not change. Ui elements are not settled by a rollback, and a timeline the failed node started without `await` runs on.
|
|
484
510
|
- A loop adds its sway on top of the value under it. The world compares that value with the rest pose, so a looping view that stands at home is never settled, and one a node moved is brought home while its loop runs on. A custom tween driver may report what its additive tracks add with the optional `offsetsOf(entity, component)`; without it the stored value is compared as it stands.
|
|
485
511
|
- The `loop` hook of a view runs while the world is live and `anim` is installed: a view that entered on a load, a mount or a restore gets its loop from the next live frame. `anim.finishAll()` ends it; the next flush of the world (a load, a restore, a commit in a paused or fast world) owes it again.
|
|
512
|
+
- For a JSX screen ui compares the description prop by prop, by value: inline styles are fine, an equal description costs nothing (no style pass, no layout, no write), hooks stay built once (a function compares by identity). A style object changed in place is not seen: build a new one.
|
|
486
513
|
- A projection may build a new item object on every commit. A view whose components did not change keeps its running motions; only a real component change cancels them. No frozen item tables are needed.
|
|
487
514
|
- After a `restore` or a load the picture is the one of the restored state, whatever played before: every view of a mounted projection stands on its rest pose, its `loop` starts again on the next live frame and no other motion runs, and every ui root is built again through the mount path, so `enter` hooks run and a loop starts as after a fresh mount. Scroll offsets, local state, hover and focus start anew. Look an element up again with `ui.find(key)` after a restore. A rest node names the scene it is shown on (`scene: "splash"`): a restore into a node without `scene` keeps the mounted scene.
|
|
488
515
|
- `restore` of a rest node resolves when the node is entered. `restore` of a transit node runs it again from its start, so its effects before the gate play again, and resolves once its gate is open. The open gate is not the draw: the popup shows on the next frame. `restore` also resolves when the loop ends before the bookmark was entered, after a stop or a fatal error, and nothing is entered then.
|
|
@@ -493,19 +520,37 @@ The graph. Modules `runner`, `gate`, `inbox`, `fx`, `features`. Runner methods s
|
|
|
493
520
|
|
|
494
521
|
`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.
|
|
495
522
|
- `app.scenes`: `current()` the mounted scene id, `expect(id)` the scene the next node without its own `scene` mounts, a rest node or a transit node in live mode (the restore door calls it; an unknown id throws). An `over` node takes it the same way, also over another mounted scene.
|
|
523
|
+
- Overlays: `Overlay({ dim, dimColor, cover, hud, fade })` on the root of an overlay (in `components`) keeps everything drawn before it as one picture, retaken in the frame anything in it changes, and with `cover: "opaque"` draws none of it once landed at alpha 1; the engine draws the dim (`dim × level`, right under the root); the level follows the root's own enter and exit motion, `fade` (250 ms) only when it has none; level and landing are read through `app.renderer.sync.debug.overlays()`.
|
|
524
|
+
- HUD: a scene layer `{ above: "overlays" }` is the HUD layer, drawn above every overlay and never in a picture; a scene with a HUD layer and no `ui` gets `ui` right under it. Its roots never take the keyboard: Escape and the Back press reach the top sheet under it. `HudPart({ key })` tags a HUD view; an overlay's `hud` (`"all"`, `"none"` or keys) names the parts that stay shown, the others fade with its level and take no tap. Keys are typed by `defineGame<{ …, hud: "energy" | "gems" }>()`.
|
|
525
|
+
- Endless loops of views under an opaque or blurred overlay write nothing (`renderer.sync.loopsHeld(entity)`, the seam anim asks before an endless loop writes); their phase follows time.
|
|
526
|
+
- A scene names bundles on top of its `bundle` from the committed state: `defineScene("ward", { bundle: "episodes-kit", bundles: ({ session }) => [session.episode], layers: {}, projections: [wardRoom] })`. `bundles` is read each time a node enters the scene, and every way in (a live entry, a restore, the `safeNode` recovery) waits for all of them before the scene is mounted and the node's gate opens. The keys are `BundleKey`s of `generated/assets.ts`.
|
|
527
|
+
- `app.assets`: `load(bundle)`, `unload(bundle)`, `isLoaded(bundle)`, `has(bundle)` (the manifest carries it, loaded or not), `texture(key)`, `font(key)`, `audio(key)`, `usage()`. Per bundle in a feature's `assets.ts`: `defineBundles({ ep01: { tier: "feature", release: "leave", mipmaps: ["items/**"] } })`. `release: "leave"` also unloads the bundle, with reason `"leave"`, at the next rest once nothing holds it: not pinned by the node entered, not named by the mounted scene, no node of its feature on the stack; a bundle the outgoing scene named leaves one rest later. The default `"budget"` leaves only under the texture budget. `mipmaps` (`true` or globs relative to `assets/`) uploads the textures with their full chain; such a texture stays loose in a pack. A sub-feature with resources gets its own bundles, named like its folder, with `dir` and `parent` in the manifest; a code-only one has none. `companions.add({ load(bundle), unload(bundle) })` adds what loads and unloads with every bundle; `i18n` adds the strings of a sub-feature that way, only when `assets` is composed. Events `assets:bundle-loaded`, `assets:bundle-progress`, `assets:bundle-unloaded` (`reason: "budget" | "request" | "leave"`), `assets:replaced` (dev). Config: `manifest`, `textureBudgetMb: 192`, `preloadDepth: 2`, `baseUrl`, `io`.
|
|
496
528
|
- `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.
|
|
529
|
+
- `Now` is the engine resource of the frame's moment: `world` writes `clock.now()` into it at start and first thing in every frame, headless too, so every system of a frame reads one moment: `const { now } = res(Now)`; a fake clock makes it exact. A system that shows time writes its component only when its picture changes. `snapshot().resources` holds `Now: { now }`. Reserved resource names: `Now`, `Pointer`, `AnimPlayer`, `UiCounters`; a game resource of one of them throws `[game] world: the resource name "Now" is the engine's (the frame's moment).`
|
|
530
|
+
- Motion hooks are told why they play: `enter(view, item, hint, cause)` and `exit(view, item, hint, cause)` get `World.MotionCause`, `"mount"` (the view comes or goes with its screen: a mount, an unmount, a load, a restore, a paused or fast world) or `"change"` (a commit). A world projection plays them only for `"change"`; `ui` plays them for both. `app.world.projection.causeOf(entity)` answers how a view came or how it is leaving, also in a removal hook of its despawn, until the next frame.
|
|
497
531
|
- `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.
|
|
532
|
+
- `input`: one followed pointer. A finger that has not started a gesture (pressed, inside the tap slop, no long press, no trace) gives way to the next finger that goes down, wherever it rests, and its press is cancelled; a finger that started a gesture keeps it. `Pointer.id` is the followed `pointerId` (`-1` at rest): a system that measures the finger's travel starts over when it changes.
|
|
533
|
+
- `Draggable({ payload, carry?, aim? })`: `aim: { x, y }`, a point of the view in its own units, moves the hover and the drop lookup from the finger to that point of the view in the hand; `null` (default) is the finger.
|
|
534
|
+
- `pluginConfigs.renderer.onDemand: true` draws a frame only when something visible changed (a written view, a filter value, a resize, a wake, a capture); `app.renderer.stats().drawn` is the frames drawn per second (`fps` without it); a game that writes into its own `Display` object calls `app.renderer.invalidate(entity)` before the write; `renderer.sync.debug.verifyFrames(true)` (dev/debug) checks skipped frames by pixels and logs `renderer:missed-frame`; a dev build warns `renderer:missed-mark` for a write without the call.
|
|
535
|
+
- `app.renderer.stats()` adds `textureMbSplit: { art, targets, kept }` (MiB). `app.renderer.host.resolution()` is the resolution drawn at: `override ?? min(devicePixelRatio, maxResolution)`; `maxResolution` is a number (default 2), a change of `devicePixelRatio` is followed from the next frame, and `app.renderer.monitor.setResolution(n | undefined)` overrides it in every build, the way to apply a player's graphics setting from an effect (0.5 to 4, else a `RangeError`).
|
|
536
|
+
- `app.renderer.monitor` (agent builds; nothing otherwise): `stream(listener)` the performance log, one frozen JSON line at a time (`PerfLogLine`, `v` = `PERF_LOG_VERSION` = 1, `t` page epoch ms, `build`): `header`, `startup`, a `sample` a second, `spike` (a frame over its budget, with its passes, GPU time, systems, compiles, filters per kind, long frames), `longFrame`, `event`, `mark`, `thermal`; `frames()` the frame ring (typed-array columns, `read(into, cursor)` copies without allocating; `frameAt(ring, age)` gives one record); `mark(label)`; `event(name, detail?)`; `beginAgent()`/`endAgent()` for engine plugins. Config `renderer.monitor: { budgetMs: "rate", frames: 1024 }`: `"rate"` makes the spike budget one frame of the frame's rate (8.33 ms at 120 fps, 16.7 at 60). The engine sends nothing anywhere.
|
|
537
|
+
- `app.renderer.viewport.size()` answers one frozen object, replaced only when the frame changes (a resize, a rotation, new insets, a new scale): compare frames with `===`; a write into it throws a `TypeError`.
|
|
538
|
+
- `pluginConfigs.renderer.warm` (default `1`, 0 to 16) is the pipeline warm-up: while a game loads, each drawn frame first draws that many invisible probes (sprites, graphics, text, particles, every filter kind and stage `effects` registers, on the canvas and on a texture, with and without a clip), wiped by the stage pass, so no screen creates a GPU pipeline in the frame it opens in; `0` turns it off. `app.renderer.sync.filters.warm(filter)` warms a game's own filter (answers `true` once drawn). An agent build writes a `warm` event `{ draws }` when the queue empties.
|
|
539
|
+
- `defineFilter(id, { area: value => [x, y, width, height] })` declares the box, in the entity's own units, outside of which the filter draws nothing: Pixi renders and fills only that box (a lens over a full-screen cover fills its disc, not the screen). The view gets the union of its enabled filters' areas, none when an enabled filter declares none (`renderer.sync.filters.area(entity, area)`). Distance-field text uses device pixels for its edge: sharper at a resolution above 1.
|
|
540
|
+
- `pluginConfigs.renderer.poolTrim` (default `{ restMs: 1000, aboveMb: 16 }`) gives back the idle render targets Pixi's texture pool keeps, strictly between frames: after `restMs` of a still screen (`onDemand`), `restMs` after a scene change (each when the targets grew by `aboveMb` MiB), and on a pause (whatever grew); `false` keeps them. `stats().textureMbSplit.targets` shows the drop; an agent build writes a `pool-trim` event `{ reason, freedMb }`.
|
|
541
|
+
- `app.renderer.viewport.size().safeArea` is read from four CSS custom properties on the page, `--moku-safe-top`, `--moku-safe-right`, `--moku-safe-bottom`, `--moku-safe-left`, each falling back to `env(safe-area-inset-*, 0px)`. Any CSS length works (`max()`, `calc()`, `env()`); a negative one counts as 0. A change of a property or of the page's insets, and the window event `moku-native:insets`, is laid out at the next frame. There is no `setInsets`: set the properties, e.g. `:root { --moku-safe-top: max(env(safe-area-inset-top, 0px), 24px); }`.
|
|
498
542
|
|
|
499
543
|
### Interface plugins (V3, built)
|
|
500
544
|
|
|
501
545
|
A screen is a projection whose `view` returns JSX. The game's `tsconfig.json` sets `"jsx": "react-jsx"` and `"jsxImportSource": "@moku-labs/game"`; screens are `.tsx` files. There is no DOM and no React.
|
|
502
546
|
|
|
503
|
-
- Tags: `screen`, `layer`, `row`, `column`, `stack`, `spacer`, `panel` (swallows taps), `image` (`texture`, `fit?: "contain" | "cover" | "fill"`), `icon` (`name`, `fit?`), `text` (`content: string | Message`, or `bind={bind(Counter, "value")}` with `components={[Counter({ value })]}`; formats `int`, `mm:ss`, `h:mm:ss`, `duration`; `Countdown({ until })` with `bind(Countdown, "left", { format: "mm:ss" })` counts down to a `clock` moment; `style` is a text style key), `button` (`intent` + `payload`, or `local`, never both), `scroll` (`axis: "y"`; children for a short list, or the windowed form `rows`, `rowHeight`, `overscan?` (5), `row: index => node`; row state belongs in the model, a row that leaves the window loses its local state), `input` (a text field; its text lives in the component's `local`, see the ui README). Common props: `key`, `style`, `state: { active?, disabled?, selected? }`, `motion`, `components` (extra component values for the element's entity, such as a filter: `components={[Glow({ strength: 2 })]}`); containers take `hosts?: string[]` (projections drawn inside the element). Style adds `nineSlice`, `tint`, `offsetX`, `offsetY`, `scale`, `origin`, `fit: "contain"`; state variants `is: { hover, pressed, disabled, active, selected, covered }`. `popup(Component, props, { over })` stacks a popup over a kept one.
|
|
547
|
+
- Tags: `screen`, `layer`, `row`, `column`, `stack`, `spacer`, `panel` (swallows taps), `image` (`texture`, `fit?: "contain" | "cover" | "fill"`), `icon` (`name`, `fit?`), `text` (`content: string | Message`, or `bind={bind(Counter, "value")}` with `components={[Counter({ value })]}`; formats `int`, `mm:ss`, `h:mm:ss`, `duration`; `Countdown({ until })` with `bind(Countdown, "left", { format: "mm:ss" })` counts down to a `clock` moment; `style` is a text style key), `button` (`intent` + `payload`, or `local`, never both), `scroll` (`axis: "y"`; children for a short list, or the windowed form `rows`, `rowHeight`, `overscan?` (5), `row: index => node`; row state belongs in the model, a row that leaves the window loses its local state), `input` (a text field; its text lives in the component's `local`, see the ui README). Common props: `key`, `style`, `state: { active?, disabled?, selected? }`, `motion`, `components` (extra component values for the element's entity, such as a filter: `components={[Glow({ strength: 2 })]}`, or `DebugTrigger()`: five taps within 3000 ms of wall time on that view call `app.debug.overlay.open()` on a page with the agent flag, nothing elsewhere; put it on a `button` naming nothing); containers take `hosts?: string[]` (projections drawn inside the element: world views hang under it from the slot's first frame; a JSX projection is laid out in the slot's content box). Children draw and are hit in markup order at equal `zIndex`; the roots of a scene draw in the order of its `projections` (the `ui` layer sorts by `Order`; `ui: { sort: "none" }` keeps insertion order). `defineMotion({ when: "change" })` plays its `enter` and `exit` only for a commit or a popup, never when its screen mounts or unmounts; declare a motion at module level (`ui:motion-rebuilt` names a loop rebuilt in a view). Style adds `nineSlice`, `tint`, `offsetX`, `offsetY`, `scale`, `origin`, `fit: "contain"`; state variants `is: { hover, pressed, disabled, active, selected, covered }`. `popup(Component, props, { over })` stacks a popup over a kept one.
|
|
504
548
|
- Style vocabulary (`defineStyle`, plain data): flow `direction`, `wrap`, `justify`, `align`, `alignSelf`, `gap`, `grow`, `shrink`; box `padding`, `margin`, `width`, `height`, `minWidth`, `minHeight`, `maxWidth`, `maxHeight`, `aspect`, `overflow`; position `position`, `left`, `top`, `right`, `bottom`, `reason`; visual `fill`, `stroke`, `strokeWidth`, `radius`, `alpha`; variants `is: { pressed?, disabled?, active?, selected? }` and `when: { portrait?, landscape?, tall?, wide? }`, each a partial style merged in when the flag holds. Lengths are reference units or `"safeArea.top" | "safeArea.right" | "safeArea.bottom" | "safeArea.left"`. No font size here: it comes from the text style.
|
|
505
549
|
- `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.
|
|
506
|
-
- `tr(key, params?)` returns a frozen `Message` `{ key, params }`; no locale is read at the call site. Strings live in `
|
|
550
|
+
- `tr(key, params?)` returns a frozen `Message` `{ key, params }`; no locale is read at the call site. Strings live in `strings/<locale>.json` of any feature or sub-feature, 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. The keys of the top-level features and the layers go into `generated/strings.<locale>.ts`, which one feature registers; a sub-feature's keys start with `<name>.` (`ep01.title`) and go into `generated/strings.<name>.<locale>.ts`, which the sub-feature registers lazily, `strings: { en: () => import("@generated/strings.ep01.en"), ru: () => import("@generated/strings.ep01.ru") }`. With `assets` composed those modules load and unload with the bundle named like the sub-feature: every way the bundle comes (a node entry, a scene's `bundles`, `load`, a restore, the `safeNode`) brings its strings, a locale switch brings them in the new locale, and while the bundle is not loaded a key of it gives `⟦key⟧` and `has(key)` is false. A compiled module calls the root helpers `messageArgument(value, intl)` (an element stays an element part, a list goes through `Intl.ListFormat`, a number through `Intl.NumberFormat`, else text) and `messageDuration(ms)` (`95_000` gives `{ minutes: 1, seconds: 35 }`). `app.i18n`: `locale()`, `setLocale(locale)` (async, emits `i18n:locale-changed`), `format(message, locale?)` returns `Part[]`, `plain(message)` returns a string, `has(key)`, `isKey(value)`, `locales()`, `replace(locale, messages)` (dev hot swap of a generated strings file). Config: `locale: "en"`, `fallback: "en"`, `locales: {}`.
|
|
551
|
+
- Keys that come from data: `I18n.TextKey<Strings>` is the keys of the generated `Strings` without parameters (nothing new is generated). A data table types its keys `TextKey` or builds them with the kit's `key("board.hint.red")`; the kit's `tr` never takes a plain `string`. A key read back from a save is checked where the app is (an effect handler, app or page code, loading a save) with one `isKey(value)`: `defineGameApp({ ..., strings: type<Strings>() })` types the game's `app.i18n`, so a true `app.i18n.isKey(value)` narrows the value to `TextKey`; `ctx.require(i18nPlugin).isKey(value)` in a plugin narrows to `string`. `app.i18n.isKey(value)` answers true for a key of the loaded tables of the current locale or the fallback, and for the `<feature>.` prefix of a sub-feature whose strings are not loaded yet; a key that takes parameters answers true too.
|
|
507
552
|
- 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.
|
|
508
|
-
- 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`.
|
|
553
|
+
- 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?, space?, reduced? })`, `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 timeline resolves each `{ projection, key }` target once, when it starts: every step writes the view that held the key then, also after a commit removed it and while its exit plays, until it despawns; a view spawned again under the same key is not followed, and a key nothing held at the start is looked up at its step. `reduced: "keep"` (on a `tween` step, `transition` and `loop` of `defineMotion`, `TweenOptions`, `RestOptions`) plays a track as written under reduced motion, a fade for one; the default `"cut"` lands it at once or holds a loop on its first key. A node plays it with `await fx(play(animation, slots))`; in fast mode it finishes at once. `defineMotion({ states, transition?, on: { enter?, exit?, change? }, when? })` builds the enter, exit and change hooks for a projection or the `motion` prop of a tag; `when: "change"` plays enter and exit only for a commit, so an element that comes or goes with its screen stands at rest and leaves without a motion (default `"always"`). 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`.
|
|
509
554
|
- 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). The context is asked to run at start: where the shell lets audio play (a native webview with the gesture requirement off) `unlocked()` is true with no touch; a browser that wants a gesture unlocks on the first `pointerdown` or `touchend`. Without `audio` every `sfx` and `music` effect resolves `undefined` at once, so the same node runs in a game with no sound.
|
|
510
555
|
- `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".
|
|
511
556
|
|
|
@@ -574,9 +619,13 @@ export const deliver = defineNode({
|
|
|
574
619
|
Cosmetic rendering extras as data, drawn by `effectsPlugin`: `plugins: [...screen, effectsPlugin]`. Nothing here enters `model`, the journal or a save. Headless it draws nothing. The full reference is `src/plugins/effects/README.md`.
|
|
575
620
|
- Particles: `defineEmitter(id, config)` returns frozen data a feature lists under `emitters`. Required: `textures` (1 to 16 asset keys of one atlas page) and exactly one of `burst` (an integer, emitted once) and `rate` (particles per second while `active`). Optional with defaults: `lifeMs`, `speed`, `angle`, `gravity`, `drag`, `spin`, `shape` (`point`, `circle`, `rect`, `ring`), `scale`, `alpha`, `tint` (each `{ from, to }`), `space` (`"world"` or `"local"`), `prewarmMs`, `maxParticles` (`256`), `blend` (`"normal"` or `"add"`). A mistake throws at definition.
|
|
576
621
|
- The `Emitter({ effect, active })` component on an entity runs an emitter: `generator: item => [Sprite({ texture: "board.generator" }), Emitter({ effect: "fx.steam" })]`. `effect: ""` draws nothing. Particles step in the `animate` phase, so they freeze while paused and a fast walk emits nothing. Their random source is seeded, never `Math.random` and never the model's rng.
|
|
577
|
-
- Filters: `defineFilter(id, { wgsl, glsl, uniforms?, passes?, padding? })` returns a component type a feature lists under `filters`. Both bodies are required: `wgsl` is the WGSL `fn mainFragment` and reads a uniform as `fu.amount`; `glsl` is GLSL ES 3.0 `void main()` that sets `finalColor` and reads a uniform as `amount`. A uniform is a number (tweenable), `{ color: 0xffd700 }` or an array of 2 to 4 numbers. Every filter component also has `enabled: true` and `order: 0`. A filter covers the entity's subtree; on a ui element it goes in the `components` prop.
|
|
622
|
+
- Filters: `defineFilter(id, { wgsl, glsl, uniforms?, passes?, padding?, inputs?, fallback? })` returns a component type a feature lists under `filters`. Both bodies are required: `wgsl` is the WGSL `fn mainFragment` and reads a uniform as `fu.amount`; `glsl` is GLSL ES 3.0 `void main()` that sets `finalColor` and reads a uniform as `amount`. A uniform is a number (tweenable), `{ color: 0xffd700 }` or an array of 2 to 4 numbers. Every filter component also has `enabled: true` and `order: 0`. A filter covers the entity's subtree; on a ui element it goes in the `components` prop.
|
|
623
|
+
- Kept pictures: a still filtered view is drawn as one quad from an engine-owned picture, also while it moves (`renderer.keptMb`, default 64; `0` turns it off; `renderer.sync.debug.kept(false)` draws everything live). `defineFilter(id, { cache?: "local" | "placed" | false })`, default `"local"`; a body that reads `uOutputFrame.xy`, `uGlobalFrame`, `gl_FragCoord`, `@builtin(position)` or `uOutputTexture` and declares nothing is drawn live (dev warns `effects:filter-reads-screen`); write `canvasPixel(uv)` for the pixel's canvas position (in WGSL and GLSL) and it is kept; `cache: false` draws it live always. `app.renderer.stats()` measures `renderPasses` on the GPU path in every build and adds `renderPassesMax` (the most of one frame in the last second) and `kept: { pictures, mb, retakes }`.
|
|
624
|
+
- Filter inputs: `inputs: { blur: { stages: [FrostBox4, FrostGauss], chain: (value, { resolution }) => [{ stage, scale, uniforms? }] } }` gives the body `sampleBlur(uv)` in both languages: the last texture of a chain of stages (each a `defineFilter`, drawing alone) drawn from the view's input into pooled textures at `resolution × scale`. The chain runs only when a uniform or the resolution changed; a step naming a stage not in `stages` breaks the filter. The engine counts the passes (leave `passes` out). `fallback: OtherFilter` (a subset of the uniforms) draws when the filter is broken.
|
|
625
|
+
- Shader checks run in every build: every body, stage and fallback compiles once on the device when the renderer first draws, and the first scene mounts after the checks (`effects:mount-waited`). A broken filter (`effects:broken` with reason `compile`, `apply` or `chain`) draws its fallback or the view unfiltered, never a blank frame; a dev build also draws magenta stripes over it.
|
|
578
626
|
- Built-in filters: `Glow`, `Outline`, `Blur`, `ColorMatrix`, `Noise`, `Displacement` (`map` is an asset key), `Alpha`. A motion hook tweens them like any component: `view.tween(Glow, { strength: 0 }, { ms: 200 })`.
|
|
579
627
|
- API: `app.effects.stats()` returns `{ particles, emitters, filters, renderPasses }`; headless every number is 0.
|
|
628
|
+
- `app.effects.diagnostics`: `kinds()` (built-ins, then feature filters, after start), `disable(kind)`, `enable(kind)`, `disabled()` (the off kinds in registration order). A switch acts on a dev or agent page only and answers `false` elsewhere or for an unknown kind (`effects:diagnostics-unknown`, once per id); an off kind keeps its instances and is left out of what the renderer draws from the next frame (`stats().filters` stays, `renderPasses` drops); a switch logs `effects:diagnostics` and writes a `switch` event to the performance log.
|
|
580
629
|
- Config: `maxParticles: 3000` and `maxPasses: 24`, each warns once per crossing; `phone: "auto"`; `blur: { quality: 2, phoneResolution: 0.5 }`.
|
|
581
630
|
- With `emitters: "fx.starsBurst" | "fx.steam"` in the `defineGame` types, the `defineEmitter` id and `Emitter.effect` are checked by the compiler; `textures` and `Displacement.map` are checked against the asset keys. No events. Depends on `flow`, `world`, `renderer`, `assets`, `anim`.
|
|
582
631
|
|
|
@@ -600,11 +649,11 @@ A plugin that owns a resource stores it in its state and frees it in `onStop: ({
|
|
|
600
649
|
|
|
601
650
|
Two subpaths, neither on the root (V4). An editor, MCP tools and e2e scripts reach a running game through them; `docs/doors.md` in the engine repository has the full catalogue.
|
|
602
651
|
|
|
603
|
-
- `@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`
|
|
604
|
-
- `@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.
|
|
605
|
-
- Commands run in dev builds
|
|
606
|
-
- 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
|
|
607
|
-
- A game's own: `defineSource({ id, title, input, changes, read })` with `changes` `"frame"`, `"commit"` or `"edge"`, and `defineCommand({ id, title, input, effect, run })
|
|
652
|
+
- `@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`, `filters`, `assets`, `log`, `explain`, `diff`, `schema`, `at`; their ids are `game.<key>`. `filters` answers `{ kinds, off }` of `effects.diagnostics`. `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` and `filters` throw `[game] The source game.effects needs effectsPlugin.` (`game.filters` for `filters`) when that plugin is not installed; the editor lists such a source as not installed.
|
|
653
|
+
- `@moku-labs/game/control`: `run(app, command, input?)` resolves `{ value, state }`, `state` being `{ path, frame, tainted }`; `defineCommand`; `controlRefused`; `controlReadOnly`; the frozen catalogue `commands`; the types `Command`, `Ran`, `ControlReadOnlyError`. Keys of `commands`: `answer`, `tap`, `drag`, `key`, `fill`, `walk`, `bookmark`, `restore`, `saveExport`, `saveImport`, `step`, `pause`, `resume`, `capture`, `debug`, `reducedMotion`, `timeScale`, `frameRate`, `mute`, `trace`, `filter`. `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. `frameRate` takes `{ fps? }`, calls `time.setRate(fps ?? "auto")` and answers `time.rate()`. `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. `filter` takes `{ kind, on }`, switches the filter kind through `effects.diagnostics` (cosmetic) and answers the off kinds; an unknown kind throws `[game] No filter kind "fx.nope".` naming the known ones; without `effectsPlugin` it throws `[game] The command game.filter needs effectsPlugin.`
|
|
654
|
+
- Commands run in dev builds and debug builds. `__MOKU_GAME_DEV__ === true` (the dev page, a test's `vi.stubGlobal("__MOKU_GAME_DEV__", true)`) or `__MOKU_GAME_CONTROL__ === true` (`--debug=full`) runs every command; `__MOKU_GAME_AGENT__ === true` alone (`--debug`) runs the `read` and `cosmetic` ones and `run` refuses the others with `controlReadOnly(id)`, an error with `reason: "read-only"`: `[game] The command game.tap changes the game: this debug build runs read and cosmetic commands only.`; no flag: `run` throws `[game] Control commands run in dev builds only.` `moku-game build` defines the three flags `false`; a game without the shell uses a bundler `define` and should add `__MOKU_GAME_AGENT__: "false"` and `__MOKU_GAME_CONTROL__: "false"`. `saveExport` answers the committed `{ player, session, rng }` (read); `saveImport` takes `{ save }` of `saveExport`, restores it at the main flow's start and flushes the store (raw, `--debug=full` only).
|
|
655
|
+
- 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 `restore` and `saveImport` are `raw`; `capture` with `diff` writes the state too, so a `--debug` page refuses it. `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, also on a page that shows another scene. A bookmark taken while an effect that takes answers waits names the waiting transit node (`info/show` in the mini game) and carries `rest`; `restore` of it answers once that node's gate is open, with `pending.gate` in its value, and the popup shows on the next frame.
|
|
656
|
+
- A game's own: `defineSource({ id, title, input, changes, read })` with `changes` `"frame"`, `"commit"` or `"edge"`, and `defineCommand({ id, title, input, effect, run, overlay?, value? })`: `overlay: "toggle"` (input exactly `{ on: "boolean" }` and `value: app => boolean`, the switch's state) or `overlay: "button"` (no required input, no value) lists the command in the editor's overlay, which shows only such commands; any other combination is a type error. 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();`: it runs in dev and is refused on a debug page. A `.dev` command meant for a debug page writes the agent form for a read or cosmetic effect, `if ((typeof __MOKU_GAME_DEV__ === "undefined" || !__MOKU_GAME_DEV__) && (typeof __MOKU_GAME_AGENT__ === "undefined" || !__MOKU_GAME_AGENT__)) throw controlRefused();`, and the control form (`__MOKU_GAME_CONTROL__` in place of `__MOKU_GAME_AGENT__`) for the others. Keep them in `.dev` modules that only the dev page, a debug page and the tests import: the editor's dev page (`preparePage` with agents) and `moku-game build --debug` import every `**/*.dev.ts`, `moku-game build` none.
|
|
608
657
|
|
|
609
658
|
## Project index
|
|
610
659
|
|
|
@@ -621,10 +670,11 @@ project.close();
|
|
|
621
670
|
```
|
|
622
671
|
|
|
623
672
|
- `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.`
|
|
624
|
-
- 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.
|
|
625
|
-
- Keys: `flow:<id>`, `node:<flow>/<node>` (every entry of a `nodes` table: node, sub-flow, slot), `feature:<id
|
|
673
|
+
- 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, and `@features/episodes/ep01` the door of a sub-feature. `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.
|
|
674
|
+
- Keys: `flow:<id>`, `node:<flow>/<node>` (every entry of a `nodes` table: node, sub-flow, slot), `feature:<id>` (at any depth; a feature made by a factory has no `feature:` key, its folder is in `features`), `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.
|
|
626
675
|
- 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.
|
|
627
|
-
- `
|
|
676
|
+
- `index.features` holds every feature by name, its folder and, for a sub-feature, its parent: `{ ep01: { dir: "features/episodes/ep01", parent: "episodes" }, home: { dir: "features/home" } }`. The feature owners and the code-only sub-features a door lists are in it; a folder that becomes or stops being a sub-feature rebuilds the index and its name is in `change.features`.
|
|
677
|
+
- `ProjectChange` is `{ revision, files, moved: [{ key, from, to }], removed, features? }`. 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.
|
|
628
678
|
- 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.
|
|
629
679
|
|
|
630
680
|
## Hot swap
|
|
@@ -636,9 +686,9 @@ Dev only. A save of a view file swaps the module in the running page: same state
|
|
|
636
686
|
plugins = ["@moku-labs/game/hot"]
|
|
637
687
|
```
|
|
638
688
|
|
|
639
|
-
- View files swap: `.tsx`, `styles.ts`, `view.ts`, `animations.ts`, `effects.ts`, `generated/strings.<locale>.ts
|
|
689
|
+
- View files swap: `.tsx`, `styles.ts`, `view.ts`, `animations.ts`, `effects.ts`, `generated/strings.<locale>.ts` and a sub-feature's `generated/strings.<feature>.<locale>.ts` (while its bundle is not loaded the new module waits for the next load). 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. Under `moku-game dev` or the editor's `watchKeys` a save of a strings JSON compiles by itself and its output swaps, a new key too. A game with a server of its own runs `bun run assets:keys` after a strings JSON edit; its output is what swaps.
|
|
640
690
|
- 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.
|
|
641
|
-
- Asset files, under `moku-game dev` on the raw files or the editor's `watchKeys`: a texture, a bitmap font (`.fnt` or its page) or a sound saved with new bytes swaps in the loaded bundles, same key, no reload. The keys watch writes `.moku/assets-stamp.ts` `{ files, changed }`, the hot plugin lets the page accept it, and `assets` reads the changed files again. A new, removed or renamed asset file, a changed nine-slice tag too, reloads the page. So does a replace that fails. Not with `--packed`, not in a built game. A bundle that is not loaded reads the new bytes on its next load. A sound that is playing plays on; a running particle effect of a replaced texture starts again.
|
|
691
|
+
- Asset files, under `moku-game dev` on the raw files or the editor's `watchKeys`: a texture, a bitmap font (`.fnt` or its page) or a sound saved with new bytes swaps in the loaded bundles, same key, no reload. The keys watch writes `.moku/assets-stamp.ts` `{ files, changed }`, the hot plugin lets the page accept it, and `assets` reads the changed files again. An asset file of a sub-feature swaps the same way. A new, removed or renamed asset file, a changed nine-slice tag too, reloads the page. So does a replace that fails. Not with `--packed`, not in a built game. A bundle that is not loaded reads the new bytes on its next load. A sound that is playing plays on; a running particle effect of a replaced texture starts again.
|
|
642
692
|
- A swap logs `ui:hot-swap` and emits the global event `ui:hot-swap` `{ file, module }`. `effects` hooks it for emitters, `assets` for the stamp module.
|
|
643
693
|
- Event `assets:replaced` `{ bundle, keys }`, dev only: `assets` emits it once per bundle after an asset swap. `keys` are the asset keys with new bytes; `app.assets.texture(key)`, `font(key)` and `audio(key)` answer the new object and the old textures are destroyed. `text`, `effects` and `audio` hook it; a game plugin that built something from a key hooks it with `depends: [assetsPlugin]` and builds it again.
|
|
644
694
|
- 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"]`.
|
|
@@ -646,7 +696,7 @@ plugins = ["@moku-labs/game/hot"]
|
|
|
646
696
|
|
|
647
697
|
## Fonts
|
|
648
698
|
|
|
649
|
-
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 `
|
|
699
|
+
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 the `assets/` of every feature owner (a feature, or a sub-feature) and of the layers of `--layer`. The licence goes next to `assets/`, not inside it.
|
|
650
700
|
|
|
651
701
|
```bash
|
|
652
702
|
mkdir -p src/features/ui/assets
|
|
@@ -659,7 +709,7 @@ The scanner keys it `ui.font-body`, which is the `text` config default `fonts.bo
|
|
|
659
709
|
|
|
660
710
|
## Testing entry
|
|
661
711
|
|
|
662
|
-
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).
|
|
712
|
+
Exports of `@moku-labs/game/testing`: `createHeadless`, `runRepro`, `stepFrames`, `fakeClock`, `memory`, `saveOf`, `isolate`, `stub`, `fakeFx`. `fakeFx(options?)` is the `fx` of a node context for a unit test that calls `node.run?.({ ...context, fx: fake.fx })`: it returns `{ fx, awaited, started, emitted }`, `options.answer(descriptor)` gives what an awaited effect resolves with, and `fx.start` refuses `answers` and a `guide` as the game does. The visual tests `defineVisualTest`, `runVisualTests`, `parseVisualArgv` come from `@moku-labs/game/visual`, node and bun only (see `docs/testing.md` in the engine repository).
|
|
663
713
|
|
|
664
714
|
- `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.
|
|
665
715
|
- `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()`.
|
|
@@ -699,6 +749,7 @@ Isolated feature tests play one feature without the rest of the game:
|
|
|
699
749
|
- A checkpoint compacts `history()`: assert on `state()` or on `flow:edge` events.
|
|
700
750
|
- Every exit of a flow with outcomes lands on the rest node `exited`; `{ at: "exited", intent: "again" }` enters the flow once more.
|
|
701
751
|
- A wrong stub key or outcome does not compile, and throws at run time: `[game] isolate: no node "enrgy" in flow "board".`
|
|
752
|
+
- The app's `model.store.snapshot()` is typed by the `player` and `session` of the call, inferred, as `game.headless()` types it: `snapshot().player.count` is a `number`, no cast. A `Model.Json` player reads `Model.Json`.
|
|
702
753
|
|
|
703
754
|
```ts
|
|
704
755
|
import { createHeadless, isolate, stub } from "@moku-labs/game/testing";
|
|
@@ -715,7 +766,7 @@ game.state().path; // "main/home"
|
|
|
715
766
|
await game.stop();
|
|
716
767
|
```
|
|
717
768
|
|
|
718
|
-
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.
|
|
769
|
+
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; with sub-features it passes the flattened tree, `plugins: flattenFeatures([homeFeature.logicOnly, episodesFeature.logicOnly])` (the `logicOnly` is taken before flattening: a parent's carries its children's).
|
|
719
770
|
|
|
720
771
|
## File layout of a game
|
|
721
772
|
|
|
@@ -725,12 +776,13 @@ config.ts export default { page, native?, system?, save?, assets? } sati
|
|
|
725
776
|
core/ the kit (the defineGame call) and its types
|
|
726
777
|
shared/ the shared layer
|
|
727
778
|
features/<f>/ one feature: flow/, rules/, views/, styles/, motion/, world/, assets/, strings/
|
|
728
|
-
|
|
779
|
+
features/<f>/<sub>/ a sub-feature: index.ts, assets/ or assets.ts or strings/, and the kind folders
|
|
780
|
+
plugins/ the game's own plugins
|
|
729
781
|
generated/ what moku-game keys writes: assets.ts, the strings, manifest.json
|
|
730
782
|
tests/ the tests; tests/scenarios/<name>.ts are the saves of ?player=<name>
|
|
731
783
|
.moku/ what moku-game writes, ignored by git
|
|
732
784
|
```
|
|
733
785
|
|
|
734
|
-
Rules are pure functions in `rules/`: no engine import, no clock, no random. The tsconfig aliases are `@core/*`, `@shared`, `@features
|
|
786
|
+
Rules are pure functions in `rules/`: no engine import, no clock, no random. The tsconfig aliases are `@core/*`, `@shared`, `@features/*` (`@features/episodes/ep01` reaches a sub-feature). 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/`.
|
|
735
787
|
|
|
736
|
-
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),
|
|
788
|
+
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), and `tests/fixtures/episodes-game/`, a game on the layered layout with a parent feature `episodes` and the sub-features `episodes-kit`, `ep01` and `ep02`; neither is 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
|