A fully playable digital version of Qwirkle with online multiplayer via room codes (2-4 players) and local hotseat mode. Built with React + TypeScript frontend, WebSocket real-time sync, and a shared TypeScript game engine. Includes comprehensive testing with random fuzzing, heuristic agents, multiplayer integration tests, and a rules judge.
- Online Multiplayer: Create/join rooms with shareable codes, real-time gameplay, no login required
- Local Hotseat Mode: Pass-and-play on one device
- Server-Authoritative: All game logic runs server-side to prevent cheating
- Reconnect Support: Players automatically rejoin if they refresh or disconnect
- Deterministic Replay: Every game is reproducible from seed + action log
- Dark Theme UI: Polished interface with pan/zoom board
- 108 tiles: 6 colors (red, orange, yellow, green, blue, purple) x 6 shapes (circle, diamond, square, star, clover, cross) x 3 copies each.
- Starting player: Determined by who has the largest set of tiles sharing one attribute (color or shape) with no duplicates. Tie-break: lower seat index.
- Trading: You may trade 1 to N tiles where N = min(hand size, bag size). You draw replacements first, then traded tiles are shuffled back into the bag.
- Forced trade: If a player has no legal placements and the bag is not empty, they must trade.
- Endgame: When the bag is empty, players do not refill. The first player to empty their hand gets +6 bonus points. If all remaining players are stuck (no legal placements and bag is empty), the game ends immediately.
- Scoring: Each tile placed scores 1 point per tile in each line it participates in (horizontal and vertical). A single isolated tile scores 1 point. A Qwirkle (line of 6) scores 6 + 6 bonus = 12.
- Lines: Maximum length 6. A color line has all same color with distinct shapes. A shape line has all same shape with distinct colors. No duplicates allowed.
- Placement: All tiles played on a turn must be in the same row or column, must be contiguous (gaps allowed if filled by existing board tiles), and must connect to the existing board (except the first move).
qwirkle/
โโโ shared/src/ # Core game engine (TypeScript)
โ โโโ types.ts # All types, constants, helpers
โ โโโ rng.ts # Seeded PRNG (xoshiro128**)
โ โโโ engine.ts # Game engine, validation, scoring, replay
โโโ frontend/src/ # React + Vite frontend
โ โโโ hooks/useGame.ts # Game state management hook
โ โโโ components/ # UI components (Board, Hand, Controls, etc.)
โ โโโ App.tsx # Main app
โโโ backend/src/ # Express API server (optional)
โ โโโ server.ts # REST API for game management
โโโ simulation/src/ # Testing infrastructure
โ โโโ runner.ts # Fuzz test runner (random + heuristic)
โ โโโ random-agent.ts # Random legal move agent
โ โโโ heuristic-agent.ts # Greedy score-maximizing agent
โ โโโ judge.ts # Rules oracle / LLM judge
โ โโโ replay.ts # Deterministic replay CLI
โ โโโ regression-runner.ts # Regression test runner
โโโ tests/regressions/ # Auto-populated regression tests
โโโ package.json
- Node.js 20+
- npm
npm install
cd frontend && npm install && cd ..# 1. Build the frontend
npm run build
# 2. Start the server
npm run serverOpens at http://localhost:3001. Features:
- Online multiplayer via room codes
- WebSocket real-time sync
- Server-authoritative game logic
- Auto-reconnect on disconnect
Usage:
- Click "Create Room" โ share room code with friends
- Others click "Join Room" โ enter code
- All players click "Ready" โ host starts game
- Play turns in real-time!
npm run devOpens at http://localhost:5173/local.
- No server needed
- Pass device between players
- Pure client-side gameplay
npm run buildOutputs to dist/ for static hosting.
npm testnpm run test:allRuns: 200 random games + 100 heuristic games + 50 judge-checked games + all regressions.
| Command | Description |
|---|---|
npm run fuzz:random -- 1000 |
Run N random-agent games (default 100) |
npm run fuzz:heuristic -- 500 |
Run N heuristic-agent games (default 100) |
npm run fuzz:random:1000 |
1000 random games shortcut |
npm run fuzz:heuristic:500 |
500 heuristic games shortcut |
npm run judge -- 50 |
Run rules judge on N games |
npm run regressions |
Run all regression tests |
npm run replay -- <file.json> |
Replay a saved game and verify |
npm run test:multiplayer |
Multiplayer integration tests (2p) |
npm run test:multiplayer:3p |
Multiplayer tests (3 players) |
npm run test:multiplayer:4p |
Multiplayer tests (4 players) |
npx tsx simulation/src/runner.ts <agent> <numGames> <numPlayers> <startSeed>
# Example: npx tsx simulation/src/runner.ts random 1000 4 1Every game can be replayed from its seed and action log:
# In the web UI, click "Replay" to download a replay JSON
npx tsx simulation/src/replay.ts path/to/replay.json- Room Codes: 5-character codes (e.g., "ABCDE") from safe alphabet (no confusing chars)
- No Logins: Players identified by UUID + secret token stored in localStorage
- Auto-Reconnect: Tokens allow rejoining after disconnect/refresh
- TTL: Rooms expire after 2 hours of inactivity
REST Endpoints:
POST /api/rooms- Create room (returns code, player credentials)POST /api/rooms/:code/join- Join room (returns credentials)GET /api/rooms/:code/snapshot- Get current state (auth required)
WebSocket Events (Client โ Server):
SET_READY- Toggle ready statusSTART_GAME- Host starts gamePROPOSE_ACTION- Submit play/trade actionKICK_PLAYER- Host kicks playerLEAVE_ROOM- Disconnect
WebSocket Events (Server โ Client):
ROOM_SNAPSHOT- Initial state on connectROOM_UPDATED- Room state changed (player joined/ready/etc)GAME_STARTED- Game began, includes initial game stateACTION_APPLIED- Move executed, new game stateACTION_REJECTED- Invalid move (with reason)KICKED- You were removed from roomERROR- General error message
- All validation on server: Clients propose actions, server validates + applies
- Prevents cheating: Server is source of truth for game state
- State broadcast: After each action, server sends updated state to all clients
- Hand privacy: Each player only receives their own hand in snapshots
- Turn enforcement: Server rejects actions from non-current player
The simulation/src/multiplayer-test.ts runs automated tests:
- โ Invalid token rejection
- โ Turn enforcement (wrong player can't act)
- โ Reconnect preserves player state
- โ Full 2/3/4-player games with bot clients
- โ Duplicate name rejection
The engine checks these after every action:
- Tile count conservation: 108 tiles always distributed across bag + hands + board
- No duplicate tile IDs: Every tile has a unique ID tracked globally
- Board line validity: No line exceeds 6, no duplicates within lines, all tiles share one attribute
- Board connectivity: All board tiles form a single connected component
- Hand size: Never exceeds 6
- Score non-negativity: All scores >= 0
- Turn synchronization: Turn counter matches action log length
| Test | Count | Result |
|---|---|---|
| Random agent 2-player games | 1,000 | All clean |
| Random agent 3-player games | 200 | All clean |
| Random agent 4-player games | 200 | All clean |
| Heuristic agent 2-player games | 500 | All clean |
| Heuristic agent 3-player games | 100 | All clean |
| Heuristic agent 4-player games | 100 | All clean |
| Judge state checks (random) | 1,100 | 0 violations |
| Judge state checks (heuristic) | 537 | 0 violations |
| Regression tests | 1 | All passing |
| Multiplayer integration (2p) | 5 tests | All passing |
| Multiplayer integration (3p) | 5 tests | All passing |
| Multiplayer integration (4p) | 5 tests | All passing |
- Turn sync at endgame (turn_sync_endgame): The turn counter was not incremented when the game ended via the empty-hand + empty-bag path, causing a mismatch between
state.turnandstate.actionLog.length. Fixed by incrementing turn before settinggameOver. Regression test auto-generated.
| Metric | Random Agent | Heuristic Agent |
|---|---|---|
| Avg turns (2p) | 107.4 | 50.6 |
| Avg score (2p) | 151 / 150 | 205 / 207 |
| Avg turns (3p) | 106.3 | 50.3 |
| Avg turns (4p) | 105.4 | 49.2 |