A two-laptop, camera-driven Rock-Paper-Scissors–esque game with an AI gamemaster.
- Each player sits at their own laptop; the webcam tracks their hand sign (rock / paper / scissors) locally in the browser via MediaPipe.
- Gemini is the gamemaster brain — it narrates the match live, calls out moves, and applies dynamic balance "twists".
- ElevenLabs gives the gamemaster a voice (TTS).
- Before each match, players spend 10 tokens on powerups. Each round the winner +20 coins, the loser −20 coins. Matches are best of 5.
- On match win, the winner's photo is captured and turned into a themed AI drawing, used as their leaderboard avatar.
- Solana is the coin/trophy ledger; MongoDB stores the leaderboard.
Two browser clients ↔ one server-authoritative Node game server (Express + Socket.IO). Clients only ever send their detected move (and later, a winner photo); the server owns all game state, so both screens stay in sync and neither laptop can cheat. See the plan for the full design.
client/ React + Vite + zustand — camera, hand-tracking, powerup shop, leaderboard UI
server/ Express + Socket.IO — authoritative GameRoom state machine + service wrappers
shared/ TypeScript types + rules — the client/server contract (win-matrix, event names)
npm install # installs all three workspaces
cp .env.example server/.env # optional — game runs on mocks with no keys
npm run dev # starts server (:3001) + client (:5173)Open http://localhost:5173 in two browser tabs (or two laptops). Create a room on one, join with the code on the other, and play.
Two-laptop / camera note:
getUserMediaonly works onlocalhostor HTTPS. To play across two physical laptops, build the client (npm run build), let the server serve it, and expose one HTTPS URL withngrok http 3001(or deploy to Render/Fly.io). Both laptops open that one URL. Over plainhttp://<lan-ip>Chrome silently blocks the camera — use the keyboard1/2/3fallback.
Everything runs end-to-end on mocks out of the box. Turn services "real" one at a time via env
vars (server/.env):
| Service | Default | Make it real |
|---|---|---|
| Gemini | canned narration | set GEMINI_API_KEY |
| ElevenLabs | text only, no audio | set ELEVENLABS_API_KEY + ELEVENLABS_VOICE_ID |
| Image-gen | photo passthrough | set STUB_IMAGE_GEN=false (+ Gemini key) |
| MongoDB | in-memory | set MONGODB_URI |
| Solana | in-memory ledger | set USE_REAL_SOLANA=true (+ RPC + secret key) |
npm run dev— server + client with hot reloadnpm run build— build shared → client → server for productionnpm run typecheck— typecheck every workspacenpm test— run server unit tests (rules + resolver)
The whole stack runs containerized — nginx (reverse proxy) → app → redis — with zero changes to the native workflow above:
docker compose up --build # play at http://localhost:8080
npx ngrok http 8080 # share it, same as beforeSecrets (secrets/, .env) are never baked into the image — the compose
file mounts them read-only at runtime and overrides the file-path env vars
with in-container paths. Game rooms are deliberately pinned to ONE app
instance (authoritative in-process state machine with live timers); the
scale path is the Socket.IO Redis adapter + sticky sessions, for which the
redis service and REDIS_URL are already provisioned.