Adds a complete block-cadence multiplayer race mode alongside solo and tavern.
Disabled by default; enable with MATCH_ENABLED=true.
Highlights:
- New shared engine: MatchRoom, MatchEngine, MatchState (transport-agnostic, unit-tested).
- Per-economy queues: free, credits_prestige, crypto_race with atomic escrow/refund.
- MatchScheduler drains queues on every new block and starts 2–32 player races.
- MatchManager handles Socket.IO lifecycle, server-authoritative movement, reconnect grace,
forfeit/AFK handling, and end-of-match persistence.
- Crypto races use race-entry tickets (grants.race_entries) to avoid on-chain refunds; winner
payout is queued through the existing batch payout/retry service.
- Leaderboard integration: free → Pleb, prestige → /api/leaderboard?board=prestige,
crypto → Hall of Champions.
- Tavern integration: Queue for Race panel, active race list, and live spectator rendering.
- Dedicated /match.html client with economy selector, race HUD, and post-match scoreboard.
- Migration 022 adds matches, match_entrants, match_events, match_queue_entries,
race_entry_transactions, users.race_entries, and payouts.match_id.
Docs:
- New docs/MATCH_MODE.md with full architecture, config, events, and operational notes.
- Updated README.md, docs/TAVERN_AND_MULTIPLAYER.md, docs/TODO.md, src/.env.example,
and src/.env.
Tests: 40 new unit tests across 7 suites, all passing.
Known follow-ups for real-world validation:
- Full integration test with Postgres + Socket.IO.
- Server boot check with MATCH_ENABLED=false (default) to ensure solo/tavern remain unaffected.
- Browser render-kit verification in /match.html and tavern spectator.
- Crypto-race payout end-to-end with real wallet-rpc.
7.5 KiB
Match Mode — Race/PvP Multiplayer
Status: Implemented — all five milestones complete. Single-player and Tavern modes are untouched.
Match mode adds real-time multiplayer races to Wownerogue. It reuses the existing shared-world
engine (Room/Occupant), the render kit, the payment/payout systems, and provably-fair seeding.
Supported economies
| Economy | Entry cost | Payout | Leaderboard |
|---|---|---|---|
free |
None | None | Pleb board |
credits_prestige |
Credits | None (prestige only) | Prestige board |
crypto_race |
Race-entry ticket | Winner-take-pot (minus house fee) | Hall of Champions |
Unredeemable credits are the same users.credits balance running with CREDITS_PAYOUTS_ENABLED=false;
there is no separate "fake credit" system. Crypto races use non-refundable race-entry tickets
(users.race_entries) so players never need an on-chain refund when leaving a queue before a match
starts.
Lifecycle: a race every block
The existing solo queueHandler already reacts to new crypto blocks. MatchScheduler subscribes to
the same block event and drains per-economy queues:
- If a queue has ≥2 players at block H, all queued players are placed into a new
MatchRoomand the race starts immediately. - If a queue has 1 player, they carry over to block H+1 and may leave at any time.
- If a queue is empty, nothing happens.
A race ends when:
- The first player reaches the exit.
- The next block is detected (with a
MATCH_MIN_DURATION_MSfloor to prevent degenerate short races). - The
MATCH_HARD_CEILING_MSabsolute ceiling expires.
If no one escaped, living players are ranked by proximity to the exit; dead players are ranked after them by progress, treasure, and moves.
Architecture
Engine layer (src/multiplayer/)
MatchRoom.js— extendsRoom. Owns a shared deterministic dungeon, shared monster, player life/death/finish state, treasure, and the block/timer deadline. Serves as the synchronous tick resolver.MatchEngine.js— server tick driver. Wraps aMatchRoomwith asetIntervaltimer, callsresolveTick(), and firesonFinishwhen the match ends.MatchState.js— serialization helpers that produce broadcast/persistence shapes, including the renderer-agnosticgameStateconsumed byhtml/js/render/sceneModel.js.
Transport / lifecycle layer (src/network/)
matchQueue.js— persisted per-economy queues (free,credits_prestige,crypto_race), join/leave, atomic credit/ticket escrow.matchScheduler.js— block-cadence scheduler that drains queues into matches.matchManager.js— Socket.IO rooms, match lifecycle, broadcasts, reconnect grace, persistence.matchPayoutService.js— crypto-race pot accounting, house-fee split, winner payout record.matchLeaderboard.js— posts match scores to the correct leaderboard.tavernMatchBridge.js— forwards public match state to the tavern room for spectators.
Persistence
See migration src/migrations/022_match_mode.sql:
matches— one race, provably-fair seed, economy, pot/fee, winner.match_entrants— per-player state, placement, score, payment link.match_events— replay / spectator / audit feed.match_queue_entries— persisted queue for restart safety.race_entry_transactions— race-entry ticket ledger.users.race_entries— hot-read ticket balance.payouts.match_id— links winner payout to match.
Provably fair
Each match commits to a SHA-256 seed_hash at creation. The seed is revealed on finish so anyone
can regenerate the exact dungeon and verify the hash. This mirrors the existing solo
games.dungeon_seed / games.seed pattern.
Configuration
Add to src/.env (documented in src/.env.example):
# Enable match mode (default false)
MATCH_ENABLED=false
# Max players per race (2–32)
MATCH_MAX_PLAYERS=4
# Server tick interval in ms
MATCH_TICK_MS=250
# Minimum race duration before the next block can end it
MATCH_MIN_DURATION_MS=20000
# Hard ceiling after which a race is force-ended
MATCH_HARD_CEILING_MS=240000
# Credits cost for prestige-only races
MATCH_CREDITS_COST=1
# Crypto race house fee percent (default 5)
MATCH_HOUSE_FEE_PERCENT=5
# Optional: override per-player entry fee atomic amount.
# If unset, uses the existing per-game price (singleGamePrice / DIRECT_GAME_PRICE).
# MATCH_ENTRY_FEE_ATOMIC=5000000000
# Enable the crypto race-entry ticket economy
MATCH_CRYPTO_RACE_ENABLED=false
Crypto races also require:
- A paid instance (
GAME_MODE=PAID_SINGLE/PAID_CREDITSor equivalentPAYMENT_MODES). - Race-entry ticket products in the credit/cosmetic catalog with
grants.race_entries: N.
Client surfaces
/match.html— dedicated race queue / race view with economy selector and race HUD./tavern.html— Queue for Race panel and Active Races list with Watch buttons. Tavern spectators receivetavern_match_tick/tavern_match_endevents and render races through the existing render kit.
Socket events
Server emits:
game_mode_info— now includesmodes.match { enabled, economies, maxPlayers }.match_queue_joined/match_queue_left— queue responses.match_joined/match_start/match_tick/match_end— race lifecycle for players.tavern_match_list/tavern_match_tick/tavern_match_end— public race feed for tavern spectators.
Client sends:
match_queue{ economy, action: 'join' | 'leave' }match_move{ dx, dy }match_leave— forfeit an active race.tavern_match_list— request active races.
Leaderboards
- Free races create synthetic
gamesrows withgame_mode = 'FREE'for the Pleb board. - Credits/prestige races populate the
prestige_leaderboardview and are served byGET /api/leaderboard?board=prestige. - Crypto races create synthetic
gamesrows withgame_mode = 'PAID_CREDITS'for the Hall of Champions board.
Tests
Run the match-mode test suites in isolation:
cd src
npx jest ../test/match
Current result: 40 passing across 7 suites:
matchRoom.test.jsmatchQueue.test.jsmatchScheduler.test.jsmatchLeaderboard.test.jsmatchPayoutService.test.jstavernMatchBridge.test.jsmatchReconnect.test.js
Files added / modified
New:
src/migrations/022_match_mode.sqlsrc/multiplayer/MatchRoom.js,MatchEngine.js,MatchState.jssrc/network/matchQueue.js,matchScheduler.js,matchManager.js,matchPayoutService.js,matchLeaderboard.js,tavernMatchBridge.jshtml/match.html,html/js/matchClient.jsdocs/MATCH_MODE.md,src/.env.match.example- 7 test files
Modified:
src/network/socketHandlers.js— initializes match services and wires socket events.src/network/identityService.js— addsuserForId().src/game/gameModeManager.js— advertisesmodes.match; grants race-entry tickets on product payment.src/payments/productGrants.js— recognizesgrants.race_entries.src/index.js—/api/leaderboard?board=prestige.html/tavern.html— race queue / spectator UI.src/.env.example,docs/TAVERN_AND_MULTIPLAYER.md,docs/TODO.md,README.md.
Operational notes
- Match mode is opt-in and inert unless
MATCH_ENABLED=true. - Crypto race payouts reuse the existing
payoutstable, batch processor, and retry service. - The unique partial index
idx_payouts_one_per_matchprevents double payouts. - Race-entry tickets are held in escrow on queue join and consumed only when a match starts.
- Players who disconnect during a race have a 30-second grace period to reconnect.