@moku-labs/game 0.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +428 -0
- package/dist/index.d.mts +327 -0
- package/dist/index.mjs +6008 -0
- package/dist/registry-DWV5C0Mf.mjs +666 -0
- package/dist/rolldown-runtime-D7D4PA-g.mjs +13 -0
- package/dist/testing.d.mts +161 -0
- package/dist/testing.mjs +366 -0
- package/dist/types-BfsmUzLC.d.mts +1908 -0
- package/package.json +81 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 moku-labs
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
# @moku-labs/game
|
|
2
|
+
|
|
3
|
+
**A 2D puzzle game engine where the game is a deterministic graph of business logic.**
|
|
4
|
+
|
|
5
|
+
`@moku-labs/game` is a Layer-2 framework on [`@moku-labs/core`](https://github.com/moku-labs/core), written in TypeScript, with PixiJS v8 as a peer dependency. You write small nodes and edge tables. The engine runs them, commits state on the edges, saves at rest points and replays the same game without a screen. It is not a general-purpose engine and it ships no genre rules: no match-3, no merge, no physics. Today it is the logic half only. The screen arrives in V2.
|
|
6
|
+
|
|
7
|
+
<br/>
|
|
8
|
+
|
|
9
|
+
[](#status)
|
|
10
|
+
[](#requirements)
|
|
11
|
+
[](#requirements)
|
|
12
|
+
[](#requirements)
|
|
13
|
+
[](https://github.com/moku-labs/core)
|
|
14
|
+
[](./LICENSE)
|
|
15
|
+
|
|
16
|
+
<br/>
|
|
17
|
+
|
|
18
|
+
[Why](#why-moku-labsgame) · [Status](#status) · [Install](#install) · [Quick start](#quick-start) · [How it works](#how-it-works) · [Plugins](#plugins) · [Events](#events) · [Configuration](#configuration) · [Development](#development) · [Requirements](#requirements) · [Docs](#docs)
|
|
19
|
+
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
## Why @moku-labs/game
|
|
23
|
+
|
|
24
|
+
- **The game is a graph.** Flows are edge tables, nodes are small functions with plain `await` bodies. Exactly one node is active at any time.
|
|
25
|
+
- **State commits only on edges.** A node works on drafts. The runner commits them when the node returns an outcome. A node that throws changes nothing.
|
|
26
|
+
- **Position is data.** Node path, input and state describe the whole game. That gives checkpoints, rollback, bookmarks, fast walk and repro runs.
|
|
27
|
+
- **Input and world events are answers.** A rest node waits. A player answer comes through the gate, a world event comes through the inbox. Nothing else moves the graph.
|
|
28
|
+
- **The screen is a projection, not the game.** Rendering reads committed state and never owns it. The projection arrives in V2. V1 plays whole games headless.
|
|
29
|
+
- **Deterministic by construction.** Time is an input named `now`. Randomness is a persisted `rng` stream. Lint rule L3 refuses `Date.now` and `Math.random` in the logic set.
|
|
30
|
+
|
|
31
|
+
## Status
|
|
32
|
+
|
|
33
|
+
Only V1 exists. Everything else in this table is a plan and may change.
|
|
34
|
+
|
|
35
|
+
| Milestone | State | Scope | Exit criterion |
|
|
36
|
+
|---|---|---|---|
|
|
37
|
+
| V1 | built | `time`, `lifecycle`, `model`, `clock`, `flow`, the `@moku-labs/game/testing` entry | A fixture game is played to the end headless |
|
|
38
|
+
| V2 | planned | `world`, `renderer`, `input`, `assets`, `scenes` | A board is visible and items move by drag |
|
|
39
|
+
| V3 | planned | `anim`, `i18n`, `audio`, `text`, `ui` | Popup, HUD and buttons with sound |
|
|
40
|
+
| V4 | planned | `/inspect` and `/control` entries | External tools can read and drive a game |
|
|
41
|
+
| V5 | planned | `effects`, production mode of `assets`, visual test helpers | Not defined yet |
|
|
42
|
+
| V6 | planned | `platform` | A template game runs on a phone |
|
|
43
|
+
|
|
44
|
+
Rendering decision for V2: WebGPU is preferred, with Pixi's WebGL fallback.
|
|
45
|
+
|
|
46
|
+
## Install
|
|
47
|
+
|
|
48
|
+
```sh
|
|
49
|
+
bun add @moku-labs/game pixi.js
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
> [!NOTE]
|
|
53
|
+
> **Status: `0.0.0`, not published yet.** The package is not on npm. The command above is the intended install line. `pixi.js` `^8.0.0` is a peer dependency. No V1 code imports it.
|
|
54
|
+
|
|
55
|
+
> [!IMPORTANT]
|
|
56
|
+
> Bun only. ESM only. `"sideEffects": false`. There is no CJS build.
|
|
57
|
+
|
|
58
|
+
## Quick start
|
|
59
|
+
|
|
60
|
+
A tiny dice game: one rest node, two transit nodes, one flow.
|
|
61
|
+
|
|
62
|
+
**1. Bind the helpers to the types of the game.** `defineNode` and `defineFlow` come from `defineGame`. They are not root exports.
|
|
63
|
+
|
|
64
|
+
```ts
|
|
65
|
+
// state.ts
|
|
66
|
+
import { defineGame } from "@moku-labs/game";
|
|
67
|
+
|
|
68
|
+
export type Player = { coins: number; lastRoll: number };
|
|
69
|
+
export type Session = { rolls: number };
|
|
70
|
+
|
|
71
|
+
export const { defineNode, defineFlow, defineFeature } = defineGame<{
|
|
72
|
+
player: Player;
|
|
73
|
+
session: Session;
|
|
74
|
+
assets: string;
|
|
75
|
+
strings: Record<string, unknown>;
|
|
76
|
+
}>();
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
**2. Write the nodes.** A rest node without a body is a pure wait. The intent of the answer names the outcome.
|
|
80
|
+
|
|
81
|
+
```ts
|
|
82
|
+
// nodes.ts
|
|
83
|
+
import { type } from "@moku-labs/game";
|
|
84
|
+
import { defineNode } from "./state";
|
|
85
|
+
|
|
86
|
+
export const home = defineNode({
|
|
87
|
+
outcomes: { roll: type(), reset: type() },
|
|
88
|
+
rest: true,
|
|
89
|
+
checkpoint: true
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
export const roll = defineNode({
|
|
93
|
+
outcomes: { done: type() },
|
|
94
|
+
run: ({ player, session, rng, out }) => {
|
|
95
|
+
const face = rng.stream("dice").range(1, 6);
|
|
96
|
+
|
|
97
|
+
player.coins += face;
|
|
98
|
+
player.lastRoll = face;
|
|
99
|
+
session.rolls += 1;
|
|
100
|
+
return out.done();
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
export const reset = defineNode({
|
|
105
|
+
outcomes: { done: type() },
|
|
106
|
+
run: ({ player, out }) => {
|
|
107
|
+
player.coins = 0;
|
|
108
|
+
return out.done();
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
**3. Wire the flow.** The edge table is checked by the compiler. A missing edge or an unknown target is a compile error with a sentence.
|
|
114
|
+
|
|
115
|
+
```ts
|
|
116
|
+
// flow.ts
|
|
117
|
+
import { defineFlow } from "./state";
|
|
118
|
+
import { home, reset, roll } from "./nodes";
|
|
119
|
+
|
|
120
|
+
export const mainFlow = defineFlow("main", {
|
|
121
|
+
nodes: { home, roll, reset },
|
|
122
|
+
start: "home",
|
|
123
|
+
edges: {
|
|
124
|
+
home: { roll: "roll", reset: "reset" },
|
|
125
|
+
roll: { done: "home" },
|
|
126
|
+
reset: { done: "home" }
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
**4. Create the app.** The graph is started by the game, never by the plugin.
|
|
132
|
+
|
|
133
|
+
```ts
|
|
134
|
+
// game.ts
|
|
135
|
+
import { createApp } from "@moku-labs/game";
|
|
136
|
+
import { mainFlow } from "./flow";
|
|
137
|
+
|
|
138
|
+
export const createGame = (seed: "from-save" | number = "from-save") =>
|
|
139
|
+
createApp({
|
|
140
|
+
pluginConfigs: {
|
|
141
|
+
model: { initialPlayer: { coins: 0, lastRoll: 0 }, initialSession: { rolls: 0 }, seed },
|
|
142
|
+
flow: { mainFlow, safeNode: "home" }
|
|
143
|
+
},
|
|
144
|
+
onStart: ctx => {
|
|
145
|
+
ctx.flow.run().catch((error: unknown) => {
|
|
146
|
+
ctx.log.error("game: the graph failed", { error });
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
**5. Play it headless.** `createHeadless` returns a game object once the graph rests at its first
|
|
153
|
+
rest node. Its `walk` method plays a route.
|
|
154
|
+
|
|
155
|
+
```ts
|
|
156
|
+
// game.test.ts
|
|
157
|
+
import type { Flow } from "@moku-labs/game";
|
|
158
|
+
import { createHeadless } from "@moku-labs/game/testing";
|
|
159
|
+
import { expect, it } from "vitest";
|
|
160
|
+
import { createGame } from "./game";
|
|
161
|
+
|
|
162
|
+
const rollOnce: Flow.RouteStep = { at: "home", intent: "roll" };
|
|
163
|
+
|
|
164
|
+
it("plays two rolls and a reset without a screen", async () => {
|
|
165
|
+
const app = createGame(42);
|
|
166
|
+
const game = await createHeadless(app);
|
|
167
|
+
|
|
168
|
+
const state = await game.walk([rollOnce, rollOnce]);
|
|
169
|
+
|
|
170
|
+
expect(state.path).toBe("home");
|
|
171
|
+
expect(app.model.store.snapshot().session).toEqual({ rolls: 2 });
|
|
172
|
+
|
|
173
|
+
await game.walk([{ at: "home", intent: "reset" }]);
|
|
174
|
+
|
|
175
|
+
expect(app.model.store.snapshot().player).toMatchObject({ coins: 0 });
|
|
176
|
+
|
|
177
|
+
await game.stop();
|
|
178
|
+
});
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
In a live game the same answer comes from the screen: `app.flow.gate.answer({ intent: "roll" })`.
|
|
182
|
+
|
|
183
|
+
> [!TIP]
|
|
184
|
+
> Types reach a game through one namespace per plugin: `import type { Flow, Model, Clock, Lifecycle, Time } from "@moku-labs/game"`, then `Flow.RouteStep`, `Model.PlayerStateProvider`, `Time.Phase`.
|
|
185
|
+
|
|
186
|
+
> [!TIP]
|
|
187
|
+
> A larger worked example lives in [`tests/integration/merge-game/`](./tests/integration/merge-game). It is a small game written on the public API only, with sub-flows, a slot, a feature and timers. It is an internal test fixture and is not published. Its scenario is [`tests/integration/template-merge.test.ts`](./tests/integration/template-merge.test.ts).
|
|
188
|
+
|
|
189
|
+
## How it works
|
|
190
|
+
|
|
191
|
+
```mermaid
|
|
192
|
+
flowchart LR
|
|
193
|
+
P["Player answer<br/>gate.answer"] --> R["Rest node<br/>waits"]
|
|
194
|
+
W["World event<br/>inbox.post"] --> R
|
|
195
|
+
R --> N["Transit node<br/>works on drafts"]
|
|
196
|
+
N --> E["Edge<br/>commit, journal, flow:edge"]
|
|
197
|
+
E --> R
|
|
198
|
+
E --> M["model<br/>committed state and save"]
|
|
199
|
+
M --> S["Screen<br/>projection, V2"]
|
|
200
|
+
classDef u fill:#0b7285,stroke:#08525f,color:#fff;
|
|
201
|
+
classDef m fill:#1864ab,stroke:#0d3d6e,color:#fff;
|
|
202
|
+
class P,W,S u
|
|
203
|
+
class R,N,E,M m
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
### The contract
|
|
207
|
+
|
|
208
|
+
| Term | Meaning |
|
|
209
|
+
|---|---|
|
|
210
|
+
| Node | `defineNode({ input?, outcomes, run })`. The body gets one context object and returns `out.name(data)`. |
|
|
211
|
+
| Node context | `{ input, player, session, rng, fx, out, signal, now }`. `player` and `session` are drafts. `now` is `clock.now()` read once at node entry. |
|
|
212
|
+
| Rest node | `rest: true`. The graph waits here. Without `run` it is a pure wait for a gate answer or an inbox event. Entering it marks a rest point. |
|
|
213
|
+
| `checkpoint` | A rest node where the journal is compacted. `safeNode` points at one. |
|
|
214
|
+
| `barrier` | After the edge of this node the save is written durably with `commitDurable`. Rollback cannot cross it. Every edge of a barrier node leads to a rest node of the same flow. |
|
|
215
|
+
| `over` | The node waits while a pointer is down, so a popup never appears in the middle of a drag. |
|
|
216
|
+
| `inbox` | Outcome names of a rest node that a world event of the same type may produce. |
|
|
217
|
+
| Flow | `defineFlow(id, { nodes, start, edges, input?, outcomes? })`. A flow with `outcomes` is used as a node of another flow. |
|
|
218
|
+
| Edge target | A node name, `exit("outcome")` to leave a sub-flow, or `to("node", payload => input)` to adapt the payload. |
|
|
219
|
+
| Slot | `slot("name")`. An extension point. Features contribute sub-flows to it with an `order`. |
|
|
220
|
+
| Feature | `defineFeature(name, { nodes?, flows?, contribute? })`. An ordinary plugin that registers into `flow.features`. `feature.logicOnly` is the headless twin. |
|
|
221
|
+
| Effect | `await fx(descriptor)` for awaited effects, `fx.emit(hint(kind, payload))` for cosmetic ones. Hints are released after the commit and dropped in fast mode. |
|
|
222
|
+
| Transition | Rest node to rest node. It is a transaction. An error rolls back to the last rest point, retries, then enters `safeNode`. |
|
|
223
|
+
|
|
224
|
+
## Plugins
|
|
225
|
+
|
|
226
|
+
Seven plugins are on every app today. `log` and `env` come from [`@moku-labs/common`](https://github.com/moku-labs/common) and sit on every plugin context as `ctx.log` and `ctx.env`.
|
|
227
|
+
|
|
228
|
+
### Built
|
|
229
|
+
|
|
230
|
+
| Plugin | Tier | Owns | Key API |
|
|
231
|
+
|---|---|---|---|
|
|
232
|
+
| [`time`](./src/plugins/time/README.md) | Standard | The single `requestAnimationFrame` loop, six frame phases, the `Time` resource | `onFrame(phase, callback)`, `snapshot()`, `setScale(scale)`, `pause()`, `resume()`, `isPaused()`, `isRunning()`, `step(deltaMs)` |
|
|
233
|
+
| [`lifecycle`](./src/plugins/lifecycle/README.md) | Standard | The stack of pause reasons. Pauses `time` by a direct call | `push(reason)`, `pop(reason)`, `reasons()`, `isPaused()` |
|
|
234
|
+
| [`model`](./src/plugins/model/README.md) | Very Complex | The `session` tree and the save document `{ player, rng }`, transactions, rest-point rollback, rng streams | `store.load()`, `store.snapshot()`, `store.begin()`, `store.markRest()`, `store.markBarrier(txId)`, `store.rollback()`, `store.restore(input)`, `store.flush()`, `rng.peek(id)` |
|
|
235
|
+
| [`clock`](./src/plugins/clock/README.md) | Standard | Trusted time as an input: monotonic `now()` and one `elapsed` signal at the next due moment | `now()`, `scheduleAt(moment)`, `onElapsed(listener)`, `poke()`, `dueAt()` |
|
|
236
|
+
| [`flow`](./src/plugins/flow/README.md) | Very Complex | The graph: runner, gate, inbox, effects gateway, features registry | `run()`, `register(flow)`, `onEnter(stage, callback)`, `walk(route, options?)`, `bookmark()`, `restore(bookmark)`, `describe()`, `state()`, `history()`, `setMode(mode)`, `gate.answer(answer)`, `gate.pointer(active)`, `gate.state()`, `inbox.post(event)`, `fx.handle(kind, handler, options?)`, `fx.dispatch(descriptor)`, `features.register(name, description)`, `features.all()`, `features.contributions(slotName)` |
|
|
237
|
+
|
|
238
|
+
```mermaid
|
|
239
|
+
flowchart LR
|
|
240
|
+
G["Game<br/>createApp, features"] --> F["flow"]
|
|
241
|
+
F --> T["time"]
|
|
242
|
+
F --> L["lifecycle"]
|
|
243
|
+
F --> M["model"]
|
|
244
|
+
F --> C["clock"]
|
|
245
|
+
L --> T
|
|
246
|
+
classDef u fill:#0b7285,stroke:#08525f,color:#fff;
|
|
247
|
+
classDef m fill:#1864ab,stroke:#0d3d6e,color:#fff;
|
|
248
|
+
class G u
|
|
249
|
+
class F,T,L,M,C m
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
An arrow means "depends on". `time`, `model` and `clock` depend on nothing. The plugins are registered in this order: `time`, `lifecycle`, `model`, `clock`, `flow`.
|
|
253
|
+
|
|
254
|
+
### Planned
|
|
255
|
+
|
|
256
|
+
Not built. Names are reserved: `defineFeature` refuses them as feature names. Scope and tiers come from the plan and may change.
|
|
257
|
+
|
|
258
|
+
| Plugin | Milestone | Tier | Depends on | Will own |
|
|
259
|
+
|---|---|---|---|---|
|
|
260
|
+
| `world` | V2 | Very Complex | `time`, `model`, `flow` | ECS world and the projections of the model |
|
|
261
|
+
| `renderer` | V2 | Very Complex | `time`, `lifecycle`, `world` | The Pixi host, sync and viewport. Pixi is loaded lazily |
|
|
262
|
+
| `input` | V2 | Standard | `time`, `flow`, `world`, `renderer` | Pointer listeners on the canvas |
|
|
263
|
+
| `assets` | V2 | Complex | `flow`, `renderer` | Manifest, tiers, preload, budget |
|
|
264
|
+
| `scenes` | V2 | Standard | `flow`, `world`, `assets` | Scenes as data, switched through `flow.onEnter` |
|
|
265
|
+
| `anim` | V3 | Complex | `flow`, `world` | Tweens and motion |
|
|
266
|
+
| `i18n` | V3 | Standard | `flow`, `world`, `assets` | String tables per locale |
|
|
267
|
+
| `text` | V3 | Complex | `world`, `renderer`, `assets`, `i18n` | Text rendering and the text field |
|
|
268
|
+
| `ui` | V3 | Very Complex | `flow`, `world`, `renderer`, `anim`, `i18n`, `text` | JSX components, styles, layout |
|
|
269
|
+
| `audio` | V3 | Standard | `lifecycle`, `flow`, `assets`, `scenes` | Audio context and unlock |
|
|
270
|
+
| `effects` | V5 | Complex | `flow`, `world`, `renderer` | Particles, filters, frames |
|
|
271
|
+
| `platform` | V6 | Standard | `lifecycle`, `flow` | The native provider: background, system dialogs |
|
|
272
|
+
|
|
273
|
+
### Root exports
|
|
274
|
+
|
|
275
|
+
| Export | Kind | Purpose |
|
|
276
|
+
|---|---|---|
|
|
277
|
+
| `createApp` | function | Creates a game application |
|
|
278
|
+
| `createPlugin` | function | Creates a game plugin bound to the engine's config and events |
|
|
279
|
+
| `defineGame` | function | Returns `{ defineNode, defineFlow, defineFeature }` typed with the game's `player` and `session` |
|
|
280
|
+
| `defineFeature` | function | Turns a feature description into a plugin |
|
|
281
|
+
| `type`, `exit`, `to`, `slot` | functions | Type tag of a payload, and the three graph helpers for edge targets and slots |
|
|
282
|
+
| `schedule`, `guide`, `hint` | functions | Effect descriptors: next due moment, tutorial narrowing of the gate, cosmetic hint |
|
|
283
|
+
| `SaveUnreadableError` | class | Thrown by `model.store.load()` when the save cannot be read |
|
|
284
|
+
| `teardown` | object | `teardown.register(global, key, dispose)` and `teardown.run(global, key)` for plugins that own a resource |
|
|
285
|
+
| `timePlugin`, `lifecyclePlugin`, `modelPlugin`, `clockPlugin`, `flowPlugin` | plugin instances | For `depends` and `ctx.require` in game plugins |
|
|
286
|
+
| `Time`, `Lifecycle`, `Model`, `Clock`, `Flow` | type namespaces | All public types of one plugin |
|
|
287
|
+
|
|
288
|
+
### Testing entry
|
|
289
|
+
|
|
290
|
+
`@moku-labs/game/testing` re-exports the headless helpers.
|
|
291
|
+
|
|
292
|
+
| Export | Signature | Purpose |
|
|
293
|
+
|---|---|---|
|
|
294
|
+
| `createHeadless` | `(app: HeadlessApp) => Promise<HeadlessGame>` | Sets flow mode `"fast"`, starts the app, starts `flow.run()` unless the app already did, and waits for the first rest point |
|
|
295
|
+
| `runRepro` | `(app: HeadlessApp, repro: Repro) => Promise<ReproResult>` | Restores a state and a checkpoint, then walks a route |
|
|
296
|
+
| `stepFrames` | `(app: HeadlessApp, count: number, deltaMs: number) => void` | Calls `app.time.step(deltaMs)` `count` times |
|
|
297
|
+
| `fakeClock` | `(start = 0) => FakeClock` | A `ClockSource` with `advance(ms)` and `set(moment)` |
|
|
298
|
+
| `memory` | `(fixture?: { state: SaveDoc; version: number }) => PlayerStateProvider & { calls: ProviderCall[] }` | In-memory save provider. It keeps what it was committed and records every call |
|
|
299
|
+
| `saveOf` | `(player: Json, seed?: number) => SaveDoc` | Builds a save document for a fixture |
|
|
300
|
+
|
|
301
|
+
A `HeadlessGame` has `walk(route)`, `answer(answer)`, `state()`, `history()` and `stop()`.
|
|
302
|
+
|
|
303
|
+
## Events
|
|
304
|
+
|
|
305
|
+
Global events are empty: every event belongs to a plugin. `time` and `clock` emit nothing.
|
|
306
|
+
|
|
307
|
+
| Event | Emitted by | Payload | When |
|
|
308
|
+
|---|---|---|---|
|
|
309
|
+
| `lifecycle:changed` | `lifecycle` | `{ reason: PauseReason; action: "push" \| "pop"; reasons: readonly PauseReason[]; paused: boolean; resumed: boolean }` | The pause stack really changed. `resumed` is true only on the change that emptied the stack |
|
|
310
|
+
| `model:committed` | `model` | `{ roots: readonly Root[]; cause: "edge" \| "rollback" \| "restore" \| "load" }` | Committed state changed. `Root` is `"player" \| "session" \| "rng"` |
|
|
311
|
+
| `flow:edge` | `flow` | `{ flow: string; node: string; outcome: string; payload: Json; next: string; patches: { doc: Patch[]; session: Patch[] }; index: number; now: number }` | After the commit of an edge |
|
|
312
|
+
| `flow:rest` | `flow` | `{ path: string; checkpoint: boolean }` | The graph entered a rest node |
|
|
313
|
+
| `flow:error` | `flow` | `{ path: string; error: unknown; rolledBackTo: string; retry: boolean }` | A node failed and the graph rolled back |
|
|
314
|
+
|
|
315
|
+
```ts
|
|
316
|
+
import { createPlugin, flowPlugin } from "@moku-labs/game";
|
|
317
|
+
|
|
318
|
+
export const edgeLog = createPlugin("edgeLog", {
|
|
319
|
+
depends: [flowPlugin],
|
|
320
|
+
hooks: ctx => ({
|
|
321
|
+
"flow:edge": payload => {
|
|
322
|
+
ctx.log.info("edge", { node: payload.node, outcome: payload.outcome });
|
|
323
|
+
}
|
|
324
|
+
})
|
|
325
|
+
});
|
|
326
|
+
```
|
|
327
|
+
|
|
328
|
+
## Configuration
|
|
329
|
+
|
|
330
|
+
### Global
|
|
331
|
+
|
|
332
|
+
```ts
|
|
333
|
+
createApp({ config: { orientation: "landscape", referenceSide: 1080 } });
|
|
334
|
+
```
|
|
335
|
+
|
|
336
|
+
| Key | Type | Default | Meaning |
|
|
337
|
+
|---|---|---|---|
|
|
338
|
+
| `orientation` | `"portrait" \| "landscape"` | `"portrait"` | Screen orientation the game is designed for |
|
|
339
|
+
| `referenceSide` | `number` | `1080` | Short side of the reference resolution in pixels |
|
|
340
|
+
|
|
341
|
+
### Per plugin
|
|
342
|
+
|
|
343
|
+
Set with `createApp({ pluginConfigs: { <plugin>: { ... } } })`.
|
|
344
|
+
|
|
345
|
+
| Plugin | Key | Type | Default | Meaning |
|
|
346
|
+
|---|---|---|---|---|
|
|
347
|
+
| `time` | `maxFps` | `30 \| 60 \| 120` | `60` | Frame rate cap |
|
|
348
|
+
| `time` | `maxDeltaMs` | `number` | `50` | Upper bound of one frame's delta in milliseconds |
|
|
349
|
+
| `lifecycle` | none | | | The plugin has no config |
|
|
350
|
+
| `model` | `playerProvider` | `PlayerStateProvider \| undefined` | `undefined` | The save seam. `undefined` means an in-memory provider: the save lives as long as the app does |
|
|
351
|
+
| `model` | `initialPlayer` | `Json` | `{}` | Player state of a new player. Deep-cloned |
|
|
352
|
+
| `model` | `initialSession` | `Json` | `{}` | Session state at every start. Deep-cloned |
|
|
353
|
+
| `model` | `seed` | `"from-save" \| number` | `"from-save"` | `"from-save"`: a new player gets a random seed once. A number fixes it for tests |
|
|
354
|
+
| `model` | `schemaVersion` | `number` | `1` | Version of the save schema this build writes |
|
|
355
|
+
| `model` | `migrations` | `readonly Migration[]` | `[]` | Ordered chain. `up` of `from: n` produces version `n + 1` |
|
|
356
|
+
| `clock` | `source` | `ClockSource \| undefined` | `undefined` | Time source. `undefined` means the system source. Tests pass `fakeClock()` |
|
|
357
|
+
| `flow` | `mainFlow` | `AnyFlow \| undefined` | `undefined` | The top-level flow. Required before `run()` |
|
|
358
|
+
| `flow` | `safeNode` | `string \| undefined` | `undefined` | Path of the checkpoint entered after a failed retry. `undefined` means the main flow's `start` |
|
|
359
|
+
| `flow` | `retries` | `number` | `1` | Retries of a failed transition before `safeNode` |
|
|
360
|
+
| `flow` | `settleTimeoutMs` | `number` | `2000` | How long `onStop` waits for the active node to settle after abort |
|
|
361
|
+
| `flow` | `journalLimit` | `number` | `500` | Journal entries kept between checkpoints |
|
|
362
|
+
|
|
363
|
+
## Development
|
|
364
|
+
|
|
365
|
+
### Scripts
|
|
366
|
+
|
|
367
|
+
```sh
|
|
368
|
+
bun run build # build with tsdown: dist/index.mjs and dist/testing.mjs
|
|
369
|
+
bun run typecheck # tsc --noEmit
|
|
370
|
+
bun run lint # biome check . && eslint .
|
|
371
|
+
bun run lint:fix # biome check --write . && eslint --fix .
|
|
372
|
+
bun run format # biome format --write .
|
|
373
|
+
bun run test # all tests, vitest run
|
|
374
|
+
bun run test:unit # vitest project "unit"
|
|
375
|
+
bun run test:integration # vitest project "integration"
|
|
376
|
+
bun run test:coverage # both projects with coverage, 90% thresholds
|
|
377
|
+
bun run validate # publint and attw with the esm-only profile
|
|
378
|
+
bun run release:setup # moku-release setup
|
|
379
|
+
bun run release:doctor # moku-release doctor
|
|
380
|
+
bun run release # moku-release
|
|
381
|
+
```
|
|
382
|
+
|
|
383
|
+
### Test layout
|
|
384
|
+
|
|
385
|
+
| Path | Holds |
|
|
386
|
+
|---|---|
|
|
387
|
+
| `tests/unit/` | Framework-level unit tests: root index, setup, teardown registry |
|
|
388
|
+
| `tests/integration/` | Framework-level scenarios across plugins |
|
|
389
|
+
| `tests/integration/merge-game/` | The fixture game, written on the public API only. Not published |
|
|
390
|
+
| `src/plugins/<name>/__tests__/unit/` | Unit tests of one plugin |
|
|
391
|
+
| `src/plugins/<name>/__tests__/integration/` | Integration tests of one plugin |
|
|
392
|
+
| `src/plugins/flow/__tests__/types/` | Type-level tests of the graph typing |
|
|
393
|
+
|
|
394
|
+
Plugin tests never go into the root `tests/` folder. Coverage thresholds are 90% for lines, functions, branches and statements.
|
|
395
|
+
|
|
396
|
+
### Lint rules L1 to L6
|
|
397
|
+
|
|
398
|
+
The project rules live in [`eslint.config.ts`](./eslint.config.ts).
|
|
399
|
+
|
|
400
|
+
| Rule | Says | Applies to |
|
|
401
|
+
|---|---|---|
|
|
402
|
+
| L1 | A module imports a sibling module only as `import type` from its `types.ts`. The plugin `index.ts` injects sibling APIs | Modules of `model` and `flow` |
|
|
403
|
+
| L2 | No static import of `pixi.js` or `yoga-layout`. They are loaded lazily with `import()` | `src/**` |
|
|
404
|
+
| L3 | Determinism: no `Date.now`, `performance.now`, `new Date`, `Math.random`, `setTimeout`, `setInterval` | `model`, `flow`, `clock` except `clock/system.ts`, and the rules of the fixture game |
|
|
405
|
+
| L4 | The rules of the fixture game import only their siblings | `tests/integration/merge-game/rules/` |
|
|
406
|
+
| L5 | No module-scope state: no top-level `let`, no top-level `Map`, `Set`, `WeakMap`, `WeakSet`. The only registry is `src/teardown.ts` | `src/**` |
|
|
407
|
+
| L6 | Plugin wiring files need no JSDoc on small inline arrows. Every other export needs JSDoc with description, params, returns and example | `src/plugins/*/index.ts` |
|
|
408
|
+
|
|
409
|
+
## Requirements
|
|
410
|
+
|
|
411
|
+
- **Node `>= 24`** and **Bun `>= 1.3.14`**. Use `bun` only, never npm, yarn or pnpm.
|
|
412
|
+
- **TypeScript** in strict mode, with `exactOptionalPropertyTypes` and `noUncheckedIndexedAccess`.
|
|
413
|
+
- **`pixi.js` `^8.0.0`** as a peer dependency.
|
|
414
|
+
- **[`@moku-labs/core`](https://github.com/moku-labs/core)** is the kernel: plugins, lifecycle, events. **[`@moku-labs/common`](https://github.com/moku-labs/common)** brings `log` and `env`.
|
|
415
|
+
|
|
416
|
+
## Docs
|
|
417
|
+
|
|
418
|
+
- [`time`](./src/plugins/time/README.md): frame loop, phases, `step`
|
|
419
|
+
- [`lifecycle`](./src/plugins/lifecycle/README.md): pause reasons
|
|
420
|
+
- [`model`](./src/plugins/model/README.md): store, rng, provider seam, migrations
|
|
421
|
+
- [`clock`](./src/plugins/clock/README.md): `now`, `scheduleAt`, `fakeClock`
|
|
422
|
+
- [`flow`](./src/plugins/flow/README.md): runner, gate, inbox, fx, features
|
|
423
|
+
- [`llms.txt`](./llms.txt): overview for an LLM that writes a game on this engine
|
|
424
|
+
- [Moku Core specification](https://github.com/moku-labs/core/tree/main/specification)
|
|
425
|
+
|
|
426
|
+
## License
|
|
427
|
+
|
|
428
|
+
[MIT](./LICENSE) © [moku-labs](https://github.com/moku-labs)
|