Bike Playground
Overview
Bike Playground is a four-surface webapp built for live events. Riders pedal a stationary bike; their cadence (RPM) drives a fullscreen p5.js sketch on a projector while a separate operator dashboard runs the show.
The four surfaces
| Surface | URL | Who uses it | Where |
|---|---|---|---|
| Setup | /setup |
Event operator (BPE staff) | Operator's laptop — also where the bike is plugged in |
| Display | /display |
Riders + audience | Display PC connected to projector / TV / LED wall |
| Studio | /studio |
Sketch developers (Natan, Peter) | Any laptop with the running app |
| Docs | /docs |
Everyone | This page |
Three audiences in one app
Each surface is a distinct UX. The operator is in a noisy venue, one hand free, making decisions in seconds — their UI is dense, glanceable, and live. The display is an installation, not a webpage — fullscreen, no chrome, branded. The studio is a creative coding IDE — for sketch authors at a focused desk.
/ (legacy Setup page) still works as a fallback; /setup is the dashboard going forward.
Architecture
Where the bike data actually flows
(Web Serial API)
(bike:rpm fanout)
(p5.js sketch reads bike.rpm)
The bike's microcontroller speaks USB-serial — even though the physical cable is USB, the protocol on the wire is the old serial standard. The browser reads this directly via the Web Serial API. The server does not talk to the bike; it never has. The operator's Chrome tab owns the serial port, reads RPM 5–20 times per second, smooths the signal, and forwards each tick to the server over WebSocket. The server's job is purely fanout — it rebroadcasts every bike:rpm message to every connected display, plus any future operator/admin tabs.
bike-client.js handles this carefully — see Hardware Setup.
Roles
Every WebSocket client registers as one of three roles. The server uses the role to route messages — operator commands flow to displays, ride results flow to operators, RPM goes to everyone.
| Role | Sent by | Receives |
|---|---|---|
| operator | Setup tabs (/setup) |
All bike events, session:started/ended, deployment snapshot on connect |
| display | Display tabs (/display) |
All bike events, ride:start/cancel, params:set, display:blackout |
| sensor | Reserved for headless RPM producers (not used in v1) | Same as operator |
Server modules
| File | Role |
|---|---|
| server/index.js | Express + http server entry. Wires WebSocket, routes, session store, retention sweep at boot. |
| server/ws.js | WebSocket fanout with role registry. 13 message types (see contract). Heartbeat + reconnect handling. |
| server/sessions.js | Append-only sessions.jsonl per deployment. List / rank / sweep operations. Retention parser (24h/event/forever). |
| server/sketches.js | Sketch discovery + parameter schema adapter (object↔array) + new endpoints (/api/sketches/active, /api/sketches/:slug/config, /api/params). |
| server/deployments.js | Deployment loading + filtering. Exposes the active deployment via /api/deployment. |
| server/auth.js | BIKE-XXXX access code generation + validation. In-memory. Duration accepts "8h" strings or numeric seconds. |
Client modules
| File | Role |
|---|---|
| public/js/bike-client.js | Web Serial connection, port-ownership coordination across tabs, simulator slider, window.bike + window.bikes globals. ~1,000 LOC of edge-case handling — touch with care. |
| public/js/sketch-loader.js | Loads a sketch's sketch.js into the page, injects bike + params globals, calls p5 lifecycle. |
| public/js/branding.js | Fetches /api/deployment, sets CSS custom properties, fills [data-brand] DOM slots. Drop into any page. |
| public/js/params-panel.js | Auto-generates sliders / selects / toggles from a sketch's parameters[]. Used by the Setup page's Params modal. |
Quick Start
Local development
npm install
npm start
# Setup: http://127.0.0.1:3000/setup
# Display: http://127.0.0.1:3000/display
# Studio: http://127.0.0.1:3000/studio
The Setup page on http://127.0.0.1:3000/setup is the operator dashboard. The Display page on http://127.0.0.1:3000/display is what gets projected. Both tabs talk to the same WebSocket server.
Production (Cloudflare)
The live build is on Cloudflare Pages at bpe-bike-playground.pages.dev. Git push to main auto-deploys via the Pages git integration. Real-time fanout runs on Cloudflare Durable Objects; session storage is D1. See the team's deployment notes for details.
Event-day operator checklist
- Plug the bike into the operator laptop via USB.
- Open Chrome (must be Chromium-based — Web Serial doesn't work in Firefox or Safari).
- Navigate to the Setup page. If the "Connect Bike" modal opens automatically, click Choose Device and pick the bike from Chrome's permission prompt, then Connect.
- Confirm the sidebar LIVE RPM updates as you spin the pedal.
- Click + Code in the header to generate a fresh
BIKE-XXXXaccess code. - On the display PC, open the same site's
/displayURL. Enter the access code. Pick a sketch. - Read the code aloud to anyone else who needs display access (rider phones, secondary projectors).
Sketch API Reference
Every sketch runs with two globals available: bike (or bikes[] for multi-bike) and params. They're injected by sketch-loader.js before setup() runs.
The bike object
| Property | Type | Description |
|---|---|---|
| bike.rpm | number | Current pedaling speed. 0 when stopped. Range 0–200 (typical 60–130; sprint 130–160). |
| bike.cadence | number | Alias for bike.rpm. |
| bike.active | boolean | true when rpm > 0. |
| bike.connected | boolean | true when an operator tab is actively forwarding RPM. false if the bike is unplugged, the operator tab is closed, or the WS dropped. |
| bike.raw | object | Pass-through of any extra fields the microcontroller sends (watts, distance, cadence stream). Future-proofing — sketches that read bike.rpm keep working as the firmware grows. |
bike.rpm is noisy. Every sketch should keep a smoothed local copy: displayRpm += (bike.rpm - displayRpm) * 0.08; on each frame. See Writing Sketches for the standard pattern.
The params object
Live parameter store. Initially populated from the sketch's config.json defaults. The operator can change values from the Setup page's Params modal — updates arrive via WebSocket and overwrite keys on this object between frames. The sketch picks up the new value on the next draw() call without any subscription needed.
// config.json declares "speed" with default 72
// Operator drags the slider to 90
// Next frame:
console.log(params.speed); // 90
Read defensively in case a parameter is missing:
const speed = params.speed ?? 72;
Multi-bike sketches
For sketches that take input from two bikes (Tug of War, Versus Bars, Race Track):
| Reference | Description |
|---|---|
bikes[0] | Player 1's bike (same object as the singular bike) |
bikes[1] | Player 2's bike (only present when two bikes are connected) |
bikes[i].rpm | Player i's RPM |
bikes[i].active | Player i currently pedaling? |
Single-player sketches that only read bike keep working — bike aliases bikes[0]. The v2 WebSocket contract carries a bikeId field on every bike:rpm message, so multi-bike grows additively as hardware support lands.
Reference sketch
/**
* @sketch speed-rings
* @category particles
* @idle Single faint ring, slow drift
* @cruise 8 concentric rings, hue shifts with RPM
* @sprint Rings flash and expand outward at 120+ RPM
*/
let displayRpm = 0;
function setup() {
createCanvas(windowWidth, windowHeight);
colorMode(HSB, 360, 100, 100, 100);
}
function draw() {
// 1. Smooth the noisy raw signal.
displayRpm += (bike.rpm - displayRpm) * 0.08;
// 2. Background: never pure black on projectors.
background('#04040e');
// (Use a low-alpha background for a trail effect.)
// 3. Map RPM to visual parameters — never multiply raw RPM.
const numRings = Math.floor(map(displayRpm, 0, 160, 1, params.maxRings ?? 12));
const hue = map(displayRpm, 0, 160, 200, 360);
stroke(hue, 80, 100);
noFill();
strokeWeight(2);
for (let i = 0; i < numRings; i++) {
const r = map(i, 0, numRings, 20, min(width, height) * 0.45);
ellipse(width / 2, height / 2, r * 2, r * 2);
}
// 4. Sprint state — explicit qualitative shift.
if (displayRpm > 120) {
fill(0, 0, 100, 20);
rect(0, 0, width, height); // brief flash overlay
}
}
function windowResized() {
resizeCanvas(windowWidth, windowHeight);
}
Writing Sketches
Sketches live in sketches/. Each is a folder with sketch.js + config.json. The server discovers them on demand — no manifest to update.
Step-by-step: add a sketch
- Pick a folder name (lowercase, hyphen-separated). This becomes the sketch's
slugin the API. - Create
sketches/<slug>/sketch.js. Write the@sketchheader comment first (see Aesthetic Brief for the convention) — describe what idle, cruise, and sprint should look like before you write code. - Create
sketches/<slug>/config.json. Declareparametersthe operator should be able to tune (see below). - Optionally add
thumbnail.pngfor the sketch picker. - Restart the server (or just reload the page if the server's already running — sketches are scanned per request).
The contract every sketch must hit
Every sketch responds to three named states:
| State | RPM Range | What it means |
|---|---|---|
| Idle / Attract | 0–5 | Not pedaling. Minimum intensity — but never static. Something must move, even imperceptibly. |
| Cruise | 60–100 | Normal effort. Full visual output, responsive but not overwhelming. |
| Sprint | >120 | Maximum effort. Qualitatively different, not just faster. The "money shot." |
docs/sketch-aesthetic.md. Read it before writing a new sketch.
Non-negotiables
- Smooth the RPM signal on every frame:
displayRpm += (bike.rpm - displayRpm) * 0.08; - Background is never pure black. Use
#04040e— pure black reads as off-state on projectors. - Map, don't multiply.
speed = map(rpm, 0, 160, minSpeed, maxSpeed), neverrpm * constant. - No internet dependencies. Sketches load assets from their own folder or bundled libraries. No CDN.
- Frame rate target: 60fps at 1920×1080. Profile on actual hardware (Pi 5, Intel NUC, Mac mini).
- Reset cleanly on sketch switch.
background()every frame.
Sketch Configuration
A sketch's config.json declares its name, category, tier, and the parameters the operator can tune at runtime. The server normalizes two shapes — the new array form (preferred) and the legacy object form (still works).
config.json — preferred (array) form
{
"name": "Warp Stars",
"description": "Hyperspace starfield. Pedal faster for warp.",
"category": "particles",
"tier": "flagship",
"hue": 220,
"parameters": [
{ "name": "speed", "label": "Speed", "type": "range", "default": 72, "min": 0, "max": 100 },
{ "name": "density", "label": "Star Density", "type": "range", "default": 200, "min": 50, "max": 500 },
{ "name": "color", "label": "Color Mode", "type": "select", "default": "auto", "options": ["auto", "brand", "mono"] }
]
}
Top-level fields
| Field | Required | Description |
|---|---|---|
| name | Yes | Human-readable name shown in the sketch selector and Now Playing card. |
| description | Recommended | One sentence shown in the picker. |
| category | Recommended | particles · scenes · gamified · stats. Drives aesthetic direction (see Aesthetic Brief). |
| tier | Optional | flagship · strong · situational. Editorial signal, not enforced anywhere. |
| hue | Optional | Number 0–360. Sets the Now Playing card's background tint on the Setup page. |
| parameters | Optional | Array (or legacy object) of tunable params. Empty/absent = no parameters; the Params modal shows an empty state. |
| author | Optional | Informational. |
Parameter types
| type | Renders as | Fields |
|---|---|---|
| range | Horizontal slider | name, label, default, min, max, optional step, optional unit |
| select | Segmented button group | name, label, default, options (array of strings) |
| toggle | iOS-style switch | name, label, default (boolean) |
Legacy (object) form — still supported
Older sketches declare parameters keyed by name with type: "number". The server adapter converts them on read — no migration required.
{
"parameters": {
"starCount": { "type": "number", "default": 800, "min": 200, "max": 2000 },
"color": { "type": "select", "default": "white", "options": ["white", "blue", "rainbow"] }
}
}
type: "number" maps to type: "range". Both array and object forms produce the same UI.
Sprint & Sessions
A "ride" is a timed sprint with a recorded score. The operator clicks START RIDE on the Setup page; the Display overlays a countdown, runs a 20-second sprint, shows an end card with the rider's score and rank, then returns to the running sketch. The result is persisted to the leaderboard for that deployment.
The lifecycle
(live W·s score)
(score · rank · QR)
Score formula
Default scoring is W·s (watt-seconds). Each second of the sprint, the overlay smooths the rider's RPM, multiplies by 2.2 to estimate watts, and adds that to a running total. Peak watts and average watts are recorded separately. The score formula is configurable per deployment via ride.scoreFormula.
Sprint configuration (deployment-level)
"ride": {
"durationSeconds": 20, // length of the sprint
"warmupSeconds": 3, // countdown length
"scoreFormula": "watts" // currently only "watts" is implemented
}
Operator HUD during a sprint
The Setup page's sidebar still shows live RPM the whole time. The sparkline records the sprint. The right-panel preview iframe shows the actual display HUD. The operator can hit BLACKOUT at any time to cut the display to black (display:blackout WS message) — useful during speeches, glitches, or kids crying.
Sessions storage
Each completed sprint appends one JSON line to deployments/<id>/sessions.jsonl (local mode) or to the equivalent D1 row (cloud mode):
{"name":"Alex","score":5120,"peakWatts":287,"avgWatts":256,"duration":20,"sketch":"warp-stars","timestamp":"2026-05-21T19:14:32.412Z"}
Leaderboard API
| Endpoint | Returns |
|---|---|
| GET /api/sessions?limit=N | Top N sessions sorted by score, with rank + time formatting applied. |
| POST /api/sessions/rank body: {"score": N} | {"rank": K, "total": M} — where score K would land in the current leaderboard. |
Retention
The retention field in deployment.json controls how long sessions stick around. A sweep runs at server startup:
| Value | Behavior |
|---|---|
"24h" (default) | Drop entries older than 24 hours on boot. Supports "<n>h", "<n>d", "<n>w". |
"event" | Wipe on every boot. Use for single-day events where you don't want stale data carrying over. |
"forever" | Never sweep. The operator manages cleanup manually. |
Deployment Profiles
A deployment is a per-event configuration bundle: event name, client branding, allowed sketches, ride settings, retention. Lives in deployments/<id>/deployment.json.
v2 schema
{
"name": "Default",
"event": "Acme Wellness Day 2026",
"client": "Acme Corporation",
"venue": "HQ Atrium — Building 3",
"date": "2026-05-20",
"branding": {
"logo": "assets/acme-logo.svg",
"primary": "#1A3A8F",
"accent": "#E8402A",
"onDark": "#F4F2EE",
"footerText": "Acme Corp Wellness Day 2026"
},
"sketches": ["kaleidoscope", "warp-stars", "aurora-borealis"],
"ride": {
"durationSeconds": 20,
"scoreFormula": "watts",
"warmupSeconds": 3
},
"features": {
"leaderboard": true,
"requireName": true,
"prize": false,
"qr": true,
"qrUrl": "bike.local/leaderboard",
"blackout": true
},
"accessCode": {
"duration": "8h"
},
"retention": "24h"
}
Fields
| Field | Type | Notes |
|---|---|---|
| name | string | Internal label. Shown in the deployment picker. |
| event / client / venue / date | strings | Pass-through to the operator UI header + Display. |
| branding.logo | string | Relative path to a logo file in the deployment folder. Served as static. |
| branding.primary / accent / onDark | hex strings | CSS custom properties (--brand-primary, etc.). See Branding. |
| branding.footerText | string | Shown on the Display end card and selected pages. |
| sketches | string[] | Whitelist of sketch slugs. Empty array = "show all" (every discovered sketch is allowed). |
| ride.durationSeconds | number | Sprint length. Default 20. |
| ride.warmupSeconds | number | Countdown length before the sprint. Default 3. |
| ride.scoreFormula | string | Currently only "watts". |
| features.leaderboard | boolean | Show the leaderboard side display. (Flag exists; full leaderboard surface lands in v1.1.) |
| features.requireName | boolean | If true, START RIDE is disabled until the operator types a rider name. |
| features.prize | boolean | If true, the end card shows the prize line. |
| features.qr / features.qrUrl | boolean / string | If true, end card shows a QR pointing at qrUrl (typically a leaderboard page). |
| features.blackout | boolean | If true, the Setup page exposes the BLACKOUT button. |
| accessCode.duration | string or number | "8h" / "30m" / number of seconds. Default 8h. |
| retention | string | See Sessions. |
Switching deployments
The active deployment is set via the BPE_DEPLOYMENT environment variable. Defaults to default. To run a different event:
BPE_DEPLOYMENT=acme-wellness npm start
Per-deployment overrides (per-sketch parameter defaults different from the sketch's own config.json) live under an optional overrides key — see server/deployments.js.
Branding System
The deployment's branding block drives visual identity across every surface: logo, primary color, accent, on-dark text color, footer text. branding.js fetches the deployment from the API and applies it two ways — as CSS custom properties on :root, and by filling DOM elements marked with data-brand attributes.
How a logo + color reaches the screen
+ [data-brand] slots
CSS custom properties
Any page can use these — they're set on :root as soon as branding.js fetches the deployment:
| Property | Set from |
|---|---|
| --brand-primary | branding.primary |
| --brand-accent | branding.accent |
| --brand-on-dark | branding.onDark |
| --brand-footer-text | branding.footerText (as a quoted string) |
DOM slots
Any element with a data-brand attribute gets populated automatically:
<img data-brand="logo" alt="Logo">
<span data-brand="event-name">Bike Playground</span>
<span data-brand="client"></span>
<span data-brand="footer-text"></span>
<span data-brand="qr-url"></span>
Wiring branding into a new page
<!-- in your HTML -->
<script src="/js/branding.js"></script>
<!-- in your CSS -->
.btn-primary { background: var(--brand-primary, #4d7fff); }
The fallback in var(...) is what's shown before branding.js finishes its fetch, or if the deployment doesn't override that token.
Reacting to branding from JS
branding.js dispatches a branding:applied event on window once branding lands. The Display page listens for this to know when it's safe to instantiate the Sprint Overlay (which needs the deployment object for QR URL + sprint duration):
window.addEventListener('branding:applied', ({ detail }) => {
overlay = new SprintOverlay({ bike, deployment: detail.deployment, onEnd: ... });
});
WebSocket Message Contract
Every real-time message in v2 flows through one WebSocket per client. Each message is a JSON object with a type field. The server routes by type and the sender's registered role.
Direction legend
C → S client to server (a tab sends to the server)
S → C server to client (server broadcasts to clients)
both client sends, server relays
Connection lifecycle
| Message | Direction | Shape + behavior |
|---|---|---|
| register | C → S | { type: 'register', role: 'operator' | 'display' | 'sensor' }Sent on WS open. Server immediately responds with deployment + any active sketch:active + last known bike:rpm. |
| deployment | S → C | { type: 'deployment', data: { ...deployment.json } }Sent once on register. Lets new clients bootstrap without an extra fetch. |
Bike data
| Message | Direction | Shape + behavior |
|---|---|---|
| bike:rpm | both | { type: 'bike:rpm', bikeId: 0, rpm: Number }Operator tab posts on every Web Serial read (throttled to ≥200ms or on-change). Server broadcasts to all. bikeId defaults to 0; multi-bike is additive. |
| bike:connected | both | { type: 'bike:connected', port: String }Operator tab posts when Web Serial connects. Server broadcasts. |
| bike:disconnected | both | { type: 'bike:disconnected' }Posted on operator tab close or Web Serial disconnect. |
Ride lifecycle
| Message | Direction | Shape + behavior |
|---|---|---|
| ride:start | both | { type: 'ride:start', payload: { name, sketch } }Operator sends. Server broadcasts to displays; sends session:started to operators. |
| ride:cancel | both | { type: 'ride:cancel' }Operator aborts a ride in progress. |
| ride:end | both | { type: 'ride:end', result: { name, score, peakWatts, avgWatts, duration, sketch } }Display sends on overlay completion. Server appends to sessions.jsonl and broadcasts ride:end + session:ended. |
| session:started | S → C | { type: 'session:started', name: String }Operator UI starts its session timer. |
| session:ended | S → C | { type: 'session:ended', result: Object }Operator UI refreshes the sessions list. |
Display control
| Message | Direction | Shape + behavior |
|---|---|---|
| params:set | both | { type: 'params:set', sketch: String, params: Object }Operator dragged a slider. Server stashes in memory + relays to displays. The display merges into window.params; the sketch picks up the new value on the next frame. |
| display:blackout | both | { type: 'display:blackout' }Cut display to black. Clears on next ride:start. |
| sketch:active | both | { type: 'sketch:active', slug, name, hue, params }Display announces which sketch is rendering. Server caches; operator UI's Now Playing card reads. |
Hardware Setup
The bike → laptop connection
The bike's microcontroller plugs into the operator's laptop via USB. Even though the cable is USB, the microcontroller presents itself to the operating system as a serial port — a stream of bytes, framed as newline-delimited JSON. This is an old, stable convention that USB-to-serial chips have supported for decades.
The browser reaches that data through the Web Serial API. There is no native serial reader on the server. Everything goes through the operator's Chrome tab.
Browser requirements
- Chromium-based browser only. Chrome, Edge, Brave, Arc — all work. Firefox and Safari do not implement Web Serial.
- Secure context required. Must be
https://orhttp://127.0.0.1. Reaching the dev server vialocalhostis silently redirected to127.0.0.1so the permission grant sticks. - Permission is per-device. Chrome remembers the user's choice and re-uses it on subsequent visits.
The Connect Bike flow (operator UI)
The Setup page opens the Connect Bike modal automatically if the bike isn't yet detected. The modal preserves the five-button serial flow that handles every edge case Chrome's API throws:
- Choose Device — opens Chrome's native picker. The operator selects the bike (usually labeled something like "BPLED" or by USB vendor ID).
- Connect — opens a serial connection to the previously-chosen device.
- Scan All — when an authorized device is already remembered, tries each one until one matches the BPLED data pattern. Useful when the laptop has been to many events.
- Disconnect — closes the active connection. Lets another tab claim the port.
- Refresh — re-queries Chrome for the list of currently-authorized devices.
- Simulator slider — drives RPM by hand. Use this for demos without hardware.
Serial protocol
The microcontroller emits one JSON line per reading at 9600 baud:
{"rpm": 75}
{"rpm": 82}
{"rpm": 0}
{"rpm": 65, "watts": 120, "distance": 1.4}
Any additional fields pass through as bike.raw. bike-client.js handles partial lines, occasional garbled bytes, and reconnection silently.
Hardware reality check
- Target hardware for the display PC is Pi 5 or x86 (Intel NUC, Mac mini). 3D and WebGL sketches run fine on this class of machine.
- The operator laptop just needs Chrome and a USB port. Any modern laptop works.
- The display PC does not need to be on the same network as the operator — once they're both pointed at the live site, the WebSocket handles the connection over the internet.
Access Codes
Access codes gate the Display page. They're a practical barrier, not cryptographic security — they prevent random people on the network from opening /display mid-event.
Format
BIKE-XXXX where XXXX is four random digits. Examples: BIKE-7294, BIKE-0182.
- Case-insensitive.
bike-7294works. - Time-limited. Expires after the deployment's
accessCode.duration. Default 8 hours. - One active at a time per deployment. Generating a new code invalidates the previous one.
- Memory only. Server restart wipes all codes. Generate fresh ones after a restart.
Operator workflow
- Open the Setup page on the operator laptop.
- Click + Code in the header.
- A code appears (
BIKE-XXXX). - Read the code aloud to anyone who needs Display access (projector PC, secondary screens, rider phones).
- If the code expires mid-event, generate a fresh one and repeat.
API
| Endpoint | Behavior |
|---|---|
| POST /api/auth/generate | Returns { code, expiresAt }. Optional body { duration } overrides the deployment default. |
| POST /api/auth/validate | Returns { valid: boolean } for a given { code }. |
Troubleshooting
"Choose Device" doesn't list the bike
Chrome only shows USB-serial devices that match a known filter pattern. If the bike isn't in the list: (1) verify the USB cable on both ends; (2) try a different USB port — some hubs don't pass through the serial interface; (3) on macOS, make sure the user account has serial port access (System Settings → Privacy & Security). Try the Scan All button if Chrome remembers other authorized devices.
Bike connects, RPM stays at 0
Connection is healthy but no data is arriving. Verify the microcontroller is actually sending — open Chrome devtools, look for bike-client log lines showing incoming bytes. If you see bytes but they're garbled, the baud rate is wrong (default is 9600 — change in bike-client.js's connect call if your firmware uses a different rate).
"This tab is not allowed to open a serial port"
Web Serial requires a secure context. Either run the site over https:// (live deployment) or use http://127.0.0.1 for local development. http://localhost is automatically redirected to 127.0.0.1 by the server. Other private IPs (192.168.x.x) do not work without TLS.
Two tabs are fighting for the bike
Chrome lets only one tab own a serial port. If you have the operator dashboard open in two tabs simultaneously, they'll trade ownership. Close one. The Disconnect button in the Connect Bike modal releases the port cleanly.
"WebSocket disconnected — retrying…"
Expected after a brief network hiccup. The client retries with exponential backoff (1s → 10s, capped). The Display tab auto-reconnects and resumes receiving bike:rpm. If it stays disconnected for >30 seconds, reload the page. The Display will need the access code re-entered if the session expired.
Sprint overlay doesn't appear when I click START RIDE
The overlay only initializes after branding.js finishes loading the deployment. Reload the Display tab and watch the browser console — you should see a branding:applied event. If the deployment fetch failed (network error, 404), the overlay won't attach.
Sketch is missing from the picker
Two possible causes. (1) The deployment's sketches whitelist excludes it — check deployments/<id>/deployment.json. (2) The sketch folder is missing sketch.js — the server skips folders without one. Note that the empty array "sketches": [] means "allow all," not "allow none."
Display shows "Code Expired"
The deployment's accessCode.duration elapsed, or the server restarted (codes are in-memory only). Generate a new code from the Setup page and have the projector operator re-enter it.
Operator UI's "Now Playing" card is empty
The Display tab tells the operator UI which sketch is running via the sketch:active WS message. If the operator UI is open but no Display is connected, there's no sketch:active to receive. Open /display on the projector PC — the operator's card will populate as soon as a sketch loads.
Sessions list is empty after riders have ridden
Check the deployment's retention setting. If set to "event", the server wipes all sessions on every boot — useful at single-day events, but surprising if you didn't expect it. "24h" (default) keeps a rolling 24-hour window. Verify the server is writing to deployments/<id>/sessions.jsonl by checking the file directly.
Live preview iframe on Setup page is blank
The right-panel iframe pointing at /display only renders when both pages are on the same origin and reachable. It's cosmetic — don't block on it if the actual projector display works. The iframe is sandboxed and won't accept clicks; it's preview-only.