Skip to content

Web Replay + GUI revamp — plan & data contract

Branch: web-replay-dev (off main @ eb2f26c, v1.1.3). Status: shipped — F0–F4 + F6 done (static player, rich panels, world/multi-arena replay, curated showcase). F5 (live GUI overlays) and F7 (Quarto embed) deferred.

Goal

A static, server-less replay player for any BDPD game, plus an incremental revamp of the live GUI to surface features added since it was first built (governance, reputation, cheap-talk, perturbations, Worlds).

The replay is the centerpiece: open a recorded game and step through it turn-by-turn, with no engine, no LLM, no Express server running — just a trace.json and a static page. This makes it embeddable in teaching material later (mini_course), but Quarto integration is deferred — the player ships first as a standalone static tool.

Running it

Launch the server (serves the player + fixtures + live data). All commands in this section run from the git root — the migration/ working tree that holds package.json, main.js, web/, data/, scripts/. Every path below is relative to it (./web, ./data, ./scripts, …).

Be in the right directory. A stale main.js also exists one level up (in bdpd/, outside the git tree). Running node main.js from there starts a different, older app with no /replay route (you'll get {"ok":false,"error":"No route: GET /replay/replay.html"}). Confirm you're in the git root first: git rev-parse --show-toplevel must print the migration/ path.

# from the repository root (the git root — see the warning above)
node main.js                    # → http://localhost:3000
# or:  npm start                # same thing (package.json "start": node main.js)
# custom port:  PORT=8080 node main.js

You should see BDPD platform → http://localhost:3000 (and Plugins loaded: …). api/server.js static-serves web/ at the root and mounts data/ read-only at /data (GET/HEAD only; POST → 405). Stop with Ctrl-C.

Open the replay player:

http://localhost:3000/replay/replay.html

Where the catalog gets its games (two sources, merged). On load the player fetches, in order:

  1. /replay/fixtures/fixtures.json — the curated, git-tracked showcase (≤10 hand-picked fixtures under web/replay/fixtures/). Primary index; shown first under a "Curated showcase" header with an inset accent bar. Works even with no runtime data/.
  2. /data/manifest.json — the live runtime catalog, every game emitted under data/ (gitignored). Appended below a "Live (runtime) games" divider; duplicates of curated entries are skipped.

A catalog row's tracePath may be absolute (curated, /replay/fixtures/…) or relative (live, prefixed with /data/).

Opening a specific game — every game is its own URL (no SPA routing):

/replay/replay.html?trace=<url>            # open a game / world
/replay/replay.html?trace=<url>&round=N     # jump to round/turn N
/replay/replay.html?trace=<url>&arena=B     # drill a world straight into arena B

Plain click opens; ctrl/⌘-click → new tab; shift-click → new window. The player also works fully offline: drag-and-drop any trace.json / world_trace.json onto the drop zone — no server needed (decision D1).

Generating fixtures with --trace. --trace is an opt-in flag that makes an experiment/pilot also write a replay artifact (trace.json for a single arena, world_trace.json for a World) under the gitignored data/worlds/…/, plus a catalog entry in data/manifest.json. Without it, runs stay aggregate-only (so sweeps of thousands of runs don't get fattened — decision D4).

Worked example — emit the Seneca pollution cascade and view it (from the repo root):

cd <repo-root>
node scripts/pilot_v11_s2_polycentric.mjs --trace
#  → ✓ world trace [cascade] → worlds/s2_cascade/world_trace.json (40 rounds, 3 arenas)
#  → ✓ world trace [control] → worlds/s2_control/world_trace.json
# then (server running) open it directly:
#  http://localhost:3000/replay/replay.html?trace=/data/worlds/s2_cascade/world_trace.json
# or just reload the catalog — it now lists the new game under "Live (runtime) games".

All the emitters (each writes into the gitignored data/):

node experiments/experiment.js run aggressive_fraction --trace   # single-arena sweep fixtures (1 trace/sweep point)
node scripts/pilot_v11_s2_polycentric.mjs --trace                # S2 Seneca pollution cascade (world)
node scripts/pilot_v11_s3_pollution_governance.mjs --trace       # S3 governed cascades (world)
DEEPSEEK_API_KEY= node scripts/pilot_llm_resource_cascade.mjs   # LLM governance + cascade (world; always traces)
node scripts/build_manifest.mjs                                   # rebuild /data/manifest.json from existing data/
node scripts/curate_fixtures.mjs                                  # copy the curated subset → web/replay/fixtures/ (tracked)

To update the git-tracked showcase after regenerating, re-run curate_fixtures.mjs (build_manifest.mjs/rebuildManifest() only walk summary.json, so they do not pick up world/experiment/pilot traces — re-run those emitters instead).

Decisions taken (so far)

# Decision Rationale
D1 Replay is static / no-server "Replay any game without launching a server." Trace-pure player → no backend dependency.
D2 GUI revamp is incremental New features are additive; overlay them on a timeline, don't rewrite the framework.
D3 Full dump, no flattening Games are short (~20–30 turns, 3 players → trace ~100–200 KB). Size is a non-issue for single games.
D4 The "extended report" switch lives at the sweep layer Single games / live GUI always emit a trace. runner.js sweeps gate it behind --trace (default off) so campaigns don't fatten thousands of runs.
D5 trace.json is a sibling of report.json, not a replacement report.json stays lean (history table, at-a-glance stats); trace.json is the fat artifact consumed only by the replay player. The existing /history endpoint is untouched.
D6 Manifest is global for the MVP With Quarto deferred, the near-term driver is "browse all my games", which favors one global data/manifest.json. Per-collection manifests come back with Quarto packaging.
D7 Quarto embedding deferred Player is a standalone static tool first; lesson embedding agganciato later.

Architecture (3 decoupled layers)

  1. Trace emitter (engine-side, small). Serialize the arena's full per-turn history to a self-contained trace.json + append an entry to the global manifest.json. Hook in two places: the live arena close() and the experiments runner.js (behind --trace). An index-builder backfills the manifest from existing data/.
  2. Replay player (static, vanilla JS, zero backend). Loads a trace.json from file/URL. Timeline scrubber (step/play/pause/speed) + per-turn panels. Reuses the solarized themes and, where possible, the live arena's rendering components (a turn is a turn, whether from SSE or trace[i]).
  3. Narration sidecar (didactic, later). Optional narration.json keyed by turn (titles/notes/highlights) so the same game can carry different narrations for different lessons. Kept separate from the trace.

The raw-JSON inspector inside the player reuses tools/mimo_mem's renderTree collapsible widget — not a second tool.

Key finding

arena.fullSummary() (platform/arena.js:920) already returns the full per-turn history (history: this.history). The reporter's buildJsonReport (platform/reporter.js:165) is what flattens it down to thin series and discards messages / pacts / sanctions / reputation / per-player harvest.

So the trace emitter is almost trivial: it serializes fullSummary() verbatim (plus a traceVersion envelope). No new instrumentation in the engine is needed — the data already exists at close(); today it is simply thrown away by the reporter.

trace.json — data contract

Envelope + the existing fullSummary() payload. Field shapes below are taken directly from platform/arena.js; this documents what is already produced, it does not invent new fields.

{
  "traceVersion": 1,                 // bump on breaking shape changes
  "kind": "single-arena",            // future: "world" for multi-arena replays

  // ── identity / model (from fullSummary) ───────────────────────────────
  "arenaId":   "uuid",
  "model":     { /* name, description, scheduler, maxTurns, commonsInitial,
                    commonsCapacity, regenRate, threshold, ... */ },
  "status":    "closed",
  "endReason": "max_turns" | "collapse" | ...,
  "turns":     30,                   // turns actually played
  "collapsed": false,
  "collapseReason": null,

  // ── final state ───────────────────────────────────────────────────────
  "players":  [ /* p.toPublic(): id, displayName, agentType, strategy,
                   wealth, lastHarvest, totalHarvest, ... */ ],
  "commons":  { "stock": 0.0, "capacity": 0.0, "regenRate": 0.0, ... },
  "hiddenReserve": null,
  "metrics":  { /* snapshot(): gini, welfareScore, ... final-turn */ },
  "victory":  { "gatePassed": bool, "winner": ..., "ranking": [...], "explanation": "..." },

  // ── the replay substrate: full per-turn history ───────────────────────
  "history": [ /* TurnRecord[], see below */ ],

  "perturbations": [ /* schedule.toJSON() */ ],
  "createdAt": "iso", "startedAt": "iso", "closedAt": "iso"
}

TurnRecord (one element of history[])

From platform/arena.js:412 onward. Governance/messages fields are present only when non-empty (keeps non-governance arenas clean) — the player must treat them as optional.

{
  "turn": 7,
  "schedule": ["pid1", "pid2", ...],     // resolved play order this turn
  "scaledHarvest": false,                // true if demands were capacity-scaled
  "elapsed": 1234,                        // ms

  "commons": {                            // shape after the turn
    "stockBefore": 0.0,
    "stockAfter":  0.0,
    "regen":       0.0
  },

  "decisions": [                          // one per player that acted
    {
      "playerId":  "pid1",
      "demanded":  3.0,                    // requested harvest
      "actual":    2.4,                    // granted (after caps/scaling)
      "announced": 3.0,                    // cheap-talk announced value, or null
      "error":     null                    // agent error string, or null
    }
  ],

  "players":  [ /* p.toPublic() snapshot at this turn */ ],
  "metrics":  { /* snapshot(): gini, welfareScore, ... */ },

  // ── optional governance bundle ────────────────────────────────────────
  "pacts":      { "active": [...], "violations": [...] },   // only if any
  "reputation": { /* per-player behaviour score snapshot */ },
  "messages":   [ /* cheap-talk this turn: broadcast/private/announce */ ], // only if any
  "perturbations":     [ /* applied this turn */ ],          // only if any
  "metaPerturbations": [ /* meta-role agent effects */ ],    // only if any
  "governance": { /* governanceSnapshot(): cooperation_index, sanction_rate, ... */ },

  // ── only on the final record ──────────────────────────────────────────
  "victory": { /* victoryGateRank() */ }
}

manifest.json — catalog contract (global)

The static catalog browser can't ls a directory, so the emitter maintains an index. One entry per recorded game; the browser filters on it and links to the trace.

{
  "manifestVersion": 1,
  "games": [
    {
      "arenaId":    "uuid",
      "tracePath":  "worlds/default/arenas/<uuid>/trace.json",
      "model":      "fishery_commons_v1",
      "description": "Standard fishery — tragedy of the commons",
      "turns":      30,
      "players":    3,
      "gatePassed": false,
      "endReason":  "collapse",
      "tags":       ["seneca", "paper_03"],   // optional, for curation
      "closedAt":   "iso"
    }
  ]
}

Relation to existing artifacts

Artifact Producer Size Consumer Touched?
report.json reporter.buildJsonReport ~4–14 KB /history table, quick stats No — stays lean
summary.json registry ~90 KB experiments / history No
trace.json new emitter (≈ fullSummary()) ~100–200 KB replay player new
manifest.json new index-builder / emitter small catalog browser new

MVP (phased)

  • F0 — trace emitter + manifest. ✅ DONE. platform/trace.js writes trace.json (envelope around fullSummary()) + upserts the global data/manifest.json on close(); deleteArena removes the entry; scripts/build_manifest.mjs backfills existing data/. (runner.js --trace for sweeps is still pending — runner builds Arenas directly, bypassing the registry persistence path.)
  • F1 — static player. ✅ DONE. web/replay/replay.html — self-contained, no-build, no-dep. Drop/pick a trace.json (zero-server) or browse the catalog when served (read-only /data mount in api/server.js). Renders a timeline scrubber (play/pause/step/speed), commons trajectory (SVG, with capacity + collapse-threshold guides), per-player wealth bars, per-turn metrics, and a raw turn-record peek. ?trace=<url> deep-link supported.
  • F2 — rich panels. ✅ DONE. Wealth bars now also show per-turn harvest (granted vs asked, plus announced value). New panels: reputation (score + coop/defection/violation counts), governance (cooperation index, sanction rate, announce freq, silent defection, lie score), and — only when the trace carries them — a cheap-talk message log (broadcast/announcement/private) and a pacts & sanctions panel (active pacts, violations, applied sanctions). Conditional panels auto-reveal from a trace-level scan. Verified by headless (chromium --dump-dom) render of a synthetic governance fixture (all panels populate) and a real non-governance game (conditional panels stay hidden).
  • F3 — fixtures via runner.js --trace. ✅ DONE (mechanism). The experiment runner now emits one representative replay trace per sweep point (first run only — not one per run, which would flood the catalog) under data/worlds/<trace-world>/arenas/, with a deterministic per-point id so reruns overwrite. CLI: experiment.js run <def> --trace [--trace-world W]. Traces carry tags + a <def> · <cell> description; the catalog shows the description as the heading so sweep cells are distinguishable. Experiment arenas get only trace.json (no meta.json), so they appear in the replay catalog but never in the history page. Verified: pure_seneca_shock --trace produced 7 catalog entries (incl. the ×0.10 collapse, gate ✗ at 53 turns). Remaining: curate which canonical games to ship as teaching fixtures (Seneca done; polycentric cascade S2 still to generate).

Real governance fixtures (cheap-talk + pacts + sanctions) come for free from existing LLM pilots — several data/pilot/... runs are already full fullSummary() dumps with per-turn messages/pacts/sanctions. scripts/import_pilot_fixtures.mjs wraps a curated set (D2-LLM graduated & constant sanctions, D1 cheap-talk on/off) into the catalog under data/worlds/pilots/arenas/ — no re-run, no API. This surfaced one real-data divergence from the synthetic fixture: the engine emits changes.sanction as an object {playerId,name,amount,graduated,…}, not a flat boolean+wealth; the pacts panel now reads both shapes (graduated ladder amounts render correctly, e.g. −1 then −3). - F4 — curated paper fixtures (single-arena + world). ✅ DONE. The original F4 idea — building worked solutions of the mini_course mini-challenges as "model answer" replays — was dropped (too much bespoke authoring for the payoff). Instead we ship the complex games already reported in the papers as replay fixtures: - single-arena (via experiment.js run <def> --trace): aggressive_fraction (BDPD¹ cooperation→collapse phase transition, 7 cells), mule_strategy_override (BDPD³ structural perturbation, 6 cells), adaptive_effectiveness (governance, 9 cells), on top of the existing pure_seneca_shock Seneca sweep and the imported LLM pilots. - world (multi-arena, see F6): the S2/S3 polycentric pollution cascades. - F5 — live GUI overlays. governance / reputation / cheap-talk / Worlds, reusing F2 panels. (deferred) - F6 — Worlds multi-arena synchronized replay (kind: "world"). ✅ DONE. The static player now also replays a World: N per-arena summaries stepped in lockstep by a single global-round scrubber, with a cross-arena flow panel. - Per-turn engine state. arena.js now records turnRecord.extra = engine.extraObservations() each turn (engine-agnostic; {} for non-Seneca). This is the raw engine state, not observability-gated, so replay can chart the hidden variables (the cascade's pollution) — the whole point of the S2 vignette. It also enriches every Seneca single-arena trace (capital/pollution trajectories). - world_trace.json contract (platform/trace.js buildWorldTrace): { traceVersion, kind:"world", worldId, name, model, globalRounds, links[], config, arenas:[{ label, role, …fullSummary() }], rounds:[{ globalClock, links:[…flow…], metaActions?, aggregated }] }. Emitted with emitWorldTrace into data/worlds/<wid>/world_trace.json + a manifest entry (worldManifestEntry, kind:"world", arenaCount). - Fixtures. pilot_v11_s2_polycentric.mjs --traces2_cascade (A poisons B,C; B/C economy ✕ while their visible R rises — the "no visible culprit" perversion) and s2_control. pilot_v11_s3_pollution_governance.mjs --traces3_control, s3_cap-reactive (lagging P signal, too late), s3_cap-leading (leading capital signal, win-win). Verified by headless chromium render. - Player view. web/replay/replay.html branches on trace.kind. World mode draws a grid of per-arena cards (R + capital + hidden-pollution mini-charts, shared scale for R/C, own scale for P, current-round markers) and a per-round cross-arena flow panel. Each card shows a mechanism summary (cheap-talk / pacts / sanctions pills) and a detail ↗ deep-link (?trace=…&arena=<label>) that opens that arena in the full single-arena player — so the rich governance panels (cheap-talk log, pacts & sanctions, reputation, governance metrics) are reachable per arena without duplicating them in the world grid. - LLM showcase fixture. pilot_llm_resource_cascade.mjsllm_cascade: 3 logistic arenas, 3 deepseek-v4-flash players each (2 conservative + 1 aggressive "mule"), a harvest_cap pact + graduated sanctions per arena, connected by resource links A→{B,C} (upstream feeds downstream; A's draw-down shrinks the downstream feed). The one fixture that exercises every mechanism at once with real cognition — drill into any arena to see cheap-talk + pacts + sanctions. Why resource (not pollution) links: the Seneca substrate has no resource regen and runs at R₀≈1.0 with negligible harvest, so LLM agents (fishery-scale harvests) drain it on turn 1 — verified by a smoke test. Resource links (setStock, engine-agnostic) work on logistic where the proven d2_llm governance recipe behaves. A faithful Seneca-pollution LLM cascade would need a fragile ODE re-tune (R₀ large, k1÷~300) and is left as a possible follow-up.

Manifest entries now carry an optional kind field ("single-arena" | "world"); entries written before it are treated as single-arena. NOTE: rebuildManifest() only walks summary.json under data/worlds/*/arenas/, so it does not regenerate world traces, experiment --trace fixtures, or imported LLM pilots (none of which have a summary.json) — re-run their emitter scripts to refresh, don't rely on a rebuild.

Catalog is one-page-per-game. Catalog entries are real <a href="?trace=…"> anchors (the deep-link also accepts &round=N, and &arena=<label> to drill a world straight into one arena's full single-arena view), so plain click opens a game, ctrl/⌘-click a new tab, shift-click a new window — manageable as tabs/windows without any SPA routing. World entries additionally render per-arena arena detail: sub-links in the catalog.

Curated, git-tracked showcase. Runtime fixtures live under data/ which is gitignored (runtime state, regenerable, ~10 MB). To ship a stable, versioned set, scripts/curate_fixtures.mjs copies a curated subset (≤10, the coolest / most articulated) into web/replay/fixtures/ (tracked, served by the app at /replay/fixtures/) and writes fixtures.json, a curated catalog with showcase-order entries and punchy labels. The player's catalog loads fixtures.json first (primary index, works with no runtime data/) and appends the live /data/manifest.json for dev. A fixture's tracePath may be absolute (curated, /replay/fixtures/…) or relative (live, prefixed /data/). Re-run the curator after regenerating fixtures. (Publishing the showcase inside the docs/Quarto site — serving these same files from the docs build — remains the deferred F7 step.)