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.
- 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.
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.
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.
Fixes the "thin disconnected slabs" on the top-left/bottom-right walls and juices
the iso scene:
- Wall facing: iso walls have an orientation. A run along grid-x uses the _S
rotation, a run along grid-y uses the perpendicular _W (I viewed the tiles:
_S faces SW, _W faces SE — the two camera-facing rotations), and true corners
use the corner tile. The renderer picks per-cell from neighbours. Enclosing
walls now read as continuous faces on all four edges. Same logic gives
windows/doors a Y rotation.
- Floor variety: deterministic per-cell hash sprinkles planksBroken into wood
floors and stoneTile/stoneUneven into stone — no more flat identical sea.
- Juice: soft contact shadows ground every prop + character, and a warm radial
vignette lights the room instead of it looking pasted onto black.
- Tavern map: barrels/crates line the side walls (clear of windows/spawn, via
the wall-safe put()) so the lobby reads lived-in in every render mode.
Suite 58/419 green.
A — RICH ISO. The old iso-dungeon was a 7-tile sliver of Kenney Isometric
Dungeon; wire the full set so town/dungeon are RICH and interchangeable:
- Real architecture: full stoneWall, stoneWallWindow (actual window),
stoneWallDoorOpen (real door), archway, wood column, stairs.
- Distinct furniture per kind: table -> tableShortChairs (table+chairs),
keg/barrel -> real barrel, crate -> woodenCrate, shelf -> woodenCrates,
chest -> chestClosed (added to the floor+prop compositor). No more
everything-is-a-chest.
Facing (was 90 deg off, twice): verified against live movement the sprites are
angled CLOCKWISE from NE (0=NE,2=SE,4=SW,6=NW), so up->Male_0, right->Male_2,
down->Male_4, left->Male_6. Now the run anim points the way you actually move.
ASCII picker no-op: ASCII draws glyphs and consumes no tile pack, so
projForMode(ascii)=null and the packbar hides for it (it only ever made sense
for Tiled/Iso).
Suite 58/419 green.
Modes are TECHNIQUES; 'Fancy' (tiles+FX) was really a topdown PACK, so:
- Remove 'fancy' from RENDER_MODES (saved 'fancy' falls back to tiles; its dead
Pixi branch is short-circuited by canUseMode). Modes = Tiled/ASCII/Iso/3D.
- Modes now carry a ; a premium mode unlocks once the user has ANY
unlocked pack for that projection (not a single hardcoded pack) — needed now
that iso is multi-pack.
- New 'roguelike-dungeon' topdown pack: same sheet, a DUNGEON palette (stone
floors + barrels/crates vs the wood interior) — a distinct 2D tileset with zero
new assets. Registered + catalog (migration 027, topdown tier 1 / 1 credit).
The tiled renderer's lighting/shadows/vignette are the built-in FX.
- Also: iso facing fix (Male 0=NW..6=NE) reflected in tests.
So the Tiled pack picker now offers Roguelike Interior <-> Dungeon, and Iso offers
Dungeon <-> Medieval — packs as products, proven in the best-looking mode too.
Suite 58/419 green.
Male_0..7 are 45-degree renders (viewed: 0=NW, 2=SW, 4=SE, 6=NE). In this iso
projection grid up->NE, down->SW, left->NW, right->SE, so map facing to the visual
movement direction. Applies to both iso packs (shared characters).
Proves the packs-as-products loop end to end:
- Copied Kenney Isometric Medieval Town wood tiles (floor/floor2/wall/window/
door/banner) into assets/kenney/iso-medieval/.
- Registered 'iso-medieval' as a 2nd iso RENDER pack (its own tile geometry —
Medieval tiles are shorter than the dungeon set; reuses the dungeon characters).
Strong on architecture; no medieval furniture at this scale, so furniture kinds
fall back to floor for now (shines most in the dungeon).
- Catalog product: DEFAULT_CATALOG + migration 026, iso tier 2 / 15 credits.
- PACK PICKER in the tavern: when a projection has >1 unlocked pack, a selector
appears to switch live (setActivePack -> re-render). Uses ?unlock=1 to preview.
So: switch Iso ★, and the pack picker lets you flip iso-dungeon <-> Medieval Town
live. Adding the next pack = assets + registerPack + a catalog row. Suite 58/419 green.
The engine behind 'add more 2D tilesets / iso packs / 3D skins as products'. A
pack is a self-contained render environment for a projection (topdown/iso/3d);
because scenes are kind-based, the SAME pack renders both town and dungeon
(interchangeable), and each maps to a catalog id (monetizable, entitlement-gated).
- packRegistry.js: registerPack / packsForProjection / unlockedPacks (canUsePack)
/ activePackId (saved-if-unlocked else first unlocked) / setActivePack /
activePackAssets. Per-projection active-pack selection, persisted. In-memory
store fallback so it's testable under Node.
- assetPacks: registers the 3 built-ins (roguelike-interior topdown, iso-dungeon,
kenney-3d-characters) + activeIsoAssets()/activeThreeAssets() resolvers; RK.theme()
now defaults to the active topdown pack.
- isoRenderer + avatarVisuals (iso/3d character lookup) read the ACTIVE pack.
- packRegistry loaded before assetPacks in tavern + game.
Backward-compatible: one pack per projection resolves to today's defaults — nothing
changes visually. Adding a pack now = registerPack(...) + assets + a catalog row.
Tests: +5 (register/list, unlock filter, active/saved/fallback, refuse locked).
Suite 58 suites / 419 tests green.
The iso renderer only drew floor+wall (bar/table hardcoded). Generalize the
floor-base + prop compositing to all furniture kinds (bar/table/chair/keg/shelf/
barrel/crate) and map them to the iso-dungeon tiles: stacked barrels = the bar +
kegs, chest = tables/crates/shelves, chair = chairs. Half walls kept (full walls
would occlude the front row). Same coherent iso-dungeon art set. Client-only.
Reusable zoom control (zoomControl.js): RK.attachZoom(host) adds wheel/pinch zoom
to a render host; RK.applyZoom(host) scales whatever canvas is mounted (2D or
WebGL) via CSS transform (pixelated so pixel art stays crisp), re-applied on mode
switch. Trackpad pinch = ctrl+wheel, handled the same way; double-click resets.
Wired into the tavern stage, so it works across Tiled/ASCII/Fancy/Iso/3D. Client-only.
- catSprites: scale the cat to ~2.7x its cell (the sprite is ~40% cat, 60%
transparent padding) so it reads tile-sized instead of tiny.
- tavernMap: remove the centre rug — tile 923 renders green, which looks wrong on
the wood floor. (Re-add with a verified warm rug tile later.)
The black boxes around furniture/windows were transparent object tiles drawn
straight onto the dark canvas. Fix at the pipeline level, not as a tavern one-off:
- tileRenderer: a tile kind whose legend declares `over` (the ground it sits on)
now draws that base tile FIRST, then the object on top — so the object's
transparent areas show the floor/wall. Works for any scene, atlas or flat-colour
fallback. Wall-grounding shadow strip now only applies to real walls, not objects.
- sceneModel TAVERN_LEGEND: declares over='floor' for bar/keg/shelf/table/chair/
barrel/crate/rug and over='wall' for windows.
- catSprites: the cat occupies ~40% of its 50x50 frame, so scale to ~2x the cell
and centre it — it now reads about tile-sized instead of tiny.
Client-only. Suite 57 suites / 413 tests green.
The old 'cat' used a char-goblin sprite (green monster). Replaced with actual
pixel cats from the Pet Cats Pack (CC-0, commercial-ok):
- html/assets/cats/cat1..6-idle.png — 6 cats, 10-frame 50x50 idle strips.
- catSprites.js: loads + animates a cat sprite (RK.drawCatCanvas), flips to face left.
- tileRenderer: draws entities with kind 'cat' via the cat sprite path (shadow + frame).
- tavernLife: spawns a RANDOM cat that wanders the customer floor (not behind the
bar), injected as kind 'cat' with its catSprite id. Degrades to a villager if
catSprites.js isn't loaded.
- tavern.html loads catSprites.js.
Client-only (no server change). Suite 57 suites / 413 tests green.
Used the sheet's hand-labelled roguelikeSheet_meta.json to pick the RIGHT tiles
instead of guessing:
- New tile kinds + coords (window, counter, keg/barrel, shelf, table, chair,
crate, rug, door) in sceneModel TAVERN_LEGEND/TILE_OF + assetPacks tileMap.
- tavernMap redesigned: windowed walls, a bar counter with a shelf of bottles
behind it and a keg (beer!) at each end, six tables each with a chair, a
central rug, corner barrels/crates, and a door. Chairs are walkable so standing
on one reads as sitting. Warm wood floor color (was a dark green in the fallback).
- Bartender now stands BEHIND the bar (spawn + roam confined to the row between
the top wall and the counter, sealed off by it).
Still TODO from feedback: a cat (no cat sprite on the sheet — needs an asset) and
richer bar bottles. Suite 57 suites / 413 tests green.
- The imported .tmx tavern room renders as a block-scatter in the browser (the
client falls back to the walkable grid, drawing furniture/walls as generic
blocks). Gate it behind TAVERN_DESIGNED_ROOM=true and default to the hand-built
procedural tavern (tavernMap.js) — bar + tables + walls — which renders reliably
through the theme atlas.
- Remove the tavern 'cat': it used the char-goblin sprite, so it read as a green
monster stuck in the wall. Gone until there's a real critter sprite.
A player who never customized arrives as avatar 'default' — not a char/skin/model,
so avatarVisuals resolved it to 'legacy' kind with no sprite and the renderer drew
the grey fallback circle (while NPCs with explicit char-* avatars rendered fine).
Coerce any unknown/'default' avatar to 'char-villager' on the 2D topdown path so
nobody is ever a grey dot in a room of people. iso/3d keep their own fallbacks.
Caveat 2 (premium sub had no data source) -> RESOLVED:
- src/services/subscriptionService.js: resolves a premium tier by npub. Two
sources, both optional (absent => null => unchanged): PREMIUM_NPUBS operator
allowlist (npub1/hex[:tier]) works TODAY with zero backend dependency, and an
optional SMIRK_PREMIUM_STATUS_URL HTTP endpoint for full automation. Cached.
- identityService.entitlementsForUser populates user.subscription_tier from it
(by smirk_public_key) before the snapshot, so an active sub unlocks cosmetics.
(The Smirk backend's /premium/status is self-only, so a by-npub service lookup
is the one remaining backend addition for full automation — documented.)
Caveat 1 (client signing was bare scaffold) -> HARDENED:
- html/js/smirkChatSign.js now does the Nostr-scope-grant retry (sign first; on
NOT_AUTHORIZED, getNostrPublicKey() once + retry), same as SmirkAuth — so a
returning user signs with one approval instead of silently falling back. Still
feature-detected + graceful. A real Smirk wallet click is the last confirm.
Docs updated (MONETIZATION.md, CHAT_AND_NOSTR.md). Tests: +6 (allowlist hex/npub1,
HTTP active/inactive+cache, identityService unlock, non-premium unchanged).
Suite 57 suites / 413 tests green.
Phase 2 — a signed-in player can post global chat under their OWN Smirk npub
instead of the shared bridge account:
- src/utils/verifyChatEvent.js: verify a client-signed chat event with the same
defenses as verifyNip98Event (rebuild clean event, recompute id, verify schnorr,
freshness) PLUS the impersonation guard — the event's pubkey MUST equal the
session's authenticated npub (users.smirk_public_key from NIP-98 login).
- ChatProvider.relaySignedEvent (base: local delivery only) + NostrChatProvider
override (local delivery + publish the PRE-SIGNED event to the relays, no
re-signing, echo-deduped) so it carries the player's npub.
- chatHandler.handleSignedChatMessage: rate-limit + resolve the session npub +
verify + escape + relay. socketHandlers wires the event. Serves
lobby + tavern (both render the global broadcast).
- html/js/smirkChatSign.js + tavern send wire: feature-detected client helper —
signs via window.smirk.signNostrEvent, falls back to the unsigned send when
Smirk isn't connected, so it's inert (and safe) until a page has a wallet.
Premium -> cosmetics (the P1 catalog already had the hook):
- entitlements.levelForUser now takes the HIGHER of premium_level and an
active-subscription tier, so a wowne.ro premium sub sets the entitlement tier
and unlocks tile/customization packs at/below it — one sub, chat perks +
cosmetics. Buying credits still never joins the premium ladder.
Behavior-preserving: no subscription_tier => unchanged; chat_signed only fires
when a client opts in. The client signing path needs browser verification against
a connected Smirk wallet (like the NIP-98 login). Tests: +13 (verify incl.
impersonation/tamper/channel/freshness, relay+echo-dedupe, sub-tier unlock).
Suite 56 suites / 407 tests green.
Two tavern rendering bugs in Tiled mode:
1. The 'you' cell looked like a flaming dot. The warm torch light + drifting
embers are a DUNGEON effect (the player carries the torch) but _composite/
_ensureEmbers ran on every scene, so in the tavern they painted a flame on
the 'you' avatar while other players rendered normally. Gate both to
scene.isDungeon.
2. Tavern tiles showed as flat 'blocks' that never refreshed. The tile atlas
image loads async; switching INTO the tiled renderer (e.g. entering the
tavern) before it's ready paints the flat-colour fallback once and never
repaints until the next scene update. Hook atlas.onReady to re-render the
last scene the moment the atlas is ready.
Client-only; no server change. Deploy = git pull + restart (or just pull, it's
static assets).
The single-game option now reads 'Single game · 1 credit' and notes it counts toward cosmetic unlocks (matching the backend: a direct entry advances total_credits_purchased). Fixes the direct payout copy to only show multipliers when payouts are actually enabled (was defaulting on).
① assetPacks.js merges the server-authoritative catalog (from the entitlement snapshot) into RK.PACKS — operator-added packs now appear client-side and existing packs pick up server definitions, retiring the client half of the split-brain. ② A confirmed direct/single_game payment now records as 'buy 1 credit + spend it': balance nets to zero but total_credits_purchased advances (recordDirectEntryPurchase), so direct play unlocks the same tier/threshold cosmetics as buying credits, and the client gets a fresh entitlement snapshot. +2 tests. Suite 49/345 green.
P1 foundation (branch feat/credits-catalog-foundation). Remaining: the buy modal reflects the unified credits model; a later pass can fully collapse single_game into the credit spend path.
Reframe the two 'Direct Payment / Credits Mode' cards as one 'Credits' model — 1 credit = 1 run, bought singly at base price or in discounted bundles. Adds the drop-in-timing note (jump in now or next block; block timing is memoryless so the expected wait is the same). Reward line is now config-driven: shows crypto multipliers only when payouts are actually enabled, otherwise the Hall-of-Champions framing — fixes the help advertising 2x/3x payouts on a PAYOUTS_ENABLED=false (prestige) deployment. Header renamed to 'Credits & Entry'.
Previously always called getNostrPublicKey() then signNostrEvent() = two approvals every login. Now signNostrEvent() runs first; only if the wallet reports the origin lacks the Nostr scope (NOT_AUTHORIZED, thrown without a popup) do we call getNostrPublicKey() to grant it and retry. First-time still does the one-time scope grant; returning users get a single approval. (The double PASSWORD is the wallet's autoLockMinutes=0 default and is user-configurable, not dapp-controllable.)
The Smirk dapp API requires the origin to hold the Nostr scope before signNostrEvent(); without it the wallet throws 'Origin lacks the Nostr scope — call getNostrPublicKey() first'. getNostrPublicKey() establishes that grant (prompts once), so call it first in the NIP-98 path.
Only an explicit user-cancel skips the fallback now — a LOCKED signNostrEvent again tries connect(), which opens the unlock popup on capable builds. My earlier refinement had removed this path.
A Smirk wallet that already owns another account no longer fails with 'already linked to another account'. Ownership is cryptographically proven, so it's a login: the server re-points that account to the current socket and returns its session token; the client persists it and reloads to re-establish as that account. Fixes a returning user being unable to sign in from a fresh browser session.
NOTE: touches the identity/session path — verify live (sign in with a wallet already linked to another account -> lands on that account with its address/credits).
Play area: add a 6px right gutter on .container (border-box) so the outward yellow focus outline isn't clipped by overflow:hidden at the viewport edge — the inset-outline attempt got clipped in Chromium. Revert to an outward outline (ui.js).
Smirk: on the NIP-98 path, only fall back to the legacy connect() when the wallet genuinely lacks signNostrEvent — a LOCKED or user-cancelled signNostrEvent now surfaces instead of triggering a second unlock prompt. (The unlock-popup-on-locked behavior itself is a wallet-build capability, added to smirk-monorepo 2026-07-09; builds before that throw LOCKED and get the friendly retry hint.)