@table-games-engine/game-kit 0.0.0-stage → 0.1.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/nakama/init.ts ADDED
@@ -0,0 +1,215 @@
1
+ /**
2
+ * @fileoverview Nakama TypeScript runtime entry point.
3
+ *
4
+ * Nakama hosts this file as a CommonJS bundle (single file, no `node_modules`
5
+ * resolution at runtime, no ESM). The host invokes the top-level
6
+ * `InitModule(ctx, logger, nk, initializer)` function exactly once on boot.
7
+ *
8
+ * Wires `@table-games-engine/nakama-adapter`'s match handler into Nakama,
9
+ * registers the reference games via `GameRegistry`, and exposes an RPC
10
+ * `create_tabletop_match(payload)` that clients call to spawn a match.
11
+ *
12
+ * IMPORTANT — Nakama 3.x match-handler binding requirement:
13
+ * `initializer.registerMatch(name, handler)` looks up the handler's seven
14
+ * methods by NAME in the bundle's global object scope. A closure-bound
15
+ * object (the value `createMatchHandler` returns) doesn't satisfy this
16
+ * lookup. We define top-level named functions `matchInit`/`matchJoinAttempt`/
17
+ * etc. that delegate to a module-scope singleton populated inside
18
+ * `InitModule`. This keeps the M3 adapter's API untouched while satisfying
19
+ * Nakama's global-name binding contract.
20
+ *
21
+ * The trailing `!InitModule && InitModule;` expression keeps the function
22
+ * symbol alive when the bundler tree-shakes the module — the function is
23
+ * only ever called by the runtime host.
24
+ */
25
+
26
+ /// <reference path="./nkruntime.d.ts" />
27
+
28
+ import {
29
+ createMatchHandler,
30
+ GameRegistry,
31
+ IdempotencyCache,
32
+ } from '@table-games-engine/nakama-adapter';
33
+ import { registerPlugin } from '@table-games-engine/nakama-adapter';
34
+ import game from '__game__';
35
+
36
+ const MATCH_HANDLER_NAME = 'tabletop_match';
37
+
38
+ // Nakama allocates a pool of independent JS runtimes; module-level state
39
+ // is NOT shared between the InitModule runtime and the per-match runtimes.
40
+ // Lazy-build the handler per-runtime on first match-handler invocation.
41
+ let _handler: ReturnType<typeof createMatchHandler> | null = null;
42
+
43
+ function _ensureHandler(): ReturnType<typeof createMatchHandler> {
44
+ if (!_handler) {
45
+ const registry = new GameRegistry();
46
+ registerPlugin(registry, game);
47
+ _handler = createMatchHandler({
48
+ registry,
49
+ idempotencyCache: new IdempotencyCache(),
50
+ });
51
+ }
52
+ return _handler;
53
+ }
54
+
55
+ // Nakama-required match-handler entry points. Each function MUST be
56
+ // declared at the bundle's top level so Nakama can resolve it by name.
57
+ // The arity is intentional and matches Nakama's TS runtime contract.
58
+
59
+ function matchInit(
60
+ ctx: nkruntime.Context,
61
+ logger: nkruntime.Logger,
62
+ nk: nkruntime.Nakama,
63
+ params: { [key: string]: string },
64
+ ): { state: unknown; tickRate: number; label: string } {
65
+ return _ensureHandler().matchInit(ctx, logger, nk, params) as never;
66
+ }
67
+
68
+ function matchJoinAttempt(
69
+ ctx: nkruntime.Context,
70
+ logger: nkruntime.Logger,
71
+ nk: nkruntime.Nakama,
72
+ dispatcher: nkruntime.MatchDispatcher,
73
+ tick: number,
74
+ state: unknown,
75
+ presence: nkruntime.Presence,
76
+ metadata: { [key: string]: unknown },
77
+ ): { state: unknown; accept: boolean; rejectMessage?: string } | null {
78
+ return _ensureHandler().matchJoinAttempt(
79
+ ctx, logger, nk, dispatcher as never, tick, state as never, presence as never, metadata,
80
+ ) as never;
81
+ }
82
+
83
+ function matchJoin(
84
+ ctx: nkruntime.Context,
85
+ logger: nkruntime.Logger,
86
+ nk: nkruntime.Nakama,
87
+ dispatcher: nkruntime.MatchDispatcher,
88
+ tick: number,
89
+ state: unknown,
90
+ presences: nkruntime.Presence[],
91
+ ): { state: unknown } | null {
92
+ return _ensureHandler().matchJoin(
93
+ ctx, logger, nk, dispatcher as never, tick, state as never, presences as never,
94
+ ) as never;
95
+ }
96
+
97
+ function matchLeave(
98
+ ctx: nkruntime.Context,
99
+ logger: nkruntime.Logger,
100
+ nk: nkruntime.Nakama,
101
+ dispatcher: nkruntime.MatchDispatcher,
102
+ tick: number,
103
+ state: unknown,
104
+ presences: nkruntime.Presence[],
105
+ ): { state: unknown } | null {
106
+ return _ensureHandler().matchLeave(
107
+ ctx, logger, nk, dispatcher as never, tick, state as never, presences as never,
108
+ ) as never;
109
+ }
110
+
111
+ function matchLoop(
112
+ ctx: nkruntime.Context,
113
+ logger: nkruntime.Logger,
114
+ nk: nkruntime.Nakama,
115
+ dispatcher: nkruntime.MatchDispatcher,
116
+ tick: number,
117
+ state: unknown,
118
+ messages: nkruntime.MatchData[],
119
+ ): { state: unknown } | null {
120
+ return _ensureHandler().matchLoop(
121
+ ctx, logger, nk, dispatcher as never, tick, state as never, messages as never,
122
+ ) as never;
123
+ }
124
+
125
+ function matchTerminate(
126
+ ctx: nkruntime.Context,
127
+ logger: nkruntime.Logger,
128
+ nk: nkruntime.Nakama,
129
+ dispatcher: nkruntime.MatchDispatcher,
130
+ tick: number,
131
+ state: unknown,
132
+ graceSeconds: number,
133
+ ): { state: unknown } | null {
134
+ return _ensureHandler().matchTerminate(
135
+ ctx, logger, nk, dispatcher as never, tick, state as never, graceSeconds,
136
+ ) as never;
137
+ }
138
+
139
+ function matchSignal(
140
+ ctx: nkruntime.Context,
141
+ logger: nkruntime.Logger,
142
+ nk: nkruntime.Nakama,
143
+ dispatcher: nkruntime.MatchDispatcher,
144
+ tick: number,
145
+ state: unknown,
146
+ data: string,
147
+ ): { state: unknown; data?: string } | null {
148
+ return _ensureHandler().matchSignal(
149
+ ctx, logger, nk, dispatcher as never, tick, state as never, data,
150
+ ) as never;
151
+ }
152
+
153
+ function createTabletopMatchRpc(
154
+ _rpcCtx: nkruntime.Context,
155
+ rpcLogger: nkruntime.Logger,
156
+ nk: nkruntime.Nakama,
157
+ payload: string,
158
+ ): string {
159
+ let parsed: { gameId?: string; matchId?: string; playerIds?: string[]; config?: Record<string, unknown> } = {};
160
+ try {
161
+ parsed = payload ? JSON.parse(payload) : {};
162
+ // nakama-js's `client.rpc(session, id, input)` JSON-encodes `input`
163
+ // before posting. When callers pass an object, we receive an empty body
164
+ // string ("{}"); when callers pre-stringify (the supported path) we
165
+ // receive the string twice-encoded — an outer JSON string wrapping the
166
+ // inner object. After the first parse we get the inner string; parse
167
+ // again to land on the actual object.
168
+ if (typeof parsed === 'string') {
169
+ parsed = JSON.parse(parsed);
170
+ }
171
+ } catch (err) {
172
+ rpcLogger.warn('create_tabletop_match: invalid JSON payload: %s', String(err));
173
+ throw new Error('Invalid JSON payload');
174
+ }
175
+ const gameId = parsed.gameId;
176
+ if (!gameId) {
177
+ throw new Error('payload.gameId is required');
178
+ }
179
+ // Nakama's matchCreate accepts only string-valued params; stringify
180
+ // any complex shapes (player lists) here, the match handler decodes.
181
+ const params: { [key: string]: string } = { gameId };
182
+ if (parsed.matchId) params.matchId = parsed.matchId;
183
+ if (parsed.playerIds && parsed.playerIds.length > 0) {
184
+ params.playerIds = parsed.playerIds.join(',');
185
+ }
186
+ if (parsed.config) params.config = JSON.stringify(parsed.config);
187
+ const matchId = nk.matchCreate(MATCH_HANDLER_NAME, params);
188
+ rpcLogger.info('Created tabletop match %s for game %s', matchId, gameId);
189
+ return JSON.stringify({ matchId });
190
+ }
191
+
192
+ function InitModule(
193
+ _ctx: nkruntime.Context,
194
+ logger: nkruntime.Logger,
195
+ _nk: nkruntime.Nakama,
196
+ initializer: nkruntime.Initializer,
197
+ ): void {
198
+ initializer.registerMatch(MATCH_HANDLER_NAME, {
199
+ matchInit,
200
+ matchJoinAttempt,
201
+ matchJoin,
202
+ matchLeave,
203
+ matchLoop,
204
+ matchTerminate,
205
+ matchSignal,
206
+ });
207
+
208
+ initializer.registerRpc('create_tabletop_match', createTabletopMatchRpc);
209
+
210
+ logger.info('game-kit preview runtime loaded - game %s', String(game.id));
211
+ }
212
+
213
+ // Keep the symbol live for Nakama's V8 host. Do not remove.
214
+ // eslint-disable-next-line @typescript-eslint/no-unused-expressions
215
+ !InitModule && InitModule;
@@ -0,0 +1,28 @@
1
+ # Nakama local config for the SaaS Game Engine playground.
2
+ #
3
+ # `runtime.path` is the directory Nakama scans on boot for runtime
4
+ # modules (Lua / Go / TypeScript). `runtime.js_entrypoint` names the
5
+ # bundled CommonJS file; Nakama then calls its top-level
6
+ # `InitModule(ctx, logger, nk, initializer)` function once.
7
+ name: game-kit-nakama
8
+ data_dir: /nakama/data
9
+
10
+ logger:
11
+ level: INFO
12
+ stdout: true
13
+
14
+ session:
15
+ encryption_key: defaultencryptionkey
16
+ token_expiry_sec: 7200
17
+
18
+ socket:
19
+ server_key: defaultkey
20
+
21
+ console:
22
+ port: 7351
23
+ username: admin
24
+ password: localdev
25
+
26
+ runtime:
27
+ path: /nakama/data/modules
28
+ js_entrypoint: index.js
@@ -0,0 +1,150 @@
1
+ /**
2
+ * @fileoverview Minimal `nkruntime` namespace type stubs.
3
+ *
4
+ * Nakama's official `nkruntime` types ship inside the Nakama runtime image
5
+ * itself (no public npm package as of 2026). For local typechecking we declare
6
+ * the structural subset of the namespace this server actually uses. The real
7
+ * implementation is provided by the V8 host at runtime; nothing in this file
8
+ * appears in the bundled output.
9
+ *
10
+ * Reference:
11
+ * https://heroiclabs.com/docs/nakama/server-framework/typescript-setup/
12
+ */
13
+
14
+ // eslint-disable-next-line @typescript-eslint/no-namespace
15
+ declare namespace nkruntime {
16
+ interface Logger {
17
+ debug(format: string, ...args: unknown[]): void;
18
+ info(format: string, ...args: unknown[]): void;
19
+ warn(format: string, ...args: unknown[]): void;
20
+ error(format: string, ...args: unknown[]): void;
21
+ }
22
+
23
+ interface Context {
24
+ [key: string]: unknown;
25
+ }
26
+
27
+ interface Presence {
28
+ userId: string;
29
+ sessionId: string;
30
+ username?: string;
31
+ node?: string;
32
+ status?: string;
33
+ }
34
+
35
+ interface MatchData {
36
+ opCode: number;
37
+ data: Uint8Array;
38
+ sender: Presence;
39
+ reliable?: boolean;
40
+ }
41
+
42
+ interface MatchDispatcher {
43
+ broadcastMessage(
44
+ opCode: number,
45
+ data?: string | Uint8Array | null,
46
+ presences?: Presence[] | null,
47
+ sender?: Presence | null,
48
+ reliable?: boolean,
49
+ ): void;
50
+ matchKick?(presences: Presence[]): void;
51
+ matchLabelUpdate?(label: string): void;
52
+ }
53
+
54
+ interface MatchState {
55
+ [key: string]: unknown;
56
+ }
57
+
58
+ interface MatchHandler<T = MatchState> {
59
+ matchInit(
60
+ ctx: Context,
61
+ logger: Logger,
62
+ nk: Nakama,
63
+ params: { [key: string]: string },
64
+ ): { state: T; tickRate: number; label: string };
65
+
66
+ matchJoinAttempt(
67
+ ctx: Context,
68
+ logger: Logger,
69
+ nk: Nakama,
70
+ dispatcher: MatchDispatcher,
71
+ tick: number,
72
+ state: T,
73
+ presence: Presence,
74
+ metadata: { [key: string]: unknown },
75
+ ): { state: T; accept: boolean; rejectMessage?: string } | null;
76
+
77
+ matchJoin(
78
+ ctx: Context,
79
+ logger: Logger,
80
+ nk: Nakama,
81
+ dispatcher: MatchDispatcher,
82
+ tick: number,
83
+ state: T,
84
+ presences: Presence[],
85
+ ): { state: T } | null;
86
+
87
+ matchLeave(
88
+ ctx: Context,
89
+ logger: Logger,
90
+ nk: Nakama,
91
+ dispatcher: MatchDispatcher,
92
+ tick: number,
93
+ state: T,
94
+ presences: Presence[],
95
+ ): { state: T } | null;
96
+
97
+ matchLoop(
98
+ ctx: Context,
99
+ logger: Logger,
100
+ nk: Nakama,
101
+ dispatcher: MatchDispatcher,
102
+ tick: number,
103
+ state: T,
104
+ messages: MatchData[],
105
+ ): { state: T } | null;
106
+
107
+ matchSignal(
108
+ ctx: Context,
109
+ logger: Logger,
110
+ nk: Nakama,
111
+ dispatcher: MatchDispatcher,
112
+ tick: number,
113
+ state: T,
114
+ data: string,
115
+ ): { state: T; data?: string } | null;
116
+
117
+ matchTerminate(
118
+ ctx: Context,
119
+ logger: Logger,
120
+ nk: Nakama,
121
+ dispatcher: MatchDispatcher,
122
+ tick: number,
123
+ state: T,
124
+ graceSeconds: number,
125
+ ): { state: T } | null;
126
+ }
127
+
128
+ type RpcFunction = (
129
+ ctx: Context,
130
+ logger: Logger,
131
+ nk: Nakama,
132
+ payload: string,
133
+ ) => string | void;
134
+
135
+ interface Initializer {
136
+ registerMatch(name: string, handler: MatchHandler): void;
137
+ registerRpc(id: string, fn: RpcFunction): void;
138
+ }
139
+
140
+ interface Nakama {
141
+ matchCreate(module: string, params?: { [key: string]: string }): string;
142
+ matchGet?(matchId: string): unknown;
143
+ [key: string]: unknown;
144
+ }
145
+ }
146
+
147
+ declare module '__game__' {
148
+ const plugin: import('@table-games-engine/core').GamePlugin;
149
+ export default plugin;
150
+ }
package/package.json CHANGED
@@ -1,6 +1,34 @@
1
1
  {
2
2
  "name": "@table-games-engine/game-kit",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "license": "Apache-2.0",
5
+ "publishConfig": { "access": "public" },
6
+ "description": "Build, check, simulate and preview store games on table-games-engine.",
7
+ "type": "module",
8
+ "bin": { "game-kit": "dist/cli.js" },
9
+ "main": "dist/index.js",
10
+ "types": "dist/index.d.ts",
11
+ "exports": {
12
+ ".": { "types": "./dist/index.d.ts", "default": "./dist/index.js" },
13
+ "./bridge": { "types": "./dist/bridge.d.ts", "default": "./dist/bridge.js" }
14
+ },
15
+ "files": ["dist", "schema", "template", "nakama", "host", "skill"],
16
+ "scripts": {
17
+ "build": "esbuild src/index.ts src/bridge.ts --bundle --format=esm --platform=node --packages=external --outdir=dist && esbuild src/cli.ts --bundle --format=esm --platform=node --packages=external --outfile=dist/cli.js --banner:js='#!/usr/bin/env node' && tsc -p tsconfig.build.json",
18
+ "test": "vitest run",
19
+ "typecheck": "tsc --noEmit"
20
+ },
21
+ "dependencies": {
22
+ "@heroiclabs/nakama-js": "^2.7.0",
23
+ "@table-games-engine/core": "^0.1.0",
24
+ "@table-games-engine/nakama-adapter": "^0.1.0",
25
+ "@table-games-engine/persistence": "^0.1.0",
26
+ "ajv": "^8.17.0",
27
+ "esbuild": "^0.24.0",
28
+ "eslint": "^9.0.0",
29
+ "tsx": "^4.19.0",
30
+ "typescript-eslint": "^8.0.0",
31
+ "vite": "^5.4.0"
32
+ },
33
+ "devDependencies": { "typescript": "^5.4.0", "vitest": "^2.0.0" }
34
+ }
@@ -0,0 +1,54 @@
1
+ {
2
+ "$schema": "http://json-schema.org/draft-07/schema#",
3
+ "$id": "https://github.com/Dmitry-Kucher/table-games-engine/game.schema.json",
4
+ "title": "Store game metadata (game.json)",
5
+ "type": "object",
6
+ "definitions": {
7
+ "localized": {
8
+ "oneOf": [
9
+ { "type": "string", "minLength": 1 },
10
+ { "type": "object", "minProperties": 1, "additionalProperties": { "type": "string", "minLength": 1 } }
11
+ ]
12
+ }
13
+ },
14
+ "required": ["slug", "name", "version", "engine", "players", "locales", "description", "rules", "authors", "listed", "client"],
15
+ "additionalProperties": false,
16
+ "properties": {
17
+ "$schema": { "type": "string" },
18
+ "slug": { "type": "string", "pattern": "^[a-z][a-z0-9-]{1,40}$" },
19
+ "name": { "$ref": "#/definitions/localized" },
20
+ "version": { "type": "string", "pattern": "^\\d+\\.\\d+\\.\\d+$" },
21
+ "engine": { "type": "string", "minLength": 1 },
22
+ "players": {
23
+ "type": "object", "required": ["min", "max"], "additionalProperties": false,
24
+ "properties": { "min": { "type": "integer", "minimum": 2 }, "max": { "type": "integer", "minimum": 2 } }
25
+ },
26
+ "locales": { "type": "array", "minItems": 1, "uniqueItems": true, "items": { "type": "string", "pattern": "^[a-z]{2}(-[A-Z]{2})?$" } },
27
+ "description": { "$ref": "#/definitions/localized" },
28
+ "rules": { "type": "object", "minProperties": 1, "additionalProperties": { "type": "string", "pattern": "\\.md$" } },
29
+ "tags": { "type": "array", "items": { "type": "string" } },
30
+ "cover": { "type": "string" },
31
+ "authors": { "type": "array", "minItems": 1, "items": { "type": "string", "minLength": 1 } },
32
+ "credits": { "type": "string" },
33
+ "listed": { "type": "boolean" },
34
+ "client": {
35
+ "type": "object", "required": ["kind", "entry"], "additionalProperties": false,
36
+ "properties": { "kind": { "enum": ["iframe"] }, "entry": { "type": "string", "minLength": 1 } }
37
+ },
38
+ "presets": {
39
+ "type": "array", "minItems": 1,
40
+ "items": {
41
+ "type": "object", "required": ["id", "name", "config"], "additionalProperties": false,
42
+ "properties": {
43
+ "id": { "type": "string", "pattern": "^[a-z0-9-]+$" },
44
+ "name": { "$ref": "#/definitions/localized" },
45
+ "config": { "type": "object" }
46
+ }
47
+ }
48
+ },
49
+ "simulation": {
50
+ "type": "object", "additionalProperties": false,
51
+ "properties": { "maxSteps": { "type": "integer", "minimum": 1 } }
52
+ }
53
+ }
54
+ }
@@ -0,0 +1,135 @@
1
+ # Skill: create-store-game
2
+
3
+ Build a store game on table-games-engine in a standalone package created by
4
+ `game-kit create`. You are done when `npx game-kit check` exits 0 and prints `check: OK`.
5
+ Do not edit anything under `node_modules/`.
6
+
7
+ > **Unpublished packages:** until `@table-games-engine/*` is on npm, you need the five
8
+ > local tarballs (`core`, `persistence`, `nakama-adapter`, `client-sdk`, `game-kit`). Then:
9
+ > ```sh
10
+ > mkdir installer && (cd installer && npm init -y && npm install /path/to/tarballs/*.tgz)
11
+ > npx --prefix ./installer game-kit create <slug> --no-install
12
+ > cd <slug> && npm install /path/to/tarballs/*.tgz
13
+ > ```
14
+ > (`npx --package=<game-kit tgz>` alone fails: it cannot resolve the sibling packages.)
15
+ > Once published, plain `npx @table-games-engine/game-kit create <slug>` installs for you.
16
+
17
+ ## Inputs (ask the user if missing)
18
+
19
+ 1. Rules text for every locale the game ships (`rules.md`, `rules.<locale>.md`).
20
+ 2. Locales (first = default) and presets (match configurations, first = default).
21
+ 3. Player count.
22
+
23
+ ## What `game-kit create` gives you
24
+
25
+ ```
26
+ game.json rules.md AGENTS.md package.json tsconfig.json
27
+ logic/index.ts a working 2-player "Fire Bolt" duel: replace it
28
+ tests/game.test.ts drives a full match: copy its pattern
29
+ client/index.html, client/main.ts bridge client: replace it
30
+ ```
31
+
32
+ Read `logic/index.ts` and `tests/game.test.ts` first; they are the reference for everything below.
33
+
34
+ ## The contract you implement
35
+
36
+ `logic/index.ts` does `export default plugin`, where `plugin` is a `GamePlugin`
37
+ from `@table-games-engine/core` with:
38
+
39
+ | Field | What it must do |
40
+ |---|---|
41
+ | `id`, `version`, `requiredMechanics: []` | As in the template. |
42
+ | `manifest` | Phases and actions. Keep one phase with `exitCondition: 'manual'` unless the game really has phases. Every action id a player can take must be in that phase's `legalActions`, and defined in `actions.actions` with `usesStack: true`, `effects: []`. Copy the template's manifest shape. |
43
+ | `setup(ctx, config, seats)` | Return `ok({ state })`. `ctx.state` is already a valid empty state with the seats in turn order and the first seat holding priority: spread it and set `players` and `shared`. `config` is the chosen preset's `config` (`{}` with no presets); only `config` reaches `setup`, never the preset's `id`, so put everything the logic needs (board size, variant flags) into `config`. |
44
+ | `actionHandlers` | `{ [actionTypeId]: (ctx, frame) => ... }`. Pure: read `ctx.state` and `frame.payload`, return `resolved(mutations)` (or `resolved(mutations, events)`), both helpers imported from `@table-games-engine/core`; no `as never` casts needed. The acting player is `'actor' in frame ? frame.actor : frame.controller`. **To end the turn, include `{ domain: 'turn', operation: { type: 'advance' } }` in `mutations`**: the engine then gives priority to the next player. If the same player moves again (bonus move), do not emit it. **To reject an illegal move, `return reject('rule-violation')`** (i.e. `{ success: false, failureReason: 'rule-violation', mutations: [], events: [], newFrames: [], firedTriggers: [] }`; other reasons: `'condition-failed'`, `'targets-illegal'`): the server rejects the input (`driveAction` returns `ok: false` with code `CONDITION_NOT_MET`, the player gets a `rejected` notice) and nothing changes, even if you returned mutations. |
45
+ | `winConditionChecker(ctx)` | `{ gameOver: false }` or `{ gameOver: true, winners: [...playerIds], reason }`. A draw is `winners: []`. Every game must end: add a turn limit if the rules have none. (The engine also ends the match when at most one non-eliminated player remains.) |
46
+ | `legalMoves(state, playerId)` | **Every** concrete move this player may submit now, each `{ playerId, type: 'action', data: { type: 'action', actionTypeId, targets: {}, payload } }` (type `LegalMove`). Return `[]` when `state.result` is set or `state.priority.holder !== playerId`. Never return `[]` for the holder of an unfinished game. |
47
+
48
+ Store game data in `state.shared` (JSON only: no class instances, `Map`, `Set`,
49
+ `undefined`). Update it with `{ domain: 'shared', operation: { type: 'merge', updates } }`
50
+ (shallow merge of top-level keys) or `{ domain: 'shared', operation: { type: 'set', path, value } }` (`path` is the literal top-level key `shared[path]`, not a nested path).
51
+ Other useful mutations: `{ domain: 'player', operation: { type: 'add-resource', playerId, resource, delta } }`,
52
+ `{ domain: 'turn', operation: { type: 'eliminate-player', playerId } }`.
53
+
54
+ ### Randomness
55
+
56
+ `ctx.random()`, `ctx.randomInt(min, max)`, `ctx.randomPick(arr)`, `ctx.randomShuffle(arr)` return
57
+ `{ value, newRngState, consumed }` and do **not** change state by themselves. Every call within one
58
+ handler reads the same RNG state, so do **one draw per handler** (use `randomShuffle` for many items)
59
+ and persist it by emitting `{ domain: 'rng', operation: { type: 'set-state', state: result.newRngState } }`.
60
+ Otherwise the next action draws the same number.
61
+
62
+ ### Logic rules (`game-kit check` lint stage enforces them; all files under `logic/`)
63
+
64
+ - Import only `@table-games-engine/core` and relative files.
65
+ - No `Math.random`, `Date`, `process`, `window`, `globalThis`, `setTimeout`, `setInterval`, `fetch`, `require`, dynamic `import()`.
66
+ - No module-level `let`/`var`: modules are shared by every match on the server (`const` is fine).
67
+ - Never mutate `ctx.state`; return mutations.
68
+
69
+ ## The client
70
+
71
+ `client/` is a vite app (`client/index.html` -> `client/main.ts`) shown to each player in a sandboxed
72
+ iframe **with no network access** (no fetch, no CDN, no remote fonts or images: bundle everything).
73
+ It talks to the store only through the bridge:
74
+
75
+ ```ts
76
+ import { connectBridge, type StatePayload } from '@table-games-engine/game-kit/bridge';
77
+ const bridge = connectBridge(
78
+ (s: StatePayload) => render(s), // s.view (this player's view; s.view.viewerId is you), s.seat, s.legalMoves, s.status ('active'|'finished'), s.result
79
+ (kind) => showNotice(kind), // 'rejected' | 'reconnecting' | 'server-restart' | 'opponent-left'
80
+ );
81
+ // on a click: bridge.send(oneOf_s.legalMoves)
82
+ ```
83
+
84
+ - Only send moves taken from the latest `s.legalMoves`; anything else is rejected (you get a `rejected` notice).
85
+ - Build the UI from `s.legalMoves`: they are exactly what is clickable now. `s.legalMoves` is empty when it is not your turn.
86
+ - Read the locale from `new URLSearchParams(location.search).get('locale')`, fall back
87
+ to the first locale in `game.json`, and ship UI strings for every listed locale.
88
+ - Must work on a phone-width screen (>= 360 px).
89
+
90
+ ## game.json
91
+
92
+ Edit the template's file; keep every field the schema requires (`slug`, `name`, `version`, `engine`, `players`, `locales`, `description`, `rules`, `authors`, `listed`, `client`) and add no unknown fields. `locales` (first = default); `name` / `description` either a string or
93
+ `{ "<locale>": "..." }` covering every locale; `rules` = `{ "<locale>": "<file>.md" }` with every file present;
94
+ `presets` = `[{ "id": "small", "name": "..." or { ... }, "config": { ... } }]` (first = default, unique ids; `config`
95
+ is passed to `setup`); `players` `{min,max}`; `credits` with the original author and licence when adapting an
96
+ existing game; `client` stays `{ "kind": "iframe", "entry": "client/dist/index.html" }`;
97
+ `simulation.maxSteps` (default 2000) high enough for the longest legal game (one step = one move).
98
+ `players.min` is at least 2. `simulate` and `check` play every player count from `players.min` to `players.max`.
99
+
100
+ ## Procedure
101
+
102
+ 1. Read `rules.md` (and translations) completely. List every rule, then every
103
+ ambiguity; decide each one and write the decision into the rules files.
104
+ 2. Write `logic/` (split board geometry / move generation / handlers into
105
+ files). Write `legalMoves` from the same helpers the handler validates with,
106
+ so they cannot disagree.
107
+ 3. Write `tests/` (vitest): at least one scripted full match ending in a result, plus a
108
+ test per tricky rule. Drive moves with `setupMatch` + `driveAction` exactly
109
+ like `tests/game.test.ts` in the template. Read a move's fields with its typed
110
+ helper `actionOf(m)` (`m.data.type === 'action' ? m.data : null`, then
111
+ `actionOf(m)?.actionTypeId`, `actionOf(m)?.payload['x']`) instead of casting `m.data`.
112
+ A rejected move is `expect(r.ok).toBe(false)`.
113
+ 4. Run `npx game-kit test`, then `npx game-kit simulate -n 200` (flags: `-n` matches per preset and
114
+ player count, default 1000; `--seed` default 1; `--preset <id>`; `--players <n>` one count instead of
115
+ every count in `players`). On a failure
116
+ it prints `FAIL preset=… players=… seed=… step=…: <reason>`, a `replay:` line and the last moves; replay one match with
117
+ `npx game-kit simulate --preset <id> --players <n> --seed <seed> -n 1` and fix the logic.
118
+ 5. Write the client. If Docker is available run `npx game-kit preview --preset <id> --locale <l>`
119
+ and play both seats.
120
+ 6. Run `npx game-kit check` (game.json, lint, typecheck, tests, simulate with 1000 matches per preset and player count, client build)
121
+ until it prints `check: OK`. On failure it prints `check: FAILED at <stage>` and the details.
122
+
123
+ ## Common failures
124
+
125
+ | Symptom from `simulate` | Cause |
126
+ |---|---|
127
+ | `no result after maxSteps=N` | The game can loop forever (also: a handler that forgets the turn `advance` lets the same player act forever): add a turn limit/draw rule and make every handler end the turn, or raise `simulation.maxSteps` if games are legitimately long. |
128
+ | `legalMoves is empty for pX, who must act` | `legalMoves` returned `[]` for the priority holder of an unfinished game: no moves left and no win/draw ended the match, or a wrong holder/`state.result` check inside `legalMoves`. |
129
+ | `legalMoves offered a move the engine rejected (NOT_PRIORITY_HOLDER …)` | A move returned by `legalMoves` carries a `playerId` other than the priority holder. |
130
+ | `… (CONDITION_NOT_MET: <reason>)` | Your handler returned `reject(...)` / `success: false` for a move `legalMoves` offered: the two disagree. Build both from the same helper. |
131
+ | `… (INVALID_ACTION_TYPE …)` | The action id is missing from the manifest's `actions.actions`. |
132
+ | `… (ILLEGAL_IN_WINDOW: <id> not legal in current window)` | The action id is not in the current phase's `legalActions` (or its speed does not fit the window). |
133
+ | `non-deterministic: replaying the same seed produced a different final state` | Module-level state, iteration over something unordered, or randomness outside `ctx`. |
134
+ | `setup threw: …` / `legalMoves threw: …` / `game logic threw: …` | Exception in your code; fix it. |
135
+ | `legalMoves returned N moves (> 5000)` (warning) | Too many concrete moves; the UI and bots get slow. Consider splitting the move into steps. |
@@ -0,0 +1,6 @@
1
+ # Agent instructions
2
+
3
+ This is a store game for table-games-engine. Follow the skill at
4
+ `node_modules/@table-games-engine/game-kit/skill/create-store-game.md`
5
+ step by step. You are done when `npx game-kit check` exits 0.
6
+ Do not edit anything under `node_modules/`.
@@ -0,0 +1,17 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>Game</title>
7
+ <style>
8
+ body { font-family: system-ui, sans-serif; margin: 16px; }
9
+ .row { display: flex; justify-content: space-between; max-width: 320px; }
10
+ button { margin-top: 12px; padding: 8px 16px; }
11
+ </style>
12
+ </head>
13
+ <body>
14
+ <main id="app"></main>
15
+ <script type="module" src="./main.ts"></script>
16
+ </body>
17
+ </html>