A seff-hosted, real-time multiplayer party game platform inspired by Jackbox Games, built with PartyServer + React on Cloudflare Workers. Hosts pick Trivia or Bingo when creating a game.
Players join from their phones, the host controls (or, for Bingo, simply starts/ends) the game, and a presentation view shows everything on a shared screen — all connected via WebSockets.
- Two game types, picked by the host at creation time:
- Trivia — multiple rounds with customizable questions and point values, fuzzy answer matching (Fuse.js, with host override for edge cases), bonus points for creative or funny answers, and an answer reveal once each question closes showing what it was and what everyone said
- Bingo — numbered or custom-phrase-pool cards, free-for-all self-marking (no host pacing), configurable win patterns (line / four corners / blackout), and cross-player glow hints when another player marks a square matching yours
- 4 views designed for different roles:
- Host — control panel for managing the game, starting with a Trivia/Bingo type picker (desktop)
- Presentation — screen-share view for video calls (TV/projector)
- Player — mobile-first interface for answering questions or marking a bingo card (phone)
- Audience — spectator mode with reactions (phone)
- Spreadsheet authoring — write a whole quiz in Google Sheets from a downloadable template, export one CSV, import, and run. Games export back out the same way, so a quiz is a file you can keep, edit and share
- Question images — hosts can attach an image to any trivia question, present it in a frame (Polaroid, TV screen, 35mm slide, gallery frame, phone screen) and stack filters over it (black & white, sepia, halftone, film grain, vignette, VHS, blur, pixelate). Stored in Cloudflare R2, shown on the presentation screen and every player's phone alongside the question. A second, independent image can be attached to show only once the answer is revealed — unveil the question's own picture without its filter, or show something new. Audio and video are architected for but not enabled yet — see Question Media.
- Real-time WebSocket communication via PartyServer
- In-app notifications — toasts and synthesized sound effects (mutable) for marks, wins, and other live events, so players don't need to be watching the shared presentation screen
- Animated transitions, score reveals, and celebrations (Framer Motion)
- Accessible — WCAG 2.2 AA compliant (semantic HTML, keyboard navigation, screen reader support, reduced motion)
Each game mode has its own host (desktop), player (phone), presentation (desktop, for the shared screen), and audience (phone, spectator mode) views. A full set of every screen and state is in docs/screenshots/, organized by mode — regenerate it anytime with npm run capture:screenshots. Both trivia flows are captured twice per question screen: once on a plain text question, and once on a picture question (the -media files). The capture plays four people against each question with a deliberate spread of right, nearly-right and way-off answers, so the answer reveal below is real output rather than a mock-up.
| Host | Player | Presentation | Audience |
![]() |
![]() |
![]() |
![]() |
| The same moment on a picture question — an image the host uploaded, framed and filtered | |||
![]() |
![]() |
![]() |
![]() |
| Host | Player | Presentation | Audience |
![]() |
![]() |
![]() |
![]() |
| The moment a picture question is revealed mid-round | |||
![]() |
![]() |
![]() |
![]() |
Once a question closes — after every question in individual play, at the end of the round in Team Play — every screen shows what the answer actually was: the one being looked for, the alternates the question was set up to accept, the near-misses the host waved through by hand, and the ones that didn't count. A wrong answer the host thought was funny enough to award bonus points for gets a ⭐ and its own color. Players see their own answers ringed and tagged.
A question can also carry a second, independent image that shows only here, at the reveal — see Answer Reveal Media.
| Individual play — the answer for the question just played | |
![]() |
![]() |
| Presentation | Player |
| The same question's answer reveal image — the picture question's photo, unveiled without its filter | |
![]() |
![]() |
| Presentation | Player |
| Team Play — the whole round at once, since teams are judged a round at a time (notice the middle question's reveal image) | |
![]() |
![]() |
| Presentation | Player |
| In context, next to the scores | |
![]() |
![]() |
| Individual play | Team Play |
Hosts who'd rather keep the answers on the shared screen only can turn off Show answers on player phones when creating the game — the presentation view always shows them.
| Host | Player | Presentation | Audience |
![]() |
![]() |
![]() |
![]() |
| Host | Player | Presentation | Audience |
![]() |
![]() |
![]() |
![]() |
- Node.js 22+
- npm 9+
# Install dependencies
npm install
# Create your environment file
cp .env.example .env
# Edit .env and set JANEDECK_ADMIN_PASSWORD to a strong password
# Start the development server
npm run devThe dev server starts Vite with the Cloudflare plugin, serving both the WebSocket server (via workerd/miniflare) and the React frontend.
- Host opens
/hostand enters the admin password - Host picks a game type at
/host/create— Trivia or Bingo - Players join via game code at
/play/CODE(or from the home page at/) - Host shares the Presentation view (
/present/CODE) on a video call or screen
Trivia:
- Host creates the game with rounds and questions (or uses Quick Start template)
- Host advances through rounds — players answer from their phones
- After each question, the host reviews answers (auto-scored by fuzzy matching)
- Scores are revealed with animated leaderboard updates, alongside the correct answer and everyone's guesses — after every question in individual play, at the end of the round in Team Play
- At the end, the winner is crowned with confetti 🎉
Bingo:
- Host creates the game, choosing numbered or custom-phrase cards and which win patterns count (line / four corners / blackout)
- Host starts the game — each player gets their own shuffled card
- Players tap squares to mark or unmark them at their own pace, no host pacing required
- When another player marks a square matching one on your card, it glows as a hint
- Marks and wins are announced live (toast + sound, mutable via the in-app sound toggle) so players don't need to watch the shared screen
- Host ends the game when ready — multiple players can complete multiple patterns before then
Typing a whole quiz into the browser form works, but it isn't the nicest way to write one. The form is best for tweaks; a spreadsheet is best for authoring. Write the questions in Google Sheets (or Excel, or Numbers), export one CSV, import it, hit Start Game.
1. Grab a template — these are the same files the app's Download Template button produces:
| Template | |
|---|---|
janedeck-trivia-template.csv |
Rounds, questions, answers, timings, and picture-round columns |
janedeck-bingo-template.csv |
Card settings, win patterns, and a 24-phrase pool |
On GitHub, open the file and use the Download raw file button (⤓, top-right of the file view).
2. Open it in Google Sheets — File → Import → Upload, choose Replace spreadsheet, and leave the separator on Detect automatically. Keep the header row: it tells you what every column accepts, e.g.
Media Frame (none, polaroid, tv, slide, gallery or phone)
3. Write your quiz. Rows sharing a Round Name become one round, in the order they appear. Delete the example rows when you're done with them, and delete any column you don't need — every column except Round Name, Question and Correct Answer is optional.
4. Download as CSV — File → Download → Comma-separated values (.csv).
5. Import and run — Host → Trivia → 📥 Import CSV, then Start Game. The import is also remembered in your browser, so you can close the tab and pick up where you left off without re-uploading.
| Imported and ready | Running it |
![]() |
![]() |
Images. A CSV carries a reference to an image, not the image itself — see Question Media. Add pictures on the question after importing, and if you export afterwards the reference travels with the sheet.
Beat Google Sheets' autocorrect. Sheets reformats things that look like numbers or dates, which occasionally mangles an answer on the way out:
| You type | Sheets stores | Fix |
|---|---|---|
3/4 |
3/4/2026 |
Format the column as Plain text (Format → Number → Plain text) before typing |
007 |
7 |
Same, or prefix with an apostrophe: '007 |
=MC² |
a broken formula | Prefix with an apostrophe: '=MC² |
Formatting the whole sheet as plain text before you start avoids all three. It's worth a quick skim of the exported CSV before importing — and the importer will tell you about any row it couldn't read rather than dropping it silently.
See docs/ARCHITECTURE.md for the full system design.
┌─────────────┐ ┌──────────────────────┐ ┌─────────────┐
│ React App │◄───►│ PartyServer (DO) │◄───►│ React App │
│ (Host) │ WS │ ┌─────────────────┐ │ WS │ (Players) │
│ │ │ │ GameRoom │ │ │ │
└─────────────┘ │ │ (state machine) │ │ └─────────────┘
│ └─────────────────┘ │
┌─────────────┐ │ ┌─────────────────┐ │ ┌─────────────┐
│ Presentation│◄───►│ │ AuthGate │ │◄───►│ Audience │
│ (Screen) │ WS │ │ (token auth) │ │ WS │ (Spectator)│
└─────────────┘ │ └─────────────────┘ │ └─────────────┘
└──────────────────────┘
- GameRoom — single Durable Object class shared by both game types;
Gameis a discriminated union (TriviaGame | BingoGame) narrowed on atypefield, each with its own state machine and message handlers - AuthGate — secondary Durable Object; validates host passwords, issues session tokens
- Trivia state machine —
LOBBY → ROUND_INTRO → QUESTION_DISPLAY → ANSWERING → REVIEWING → SCORE_REVEAL → ROUND_RESULTS → GAME_OVER - Bingo state machine —
LOBBY → BINGO_PLAYING → BINGO_ENDED, with marking/win-checking handled bysrc/server/bingo/(bingoEngine.tsfor card generation and pattern checks,bingoHandlers.tsfor the message handlers)
| Layer | Technology |
|---|---|
| Runtime | PartyServer on Cloudflare Workers |
| Frontend | React 19 |
| Routing | React Router 7 |
| State | Zustand |
| Animation | Framer Motion |
| Validation | Zod 4 |
| Fuzzy Match | Fuse.js |
| Build | Vite 8 + @cloudflare/vite-plugin |
| WebSocket | PartySocket |
| Language | TypeScript 6 |
| Variable | Required | Description |
|---|---|---|
JANEDECK_ADMIN_PASSWORD |
Yes | Password that hosts must enter to create games. Set in .env for local dev; configure as a secret for deployment. |
| Binding | Required | Description |
|---|---|---|
GameRoom, AuthGate |
Yes | Durable Object classes. Declared in wrangler.jsonc; created automatically on first deploy. |
MEDIA |
No | R2 bucket holding host-uploaded question images. Without it, JaneDeck runs every game type normally and simply reports image uploads as unavailable. See Question Media. |
docs/
├── ARCHITECTURE.md # Design document
├── screenshots/ # Every screen and state, by game mode (npm run capture:screenshots)
└── templates/ # Downloadable CSV templates (npm run templates)
scripts/
├── capture-screenshots.mjs # Playwright capture of every role x state
└── write-templates.mjs # Generates docs/templates/ from src/client/utils/csv.ts
src/
├── shared/ # Shared types, messages, constants
│ ├── types.ts # TypeScript interfaces (Game, Player, Answer, BingoCard, etc.)
│ ├── messages.ts # WebSocket message type definitions
│ ├── schemas.ts # Zod schemas for runtime validation
│ ├── gameStates.ts # State machine transitions (trivia + bingo)
│ ├── media.ts # Question media: kinds, frame/filter catalog, byte sniffer
│ └── constants.ts # Shared constants
├── server/ # PartyServer / Cloudflare Workers code
│ ├── index.ts # Worker entry point
│ ├── gameRoom.ts # Main game room Durable Object (both game types)
│ ├── authGate.ts # Authentication Durable Object
│ ├── media.ts # R2-backed upload/serve/status routes for question media
│ ├── stateMachine.ts # Game state transition logic (trivia + bingo)
│ ├── timer.ts # Alarm-based countdown timer
│ ├── fuzzyMatcher.ts # Fuse.js answer matching
│ ├── bingo/ # Bingo-specific server logic
│ │ ├── bingoEngine.ts # Card generation, win-pattern checking
│ │ └── bingoHandlers.ts # Message handlers (create/start/mark/unmark/end)
│ └── utils/ # Server utilities
│ ├── broadcast.ts # Role-targeted message broadcasting
│ ├── storage.ts # Durable Object storage helpers
│ └── gameCode.ts # Game code generation
└── client/ # React frontend
├── App.tsx # Root component with routes
├── main.tsx # React entry point
├── styles/ # Global CSS, media frames/filters, theme tokens
├── animations/ # Framer Motion presets, variants, reduced-motion provider
├── hooks/ # Custom React hooks
│ ├── usePartySocket.ts # WebSocket connection management
│ ├── useGameState.ts # Game state subscription
│ ├── useAuth.ts # Host authentication (login + logout)
│ ├── useMediaUpload.ts # Upload question images to R2 (inherits host auth)
│ ├── useMediaAvailability.ts # Batched check that referenced media exists
│ ├── useTimer.ts # Client-side timer sync
│ └── useAnimatedScore.ts # Score counting animation
├── stores/ # Zustand state stores
│ ├── gameStore.ts # Shared game state (trivia + bingo)
│ ├── hostStore.ts # Host-specific state
│ ├── playerStore.ts # Player-specific state
│ └── notificationStore.ts # Toast notification queue
├── utils/ # Client utilities
│ ├── csv.ts # CSV import/export (questions, media, phrase pools, results)
│ ├── mediaStyles.ts # Frame/filter CSS derived from a media record
│ └── soundEffects.ts # Web Audio synthesized sound effects (mutable)
├── components/ # Reusable UI components
│ ├── Timer.tsx # SVG countdown ring
│ ├── Leaderboard.tsx # Animated score list
│ ├── Confetti.tsx # Canvas confetti effect
│ ├── StatusBadge.tsx # Game state indicator
│ ├── QuestionCard.tsx # Question display
│ ├── QuestionMedia.tsx # Framed + filtered question image (all four views)
│ ├── PlayerAvatar.tsx # Color-coded player icon
│ ├── AnimatedScore.tsx # Counting-up score display
│ ├── LogoutButton.tsx # Host session logout
│ ├── SoundToggle.tsx # Mute/unmute sound effects
│ └── ToastStack.tsx # Renders queued notification toasts
└── views/ # Route-level view components
├── HomeView.tsx # Landing page
├── host/ # Host views (login, game type selector, trivia + bingo creators/dashboards, sub-components)
├── player/ # Player views (trivia question flow + BingoCard, sub-components)
├── presentation/ # Screen-share views (lobby, question, score reveal, game over, sub-components)
└── audience/ # Spectator views (leaderboard, vote input)
# Start dev server (Vite + Cloudflare plugin)
npm run dev
# Type check
npm run typecheck
# Build for production
npm run build
# Deploy to Cloudflare Workers
npm run deploy
# Re-capture every README screenshot with Playwright (needs `npm run dev` running in another terminal)
npm run capture:screenshots
# ...or just the flows you changed
npm run capture:screenshots -- trivia-individual trivia-team
# Regenerate the downloadable CSV templates in docs/templates/
npm run templatesnpm run templates writes docs/templates/*.csv from the very functions the app's Download Template buttons call, so the files people download from GitHub can't drift from what the app produces. Run it after changing anything in src/client/utils/csv.ts — CI re-runs it and fails if the committed files come out different.
The capture script drives five flows — trivia-individual, trivia-team, bingo, bingo-phrases, and media — writing to docs/screenshots/<flow>/. Name flows as arguments to re-capture only those; no arguments runs all five. Both trivia flows attach an image to one question, so every question screen is captured twice: plain, and with media (-media suffix).
Four players answer each trivia question with a deliberate spread — one exact match, two near-misses, one way-off guess — and the host judges them one at a time rather than bulk-accepting everything. That's what fills all three groups of the answer reveal; a table where everyone answers correctly produces a reveal with nothing in it but the correct answer, which documents the feature poorly and isn't what a real trivia night looks like either.
The media steps need two extra things:
- An R2 bucket bound as
MEDIA.npm run devemulates one locally, so nothing to set up. - Network access to placecats.com (falling back to placekittens.com) for the sample photo. Real photographic detail is what makes the filters legible — grain, halftone and pixelate all read as nothing on a flat synthetic image.
If either is unavailable the media steps log a warning and skip; every other screenshot is still captured.
JaneDeck is built on Cloudflare Workers and Durable Objects — deploying it to your own Cloudflare account is the officially supported, zero-Docker way to run it in production. workerd runs on Cloudflare's infrastructure, so none of the Docker/Raspberry Pi virtual-address-space caveats below apply.
- A Cloudflare account (free to create).
- A paid Workers plan. JaneDeck's
GameRoomandAuthGateDurable Object classes use SQLite storage (see themigrationsblock inwrangler.jsonc), which is not available on Cloudflare's free Workers plan. Check Cloudflare's current Workers pricing before deploying — plan names and what's included in free vs. paid tiers change over time, so verify rather than relying on this README. - Node.js 22+ and npm 9+ (same as local dev — see Quick Start).
- A clone of this repo with
npm installalready run.
# Authenticate wrangler with your Cloudflare account (opens a browser to authorize)
npx wrangler login
# Store the admin password as an encrypted secret — NOT your local .env file.
# .env/.dev.vars only apply to `npm run dev`; deployed Workers read secrets
# set this way instead.
npm run deployThis builds the frontend (vite build → dist/), then runs wrangler deploy, which bundles src/server/index.ts, provisions the GameRoom/AuthGate Durable Object classes (first deploy only), and uploads the built frontend as static assets.
On success, wrangler prints the live URL:
https://janedeck.<your-subdomain>.workers.dev
If this is the first Worker on your account, wrangler may prompt you to claim a workers.dev subdomain first — any available name works.
To serve JaneDeck from your own domain instead of *.workers.dev:
- Add the domain to Cloudflare if it isn't already there (zone setup guide).
- Add a
routesentry towrangler.jsonc: - Redeploy:
npm run deploy. Cloudflare provisions DNS and TLS for the custom domain automatically.
The name field in wrangler.jsonc ("janedeck" by default) sets your Worker's name and its default *.workers.dev URL. Worker names are scoped to your own account, so there's no conflict with anyone else's deployment — change it freely before your first deploy if you want a different URL. Renaming after Durable Objects have been provisioned under the original name requires a proper migration, not just an edit to name.
git pull
npm install
npm run deployGame data lives in Durable Object storage and persists across deploys — redeploying replaces code, not state.
npx wrangler tailStreams live logs from the deployed Worker.
For automated deploys instead of running npm run deploy by hand, authenticate with an API token rather than wrangler login:
- Create a token at Cloudflare's API Tokens page using the "Edit Cloudflare Workers" template.
- Add it as a
CLOUDFLARE_API_TOKENsecret in your CI provider. - Run
npm run deployin your CI job — wrangler picks upCLOUDFLARE_API_TOKENfrom the environment automatically.
This repo's .github/workflows/ currently only publishes the Docker image to GHCR; it does not auto-deploy to Cloudflare. Add a workflow yourself if you want that.
JaneDeck can run in Docker on any platform — a cloud VM like a DigitalOcean Droplet, or Raspberry Pi 4/5 hardware (ARM64, running Ubuntu Server — see the Prerequisites note below on why the official Raspberry Pi OS doesn't work). There are two ways to run it:
- Self-hosted (prebuilt image) — pulls the published image from GHCR, no repo clone needed. Recommended for most self-hosters.
- Build from source — clones the repo and builds the image locally. Use this if you're developing or want to run an unreleased change.
- Docker Engine 20+ and Docker Compose v2
- For Raspberry Pi: a 48-bit VA kernel required — the official Raspberry Pi OS does not work.
workerd(Cloudflare's Workers runtime) uses an allocator that reserves virtual memory at addresses above 512GB. Raspberry Pi Foundation's own kernel builds (the*-rpi-v8packages used by Raspberry Pi OS) hardcodeCONFIG_ARM64_VA_BITS=39— capping user-space VA at 512GB — on every version we've tested, from Bullseye's 6.1.x through a Bookworm-era 6.12.x build from September 2025. This appears to be a permanent characteristic of their kernel config (likely to keep one unified build across the whole Pi lineup, including low-RAM boards like Pi Zero 2 W), not something a Debian version bump fixes. The container will crash-loop on startup regardless of available RAM or Docker settings.- Raspberry Pi OS (any version): 39-bit VA → confirmed does not work
- Ubuntu Server 22.04/24.04 LTS for Raspberry Pi (ARM64): targets the same generic arm64 kernel config used for cloud arm64 (AWS Graviton, etc.), which is 48-bit VA — expected to work, but verify before relying on it
- Verify before running:
zcat /proc/config.gz 2>/dev/null | grep CONFIG_ARM64_VA_BITS || grep CONFIG_ARM64_VA_BITS /boot/config-$(uname -r)— must showCONFIG_ARM64_VA_BITS=48 - Alternatively, skip self-hosting on Pi hardware entirely and run
npm run deployto deploy to actual Cloudflare Workers (generous free tier) —workerdthen runs on Cloudflare's infrastructure, not the Pi, sidestepping this issue altogether
# 1. Make a directory for JaneDeck's compose file and data
mkdir janedeck && cd janedeck
# 2. Download the self-hoster compose file
curl -O https://raw.githubusercontent.com/therebelrobot/janebox/main/docker-compose.prod.yml
# 3. Create your environment file
echo "JANEDECK_ADMIN_PASSWORD=your-strong-password" > .env
# 4. Start
docker compose -f docker-compose.prod.yml up -d
# 5. Open in browser
# http://localhost:5173 (local)
# http://<your-pi-ip>:5173 (LAN)Game data persists in ./data, right next to docker-compose.prod.yml — a plain folder you can see, back up, or delete, not a hidden Docker-managed volume.
# Start in background
docker compose -f docker-compose.prod.yml up -d
# View logs
docker compose -f docker-compose.prod.yml logs -f janedeck
# Stop
docker compose -f docker-compose.prod.yml down
# Update to the latest published image
docker compose -f docker-compose.prod.yml pull
docker compose -f docker-compose.prod.yml up -d
# Reset game data
rm -rf ./data# 1. Clone the repo
git clone https://github.com/therebelrobot/janebox.git
cd janebox
# 2. Create your environment file
cp .env.example .env
# Edit .env and set JANEDECK_ADMIN_PASSWORD
# 3. Build and start
docker compose up -d
# 4. Open in browser
# http://localhost:5173 (local)
# http://<your-pi-ip>:5173 (LAN)# Start in background
docker compose up -d
# View logs
docker compose logs -f janedeck
# Stop
docker compose down
# Rebuild after code changes
docker compose up -d --build
# Reset game data (clear persistent storage)
docker compose down -v-
Install Ubuntu Server 64-bit, not Raspberry Pi OS. Flash Ubuntu Server for Raspberry Pi (ARM64) to your SD card using Raspberry Pi Imager. The official Raspberry Pi OS does not work — see the VA-bits note in Prerequisites above.
-
Verify the kernel's VA bits before going further:
zcat /proc/config.gz 2>/dev/null | grep CONFIG_ARM64_VA_BITS || grep CONFIG_ARM64_VA_BITS /boot/config-$(uname -r) # Must show CONFIG_ARM64_VA_BITS=48 — if it shows 39, workerd will crash-loop regardless of the steps below
-
Install Docker:
curl -fsSL https://get.docker.com | sh sudo usermod -aG docker $USER # Log out and back in, then: docker --version
-
Set up and run JaneDeck (prebuilt image — see Option A above; swap in Option B if you'd rather build from source):
mkdir janedeck && cd janedeck curl -O https://raw.githubusercontent.com/therebelrobot/janebox/main/docker-compose.prod.yml echo "JANEDECK_ADMIN_PASSWORD=your-strong-password" > .env docker compose -f docker-compose.prod.yml up -d
-
Access from your network: Open
http://<pi-ip>:5173from any device on the same network. Find your Pi's IP withhostname -I.
Standard cloud kernels — including DigitalOcean's — use a 48-bit virtual address space, so none of the Raspberry Pi VA-bits caveats above apply. This is the simpler path if you just want a reliably-working self-hosted instance reachable from the internet, not specifically a Pi.
-
Create a Droplet: Ubuntu 22.04/24.04 LTS, Basic plan. See Resource Usage below for sizing guidance — either the Regular (Intel/AMD) or Premium AMD/Arm tier works fine, since JaneDeck's footprint is small. DigitalOcean also offers a Docker 1-Click App image with Docker preinstalled if you'd rather skip step 3.
-
Point a domain at it (optional but recommended): add an
Arecord for your domain/subdomain to the Droplet's public IP, in DigitalOcean's DNS or wherever your domain is hosted. -
SSH in and install Docker (skip if you used the Docker 1-Click App image):
ssh root@<droplet-ip> curl -fsSL https://get.docker.com | sh
-
Set up and run JaneDeck (prebuilt image — see Option A above; swap in Option B if you'd rather build from source):
mkdir janedeck && cd janedeck curl -O https://raw.githubusercontent.com/therebelrobot/janebox/main/docker-compose.prod.yml echo "JANEDECK_ADMIN_PASSWORD=your-strong-password" > .env docker compose -f docker-compose.prod.yml up -d
-
Open the firewall: if you have DigitalOcean Cloud Firewalls or
ufwenabled, allow the port you're exposing JaneDeck on:sudo ufw allow 5173/tcp # direct access, no reverse proxy # or, if adding HTTPS via reverse proxy (recommended for a real domain): sudo ufw allow 80/tcp sudo ufw allow 443/tcp
-
Access:
http://<droplet-ip>:5173directly, or set up HTTPS below with your domain for a properhttps://URL — a Droplet reachable from the public internet is exactly the case HTTPS matters most for.
| Component | RAM Usage |
|---|---|
workerd (Cloudflare Workers runtime) |
~100–200 MB |
| Per active game room | ~1–5 MB |
| Total (1–5 concurrent games) | ~250–500 MB |
This profile is the same regardless of host. A Raspberry Pi 4 (2 GB+) or Pi 5 handles 16–50 players comfortably. On DigitalOcean, the smallest Basic Droplet tier (1 GB RAM) is enough for casual/personal use; size up if you expect several concurrent games or larger groups — check DigitalOcean's current Droplet pricing for what's available. The compose files don't set a hard memory cap on the container — workerd's allocator reserves a large virtual-memory arena up front at startup, and a tight cgroup limit can make that reservation fail rather than actually constraining real usage (which stays in the range above).
For access beyond your LAN (or for any internet-facing host like a DigitalOcean Droplet), use a reverse proxy. Example with Caddy:
# Install Caddy on the host (outside Docker)
sudo apt install -y caddy
# /etc/caddy/Caddyfile
janedeck.yourdomain.com {
reverse_proxy localhost:5173
}
# Reload Caddy
sudo systemctl reload caddyCaddy automatically provisions TLS certificates via Let's Encrypt.
Where game state lives depends on which compose file you used:
- Option A (
docker-compose.prod.yml) — bind-mounted to./data, right next to the compose file. It's a plain folder: back it up with a normal file copy, or wipe it withrm -rf ./data(container must be stopped first). - Option B (
docker-compose.yml) — a named Docker volume (janedeck-data), stored at/var/lib/docker/volumes/janedeck_janedeck-data/. Survivesdocker compose restart; clear it withdocker compose down -v.
| Path | View | Description |
|---|---|---|
/ |
HomeView | Landing page with join/host options |
/host |
HostLogin | Host password entry |
/host/create |
GameTypeSelector | Choose Trivia or Bingo |
/host/create/trivia |
GameCreator | Create a trivia game with rounds/questions |
/host/create/bingo |
BingoGameCreator | Create a bingo game (card mode, win patterns) |
/host/:gameCode |
HostDashboard | Live game control panel (trivia or bingo) |
/play/:gameCode |
PlayerView | Player mobile interface (question answering or bingo card) |
/present/:gameCode |
PresentationView | Screen-share display |
/audience/:gameCode |
AudienceView | Spectator mode |
These are HTTP endpoints rather than views:
| Path | Method | Auth | Description |
|---|---|---|---|
/media/config |
GET | — | What this server accepts: whether uploads are enabled, allowed kinds, MIME types, size caps |
/media |
POST | Host token | Upload an image; returns the media record to attach to a question |
/media/status |
POST | Host token | Which of a batch of media ids this server holds — used to catch CSV references it can't resolve |
/media/:id |
GET / HEAD | — | Serve the bytes. Immutably cacheable, ETag-revalidated, range-capable. Public because every player needs it. |
/media/:id |
DELETE | Host token | Remove an object |
/parties/auth-gate/global |
POST / GET | — | Host login and token validation |
Hosts can attach an image to any trivia question. The image shows on the presentation screen next to the question and on every player's and audience member's phone, so nobody has to squint at the shared screen.
Question media needs an R2 bucket. Without one, JaneDeck runs exactly as before and the image controls hide themselves, with a one-line explanation on the game creator page.
# Create the bucket (name must match wrangler.jsonc's r2_buckets entry)
npx wrangler r2 bucket create janedeck-mediawrangler.jsonc already declares the binding:
"r2_buckets": [
{ "binding": "MEDIA", "bucket_name": "janedeck-media" }
]For local development, npm run dev emulates R2 on disk under .wrangler/ — no bucket creation and no Cloudflare account needed. The Docker setup mounts .wrangler/, so self-hosted instances persist uploads the same way.
To turn media off entirely, delete the r2_buckets block.
Pick a frame — the chrome the image is presented in. Frames with a caption slot print the host's caption on the frame itself.
| No frame | Polaroid caption slot |
TV screen |
![]() |
![]() |
![]() |
| 35mm slide caption slot |
Gallery frame caption slot |
Phone screen |
![]() |
![]() |
![]() |
Then stack filters over it:
| Black & white colour |
Sepia colour |
Halftone colour |
Film grain |
![]() |
![]() |
![]() |
![]() |
| Vignette | VHS | Blur obscure |
Pixelate obscure |
![]() |
![]() |
![]() |
Only one filter from each group can be active: picking Sepia when Black & white is on swaps them, as does picking Pixelate when Blur is on. Everything ungrouped stacks freely — here's Sepia + Film grain + Vignette together:
Blur and Pixelate read the strength slider, which is what makes them useful for guess-the-thing rounds.
Everything is rendered from the same component on all four surfaces, so the preview in the editor is exactly what the room sees:
The editor asks for a description of every image, which is what a player using a screen reader hears. It's optional — a blank one falls back to a generic label — but the field turns amber until you fill it in. Mind that the description doesn't give the answer away.
Media travels through the CSV alongside every other question setting, in nine columns:
| Column | Example | Notes |
|---|---|---|
Media |
yes |
The switch. no imports the row without the image but keeps the settings in the file, so a picture round can be turned back on later. |
Media File |
eiffel.jpg |
Original filename — how you recognise the row in a spreadsheet |
Media ID |
38_aFdppJturby58AcE6Mlcs |
The R2 object id. This is the part that actually resolves. |
Media Kind |
image |
image, audio or video (only image renders today) |
Media Frame |
polaroid |
none, polaroid, tv, slide, gallery, phone |
Media Filters |
sepia; grain; vignette |
Semicolon-separated, same convention as Acceptable Answers |
Media Intensity |
65 |
0–100, used by blur/pixelate |
Media Alt |
The Eiffel Tower at night |
Screen-reader description |
Media Caption |
Paris, 1962 |
Printed on frames that have a caption slot |
Every media column is optional. A sheet written by hand, or one exported before this feature existed, imports exactly as it always did.
The same template is downloadable straight from this repo — docs/templates/janedeck-trivia-template.csv — and there's a walkthrough of the spreadsheet workflow in Write It in a Spreadsheet, Import, Run.
Download Template writes the accepted values into the header row itself — Media Frame (none, polaroid, tv, slide, gallery or phone) and so on — plus a worked "Picture Round" showing filled-in media columns. Imports strip those hints, so a template can be filled in and imported straight back. Exports keep the plain header names.
The one column you can't author by hand is Media ID: it identifies a file already uploaded to this server. Add the image on the question in the app, then Export CSV and the real id lands in that column. The template's picture rows are switched off (Media = no) for exactly that reason — they show the shape without pointing at a file that doesn't exist.
A CSV carries the reference, not the file. The image itself lives in R2, so a sheet imported onto a server that never had the upload — a fresh instance, another host's machine — points at something that isn't there. The host pages handle that rather than letting the room find out on the projector:
- The import summary says how many questions reference an image.
- Each question whose image the server can't find shows an amber "This image isn't on the server" notice, keeping its frame, filters, alt text and caption so re-uploading restores the look exactly.
- Start Game is blocked while any referenced image is missing, naming the round and question.
| The question flags it, settings intact | …and the game won't start until it's fixed |
![]() |
![]() |
| Kind | Max size | Accepted types |
|---|---|---|
| Image | 10 MB | PNG, JPEG, GIF, WebP, AVIF |
| Audio | 25 MB | (uploads not enabled yet) |
| Video | 100 MB | (uploads not enabled yet) |
Uploads inherit host authentication — the same password-issued token that gates game creation, re-validated on every upload. Reads are public, because every player's phone needs the bytes and none of them hold a token; ids are unguessable and a question's image is only broadcast while that question is on screen.
The uploaded Content-Type header is never trusted. Files are identified by their container signature and served back as the type actually detected, so a mislabelled HTML file is rejected rather than stored and served as a script.
Not enabled yet, but the whole path underneath is already kind-agnostic: storage, the upload route, the byte sniffer, range requests (which video seeking needs), and the QuestionMedia record all handle all three kinds. What's missing is the presentation half.
Images sit beside the question; audio and video are meant to play before it, so players hear or watch the clip and only then see what they're being asked. That needs one new state in the trivia state machine, MEDIA_PLAYBACK, plus host playback controls — the seam is documented in src/shared/gameStates.ts. Flipping ENABLED_MEDIA_KINDS in src/shared/media.ts is what turns uploads on once that exists.
A question can carry a second image, completely independent of the one above, that only shows once its answer is revealed. It's the same QuestionMedia record — same upload flow, same frames and filters — attached separately, so a question can have either image, both, or neither. Two ways hosts use it:
- Unveil the question's own picture. Upload the same photo twice: filtered (blurred, pixelated, black & white) for the question, plain for the reveal. Players guess from the obscured version; the answer reveal shows what it actually was.
- Show something new. A different photo entirely — the answer written on a card, a wider shot, a follow-up punchline — that has nothing to do with the question image, or that a text-only question never had one to begin with.
The host editor shows it as a second, clearly-labelled upload slot right under the question's own image, with the identical frame/filter/alt-text/caption controls documented above:
| The reveal image on the shared screen and a player's phone | |
![]() |
![]() |
| Presentation | Player |
It's held to the same scraping protection as the question image: never broadcast to any client until that question's answer is actually revealed, so it can't be found early by opening dev tools on the socket.
The answer reveal image travels through the CSV too, in nine columns that mirror the question media ones exactly:
| Column | Example | Notes |
|---|---|---|
Answer Reveal Media |
yes |
The switch. no imports the row without the reveal image but keeps the settings in the file. |
Answer Reveal Media File |
eiffel-clear.jpg |
Original filename — how you recognise the row in a spreadsheet |
Answer Reveal Media ID |
38_aFdppJturby58AcE6Mlcs |
The R2 object id. This is the part that actually resolves. |
Answer Reveal Media Kind |
image |
image, audio or video (only image renders today) |
Answer Reveal Media Frame |
none |
none, polaroid, tv, slide, gallery, phone |
Answer Reveal Media Filters |
(blank) | Semicolon-separated, same catalog as Media Filters |
Answer Reveal Media Intensity |
0 |
0–100, used by blur/pixelate |
Answer Reveal Media Alt |
The Eiffel Tower, unobscured |
Screen-reader description |
Answer Reveal Media Caption |
(blank) | Printed on frames that have a caption slot |
Same rules as the question media columns: every one is optional, Answer Reveal Media ID is the one field you can't author by hand (add the image on the question, then Export CSV), and a missing reference gets the same amber "isn't on the server" notice and blocks Start Game until it's fixed.
This project follows WCAG 2.2 AA guidelines:
- Semantic HTML — proper heading hierarchy,
<button>,<label>,<ol>, etc. - Keyboard navigation — all interactive elements reachable, visible focus indicators
- Screen reader support —
aria-liveregions,aria-label,roleattributes - Reduced motion — respects
prefers-reduced-motionvia Framer Motion'sReducedMotionProvider - Color contrast — ≥ 4.5:1 for text, ≥ 3:1 for UI components
- Touch targets — ≥ 44×44 CSS pixels for interactive elements
- Forced colors — supports Windows High Contrast mode
Following the Inclusive Software Ruleset:
- R1.2/R1.4/R1.6 — Unicode-safe display names, no regex validation, chosen name always used
- R2.1 — No gender or demographic data collected (not needed for game mechanics)
- R5.x — Full WCAG 2.2 AA accessibility compliance
- R7.1/R7.4 — Inclusive terminology, non-blame error messages
- R9.5 — Session-scoped auth tokens (sessionStorage, not localStorage)
Player avatars use the Big Smile style (Bold Pop preset) from DiceBear, based on Custom Avatar by Ashley Seo, licensed under CC BY 4.0.
MIT



















































{ // ...existing config... "routes": [ { "pattern": "janedeck.yourdomain.com", "custom_domain": true } ] }