Adversarial multi-agent review of the launch candidate produced 22 confirmed
findings (12 more were refuted). This closes the critical, high, and the
mediums/lows that are cheap and real, then does the iso/3D/Tavern art pass.
CRITICAL — unauthenticated remote kill switch
Socket.IO does not wrap listener invocation, and this process treats
uncaughtException/unhandledRejection as fatal, so ONE unguarded throw in ANY
listener exits the server. `socket.emit('debug_ping')` with no argument did
exactly that, and systemd restarts straight back into it. Every registration
in handleConnection now goes through a safe-dispatch wrapper that contains
sync throws and rejected promises to the offending socket; handleDebugPing
also stops trusting its payload.
HIGH
- NIP-98 login now binds the signed event to this deployment's host. The path
check alone let an event signed for any other origin authenticate here, and
linking ADOPTS the account owning that pubkey — full account takeover.
- Fog of war is enforced server-side. getState() shipped absolute
entrance/exit/treasure coordinates and the monster's exact position in every
frame, with concealment implemented only in the browser. Discovery is sticky;
the monster is reported only while in view.
- NODE_ENV must now be explicit and recognized. Unset, every production gate
read false while DebugManager reported production and silenced logging.
- Tavern chat applies the lobby's ban list, rate limiter and user_id
attribution before entering global chat.
- userRegistry is deleted by socket.id (it was keyed by socket.id and deleted
by socket.client.id, so nothing was ever freed).
MEDIUM / LOW
- Payouts: a legitimately split transfer_split kept its transaction hashes
instead of quarantining already-broadcast funds with tx_hash NULL, and
provably pre-broadcast failures return to 'pending' rather than needs_review.
- RateLimiter key parsing fixed — cleanup() freed nothing and expired windows
never reset, because ids contain colons.
- Enter no longer submits a paid entry from behind a modal.
- Lobby chat no longer double-escapes server-escaped text.
- Rate limits for identity:get / address:prompt / early_entry.
- Spectators released when a game ends by disconnect, not just settlement.
- movementManager / addressManager per-socket maps cleaned on disconnect.
Renderers
Iso: opaque shading instead of translucent fog, additive torch/lantern/beacon
light pools, crevice AO, embers, cold memory wash, and it stops reallocating
the whole canvas every animation frame.
3D: instanced level geometry (3 draw calls), procedural stone/flagstone
materials, torch lights + shadows, real meshes for stairs/chest/monster
(they were humanoid capsules), dust motes, wall cutaway, and lighting
rebalanced now that fog is measured from the camera.
Tavern
Block height and hash fragments drift around the stage edge and ripple when a
block lands, fed by a cosmetic chain tip added to the blockheight broadcast.
Speech bubbles work again (they keyed off a socketId the server stopped
sending).
126 suites / 1005 tests pass.
8.1 KiB
Render kit: modes, packs & FX
The client render kit (html/js/render/, namespace RK) draws the same Scene in multiple
techniques and art styles, so the tavern and dungeon are rich, interchangeable environments and new
art can be added as products.
Modes vs. packs
- Render modes = techniques (how a scene is drawn):
Tiled,ASCII,Iso,3D(renderModes.js). Each declares aprojection(topdown/iso/3d). - Packs = sets/styles within a projection (chosen via the pack picker): e.g. topdown Roguelike
Interior ↔ Roguelike Dungeon, iso Dungeon ↔ Medieval. Registered in
packRegistry.js/assetPacks.js. "Fancy" is not a mode — it's a pack (tiles + FX). - A premium mode unlocks once the user has any unlocked pack for its projection.
- The scene model is kind-based (
floor/wall/table/torch/monster/…), so one pack renders both the tavern (sceneFromTavern) and the dungeon (sceneFromGameState) — that's what makes packs interchangeable across both.
Entitlement: packs are gated by the operator-owned cosmetic catalog (cosmetic_catalog table,
src/multiplayer/entitlements.js) — free / grant / lifetime-spend / subscription-tier. See
MONETIZATION.md.
For browser QA, ?unlock=1 sets a sticky local bypass for render-mode and pack display gates;
?unlock=0 clears it. This affects only that browser's localStorage, grants no server
entitlement, and is not an operator access-control mechanism.
Gameplay focus contract
Single-player keyboard movement is deliberately accepted only while #game-display owns browser
focus, so typing in chat or another form cannot move the player. Choosing Tiled, ASCII, Iso, 3D, a
pack, or a camera control immediately returns focus to that gameplay area without scrolling.
Local 3D runtime
Three.js 0.160.0 is an exact production dependency. The server advertises the local version in
/runtime-config.js and exposes only the browser build and required addons beneath:
/vendor/three/0.160.0/three.module.min.js
/vendor/three/0.160.0/addons/loaders/GLTFLoader.js
/vendor/three/0.160.0/addons/utils/SkeletonUtils.js
The modules are imported lazily when 3D is selected. Local delivery is always enabled in the
application runtime and takes precedence over external sources. RENDERER_CDN_ENABLED=true makes
the jsDelivr renderer source path eligible only in a runtime where local delivery is disabled. All
checked-in environment profiles keep external renderer execution off, including production, so
Three.js remains same-origin under CSP.
Three.js library delivery is separate from generated model delivery. Runtime GLBs under
html/assets/generated/3d/ are gitignored and must be provisioned independently; the renderer
keeps a low-poly fallback when a model cannot load and removes it once the animated GLB is attached.
The FX layer (fxLayer.js, RK.fx)
Pure-canvas animated FX (CSP-safe, no WebGL), shared by the tiled and iso renderers so a scene lights up the same way in either projection:
RK.fx.fire— dancing two-layer flame + warm glow (torches, hearths).RK.fx.hazard— pulsing overlay for hazard tiles (lava/poison/spikes) with a footprint-clip callback so it fits square cells and iso diamonds alike.RK.fx.flicker/pulse/glow— the primitives.
A tile carries fx: 'fire' or hazard: '<kind>' in the scene legend (sceneModel.js); the renderer
draws only the tile's floor base in the static pass and lets RK.fx paint the animated layer in
the live loop. The tavern has wall braziers + a hearth; the dungeon hazard kinds are wired and render
the moment the generator emits their chars (L/P/^) — pending the generator + gameplay stakes.
Lighting model (iso & 3D)
Both premium projections light the dungeon rather than just tinting it, and both derive their
lighting from the same scene.lightGrid the 2D modes use — so switching projection changes the
technique, never what you are allowed to see.
Iso composites in one pass per frame:
- tiles are shaded opaque (
_shadedImage, quantized to 6 cached levels) instead of alpha-faded. Alpha-fading made remembered walls translucent, so the dungeon read as a haze of overlapping ghosts; multiplying a sprite's own pixels toward a cold near-black keeps distant stone solid and unlit, which is what memory should look like; - light pools (
_lightPool) are additive, squashed to the ground plane, and emitted by torches (flickering), the player's lantern, and objective beacons on the stairs/treasure; - crevice AO (
_wallAO) darkens floor cells by how many walls they touch; - embers rise off each flame, seeded per cell so they don't reshuffle every frame;
- a cold blue wash (
_memoryWash) separates remembered rooms from torchlit ones by colour temperature. The wash colour must stay darker than the tiles it covers — a mid-tone blue lifts memory brighter than the lit centre and inverts the depth hierarchy.
3D (threeRenderer.js) draws the level as three InstancedMeshes (walls / ground / props),
so a 70×35 dungeon costs three draw calls and a fog-of-war update is a few thousand matrix writes
rather than ~2500 mesh allocations. Wall and floor materials are generated on a canvas at startup
(coursed stone, irregular flagstones, both doubling as bump maps) — no new downloads, nothing for
the CSP to block. On top: a low warm key light with a player-tracking shadow frustum, a pool of six
point lights reassigned each frame to the nearest braziers, a hand-held lantern, drifting dust motes,
and a wall cutaway that squashes the walls between the isometric camera and the player.
Two things that are easy to get wrong here:
- Fog is measured from the CAMERA, which sits ~17 world units back along the isometric offset — not from the player. A fog band tuned as if it started at the player puts the player a quarter of the way into fog and crushes the scene to near-black.
- Do not apply
lightGridto albedo at full strength in 3D. The point lights already provide falloff; multiplying by the grid as well double-darkens every surface. 3D compresses it (0.38 + 0.62·lb) and lets the lamps shape the scene.
Features (entrance / exit / treasure / items) are set dressing, not avatars in both
projections: stairs with a beacon shaft, a gold chest, a spinning pickup. Before that distinction
existed, every one of them was drawn as a humanoid, so the entrance looked like a second player
standing at the map origin.
Other pieces
| File | Role |
|---|---|
isoRenderer.js |
Canvas isometric projection. Orientation-aware walls (_S for x-runs, _W for y-runs, corner tile), floor variety, contact shadows + vignette juice. Kenney iso dungeon set (assets/kenney/iso-dungeon/). |
tileRenderer.js |
Top-down atlas renderer. over compositing (object tiles drawn on a base tile), torch-lit vignette, ember/flicker loop, animated fire/hazard emitters. |
zoomControl.js |
RK.attachZoom(host) — wheel/pinch zoom (0.4–4.0), dblclick reset, pixelated scaling. |
catSprites.js |
Animated tavern cat (Pet Cats Pack idle strips). |
packRegistry.js |
Multi-pack-per-projection registry: active-pack selection persisted per projection, entitlement-gated, graceful fallback. Node-testable. |
threeRenderer.js |
Same-origin Three.js scene. Instanced level geometry, procedural stone/flagstone materials, torch light pool + shadows, dedicated feature/monster meshes, dust motes, wall cutaway, fog-aware entity retention, animated GLB cloning, camera follow, and complete renderer/resource cleanup. |
chainAmbience.js |
RK.mountChainAmbience(host) — drifting top-block-hash fragments, a block-height readout, and a ripple when a block lands. Consumes the public blockheight broadcast; decoration only, and it pauses/prunes itself while the tab is hidden. |
spGameRenderer.js |
Single-player render bridge, player-centred camera, local 3D lazy-load, and explicit gameplay-focus restoration. |
Tests: test/renderPackResolver.test.js, test/packRegistry_ultra.test.js,
test/rendererCdnPolicy.test.js, and test/pvpUiPolish.test.js.