Skip to content

Architecture Overview

Deep reference for the Endless Idle game package (packages/game/). This document covers every major subsystem, its source files, key exports, data flow, and the reasoning behind design decisions.

All file paths below are relative to packages/game/src/ unless stated otherwise.


1. System Overview

Technology Stack

LayerTechnologyWhy
ECSKootaGame state as entities + traits; systems run each animation frame, fully decoupled from the React render cycle
3D RenderingReact Three Fiber (R3F)React component model for mounting/unmounting Three.js objects; lifecycle cleanup via useEffect
GPU BackendWebGPU via three/webgpuTSL (Three Shading Language) for shader authoring; future-proof rendering backend replacing WebGL
UIReact + Tailwind CSSScreen-space overlays outside the Canvas; world-anchored UI via Drei <Html> inside the Canvas
DebugLeva, stats-glLeva for runtime parameter tweaking (hidden in prod); stats-gl for FPS/CPU/GPU overlay (dev only)

Why ECS?

React re-renders are expensive and unpredictable for real-time game state that mutates 60+ times per second. Koota ECS stores all game state as entities with traits (components). Plain functions ("systems") query and mutate that state each animation frame without triggering React renders. React is used only for mounting Three.js objects and DOM overlays -- it never drives game logic.

Monorepo Structure

packages/
  game/         # The game client (this document)
  wiki/         # VitePress documentation site
  marketing/    # Landing page
shared/
  game-data/    # JSON data + TypeScript constants shared across packages

Shared constants used by the game:

typescript
// shared/game-data/constants.ts
export const HEX_SIZE = 1;           // hex cell radius (center to corner)
export const CHUNK_SIZE = 32;        // hex cells per chunk side
export const TICK_MS = 250;          // ms per game tick (4 ticks/sec)
export const PLAYER_MOVE_SPEED = 3;  // world units per second

2. Entry Points & Boot Sequence

main.tsx -- Application Root

typescript
const isMapEditor = window.location.pathname === "/map-editor";
// ...
<WorldProvider world={world}>
  {isMapEditor ? <MapEditorApp /> : <App />}
</WorldProvider>

Simple route switch at line 9. The Koota WorldProvider wraps both routes so every component below can access the ECS world via useWorld(). There is no client-side router -- the path check is a one-shot conditional.

app.tsx -- Game Canvas (lines 27-69)

App sets up:

  1. Texture loading -- loadTerrainTextures() fires once in a useEffect (line 28-30)
  2. Leva debug panel -- hidden in production via import.meta.env.PROD (line 34)
  3. R3F <Canvas> with an async gl factory that initializes WebGPURenderer (lines 38-45). The trackTimestamp option is dev-only (enables GPU timer queries for stats-gl)
  4. Scene graph children mounted inside the Canvas:
    • <CameraRenderer snapBack /> -- spawns the camera ECS entity
    • <ChunksRenderer /> -- renders all chunk meshes
    • <HexHighlight /> -- hex hover outline via raycasting
    • <PlayerRenderer />, <EnemyRenderer />, <ResourceNodeRenderer /> -- entity renderers
    • <WorldUiRenderer /> -- Drei <Html> world-anchored UI
    • <DamageNumberRenderer /> -- floating combat numbers
    • <StatsGLRenderer /> -- dev-only FPS/GPU overlay
  5. <UiOverlay /> outside the Canvas -- screen-space HUD and menus (line 60)
  6. <Frameloop /> and <Startup /> -- headless React components that run ECS systems and spawn initial entities (lines 65-66)

WebGPU Renderer

The Canvas gl prop is an async factory -- R3F waits for renderer.init() to resolve before rendering the first frame. This is a WebGPU requirement; WebGL renderers initialize synchronously.

startup.ts -- Initial Entity Spawning (lines 16-35)

Runs once on mount via useEffect. Spawns:

  • Player at the center hex of chunk (0,0), computed as hexToWorld(floor(CHUNK_SIZE/2), floor(CHUNK_SIZE/2))
  • HUD entities via spawnHud(world) -- health bar, resource bar, minimap slot
  • Enemy entities via spawnEnemies(world)
  • Resource node entities via spawnResourceNodes(world)

The cleanup function destroys all spawned entities, preventing leaks during HMR or unmount.

frameloop.ts -- System Execution & Event Listeners (lines 22-77)

Frameloop is a headless React component that:

  1. Calls useAnimationFrame() to run all ECS systems each frame (lines 25-56)
  2. On mount, calls initMapManifest(world, "world") to load the map manifest (line 59)
  3. Registers keyboard listeners that write to the Keyboard world trait (lines 60-63)
  4. Calls setupPointerInput(world) to register all pointer/touch/wheel handlers (line 67)

3. ECS World & Traits

world.ts -- World Creation (lines 1-13)

typescript
export const world = createWorld(
  Time, Keyboard, PointerInput, SpatialHashMap,
  MapManifest, HexHoverState, GameTick, CameraRef,
);

These are world-level (global) traits -- singletons accessed via world.get(TraitName). They are not attached to any entity.

Global TraitShapePurpose
Time{ last, delta }Frame timing; delta is seconds since last frame
GameTick{ tick, accumulatedMs }Fixed-timestep accumulator; fires at 4 ticks/sec
KeyboardSet<string>Currently held keys (lowercase)
PointerInputAoS objectDrag state, delta, scroll, tap events, active pointer map
SpatialHashMapSpatialHashMapImpl(50)Grid-based proximity queries (cell size 50 world units)
MapManifestAoS objectworldId, authored set, biomeMap, ready flag, viewRadius
HexHoverState{ q, r, isHit }Axial coords of hovered hex; only valid when isHit is true
CameraRef{ camera, canvas }Three.js Camera + HTMLCanvasElement for pure ECS raycasting

traits/index.ts -- All Trait Definitions

Traits are organized by domain. Key categories:

Core / Identity

TraitLineKindDescription
Time9SoAFrame timing
GameTick10AoS (factory)Fixed-timestep tick counter
Keyboard11AoS (factory)Set<string> of held keys
Ref12AoS (factory)Three.js Object3D reference for ECS-to-scene sync
Transform14-18SoA (factories)position: Vector3, rotation: Euler, quaternion: Quaternion
IsPlayer20TagMarks the player entity

Skills & Combat (lines 22-61)

TraitKindDescription
SkillsAoS6 skills (attack, strength, defence, hitpoints, mining, woodcutting) each with { xp, level }
CombatStateAoStargetEntity, attackTimer, attackSpeed, inCombat
IsEnemyTagMarks enemy entities
EnemyStatsAoSFull stat block: attack, strength, defence, hitpoints, armor, xpReward, respawnTicks, ranges
IsDeadTagPresence = dead; gating for respawn logic
RespawnTimerSoAticksRemaining countdown
ArmorSoAvalue -- flat damage reduction

Movement (lines 63-74)

TraitKindDescription
MovementTargetSoAx, z, speed, hasTarget -- world-space destination
AutoBattleSoAenabled flag -- toggles automatic target seeking
PathDepthSoAvalue -- used for pathfinding depth tracking

Inventory & Consumables (lines 76-86)

TraitKindDescription
InventoryAoSslots: ItemStack[], maxSlots: 28
ConsumableSettingsSoAenabled, hpThreshold: 0.4 -- auto-eat threshold

Resource Nodes (lines 88-107)

TraitKindDescription
IsResourceNodeTagMarks resource node entities
ResourceNodeDataAoSnodeId, type (mining/woodcutting), requiredLevel, gatherTicks, xpPerGather, resourceItemId, depleted, respawnTicks
GatherStateAoStargetEntity, gatherTimer, isGathering

HP Regen & Spawn (lines 109-120)

TraitKindDescription
HpRegenSoAticksPerHeal: 4, healAmount: 1, timer
SpawnPositionSoAx, z -- original spawn location for respawn

Terrain (lines 122-297)

TraitKindDescription
ChunkCoordSoAq, r -- axial chunk coordinates
ChunkMeshAoSThree.js Mesh for this chunk
IsDirtyTagChunk needs geometry rebuild
IsLoadingTagChunk is awaiting async .bin fetch
ChunkChannelsAoS4 texture registry IDs for splat channels
ChunkBiomeSoAbiomeId -- which biome was used to generate this chunk
RibbonStateAoSactive, config: RibbonConfig | null -- world-level ribbon projection
BrushStateAoSEditor-only brush config: mode, type, texture, radius, strength, hit coords

Camera (lines 130-153)

TraitKindDescription
CameraOrbitSoAtheta, phi, radius + targets + pan offsets -- full orbit camera state
IsCameraTagMarks the camera entity
IsSnapBackTagPan offsets lerp to zero (game mode); absence = permanent pan (editor)
CameraRefAoSWorld-level Three.js Camera + canvas reference

UI (lines 181-252)

TraitKindDescription
IsHudElementTagScreen-space HUD element
IsMenuTagFull-screen overlay menu
IsWorldUITagWorld-anchored UI (Drei Html)
IsVisibleTagPresence = shown, absence = hidden
UiLayerSoAZ-ordering (reserved for future use)
AnchoredToRelationPositionally anchored to a game entity; autoDestroy: 'orphan' destroys UI when anchor dies
HealthBarSoAcurrent, max
ResourceBarSoAtype, current, max
IsMinimapSlotTagPlaceholder for minimap rendering
IsEnemyHpBarTagWorld-anchored enemy HP bar
EnemyHpBarDataAoSname, current, max
DamageNumberAoSFloating damage number data: value, flatX, flatZ, elapsed, duration
DialogContentSoAspeakerName, text, portrait
DialogTimerSoAelapsed, duration -- 0 duration = manual dismiss
ChatBubbleSoAtext, style (speech/thought/shout)
MenuTypeSoAtype (inventory/settings/npc-shop)
MenuDataAoSpayload: unknown -- arbitrary per-menu data

ECS Concepts Cheat Sheet

  • SoA traits -- data is stored in struct-of-arrays. Use entity.set(Trait, { field: value }) to write. .get() returns a snapshot for reading but mutations do not propagate unless .set() is called.
  • AoS traits -- defined with a factory function trait(() => ...). .get() returns a live mutable reference. Mutations are visible immediately. Call .changed(Trait) to notify reactive queries (e.g. useTrait).
  • Tag traits -- trait() with no data. Used as presence/absence flags. entity.add(Tag) / entity.remove(Tag).
  • Relations -- relation() creates a link between entities. AnchoredTo(anchor) means "this entity is anchored to anchor." autoDestroy: 'orphan' destroys the relation holder when the target dies.

actions.ts -- Entity Factories (lines 19-42)

typescript
export const actions = createActions((world) => ({
  spawnPlayer: (position?: Vector3) => {
    const entity = world.spawn(
      IsPlayer, Transform, Health, Skills, CombatState,
      MovementTarget, AutoBattle, Inventory, ConsumableSettings,
      HpRegen, SpawnPosition, GatherState,
    );
    // ... set position, spawn position, movement speed
    return entity;
  },
}));

createActions is Koota's pattern for world-scoped factory functions. Components use useActions(actions) to get bound versions.

actions/ui.ts -- UI Entity Actions (lines 22-124)

FunctionPurpose
spawnHud(world)Spawns health bar, resource bar, minimap slot. Guards against duplicate spawning with queryFirst. Returns entity array for cleanup.
spawnChatBubble(world, anchor, opts)Spawns a timed world-anchored chat bubble with AnchoredTo(anchor) relation
spawnDialog(world, anchor, opts)Spawns a world-anchored dialog panel. Destroys any existing dialog on the same anchor first (one dialog per NPC).
dismissDialog(entity)Destroys a dialog entity
openMenu(world, type, payload?)Opens or shows a menu. Reuses existing hidden menu entities of the same type rather than spawning new ones -- adds IsVisible if absent. Calls .changed(MenuData) to notify reactive queries.
closeMenu(entity)Hides a menu by removing IsVisible (does not destroy)
closeAllMenus(world)Removes IsVisible from all menu entities

Menu Reuse Pattern

openMenu does not destroy and recreate menu entities. It queries for an existing entity with matching MenuType, then adds IsVisible to show it. This avoids re-mounting React subtrees and keeps menu state (scroll position, etc.) intact.


4. Frameloop & System Execution Order

Execution Order (frameloop.ts:25-55)

Every animation frame, systems run in this exact order:

Phase 1: Input & Spatial
  updateTime(world)              // compute delta from performance.now()
  pollCameraInput(world)         // consume PointerInput deltas -> camera orbit targets
  handlePlayerTap(world)         // consume tap event -> set player MovementTarget
  updateSpatialHashing(world)    // insert/update entities in spatial hash grid
  cleanupSpatialHashMap(world)   // remove dead entities from spatial hash

Phase 2: Camera & Transform
  updateCamera(world)            // lerp camera orbit toward targets, compute final position
  syncTransformToRef(world)      // copy ECS Transform -> Three.js Ref (one-way)

Phase 3: UI
  syncHudWithPlayer(world)       // player Health/Resource -> HUD bar entities
  updateDialogTimers(world)      // tick dialog auto-dismiss timers, destroy expired

Phase 4: Movement & Game Logic
  updateMovement(world)          // move entities toward MovementTarget each frame
  gameTick(world)                // fixed-timestep accumulator: runs tick sub-systems at 4/sec

Phase 5: Terrain Pipeline
  streamChunks(world)            // spawn/destroy chunk entities based on player position
  loadAuthoredChunks(world)      // async fetch .bin files for IsLoading chunks
  buildDirtyChunks(world)        // build BufferGeometry for ONE dirty chunk per frame

Why This Order?

  • Input first: pollCameraInput and handlePlayerTap consume accumulated pointer deltas before camera or movement systems read them, preventing one-frame-old input.
  • Spatial before camera: Spatial hashing must be current so handlePlayerTap raycast queries work against up-to-date positions.
  • Camera before transform sync: Camera orbit math runs before syncTransformToRef copies positions to Three.js, so the renderer sees the latest camera position this frame.
  • UI after transform sync: HUD reads player stats after combat/movement have updated them, so bars never show stale values.
  • Movement before gameTick: Smooth per-frame movement runs before fixed-timestep game logic so positions are current when combat range checks fire.
  • Terrain last: Terrain streaming and building are the most expensive operations and don't affect gameplay logic. Running them last means if a frame is over budget, the latency is in terrain loading rather than input responsiveness.

Fixed-Timestep Game Tick (systems/game-tick.ts)

typescript
export function gameTick(world: World) {
  const gt = world.get(GameTick)!;
  gt.accumulatedMs += time.delta * 1000;

  while (gt.accumulatedMs >= TICK_MS) {   // TICK_MS = 250 (4 ticks/sec)
    gt.accumulatedMs -= TICK_MS;
    gt.tick++;
    // ... run tick sub-systems
  }
}

Tick sub-systems run inside the accumulator loop (in order):

  1. tickAutoBattle -- seek nearest enemy when auto-battle is enabled
  2. tickAutoGather -- seek nearest resource node when idle
  3. tickCombat -- attack timer, damage calculation, XP reward
  4. tickConsumables -- auto-eat food when HP below threshold
  5. tickGathering -- gather timer, XP and item reward
  6. tickDeriveStats -- recompute derived stats from skill levels
  7. tickHpRegen -- passive HP recovery when out of combat
  8. tickRespawn -- countdown dead enemies, respawn at SpawnPosition
  9. syncEnemyHpBar -- update world-anchored enemy HP bar data

Why fixed timestep? Combat balance depends on consistent tick rates. A 144Hz monitor should not attack faster than a 60Hz one. The accumulator pattern decouples game logic frequency from render frequency.


5. Terrain Pipeline

The terrain is a hex-grid chunk system. Each chunk is a CHUNK_SIZE x CHUNK_SIZE (32x32) grid of hexagonal tiles. The pipeline has three stages that run every frame.

Stage 1: Stream Chunks (systems/stream-chunks.ts)

Purpose: Spawn and destroy chunk entities based on player proximity.

Key behavior (lines 25-92):

  • Streams around the player position (not the camera), preventing chunk loading on every camera orbit rotation
  • Uses Chebyshev (square) distance in axial space with viewRadius (default 5 chunks)
  • Only spawns chunks present in MapManifest.authored -- unlisted coordinates are void
  • New chunks get ChunkCoord, ChunkMesh (with sharedTerrainMaterial), and IsLoading tag
  • Mesh starts visible = false until buildDirtyChunks fills geometry
  • On unload: disposes geometry, clears vertex registry entries for all hex centers + corners, destroys entity

Optimization: Tracks lastChunkQ/lastChunkR/lastViewRadius at module scope (lines 20-23). Skips all streaming work if the player has not crossed a chunk boundary and view radius is unchanged.

Stage 2: Load Authored Chunks (systems/load-authored-chunks.ts)

Purpose: Fetch .bin files for chunks tagged IsLoading and decode vertex data.

Key behavior (lines 14-72):

  • Prevents duplicate in-flight fetches via a WeakMap<object, Set<string>> keyed on the MapManifest AoS object (line 12). This gives stable per-world isolation.
  • Fetches /maps/{worldId}/chunks/{q}_{r}.bin
  • Decodes binary via decodeChunkFile() (see Map Format below)
  • Writes all decoded vertices into the global vertexRegistry
  • Assigns ChunkBiome from the manifest's biomeMap
  • Transitions: removes IsLoading, adds IsDirty
  • Re-dirties neighbors (lines 50-63): After loading, marks all 6 adjacent chunks as IsDirty so they rebuild with the newly available shared boundary vertex data

Stage 3: Build Dirty Chunks (systems/build-dirty-chunks.ts)

Purpose: Build GPU-ready BufferGeometry from vertex registry data.

Critical constraint: Processes ONE chunk per frame (line 71: world.queryFirst(...)). This caps per-frame cost and prevents jank when many chunks load simultaneously.

Per-chunk build process (lines 70-247):

  1. Read vertex data from vertexRegistry for all hex centers + corners in the chunk
  2. For each hex, emit 6 triangles (18 vertices total per hex = VERTICES_PER_HEX)
  3. Texture canonicalization (canonicalizeTriangle, lines 27-68): For each triangle, compute a single canonical slot assignment from the 3 vertices' texture indices. This ensures linearly-interpolated weights carry consistent meaning across the triangle -- without it, different slot mappings on different vertices create semantic mismatches in the fragment shader.
  4. Build typed arrays: positions (Float32x3), uvs (Float32x2), weights (Float32x4), texIndices (Float32x4), tints (Float32x3)
  5. Ribbon projection (lines 83-95): When RibbonState is active, writePosition projects flat hex positions onto the helix surface via hexToUV and projectToRibbon instead of using flat XZ layout
  6. Upload to GPU via BufferAttribute. If geometry already exists, updates in-place with needsUpdate = true (prevents one-frame visibility gaps during brush painting). Otherwise creates a new BufferGeometry.
  7. Sets mesh.visible = true, signals entity.changed(ChunkMesh), removes IsDirty

Vertex Registry (utils/vertex-registry.ts)

typescript
export type VertexData = {
    x: number;
    z: number;
    height: number;
    weights: [number, number, number, number];
    textureIndices: [number, number, number, number];
    tint: [number, number, number];
};

export const vertexRegistry = new Map<string, VertexData>();

A global Map<string, VertexData> keyed by vertexKey(x, z). This is the single source of truth for all terrain vertex data.

Why a global registry? Adjacent chunks share boundary vertices (hex corners). The registry ensures both chunks read the same height/texture data at shared positions, eliminating seams. When a chunk loads, its vertex data overwrites any existing entries -- then neighbors are re-dirtied to rebuild with the updated data.

Vertex Key Precision (utils/hex-math.ts:50-52)

typescript
export function vertexKey(x: number, z: number): string {
  return `${Math.round(x * 100) / 100}-${Math.round(z * 100) / 100}`;
}

Rounds to 2 decimal places. This is coarse enough to survive float32/float64 round-trips in .bin chunk files, but fine enough to distinguish all hex vertices (minimum separation = HEX_SIZE = 1.0, resolution = 0.01).

Map Data Format

Map data lives in packages/game/public/maps/. Structure:

maps/
  index.json              # lists available levels
  world/
    manifest.json         # v1: chunk keys, biome assignments, optional ribbon config
    chunks/
      0_0.bin             # binary vertex data for chunk (0, 0)
      1_0.bin
      ...

Manifest format (systems/init-map-manifest.ts:43-48):

typescript
{
  v: 1,
  chunks: ["0_0", "1_0", "-1_0", ...],           // chunk keys
  biomes?: { "0_0": "grassland", "1_0": "forest" }, // per-chunk biome
  ribbon?: { R, W, P, segmentArc, segmentStartAngle } // optional ribbon config
}

Binary chunk format (utils/map-format.ts):

  • Magic bytes: MAP\x08 (4 bytes)
  • Vertex count: uint32 (4 bytes, little-endian)
  • Per vertex (56 bytes):
    • x, z, height: 3x float32 (12 bytes)
    • w0, w1, w2, w3: 4x float32 splat weights (16 bytes)
    • ti0, ti1, ti2, ti3: 4x uint32 texture indices (16 bytes)
    • tintR, tintG, tintB: 3x float32 RGB tint (12 bytes)

Weights are renormalized on load (line 50-51 of map-format.ts) to correct float32 drift from repeated edits.

Manifest Loading (systems/init-map-manifest.ts)

initMapManifest(world, worldId) is called once on mount. It:

  1. Fetches /maps/{worldId}/manifest.json
  2. Validates version (must be v: 1)
  3. Populates MapManifest.authored set and biomeMap
  4. If the manifest has a ribbon key, adds RibbonState to the world and configures it (lines 56-68)
  5. Sets manifest.ready = true -- this gates streamChunks (which returns early until ready)

The function deduplicates concurrent calls via inFlightByManifest WeakMap and supports cancellation via cancelPendingManifest() for clean level switches.


6. Biome System

Biomes define how procedural terrain is generated in the map editor. They are not used at runtime in the main game (which loads pre-authored .bin chunks), but the authored data was originally generated from biome definitions.

Biome Definition Shape (biomes/types.ts)

typescript
export interface BiomeDefinition {
  id: string;
  name: string;
  noise: NoiseConfig;   // height map: algorithm, frequency, octaves, heightScale, exponent, warpStrength
  layers: [TextureLayer, TextureLayer, TextureLayer, TextureLayer]; // 4 splat channels
}

Each TextureLayer has:

  • textureId and path -- which texture to use
  • tint: [r, g, b] -- RGB multiplier applied per-vertex
  • noise: NoiseConfig -- independent noise for this layer's weight map
  • coverage: number -- bias in [-1, 1] added to the noise output. 0 = ~50% coverage, 0.4 = dominant, -0.4 = sparse
  • Optional variant -- secondary texture blended by its own noise frequency

No height thresholds. Unlike traditional biome systems that say "above 0.5 = rock", each layer has independent noise. The 4 weights are noise-computed per vertex and renormalized. This produces more organic transitions.

Supported noise algorithms: fbm, ridged, billow, caldera, perlin.

Biome Registry (biomes/registry.ts)

typescript
export const BIOMES: Record<string, BiomeDefinition> = {};
export const activeBiome: BiomeDefinition = {} as BiomeDefinition;

export function registerBiome(def: BiomeDefinition): void { ... }
export function loadBiomePreset(id: string): void {
  const clone = JSON.parse(JSON.stringify(preset)); // deep clone
  Object.assign(activeBiome, clone);
}
  • BIOMES -- immutable presets, populated at import time
  • activeBiome -- mutable singleton. Systems read from it directly. Mutate fields in-place to change terrain without a full reload (e.g., activeBiome.noise.frequency = 0.2).
  • loadBiomePreset(id) -- deep-clones a preset into activeBiome, replacing all values

Built-in Biomes

Six definitions in biomes/definitions/:

FileIDNotes
grassland.tsgrasslandDefault preset, loaded on init
forest.tsforest
desert.tsdesert
mountains.tsmountains
tundra.tstundra
volcanic.tsvolcanicUses the caldera noise algorithm

Registration happens via side-effect imports in biomes/index.ts (lines 14-19). The grassland preset is loaded as the default on line 23.


7. Texture Pipeline

Texture Registry (materials/terrain-texture-registry.ts)

Five terrain textures are declared in a static catalogue:

typescript
export const TERRAIN_TEXTURES = [
    { id: 'grass_2', path: '/textures/terrain/grass_2.jpg' },
    { id: 'rock_2',  path: '/textures/terrain/rock_2.jpg'  },
    { id: 'sand_1',  path: '/textures/terrain/sand_1.jpg'  },
    { id: 'snow_1',  path: '/textures/terrain/snow_1.jpg'  },
    { id: 'brick_path', path: '/textures/terrain/brick_path.jpg' },
] as const;

Key exports:

ExportPurpose
TERRAIN_TEXTURESStatic catalogue array
getTextureIndex(id)Maps string ID to integer index (throws in dev if unknown)
terrainTexUniformsArray of TSL uniformTexture nodes, one per texture slot
loadTerrainTextures()Async loader: fetches images, draws to canvas at 512x512, creates DataTexture with mipmaps, swaps into uniform nodes

Why individual textures instead of a texture array? TSL's vec3 UV API for DataArrayTexture is unsupported in the current Three.js WebGPU backend (noted in the source comment at line 39). Individual uniforms with select() is the explicit fallback.

Each texture uniform starts as a 1x1 grey DataTexture placeholder so the material is valid before async loading completes.

Terrain Material (materials/terrain-material.ts)

A single shared MeshStandardNodeMaterial instance used by all chunks:

typescript
export const sharedTerrainMaterial = new MeshStandardNodeMaterial();
sharedTerrainMaterial.roughness = 0.9;
sharedTerrainMaterial.metalness = 0.0;

The colorNode is a TSL function (lines 19-68) that:

  1. Reads per-vertex attributes: weights (vec4), textureIndices (vec4, flat interpolation), tint (vec3)
  2. Computes UVs from positionWorld.xz * TILE_SCALE (0.5)
  3. Samples all 5 textures at those UVs
  4. Selects 4 texture colors using nested select() chains keyed on the integer texture index per slot
  5. Depth blending: Adds per-slot noise (mx_noise_float with phase offsets) to the splat weights, clamps negatives, renormalizes. This creates organic edge transitions between textures rather than hard splat boundaries.
  6. Blends the 4 selected colors by the depth-blended weights, multiplied by the per-vertex tint

Why a Shared Material?

One material for all chunks means one shader compilation. Per-vertex attributes (weights, textureIndices, tint) carry all the variation. Without sharing, each chunk would compile its own shader variant, causing seconds of GPU stalls on load.

Why flat interpolation for texture indices? The textureIndices attribute uses .setInterpolation('flat') (line 25). Without this, the GPU would interpolate integer indices between vertices, producing fractional values that sample the wrong texture. Flat interpolation uses the provoking vertex's value for the entire triangle.


8. Ribbon System

The ribbon system projects flat hex-grid chunks onto a 3D helical ribbon surface, creating a winding path through 3D space.

Ribbon Config (ribbon/types.ts)

typescript
export interface RibbonConfig {
  R: number;              // helix radius -- center axis to ribbon centerline
  W: number;              // ribbon width -- playable cross-section
  P: number;              // pitch -- vertical rise per full 2pi revolution
  segmentArc: number;     // angular span of this level segment (radians)
  segmentStartAngle: number; // starting theta (radians)
  biomeId: string;        // biome preset for noise sampling
}

Projection Math (ribbon/ribbon-math.ts)

projectToRibbon(t, w, displacement, config) (lines 16-59):

Maps parametric UV coordinates to 3D world position:

  1. t in [0, 1] -- progress along ribbon length
  2. w in [-0.5, 0.5] -- position across ribbon width (0 = centerline)
  3. displacement -- height pushed along surface normal (terrain elevation)

The math:

  • Computes theta from segment start angle + t * arc span
  • Calculates helix centerline: (R*cos(theta), theta/(2pi)*P, R*sin(theta))
  • Computes radial outward vector (perpendicular to helix axis in XZ plane)
  • Computes tangent vector along the helix
  • Derives surface normal via cross product of tangent and radial vectors
  • Final position: centerline + wWradial + displacement*normal

hexToUV(hexWorldX, hexWorldZ, arcLength, W) (lines 88-98):

Converts flat hex world coordinates to ribbon UV parameters:

  • t = hexWorldZ / arcLength
  • w = hexWorldX / W - 0.5

segmentArcLength(config) (lines 65-70):

Computes arc length in world units: sqrt(R^2 + (P/2pi)^2) * segmentArc. Used to scale noise coordinates so texture density is consistent regardless of helix parameters.

Integration Points

The ribbon system integrates at three key points:

  1. buildDirtyChunks (systems/build-dirty-chunks.ts:83-95): When RibbonState.active is true, the writePosition helper projects flat hex positions through hexToUV and projectToRibbon instead of writing flat XZ coordinates.

  2. syncTransformToRef (systems/sync-transform-to-ref.ts:8-15): Reads RibbonState and delegates to projectPosition(), which applies ribbon projection to entity positions (player, enemies, etc.) so they follow the helix surface.

  3. HexHighlight (entities/hex-highlight.tsx:127-175): In ribbon mode, reads flat-space coordinates from the UV attribute (which stores the original flat hex positions) rather than the 3D hit point, then projects highlight corners through the ribbon math.

Position Projection Utility (utils/project-position.ts)

typescript
export function projectPosition(
  flatX: number, flatZ: number,
  ribbonConfig: RibbonConfig | null,
  out: { x: number; y: number; z: number },
): void

Used by syncTransformToRef and WorldUiRenderer. Looks up terrain height from vertexRegistry, then either writes flat XZ or projects through projectToRibbon. Reuses a pre-allocated output object to avoid garbage collection pressure.


9. UI System

All UI elements are Koota entities. The system splits rendering into two contexts that map to different DOM/scene locations.

Screen-Space UI (ui/UiOverlay.tsx)

typescript
export function UiOverlay() {
  return (
    <div className="absolute inset-0 pointer-events-none z-10" data-ui-overlay>
      <HudRenderer />
      <MenuRenderer />
    </div>
  );
}

Mounted outside the R3F <Canvas> as a regular React div. The data-ui-overlay attribute is used by setupPointerInput to detect UI taps and prevent them from triggering world interactions (line 79 of setup-pointer-input.ts).

HudRenderer (ui/hud/HudRenderer.tsx):

  • Queries (IsHudElement, IsVisible, HealthBar), (IsHudElement, IsVisible, ResourceBar), (IsHudElement, IsVisible, IsMinimapSlot)
  • Renders HealthBarUi, ResourceBarUi, MinimapSlotUi components
  • Also renders SkillsPanel and AutoBattleToggle as fixed-position elements

MenuRenderer (ui/menus/MenuRenderer.tsx):

  • Queries (IsMenu, IsVisible, MenuType)
  • Dispatches to InventoryMenu, SettingsMenu, or NpcShopMenu based on MenuType.type

World-Anchored UI (ui/WorldUiRenderer.tsx)

Mounted inside the <Canvas>. Uses Drei's <Html> component to render DOM elements at 3D positions.

typescript
const entities = useQuery(IsWorldUI, IsVisible, AnchoredTo("*"));

The AnchoredTo("*") wildcard matches any anchor target. Each entity renders an AnchoredHtml component that:

  1. Reads the anchor entity's Transform each frame via useFrame
  2. Projects the position through projectPosition (handles ribbon mode)
  3. Scales the UI element based on camera zoom distance relative to a reference radius of 60
  4. Conditionally renders ChatBubbleUi, DialogUi, or EnemyHpBarUi based on which traits the entity has

UI Systems

syncHudWithPlayer (systems/sync-hud-with-player.ts):

  • Queries the player's Health and Resource traits
  • Updates HealthBar and ResourceBar HUD entities only when values differ (dirty check avoids unnecessary Koota change signals)

updateDialogTimers (systems/update-dialog-timers.ts):

  • Queries (IsVisible, DialogTimer)
  • Increments elapsed by delta * 1000 (delta is seconds, duration is ms)
  • Destroys entities when elapsed >= duration
  • Duration of 0 means manual dismiss only (skipped in the update loop)

Entity Lifecycle

spawn → add IsVisible → [user interacts] → remove IsVisible → [reuse or destroy]
  • HUD elements: Spawned once in startup.ts, never destroyed during gameplay
  • Menus: Spawned on first openMenu call, then toggled via IsVisible add/remove
  • Dialogs/Chat bubbles: Spawned per event, auto-destroyed by timer or anchor death (via autoDestroy: 'orphan')
  • Enemy HP bars: Spawned when combat begins, destroyed when the anchor entity dies

10. Map Editor

Route & Modes (routes/map-editor/index.tsx)

Accessed at /map-editor. The MapEditorApp component (line 223) manages:

  • Mode selector -- top toolbar with buttons for biomes, levels, ribbon-levels (line 293)
  • Left sidebar -- BiomeSidebar (biome preset picker, visible in biomes mode only)
  • Right sidebar -- mode-dependent panel:
    • biomes mode: BiomeNoisePanel + 4x BiomeLayerPanel + ExportBiomeButton
    • levels mode: LevelsBrushPanel + ExportButton
    • ribbon-levels mode: RibbonLevelsPanel
  • Canvas -- same WebGPU setup as the main game, with BrushCursor and BrushRaycaster instead of player/enemy renderers

Editor Startup (StartupMapEditor, line 92)

Different initialization per mode:

ModeBehavior
biomesLoads a sentinel manifest (__biome_preview__), spawns a single chunk at (0,0) with IsDirty. generateChunkData fills procedural terrain.
levelsSets viewRadius = 5, calls initMapManifest with the selected level ID. If the manifest has ribbon config, calls fitCameraToRibbon.
ribbon-levelsClears vertex registry, sets RibbonState active with the UI-driven config, computes chunk keys from ribbon geometry, spawns chunks with IsDirty.

Editor Frameloop (MapEditorFrameloop, line 202)

Similar to the game frameloop but:

  • No spatial hashing, movement, or combat systems
  • Runs paintBrush(world, delta) in levels mode (applies sculpt/texture brush strokes)
  • Runs generateChunkData(world) (procedural terrain generation for biome preview)
  • Skips streamChunks and loadAuthoredChunks in ribbon-levels mode

Ghost Chunk Stamping (lines 256-275)

In levels mode, clicking empty space shows a GhostChunk overlay. Clicking it opens a biome picker popup. Selecting a biome stamps a new chunk: loads the biome preset, adds the key to the manifest, spawns a chunk entity with ChunkBiome and IsDirty.

Export

  • ExportButton -- exports all authored chunks as .bin files + manifest.json
  • ExportBiomeButton -- exports the current biome configuration as a stamp

11. Key Architectural Patterns

Transform Sync: ECS as Source of Truth

All game state lives in ECS traits. Three.js objects are output targets, not sources.

The pattern:

  1. React renderer creates a Three.js object (mesh, group, etc.)
  2. Shares it with ECS via the Ref trait: entity.set(Ref, group)
  3. Systems mutate entity.get(Transform).position etc.
  4. syncTransformToRef copies Transform -> Ref every frame (one-way)
typescript
// systems/sync-transform-to-ref.ts
world.query(Transform, Ref).updateEach(([transform, ref]) => {
    projectPosition(transform.position.x, transform.position.z, config, _pos);
    ref.position.set(_pos.x, _pos.y, _pos.z);
    ref.quaternion.copy(transform.quaternion);
});

Why? This prevents React renders from interfering with game logic. Systems can safely assume Transform is always authoritative. The only place Three.js objects are written to is syncTransformToRef.

Exception: CameraRenderer sets the camera entity's Transform to share the same Vector3/Euler/Quaternion instances as the Three.js camera (so updateCamera can write directly to both). This is the reverse of the normal pattern and is unique to the camera.

Manifest-Driven Streaming

Chunks are only spawned if their key exists in MapManifest.authored. The manifest is the contract between the map editor (which exports it) and the runtime (which reads it). This means:

  • No terrain is generated at runtime -- all geometry comes from pre-authored .bin files
  • The world can have arbitrary shapes (islands, paths) without wasting resources on empty chunks
  • Streaming is a simple set membership check: if (!manifest.authored.has(key)) continue

One-Per-Frame Builds

buildDirtyChunks processes exactly one chunk per frame (world.queryFirst). This is a deliberate trade-off:

  • Pro: Guarantees bounded per-frame work regardless of how many chunks are dirty. Prevents frame spikes when 20 chunks load simultaneously.
  • Con: If 20 chunks are dirty, it takes 20 frames (~333ms at 60fps) to build them all. During this window, some chunks are invisible.

In practice, chunks load fast enough that this window is barely noticeable. The alternative (building all dirty chunks) would cause 100ms+ frame spikes that are far more jarring.

Shared Boundary Vertices

Adjacent hex chunks share corner vertices. The vertexRegistry is the deduplication mechanism:

  1. Chunk A loads and writes its vertex data to the registry
  2. Chunk B loads and writes its vertex data -- including corners that overlap with chunk A
  3. Chunk A is re-dirtied (via loadAuthoredChunks's neighbor re-dirty logic)
  4. When chunk A rebuilds, it reads the now-updated shared corners from the registry

This eliminates terrain seams at chunk boundaries without any explicit stitching logic.

Hex Math (utils/hex-math.ts)

The game uses flat-top hex orientation with axial coordinates (q, r):

FunctionDescription
hexToWorld(q, r)Axial coords to world XZ position
worldToHex(x, z)World XZ to nearest axial coords (cube rounding)
worldToChunk(x, z)World XZ to chunk-space coords (integer floor)
hexCorners(q, r)Returns 6 corner positions of a hex cell
vertexKey(x, z)Stable string key for vertex deduplication (2-decimal precision)

Leva for Dev-Only Controls

Leva panels are hidden in production (<Leva hidden={import.meta.env.PROD} />). In development, they provide real-time parameter tweaking for biome noise, camera settings, and other systems. The import.meta.env.DEV guard also tree-shakes StatsGLRenderer from production builds.

CORS Headers

packages/game/vite.config.ts sets Cross-Origin-Embedder-Policy: require-corp and Cross-Origin-Opener-Policy: same-origin. These are required for SharedArrayBuffer (used by Koota's worker functionality). Removing them will break the game at runtime.