feat(gastown): add 3D hex world visualization of town state#1039
feat(gastown): add 3D hex world visualization of town state#1039
Conversation
Adds an interactive 3D hex-tiled island that represents a Gastown town, using KayKit medieval hex models rendered with @react-three/fiber. The visualization maps rigs to districts, agents to buildings, beads to crates, and escalations to fires, all driven by live Town DO WebSocket data.
Code Review SummaryStatus: 2 Issues Found | Recommendation: Address before merge Fix these issues in Kilo Cloud Overview
Issue Details (click to expand)WARNING
Other Observations (not in diff)N/A Files Reviewed (12 files)
Reviewed by gpt-5.4-20260305 · 1,486,833 tokens |
- Pass linkedRigId through StructurePlacement so drawer panels get a valid rig ID for their rig-scoped tRPC queries - Change InstancedTileGroup from useMemo to useEffect so instance matrices are written after the ref is attached (not during render) - Query agents and beads for ALL rigs via useQueries instead of only the first rig, so multi-rig towns render complete data
| if (parsed && typeof parsed === 'object') { | ||
| const data = parsed as Record<string, unknown>; | ||
|
|
||
| if ('agents' in data && Array.isArray(data.agents)) { |
There was a problem hiding this comment.
WARNING: The WebSocket handler never updates the scene snapshot
/status/ws only sends the alarm-status snapshot (agents is an object of counts, not an array) plus lightweight { type: 'agent_status' } events. Because this branch only accepts { agents: Agent[] }, wsAgents stays null and the bead/agent state here never changes after the initial tRPC fetch. The 3D view will go stale until the page is refreshed.
| } else if (placement.kind === 'fire') { | ||
| // Bob up and down | ||
| const baseY = placement.elevation * LEVEL_HEIGHT + TILE_SURFACE_Y; | ||
| meshRef.current.position.y = baseY + Math.sin(clock.elapsedTime * 3) * 0.05; |
There was a problem hiding this comment.
WARNING: Fire animation applies the tile height twice
This mesh already lives inside <group position={[x, y, z]}>, so meshRef.current.position.y is a local offset. Adding placement.elevation * LEVEL_HEIGHT + TILE_SURFACE_Y again here doubles the vertical placement for escalation markers and makes them float well above the district tile. The bobbing offset should be relative to the group's origin instead.
| ); | ||
| if (unassignedBeads.length > 0 && ring1.length > 0) { | ||
| structures.push({ | ||
| col: center.col, |
There was a problem hiding this comment.
WARNING: Unassigned-bead market overlaps the rig flag
The district center is already occupied by the flag placed above, and both structures use the same col, row, and elevation. When a rig has unassigned beads this adds a market mesh at the exact same transform, so the models z-fight and the center tile becomes ambiguous to click. This should use a free slot from ring1/placementSlots instead of center.
Use negative bottom margin to reclaim the parent layout's terminal bar padding, and size the canvas to 100dvh minus the collapsed tab strip height (38px). The expanded terminal panel overlays on top of the 3D scene.
| // ── Assemble the snapshot ───────────────────────────────────────── | ||
|
|
||
| // Stabilize the query data arrays so useMemo deps work correctly | ||
| const rigAgentData = rigAgentQueries.map(q => q.data); |
There was a problem hiding this comment.
WARNING: This memo never stabilizes, so the whole 3D layout regenerates on every render
rigAgentQueries.map(...) and rigBeadQueries.map(...) allocate fresh arrays every render. Because those new arrays are later included in the useMemo dependency list for snapshot, the hook returns a brand new snapshot object even when the underlying query data is unchanged. HexVizPageClient then passes that unstable object into GastownHexScene, which forces generateLayout(snapshot) to rerun on every selection/connection update and defeats the memoization around the scene.
| const row = fr; | ||
| const key = `${col},${row}`; | ||
|
|
||
| if (!occupiedHexes.has(key)) { |
There was a problem hiding this comment.
WARNING: This occupancy check prevents the district roads from rendering
All center and district grass hexes are marked occupied before buildRoad() runs, and every district center is only 5 hexes away while each landmass has radius 2. That means the straight path is already fully occupied by the two land blobs, so this branch skips every interpolated tile and buildRoad() returns an empty list. If the roads are meant to connect into the center/district terrain, this needs to replace some grass tiles or reserve a corridor before the districts are marked occupied.
|
No bueno |
Summary
/gastown/[townId]/viz, using KayKit medieval hex tile models rendered with@react-three/fiberthree,@react-three/fiber,@react-three/drei,@types/three, andgsapas workspace dependenciesArchitecture
New modules in
src/components/gastown/hex-viz/:types.ts— Domain types mapping Gastown objects to hex worldhex-math.ts— Pointy-top hex coordinate math (offset/cube conversion, neighbor lookup, radius enumeration)layout-generator.ts— TransformsTownSnapshot→HexWorldLayout(tile + structure placement)HexTile3D.tsx— Instanced mesh rendering grouped by mesh type for draw call efficiencyStructure3D.tsx— Interactive buildings with glow, hover labels, click → drawer panelGastownHexScene.tsx— Full R3F scene with OrbitControls, environment lighting, shadows, foguse-town-snapshot.ts— Hook combining tRPC queries + WebSocket for live stateAssets from KayKit Medieval Hexagon pack (CC0 license):
hex-terrain.glb(all tile + decoration geometries), biome textures, HDR environment map.Verification
tsc --noEmitshows no errors in new files/gastown/[townId]/viz(auth-gated, feature-flagged)ssr: falseprevents Three.js SSR issuesVisual Changes
This is an entirely new page. No existing UI is changed (sidebar gets one new nav item).
Reviewer Notes
@react-three/fiberfor broad browser compatibilityhex-terrain.glbfile is ~2.5MB; consider CDN or lazy loading for production