diff --git a/history/260731-game-kernel-research.md b/history/260731-game-kernel-research.md new file mode 100644 index 0000000..9b14cc9 --- /dev/null +++ b/history/260731-game-kernel-research.md @@ -0,0 +1,71 @@ +# 2026-07-31 — game-kernel research trail (CB-RES-0001, T03) + +How the survey was actually conducted, per InnerLoop §Step 2. Session: +custodian on claude-fable-5. + +## Sources and queries + +1. WebSearch `boardgame.io maintained status 2026 alternative open source + board game engine framework` → Snyk maintenance page (key fact: + **Inactive, v0.50.2, last publish ~4 years ago**), GitHub issue #1150 + ("What's the state of the project?"), Vassal as the named alternative + (module player for humans, no programmable rules kernel — not pursued + as a kernel candidate). +2. WebFetch `github.com/boardgameio/boardgame.io` → 12.4k stars, 830 forks, + 1,894 commits; feature list (phases, log/time-travel, multiplayer sync). +3. WebFetch `boardgame.io/documentation/#/` → **dead end**: docsify SPA, + no content server-side. Architecture facts were instead verified from + the installed package source (`node_modules/boardgame.io`) during + harness construction (Redux + Immer pipeline, plugins incl. seeded + `random`, `playerView`/`STRIP_SECRET`, `activePlayers` stages). +4. WebSearch `Rune games SDK deterministic logic.js …` → + developers.rune.ai docs: deterministic-JS enforcement (patched + `Math.random`, static checks), predict-rollback sync model. +5. WebFetch `api.tabletopsimulator.com` → Global vs Object scripts, Lua + state persisted as strings in save JSON; deeper pages not fetched — + remaining TTS characterization (no move validation, hand zones, + no replay) is prior knowledge, marked *cited* in the survey. +6. Event-sourcing and bevy_ecs performance figures are **cited/estimated** + from general engineering knowledge (cqrs-es aggregate pattern, + EventStoreDB append throughput class, Bevy's published bench claims) — + not fetched this session. Flagged as directional in the survey; + parity-cap rule applies. + +## Harness (measured baseline) + +`research/CB-RES-0001-harness/boardgame-io/bench.js` — boardgame.io 0.50.2, +headless `Client`, synthetic 3-player GROUND-shaped workload (relationship +map, hands, commit list, resolve step), seeded, SHA-256 state hash. + +Dead ends and fixes: + +- **v1 harness used `turn.stages` + `setActivePlayers` events** to model + the commit/reveal windows faithfully. The client rejected the moves + ("disallowed move/event") — stage gating plus client-sent events needs + more setup than the bench warrants. Rewrote with flat top-level moves and + `activePlayers: {all: Stage.NULL}`. Fidelity cost: stage-machinery + overhead is *not* measured; the reducer/log pipeline (the thing we + benchmark) is. Noted in the survey's risk paragraph. + - Side-effect: the v1 "8.4 s for 100k moves" figure was garbage — moves + were being rejected, so it measured no-ops. Discarded. +- **100k-move run did not finish in 300 s** and was killed. Reran at + 5k/10k/20k, which exposed the real story: throughput halves as history + doubles (1,930 → 1,605 → 870 moves/s). The degradation is the finding; + a single big-N number would have hidden it. +- A stray broken edit left duplicate code in bench.js (syntax error); + fixed before the measured runs. Measurements reported in the survey are + from the clean file only. + +Environment: Node v24.11.1, bnt-lap001 (WSL2), single process, no other +load control — numbers are same-machine comparable per MetricsAndScenarios +§3, not lab-grade. + +## What was deliberately not done + +- No local measurement of a Rust event-sourcing comparator (would harden + the D3 ceiling row; candidate follow-up if the adversarial review demands + it). +- Vassal, Tabletopia, Phaser/Godot examined only far enough to exclude: + none is a programmable authoritative rules kernel. +- boardgame.io server-side master (no client subscription) not separately + benchmarked; harness measures canonical client usage. diff --git a/research/CB-RES-0001-game-kernel.md b/research/CB-RES-0001-game-kernel.md new file mode 100644 index 0000000..d2c2fa4 --- /dev/null +++ b/research/CB-RES-0001-game-kernel.md @@ -0,0 +1,154 @@ +# CB-RES-0001: game-state kernel + +capability: game.kernel.authoritative-state +status: draft # becomes approved only after adversarial review (T04) +tier: L (structural L, chaos roll pending at T04 declaration — see history trail) +runnable-baseline: invoked — harness in research/CB-RES-0001-harness/boardgame-io/ + +Survey of the best existing implementations of a **turn/phase game-state +kernel**: deterministic authoritative state, command → validation → events, +simultaneous commit/reveal, hidden information, replay. Conducted +2026-07-31; research trail in +[history/260731-game-kernel-research.md](../history/260731-game-kernel-research.md). + +--- + +## Candidates + +### 1. boardgame.io 0.50.2 (JS/TS) — measured + +The most direct comparator: a declarative turn-based game engine. + +- **Data model:** single plain-object `G` (game state) + framework `ctx` + (turn/phase bookkeeping). Game defined declaratively: `setup`, `moves`, + `phases`, `turn.stages`. +- **Mutation:** moves are reducer functions run through Redux + Immer; + mutate a draft, framework produces immutable next state and appends to an + action **log** (basis for time travel). +- **Determinism/replay:** seeded RNG via `random` plugin; log + seed give + replay and time travel. Measured: same seed → identical state hash across + runs; different seed diverges. ✅ +- **Hidden information:** `playerView` projection (e.g. `STRIP_SECRET`) — + server strips secret state per player. +- **Simultaneous actions:** `activePlayers` stages give simultaneous move + windows; no built-in cryptographic commit/reveal — commitment is plain + state the server can see (fine for server-authoritative, nothing for + peer settings). +- **Maturity:** 12.4k GitHub stars, but **inactive** — last npm release + 0.50.2 ≈ 4 years ago (Snyk: "maintenance: Inactive"). +- **Measured performance** (our harness, synthetic 3-player GROUND-shaped + commit/reveal workload, Node v24, this machine `bnt-lap001`): + + | applied moves | moves/s | elapsed | RSS | + |---:|---:|---:|---:| + | 5,000 | 1,930 | 2.6 s | 224 MB | + | 10,000 | 1,605 | 6.2 s | 229 MB | + | 20,000 | 870 | 23.0 s | 271 MB | + | 100,000 | did not finish in 300 s | — | — | + + **Per-move cost grows with history length** (log accumulation + state + pipeline): throughput halves as move count doubles — superlinear total + cost. This is architectural (unbounded redux log per client), not a + tuning artifact. +- **Weight:** 120 transitive npm packages, 37 MB `node_modules`, core + package 3.9 MB. + +### 2. Tabletop Simulator scripting model (Lua) — cited + +The dominant commercial virtual tabletop; reference for *tabletop +semantics*, not a rules kernel. + +- **Data model:** none authoritative — game state *is* the physical scene; + Lua state serialized as strings into the save JSON (`onSave`/`onLoad`). +- **Mutation:** imperative Lua in a Global script + per-object scripts with + event hooks; **no move validation or rules engine** — rules are social, + physics is primary (sandbox mode in Clay-Borg terms). +- **Determinism/replay:** none. Hidden info via hand zones (engine feature, + not a projection model). +- Valuable as the pattern source for object-attached behavior and hand + zones; architecturally the anti-model for an authoritative kernel. + +### 3. Rune SDK (JS) — cited + +Modern (active, 2024–2026) deterministic multiplayer engine for casual web +games. + +- **Data model/mutation:** pure `logic.js` — game state + action functions, + statically checked for nondeterminism (mutation escape, `Math.random` + patched deterministic). +- **Sync:** predict-rollback: all clients + server simulate the same + deterministic logic; server authoritative, clients predict. Strongest + determinism *discipline* of the candidates — enforced by tooling, not + convention. +- **Limits:** platform-bound (Rune's hosted app ecosystem), not an + embeddable open kernel; no phase/stage framework, hidden-information + projection, or event-sourced replay surface comparable to boardgame.io. + +### 4. Event-sourcing kernels (Rust `cqrs-es` pattern / EventStoreDB) — cited + +The general-purpose form of our mutation pipeline (command → validate → +events → fold). + +- Aggregates validate commands and emit events; state is a fold over the + append-only log; snapshots bound replay cost. Replay/audit are native. +- **Performance (cited/estimated):** in-process Rust event application is + memory-bandwidth-bound — order 10⁵–10⁶ small events/s per core is the + commonly reported range for fold-style aggregates; dedicated stores + (EventStoreDB) sustain tens of thousands of appends/s over the network. + No game semantics: phases, visibility, simultaneity all DIY. + +### 5. bevy_ecs 0.x (Rust) — cited + +Archetypal ECS; the world/spatial layer in our architecture, surveyed as a +kernel candidate for completeness. + +- Cache-friendly iteration: millions of entity-component accesses per frame + (cited from Bevy's own benches; ns-scale per component access). +- No authoritative command/event pipeline, no replay, no hidden-info + projection; determinism requires care (system ordering, hash maps). + Confirms the ADR-anticipated split: ECS for world representation, + **typed aggregates for the semantic kernel** — not a competitor on this + capability. + +--- + +## Baselines (benchmark-to-beat) + +| Dimension | Baseline holder | Metric | Value | Provenance | +|---|---|---|---|---| +| D1 ease of specification | boardgame.io | LOC to express the synthetic 3p commit/reveal game (declarative object) | ~45 LOC | measured (harness bench.js game def) | +| D1 | — (no candidate) | rule-to-scenario traceability (M-D1-COV) | 0 % — none of the candidates link rules to tests | measured/observed | +| D2 implementation weight | boardgame.io | transitive deps / install size | 120 pkgs / 37 MB | measured | +| D3 throughput | boardgame.io | applied moves/s, 3p workload @5k moves | 1,930 moves/s | measured, bnt-lap001 | +| D3 scaling | boardgame.io | throughput @20k vs @5k moves | 0.45× (superlinear cost) | measured, bnt-lap001 | +| D3 memory | boardgame.io | RSS @5k moves | 224 MB | measured, bnt-lap001 | +| D3 ceiling (adjacent layer) | in-proc event-sourcing (Rust) | events applied/s per core | ~10⁵–10⁶ | cited/estimated — directional, caps our verdict at parity unless we measure a Rust comparator | +| D4 optionality | Rune | determinism enforced by tooling | static nondeterminism checks | cited | +| D4 | boardgame.io | replaceability of subsystems | plugin API, but JS-ecosystem-locked; no null/reference impl pattern | observed | + +**Headline benchmark-to-beat for the Clay-Borg kernel (proposed for the +ADR):** ≥ 100,000 applied events/s sustained with **flat scaling** (throughput +@100k events within 10% of @5k), deterministic replay bit-identical, on the +same machine and workload shape as the boardgame.io harness. + +## Verdict + +- **Per dimension:** D1 — boardgame.io's declarative game object is the bar + to match; nobody has rule-to-scenario traceability (open surpass lane). + D2 — boardgame.io's 120-dep footprint is beatable by an order of + magnitude in Rust. D3 — boardgame.io is slow *and* degrades; the honest + comparison class is in-proc event sourcing (10⁵–10⁶/s), and our D3 + advantage over boardgame.io is partly language choice — the meaningful + target is **flat scaling + the 100k/s floor**, not the ×50 headline. + D4 — Rune's tooling-enforced determinism is the discipline to assimilate; + no candidate offers a null/reference/optimized port pattern. +- **What none of them do:** combine deterministic replayable authoritative + state, first-class simultaneous commit/reveal with hidden-information + projection, flat per-event cost with snapshots, and an embeddable + language-portable boundary. That combination is the surpass opportunity. +- **Risks in these baselines:** the boardgame.io harness measures the + headless *client* pipeline (includes subscription/log overhead — canonical + usage, but a bare server-side master could differ); the event-sourcing + numbers are cited, not locally measured; TTS and Rune numbers are + qualitative. The D3 event-sourcing row is directional and caps related + evidence verdicts at parity per MetricsAndScenarios §3. diff --git a/research/CB-RES-0001-harness/boardgame-io/.gitignore b/research/CB-RES-0001-harness/boardgame-io/.gitignore new file mode 100644 index 0000000..504afef --- /dev/null +++ b/research/CB-RES-0001-harness/boardgame-io/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +package-lock.json diff --git a/research/CB-RES-0001-harness/boardgame-io/bench.js b/research/CB-RES-0001-harness/boardgame-io/bench.js new file mode 100644 index 0000000..f50df47 --- /dev/null +++ b/research/CB-RES-0001-harness/boardgame-io/bench.js @@ -0,0 +1,116 @@ +// Baseline harness for CB-RES-0001 — boardgame.io v0.50.2, headless client. +// Measures: (1) throughput of applying moves to authoritative state, +// (2) determinism across two seeded runs, (3) commit/reveal-style stage flow. +// Fidelity note: synthetic GROUND-shaped workload (3 players, relationship +// map, per-player hands, commit list), not a real GROUND implementation. +// Run: node bench.js [numMoves] + +const { Client } = require('boardgame.io/client'); +const { Stage } = require('boardgame.io/core'); +const crypto = require('crypto'); + +const NUM_MOVES = parseInt(process.argv[2] || '100000', 10); +const NUM_PLAYERS = 3; + +const SyntheticGround = { + name: 'synthetic-ground', + setup: ({ ctx }) => ({ + round: 0, + relationships: {}, // "a->b" -> {kind, strength} + hands: Object.fromEntries( + Array.from({ length: ctx.numPlayers }, (_, i) => [String(i), Array.from({ length: 7 }, (_, c) => c)]) + ), + committed: {}, + revealed: [], + score: Object.fromEntries(Array.from({ length: ctx.numPlayers }, (_, i) => [String(i), 0])), + }), + moves: { + commitAction: ({ G, playerID, random }, targetOffset) => { + const target = String((Number(playerID) + targetOffset) % NUM_PLAYERS); + const card = G.hands[playerID].length > 0 ? G.hands[playerID][0] : random.D6(); + G.committed[playerID] = { target, card }; + }, + resolveAll: ({ G, random }) => { + const order = Object.keys(G.committed).sort(); + for (const p of order) { + const { target, card } = G.committed[p]; + const key = `${p}->${target}`; + const rel = G.relationships[key] || { kind: 'rivalry', strength: 0 }; + rel.strength = (rel.strength + card + random.D6()) % 10; + G.relationships[key] = rel; + G.score[p] += rel.strength; + G.revealed.push({ p, target, card }); + if (G.revealed.length > 50) G.revealed.shift(); + } + G.committed = {}; + G.round += 1; + }, + }, + turn: { activePlayers: { all: Stage.NULL } }, +}; + +function makeClient(seed) { + return Client({ + game: { ...SyntheticGround, seed }, + numPlayers: NUM_PLAYERS, + playerID: '0', + }); +} + +function stateHash(client) { + const { G } = client.getState(); + return crypto.createHash('sha256').update(JSON.stringify(G)).digest('hex'); +} + +function run(seed, numMoves, collectHash) { + const client = makeClient(seed); + // Drive all players through commit windows via events/moves. + // Each "round" = NUM_PLAYERS commitAction moves + 1 resolveAll. + const rounds = Math.floor(numMoves / (NUM_PLAYERS + 1)); + const t0 = process.hrtime.bigint(); + for (let r = 0; r < rounds; r++) { + for (let p = 0; p < NUM_PLAYERS; p++) { + client.updatePlayerID(String(p)); + client.moves.commitAction(1 + (r % (NUM_PLAYERS - 1))); + } + client.updatePlayerID('0'); + client.moves.resolveAll(); + } + const t1 = process.hrtime.bigint(); + const elapsedMs = Number(t1 - t0) / 1e6; + const applied = rounds * (NUM_PLAYERS + 1); + return { + applied, + elapsedMs, + movesPerSec: Math.round(applied / (elapsedMs / 1000)), + hash: collectHash ? stateHash(client) : null, + finalRound: client.getState().G.round, + }; +} + +// --- throughput --- +const warm = run(42, Math.min(NUM_MOVES, 5000), false); // warmup/JIT +const main = run(42, NUM_MOVES, true); + +// --- determinism: same seed twice, different seed once --- +const detA = run(7, 4000, true); +const detB = run(7, 4000, true); +const detC = run(8, 4000, true); + +// --- memory --- +const mem = process.memoryUsage(); + +const result = { + library: 'boardgame.io@0.50.2', + node: process.version, + workload: `synthetic-ground ${NUM_PLAYERS}p commit/reveal`, + applied_moves: main.applied, + elapsed_ms: Math.round(main.elapsedMs), + moves_per_sec: main.movesPerSec, + final_round: main.finalRound, + determinism_same_seed: detA.hash === detB.hash, + determinism_diff_seed_differs: detA.hash !== detC.hash, + rss_mb: Math.round(mem.rss / 1048576), + heap_mb: Math.round(mem.heapUsed / 1048576), +}; +console.log(JSON.stringify(result, null, 2)); diff --git a/research/CB-RES-0001-harness/boardgame-io/package.json b/research/CB-RES-0001-harness/boardgame-io/package.json new file mode 100644 index 0000000..de96366 --- /dev/null +++ b/research/CB-RES-0001-harness/boardgame-io/package.json @@ -0,0 +1,16 @@ +{ + "name": "boardgame-io", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "keywords": [], + "author": "", + "license": "ISC", + "type": "commonjs", + "dependencies": { + "boardgame.io": "^0.50.2" + } +} diff --git a/research/CB-RES-0001-harness/boardgame-io/results-260731.json b/research/CB-RES-0001-harness/boardgame-io/results-260731.json new file mode 100644 index 0000000..c4648bc --- /dev/null +++ b/research/CB-RES-0001-harness/boardgame-io/results-260731.json @@ -0,0 +1,16 @@ +{ + "library": "boardgame.io@0.50.2", + "node": "v24.11.1", + "machine": "bnt-lap001 (WSL2)", + "recorded": "2026-07-31", + "workload": "synthetic-ground 3p commit/reveal (bench.js)", + "runs": [ + {"applied_moves": 5000, "moves_per_sec": 1930, "elapsed_ms": 2590, "rss_mb": 224}, + {"applied_moves": 10000, "moves_per_sec": 1605, "elapsed_ms": 6232, "rss_mb": 229}, + {"applied_moves": 20000, "moves_per_sec": 870, "elapsed_ms": 22978, "rss_mb": 271}, + {"applied_moves": 100000, "note": "did not finish within 300s; killed"} + ], + "determinism_same_seed": true, + "determinism_diff_seed_differs": true, + "dependency_stats": {"transitive_packages": 120, "node_modules_size": "37M", "core_package_size": "3.9M"} +} diff --git a/workplans/CB-WP-0001-inner-loop.md b/workplans/CB-WP-0001-inner-loop.md index 4caea4b..1d79ece 100644 --- a/workplans/CB-WP-0001-inner-loop.md +++ b/workplans/CB-WP-0001-inner-loop.md @@ -77,7 +77,7 @@ contains. These are the instruments the loop measures with; without them ```task id: CB-WP-0001-T03 -status: todo +status: done priority: high state_hub_task_id: "8dec6577-9397-40ee-b99b-9119dcdac115" ```