Files
wownerogue/docs/BALANCE_SIM.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

123 lines
6.3 KiB
Markdown

# 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:
```bash
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 from `DIFFICULTY_PRESET` and the process `CRYPTO_TYPE` (default `WOW`), so the
per-network columns differ only in block time, not in level count. Set `CRYPTO_TYPE` explicitly 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
1. Presets declare a `targetHouseWinRate` but measured rates land below it, and the edge varies by
roughly 15 points across chains for an identical dungeon purely from block time.
2. 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.
3. 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.