← Control Plane Display Decisions / Docs

Bike Playground

An interactive installation: stationary bikes drive real-time p5.js visuals on a projector. This document describes the v2 architecture — the operator dashboard, sprint mechanics, sessions leaderboard, branding system, and the WebSocket fabric that ties them together. Reference doc, not marketing — written for the people running and extending the system.

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.

Version 2: This documentation covers the v2 architecture (May 2026). The earlier / (legacy Setup page) still works as a fallback; /setup is the dashboard going forward.

Architecture

Where the bike data actually flows

Bike
USB cable
Operator's Chrome tab
(Web Serial API)
WebSocket server
(bike:rpm fanout)
Display tab
(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.

One tab owns the serial port at a time. Chrome enforces this. If the operator tab disconnects (or another tab grabs the port), the bike goes dark for everyone. 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.

RoleSent byReceives
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

FileRole
server/index.jsExpress + http server entry. Wires WebSocket, routes, session store, retention sweep at boot.
server/ws.jsWebSocket fanout with role registry. 13 message types (see contract). Heartbeat + reconnect handling.
server/sessions.jsAppend-only sessions.jsonl per deployment. List / rank / sweep operations. Retention parser (24h/event/forever).
server/sketches.jsSketch discovery + parameter schema adapter (object↔array) + new endpoints (/api/sketches/active, /api/sketches/:slug/config, /api/params).
server/deployments.jsDeployment loading + filtering. Exposes the active deployment via /api/deployment.
server/auth.jsBIKE-XXXX access code generation + validation. In-memory. Duration accepts "8h" strings or numeric seconds.

Client modules

FileRole
public/js/bike-client.jsWeb 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.jsLoads a sketch's sketch.js into the page, injects bike + params globals, calls p5 lifecycle.
public/js/branding.jsFetches /api/deployment, sets CSS custom properties, fills [data-brand] DOM slots. Drop into any page.
public/js/params-panel.jsAuto-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

  1. Plug the bike into the operator laptop via USB.
  2. Open Chrome (must be Chromium-based — Web Serial doesn't work in Firefox or Safari).
  3. 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.
  4. Confirm the sidebar LIVE RPM updates as you spin the pedal.
  5. Click + Code in the header to generate a fresh BIKE-XXXX access code.
  6. On the display PC, open the same site's /display URL. Enter the access code. Pick a sketch.
  7. Read the code aloud to anyone else who needs display access (rider phones, secondary projectors).
No bike attached? Use the Simulator slider in the Connect Bike modal to drive RPM by hand. Works exactly like a real bike for testing.

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

PropertyTypeDescription
bike.rpmnumberCurrent pedaling speed. 0 when stopped. Range 0–200 (typical 60–130; sprint 130–160).
bike.cadencenumberAlias for bike.rpm.
bike.activebooleantrue when rpm > 0.
bike.connectedbooleantrue when an operator tab is actively forwarding RPM. false if the bike is unplugged, the operator tab is closed, or the WS dropped.
bike.rawobjectPass-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.
Smoothing is the sketch's job. Raw 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):

ReferenceDescription
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].rpmPlayer i's RPM
bikes[i].activePlayer 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

  1. Pick a folder name (lowercase, hyphen-separated). This becomes the sketch's slug in the API.
  2. Create sketches/<slug>/sketch.js. Write the @sketch header comment first (see Aesthetic Brief for the convention) — describe what idle, cruise, and sprint should look like before you write code.
  3. Create sketches/<slug>/config.json. Declare parameters the operator should be able to tune (see below).
  4. Optionally add thumbnail.png for the sketch picker.
  5. 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:

StateRPM RangeWhat it means
Idle / Attract0–5Not pedaling. Minimum intensity — but never static. Something must move, even imperceptibly.
Cruise60–100Normal effort. Full visual output, responsive but not overwhelming.
Sprint>120Maximum effort. Qualitatively different, not just faster. The "money shot."
Full direction per category (particles, scenes, gamified, stats) lives in 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), never rpm * 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

FieldRequiredDescription
nameYesHuman-readable name shown in the sketch selector and Now Playing card.
descriptionRecommendedOne sentence shown in the picker.
categoryRecommendedparticles · scenes · gamified · stats. Drives aesthetic direction (see Aesthetic Brief).
tierOptionalflagship · strong · situational. Editorial signal, not enforced anywhere.
hueOptionalNumber 0–360. Sets the Now Playing card's background tint on the Setup page.
parametersOptionalArray (or legacy object) of tunable params. Empty/absent = no parameters; the Params modal shows an empty state.
authorOptionalInformational.

Parameter types

typeRenders asFields
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.

Test live tuning. Open Setup, click Params on the Now Playing card, drag a slider. The Display tab's sketch picks up the new value within one frame.

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

START RIDE
3-2-1 countdown
20-sec sprint
(live W·s score)
End card
(score · rank · QR)
Persist to sessions.jsonl

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

EndpointReturns
GET /api/sessions?limit=NTop 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:

ValueBehavior
"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

FieldTypeNotes
namestringInternal label. Shown in the deployment picker.
event / client / venue / datestringsPass-through to the operator UI header + Display.
branding.logostringRelative path to a logo file in the deployment folder. Served as static.
branding.primary / accent / onDarkhex stringsCSS custom properties (--brand-primary, etc.). See Branding.
branding.footerTextstringShown on the Display end card and selected pages.
sketchesstring[]Whitelist of sketch slugs. Empty array = "show all" (every discovered sketch is allowed).
ride.durationSecondsnumberSprint length. Default 20.
ride.warmupSecondsnumberCountdown length before the sprint. Default 3.
ride.scoreFormulastringCurrently only "watts".
features.leaderboardbooleanShow the leaderboard side display. (Flag exists; full leaderboard surface lands in v1.1.)
features.requireNamebooleanIf true, START RIDE is disabled until the operator types a rider name.
features.prizebooleanIf true, the end card shows the prize line.
features.qr / features.qrUrlboolean / stringIf true, end card shows a QR pointing at qrUrl (typically a leaderboard page).
features.blackoutbooleanIf true, the Setup page exposes the BLACKOUT button.
accessCode.durationstring or number"8h" / "30m" / number of seconds. Default 8h.
retentionstringSee 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

deployment.json
GET /api/deployment
branding.js
--brand-primary CSS var
+ [data-brand] slots

CSS custom properties

Any page can use these — they're set on :root as soon as branding.js fetches the deployment:

PropertySet from
--brand-primarybranding.primary
--brand-accentbranding.accent
--brand-on-darkbranding.onDark
--brand-footer-textbranding.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

MessageDirectionShape + 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

MessageDirectionShape + 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

MessageDirectionShape + 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

MessageDirectionShape + 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:// or http://127.0.0.1. Reaching the dev server via localhost is silently redirected to 127.0.0.1 so 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:

  1. Choose Device — opens Chrome's native picker. The operator selects the bike (usually labeled something like "BPLED" or by USB vendor ID).
  2. Connect — opens a serial connection to the previously-chosen device.
  3. 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.
  4. Disconnect — closes the active connection. Lets another tab claim the port.
  5. Refresh — re-queries Chrome for the list of currently-authorized devices.
  6. Simulator slider — drives RPM by hand. Use this for demos without hardware.
Only one tab can own the serial port. If a second tab tries to connect, the first tab loses the port. This is a Chrome rule, not ours. The operator's tab should be the only one with the bike plugged in.

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-7294 works.
  • 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

  1. Open the Setup page on the operator laptop.
  2. Click + Code in the header.
  3. A code appears (BIKE-XXXX).
  4. Read the code aloud to anyone who needs Display access (projector PC, secondary screens, rider phones).
  5. If the code expires mid-event, generate a fresh one and repeat.

API

EndpointBehavior
POST /api/auth/generateReturns { code, expiresAt }. Optional body { duration } overrides the deployment default.
POST /api/auth/validateReturns { 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.