mirror of
https://github.com/Such-Software/wownerogue.git
synced 2026-10-17 14:21:25 +02:00
A browser-based roguelike synchronized with crypto block timing. A run is a race against the next block: reach the exit before the block lands, or the house wins. The same codebase runs the solo dungeon, the social Tavern, and operator-selected PvP and race rulesets, with separate free and paid leaderboards. Self-hostable under the MIT License. See README.md to run it, docs/ARCHITECTURE.md for the design, and docs/DEPLOY.md to operate an instance.
296 lines
15 KiB
Markdown
296 lines
15 KiB
Markdown
# Match Mode: Real-Time Multiplayer Races
|
|
|
|
Match mode adds real-time multiplayer races. It reuses the shared-world engine (`Room` / `Occupant`),
|
|
the render kit, the payment and payout systems, and provably-fair seeding. Single-player and Tavern
|
|
modes are unaffected by it.
|
|
|
|
Match mode is opt-in. Nothing in this document is active unless `MATCH_ENABLED=true`.
|
|
|
|
## 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 |
|
|
|
|
`credits_prestige` spends the same `users.credits` balance used everywhere else; there is no separate
|
|
"fake credit" system. On an instance running `CREDITS_PAYOUTS_ENABLED=false` those credits are
|
|
unredeemable, so the economy is prestige-only.
|
|
|
|
Crypto races spend non-refundable race-entry tickets (`users.race_entries`, backed by rows in
|
|
`race_entry_lots`), so leaving a queue before a match starts never requires an on-chain refund.
|
|
|
|
## Lifecycle
|
|
|
|
`MatchScheduler` subscribes to the same block-count event as the solo `queueHandler`. Free and
|
|
value-bearing queues start differently on purpose.
|
|
|
|
- A free queue with at least the ruleset minimum of players drains into a `MatchRoom` immediately on
|
|
the current block event.
|
|
- A paid queue at canonical header H atomically creates a `pending` match and links the exact FIFO
|
|
queue rows and entrants. That transaction records a deterministic entrant-freeze commitment and
|
|
fixes canonical header `H + MATCH_PAID_ENTROPY_DELAY_BLOCKS` as its target; it does not read a
|
|
block hash or create a playable seed. A fresh post-commit daemon count is persisted while the
|
|
target is still in the future.
|
|
- Only a later, sufficiently confirmed block-count event can resolve that pending match. The
|
|
scheduler requests the canonical hash for the exact target twice, verifies the frozen IDs again,
|
|
derives the seed, and moves the match from `pending` to `starting` before ticket collection and
|
|
countdown. It re-reads the hash immediately after activation. Another height and server randomness
|
|
are not substitutes.
|
|
- If a queue is below the minimum player count, its members carry over and may leave until a freeze
|
|
claims their row.
|
|
- If a queue is empty, nothing happens.
|
|
|
|
A match ends when:
|
|
|
|
1. Its selected ruleset resolves (for example first exit, last alive, or all players resolved).
|
|
2. `ruleset.timing.blockDeadline` is enabled and the first advancing canonical header is observed
|
|
after both the match's start header and `MATCH_MIN_DURATION_MS` of active play. A duplicate block
|
|
poll, the start header itself, or a block arriving before the floor cannot end the match; after an
|
|
early block the match waits for the next advancing header.
|
|
3. The `MATCH_HARD_CEILING_MS` absolute ceiling expires.
|
|
|
|
Final placement and `winnerId` come from the selected ruleset's deterministic rank strategy. The
|
|
classic race uses exit/proximity ranking; last-alive and score-attack use their own competitive
|
|
rankings.
|
|
|
|
## Architecture
|
|
|
|
### Engine layer (`src/multiplayer/`)
|
|
|
|
- `MatchRoom.js` extends `Room`. Owns the shared deterministic dungeon, the shared monster when the
|
|
ruleset includes one, player life/death/finish state, treasure, and the block/timer deadline. Acts
|
|
as the synchronous tick resolver.
|
|
- `MatchEngine.js` is the server tick driver. It wraps a `MatchRoom` with an interval timer, calls
|
|
`resolveTick()`, and fires `onFinish` when the match ends.
|
|
- `MatchState.js` holds serialization helpers that produce broadcast and persistence shapes,
|
|
including the renderer-agnostic `gameState` consumed by `html/js/render/sceneModel.js`.
|
|
|
|
### Transport and lifecycle layer (`src/network/`)
|
|
|
|
- `matchQueue.js` persists per-economy queues (`free`, `credits_prestige`, `crypto_race`), handles
|
|
join and leave, and performs atomic credit and ticket-lot escrow.
|
|
- `matchScheduler.js` is the block-cadence scheduler that freezes, activates, and drains queues into
|
|
matches.
|
|
- `matchFairness.js` builds and verifies the paid entrant-freeze commitment and derives match seeds.
|
|
- `matchEconomyPolicy.js` is the single fail-closed admission contract for crypto matches, shared by
|
|
the queue, scheduler, mode advertisement, and environment validator.
|
|
- `matchManager.js` owns Socket.IO rooms, match lifecycle, broadcasts, reconnect grace, and
|
|
persistence.
|
|
- `matchPayoutService.js` handles crypto-race pot accounting, house-fee split, and the winner payout
|
|
record.
|
|
- `matchLeaderboard.js` posts match scores to the correct leaderboard.
|
|
- `tavernMatchBridge.js` forwards public match state to the tavern room for spectators.
|
|
|
|
### Rulesets (`src/game/rulesets/`)
|
|
|
|
Built-in match rulesets are `race`, `last-alive`, `score-attack`, and `coop-escape`; `solo-classic`
|
|
is solo-only. `resolveMatchRuleset()` fails closed to `race` for an unknown or solo-only id. See
|
|
[RULESETS.md](RULESETS.md).
|
|
|
|
### Persistence
|
|
|
|
Schema lives in `src/migrations/`:
|
|
|
|
- `022_match_mode.sql` creates `matches`, `match_entrants`, `match_events`, `match_queue_entries`,
|
|
and `race_entry_transactions`, adds `users.race_entries` and `payouts.match_id`.
|
|
- `029_match_ruleset_id.sql` records the ruleset that produced each match.
|
|
- `033_match_payout_liability.sql` adds `race_entry_lots` (per-payment funded ticket lots), links
|
|
queue entries and entrants to their exact lot, and makes the accepted match liability immutable.
|
|
- `040_paid_match_entropy_precommit.sql` adds `entropy_precommit_tip_height` and
|
|
`entropy_precommit_verified_at`, with a check constraint requiring the witnessed tip to be below
|
|
the target height for paid economies.
|
|
|
|
`032_solo_liability_invariants.sql` defines the unconditional unique index
|
|
`idx_payouts_one_per_match`.
|
|
|
|
Tables:
|
|
|
|
- `matches` is one race, including the `pending` entrant-freeze envelope, the verifiable seed
|
|
commitment, economy, pot and fee, and winner.
|
|
- `match_entrants` is per-player state, placement, score, and payment link.
|
|
- `match_events` is the replay, spectator, and audit feed.
|
|
- `match_queue_entries` is the persisted queue, for restart safety.
|
|
- `race_entry_transactions` is the race-entry ticket ledger.
|
|
|
|
### Match seed verification
|
|
|
|
Paid matches use the `future-block-freeze-v2` freeze and `future-chain-block-v2` seed versions. At
|
|
canonical header H the server commits the exact economy, ruleset, sorted durable FIFO queue-entry
|
|
IDs, configured delay, and target `H + MATCH_PAID_ENTROPY_DELAY_BLOCKS` into a `pending` row before
|
|
requesting any hash. After that transaction commits, a fresh strict daemon count must prove the
|
|
target does not yet exist; the witnessed tip and verification timestamp are durable and immutable. A
|
|
delayed commit that reaches the target is cancelled and every exact escrow anchor is refunded without
|
|
reading the target hash. Legacy v1 and otherwise unverified pending freezes are refunded at startup.
|
|
|
|
Activation waits until the target itself has the configured confirmation depth, where the target
|
|
counts as confirmation one. The scheduler reads count, header, count, header, and requires both
|
|
exact-height headers to carry the same canonical hash. It re-reads that target immediately after the
|
|
activation transaction and before ticket collection, gameplay, or client notification; an unavailable
|
|
or changed hash aborts and refunds the match. The playable seed is SHA-256 derived only from the v2
|
|
freeze commitment and the exact target hash. The persisted public proof records the delay, required
|
|
confirmations, minimum activation tip, and post-commit witness. Confirmation settings are safety
|
|
metadata, not extra seed material.
|
|
|
|
Monero-family `getblockcount` returns a count while `get_block(height)` uses zero-based header
|
|
heights. The scheduler normalizes the event exactly once (`observedHeaderHeight = blockCount - 1`).
|
|
Every persisted or disclosed `blockHeight` is the actual header height passed unchanged to
|
|
`get_block`, so a published height resolves to the published hash with no off-by-one translation.
|
|
|
|
Free matches derive their seed from the current block hash when one is available. When it is not,
|
|
they fall back to a server-random seed recorded as derivation version `server-random-v1`. Paid
|
|
economies have no such fallback: a paid match without a `future-chain-block-v2` proof is retained
|
|
rather than started.
|
|
|
|
This construction proves the documented seed derivation and the deterministic dungeon. It does not
|
|
prove honest input handling, block-source independence, payout delivery, or resistance to a malicious
|
|
chain producer or operator. Do not describe match mode as equivalent to the solo two-party fairness
|
|
protocol without an independent review.
|
|
|
|
## Configuration
|
|
|
|
Set these in `src/.env`. The full annotated template is `src/.env.example`; a match-only excerpt is
|
|
`src/.env.match.example`.
|
|
|
|
```bash
|
|
# Enable match mode (default false)
|
|
MATCH_ENABLED=false
|
|
|
|
# Max players per match, also bounded by the selected ruleset (built-ins max at 8)
|
|
MATCH_MAX_PLAYERS=4
|
|
|
|
# Server tick interval in ms
|
|
MATCH_TICK_MS=250
|
|
|
|
# Server-selected gameplay ruleset; clients cannot override it.
|
|
# race | last-alive | score-attack | coop-escape
|
|
MATCH_RULESET_ID=race
|
|
|
|
# Paid freeze target distance and activation confirmation depth.
|
|
# Production requires explicit safe integers from 2 through 100.
|
|
MATCH_PAID_ENTROPY_DELAY_BLOCKS=2
|
|
MATCH_PAID_ENTROPY_CONFIRMATIONS=2
|
|
|
|
# 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 (required; 0 <= fee < 100)
|
|
MATCH_HOUSE_FEE_PERCENT=5
|
|
|
|
# Required per-player funded ticket value in atomic units
|
|
MATCH_ENTRY_FEE_ATOMIC=5000000000
|
|
|
|
# Crypto admission requires both explicit match gates and an outer atomic-unit payout cap
|
|
MATCH_CRYPTO_RACE_ENABLED=false
|
|
MATCH_PAYOUTS_ENABLED=false
|
|
MATCH_PAYOUT_MAX=50000000000
|
|
```
|
|
|
|
Crypto races additionally require:
|
|
|
|
- A paid instance (`GAME_MODE=PAID_SINGLE` or `PAID_CREDITS`, or the equivalent `PAYMENT_MODES`).
|
|
- At least one race-entry product with `grants.race_entries: N` and `grants.race_entry_value_atomic`
|
|
exactly equal to `MATCH_ENTRY_FEE_ATOMIC`. The product price must cover `N * MATCH_ENTRY_FEE_ATOMIC`;
|
|
confirmation records a durable lot per payment.
|
|
- A competitive single-winner ruleset. `coop-escape` is rejected because it has no
|
|
winner-takes-pot split contract.
|
|
|
|
## Client surfaces
|
|
|
|
- `/match.html` with `html/js/matchClient.js` is the dedicated race page: entry-mode selector,
|
|
ruleset catalog, race HUD, render-style picker, and touch d-pad.
|
|
- `/tavern.html` adds a Multiplayer Arena panel with the entry-mode selector and an active-match list
|
|
with Watch buttons. Tavern spectators receive `tavern_match_tick` and `tavern_match_end` and render
|
|
races through the render kit. A tavern viewer who is a player in the match keeps movement controls
|
|
instead of a spectator camera.
|
|
|
|
## Socket events
|
|
|
|
Server emits:
|
|
|
|
- `game_mode_info` includes `modes.match { enabled, economies, maxPlayers, activeRuleset, rulesets }`.
|
|
- `match_queue_joined` and `match_queue_left` are queue responses.
|
|
- `match_joined`, `match_start`, `match_tick`, `match_end` are the race lifecycle for players.
|
|
- `match_rejoined` restores a reconnecting player; `match_settlement_pending` reports a result whose
|
|
payout is still settling.
|
|
- `player_forfeit` and `player_death` are in-match participant updates.
|
|
- `match_error` is the benign catch-all failure response for any match request.
|
|
- `tavern_match_list`, `tavern_match_tick`, `tavern_match_end` are the public race feed for tavern
|
|
spectators.
|
|
|
|
Client sends:
|
|
|
|
- `match_queue` `{ economy, action: 'join' | 'leave' }`
|
|
- `match_move` `{ dx, dy }`
|
|
- `match_reconnect` to rejoin an active or just-finished match after a reconnect.
|
|
- `match_leave` to forfeit an active race.
|
|
- `tavern_match_list` to request active races.
|
|
|
|
Identity for every match request is resolved from the connection, never from the payload.
|
|
|
|
## Leaderboards
|
|
|
|
- Free competitive matches create synthetic `games` rows with `game_mode = 'FREE'` for the Pleb
|
|
board.
|
|
- Credits/prestige competitive matches are served from the authoritative `matches` and
|
|
`match_entrants` rows by `GET /api/leaderboard?board=prestige`.
|
|
- Crypto competitive matches create synthetic `games` rows with `game_mode = 'PAID_CREDITS'` for the
|
|
generic self-hosted Hall of Champions board. The operated product profiles
|
|
(`such-play-wow-prestige`, `such-monerogue-stagenet`) keep this economy disabled and exclude all
|
|
match-generated rows from Champions.
|
|
|
|
The public leaderboard endpoint accepts `board=pleb|champions|prestige`. Omitting `board` defaults to
|
|
`pleb`; unknown or blank values return 400 rather than silently combining or translating economies.
|
|
|
|
An individual win requires the durable match winner and placement #1 to agree; reaching the exit
|
|
alone is never treated as a win in last-alive or score-attack. `coop-escape` is collective, records
|
|
no individual `winnerId`, and keeps its results in `matches` and `match_entrants`. It is excluded
|
|
from individual boards.
|
|
|
|
## Tests
|
|
|
|
```bash
|
|
cd src
|
|
npx jest test/match
|
|
```
|
|
|
|
The suites cover the room, queue, scheduler, payout service, and leaderboard, plus block-deadline,
|
|
authoritative leaderboard-result, co-op exclusion, durable finish, crash recovery, client
|
|
settlement, and finished-room reconnect regressions.
|
|
|
|
## Operational notes
|
|
|
|
- `MATCH_RULESET_ID` is trusted server configuration. Unknown or solo-only values fall back to
|
|
`race`.
|
|
- Crypto race payouts reuse the existing `payouts` table, batch processor, and retry service.
|
|
- `MATCH_PAYOUTS_ENABLED=false` blocks new crypto admission. Queue entries persisted for a
|
|
now-unavailable economy are cancelled and refunded transactionally during queue initialization.
|
|
Matches whose pot and liability were already accepted still create their durable payout from the
|
|
immutable admission snapshot; the global payout master switch may pause dispatch without erasing
|
|
it.
|
|
- The unconditional unique index `idx_payouts_one_per_match` prevents a replacement payout in every
|
|
status, including `failed` and `needs_review`.
|
|
- Only confirmed-payment ticket lots with the current exact entry value can enter a crypto race, so
|
|
legacy or admin-granted aggregate tickets cannot create a payout liability. The exact lot is held
|
|
on queue join and consumed in the same transaction that accepts the immutable match liability.
|
|
- Players who disconnect during a race have a 30-second grace period to reconnect before an AFK kill.
|
|
Match mappings are retained for a further 30 seconds after a finish so late reconnects still
|
|
resolve the result.
|
|
- Paid freeze and start block events are serialized. A crash before activation leaves a resumable
|
|
`pending` freeze; a crash after activation is handled by abandoned-match recovery. Orderly shutdown
|
|
stops the scheduler, waits for its block task, and transactionally cancels and refunds every
|
|
still-pending freeze before shutting down match transport.
|
|
|
|
## See also
|
|
|
|
- [TAVERN_AND_MULTIPLAYER.md](TAVERN_AND_MULTIPLAYER.md)
|
|
- [RULESETS.md](RULESETS.md)
|
|
- [PRODUCTION_DISCLOSURES.md](PRODUCTION_DISCLOSURES.md)
|