Skip to main content
Glama
pefman
by pefman

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.

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 below.

Related MCP server: AFSIM MCP Server

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.

Coastal Palm

Mountain Hairpins

Alpine green and the twistiest layout of the three.

Mountain Hairpins

City Night

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

City Night

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).

Watch a demo from your terminal

Four scripted agents race while the terminal narrates every decision:

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/.


Everything below is for people who want to run, extend, or deploy the game.

Quickstart (watch a race in the browser)

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

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).

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)

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:

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.

Related MCP Connectors

Related MCP Servers

  • A
    license
    C
    quality
    D
    maintenance
    Enables AI assistants to access real-time iRacing telemetry, race information, camera control, pit operations, and replay features through the MCP protocol.
    17
    1
    MIT
  • F
    license
    A
    quality
    C
    maintenance
    MCP server for motorsport event documents, enabling AI agents to search, read, compare amendments, and parse schedules via typed tools.
    9
    -
  • A
    license
    B
    quality
    B
    maintenance
    MCP server that provides F1 telemetry analysis, tyre degradation modeling, and pit strategy recommendations, delivering race engineer-style calls grounded in real data. It exposes tools for degradation fits, fuel correction, and strategy reasoning via LangGraph.
    4
    MIT