← Setup Playground / Docs

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

Bike
Hall Sensor
Microcontroller
USB Serial
Node.js Server
WebSocket
Browser
p5.js Sketch

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

  1. Open the Setup page (/) on any device on the network.
  2. Verify the bike connection status shows Connected. If it shows Disconnected, check the USB cable and serial port permissions (see Troubleshooting).
  3. Select the deployment profile for this event.
  4. Click Generate Code. A code like BIKE-7294 appears.
  5. Tell the projector operator: "Open http://bpe.local:3000/display and enter BIKE-7294."
  6. The display page loads the sketch selector (or jumps straight to the sketch if the deployment has only one).
No bike attached? The simulator activates automatically when no serial device is detected. Use the RPM slider on the Setup page to test sketches without hardware.

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.
Forward compatibility: As hardware capabilities grow, new fields appear directly on the 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:

PropertyDescription
bikes[0].rpmPlayer 1 RPM (same as bike.rpm)
bikes[1].rpmPlayer 2 RPM
bikes[0].activePlayer 1 pedaling?
bikes[1].activePlayer 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);
}
Backward compatible: The 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

  1. Create a new folder inside sketches/. Use a lowercase, hyphenated name — this becomes the sketch's ID. Example: sketches/speed-rings/
  2. Add sketch.js — your p5.js code. The bike and params globals are automatically available. No imports needed.
  3. Add config.json — metadata and parameter definitions (see Sketch Configuration below).
  4. Optionally add thumbnail.png — a preview image shown in the sketch selector. Any resolution works; ~400×300 is ideal.
  5. Restart the server (or wait — the server watches for new sketch folders and reloads automatically).
  6. The sketch appears in the selector on the Setup and Display pages.
Use the Playground (/playground) to write and test new sketches interactively. The Playground has a built-in bike simulator and a "Save to Library" button that creates the folder and files for you.

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() with resizeCanvas(windowWidth, windowHeight) so the sketch fills any display resolution.
  • Check bike.connected at the top of draw() and show a "waiting" state if false.
  • The bike object updates up to 60 times per second — reading it directly in draw() is fine.
  • Avoid blocking operations (file I/O, large loops) inside draw(). p5.js runs draw() 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.

FieldRequiredDescription
typeYes"number"
defaultYesInitial value.
minYesMinimum allowed value.
maxYesMaximum allowed value.

select — renders as a dropdown in the operator UI.

FieldRequiredDescription
typeYes"select"
defaultYesThe initially selected option (must be one of the options).
optionsYesArray 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

  1. Copy an existing deployment folder (e.g., duplicate deployments/default/).
  2. Rename the folder to something descriptive (e.g., deployments/google-wellness/).
  3. Edit deployment.json — set the name, select sketches, add branding, tune overrides.
  4. Drop a logo.png into the folder if the client has branding.
  5. 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".
Default deployment: The 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.

macOS permissions: On macOS, your user account needs permission to access serial ports. If the bike shows as Disconnected even with the cable plugged in, see Troubleshooting.

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-insensitivebike-7294 and BIKE-7294 both 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

  1. Open the Setup page on your phone or any device on the network.
  2. Select the deployment profile for this client.
  3. Click Generate Code.
  4. Read the code to the projector operator: "Go to bpe.local:3000/display and enter BIKE-7294."
  5. 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.