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.
6.3 KiB
Balance simulation and per-network calibration
Wownerogue is a block-timed roguelike: a single-player run is bounded by the blockchain, and the house edge comes from whether the player clears the dungeon before the next block lands. The deadline is a random variable and every supported chain has a different block time, so difficulty is measured rather than estimated. This document covers the simulator that measures it and the per-network tuning it feeds.
Timing model
A run starts on block N and ends as a loss if the player has not escaped by the time block N+1
arrives (src/network/socketHandlers.js, checkGamesTimeout). Movement is real time within that
window, with a 100 ms per-move cooldown (src/game/movementManager.js). A grace period,
GAME_START_GRACE_MS (default 2000 ms, 0 disables), suppresses only the degenerate case where a
block lands in the same instant the game starts; random block timing is otherwise preserved intact.
Block arrival is a Poisson process, so the run window is exponentially distributed with mean equal to the network's block time. Entering just after a block yields a near-full interval; entering just before yields seconds. That variance is the core mechanic.
For a run that escapes at wall-clock time T:
P(survive) = P(block lands after T) = exp(-T / meanBlockTime)
house_win(run) = caught or stuck ? 1 : (1 - exp(-T / meanBlockTime))
The random deadline is integrated out analytically, so no Monte Carlo over deadlines is required.
Mean block times come from src/chain/chainProfile.js (meanBlockTimeMs):
| Chain | Mean block time |
|---|---|
| GRIN | 1 min |
| XMR | 2 min |
| LTC | 2.5 min |
| WOW | 5 min |
| BTC | 10 min |
Unknown or unset chain ids resolve to a fallback profile of 12 decimals and a 120000 ms block time,
so a mis-set CRYPTO_TYPE degrades to XMR-shaped timing instead of crashing.
The simulator (src/sim/)
The harness drives the real engine (src/game/game.js: same dungeon generator, monster AI and
movement as production) with headless bots until each run reaches a terminal outcome, then reports
the measured distribution. It never reimplements game rules; it is a thin layer over Game.
| File | Role |
|---|---|
simulate.js |
Harness and CLI. Per preset: escape, treasure, caught and stuck rates; completion-move and completion-time percentiles; measured house-win per network via the identity above. |
simBots.js |
Bot policies. omniscient-* know the full map and take BFS-optimal paths; explorer-* play under fog of war and reset their knowledge on descent. Neither policy evades the monster. |
pathfind.js |
Dependency-free 4-connected BFS distance field and downhill step, so navigation stays auditable and separate from game rules. |
calibrate.js |
Per-network solver: scales dungeon size by √(blockTime / 120000) for pacing, then binary-searches monster speed to a target house-win. |
Run:
node src/sim/simulate.js --runs 200 --bot explorer-greedy --presets normal,casino
node src/sim/calibrate.js --target 0.70 --preset casino
simulate.js accepts --runs, --bot, --presets, --nets, --cadence, --movecap and
--json. Bots are omniscient-escape, omniscient-greedy, explorer-escape and
explorer-greedy (default).
--cadence (ms per move, default 320) is the moves-to-seconds assumption the block-time math rests
on; sweep it when calibrating against real player pacing.
Two properties of the harness matter when reading its output:
- Because neither bot evades the monster, both over-count catches, so reported house-win is an upper band rather than a point estimate. A skilled human wins more often.
- One simulated dungeon configuration is scored against every network in
--nets. The dungeon itself is built fromDIFFICULTY_PRESETand the processCRYPTO_TYPE(defaultWOW), so the per-network columns differ only in block time, not in level count. SetCRYPTO_TYPEexplicitly to simulate a specific chain's level count.
test/sim_ultra.test.js guards the harness structurally: it asserts that bots reach terminal
outcomes and that rates are well formed and partition the runs. It asserts nothing about balance
values, which are expected to move.
Findings
- Presets declare a
targetHouseWinRatebut measured rates land below it, and the edge varies by roughly 15 points across chains for an identical dungeon purely from block time. - Dungeon size is not a clean edge lever. A larger map lengthens the run (timer edge up) but gives the player more room to dodge (monster edge down), so house-win self-cancels near 60% until the map becomes an unplayable slog. Size is useful as a pacing knob, not an edge knob.
- A fast monster is both unpleasant and ineffective on slow chains. On WOW's 5-minute blocks a single dungeon clears in about a minute, so even a 2.2x monster caps house-win near 64% because the timer barely bites.
The lever that follows from this is run length: descend N preset-sized levels, each with a fair
monster, so cumulative run time supplies the edge and levels scale with block time. monsterSpeed
is deliberately not used as a per-network lever. See docs/MULTI_LEVEL.md for the mechanic.
Per-network tuning (NETWORK_TUNING)
src/game/difficultyConfig.js folds per-network tuning onto the resolved preset. Precedence, low to
high: preset, network tuning, environment variables, explicit overrides. Operator settings such as
DUNGEON_*, MONSTER_* and DUNGEON_LEVELS therefore win over the tuning, and
NETWORK_TUNING_DISABLED=true removes it entirely.
The tuning sets only levels. Dungeon size and monster parameters stay at the preset values.
| Chain | Levels |
|---|---|
| GRIN | 1 |
| XMR | 1 |
| LTC | 2 |
| WOW | 2 |
| BTC | 3 |
Level counts are conservative relative to the raw pacing formula (block time divided by roughly 75 seconds per level), because a deep descent inside one block deadline is rarely completable and makes reach-the-exit-to-win unintuitive.
Game reads the resolved count as maxDepth. Reaching a non-final exit descends to a fresh level
and emits a descend event; only the final level's exit ends the run as a win, and treasure is
placed only on the final level. The client handles game_event (including descend) in
html/js/network/socketHandlers.js and surfaces depth progress to the player.