Removes operator-private material from the shipped tree, corrects documentation
that had drifted from the code, deletes dead code, and applies a consistent
prose style.
Private material moved out of the public tree (docs/private/, git-ignored):
- the wallet keyfile recovery runbook and its helper scripts, which together
published the mainnet wallet file path, the service account, backup snapshot
filenames, and the wallet RPC listener state. The helper duplicated the same
constants, so relocating the document alone would not have removed them.
- the stagenet parallel-candidate runbook and its fixtures.
- the dated production-state record, the internal remediation plan, and the
TODO list. None are reference documentation for an outside reader.
Redacted or genericised:
- operator home-directory paths in shipped scripts and units. The asset build
script now takes KENNEY_ASSETS_DIR instead of a hard-coded path.
- private LAN addresses in tests, env examples and deploy docs, replaced with
RFC 5737 documentation addresses.
- the default alert sender and the notification hub example.
- the Monero wallet unit now matches the Wownero one: a dedicated service
account and a config file, instead of the operator's login account with the
wallet password in argv where any local user can read it from /proc.
Ignore rules now cover every .env variant rather than the bare name, plus local
agent state. The deployment docs instruct operators to create .env.mainnet and
.env.stagenet, which the exact-name rule would have allowed to be committed.
Documentation corrected against the code: per-chain dungeon level counts, the
premium unlock rule, Tavern chat scope, backup retention, the chain-profile
fallback, the environment template location, and several sections describing
shipped work as pending.
Dead code removed: configPersistence, rpccalls, a duplicate vendored ROT.js
bundle, the unused tile-picker overlay, the unreachable legacy game-size path
and its vestigial parameter, spectator stubs that shadowed the real
SpectatorManager, and commented-out debugging. nip98 now imports the shared
nostr loader rather than duplicating it.
Prose style: em-dashes removed throughout in favour of colons, semicolons and
commas; comments restated as present-tense descriptions of the code rather than
narration of past changes.
127 suites / 1015 tests pass.
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