Files
wownerogue/docs/RULESETS.md
T
jwinterm fad2a695bd Wownerogue
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.
2026-07-27 08:23:01 -04:00

12 KiB

Rulesets

Gameplay is a data object. The match engine reads a Ruleset instead of hardcoding "first to the exit wins," so additional modes (PvP last-alive, score attack, co-op escape) are configuration rather than new subsystems.

The Ruleset object

src/game/rulesets/Ruleset.js exposes defineRuleset(spec), which normalizes a partial spec into a complete, frozen, range-clamped ruleset. An empty spec yields the classic single-dungeon escape.

Field Meaning Default Clamp
id, label Identity and display name custom / Custom
mode Coarse family: solo, race, pvp, coop race
world.difficultyPreset Key passed to getDifficultyConfig normal
world.maps Reserved for multi-level matches; the match engine generates one map 1 1..10
entities.monster Spawn the shared monster true
entities.monsterCount Reserved count field 1 (0 when monster is false) 0..8
entities.pvpCombat Stepping onto a living rival strikes them down false
players.min / players.max Entrant bounds; min is lowered to max if it exceeds it 1 / 4 1..32
winCondition.type first-to-exit, last-alive, high-score, all-escape first-to-exit
timing.tickMs Engine tick interval 250 50..5000
timing.minDurationMs Active-play floor before a block deadline may fire 20000 0..600000
timing.hardCeilingMs Absolute match length ceiling 240000 1000..3600000
timing.blockDeadline End on the first advancing canonical header true
economy.model free, credits_prestige, crypto_race free
economy.houseFeePercent House rake for pot modes 0
economy.payoutMultipliers escape, escapeWithTreasure 2, 3
metadata Free-form descriptor bag (description is surfaced to clients) {}

Unrecognized winCondition.type and economy.model values fall back to the defaults rather than propagating, and every nested section is frozen.

Built-in rulesets

src/game/rulesets/registry.js ships the catalog and the helpers getRuleset(id, overrides), listRulesets(), listMatchRulesets(), resolveMatchRuleset(id), and rulesetFromMatchOpts(opts).

id mode players win condition notes
solo-classic solo 1 first-to-exit Catalog descriptor for the single-player escape. Never selectable for a multiplayer queue.
race race 2..8 first-to-exit Classic escape race; race difficulty preset; crypto_race economy.
last-alive pvp 2..8 last-alive pvpCombat enabled, monster present, normal preset.
score-attack race 1..8 high-score Escaping does not end the match; score decides.
coop-escape coop 2..8 all-escape pvpCombat disabled; ends when every survivor escapes.

listMatchRulesets() filters out mode === 'solo', giving the server, scheduler, and clients one stable multiplayer allowlist. resolveMatchRuleset(id) fails closed to race for unknown or solo-only ids. rulesetFromMatchOpts() maps the legacy MatchRoom option bag (economy, difficultyPreset, maxPlayers) onto a race ruleset; the legacy variant label does not change the win condition.

Win conditions as strategies

src/game/rulesets/winConditions.js implements each condition as a small strategy object that MatchRoom consults at three decision points:

onExit(room, id)   a player reached the exit: end the match? set a winner?
onDeath(room)      a player died: is the match over?
rank(room)         final ordering (best-first) for placement; competitive modes also set winnerId
  • FIRST_TO_EXIT: the first player out wins and the match ends immediately. Ranking orders winner, then finished, alive, Manhattan distance to the exit, treasure, fewer moves, id.
  • LAST_ALIVE (PvP): reaching the exit means you survived, not that you won; the match ends when at most one contender remains in play and at least two started. Ranking orders last-standing, escaped survivor, then later deaths above earlier ones (deathOrder), then treasure, distance, id. With entities.pvpCombat, moving onto a living rival's tile strikes them down and the attacker holds position.
  • HIGH_SCORE: no instant win. Score rewards escaping, carrying treasure, move efficiency, and proximity to the exit for players still inside when the match resolves.
  • ALL_ESCAPE (co-op): ends when every player still alive has escaped (or all have died) and reuses the race ordering for placements.

resolveWinCondition(type) returns FIRST_TO_EXIT for any unknown type.

Engine integration

MatchRoom (src/multiplayer/MatchRoom.js) resolves its ruleset with the precedence opts.ruleset (an explicit spec), then opts.rulesetId plus optional opts.rulesetOverrides, then rulesetFromMatchOpts(opts). It then:

  • spawns the shared monster only when entities.monster is set;
  • takes minDurationMs and hardCeilingMs from timing (the manager and scheduler may override);
  • clamps maxPlayers into 2..32 from opts.maxPlayers or players.max;
  • delegates _checkResolution to winCondition.onExit, _killPlayer to winCondition.onDeath, and finalize to winCondition.rank;
  • treats co-op modes (mode === 'coop' or all-escape) as collective, recording placements as progress metadata and leaving the individual winnerId null;
  • exposes a client-safe summary (id, label, mode, description, winCondition, pvpCombat).

Terminal players stay in occupants so spectators and final results can render them, but corpses and escaped heroes are pass-through: co-op and score attack require several players to cross the single exit tile on different ticks. Living, in-play players remain solid, and an occupant with no match state stays solid rather than failing open into an overlap.

Same-tick intents resolve in a committed order derived from sha256(seed:tick:entrantSlot), never packet arrival order. An entrant eliminated earlier in that order cannot move or attack afterwards; the attempt emits move_failed with reason eliminated_before_action.

MatchEngine (src/multiplayer/MatchEngine.js) is a thin, transport-agnostic tick driver that calls room.resolveTick() on an interval and reports results to its owner.

The single-player engine (src/game/game.js) does not consume rulesets; solo-classic is a catalog descriptor for parity and display only.

Selection and safety

Multiplayer rulesets are trusted server configuration, selected with MATCH_RULESET_ID (default race). Clients receive the active ruleset and the safe catalog metadata for display, but cannot select or author executable rules. Match mode as a whole is gated by MATCH_ENABLED, which must be the literal string true to be active.

Crypto payout admission requires single-winner semantics. matchEconomyPolicy accepts a ruleset only when its mode is neither solo nor coop, its win condition is one of first-to-exit, last-alive, or high-score, and players.max is at least 2; otherwise admission is denied with unsupported_crypto_ruleset. environmentValidator applies the same check at boot and additionally requires MATCH_MAX_PLAYERS to fall inside the ruleset's player contract. Match persistence and transport require at least two entrants, so the effective minimum is raised to 2 even for rulesets that are also valid as a one-player challenge.

src/migrations/029_match_ruleset_id.sql persists the selected ruleset on each match (matches.ruleset_id, default race) with a partial index on finished matches, keeping newer built-ins auditable without widening the money and economy keys.

Timing and block deadlines

matchScheduler resolves timing with the precedence: explicit constructor argument, then MATCH_TICK_MS / MATCH_MIN_DURATION_MS / MATCH_HARD_CEILING_MS, then the ruleset's timing, then the built-in defaults.

When timing.blockDeadline is true, each advancing canonical header is offered to MatchManager. An active room expires with reason block_deadline only when the header is strictly later than the room's start header and its active-play minDurationMs floor has elapsed. Strict height comparison protects rooms created by the same header, and duplicate polls are idempotent. With blockDeadline: false, the hard-ceiling watchdog owned by MatchManager is the only clock-based end.

For paid multiplayer, the ruleset id is part of the durable entrant freeze at the freeze header: buildPaidEntrantFreeze commits version, freeze height, target height, delay, economy, ruleset id, and the canonical queue entry ids into a single hash. deriveFutureBlockMatchSeed then accepts only the exact committed future header, after its required confirmation wait, and mixes the freeze commitment into the seed. Changing the active ruleset cannot reinterpret a pending entrant set or trigger a server-random fallback.

Economy per mode

A ruleset's economy block (model, houseFeePercent, payoutMultipliers) maps onto the existing payout paths: gameModeManager for single player (multiplier on entry fee) and matchPayoutService for pot modes (winner takes the pot minus the house fee). See MONETIZATION.md and MATCH_MODE.md.

World presets and per-chain tuning

src/game/difficultyConfig.js holds the world half of a ruleset. getDifficultyPreset selects a preset from DIFFICULTY_PRESET or an explicit override; with neither set it returns normal for free play (GAME_MODE=FREE and PAYMENTS_ENABLED not true) and casino otherwise. Presets tune dungeon size and openness, monster start distance, speed, aggressiveness and vision, treasure placement, and a target house win rate.

Block time enters through NETWORK_TUNING, which folds a per-chain level count onto the resolved preset so a run's cumulative length scales with the chain's mean block time. Each level stays base-sized with a fair monster; the house edge comes from racing the block across the whole descent rather than from one oversized map or a faster-than-fair monster.

Chain Mean block time Levels
GRIN ~1 min 1
XMR ~2 min 1
LTC ~2.5 min 2
WOW ~5 min 2
BTC ~10 min 3

Precedence in getDifficultyConfig runs low to high: preset, per-chain tuning, environment variables (DUNGEON_LEVELS, DUNGEON_*, MONSTER_*), then explicit overrides passed by the caller. Set NETWORK_TUNING_DISABLED=true to skip the per-chain step. The level count drives multi-level solo runs; MatchRoom consumes only the dungeon geometry from the resolved config and generates a single shared map.

Files

src/game/rulesets/Ruleset.js             defineRuleset + WIN / ECONOMY enums
src/game/rulesets/winConditions.js       FIRST_TO_EXIT / LAST_ALIVE / HIGH_SCORE / ALL_ESCAPE
src/game/rulesets/registry.js            built-ins + match resolver and catalog helpers
src/multiplayer/MatchRoom.js             consumes the ruleset; PvP combat; delegates win/lose/rank
src/multiplayer/MatchEngine.js           tick driver
src/network/matchScheduler.js            advancing-header deadline dispatch
src/network/matchManager.js              active-play floor + idempotent expiry
src/network/matchEconomyPolicy.js        crypto admission contract (player bounds, win semantics)
src/network/matchFairness.js             paid entrant freeze + future-block seed derivation
src/game/difficultyConfig.js             world presets and per-chain level tuning
src/migrations/029_match_ruleset_id.sql  persists the selected ruleset on each match