Skip to content

feat(gastown): add 3D hex world visualization of town state#1039

Closed
jrf0110 wants to merge 3 commits intomainfrom
feat/gastown-3d-hex-viz
Closed

feat(gastown): add 3D hex world visualization of town state#1039
jrf0110 wants to merge 3 commits intomainfrom
feat/gastown-3d-hex-viz

Conversation

@jrf0110
Copy link
Contributor

@jrf0110 jrf0110 commented Mar 11, 2026

Summary

  • Adds an interactive 3D hex-tiled island visualization for Gastown towns at /gastown/[townId]/viz, using KayKit medieval hex tile models rendered with @react-three/fiber
  • The layout is data-driven: rigs become districts placed in a ring around the mayor's Town Hall, agents become cottages/windmills, beads become crates, escalations become fires, and roads connect districts to the center — all surrounded by water
  • Live updates flow via the existing Town DO status WebSocket, so agent status changes (working/stalled/idle) reflect as glow color updates on structures in real-time
  • Adds three, @react-three/fiber, @react-three/drei, @types/three, and gsap as workspace dependencies

Architecture

TownSnapshot (WebSocket + tRPC)
  → layout-generator.ts (deterministic hex world layout)
    → GastownHexScene.tsx (R3F Canvas with instanced tiles + structures)

New modules in src/components/gastown/hex-viz/:

  • types.ts — Domain types mapping Gastown objects to hex world
  • hex-math.ts — Pointy-top hex coordinate math (offset/cube conversion, neighbor lookup, radius enumeration)
  • layout-generator.ts — Transforms TownSnapshotHexWorldLayout (tile + structure placement)
  • HexTile3D.tsx — Instanced mesh rendering grouped by mesh type for draw call efficiency
  • Structure3D.tsx — Interactive buildings with glow, hover labels, click → drawer panel
  • GastownHexScene.tsx — Full R3F scene with OrbitControls, environment lighting, shadows, fog
  • use-town-snapshot.ts — Hook combining tRPC queries + WebSocket for live state

Assets from KayKit Medieval Hexagon pack (CC0 license): hex-terrain.glb (all tile + decoration geometries), biome textures, HDR environment map.

Verification

  • TypeScript compilation passes — tsc --noEmit shows no errors in new files
  • New page route loads at /gastown/[townId]/viz (auth-gated, feature-flagged)
  • Sidebar link added under "3D Town" with Globe icon
  • Dynamic import with ssr: false prevents Three.js SSR issues

Visual Changes

This is an entirely new page. No existing UI is changed (sidebar gets one new nav item).

Before After
No 3D view Interactive hex island: town hall at center, rig districts in ring, agents as buildings with status glow, beads as crates, water surrounding

Reviewer Notes

  • The 3D scene uses WebGL (not WebGPU) via standard @react-three/fiber for broad browser compatibility
  • Structures without matching GLB meshes fall back to colored boxes — this is intentional for the first version
  • The hex-terrain.glb file is ~2.5MB; consider CDN or lazy loading for production
  • The layout generator is deterministic (seeded by object IDs), so the same town always produces the same world layout
  • GSAP is installed but not yet actively used — it's available for future tile drop animations and transitions

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.
@kilo-code-bot
Copy link
Contributor

kilo-code-bot bot commented Mar 11, 2026

Code Review Summary

Status: 2 Issues Found | Recommendation: Address before merge

Fix these issues in Kilo Cloud

Overview

Severity Count
CRITICAL 0
WARNING 2
SUGGESTION 0
Issue Details (click to expand)

WARNING

File Line Issue
src/components/gastown/hex-viz/use-town-snapshot.ts 130 Snapshot memo invalidates every render, forcing full 3D layout regeneration
src/components/gastown/hex-viz/layout-generator.ts 436 Occupancy check skips every road tile, so district roads never render
Other Observations (not in diff)

N/A

Files Reviewed (12 files)
  • package.json - 0 issues
  • src/app/(app)/gastown/[townId]/viz/HexVizPageClient.tsx - 0 issues
  • src/app/(app)/gastown/[townId]/viz/page.tsx - 0 issues
  • src/components/gastown/GastownTownSidebar.tsx - 0 issues
  • src/components/gastown/hex-viz/GastownHexScene.tsx - 0 issues
  • src/components/gastown/hex-viz/HexTile3D.tsx - 0 issues
  • src/components/gastown/hex-viz/Structure3D.tsx - 0 issues
  • src/components/gastown/hex-viz/hex-math.ts - 0 issues
  • src/components/gastown/hex-viz/index.ts - 0 issues
  • src/components/gastown/hex-viz/layout-generator.ts - 1 issue
  • src/components/gastown/hex-viz/types.ts - 0 issues
  • src/components/gastown/hex-viz/use-town-snapshot.ts - 1 issue

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)) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@jrf0110
Copy link
Contributor Author

jrf0110 commented Mar 11, 2026

No bueno

@jrf0110 jrf0110 closed this Mar 11, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant