simocracy-v2 · gathering-world-pan-zoom-minimap
/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.
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).
md:right-[calc(50%+0.75rem)]) showing the building rectangle and 93 sim dots scattered across the world; viewport rectangle outlined in foil yellow.
right-3 at the canvas right edge.
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.
/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. 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.
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.(x,y) in [pad, worldW-pad] × [pad, worldH-pad] with sim-vs-sim spacing. Many sims start off-screen at zoom 1; the mini-map is the discoverability hook.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.
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.
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).
setCamera fired on every pointermove during a drag — rerendering the entire GatheringWorld tree (sidebar tabs, sim avatars, etc) for every pixel of pan. Fix: the rAF loop now reads from a mutable cameraRef updated synchronously during drag; setCamera is rAF-coalesced via a panFlushRafRef and only fires at most once per frame.drawImage, renderAvatar, and drawPetFrame for each one regardless of visibility. Fix: compute the visible-world rect (with an avatarSize margin) once per render frame and continue past avatars whose center falls outside it. They keep walking (position update is gated separately), and lazy-re-render their offscreen when they come back into view via the existing lastRenderedFrame/lastRenderedDirection staleness check.worldW × worldH rect at world scale — 4× canvas pixels for the 2× world. Fix: dropped to a single canvas-coords fillRect before the transform (this also incidentally fixes the swamp-green floor in regression #1).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.
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.
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