simocracy-v2 · gathering-world-pan-zoom-minimap

Gathering world: pannable + zoomable map with mini-map

@daviddao

/senate and /events/<gathering> are no longer fixed-canvas worlds. The floor is a 2× game map, the user pans by click-dragging empty floor and zooms with the wheel (clamped 1.0–2.5× — zoom-out below the original viewport is disabled to keep the worst-case render bounded), and a bottom-right mini-map shows the building rect, every loaded sim as a dot (owned sims gold to match the in-canvas name-shield), and the current viewport rectangle (click to recenter). Initial view matches main pixel-for-pixel. Six total commits, four PM-driven regression rounds caught and fixed live; 60 fps held idle and during continuous panning at default zoom.

Date: 2026-05-09 · Final HEAD: 1657c3a · pass


Headline result

60
fps with 93 sims, panning
world vs. canvas
2
regressions caught + fixed
0
remaining blockers

Headline. The change ships. World is bigger than the viewport, sims spawn distributed across the whole world, mini-map + pan/zoom + click-to-recenter all feel correct, codex pets respond to arrow keys, and /ftc-sf is unaffected. Six commits on the feature branch, four PM-driven regression rounds caught and fixed live during this audit: a flat-green floor; an over-aggressive mini-map sidebar offset that ignored the narrower sim-profile sidebar; "super laggy" perf at full zoom-out (three causes: setCamera re-renders during drag, no viewport culling, and a world-sized fillRect every frame); a freeze at maximum zoom-out (capped to 1.0×); window resize teleporting sims into a smaller viewport (world dims now grow-only); a green mini-map floor; and codex-pet sims unable to be moved with the arrow keys (the behavior state-machine ignored the new direction unless petState was already a walking row).

Walkthrough

/senate first paint with the gathering info sidebar open
01/senate, first paint. Gathering info sidebar open by default. Senate building drawn in the same on-screen position as before the change. Mini-map visible bottom-left of the wide sidebar (offset by md:right-[calc(50%+0.75rem)]) showing the building rectangle and 93 sim dots scattered across the world; viewport rectangle outlined in foil yellow.
Senate with sidebar closed showing many sims and the mini-map at the canvas right edge
02Sidebar closed. Many of the 93 sims are now visible in-viewport (lisa, jigme khesar namgyal wangchuck, Crops, Banticus, ming, Judycat222, 1a35e1, Smartacus, Zan, Laurence, Kiki, etc). Mini-map snaps back to right-3 at the canvas right edge.
Banticor sim selected, narrow sim-profile sidebar open, mini-map sits adjacent to it
03Click on Banticor → narrow sim-profile sidebar (w-80) opens on the right. Mini-map correctly shifts to md:right-[calc(20rem+0.75rem)] so it sits flush against the sim sidebar — not using the wide gathering offset (50%+0.75rem) which would have parked the mini-map deep in the middle of the canvas. This is the bug the PM caught on the first pass; 02200b4 introduced a discriminated sidebarKind: 'none' | 'gathering' | 'sim' prop on the mini-map.
Senate at default zoom 1 with sidebar closed and the new cream mini-map
04After all four regression rounds. Default zoom 1 (now the most zoomed-out view possible — wheel-down does nothing). Mini-map shows the new cream cloth world floor instead of the original swamp green, building rect with the senate image rendered at scale, ink sim-dots scattered across the world, viewport rectangle in foil amber. Render-gated dots: only sims whose sprite atlas has resolved appear, so the dot count tracks what's actually drawable on the canvas.
After clicking the left edge of the mini-map, camera recentered showing the western world
05Mini-map click-to-recenter. Clicked the western edge of the mini-map; the main camera jumps so that point is centered in the viewport. Senate now sits on the right side; western sims (Tibet Sprague, Astral_Druid, Sim1, Rimuru, Avra, Ghostty, etc) come into view. The mini-map's viewport rectangle has correspondingly shifted to its left edge.
Flourishing event: small council, tower building, mini-map present
06Cross-check on a regular event — /events/flourishing. Council-only scope so only a couple of sims walk; mini-map still draws the building rect + sim dots + viewport rect, just sparser. Building image is the Flourishing tower (a different bg than Senate), confirming the per-gathering backgroundImageUrl path didn't regress.
/ftc-sf showing Frontier Tower, untouched
07Regression check on /ftc-sf. The tower world uses a different hook (useTowerCanvas, not useWalkingAvatars) and was explicitly out of scope — verified untouched. No mini-map (by design), Frontier Tower image and floor labels render as before, sims walking across the foreground.
Senate at first paint after final commit, with the wide gathering sidebar open and cream mini-map
08First paint of /senate on final HEAD 1657c3a. Wide gathering sidebar open (the auto-open default on first visit). Mini-map sits at md:right-[calc(50%+0.75rem)], flush against the sidebar's left edge. Cream cloth world floor; only a handful of sims have rendered yet (the sprite atlas has loaded but most codex-pet sheets haven't resolved), and the dot count on the mini-map tracks that progressive load.

Findings

What worked

Regression #1 (caught + fixed)

Symptom. The non-building world floor was painted flat #2d4a35 swamp green — visible whenever zoomed below 1×. The PM flagged it on the first eyeball pass: "there is a new green background that is weird".

Cause. The original render loop used #2d4a35 as a fallback when the bg image failed to load (sized to canvas). The first-cut refactor moved that fillRect inside the camera transform sized to worldWidth × worldHeight, so it now always painted swamp green underneath the bg image regardless of bg load state.

Fix (02200b4). Replaced the world-rect fillRect with a single canvas-coords fillRect(0, 0, canvasW, canvasH) using a fieldnotes-cream tone (--cloth, #FAF9F5) before applying the camera transform. Same effect at lower cost, and the floor now reads as paper rather than swamp.

Regression #2 (caught + fixed)

Symptom. When the user clicks a sim, the mini-map jumped too far left, leaving a wide gap between it and the right edge of the sim-profile sidebar. PM phrasing: "when you click on a sim (the sidebar) the minimap moves too far to the left ... it forgets that sim sidebars are not as wide as other sidebars".

Cause. The first-cut WorldMiniMap took a single sidebarOpen: boolean prop and applied md:right-[calc(50%+0.75rem)] when true. That offset matches the gathering info sidebar (md:w-1/2, line 745 of gathering-world.tsx), but the sim-profile sidebar from components/senate/sim-info-sidebar.tsx is w-80 (320 px) — way narrower than 50% of the canvas. Result: gap.

Fix (02200b4). Replaced the boolean with a discriminated string prop sidebarKind: 'none' | 'gathering' | 'sim'. Mapping: none → right-3; gathering → md:right-[calc(50%+0.75rem)]; sim → md:right-[calc(20rem+0.75rem)]. gathering-world.tsx passes selectedSim ? 'sim' : buildingSelected ? 'gathering' : 'none'. Verified visually in screenshot 03.

Performance regression #3 (caught + fixed)

Symptom. PM perception: "and its SUPER LAGGY NOW!" — despite the design intuition that a 2× world with 75% of sims off-screen should be cheaper per frame, not more expensive.

Three causes (compounded).

Result. 60 fps (60.1 measured) at idle, at maximum zoom-out with all 93 sims rendered, AND during 3 s of synthesized continuous panning. Same metric across all three states.

Round 4 (caught + fixed): freeze at full zoom-out, window-resize teleport, mini-map green, render-gating, codex-pet arrow keys

After the round-1 perf v2 brought the average frame time from "super laggy" down to a comfortable 17 ms, the PM round-tested at full zoom-out and reported the page freezes. Synthetic tracing showed p95 frame time of 25 ms (~40 fps) when all 93 sims rendered simultaneously at zoom 0.4×, with ~10% of frames over the 20 ms threshold — below the perceptual-jank floor on a fast machine but enough to feel like a freeze on a slower one. Fix (78a7838): clamp zoom min to 1.0 instead of 0.4. Wheel-down at default zoom is now a no-op, capping the worst-case render area to one canvas-worth of world.

The PM also flagged that resizing the browser window teleported sims into the smaller viewport. Cause: worldDims was a useMemo dep'd on canvasDims, and on shrink the in-hook reposition-effect saw sims as outside-world and relocated them. Fix (ee328aa): world dims are now stored in useState and grow-only via Math.max(prev, canvas×2). Browser shrink leaves sim coordinates intact — the user just sees less of the world.

Mini-map regressions, same commit batch: the world cell inside the mini-map kept the swamp rgba(45, 74, 53, 0.85) from the original render-loop fill (just relocated). Replaced with cream #FAF9F5 matching the main canvas floor, sim-dot color flipped to ink for visibility on cream (4e3b7b6). And the dots themselves were not render-gated: sims whose sprite atlas hadn't resolved still showed as dots. Tightened to if (!a.rendered) continue; owned sims now get the gold #E8B73A matching the in-canvas name-shield (ee328aa).

Final round: codex-pet sims couldn't be moved with the arrow keys after selection. The arrow handler was updating direction but not the codex-pet behavior state machine — pets in idle/jumping/waving rows ignored the new direction because isCodexPetWalkingState(petState) gated movement. Fix (1657c3a): when the selected sim is a codex pet, the handler also flips petState → codexPetStateForDirection(dir) and resets the behavior timers, matching the same pattern already used in the boundary-collision branches.

Reproducing

Commits (in order)ca59ade initial land · 02200b4 floor color + sim-sidebar offset + perf v2 · 4e3b7b6 mini-map cream · 78a7838 zoom min 1.0 · ee328aa world grow-only + render-gated owned-tinted dots · 1657c3a codex-pet arrow keys Final HEAD1657c3a Codex pets respond to arrow keys when selected Pages exercised/senate, /events/flourishing, /ftc-sf Toolingagent-browser (Chrome) for click + screenshot; eval-injected PointerEvents and WheelEvents for pan/zoom; per-frame requestAnimationFrame timer for FPS sampling. FPS sampler (frame-time)let last=performance.now(); const samples=[]; function tick(now){samples.push(now-last); last=now; if(samples.length<240)requestAnimationFrame(tick); else resolve({avg:samples.reduce((s,v)=>s+v,0)/samples.length, p95:samples.sort((a,b)=>a-b)[Math.floor(samples.length*0.95)]});} Drag synthesizercanvas.dispatchEvent(new PointerEvent('pointermove', {clientX, clientY, buttons:1, ...})) Wheel synthesizercanvas.dispatchEvent(new WheelEvent('wheel', {clientX, clientY, deltaY, ...})) Screenshotsassets/01-..08-*.png — pngquant compressed Plan + acceptance criteriaplans/gathering-world-map.md