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.
5.1 KiB
Multi-level dungeons
A run descends through maxDepth levels instead of playing a single map. Depth is the pacing lever
that lets the block timer supply the house edge on slow chains without an oversized dungeon or a
monster that moves faster than the player. See BALANCE_SIM.md for the balance
model behind that choice.
Mechanic
- Each level is a normal preset-sized dungeon with its own entrance, exit, and a freshly placed monster.
- Reaching a non-final exit takes the stairs down: a new level is generated, the player is placed at its entrance, a new monster spawns, and fog of war resets. Descending is not a win.
- Reaching the final level's exit escapes the dungeon and wins the run.
- Treasure exists only on the final level. Intermediate levels are a pure race to the stairs, so the treasure payout multiplier requires descending all the way and getting back out.
maxDepth = 1is the single-level case:levelSeedreturns the master seed unchanged at depth 1, so a one-level run's layout is exactly what the master seed alone produces.- The monster does not take its turn on the arrival move of a descent.
afterPlayerMovereturns early on adescendresult, so the player is never caught by a monster they have not yet seen.
Provable fairness across the descent
Every level in a run regenerates from the one committed seed. levelSeed(masterSeed, depth) in
provablyFair.js returns the master seed verbatim for level 1 and salts it as <seed>:L<depth> for
deeper levels. Any level can be reproduced with:
DungeonGenerator.regenerateFromSeed(levelSeed(seed, depth), cryptoType, generationOptions);
The Game constructor commits a layout fingerprint for every level of the run up front, before
play, in gameProof.layoutFingerprints (one entry per depth, each carrying depth,
fingerprintVersion, generatorVersion, and the SHA-256 fingerprint). gameProof.layoutFingerprint
remains as a level-1 alias for older verification clients. Because the manifest is complete at
creation time, a run that ends early still exposes a full, immutable record of the advertised
descent.
Each level is re-fingerprinted as it is actually generated during play, and a mismatch against the committed manifest throws. Generator drift inside a live process becomes a hard failure rather than an unverifiable served layout.
src/game/fairnessVerifier.js replays the whole manifest: it rejects a manifest whose depths are
incomplete or out of order, regenerates each level from its per-depth seed, nulls the treasure on
every non-final level to match play, and reports per-level fingerprint matches alongside entrance,
exit, treasure, and dimensions.
Configuration
maxDepth comes from difficultyConfig.levels, set per chain by NETWORK_TUNING in
src/game/difficultyConfig.js. Depth scales with block time so that a slower chain gives a longer
run before the block lands:
| Chain | Approx. block time | Levels |
|---|---|---|
| GRIN | ~1 min | 1 |
| XMR | ~2 min | 1 |
| LTC | ~2.5 min | 2 |
| WOW | ~5 min | 2 |
| BTC | ~10 min | 3 |
Resolution order for the level count, lowest precedence first: preset (no levels key, so the
effective floor is 1) → NETWORK_TUNING for the chain → customOverrides.levels → the
DUNGEON_LEVELS environment variable, which always wins and is clamped to a minimum of 1.
NETWORK_TUNING_DISABLED=true skips per-network tuning entirely, which leaves the run at a single
level unless DUNGEON_LEVELS or an explicit override supplies a count. An unknown cryptoType is
also left untuned and therefore single-level.
Dungeon size and monster behaviour are not varied by depth: applyNetworkTuning folds only the
level count onto the resolved difficulty preset, and every level uses the same preset dimensions and
the same fair monster settings.
Where it lives
| Concern | Location |
|---|---|
| Level generation and descent | src/game/game.js: _generateLevel(depth), _descend(), the exit branch in movePlayer returning event: 'descend', and depth/maxDepth in getState |
| All-level fingerprint manifest | src/game/game.js: _buildLayoutFingerprintManifest(), getProofContext() |
| Per-level seed | src/game/provablyFair.js: levelSeed() |
| Level count | src/game/difficultyConfig.js: NETWORK_TUNING, applyNetworkTuning(), DUNGEON_LEVELS |
| Verification | src/game/fairnessVerifier.js |
| Server wiring | src/network/socketHandlers.js: the 'descend' branch in afterPlayerMove (no game over, no monster move on arrival) |
| Event emission | src/game/movementManager.js: emits game_event with event, depth, maxDepth after the game_update |
| Client state | html/js/core/gameState.js: clears accumulated map, explored, and visible tiles on a depth change so the previous level does not ghost through the new one |
| Client feedback | html/js/network/socketHandlers.js: the game_event handler renders the descend message and on-screen banner with Level N / M |
| Sim | src/sim/simBots.js: the explorer bot clears its known-tile set and remembered objectives on a depth change |
| Tests | test/multiLevel_ultra.test.js, test/networkTuning_ultra.test.js |