Appearance
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
| Layer | Technology | Why |
|---|---|---|
| ECS | Koota | Game state as entities + traits; systems run each animation frame, fully decoupled from the React render cycle |
| 3D Rendering | React Three Fiber (R3F) | React component model for mounting/unmounting Three.js objects; lifecycle cleanup via useEffect |
| GPU Backend | WebGPU via three/webgpu | TSL (Three Shading Language) for shader authoring; future-proof rendering backend replacing WebGL |
| UI | React + Tailwind CSS | Screen-space overlays outside the Canvas; world-anchored UI via Drei <Html> inside the Canvas |
| Debug | Leva, stats-gl | Leva 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 packagesShared 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 second2. 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:
- Texture loading --
loadTerrainTextures()fires once in auseEffect(line 28-30) - Leva debug panel -- hidden in production via
import.meta.env.PROD(line 34) - R3F
<Canvas>with an asyncglfactory that initializesWebGPURenderer(lines 38-45). ThetrackTimestampoption is dev-only (enables GPU timer queries for stats-gl) - 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
<UiOverlay />outside the Canvas -- screen-space HUD and menus (line 60)<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:
- Calls
useAnimationFrame()to run all ECS systems each frame (lines 25-56) - On mount, calls
initMapManifest(world, "world")to load the map manifest (line 59) - Registers keyboard listeners that write to the
Keyboardworld trait (lines 60-63) - 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 Trait | Shape | Purpose |
|---|---|---|
Time | { last, delta } | Frame timing; delta is seconds since last frame |
GameTick | { tick, accumulatedMs } | Fixed-timestep accumulator; fires at 4 ticks/sec |
Keyboard | Set<string> | Currently held keys (lowercase) |
PointerInput | AoS object | Drag state, delta, scroll, tap events, active pointer map |
SpatialHashMap | SpatialHashMapImpl(50) | Grid-based proximity queries (cell size 50 world units) |
MapManifest | AoS object | worldId, 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
| Trait | Line | Kind | Description |
|---|---|---|---|
Time | 9 | SoA | Frame timing |
GameTick | 10 | AoS (factory) | Fixed-timestep tick counter |
Keyboard | 11 | AoS (factory) | Set<string> of held keys |
Ref | 12 | AoS (factory) | Three.js Object3D reference for ECS-to-scene sync |
Transform | 14-18 | SoA (factories) | position: Vector3, rotation: Euler, quaternion: Quaternion |
IsPlayer | 20 | Tag | Marks the player entity |
Skills & Combat (lines 22-61)
| Trait | Kind | Description |
|---|---|---|
Skills | AoS | 6 skills (attack, strength, defence, hitpoints, mining, woodcutting) each with { xp, level } |
CombatState | AoS | targetEntity, attackTimer, attackSpeed, inCombat |
IsEnemy | Tag | Marks enemy entities |
EnemyStats | AoS | Full stat block: attack, strength, defence, hitpoints, armor, xpReward, respawnTicks, ranges |
IsDead | Tag | Presence = dead; gating for respawn logic |
RespawnTimer | SoA | ticksRemaining countdown |
Armor | SoA | value -- flat damage reduction |
Movement (lines 63-74)
| Trait | Kind | Description |
|---|---|---|
MovementTarget | SoA | x, z, speed, hasTarget -- world-space destination |
AutoBattle | SoA | enabled flag -- toggles automatic target seeking |
PathDepth | SoA | value -- used for pathfinding depth tracking |
Inventory & Consumables (lines 76-86)
| Trait | Kind | Description |
|---|---|---|
Inventory | AoS | slots: ItemStack[], maxSlots: 28 |
ConsumableSettings | SoA | enabled, hpThreshold: 0.4 -- auto-eat threshold |
Resource Nodes (lines 88-107)
| Trait | Kind | Description |
|---|---|---|
IsResourceNode | Tag | Marks resource node entities |
ResourceNodeData | AoS | nodeId, type (mining/woodcutting), requiredLevel, gatherTicks, xpPerGather, resourceItemId, depleted, respawnTicks |
GatherState | AoS | targetEntity, gatherTimer, isGathering |
HP Regen & Spawn (lines 109-120)
| Trait | Kind | Description |
|---|---|---|
HpRegen | SoA | ticksPerHeal: 4, healAmount: 1, timer |
SpawnPosition | SoA | x, z -- original spawn location for respawn |
Terrain (lines 122-297)
| Trait | Kind | Description |
|---|---|---|
ChunkCoord | SoA | q, r -- axial chunk coordinates |
ChunkMesh | AoS | Three.js Mesh for this chunk |
IsDirty | Tag | Chunk needs geometry rebuild |
IsLoading | Tag | Chunk is awaiting async .bin fetch |
ChunkChannels | AoS | 4 texture registry IDs for splat channels |
ChunkBiome | SoA | biomeId -- which biome was used to generate this chunk |
RibbonState | AoS | active, config: RibbonConfig | null -- world-level ribbon projection |
BrushState | AoS | Editor-only brush config: mode, type, texture, radius, strength, hit coords |
Camera (lines 130-153)
| Trait | Kind | Description |
|---|---|---|
CameraOrbit | SoA | theta, phi, radius + targets + pan offsets -- full orbit camera state |
IsCamera | Tag | Marks the camera entity |
IsSnapBack | Tag | Pan offsets lerp to zero (game mode); absence = permanent pan (editor) |
CameraRef | AoS | World-level Three.js Camera + canvas reference |
UI (lines 181-252)
| Trait | Kind | Description |
|---|---|---|
IsHudElement | Tag | Screen-space HUD element |
IsMenu | Tag | Full-screen overlay menu |
IsWorldUI | Tag | World-anchored UI (Drei Html) |
IsVisible | Tag | Presence = shown, absence = hidden |
UiLayer | SoA | Z-ordering (reserved for future use) |
AnchoredTo | Relation | Positionally anchored to a game entity; autoDestroy: 'orphan' destroys UI when anchor dies |
HealthBar | SoA | current, max |
ResourceBar | SoA | type, current, max |
IsMinimapSlot | Tag | Placeholder for minimap rendering |
IsEnemyHpBar | Tag | World-anchored enemy HP bar |
EnemyHpBarData | AoS | name, current, max |
DamageNumber | AoS | Floating damage number data: value, flatX, flatZ, elapsed, duration |
DialogContent | SoA | speakerName, text, portrait |
DialogTimer | SoA | elapsed, duration -- 0 duration = manual dismiss |
ChatBubble | SoA | text, style (speech/thought/shout) |
MenuType | SoA | type (inventory/settings/npc-shop) |
MenuData | AoS | payload: 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 toanchor."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)
| Function | Purpose |
|---|---|
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 frameWhy This Order?
- Input first:
pollCameraInputandhandlePlayerTapconsume accumulated pointer deltas before camera or movement systems read them, preventing one-frame-old input. - Spatial before camera: Spatial hashing must be current so
handlePlayerTapraycast queries work against up-to-date positions. - Camera before transform sync: Camera orbit math runs before
syncTransformToRefcopies 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):
tickAutoBattle-- seek nearest enemy when auto-battle is enabledtickAutoGather-- seek nearest resource node when idletickCombat-- attack timer, damage calculation, XP rewardtickConsumables-- auto-eat food when HP below thresholdtickGathering-- gather timer, XP and item rewardtickDeriveStats-- recompute derived stats from skill levelstickHpRegen-- passive HP recovery when out of combattickRespawn-- countdown dead enemies, respawn atSpawnPositionsyncEnemyHpBar-- 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(withsharedTerrainMaterial), andIsLoadingtag - Mesh starts
visible = falseuntilbuildDirtyChunksfills 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 theMapManifestAoS 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
ChunkBiomefrom the manifest'sbiomeMap - Transitions: removes
IsLoading, addsIsDirty - Re-dirties neighbors (lines 50-63): After loading, marks all 6 adjacent chunks as
IsDirtyso 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):
- Read vertex data from
vertexRegistryfor all hex centers + corners in the chunk - For each hex, emit 6 triangles (18 vertices total per hex =
VERTICES_PER_HEX) - 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. - Build typed arrays:
positions(Float32x3),uvs(Float32x2),weights(Float32x4),texIndices(Float32x4),tints(Float32x3) - Ribbon projection (lines 83-95): When
RibbonStateis active,writePositionprojects flat hex positions onto the helix surface viahexToUVandprojectToRibboninstead of using flat XZ layout - Upload to GPU via
BufferAttribute. If geometry already exists, updates in-place withneedsUpdate = true(prevents one-frame visibility gaps during brush painting). Otherwise creates a newBufferGeometry. - Sets
mesh.visible = true, signalsentity.changed(ChunkMesh), removesIsDirty
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: 3xfloat32(12 bytes)w0, w1, w2, w3: 4xfloat32splat weights (16 bytes)ti0, ti1, ti2, ti3: 4xuint32texture indices (16 bytes)tintR, tintG, tintB: 3xfloat32RGB 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:
- Fetches
/maps/{worldId}/manifest.json - Validates version (must be
v: 1) - Populates
MapManifest.authoredset andbiomeMap - If the manifest has a
ribbonkey, addsRibbonStateto the world and configures it (lines 56-68) - Sets
manifest.ready = true-- this gatesstreamChunks(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:
textureIdandpath-- which texture to usetint: [r, g, b]-- RGB multiplier applied per-vertexnoise: NoiseConfig-- independent noise for this layer's weight mapcoverage: 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 timeactiveBiome-- 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 intoactiveBiome, replacing all values
Built-in Biomes
Six definitions in biomes/definitions/:
| File | ID | Notes |
|---|---|---|
grassland.ts | grassland | Default preset, loaded on init |
forest.ts | forest | |
desert.ts | desert | |
mountains.ts | mountains | |
tundra.ts | tundra | |
volcanic.ts | volcanic | Uses 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:
| Export | Purpose |
|---|---|
TERRAIN_TEXTURES | Static catalogue array |
getTextureIndex(id) | Maps string ID to integer index (throws in dev if unknown) |
terrainTexUniforms | Array 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:
- Reads per-vertex attributes:
weights(vec4),textureIndices(vec4, flat interpolation),tint(vec3) - Computes UVs from
positionWorld.xz * TILE_SCALE(0.5) - Samples all 5 textures at those UVs
- Selects 4 texture colors using nested
select()chains keyed on the integer texture index per slot - Depth blending: Adds per-slot noise (
mx_noise_floatwith phase offsets) to the splat weights, clamps negatives, renormalizes. This creates organic edge transitions between textures rather than hard splat boundaries. - 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:
tin [0, 1] -- progress along ribbon lengthwin [-0.5, 0.5] -- position across ribbon width (0 = centerline)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 / arcLengthw = 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:
buildDirtyChunks(systems/build-dirty-chunks.ts:83-95): WhenRibbonState.activeis true, thewritePositionhelper projects flat hex positions throughhexToUVandprojectToRibboninstead of writing flat XZ coordinates.syncTransformToRef(systems/sync-transform-to-ref.ts:8-15): ReadsRibbonStateand delegates toprojectPosition(), which applies ribbon projection to entity positions (player, enemies, etc.) so they follow the helix surface.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 },
): voidUsed 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,MinimapSlotUicomponents - Also renders
SkillsPanelandAutoBattleToggleas fixed-position elements
MenuRenderer (ui/menus/MenuRenderer.tsx):
- Queries
(IsMenu, IsVisible, MenuType) - Dispatches to
InventoryMenu,SettingsMenu, orNpcShopMenubased onMenuType.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:
- Reads the anchor entity's
Transformeach frame viauseFrame - Projects the position through
projectPosition(handles ribbon mode) - Scales the UI element based on camera zoom distance relative to a reference radius of 60
- Conditionally renders
ChatBubbleUi,DialogUi, orEnemyHpBarUibased on which traits the entity has
UI Systems
syncHudWithPlayer (systems/sync-hud-with-player.ts):
- Queries the player's
HealthandResourcetraits - Updates
HealthBarandResourceBarHUD entities only when values differ (dirty check avoids unnecessary Koota change signals)
updateDialogTimers (systems/update-dialog-timers.ts):
- Queries
(IsVisible, DialogTimer) - Increments
elapsedbydelta * 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
openMenucall, then toggled viaIsVisibleadd/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:
biomesmode:BiomeNoisePanel+ 4xBiomeLayerPanel+ExportBiomeButtonlevelsmode:LevelsBrushPanel+ExportButtonribbon-levelsmode:RibbonLevelsPanel
- Canvas -- same WebGPU setup as the main game, with
BrushCursorandBrushRaycasterinstead of player/enemy renderers
Editor Startup (StartupMapEditor, line 92)
Different initialization per mode:
| Mode | Behavior |
|---|---|
biomes | Loads a sentinel manifest (__biome_preview__), spawns a single chunk at (0,0) with IsDirty. generateChunkData fills procedural terrain. |
levels | Sets viewRadius = 5, calls initMapManifest with the selected level ID. If the manifest has ribbon config, calls fitCameraToRibbon. |
ribbon-levels | Clears 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
streamChunksandloadAuthoredChunksin 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.binfiles +manifest.jsonExportBiomeButton-- 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:
- React renderer creates a Three.js object (mesh, group, etc.)
- Shares it with ECS via the
Reftrait:entity.set(Ref, group) - Systems mutate
entity.get(Transform).positionetc. syncTransformToRefcopiesTransform->Refevery 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
.binfiles - 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:
- Chunk A loads and writes its vertex data to the registry
- Chunk B loads and writes its vertex data -- including corners that overlap with chunk A
- Chunk A is re-dirtied (via
loadAuthoredChunks's neighbor re-dirty logic) - 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):
| Function | Description |
|---|---|
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.