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.
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. Withentities.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.monsteris set; - takes
minDurationMsandhardCeilingMsfromtiming(the manager and scheduler may override); - clamps
maxPlayersinto 2..32 fromopts.maxPlayersorplayers.max; - delegates
_checkResolutiontowinCondition.onExit,_killPlayertowinCondition.onDeath, andfinalizetowinCondition.rank; - treats co-op modes (
mode === 'coop'orall-escape) as collective, recording placements as progress metadata and leaving the individualwinnerIdnull; - 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