Even with the anon_token status query, Smirk one-click stayed disabled after a reload: the server
ROTATES the session token on every resume (sessionManager), so the initial SmirkAuth.checkStatus()
raced the rotation and 403d with the now-stale token, leaving _isLinked=false. Re-run checkStatus
right after the fresh token is persisted (session_token / session_resumed) via _refreshSmirkLinked,
so _isLinked reflects the current token before the user buys — restoring the Smirk payment popup.
1) Double invoice: the "Buy Selected Package" button carries BOTH id=buy-credits-btn AND
class=mode-option, so a click fired the generic .mode-option handler (no data attrs -> defaulted
to single_game -> a spurious 1-credit invoice) AND its own buy-credits handler (the real pack).
Guard the generic handler to ignore #buy-credits-btn and intentless clicks.
2) Smirk one-click payment never opened: /api/auth/smirk/status 403d, leaving _isLinked=false so
onPaymentCreated skipped the Smirk path. The BOLA-hardened query matched socket_id AND anon_token,
but socket_id is VOLATILE (changes every refresh) so it returned 0 rows after a reload. Match on
the anon_token alone — the actual ownership secret — same protection, no refresh fragility.
Marking only the adjacent walkable cells was not enough — a dark wall-gap/void still read as a
hole you could walk into. Now every visible floor cell gets a soft cyan pip, so the WALKABLE
ground is explicit everywhere; a dark gap with no pip is unambiguously not floor. Keeps the
stronger pulsing outline on the immediately-adjacent walkable cells.
The Kenney iso wall tiles are single-cell segments that leave triangular gaps between slabs, which
read as doorways but are solid wall. Rather than chase perfect wall continuity, flag the cells the
player can actually step to: a soft pulsing cyan diamond outline on each orthogonally-adjacent
walkable+explored floor cell. A reliable "you can go here" cue regardless of wall rendering.
Iso revealed the WHOLE map from move 0: unexplored (dark) cells fell back to the floor tile
(_tileUrl(dark) -> tiles.floor) and rendered as floor. Now skip dark cells entirely (unexplored =
void), and dim explored tiles by the lightGrid brightness (globalAlpha, floor to 0.16) so the
remembered area fades to black with distance — parity with the 2D modes and 3D (which already
skips dark + uses THREE distance fog).
Zoom: the wheel handler based its step on a hardcoded 1.7, but iso defaults to 1.0 — so the first
scroll (either direction) jumped up to ~1.7 and read as "always zoom in". Base the step on the
CURRENT effective zoom via SP._defaultZoom() instead. Same mode-aware default now used by the camera.
Iso occlusion: tall walls (184px) hide the floor/openings behind them (inherent to iso painter
order). Add a cutaway — walls between the camera and the player (higher x+y, within ~3 cells) draw
at 0.28 alpha so you can see yourself and the openings right in front of you.
Two iso bugs from reusing the tavern renderer for the SP dungeon:
- Character always faced down/SW: sceneFromGameState hardcoded facing:"down", so the renderers
ignored their movement-inferred facing (truthy default won). Now facing defaults to null (SP
server sends none) so iso/3D infer it from movement; facingDelta(null)=down keeps tiled fine.
- "Copy of himself at the origin": the iso renderer drew EVERY entity via _charFrame, so the
entrance feature rendered as a male character at the start. Features/items now draw as pack tiles
(stairs/chest) or a glyph; monsters get a red tint so they do not read as a second player.
- Iso & 3D character faced the wrong way: the SP game never sends player.facing (only the tavern
does) so it defaulted to down/SW. Both renderers now INFER facing from the movement delta they
already track (explicit e.facing still wins for the tavern).
- 3D could break the game: wrap ThreeRenderer construction (createRenderer) and the _animate RAF
body in try/catch — a 3D failure now falls back to Tiled / stops the loop instead of throwing.
- ASCII walls/floors were near-invisible (legend colours are near-black, tuned for tile fog): use
brighter glyph colours for wall/floor in ASCII (features keep their own colours).
Root cause of iso rendering fully black: `.rotdis canvas { max-height:100%; object-fit:contain }`
(styling meant for the legacy ROT canvas) also applied to the render-kit canvas. SP only overrode
max-width. A canvas TALLER than the host (iso: ~2445px vs ~900px host) got object-fit-shrunk to
~0.37x while the CAMERA translate is computed in buffer space — so the player landed far off-screen
(black). Tiled/ascii canvases usually fit the host, so they escaped it. Fix: override max-height:none
+ object-fit:fill so the buffer maps 1:1 and the camera transform alone controls scale/position.
Iso default zoom set to 1.0 (its 84px tiles are large); tiled/ascii keep 1.7.
Three mode-switch issues:
- Picking 3D gave "original": SP._ensureRenderer used the sync createRenderer, which falls back to
Tiled when THREE (lazy CDN load) is not ready. Now it kicks off RK.ensureThree and re-mounts as
real 3D once loaded (renders Tiled meanwhile so it is not blank).
- ASCII torches were inert glyphs: they now draw a warm flickering radial glow (actual light source)
with the glyph at full brightness, driven by a throttled ~22fps RAF (cancelled on destroy).
- Switching to iso could show black while the pack images load: tiles now draw a legend-coloured iso
diamond placeholder until the real image is ready, so the dungeon shape + camera always read.
3D built every tile including unexplored, revealing the whole map. Now: _buildTiles skips
unexplored (dark) cells so they never render (void), and the scene gets THREE distance Fog
(black, 14->32) which — since the camera follows the player — fades explored tiles to black away
from you. Background set to pure black to match. Entities are already fog-gated at the scene
level (features on seen, monster in-view). Net: unexplored hidden, explored fades with distance,
no map-edge leak — matching the 2D modes.
3D is a paid rung and should behave like the other modes. Rework the THREE camera to FOLLOW the
player instead of framing the whole level: canvas now sizes to the host (setSize updateStyle),
the ortho frustum is a fixed player-centered zoom (span 8) with host aspect, and _animate re-targets
the camera to the player world position every frame (keeps the iso (8,9,8) offset; glides smoothly
since the player mesh lerps). SP._applyCamera no longer CSS-transforms the 3D canvas (it fills the
host and THREE owns the camera). Handles host resize via _fitToHost.
The ASCII renderer never set focusPoint, so switching to it mid-game left the SP camera with no
focus → scale-only transform pinned the grid to the corner and the explored area sat off-screen
(black). AsciiRenderer.render now sets focusPoint on the player cell (sticky), and the camera
fallback covers name==="ascii" too (cell-based, same as tiled). Iso already reports focusPoint.
3D drives its own THREE camera + sizes its canvas to the level, so _applyCamera now scale-to-FITS
the 3D canvas into the host (centered, no player-follow) instead of applying a broken zoom.
The SP camera still intermittently pinned the whole level to the top-left at game start: the
per-frame _applyCamera read renderer.focusPoint as null (a start-of-game race, or a tick reading
a just-(re)created renderer instance before its first render) and fell back to a scale-only
transform. Add a fallback: when focusPoint is absent, derive it from SP._lastState.player (tiled
coords, cell=24) so the camera always has a target and centers the player. Also read host dims
via clientW/H||offsetW/H for resilience.
Explored-but-not-visible tiles rendered at a flat 0.25 brightness — a hard-edged grey block
that outlined the explored map (and its edge). Now explored memory dims AND fades toward black
with distance from the player, so the remembered area melts into the dark with no revealing
edge. Also set the render host background to pure #000 so the area beyond the map edge is the
SAME black as an unexplored in-bounds tile (previously #0a0c0f vs #000 — a shade seam that
traced the boundary). Currently-visible lighting unchanged.
Red pulse: _fxOnPlayerMoved fired a full-screen red FX.flash whenever a monster was within 3
tiles (throttled 550ms) — the monster usually IS within 3 tiles, so it pulsed on nearly every
step. Removed it; the monster is already on-screen, so proximity needs no flash. FX.flash is now
reserved for genuine one-shots (win/lose).
Descend feedback: taking the stairs only logged a tiny chat line. Add showEventBanner() — a big
centered pop-in/hold/fade banner over the game view (pointer-events:none, auto-removed) fired on
descend ("LEVEL n / N") and treasure ("TREASURE SECURED"), with a soft one-shot colored flash.
Chat lines kept as the log trail.
Camera: mount the render-kit host with inset:0 (fill #game-display) instead of
min(100%,78vh)+translate(-50%) centering. #game-display is grid-bounded (container is
100vh/overflow-hidden), so filling it gives the camera reliable clientWidth/Height every
frame — fixes the intermittent "player stranded in a top band, black below" off-centering.
Torches (in-game): drop the pack torch SPRITE composite — several sheets place it off-center/
rotated, reading as misaligned. Keep wall base + procedural RK.fx flame: a clean wall-mounted
torch that looks right in every pack.
Wait animations: shrink the juice bursts (18-24 -> 6-7 particles, spread 1.8-2.4 -> 0.6-0.7)
and remove the red death/caught bursts, so a grab/escape reads as a tight glint on the hero
instead of an explosion spraying onto the wall torches.
Text: the block-QUEUE wait runs drawPaymentAnimation (printed "Awaiting payment") and the
payment wait runs the epic scene (printed "Awaiting next block") — the labels were backwards.
Both now derive the label from Game._awaitingPayment so it always matches the real state.
Juice: add a tile-agnostic particle-burst helper (window.FX.sparkle) and fire one-shot bursts
on the dramatic beats — gold on treasure grab, green on escape, red on death/caught — in BOTH
animations. Particle-based, so it is identical and safe in every pack; no-op if FX is absent.
Note: the ROT waiting display is independent of the render-kit pack (always the classic tileset),
so these animations work in all packs but do not visually swap to match a non-original pack.
Torches replace WALL cells adjacent to floor (dungeon.js), but the Tiled renderer based the
torch on a floor tile and drew only a procedural flame — so they read as flames floating on
the floor. Base the dungeon torch on `wall` and draw the pack torch sprite over it, then RK.fx
animates the flame/glow on top: a proper wall-mounted sconce. Fire fixtures now composite
base + sprite + flame (hazards keep base + pulse, no sprite).
The render-kit "waiting room" replaced the beloved scripted drama (player races for a
pulsating treasure, grabs it, escapes or is devoured by a monster pack, death shockwave +
ESCAPED! celebration, randomized variants) with a static lit room — a big downgrade. Restore
BOTH ROT waiting animations (block-wait AND awaiting-payment, selected via Game._awaitingPayment)
by hiding the render-kit host and letting the ROT display own the waiting screen. Remove the
now-unused SP.showWaiting/hideWaiting/waitingScene bridge code. The pack/mode still applies in
actual gameplay, which is what matters.
The consolidated entry modal Free Play + Next block path emitted a bare join_queue with no free
intent, so handleGameQueue ran full payment eligibility and (on a mixed paid+free instance) fired
show_payment_options — the old "Choose How to Play" modal appeared on top of an already-queued free
game. Thread the free flag end-to-end, mirroring auto_start{free}: join_queue{free} -> handleJoinQueue
-> handleGameQueue (skips _checkPaymentEligibility when free+freePlayEnabled, tags the queued entry
free) -> queueManager.addPlayer stores free -> startGamesForWaiting/startGameImmediately pass
{ forceFree } into processGameStart, so the block-landing start takes no credit/payment and lands on
the Pleb board. Ranked queued path (plain join_queue) unchanged. Suite 62/435 green.
The "Free game — dropping in…" (begin) and "Starting game…" (onGameStart) console lines were
permanent log entries, so a running game sat next to text reading as if it were perpetually
starting. Tag both as transient: entry-progress is removed when onGameStart fires (kept during
the real block-wait for the queued variant); the game-start "Starting game…" line is removed once
Game.startGame succeeds. The server status_update "Game started! Escape before the next block!"
remains the standing confirmation.
The next-block wait now draws a small lit "mining room" Scene through the ACTIVE renderer
(RK.SPGame.showWaiting) — floor/walls/torches in your chosen pack (Tiled/Iso/3D), camera
centered, torches flickering via the FX loop — with the "Mining the next block" text as an
overlay. So the wait inherits your tiles/mode instead of being the plain ROT animation.
Falls back to the ROT WaitingScreenAnimator if the kit is unavailable. SP.render clears the
overlay when the real game starts.
Roguelike layout on desktop: sidebar left, play area filling the top, and the message log
(row 3, 132px) + command input (row 4) span full-width BELOW the game. The chat panel flips
from a left-column (border-right) to a bottom bar (border-top); the sidebar now scrolls if
its controls are tall. Mobile (<=820px) keeps its existing single-column stack untouched.
Per the menu redesign: PLAY on top, then Loadout (Character + Style/render-picker), Explore
(Watch + Tavern), Progress & account (Shop + Anim + the hud-chip toolbar). Pure reorder +
group headers — every id/handler preserved, no grid change. Bottom-console (#3) is next.
- Modal: "Safe — full block window" overstated it (blocks arrive randomly, Poisson). Now
"Start fresh · blocks land at random" / "Jump in · race the time left".
- Camera: was snapping to the player every frame (jerky on canvas resize / level teleport).
Now glides (lerp 0.3) and only snaps on a big jump; snap-centers on new-game start.
Replaces the timing-only modal (which then triggered the server payment/options modal) with
a single dialog: a timing toggle (Next block / Right now) + stakes cards (Free / Ranked +
Buy credits). Each card emits the FINAL intent so the server never re-prompts:
- Free -> auto_start {free:true} / join_queue (free:true skips the options modal)
- Ranked + credits -> auto_start / join_queue (uses a credit)
- Ranked no-credits / Buy -> PaymentUI.show() (the real payment, not a redundant choose)
Nothing starts until a card is clicked, so the game never renders behind the modal.
The player-centered camera snapped the dungeon to the top-left corner after a while: the
renderer nulled focusPoint on a transient player-less frame, so the transform lost its
translate. Fix: focusPoint is now sticky (keep the last if the player is momentarily
absent), and the SP bridge re-applies the camera every frame via its own RAF loop, so a
canvas resize or a stale frame can no longer break centering.
- FOG: sceneFromGameState only emits exit/entrance/treasure/items once their cell is EXPLORED,
and the monster only while IN VIEW — no more exit stairs leaking through the fog.
- DESCEND FEEDBACK: reaching a non-final exit descends (multi-level) and used to silently dump the
player at a new entrance (read as a bug). Server now sends depth/maxDepth on game_event; the
client shows "Stairs down! Level N of M — keep going". Also lowered level counts (WOW 4->2,
BTC 8->3, XMR 2->1) — clearing 4-8 levels in one block was unwinnable; kept modest until proper
multi-level calibration + UX.
- CAMERA: the host filled the very-tall #game-display so centering put the player low; it's now a
bounded viewport (min(100%,78vh)) centered in #game-display, so the player stays on-screen-centered.
Suite 62/435 green.
The `original` pack was built on the Kenney sheet, so it didn't match the classic look the
ROT instructions + waiting screens use. Point it at html/tiles.png (32px, the legacy
tileMap sheet) with grid coords = legacy pixel coords / 32: floor [0,0], wall [2,1], door
[1,0], torch [32,1], treasure [30,0], plus the classic @ hero [2,0] and ~ goblin [31,1].
The tiled renderer now prefers a pack's player/monster TILE over the character sprite in the
DUNGEON (tavern keeps sprites), so the Original pack is fully classic. Suite 62/435 green.
The render-kit dungeon host was a flex item in a centered flex container (.rotdis) with
sibling canvases, so it got shrunk + shoved into a corner; and .rotdis canvas has
max-width:100% !important which clamped the camera canvas. Fix: host is now
position:absolute inset:0 (fills #game-display, which is position:relative), and the
canvas overrides max-width via setProperty(..., important). Dungeon now renders centered
on the player in the game area.
Commit to the render kit as THE single-player renderer (legacy = hard fallback only). Fixes
the two things that broke it:
- CAMERA: each renderer now exposes focusPoint (the player's pixel pos); the SP bridge mounts
a self-sizing 16:10 viewport host and CSS-transforms the canvas to centre the player at a
readable zoom (wheel to zoom). Renderer-agnostic (tiled/iso/3d). No more whole-level-in-a-corner.
- MONSTER: the tiled renderer drew a hardcoded red dot; now it draws the Goblin character sprite
(char-goblin, the same path the tavern draws characters), red orb only as last-resort fallback.
Re-enabled: _drawGameScreen prefers RK.SPGame; spRenderPicker ENABLED. Suite 62/435 green.
NEXT (still owed): make the splash + both waiting animations render through the kit too, so
they use the chosen pack/mode instead of the ROT text screens.
Previously the timing-choice modal (Start Now / Wait for Next Block) only appeared for
paid-credits players; free players got a silent immediate drop-in. Now it shows whenever
the game would start instantly:
- Server getGameModeInfo exposes freePlayEnabled (was server-only) so the client knows
free play is available even on a paid instance.
- Client (inputHandler) gates the modal on free-play-available OR paid-credits-with-credits;
the modal is purely a TIMING pick (auto_start vs join_queue) — payment method is unchanged.
- Modal cost text reflects free play.
Both waiting animations are untouched: they render on the ROT display via WaitingScreenAnimator
using the base GameTiles, independent of the render-kit packs (which are disabled for SP anyway),
so they work regardless of visualization style. Suite 62/435 green.
The default->char-villager coercion (grey-circle guard) applied to legacy-rot too, so an
un-customized single-player showed a villager sprite with no way back to the classic hero.
Exclude legacy-rot from the coercion: default/color appearances now fall through to
RenderEngine's original @ tile fallback (chars/skins still draw their sprites; the tavern
canvas path keeps the villager to avoid grey dots). Updated the splash-legend test to the
new tile-based legend. Suite 62/435 green.
The render-kit SP dungeon shipped without a player-centered camera or entity sprites,
so it drew the whole level tiny in a corner with a red-dot monster. Disable it and fall
back to the proven legacy renderer (original tiles, real monster, centered view) until
the kit has a camera + sprites. RK.SPGame/spRenderPicker kept but dormant (one flag to
re-enable). Splash legend: draw every icon (incl. the player) as a uniform grid tile —
no avatar overlay — so the four rows align AND the player shows the original hero tile.
The migration that makes the pack ladder pay off IN the dungeon, not just the tavern.
The SP game now draws via RK (Tiled/ASCII/Iso/3D + unlocked packs) instead of the
legacy ROT display.
- Adapter: sceneFromGameState now consumes the CLIENT state shape (sparse {y:{x:v}}
tile maps, not just server arrays) via maxDims(). Verified + unit-tested.
- Bug fix: RK.sceneFromGameState was never actually exposed (only RK.scene.*), so
matchClient + tavern spectator were silently no-op-ing. Aliased onto RK.
- Bridge (spGameRenderer.js, RK.SPGame): mounts an RK renderer in a dedicated host
inside #game-display, toggles the legacy ROT canvases, zoom-enabled. Game._draw
GameScreen prefers it and FALLS BACK to RenderEngine on any failure, so the live
game can never blank. ROT still draws the splash/win/lose text screens.
- Picker (spRenderPicker.js): homepage mode + pack selector, gated by entitlements
(free = Tiled/Original + ASCII; ladder unlocks Iso/3D/packs), rebuilds on identity.
- index.html loads the render-kit renderers + the bridge/picker.
Bundled with the catalog ladder (migration 028): free Original bare pack, ladder
1/5/10/20/40/50. Deploying makes the free tavern bare (by design) AND the dungeon
render-kit-ed. v1 renders the whole explored level + zoom (player-centered camera is
the next polish). Suite 62/435 green; adapter smoke-verified.
Nails down the premium unlock system per the design pass:
- FREE `original` topdown pack (NEW): the game's bare tiles — floor/wall/door/
torch/features only, no furniture, so the free tavern reads plain (the intended
"reason to unlock"). Registered in assetPacks (RK.THEMES.original) as the free
default topdown pack; plain ASCII stays free (accessibility).
- Loyalty ladder (lifetime credits PURCHASED, cumulative, NOT deducted → buying to
play also unlocks cosmetics): Interior 1, skins 5, iso 10, +tilepack 20, +iso 40,
3D 50. Each row is data (tilepack OR skin) → operator appends more at any rung.
DEFAULT_CATALOG (entitlements.js) + migration 028 + MONETIZATION.md ladder table.
- Tests updated to the new thresholds (catalogEntitlements_ultra, directEntryCredit_ultra).
Note: this reprices roguelike-interior from free → 1cr, so once deployed the free
tavern becomes bare (by design). Deploy is bundled with the single-player render-kit
migration so the free-tier change lands with its payoff. Suite 61/432 green.
- Splash legend (screenManager.js): every row now uses a uniform 2-cell icon slot at
a shared left edge and a single fixed text column, so the four icons + descriptions
line up regardless of glyph width (was: per-row span math + leading-space hacks →
ragged columns, player sprite offset).
- Character creator was already on the homepage but read like an admin control: the
button showed the DEFAULT character label "Operator". Renamed the default to
"Adventurer" (charCustomize.js), switched the icon 🧍→🎭, and gave it a clear
"Create / customize your character" tooltip so it announces itself.
- README: rewrite Difficulty section (per-network LEVELS ∝ block time, WOW=5min,
multi-level descent, corrected casino size); document game_event + depth in the
Socket.IO table.
- NEW docs/BALANCE_SIM.md: the block-timed edge math (exponential deadline), the
headless sim over the real engine, what it proved (size self-cancels, fast monster
fails on slow chains), and the per-network NETWORK_TUNING model.
- NEW docs/MULTI_LEVEL.md: descent mechanic, vault-only treasure, per-level
provably-fair seeding, config, file map, open polish.
- NEW docs/RENDER_PACKS.md: modes-vs-packs, the RK.fx layer, iso/tiled/zoom/cat.
- docs/ARCHITECTURE.md: mark the block-time-difficulty gotcha RESOLVED; add render
kit + balance/pacing sections and cross-links.
- docs/TODO.md: 2026-07 done/pending section.
The fair, on-theme edge lever for slow chains (from the calibration work): a run
now descends `maxDepth` levels instead of one map, so cumulative run length makes
the block-timer bite WITHOUT a giant single dungeon or a cheating-fast monster.
Engine (game.js):
- depth/maxDepth; _generateLevel(depth) builds each level (fresh dungeon + monster
+ FOV), _descend() steps down. Reaching a non-final exit descends (event
"descend", stays active); only the final exit escapes (event "escaped").
- Treasure lives only in the vault (final level) — intermediate levels race to the
stairs. Single-level (maxDepth=1) is byte-identical to before.
- Provably fair per level: levelSeed(masterSeed, depth) (provablyFair.js) — level 1
= master seed, deeper levels salted; the whole descent regenerates from the one
committed seed. Verified: game levels == regenerateFromSeed(levelSeed(seed,d)).
Pacing (difficultyConfig.js): NETWORK_TUNING now sets LEVELS ∝ block time (GRIN 1,
XMR 2, LTC 2, WOW 4, BTC 8) — replacing the size-scale hack; size + monster stay at
the fair preset. DUNGEON_LEVELS operator override; NETWORK_TUNING_DISABLED → 1 level.
Wiring: socketHandlers afterPlayerMove gets a "descend" branch (no game-over, no
monster move on arrival). Client gameState.js clears the accumulated map/explored/
visible tiles on a depth change so the old level does not ghost through the new one.
Sim: explorer bot resets fog-of-war on descent so multi-level sims stay honest
(full re-calibration of the level counts is the pending follow-up).
Tests: multiLevel_ultra (descent, vault-only treasure, per-level provably-fair,
single-level default, DUNGEON_LEVELS) + networkTuning rewritten for the levels model.
Suite 61 suites / 432 green; end-to-end 4-level WOW run descends 3x then escapes.
WOW meanBlockTimeMs 120000 -> 300000. Measured 5.01 min/block over 1000 blocks on
the live daemon; the code comment claiming it was "corrected to 2 min" was backwards.
WOW is a SLOW chain, closer to BTC than XMR for calibration.
Per user feedback ("fast monster feels like cheating") + what the sim then proved:
a supernaturally fast monster doesnt even WORK on slow chains — WOW (5-min) caps
~64% house-win even at a 2.2x monster on a 111x55 map, because a single dungeon
clears in ~1 min << the block so the timer (the fair, on-theme edge) barely bites.
So NETWORK_TUNING drops the monster-speed lever entirely and keeps size∝√blockTime
only (monster stays at its fair preset speed). WOW sizeScale 1.0->1.6 for 5-min blocks.
Consequence: slow chains (WOW/BTC) run honest-but-under-target on the timer until
MULTI-LEVEL DEPTH lands — the real edge lever (cumulative run length across levels
without any single map being an unplayable slog). Deb (WOW/normal) now gets a bigger,
more block-appropriate dungeon (45x22 -> 72x35) with a fair monster.
meanBlockTimeMs has no runtime consumer yet (metadata) so the WOW fix is behavior-safe
today; it corrects the sim + future block-bounded features. Suite 60/428 green.
Solves the two gaps the sim measured (edge undershot targets AND swung ~15pts
across chains from identical dungeons because cryptoType was a dead parameter).
Data-driven design (src/sim/calibrate.js, rewritten): the size-probe showed
dungeon SIZE self-cancels as a house-edge lever — bigger map lengthens the run
(timer edge up) but gives more room to dodge (monster edge down), flat ~60% until
monstrous. So split the concerns:
• SIZE ∝ √blockTime → PACING only (consistent run-length fraction of the block;
GRIN sprint .. BTC epic), applied to every preset. Bot-robust.
• MONSTER speed → the house EDGE lever (monotonic), solved per network for
the casino/70% target only.
difficultyConfig.js: NETWORK_TUNING table + applyNetworkTuning(), folded into
getDifficultyConfig with precedence preset → tuning → env → customOverrides (so
operator DUNGEON_*/MONSTER_* still win; NETWORK_TUNING_DISABLED kills it).
Wired verification (explorer bot, 160 runs, 320ms): WOW/XMR/LTC/BTC/GRIN all land
in a ~66-76% band around target (was 48-65%). BTC 48%->73% via 112x56 + 2.2x
monster — on 10-min blocks the timer barely bites a 30s run, so the monster carries
the edge (caps ~64% maxed: a real property of block-timed play).
CAVEAT baked into the config comment: sim bots do not evade, so monsterSpeed is a
STARTING POINT to retune with live telemetry; the sizeScale half is bot-robust.
Deb is unaffected (WOW=reference scale 1.0; normal preset gets no monster tuning).
test/networkTuning_ultra.test.js: size scaling, casino-only monster, block-time
ordering, env-override-wins, kill-switch. Suite 60 suites / 428 tests green.
The instrument the design discussion said was missing. src/sim/ drives the actual
game engine (game.js — same dungeon gen, monster AI, movement as prod) with
headless bots to terminal outcomes, then measures what was only ever DECLARED:
- Per preset: escape/treasure/caught/stuck rates + completion-move/-time pctiles.
- MEASURED house-win-rate per network, integrating out the random block deadline
analytically: for D~Exp(mean=blockTime), P(house wins an escape at T)=1-exp(-T/BT),
so no Monte-Carlo over deadlines.
- Two bot skill models (omniscient beeline / explorer fog-of-war) as an upper band;
neither evades the monster, so true house-win is likely below both.
First findings (explorer, 200 runs, 320ms/move): presets UNDERSHOOT their declared
targets (casino 57% vs 70%, normal 47% vs 55% on WOW) and the edge swings ~15pts
across chains from IDENTICAL dungeons (casino 48% BTC .. 65% GRIN) — because
cryptoType never actually sizes the dungeon. Exactly the two gaps we predicted.
Files: src/sim/{pathfind,simBots,simulate}.js + test/sim_ultra.test.js (structural
guard, not a balance assertion). Run: node src/sim/simulate.js --help-ish flags.
Suite 59/422 green.
The shared "juice" layer, pure-canvas (CSP-safe, no WebGL/shaders), driven off a
clock so both renderers light a scene the same way:
- html/js/render/fxLayer.js (NEW): RK.fx — layered-sine flame flicker, additive
warm glow, a dancing two-layer canvas flame, and pulsing hazard overlays
(lava/poison/spikes) with a footprint-clip callback so it fits square cells
and iso diamonds alike.
- Tavern now has FIRE: 4 corner braziers + a hearth on the left wall throw real
flickering light. Placed along walls, clear of spawn/aisles/tables.
- Both renderers: torch/hearth/hazard tiles draw only their FLOOR base in the
static pass (no more solid amber block), then RK.fx paints the animated flame
/ hazard pulse in the live loop. Tiled caches emitter positions and draws them
over the vignette so light punches through the dark edges; iso adds them as
depth-sorted fx items and keeps its RAF alive while any fire/hazard is onscreen.
- Dungeon hazard zones wired end-to-end in the scene model (lava L / poison P /
spikes ^ chars -> pulsing tiles) — they render the moment the generator emits
them; no client-authoritative fakes.
- fxLayer.js included in tavern.html + match.html.
Suite 58/419 green; render files node --check clean.