A real-time race timing and display system for Pinewood Derby events. An ESP32 sensor node reads the finish-line sensors with hardware-interrupt precision, and a Node.js server manages race state and broadcasts live results to any number of connected displays over WebSocket.
- Live leaderboard — guest display auto-updates the moment each car crosses the line
- Track manager — arm sensors, reset heats, and configure lane colors from any browser
- Microsecond-accurate timing — ESP32 hardware interrupts (
esp_timer_get_time()) record timestamps before any network call; WiFi latency has zero effect on results - Simulation mode — develop and demo without any hardware
- CSV logging — every heat result is appended to
derby_results.csv - Configurable lane count — configure the number of lanes from the Track Manager page; supports 2–6 lanes out of the box (extend
LANE_PINSin the ESP32 sketch for more) - ZCam E2M4 integration — automatically starts recording on the first lane trigger, stops when the heat finishes, downloads the clip, and plays it back on the guest dashboard
Node.js server
| Dependency | Version |
|---|---|
| Node.js | ≥ 18 |
| express | ^4.18 |
| ws | ^8 |
| axios | ^1 (ZCam HTTP API) |
ESP32 sensor node (Arduino libraries)
| Library | Version |
|---|---|
| ArduinoJson (Benoit Blanchon) | ^6.21 |
git clone https://github.com/nprail/derby.git
cd derby
npm install# Start the server (defaults to Simulate mode on first run)
npm start
# Custom options
node server.js --port 8080 --timeout 10| Flag | Default | Description |
|---|---|---|
--port <n> |
3000 |
HTTP server port |
Heat timeout, lane count, sensor mode (GPIO, ESP32, or Simulate), and ZCam IP are all configured from the Track Manager page at
/manageand persisted toderby_config.json.
Lane count, sensor mode (GPIO, ESP32, or Simulate), and ZCam IP are all configured from the Track Manager page at
/manageand persisted toderby_config.json. The server defaults to 4 lanes and Simulate mode when no config file exists.
| URL | Description |
|---|---|
http://localhost:3000/ |
Guest display — full-screen live results, designed for a projector or TV |
http://localhost:3000/manage |
Track manager — arm/reset controls and lane color configuration |
All endpoints return JSON.
Returns the current race state object.
{
"heat": 3,
"status": "finished",
"finishOrder": [
{ "lane": 2, "gapMs": 0 },
{ "lane": 4, "gapMs": 47.3 },
{ "lane": 1, "gapMs": 112.8 },
{ "lane": 3, "gapMs": 201.5 }
],
"laneColors": { "1": "Red", "2": "Blue", "3": "Yellow", "4": "Green" },
"numLanes": 4,
"timeout": 8,
"history": [ ... ],
"videoUrl": "/videos/heat-3_2026-03-09_14-22-05.mov",
"videoReplayEnabled": true,
"zcamEnabled": true,
"zcamIp": "192.168.1.50",
"sensorMode": "esp32"
}Arms the sensors and starts the heat. Returns 400 if already armed. If the previous heat finished without a reset, also increments state.heat.
{ "ok": true }Clears results and returns the state to idle. Does not advance the heat number — use this to re-run the same heat. The heat number advances on the next POST /api/arm.
{ "ok": true }Called by the ESP32 sensor node when a car crosses the finish line. timestamp_us is the raw esp_timer_get_time() value from the ESP32, sent as a string to preserve 64-bit precision. The server computes gapMs from the difference between trigger timestamps, so WiFi latency has no effect on results. Returns { ok: true, ignored: true, reason: "Not armed" } (HTTP 200) when the race is not armed. Returns 400 for an invalid lane number.
// Request body
{ "lane": 2, "timestamp_us": "3482910" }
// Response
{ "ok": true }Clears the video URL and broadcasts a clear event without advancing the heat number.
{ "ok": true }Resets the entire race: clears results, sets heat back to 1, and clears history.
{ "ok": true }Updates one or more server settings. All changes are persisted to derby_config.json. Returns 400 if numLanes or sensorMode is changed while a heat is armed.
All fields are optional — include only the ones you want to change:
// Request body (any combination of fields)
{
"numLanes": 3,
"timeout": 10,
"colors": { "1": "Purple", "3": "Orange" },
"videoReplayEnabled": false,
"zcamIp": "192.168.1.50",
"sensorMode": "esp32"
}
// To disable ZCam:
{ "zcamIp": null }
// Response
{ "ok": true }| Field | Type | Constraints |
|---|---|---|
numLanes |
integer | 1–8; cannot change while armed |
timeout |
number | > 0 (seconds) |
colors |
object | keys are lane numbers as strings |
videoReplayEnabled |
boolean | — |
zcamIp |
string | null | IP address of ZCam; null disables |
sensorMode |
string | "gpio", "esp32", or "simulate"; cannot change while armed |
Connect to ws://localhost:3000. Every message is a JSON object that always includes the full state snapshot alongside the event-specific fields.
type |
Extra fields | Description |
|---|---|---|
init |
— | Sent immediately on connection with current state |
armed |
— | Sensors have been armed, heat is starting |
trigger |
lane, gapMs, place |
A car crossed the finish line |
finished |
— | All cars finished (or timeout elapsed) |
reset |
— | Heat state was cleared; state.heat is unchanged (increments on next armed) |
settings |
— | One or more settings were updated (lane count, colors, ZCam, sensor mode, etc.) |
clear |
— | Display was cleared; state.videoUrl is now null |
video |
videoUrl |
ZCam clip has been downloaded; state.videoUrl is now set |
Example client:
const ws = new WebSocket('ws://localhost:3000')
ws.onmessage = (e) => {
const { type, state } = JSON.parse(e.data)
console.log(type, state.finishOrder)
}When the --zcam <ip> flag is provided, the server automatically:
- Starts recording the moment the first lane trigger fires
- Stops recording when the heat is complete (all lanes triggered or timeout)
- Downloads the clip from the camera's SD card to
public/videos/heat-N_TIMESTAMP.ext - Broadcasts a
videoWebSocket event so all connected dashboards update immediately (only whenvideoReplayEnabledis true) - Plays the clip automatically on the guest display below the race results
The camera must be reachable at the given IP address and in a record-ready state before each heat. The server uses the ZCam E2 HTTP API:
| ZCam endpoint | Purpose |
|---|---|
GET /info |
Camera availability ping |
GET /ctrl/session |
Acquire control session |
GET /ctrl/session?action=quit |
Release control session |
GET /datetime?date=YYYY-MM-DD&time=hh:mm:ss |
Sync camera clock to host time |
GET /ctrl/mode?action=query |
Query current working mode |
GET /ctrl/mode?action=to_rec |
Switch camera to record-ready mode |
GET /ctrl/rec?action=start |
Start recording |
GET /ctrl/rec?action=stop |
Stop recording |
GET /DCIM/ |
List DCIM sub-folders on SD card |
GET /DCIM/<folder> |
List clip files in a folder |
GET /DCIM/<folder>/<file> |
Download a clip |
Default ZCam IP when connected via USB (RNDIS) or the camera's built-in Wi-Fi AP: 10.98.32.1. Configure the IP from the Track Manager at /manage.
Downloaded clips are saved as public/videos/heat-N_TIMESTAMP.mov (or .mp4, e.g. heat-3_2026-03-09_14-22-05.mov) and served at /videos/heat-N_TIMESTAMP.mov. They are cleared from the guest display on each heat reset.
The system has two components:
- Node.js server — runs on any machine (laptop, Pi, etc.) on the local network. See docs/SETUP.md for installation and systemd auto-start.
- ESP32 sensor node — reads the finish-line sensors and POSTs timing data to the server. See docs/SETUP.md for flashing instructions.
| Lane | ESP32 GPIO |
|---|---|
| 1 | GPIO 25 |
| 2 | GPIO 26 |
| 3 | GPIO 32 |
| 4 | GPIO 33 |
To change pin assignments, edit LANE_PINS in esp32/derby_sensor/derby_sensor.ino.
For full wiring details and sensor circuit diagrams, see docs/WIRING.md.
Results are appended to derby_results.csv in the project directory after each heat.
date,time,heat,1st,2nd,3rd,4th,gap_2nd_ms,gap_3rd_ms,gap_4th_ms
2026-03-06,14:23:01,1,2,4,1,3,47.3,112.8,201.5
2026-03-06,14:25:44,2,3,1,2,4,18.1,95.2,340.0
Eight colors are available. Configure them per-lane from the /manage page.
Red · Blue · Yellow · Green · Purple · Orange · Pink · White
derby/
├── server.js # HTTP server, WebSocket, API routes, race state, CSV logging
├── sensor.js # BaseSensor class — shared race logic and simulation
├── sensor-esp32.js # ESP32 sensor driver (triggers arrive via HTTP)
├── sensor-gpio.js # Raspberry Pi GPIO sensor driver (pigpio)
├── sensor-manager.js # Sensor factory — instantiates the right driver
├── zcam.js # ZCam E2M4 HTTP API client (record, stop, download clip)
├── esp32/
│ ├── platformio.ini # PlatformIO build config
│ └── derby_sensor/
│ └── derby_sensor.ino # ESP32 sketch: ISR timing + WiFi reporting
├── public/
│ ├── guest.html # Guest-facing live results display + heat video replay
│ ├── manage.html # Track manager UI
│ └── videos/ # Downloaded heat clips (auto-created when ZCam is enabled)
├── docs/
│ ├── SETUP.md # Server + ESP32 setup guide
│ └── WIRING.md # ESP32 and GPIO pin mapping and sensor circuit
├── derby_results.csv # Auto-created on first run
└── package.json

