- JavaScript 95.5%
- CSS 3.6%
- HTML 0.7%
- Nix 0.2%
| data | ||
| docs | ||
| e2e | ||
| public | ||
| scripts | ||
| web | ||
| .envrc | ||
| .gitattributes | ||
| .gitignore | ||
| buzzer.mjs | ||
| buzzer.test.mjs | ||
| device-reader.mjs | ||
| devices.mjs | ||
| flake.lock | ||
| flake.nix | ||
| game-fuzz.test.mjs | ||
| game.mjs | ||
| game.test.mjs | ||
| markdown.test.mjs | ||
| midi.mjs | ||
| midi.test.mjs | ||
| package-lock.json | ||
| package.json | ||
| playwright.config.mjs | ||
| README.md | ||
| server.mjs | ||
| server.test.mjs | ||
| storage.mjs | ||
| storage.test.mjs | ||
Hacker Jeopardy!
Two screens, one authoritative server, and two teams with independently configurable USB controller counts. Everything needed to play—including the selected 60-clue game, Final Jeopardy, images, fonts, and sounds—is local.
Start and play
On NixOS, enter the project and allow its direnv environment once:
cd ~/Documents/jeopardy
direnv allow
npm start
flake.nix and flake.lock pin Node.js 24 with npm, Chromium for browser tests, and FFmpeg/FFprobe for media work. .envrc loads the shell automatically when entering the directory with direnv enabled. The first load downloads any missing Nix packages. Without direnv, run nix develop -c npm start, or enter nix develop before running project commands. The shell does not start the server or install npm packages automatically.
Node.js 24 or newer (including built-in SQLite) is the only runtime dependency. To run without Nix, use npm start (or node server.mjs) with Node installed. Browser tests additionally need the JavaScript dependencies installed with npm ci; serving the game does not need them.
- Host: http://framework:5100/host
- Contestant screen: http://framework:5100/display
The server binds to 0.0.0.0, so the host screen can run on a phone reachable over Tailscale. Use your desktop's extended-display mode and move the contestant browser window to the TV. Click Enter game · enable sound on that screen. Use the browser’s fullscreen command (F11 on the laptop). The browser requires this first click to allow audio. Play sounds on one contestant screen to avoid echoes.
The normal contestant display is tested at the Sharp AQUOS LC-60TQ15U's 1920×1080, 16:9 output. See the TV display checks.
- In setup, set Controllers for Team 1 and Controllers for Team 2, then choose Apply for each changed count. Each team can use 1–64 controllers; both default to 2. For example, set Team 1 to 1 and Team 2 to 3 to share one buzzer across three players on Team 2. Connect the required 4limb controllers, choose Pair Team 1, then press and release each requested controller. The screens guide you through each team's slots. Every required controller must be paired and connected before starting. Edit the team names. Assignment follows the USB port; repeat pairing if you move cables.
- Optionally import/edit a game in the host’s Game file & media section. Each imported definition remains a reusable JSON file under
data/games/. A fresh session loads the selected game fromdata/game-template.json: 60 clues, three Daily Doubles, Final Jeopardy, and a tiebreaker. A separate demo is available indata/demo-game.json. Loading a different pack requires setup mode and fresh pairing; each team's controller count is retained. Resetting preserves current names, counts, and controller assignments. - Start game runs the six category introductions and board transition. The host can advance or skip the introduction.
- Choose a clue on the host board. Read it, then press Ready (or Space outside a form). The screen's side lights activate. The first eligible controller press selects a team.
- Correct adds the clue value and returns to the board. Incorrect subtracts it, excludes that team for this clue, and returns to standby; press Ready again for the other team. You can reveal before judging. Revealing ends further buzz-in opportunities. If nobody buzzes in three seconds, the time's-up cue plays and the response appears; choose Return to board to continue.
- Adjust scores with ±100 or type a whole-dollar number and press Enter/leave the field. Escape discards an edit; blank scores are never saved as zero. Final wagers use fixed entry scores, so score corrections resume after Final is completed.
- Daily Doubles belong to the selecting team. Enter a legal wager before revealing the clue and judge manually. Both regular buzzers are disabled for wagering clues.
- After all clues are used, start the next round. Double Jeopardy doubles the values and contains two Daily Doubles. The lower-scoring team selects first.
- Final reveals the category, then you enter each positive-scoring team's private paper wager. Show the clue, read it, and start the 30-second thinking timer. Reveal and judge written responses manually. Show final scores; a positive-score tie offers a sudden-death buzzer clue or a manual winner selection.
There is no automatic speech recognition. Host judgment handles correct phrasing, pronunciation, and answer acceptance. The two-team bottom score bar replaces physical podiums.
The host dashboard keeps the scores, board and current action together. Controller status, buzzer timing, settings and file tools expand when needed. Ready and judging controls appear above the clue; unsaved Final wagers block advancing to the clue. Newer edits survive delayed replies, and countdowns follow the server clock.
Music follows the game phase on the contestant screen: opening music, board and Daily Double cues, quiet looping music while entering wagers, Final thinking music tied to the timer, and closing music. Showing a clue stops wagering music immediately. Enable sound once when opening the display; playback then follows the host automatically. See the playback table.
State and reliability
See the full state machine for phases, transitions, invariants and all valid actions. Rules and sources distinguish TV rules from local adaptations. The official production description specifies a three-second no-buzz window, separate from spoken-response timing.
game.mjs owns game state, legal transitions, scores, wagers and timers; storage.mjs commits state to SQLite. The browser receives projections over Server-Sent Events and submits host actions by HTTP. Future answers and Daily Double locations are not sent to the contestant projection; Final wagers are disclosed only as each team is judged. Host commands have duplicate-request protection and turn-context checks; delayed HTTP replies cannot overwrite a newer buzzer event.
buzzer.mjs combines the held buttons on each controller. The game combines all controllers assigned to a team with the same OR rule: any held button on any assigned controller holds the team buzzer. Release every button on all of that team's controllers before a new press can buzz. Pressing another controller while the team is held cannot buzz again or extend an early lockout. An early press incurs a 250 ms team lockout; holding does not auto-buzz when the lights turn on. An independent worker reads each UMP device, avoiding a fixed main-thread device polling order. Winner order is server receipt order—not hardware-synchronized timestamps. This is appropriate for local play, not a guarantee of broadcast-grade sub-millisecond arbitration.
Controller counts can change during setup or pairing. Increasing a count keeps existing assignments and adds empty slots; decreasing it keeps the earliest assignments and unassigns the excess controllers. Applying a changed count ends any current pairing prompt. Use Pair controllers or Pair again on a team to replace its assignments and fill its slots again. Counts are fixed during play and survive reset, restart, and game imports.
Scores, used clues, controller counts and assignments, wagers and live settings are stored in data/jeopardy.sqlite. SQLite transactions save the session and action audit together before a host action is acknowledged or a new state is sent to a screen. WAL mode with FULL synchronization protects committed updates against process interruption. Request IDs survive restarts, so a retried scoring action is not applied twice. A reset or game replacement archives the preceding session inside the database. Existing session.json progress is migrated once; that legacy file is kept as a backup and is no longer updated. See storage and recovery. Restarting preserves game progress, clears physical held state, and returns an armed clue to standby. An interrupted Final timer returns to the reading stage so the host can restart it. Browser refreshes do not reset the game. Release all buttons before beginning after a server restart.
USB readers reconnect automatically and read only c0de:4011 UMP endpoints. No firmware writes or system configuration changes are made. If a controller disappears, its own held state clears and the host shows it disconnected. The team stays held if any other assigned controller is still pressed, and a disconnect does not clear the team's early lockout. Use Controller status at the bottom of the host screen to inspect each device’s combined held state, active buttons and latest MIDI output. Expand the raw output to pause its display or download the capture. The old /monitor URL redirects to this section. Raw JSONL files are saved in logs/.
The host’s Buzzer timing & logs panel records physical button presses from each Standby window onward. It updates live while waiting, places Ready at zero, and shows early presses as negative milliseconds and later presses as positive milliseconds. After a winner, the view fits the presses automatically while continuing to record attempts until the clue closes. Inspect an event for its exact offset and result, choose Full recording, or select a previous window. Extra presses while a controller is already held are visible without creating a second eligible buzz. Timing uses server receipt timestamps. SQLite retains the current trace plus ten previous windows, capped at 2,000 events each with any omitted count shown; raw capture logs retain the device packets.
The application is intended for a trusted local network. Visiting /host grants the host browser a session cookie; this separates the display API from accidental host access but is not a password-protected operator account. Do not expose the preview publicly. Runtime files and game packs are not served as static assets.
Content and styling
See the game authoring reference, JSON Schema, and selected game to edit or create a game. Fresh installations load that file automatically; import edits through the host to change an existing saved session.
Edit or download the current JSON pack in the host’s Game file & media section, or import a new one. Validation requires two six-category, five-clue boards, exactly one Daily Double in round one and two in round two, unique clue IDs and Final Jeopardy. The row determines value: $200–$1,000, then $400–$2,000. See the complete demo.
Clues and responses support paragraphs, line breaks, bold, italics, inline code and fenced code blocks. A language tag such as js, python or sql enables Shiki highlighting on both screens; untagged code stays plain. Shiki's Oniguruma engine, grammars and themes are bundled locally and initialize before the page appears. Highlighting preserves code text and indentation. Raw HTML is escaped. Text is uppercase on the contestant display, while code preserves case. Add an optional media field:
{"id":"example","text":"Name the object in this image.","response":"What is **Saturn**?","media":{"type":"image","src":"/media/saturn.jpg","alt":"A ringed planet"}}
Upload PNG/JPEG/WebP/GIF, MP3/WAV/OGG or MP4/WebM files through the host panel, then use the returned /media/… path in the pack. Audio/video playback is controlled by the host and synchronized to the contestant screen. Uploaded media stays on the laptop. Maximum upload size is 50 MB. The game pack JSON itself does not include media files; keep public/media/ when moving a game to another laptop.
For an image-only clue, omit text. The contestant image fills the available clue area without cropping, and the host sees a preview plus the correct response:
{
"id": "picture-1",
"response": "What is **Saturn**?",
"media": {
"type": "image",
"src": "/media/saturn.png",
"alt": "A planet with rings"
}
}
Use Upload clue media on the host to get the image path, or create public/media/ and place your image there: public/media/saturn.png maps to /media/saturn.png in JSON. This is a server-local path, not a file:// URL. Optional text adds the existing split text-and-image layout. Image-only clues also work for Daily Doubles, Final and tiebreakers; wager-stage screens keep the image hidden until the host shows the clue. Built-in show graphics use fonts and CSS; clue images come from the game content.
web/theme.css: contestant design tokens, typography, board, lighting, animations and score strip.web/display-layout.mjs: rendered text fitting, resize/font handling, and readable overflow.web/host.css: separate light host interface.web/display.mjs: presentation and sounds, independent of the state machine.web/markdown.mjs: small escaped Markdown renderer.scripts/build-syntax.mjs: rebuilds the checked-in Shiki bundle withnpm run build:syntaxafternpm ci; normal startup needs no build.public/fonts/: supplied fonts, including Swiss 911 and ITC Korinna, plus a locally stored handwritten font for the team names.- Show graphics are text and CSS: Annual for Hacker Jeopardy!, Univers for Daily Double, and procedural grain over the blue backgrounds. No downloaded graphics are loaded by the display.
public/sounds/: downloaded cue recordings. See sources and timing notes.docs/references/: downloaded official visual references and source notes.
The display follows the show's board colors, fonts, framing, side lights, clue zoom, category carousel, Daily Double title and score styling. The local transitions and two-team score bar are implemented for this application; the soundboard uploads are not authenticated studio masters. All assets play offline. The eight active recordings are matched to −18 LUFS, with a quieter mix during wagering; see the audio measurements.
Verification
npm test
npm ci
npm run test:e2e
Run these commands inside direnv or nix develop. The Nix shell sets CHROME_PATH to its pinned Chromium and disables Playwright browser downloads. Outside Nix, the browser check falls back to the installed Chrome path on this laptop; set CHROME_PATH to use a different Chrome/Chromium executable. It starts an isolated test game on port 5102, injects simulated inputs in test mode, runs both browser screens through a full game and tie, and leaves production state untouched. The simulated-input API is absent during normal operation.
The checks also fuzz display and host layouts, content boundaries, and game-state transitions with reproducible seeds. See layout and fuzz testing for coverage, overflow behavior, and commands to reproduce a failure.
Optional environment variables: PORT (choose an available preview port from 5100–5199) and GAME_DATA_DIR for an alternate session/game-pack directory. Edit Game settings at the bottom of the host screen at any time: ready window, early lockout, category duration, board transition and Final thinking time. Changes are saved in SQLite and used by subsequent timers; current deadlines remain fixed. Team names and score corrections are available in the score controls. No NixOS switch is required.