Bike Playground
Technical reference for BPE staff. Covers architecture, the sketch API, deployment profiles, hardware setup, and troubleshooting.
Overview
Bike Playground is a local web app that turns a stationary bike into a controller for real-time interactive visuals. Riders pedal, and their cadence drives p5.js sketches displayed on a projector, TV, or LED wall.
The system is designed to work entirely on a local network — no internet connection required at events. All assets (p5.js, fonts, styles) run locally. A single Node.js server on the BPE device handles everything: reading the bike sensor, serving the web app, and pushing live data to browsers via WebSocket.
The four pages
| Page | URL | Audience |
|---|---|---|
| Setup | / |
BPE operator — generates access codes, monitors bike status |
| Display | /display |
Projector/TV computer — enters access code, renders sketch fullscreen |
| Playground | /playground |
Developers — live code editor with bike simulator for building sketches |
| Docs | /docs |
Peter — this page |
Network access
The server advertises itself via mDNS as bpe.local, so anyone on the same network can open http://bpe.local:3000 without knowing the IP address. Falls back to direct IP if mDNS is unavailable.
Architecture
Data flow
The bike's hall sensor detects each wheel revolution. The microcontroller counts revolutions per unit time, calculates RPM, and sends a JSON line over USB serial to the BPE device. The Node.js server reads those serial messages, updates the shared bike state, and broadcasts it to every connected browser over WebSocket. The browser's bike-client.js receives updates and makes the bike object available to the running p5.js sketch.
Server modules
| File | Role |
|---|---|
| server/index.js | Entry point. Starts the HTTP server (Express), WebSocket server, and serial reader. Wires everything together. |
| server/serial.js | Opens the USB serial port, parses newline-delimited JSON messages from the microcontroller. Emits events on new data or connection changes. |
| server/bike-state.js | Normalizes raw serial data into the standard bike state object (rpm, active, connected, etc.). Broadcasts updates over WebSocket. |
| server/auth.js | Generates and validates access codes. Stores them in memory (codes are lost on server restart). Handles expiry. |
Client modules
| File | Role |
|---|---|
| public/js/bike-client.js | Opens the WebSocket connection. Keeps the global bike object updated in real time. Exposes setSimulatorRpm() for the simulator slider. |
| public/js/sketch-loader.js | Dynamically loads the selected sketch's sketch.js into the page. Injects the bike and params globals before the sketch runs. |
Technology choices
The server uses Express for HTTP, ws for WebSocket, and serialport for USB serial. Sketches are plain p5.js — no transpiling, no bundling. Everything runs as-is in the browser.
Quick Start
Start the server
cd "Bike Playground"
npm start
Or use the one-command launcher script:
./start.sh
The server starts on port 3000 by default. You can override this with the PORT environment variable:
PORT=8080 npm start
Open the app
On the BPE device itself, open http://localhost:3000. From any other device on the same network, open http://bpe.local:3000 (or use the device's IP address if mDNS isn't working).
Generate an access code for the display
- Open the Setup page (
/) on any device on the network. - Verify the bike connection status shows Connected. If it shows Disconnected, check the USB cable and serial port permissions (see Troubleshooting).
- Select the deployment profile for this event.
- Click Generate Code. A code like
BIKE-7294appears. - Tell the projector operator: "Open
http://bpe.local:3000/displayand enterBIKE-7294." - The display page loads the sketch selector (or jumps straight to the sketch if the deployment has only one).
Sketch API Reference
Every sketch gets two global objects injected by sketch-loader.js before the sketch runs: bike and params. These are available everywhere in the sketch — in setup(), draw(), and any helper functions.
The bike object
| Property | Type | Description |
|---|---|---|
| bike.rpm | number | Current pedaling speed in revolutions per minute. 0 when stopped. |
| bike.cadence | number | Alias for bike.rpm. Use whichever reads more clearly in your sketch. |
| bike.active | boolean | true when someone is currently pedaling (rpm > 0). |
| bike.connected | boolean | true when the bike hardware is detected and sending data. false if the USB cable is unplugged or the microcontroller has reset. |
| bike.raw | object | The full raw JSON message received from the microcontroller. Contains any fields the microcontroller sends, including future fields like bike.raw.watts as hardware develops. |
bike object (e.g., bike.watts, bike.resistance). Existing sketches that only read bike.rpm continue to work unchanged.
The params object
Contains the tunable parameters defined in the sketch's config.json, with any deployment-level overrides applied on top. Keys match the parameter names in the config.
// If config.json defines "particleCount" with default 200,
// and the deployment overrides it to 500:
console.log(params.particleCount); // → 500
Example sketch
// sketches/speed-rings/sketch.js
function setup() {
createCanvas(windowWidth, windowHeight);
colorMode(HSB, 360, 100, 100, 100);
}
function draw() {
background(0, 0, 0, 20); // fading trail
if (!bike.connected) {
fill(255);
textAlign(CENTER, CENTER);
text('Waiting for bike...', width / 2, height / 2);
return;
}
// Map RPM (0–200) to visual parameters
let speed = bike.rpm;
let numRings = map(speed, 0, 200, 1, params.maxRings);
let hue = map(speed, 0, 200, 200, 360);
stroke(hue, 80, 100);
noFill();
strokeWeight(2);
for (let i = 0; i < numRings; i++) {
let r = map(i, 0, numRings, 20, min(width, height) * 0.45);
ellipse(width / 2, height / 2, r * 2, r * 2);
}
}
function windowResized() {
resizeCanvas(windowWidth, windowHeight);
}
Two-player sketches
The system supports up to two bikes simultaneously. Two-player sketches read from the bikes array instead of the single bike object:
| Property | Description |
|---|---|
bikes[0].rpm | Player 1 RPM (same as bike.rpm) |
bikes[1].rpm | Player 2 RPM |
bikes[0].active | Player 1 pedaling? |
bikes[1].active | Player 2 pedaling? |
To declare a sketch as two-player, add "players": 2 to its config.json. The playground will automatically show two simulator sliders.
// config.json for a two-player sketch
{
"name": "Tug of War",
"players": 2,
"parameters": { ... }
}
// Example two-player sketch
function draw() {
background(0);
let p1 = bikes[0].rpm;
let p2 = bikes[1].rpm;
// Player 1 bar (left, blue)
fill(50, 100, 255);
rect(width * 0.1, height - p1 * 3, 80, p1 * 3);
// Player 2 bar (right, red)
fill(255, 80, 50);
rect(width * 0.8, height - p2 * 3, 80, p2 * 3);
}
bike global is an alias for bikes[0]. All single-player sketches continue to work without changes.
Writing Sketches
Sketches live in the sketches/ directory. The server scans this directory at startup and adds every valid sketch folder to the menu automatically — no registration, no manifest to update.
Step-by-step: add a new sketch
- Create a new folder inside
sketches/. Use a lowercase, hyphenated name — this becomes the sketch's ID. Example:sketches/speed-rings/ - Add
sketch.js— your p5.js code. Thebikeandparamsglobals are automatically available. No imports needed. - Add
config.json— metadata and parameter definitions (see Sketch Configuration below). - Optionally add
thumbnail.png— a preview image shown in the sketch selector. Any resolution works; ~400×300 is ideal. - Restart the server (or wait — the server watches for new sketch folders and reloads automatically).
- The sketch appears in the selector on the Setup and Display pages.
File structure for a sketch
sketches/
speed-rings/
sketch.js ← required: p5.js code
config.json ← required: name, description, parameters
thumbnail.png ← optional: preview image
Tips
- Always handle
windowResized()withresizeCanvas(windowWidth, windowHeight)so the sketch fills any display resolution. - Check
bike.connectedat the top ofdraw()and show a "waiting" state iffalse. - The
bikeobject updates up to 60 times per second — reading it directly indraw()is fine. - Avoid blocking operations (file I/O, large loops) inside
draw(). p5.js runsdraw()at the frame rate, so anything slow will cause visible stutter.
Sketch Configuration
Each sketch folder contains a config.json file with metadata and parameter definitions. The operator UI auto-generates controls (sliders, dropdowns) from these definitions — sketch authors define what's tunable without writing any UI code.
config.json format
{
"name": "Rainbow Vortex",
"description": "Particles spiral outward, speed controlled by cadence",
"author": "Natan",
"parameters": {
"particleCount": {
"type": "number",
"default": 200,
"min": 50,
"max": 1000
},
"colorPalette": {
"type": "select",
"default": "rainbow",
"options": ["rainbow", "monochrome", "fire"]
},
"sensitivity": {
"type": "number",
"default": 1.0,
"min": 0.1,
"max": 3.0
}
}
}
Top-level fields
| Field | Required | Description |
|---|---|---|
| name | Yes | Human-readable name shown in the sketch selector. |
| description | Yes | Short description shown below the sketch name. |
| author | No | Who wrote the sketch. Informational only. |
| parameters | No | Object of tunable parameters. Omit the key entirely if the sketch has no parameters. |
Parameter types
number — renders as a slider in the operator UI.
| Field | Required | Description |
|---|---|---|
| type | Yes | "number" |
| default | Yes | Initial value. |
| min | Yes | Minimum allowed value. |
| max | Yes | Maximum allowed value. |
select — renders as a dropdown in the operator UI.
| Field | Required | Description |
|---|---|---|
| type | Yes | "select" |
| default | Yes | The initially selected option (must be one of the options). |
| options | Yes | Array of allowed string values. |
Accessing parameters in a sketch
function draw() {
// Parameters are available as-is on the params object
let count = params.particleCount; // number
let palette = params.colorPalette; // string
if (palette === 'fire') {
// draw fire colors
}
}
Deployment Profiles
Each client event can have its own deployment profile — a folder inside deployments/ that configures the experience without any code changes.
Creating a new deployment
- Copy an existing deployment folder (e.g., duplicate
deployments/default/). - Rename the folder to something descriptive (e.g.,
deployments/google-wellness/). - Edit
deployment.json— set the name, select sketches, add branding, tune overrides. - Drop a
logo.pnginto the folder if the client has branding. - The new deployment appears in the Setup page's dropdown on next server start.
deployment.json format
{
"name": "Google Wellness Fair 2026",
"sketches": ["rainbow-vortex", "power-meter"],
"branding": {
"logo": "logo.png",
"primaryColor": "#4285F4"
},
"overrides": {
"rainbow-vortex": {
"particleCount": 500,
"colorPalette": "monochrome"
}
},
"accessCode": {
"duration": "8h"
}
}
Fields
| Field | Required | Description |
|---|---|---|
| name | Yes | Human-readable name shown in the Setup page dropdown. |
| sketches | No | Array of sketch folder names to include. If omitted, all sketches in the library are available. |
| branding.logo | No | Filename of a logo image in the deployment folder. Shown on the display page. |
| branding.primaryColor | No | Hex color used for UI accents in this deployment. |
| overrides | No | Per-sketch parameter overrides. Keys are sketch folder names; values are objects of parameter key/value pairs. These replace the defaults defined in each sketch's config.json. |
| accessCode.duration | No | How long generated access codes remain valid. Format: "8h", "2h", etc. Defaults to "8h". |
deployments/default/ profile loads automatically on server start. It includes all sketches and has no branding. Use it for development and demos without a specific client config.
Hardware Setup
Connecting the microcontroller
Plug the bike's microcontroller into the BPE device via USB. The server auto-detects USB serial devices on startup and connects to the first available one.
To target a specific port (useful if multiple USB devices are connected), set the SERIAL_PORT environment variable:
SERIAL_PORT=/dev/ttyUSB0 npm start # Linux
SERIAL_PORT=/dev/cu.usbserial-* npm start # macOS
Serial protocol
The microcontroller sends newline-delimited JSON over the serial connection. Each line is one complete JSON object. Example messages:
{"rpm": 75}
{"rpm": 82}
{"rpm": 0}
{"rpm": 65, "watts": 120, "distance": 1.4}
The server accepts any JSON the microcontroller sends. Unknown fields are passed through to bike.raw and, if the field matches a recognized name, promoted directly onto the bike object. Malformed or partial lines are silently dropped — they do not crash the server.
Baud rate
Default baud rate is 9600. If the microcontroller uses a different rate, set the BAUD_RATE environment variable:
BAUD_RATE=115200 npm start
Auto-detection behavior
On startup, the server scans available serial ports and connects to the first one it finds. If no serial device is present, it falls back to simulator mode automatically — no error, no crash. When a device is later plugged in, the server polls for it every 2 seconds and reconnects automatically.
Access Codes
Access codes gate entry to the Display page. They prevent casual access from anyone else on the same network — they're not cryptographic security, just a practical barrier for shared event environments.
Format
Codes follow the format BIKE-XXXX where XXXX is 4 random digits. Example: BIKE-7294.
- Case-insensitive —
bike-7294andBIKE-7294both work. - Time-limited — codes expire after the duration set in the deployment config (default 8 hours).
- One active code per deployment — generating a new code automatically invalidates the previous one.
- Memory only — codes are stored in server memory. If the server restarts, all codes are gone and you need to generate a new one.
Typical event workflow
- Open the Setup page on your phone or any device on the network.
- Select the deployment profile for this client.
- Click Generate Code.
- Read the code to the projector operator: "Go to
bpe.local:3000/displayand enterBIKE-7294." - If the code expires during the event, generate a fresh one and repeat step 4.
Expired code message
If someone enters an expired code, the Display page shows: "Code Expired — Ask the Operator for a New One." Generate a new code and provide it to them.
Troubleshooting
Serial not detected / Bike shows Disconnected
Check the USB cable is firmly seated on both ends. On macOS, you may need to grant terminal access to serial ports: go to System Settings → Privacy & Security → Files and Folders and ensure your terminal app has access. On Linux, add your user to the dialout group: sudo usermod -a -G dialout $USER and log out/in. You can also set SERIAL_PORT explicitly if auto-detection picks the wrong port.
WebSocket shows "Reconnecting..."
This is expected behavior after a network hiccup or when the device wakes from sleep. The client retries automatically with backoff (1s, 2s, 4s, capped at 10s). Wait a moment — it will reconnect. If it shows "Connection Lost — Please Reload" after 30 seconds, reload the Display page and re-enter the access code. Make sure the BPE device is still running and on the same network.
Sketch doesn't appear in the menu
The sketch folder must contain both sketch.js and config.json. A folder with only one file is skipped. Check that the JSON is valid (no trailing commas, no syntax errors). Restart the server after adding a new sketch folder if live reload didn't pick it up.
Simulator mode is active but I plugged in the bike
The server polls for serial devices every 2 seconds and reconnects automatically when one appears. Wait a few seconds after plugging in — the Setup page status indicator should flip to Connected. If it doesn't, check USB permissions (see above) and try setting SERIAL_PORT manually.
Access code says "Invalid" or "Expired"
Invalid means the code doesn't match any active code — it was either typed incorrectly or the server was restarted since it was generated. Expired means it was valid but the duration window passed. In both cases, generate a new code from the Setup page.
Display page is blank / sketch doesn't render
Open the browser's developer console (F12 or Cmd+Option+I) and look for JavaScript errors. Common causes: a syntax error in sketch.js, a missing file referenced from the sketch, or a p5.js API call that throws on load. Fix the error in the sketch file and reload the Display page.
bpe.local doesn't resolve
mDNS (Bonjour) must be supported on the display device's OS and network. It works out of the box on macOS and most modern Windows machines. On some corporate or locked-down networks, mDNS multicast is blocked. Fall back to using the BPE device's IP address directly: check with ipconfig (Windows) or ifconfig / ip addr (macOS/Linux).
Checking what the server is actually receiving from the bike
Start the server in verbose mode or check the terminal output — the server logs each serial message to stdout. You should see lines like [serial] {"rpm": 75} while pedaling. If you see nothing, the serial connection isn't established. If you see garbled output, the baud rate is wrong — set BAUD_RATE to match the microcontroller's configured rate.