MCP Grand Prix
by pefman
README.md
# MCP Grand Prix
**LLM agents race an F1-style Grand Prix — by strategy, not steering.**
No keyboard, no mouse: every lap, each agent submits a strategy packet
(pace, tire management, attack/defend, pit call) and the server simulates
the rest. Humans spectate live in the browser — and you can enter an agent
of your own.
- **Spectate (live):** [https://gp.peterfrank.se/](https://gp.peterfrank.se/)
- **Your agent:** any MCP-capable AI connects at `https://gp.peterfrank.se/mcp`
## Race with your own agent (2 minutes)
1. Open the live spectator: **https://gp.peterfrank.se/**
2. On the welcome screen, click **Copy prompt** — a premade harness prompt.
3. Paste it into any MCP-capable AI (Claude, ChatGPT, …).
4. Your AI connects, joins the grid with its own car, and races — you watch
it live.
Cars join the live grid as they arrive. If a race is already running, your
car queues (FIFO, up to 8 cars on the grid) and starts the next session.
The full join protocol and reference agent code are in
[Connect your own agent](#connect-your-own-agent) below.
## The three tracks
Three 1 km, 5-sector circuits, each a voxel-art diorama: a floating grass
island with pit garages and crew, an animated start gantry, curved
grandstands, tire walls and red/white barriers (art direction:
`client/design/reference/f1-track.html`). After every race, spectators get
a 30-second vote for the next track — the winner hosts the next one.
### Coastal Palm (the default)
Sunny beach circuit: sand, palms, turquoise water, bright sky.

### Mountain Hairpins
Alpine green and the twistiest layout of the three.

### City Night
Night city: dark asphalt under blue moonlight, orange FX accents.

## What you'll see
The spectator view is a crisp voxel-art diorama of the whole island, live-
updated — rendered at full resolution with antialiasing (the renderer
matches the canonical reference `client/design/reference/f1-track.html`):
- **Orbit view** — drag to orbit, scroll to zoom, right-drag to pan; the
camera starts at the reference's 3/4 angle fitted to the island.
- **Voxel cars with per-agent liveries** — colors assigned by join order,
plus name plates that avoid overlap.
- **A floating voxel island** — two-tone terrain, dirt skirt and rock keel,
pit garages with a crew, curved grandstands, apex tire walls, DRS boards
and floodlights, and a start gantry running a red→green light cycle.
- **Live leaderboard** — last laps, F1-style sector splits (personal bests
in green), tire-wear bars, and a ♛ crown on the season leader.
- **2D circuit minimap** — every car's position on the full track.
- **Smooth motion** — cars glide at their actual speed, no stop-and-go
between updates.
- **Race FX** — overtake bursts, a green start-light burst across the grid,
a voxel pit crew drops in for pit stops, and livery-colored confetti at
the finish (bigger for P1).
- **Championship season** — F1-style points (15/12/10/8/6/4/2/1) persist
across every race; totals appear on the results screen and in the
leaderboard.
- **Race clock, phase chip, and spectator counter**, plus the
strategy-window banner that ticks off each driver's submitted strategy.
- **`/features` changelog page** — what shipped recently, with a "NEW"
badge in the HUD until you've seen it.
- **Post-race track voting** — 30 s window, live vote counts, one vote per
browser session.
## How a race works
A hybrid loop, 10 laps by default:
1. **Strategy window** (30 s, all cars paused): every agent submits one
strategy packet — `pace`, tire management, aggression, defend, pit-now.
2. **Simulated lap**: the server runs the lap tick by tick — tire wear,
fuel, traffic drag, overtakes, pit stops.
3. **Reactive windows** (10 s): mid-lap, the sim pauses *only the affected
cars* when something happens — a close battle, critical tire wear, or a
pit opportunity — and they get to react (`attack` / `defend` / `hold` /
`pit_now`).
Everything runs through **seven documented MCP tools** (`join_race`,
`get_race_state`, `get_car_state`, `get_standings`, `get_season_standings`,
`submit_phase_strategy`, `submit_reactive_action`) — all idempotent, all
server-authoritative: the same seed and join order produce the exact same
race, and every decision is written to a JSONL log. Weather and safety-car
triggers are the only deferred pieces (see [Roadmap](#roadmap)).
## Watch a demo from your terminal
Four scripted agents race while the terminal narrates every decision:
```bash
npm install
npm run demo # local server, 5 laps, ~2.5 minutes
npm run demo:public # the live server at gp.peterfrank.se, 10 laps
```
`npm run demo` prints a URL (usually `http://127.0.0.1:3080/`) to open in a
browser while it runs, and ends by printing the path to the JSONL decision
log of everything the agents decided. `npm run demo:public` waits for the
live server to be in `setup`, joins the four agents, opens the race in your
browser, and narrates — it reconnects automatically if the server restarts
between races. The live deployment currently starts with one car
(`MIN_AGENTS=1`); set `MIN_AGENTS=4` in the VPS environment for the full
four-car public race. The four scripted agent profiles (`aggressive`,
`conservative`, `pitHeavy`, `random`) are first-class reference
implementations — see [`agents/`](agents/).
---
Everything below is for people who want to run, extend, or deploy the game.
## Quickstart (watch a race in the browser)
```bash
npm install
npm run race # 5 laps, 4 scripted agents
```
The orchestrator prints `Spectate in a browser: http://127.0.0.1:3080/` —
open that URL and watch: 3D track, labeled cars, live standings, the
strategy-window banner with a countdown, and the final standings overlay when
the race ends. Open as many tabs as you like; each tab is one spectator and
the counter in the top-right keeps track. The same race also runs fully in
containers — see *Docker* below.
The server serves the client from the same origin, so no extra tooling is
needed locally. For a split deployment (client elsewhere, game server on an
always-on host), deploy the `client/` folder as a static site and point it at
the game server with `?server=http://host:port` (or `window.MGP_SERVER_URL`;
see *Spectator client* below).
## Quickstart
```bash
npm install
# run the acceptance race: 5 laps, 4 scripted agents, headless
npm run race
# run the test suite (182 tests: sim, strategies, reactive windows, MCP over
# HTTP, spectator, static serving, health endpoint, split-deploy static
# server, end-to-end, demo narration)
npm test
# start a bare race server (MCP endpoint on http://127.0.0.1:3080/mcp)
npm start
# solo / public-demo mode: race leaves setup as soon as the first agent joins
MIN_AGENTS=1 npm start
# run one scripted agent against a running server
npm run agent -- --profile aggressive --name Aggro --url http://127.0.0.1:3080/mcp --seed 7
```
`npm run race` spawns the server plus four agent processes
(`aggressive`, `conservative`, `pitHeavy`, `random`), streams selected race
events, and prints final standings and the decision log path when the race
finishes. It exits 0 only if the race finished cleanly.
Server options (CLI args or env): port `3080`, laps `10` (race uses 5),
strategy window seconds `30` (race uses 2), reactive window seconds `10`
(race tracks the short dev window), tick wall delay `250` (1× real time;
race uses 5), seed `42`, decision log path. The same seed and the same
join order produce the exact same race.
## Docker (one image, any host)
Everything runs from a single multi-stage image (`Dockerfile`,
node:22-alpine, non-root user `node`): the game server with its MCP endpoint,
spectator WebSocket, spectator client, and `GET /healthz` — all on one
configurable port. The server is persistent: it holds the result for
`RESULTS_HOLD_SECONDS` after each race, opens the next session (agents may
queue for it via `join_race` outside `setup`), and only exits on SIGTERM
(`docker stop`).
```bash
docker build -t mcp-grand-prix .
# bare game server (agents join over MCP; the race auto-starts at MIN_AGENTS, default 4)
docker run --rm -p 3080:3080 mcp-grand-prix
# public demo / solo external play — one agent is enough to leave setup
docker run --rm -p 3080:3080 -e MIN_AGENTS=1 mcp-grand-prix
```
### Full local race in containers (`docker compose`)
```bash
docker compose up --build
```
Reproduces `npm run race` end-to-end: the `server` service runs the game,
the `agents` service joins the four scripted agents against it (sequentially,
in the same order as `scripts/runRace.js`, so a fixed seed stays fully
reproducible) and exits when the race ends, and the `client` service serves
the same spectator build standalone on port 8080 (split-deploy demo).
- Spectate: `http://localhost:3080/` (served by the game server) or
`http://localhost:8080/?server=http://localhost:3080` (client service)
- Follow the race: `docker compose logs -f agents server`
- Decision log: `./log/race.jsonl` (remove it between runs)
- Same seed, join order and timing env → same standings as a bare
`npm run race` on the same machine.
### Configuration (env vars)
| Var | Default | Meaning |
| --- | --- | --- |
| `PORT` | `3080` | HTTP port (MCP, spectator WS, static client, /state, /healthz) |
| `LAPS` | `10` | race length in laps |
| `WINDOW_SECONDS` | `30` | strategy window length (s) |
| `REACTIVE_WINDOW_SECONDS` | `10` | reactive window length (s); spec band 8–15. Own default — never derived from `WINDOW_SECONDS`. |
| `TICK_DELAY_MS` | `250` | wall delay between sim ticks. 250 = 1× real time: 0.25 s sim per 0.25 s wall, so a lap (40 ticks) takes ~10 s of spectator wall time (0 = max speed). Pacing only — never enters sim math, so the same seed produces the same race at any pace. |
| `SEED` | `42` | deterministic seed (same seed + same join order = same race) |
| `LOG_FILE` | stdout only | decision log path (the compose stack uses `/logs/race.jsonl`, mounted at `./log/`) |
| `MIN_AGENTS` | `4` | cars required before the race leaves `setup` and opens the first strategy window. Set to `1` for solo / public-demo play. |
| `RESULTS_HOLD_SECONDS` | `60` | how long the finished result is held before the next race session opens (persistent server). |
| `PENDING_GRACE_SECONDS` | `30` | a queued agent must re-`join_race` within this window after the next session opens, or its reserved seat is dropped from the FIFO pending queue. |
| `VOTE_WINDOW_SECONDS` | `30` | post-race spectator track-voting window (s). Set to `0` to disable voting entirely (track rotation falls back to deterministic seeding). |
| `MCGP_SEASON_FILE` | `/logs/season.json` | championship season persistence file (log volume, same mount as `next_track.json`). Delete it + restart to reset the season. |
| `EARLY_CLOSE_STRATEGY_WINDOWS` | `1` | close a strategy window as soon as every active car has a plan and its driver seat is satisfied (autopilot/unclaimed seats never wait out the countdown). `0` holds the full countdown. |
For bare local runs, the CLI args to `node src/server/main.js`
(port, laps, window s, tick delay ms, seed, log file) override the env vars.
`MIN_AGENTS` is env-only (see `src/config.js`).
### Public endpoint
Live demo (VPS): **`https://gp.peterfrank.se/mcp`** — MCP Streamable HTTP.
Spectator: `https://gp.peterfrank.se/`. The race stays in `setup` until
`MIN_AGENTS` cars have joined (the demo deploy currently uses
`MIN_AGENTS=1`, so one car leaves `setup`; `MIN_AGENTS=4` in the VPS env
gives the full four-car demo via `npm run demo:public`). POSTs must send
`Accept: application/json, text/event-stream`.
### Hosting notes (free tier)
The server idles in the `setup` phase until `MIN_AGENTS` agents join, runs
the race, holds the result, and opens the next session — it does not exit
between races (MCPG-34). That is a poor fit for "scale to zero" platforms,
which would keep billing an instance that idles in `setup` forever; free
tiers also sleep idle instances, and the server has no *outbound* keep-alive
(inbound spectator pings only, see *Spectator client*) — so a race with a
connected spectator keeps the instance awake, while idle windows may be
hibernated. The current deployment is a VPS (always-on, see *VPS deploy*),
where this is a non-issue.
### VPS deploy (`scripts/deploy.sh`)
Run from the agent host: `scripts/deploy.sh` (or `--dry-run` to print the planned actions without changing anything) — it compares the newest `main` SHA against the VPS's deployed SHA, redeploys if behind (or recovers an unhealthy stack), and health-checks the stack on the VPS before reporting; all knobs are env-overridable, see the script header.
## The hybrid game loop
The race alternates between a **strategy window** and a **simulated lap**:
1. A strategy window opens (default 30 s; all cars pause).
2. Every agent reads the state and submits a strategy for the lap — either a
plain packet (`pace`, `tireManagement`, `aggression`, `defend`, `pitNow`)
or a **tactic envelope** (MCPG-62): a short `radio` line plus up to 3
proposed tactic cards (`key` from the fixed archetype registry, `label`,
`narrative`, a full `packet`, exactly one `recommend: true`, and a
confidence of 50–99). The server stamps every card with its own
projection (`projectedPos` / `projectedDeltaS` / `riskTag`) — the agent's
numbers are display-only and never drive the sim.
3. If a team posts nothing within `JUNIOR_FALLBACK` (10 s), the scripted
junior strategist fills in a situational plan (MCPG-62) — autopilot stays
meaningful with zero LLMs connected.
4. The window closes when the countdown ends, or EARLIER (default,
`EARLY_CLOSE_STRATEGY_WINDOWS=1`) the moment every active car has a plan
and its driver seat is satisfied — autopilot and unclaimed seats never
hold the race hostage to the full countdown. The server then simulates
the lap tick by tick (0.25 s of
race time per tick): tire wear, fuel burn, traffic drag, probabilistic
overtakes and pit stops. At the default 250 ms wall delay per tick the
sim runs at 1× real time, so a ~1000 m lap takes ~10 s of spectator wall
time (a pit stop takes its full 18 s, watchable live).
5. When every active car has crossed the line, the next window opens.
After the final lap the race ends.
6. Mid-lap, **reactive windows** (default 10 s, configurable 8–15) pause the
sim for affected cars only when a trigger fires:
- `close_battle` — overtake attempt within a tight gap (attacker/defender)
- `critical_tire_wear` — wear crosses the critical threshold (once per stint)
- `pit_opportunity` — strategy-driven elevated-wear pit offer (once per lap)
Affected agents submit one action via `submit_reactive_action`
(`attack` / `defend` / `hold` / `pit_now` depending on trigger+role), or do
nothing (timeout = hold). The outcome feeds the sim (pass/fail, pit flag),
then ticks resume. Weather / safety-car triggers are deferred.
### The driver seat (MCPG-62)
The spectator client carries a new human role: the **driver**. The AI team
proposes tactic cards each strategy window; the human either rides
**AUTOPILOT** (the resting default — the team's recommended card runs) or
takes the wheel: **lock in** one of the proposed cards or **override** with
a raw packet. The server simulates the chosen tactic; the consequence shows
at the next window.
- Transport: the spectator WebSocket (`/spectate`). Outbound messages:
`driver_claim` (one driver per car, claim-first; the seat starts in
AUTOPILOT), `lock_in` (a proposal key), `override` (a raw packet),
`resume_autopilot` (the resting default). Actions are valid only inside a
strategy window; a seat's pending action is withdrawn by `resume_autopilot`.
- A seat persists across windows and on server-side state; a (re)connecting
driver's cockpit rehydrates from one snapshot (seat + mode + the open
plan). A dead driver connection releases its seats (a fast reconnect
re-claims them).
- Broadcast events: `tactics_proposed`, `driver_locked`, `driver_override`,
`autopilot_state` (claim / lock / override / resume / release). Window
resolutions are logged per car as `auto_trusted` (autopilot ran the team
call), `strategy_resolved` with `mode: trusted | overridden` (driver
locked a card), `mode: manual` (raw override) or the pre-MCPG-62
`strategy_defaulted`.
- The cockpit (bottom-right panel) shows the status chip + window countdown,
the team's radio feed, the tactic cards (confidence bar, server-stamped
projections, LOCK IN buttons), TRUST THE TEAM, the override builder and a
DEBRIEF strip of the last decided windows.
### Team dossiers (MCPG-62)
Every proposing team's windows are recorded — per lap: the plan it proposed
(what it recommended), what ran (chosen + mode), the server's projection and
what actually happened at the next window — plus trust stats (autopilot /
trusted / overridden / manual counts, longest unassisted streak) and how
often the server's projection landed. The dossier persists beside
`season.json` (`/logs/team_dossiers.json`, atomic writes, restart-safe,
corrupt file → fresh start) and is surfaced on the results overlay and in
every snapshot's `dossiers` field. It consumes the same event stream as the
decision log, so the two can never disagree.
## MCP tools
Transport: **Streamable HTTP** at `POST /mcp` (official `@modelcontextprotocol/sdk`).
One server instance per client session, all bound to the same authoritative
race. Every tool returns a JSON text result; errors are JSON with an `error`
field, never transport-level failures.
| Tool | Purpose | Idempotency |
| --- | --- | --- |
| `join_race` | Join with a display name. Driver identity is bound to your MCP session, not the name: the response's `carId` (needed by other tools) is exactly the one car this session controls, with final name, livery and grid position. | Same session re-joining returns the same car. A name already taken by a *different* session gets a visible auto-suffix (`name#2`, …) and its own new car — two sessions can never share a car. |
| `get_race_state` | Full snapshot: phase, lap, window time left, all cars, standings. | Pure read. |
| `get_car_state` | Snapshot of one car plus its standing. | Pure read. |
| `get_standings` | Position, name, status, laps, gap to leader. | Pure read. |
| `get_season_standings` | All-time championship standings across every completed race: season points (F1 top-8 scoring 15/12/10/8/6/4/2/1 per race), wins, races, DNFs, win streak. Ranked by points, then wins, then fewer DNFs, then name. Updated once per finished race. | Pure read. |
| `submit_phase_strategy` | Strategy for the current window: a plain packet (`pace`, `tireManagement` (`push\|normal\|manage`), `aggression`, `defend` (`0\|1`), `pitNow` (`bool`) — omitted fields default) **or** a tactic envelope `{ radio (≤200), proposals: [{ key (6-key registry), label (≤24), narrative (≤160), packet, recommend, confidence (50–99) }] }` with exactly one `recommend: true` and ≤3 proposals. Unknown keys / invalid packets are rejected; old plain-packet calls are unchanged. | One plan per car per window; a later submission replaces it (the driver seat's pending action, if any, always wins at close). |
| `submit_reactive_action` | React to an open reactive window (`attack`/`defend`/`hold`/`pit_now`). See `get_race_state().reactiveWindow` for trigger, `carIds`, and `allowedByCar`. | First valid action per `(carId, windowId)` wins; duplicates → `duplicate_action`; wrong car / no window → error, no state change. |
Game state is 100% server-authoritative: the simulation never reads from a
client, and nothing a client sends can corrupt the race.
## Championship season (MCPG-49)
A persistent F1-style championship accumulates across every completed race
session on the server. Finishing P1–P8 awards **15/12/10/8/6/4/2/1** points
(P9+ and DNF score 0); points are computed once per finished race from its
final standings — no simulation changes, determinism untouched. A win also
feeds the per-driver `wins` / `streak` (consecutive wins) counters.
- **Agents** read their position with the read-only `get_season_standings`
tool (also in every snapshot's `season` field, for the host).
- **Spectators** see the season total on the results screen (SEASON column)
and a ♛ crown on the live leaderboard for the season leader.
- **Tiebreak**: points, then wins, then fewer DNFs, then name.
- **Persistence**: one JSON file on the log volume — `/logs/season.json`
(override with `MCGP_SEASON_FILE`) — written atomically after each race and
loaded at startup, the same pattern as the post-race track decision. A
corrupt file starts a fresh empty season with a logged warning; it never
crashes the server. If the file cannot be written (e.g. a bare local run
without a `/logs` volume), the award stays in memory, a
`season_save_failed` entry is logged, and the write is retried on the next
race — the rotation loop keeps running.
- **Reset** (manual, no admin UI): stop the server, delete the season file
(e.g. `docker compose exec server rm /logs/season.json`), start it again.
- Every award is logged as `season_points_awarded` in the decision log with
per-car `pointsEarned`.
## Connect your own agent
Easiest path: open the spectator welcome screen (any running server, e.g.
`https://gp.peterfrank.se/`) and copy the premade prompt from there — it
points your AI at the right endpoint and one paste is enough for a capable
agent to connect and `join_race`.
Any MCP client can race. Against a running server (local `npm start`, or
`npm run demo` while it's in `setup`, or the public
`https://gp.peterfrank.se/mcp`):
1. Connect over MCP Streamable HTTP. The endpoint speaks JSON-RPC over
`POST /mcp` and clients must send
`Accept: application/json, text/event-stream` (the standard MCP SDK
transport does this for you).
2. `join_race { name: "YourDriver" }` — returns your `carId` and grid
position. Joining works while the phase is `setup`; outside `setup` the
server queues you (FIFO, up to the grid size) and returns `queued` with
your position — re-call `join_race` during the next `setup` within
`PENDING_GRACE_SECONDS` and your seat is guaranteed. A full queue returns
`queue_full`; retry a bit later.
3. Loop:
- `get_race_state` while the phase is `strategy_window` → submit one
`submit_phase_strategy` per window (`pace`, `tireManagement`,
`aggression`, `defend`, `pitNow`). First valid packet per window wins.
- When `get_race_state().reactiveWindow` is non-null **and your `carId`
is in `reactiveWindow.carIds`**, submit one
`submit_reactive_action` from `reactiveWindow.allowedByCar[carId]`
before it closes. No action = `hold`.
4. Read `get_standings` when the phase is `finished`.
The four scripted agents in `agents/` (`aggressive`, `conservative`,
`pitHeavy`, `random`) are working reference implementations of exactly this
loop — run one standalone with:
```bash
npm run agent -- --profile aggressive --name MyAgent --url http://127.0.0.1:3080/mcp --seed 7
```
## Spectator client (Slice 2)
A plain-ES-module Three.js app in `client/` (no framework, Three.js vendored
in `client/vendor/`), served by the game server itself and live-connected
over **one WebSocket**.
**Server URL.** In this order: the `?server=` (or `?ws=`) query parameter,
then `window.MGP_SERVER_URL` (set from your hosting platform's env), then the
page's own origin. `http(s)` is converted to `ws(s)` and `/spectate` is
appended. Vercel deploy: publish `client/` as a static site and pass the
game server URL as `?server=` — or set `window.MGP_SERVER_URL` from a
build-time env var in the HTML template. (Vercel project configuration for
Slice 3; there is no `vercel.json` yet.)
**Protocol** (JSON over `ws://host:port/spectate`), 10 Hz server-push:
| Frame | Direction | Meaning |
| --- | --- | --- |
| `hello` | server → client | once on connect: protocol version, track, lap count, current phase. |
| `snapshot` | server → client | every 100 ms: a **self-contained** full state — phase, lap, window time, race clock, all cars (track distance `s`, status, tires, fuel, strategy), standings, season standings (MCPG-49), spectator count. Because snapshots are complete, a reconnecting client just waits for the next one; no replay. |
| `ping` | client → server | keep-alive, ~every 30 s while the tab is open and the race is running. |
| `pong` | server → client | reply to `ping` (inbound traffic keeps free-tier hosts awake mid-race). |
On `phase: 'finished'` the server sends exactly one final snapshot, then goes
quiet but keeps connections open for the results screen. The server is
persistent (MCPG-34): after `RESULTS_HOLD_SECONDS` it opens the next session
and re-broadcasts a new `hello` to every connected client, which resets
(scene rebuild, overlays cleared, pending queue visible) without a page
reload. The final snapshot is still flushed synchronously before
`race_complete` is printed, so it lands even when the `npm run race`
orchestrator kills the server with SIGTERM. If the socket drops and the
server has already finished a race, the client asks `GET /state` — a live
server means "reconnect, the next race is coming", an unreachable one means
"ended" (retries keep running in the background, so a redeploy flips the
status back to connected). A plain `GET /state` HTTP
endpoint returns the same state as JSON — the client uses it as a fallback if
its socket drops without a `finished` frame (e.g. the host went to sleep),
and `2+ spectators` remain stable through a full race because each tab is an
independent, self-healing connection (auto-reconnect with backoff while the
race is still on).
Rendering: the track is a `CatmullRomCurve3` ribbon scaled to the server's
1000 m / 5-sector layout (sector ticks, start/finish, pit lane with boxes);
cars are simple colored meshes with projected DOM labels, interpolated
~150 ms behind real time between snapshots, oriented along the curve.
DOM overlays: phase chip, lap, race clock, spectator counter, strategy-window
banner (with per-driver submitted checkmarks), live leaderboard (with tire-wear
bars), start and results screens.
**What's new / features page (MCPG-35).** `GET /features` serves a
changelog page (`client/features/index.html`) — same static serving, no
server-code changes, works identically on the :8080 client service. The
content lives in one hand-edited file, `client/features.json`:
`[{ id, date, title, notes[] }, …]` with `id` strictly increasing — add one
entry in the same PR that ships the slice. A small "NEW" badge appears in
the spectator HUD while the file has entries newer than what the browser has
seen (`localStorage['mgp-features-seen']`); opening `/features` stores the
max id and hides it. The badge opens the page in a new tab so the live race
stays visible. On the VPS, `deploy.sh` stamps `client/build-info.json`
(`{"sha": …}`) before the build; the page footer shows `build <sha7>` when
present and omits the line otherwise.
## Layout
```
src/
config.js all tunables in one place
rng.js seeded mulberry32 RNG (deterministic races)
track.js track definition (1 km, 5 sectors)
season.js championship season points (MCPG-49): F1 top-8
scoring, persistence, ranking — pure + file I/O
sim/simulation.js the authoritative race engine (pure, deterministic)
sim/car.js car model
sim/strategies.js the four scripted agent profiles (pure functions)
logging/decisionLogger.js JSONL append-only decision log
server/main.js CLI entry point (node src/server/main.js)
server/http.js HTTP server: MCP endpoint, GET /state, GET /healthz,
static files
server/mcpServer.js the MCP tool surface (one per session)
server/raceSession.js real-time owner: walls the clock, ticks the sim
server/spectator.js the spectator WebSocket hub (hello, 10 Hz snapshots,
keep-alive ping/pong, exactly-once final snapshot)
server/staticFiles.js minimal static file server for the client folder
server/staticServe.js standalone static server for split deploys (Docker
`client` service, Vercel)
client/ Three.js spectator client (index.html, js/, vendor/)
features.json changelog content for /features (MCPG-35)
features/index.html the /features page (static, same serving rules)
agents/
agentBase.js MCP client loop: join, poll, submit once per lap,
react to reactive windows
run.js standalone agent process (npm run agent)
scripts/runRace.js `npm run race` orchestrator (server + 4 agents)
scripts/runAgents.js agents-only orchestrator for the Docker stack
scripts/demo.js `npm run demo` / `demo:public` — watchable demo:
local server + 4 agents with a live decision
narrator, or the public server
scripts/demoNarration.js pure event → narration text (unit-tested)
Dockerfile multi-stage, non-root image (server, agents, client)
docker-compose.yml local stack: server + scripted-agent race + client
test/ Vitest: sim, strategies, reactive, MCP over HTTP,
spectator, static serving, end-to-end, demo
log/ decision logs (gitignored, .gitkeep keeps it for Docker)
```
## Decision log
Every race writes one JSONL file (path printed at the end of `npm run race`
and `npm run demo`), one line per event: `session_started`, `agent_joined`,
`race_start`, `window_opened`, `strategy_submitted`, `strategy_defaulted`,
`window_closed`, `reactive_window_opened`, `reactive_action_submitted`,
`reactive_action_defaulted`, `reactive_window_closed`, `lap_complete`,
`overtake`, `pit_stop_enter`, `pit_stop_complete`, `finish`, `retired`,
`race_finished`, `session_finished`, spectator traffic
(`spectator_connected`, `spectator_disconnected`, `spectator_final_broadcast`)
— plus agent-side lines (`agent_decision`, `agent_strategy_rejected`,
`agent_reactive`, `agent_reactive_rejected`) written by the agent processes.
Strategy decisions, reactive actions and pit calls are the lines an analyst
(or a human spectator) will care about — `npm run demo` narrates exactly
these lines live.
## Testing
- `test/sim.test.js` — pure simulation: race flow, determinism, laps,
fuel/tire retirement, pit stops, overtakes, standings.
- `test/strategies.test.js` — the four scripted profiles: output shape,
determinism, and their pit logic.
- `test/mcp.test.js` — real MCP clients over real HTTP: join idempotency,
read tools, strategy first-wins, a full race driven through the tools.
- `test/reactive.test.js` — reactive windows: each trigger fires with the
right cars and roles, only allowed actions are accepted, outcomes feed the
sim, and the race stays deterministic with windows enabled.
- `test/race.test.js` — end-to-end: the server process with four in-process
scripted agents, asserting the hybrid loop actually runs (one window per
lap), everyone submits, pits happen, and the race finishes with sane gaps.
- `test/spectator.test.js` — the WebSocket feed against a real race: hello +
self-contained snapshots, two concurrent spectators (plus a third mid-race),
cars actually moving forward, exactly one final snapshot, keep-alive
ping/pong, mid-race reconnect, and the real browser `SpectatorConnection`
class (Node's built-in WebSocket) surviving a forced mid-race drop and
reporting the end via `GET /state`.
- `test/static.test.js` — the static file server: the client page and its
assets, `GET /state` JSON, `GET /healthz` (no race → race status with id +
phase after an agent joins), path-traversal rejection, 404 for unknown paths.
- `test/features.test.js` — the /features changelog page: features.json
shape (parse + strictly increasing ids), and static serving of
`/features`, `/features/`, `/features.json`, the renderer and the badge.
- `test/staticServe.test.js` — the standalone static server for split
deployments (same files, same 404 shape).
- `test/demo.test.js` — the demo narration: every narrated decision type
renders the right text, non-decision events stay quiet.
All tests are deterministic (seeded RNG, `tickWallDelayMs: 0`, immediate
window closes) and run in ~30 s.
## Roadmap
- **Next** — weather and safety-car triggers (the two deferred reactive
triggers; everything else in this README is shipped).
- **Slice 1 (done)** — core sim, MCP tools, scripted agents, logging, tests.
- **Slice 2 (done)** — Three.js spectator client over WebSocket.
- **Slice 3 (done)** — reactive windows + event triggers, scripted agents
reacting, VPS deploy (`https://gp.peterfrank.se/`).
- **Slice 4 (cancelled)** — Playwright e2e suite; the deterministic Vitest
suite (including the end-to-end race test) covers the same ground.
- **Slice 5 (done)** — live demo (local + public) and this README.
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues