From 44573686360add59e530c5806baa5f6bc76cd2d5 Mon Sep 17 00:00:00 2001 From: Jared Miller Date: Wed, 11 Mar 2026 12:44:24 -0400 Subject: [PATCH] Add ant farm sand physics implementation plan --- .../2026-03-11-ant-farm-implementation.md | 1021 +++++++++++++++++ 1 file changed, 1021 insertions(+) create mode 100644 docs/plans/2026-03-11-ant-farm-implementation.md diff --git a/docs/plans/2026-03-11-ant-farm-implementation.md b/docs/plans/2026-03-11-ant-farm-implementation.md new file mode 100644 index 0000000..7998d37 --- /dev/null +++ b/docs/plans/2026-03-11-ant-farm-implementation.md @@ -0,0 +1,1021 @@ +# Ant Farm Sand Physics — Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Transform the top-down ant colony sim into a side-view ant farm with GPU-accelerated falling sand physics, a data-driven material system, and ant digging/carrying behavior. + +**Architecture:** Margolus block cellular automata for sand physics (2x2 blocks, alternating offset per frame). Hybrid material system — 4 shader-native behavior types (powder/liquid/gas/solid) with data-driven material properties in a lookup texture. World texture R channel changes from bit-packed flags to material ID. Side view is primary camera with gravity on -Y. + +**Tech Stack:** three.js (WebGL2), GLSL3 fragment shaders, TypeScript, bun test, biome + +**Design doc:** `docs/plans/2026-03-11-ant-farm-sand-physics-design.md` + +--- + +## Phase 1: Material Registry + +Foundation layer. Defines materials as data, generates GPU lookup texture. No rendering changes yet. + +### Task 1.1: Material type definitions + +**Files:** +- Create: `src/materials/types.ts` +- Test: `src/__tests__/materials.test.ts` + +**Step 1: Write failing tests** + +```typescript +import { describe, expect, test } from "bun:test"; +import { + type Material, + type BehaviorType, + BEHAVIOR_POWDER, + BEHAVIOR_LIQUID, + BEHAVIOR_GAS, + BEHAVIOR_SOLID, +} from "../materials/types"; + +describe("material types", () => { + test("behavior type constants are distinct integers 0-3", () => { + const behaviors = [ + BEHAVIOR_POWDER, + BEHAVIOR_LIQUID, + BEHAVIOR_GAS, + BEHAVIOR_SOLID, + ]; + expect(new Set(behaviors).size).toBe(4); + for (const b of behaviors) { + expect(b).toBeGreaterThanOrEqual(0); + expect(b).toBeLessThanOrEqual(3); + } + }); + + test("Material interface has required fields", () => { + const sand: Material = { + id: 1, + name: "sand", + behavior: BEHAVIOR_POWDER, + density: 1.5, + color: [0.76, 0.7, 0.5, 1.0], + hardness: 0.1, + angleOfRepose: 34, + }; + expect(sand.behavior).toBe(BEHAVIOR_POWDER); + expect(sand.density).toBe(1.5); + }); +}); +``` + +Run: `bun test src/__tests__/materials.test.ts` +Expected: FAIL — module not found + +**Step 2: Implement types** + +```typescript +// src/materials/types.ts + +export const BEHAVIOR_POWDER = 0; +export const BEHAVIOR_LIQUID = 1; +export const BEHAVIOR_GAS = 2; +export const BEHAVIOR_SOLID = 3; + +export type BehaviorType = + | typeof BEHAVIOR_POWDER + | typeof BEHAVIOR_LIQUID + | typeof BEHAVIOR_GAS + | typeof BEHAVIOR_SOLID; + +// RGBA color, each component 0-1 +export type Color4 = [number, number, number, number]; + +export interface Material { + id: number; // 0-255, index into lookup texture + name: string; + behavior: BehaviorType; + density: number; // relative weight, determines displacement order + color: Color4; + hardness: number; // 0-1, resistance to ant digging / degradation + angleOfRepose: number; // degrees, used in tier 2 gravity +} +``` + +Run: `bun test src/__tests__/materials.test.ts` +Expected: PASS + +**Step 3: Commit** + +Message: `Add material type definitions` + +--- + +### Task 1.2: Material registry with built-in materials + +**Files:** +- Create: `src/materials/registry.ts` +- Test: `src/__tests__/materials.test.ts` (extend) + +**Step 1: Write failing tests** + +```typescript +import { MaterialRegistry } from "../materials/registry"; +import { BEHAVIOR_POWDER, BEHAVIOR_SOLID } from "../materials/types"; + +describe("MaterialRegistry", () => { + test("has air at id 0", () => { + const reg = new MaterialRegistry(); + const air = reg.get(0); + expect(air.name).toBe("air"); + expect(air.density).toBe(0); + }); + + test("has sand registered", () => { + const reg = new MaterialRegistry(); + const sand = reg.getByName("sand"); + expect(sand).toBeDefined(); + expect(sand!.behavior).toBe(BEHAVIOR_POWDER); + }); + + test("all material ids are unique", () => { + const reg = new MaterialRegistry(); + const ids = reg.all().map((m) => m.id); + expect(new Set(ids).size).toBe(ids.length); + }); + + test("all material ids are in range 0-255", () => { + const reg = new MaterialRegistry(); + for (const m of reg.all()) { + expect(m.id).toBeGreaterThanOrEqual(0); + expect(m.id).toBeLessThanOrEqual(255); + } + }); + + test("get throws for unknown id", () => { + const reg = new MaterialRegistry(); + expect(() => reg.get(254)).toThrow(); + }); +}); +``` + +Run: `bun test src/__tests__/materials.test.ts` +Expected: FAIL + +**Step 2: Implement registry** + +```typescript +// src/materials/registry.ts +import { + type Material, + BEHAVIOR_POWDER, + BEHAVIOR_SOLID, + BEHAVIOR_GAS, +} from "./types"; + +const BUILTIN_MATERIALS: Material[] = [ + { + id: 0, + name: "air", + behavior: BEHAVIOR_GAS, + density: 0, + color: [0, 0, 0, 0], + hardness: 0, + angleOfRepose: 0, + }, + { + id: 1, + name: "sand", + behavior: BEHAVIOR_POWDER, + density: 1.5, + color: [0.76, 0.7, 0.5, 1.0], + hardness: 0.1, + angleOfRepose: 34, + }, + { + id: 2, + name: "dirt", + behavior: BEHAVIOR_POWDER, + density: 1.3, + color: [0.45, 0.32, 0.18, 1.0], + hardness: 0.2, + angleOfRepose: 40, + }, + { + id: 3, + name: "rock", + behavior: BEHAVIOR_SOLID, + density: 2.5, + color: [0.5, 0.5, 0.5, 1.0], + hardness: 1.0, + angleOfRepose: 90, + }, + { + id: 4, + name: "food", + behavior: BEHAVIOR_SOLID, + density: 1.0, + color: [0.2, 0.8, 0.2, 1.0], + hardness: 0.0, + angleOfRepose: 0, + }, + { + id: 5, + name: "home", + behavior: BEHAVIOR_SOLID, + density: 0, + color: [0.3, 0.3, 1.0, 1.0], + hardness: 0.0, + angleOfRepose: 0, + }, +]; + +export class MaterialRegistry { + private byId: Map = new Map(); + private byName: Map = new Map(); + + constructor() { + for (const m of BUILTIN_MATERIALS) { + this.byId.set(m.id, m); + this.byName.set(m.name, m); + } + } + + get(id: number): Material { + const m = this.byId.get(id); + if (!m) throw new Error(`Unknown material id: ${id}`); + return m; + } + + getByName(name: string): Material | undefined { + return this.byName.get(name); + } + + all(): Material[] { + return [...this.byId.values()]; + } +} +``` + +Run: `bun test src/__tests__/materials.test.ts` +Expected: PASS + +**Step 3: Commit** + +Message: `Add material registry with built-in materials` + +--- + +### Task 1.3: GPU lookup texture generation + +**Files:** +- Create: `src/materials/lookupTexture.ts` +- Test: `src/__tests__/materials.test.ts` (extend) + +The lookup texture is a 256x1 RGBA Float32 texture. Each pixel stores material properties for one material ID. We pack: R=behavior type, G=density, B=hardness, A=angleOfRepose/90 (normalized). Color is stored in a separate 256x1 RGBA texture. + +**Step 1: Write failing tests** + +```typescript +import { generateLookupData, generateColorData } from "../materials/lookupTexture"; +import { MaterialRegistry } from "../materials/registry"; +import { BEHAVIOR_POWDER } from "../materials/types"; + +describe("lookup texture generation", () => { + test("generates 256-entry property array", () => { + const reg = new MaterialRegistry(); + const data = generateLookupData(reg); + expect(data.length).toBe(256 * 4); // 256 pixels * 4 floats + }); + + test("sand properties at correct offset", () => { + const reg = new MaterialRegistry(); + const data = generateLookupData(reg); + const sand = reg.getByName("sand")!; + const offset = sand.id * 4; + expect(data[offset + 0]).toBe(BEHAVIOR_POWDER); // behavior + expect(data[offset + 1]).toBeCloseTo(1.5); // density + expect(data[offset + 2]).toBeCloseTo(0.1); // hardness + }); + + test("generates 256-entry color array", () => { + const reg = new MaterialRegistry(); + const data = generateColorData(reg); + expect(data.length).toBe(256 * 4); + }); + + test("sand color at correct offset", () => { + const reg = new MaterialRegistry(); + const data = generateColorData(reg); + const sand = reg.getByName("sand")!; + const offset = sand.id * 4; + expect(data[offset + 0]).toBeCloseTo(0.76); + expect(data[offset + 1]).toBeCloseTo(0.7); + expect(data[offset + 2]).toBeCloseTo(0.5); + expect(data[offset + 3]).toBeCloseTo(1.0); + }); + + test("unregistered ids default to air properties", () => { + const reg = new MaterialRegistry(); + const data = generateLookupData(reg); + // id 200 doesn't exist, should be zeros (air) + const offset = 200 * 4; + expect(data[offset + 0]).toBe(0); + expect(data[offset + 1]).toBe(0); + }); +}); +``` + +Run: `bun test src/__tests__/materials.test.ts` +Expected: FAIL + +**Step 2: Implement lookup texture data generation** + +```typescript +// src/materials/lookupTexture.ts +import type { MaterialRegistry } from "./registry"; + +// generates flat Float32 array for material properties texture (256x1 RGBA) +// R: behavior type, G: density, B: hardness, A: angleOfRepose / 90 +export function generateLookupData(registry: MaterialRegistry): Float32Array { + const data = new Float32Array(256 * 4); + for (const m of registry.all()) { + const offset = m.id * 4; + data[offset + 0] = m.behavior; + data[offset + 1] = m.density; + data[offset + 2] = m.hardness; + data[offset + 3] = m.angleOfRepose / 90.0; + } + return data; +} + +// generates flat Float32 array for material color texture (256x1 RGBA) +export function generateColorData(registry: MaterialRegistry): Float32Array { + const data = new Float32Array(256 * 4); + for (const m of registry.all()) { + const offset = m.id * 4; + data[offset + 0] = m.color[0]; + data[offset + 1] = m.color[1]; + data[offset + 2] = m.color[2]; + data[offset + 3] = m.color[3]; + } + return data; +} +``` + +Run: `bun test src/__tests__/materials.test.ts` +Expected: PASS + +**Step 3: Commit** + +Message: `Add GPU lookup texture data generation for materials` + +--- + +### Task 1.4: Wire lookup textures into Renderer + +**Files:** +- Modify: `src/Renderer.ts` +- Create: `src/materials/index.ts` (re-exports) + +No tests for this task — it's WebGL wiring that requires a browser context. Verified visually. + +**Step 1: Create barrel export** + +```typescript +// src/materials/index.ts +export { MaterialRegistry } from "./registry"; +export { generateLookupData, generateColorData } from "./lookupTexture"; +export { + type Material, + type BehaviorType, + type Color4, + BEHAVIOR_POWDER, + BEHAVIOR_LIQUID, + BEHAVIOR_GAS, + BEHAVIOR_SOLID, +} from "./types"; +``` + +**Step 2: Add to Renderer** + +In `Renderer.ts`, add two new DataTextures created from the registry data. These get passed as uniforms to scenes that need material info (sand physics, screen rendering, ant compute). + +- Import `MaterialRegistry`, `generateLookupData`, `generateColorData` +- Create `materialRegistry` instance +- Create `materialPropsTexture` (256x1 Float RGBA DataTexture) +- Create `materialColorTexture` (256x1 Float RGBA DataTexture) +- Expose both textures via getter for scenes to consume + +**Step 3: Run `just check`** to verify typecheck + lint pass + +**Step 4: Commit** + +Message: `Wire material lookup textures into Renderer` + +--- + +## Phase 2: World Encoding Migration + +Change world texture R channel from bit-packed flags to material ID. This is the most invasive change — touches most shaders. + +### Task 2.1: Update constants.ts for material IDs + +**Files:** +- Modify: `src/constants.ts` +- Modify: `src/__tests__/constants.test.ts` + +**Step 1: Update tests** + +Replace bit-layout round-trip tests with material ID constant tests. The old CELL_FOOD_BIT / CELL_HOME_BIT / CELL_OBSTACLE_BIT constants are replaced by material IDs from the registry. Keep the constants file as the single source of truth for GLSL defines. + +```typescript +import { describe, expect, test } from "bun:test"; +import { + MAT_AIR, + MAT_SAND, + MAT_DIRT, + MAT_ROCK, + MAT_FOOD, + MAT_HOME, +} from "../constants"; + +describe("material ID constants", () => { + test("air is 0", () => { + expect(MAT_AIR).toBe(0); + }); + + test("all IDs are unique", () => { + const ids = [MAT_AIR, MAT_SAND, MAT_DIRT, MAT_ROCK, MAT_FOOD, MAT_HOME]; + expect(new Set(ids).size).toBe(ids.length); + }); + + test("all IDs fit in a byte", () => { + const ids = [MAT_AIR, MAT_SAND, MAT_DIRT, MAT_ROCK, MAT_FOOD, MAT_HOME]; + for (const id of ids) { + expect(id).toBeGreaterThanOrEqual(0); + expect(id).toBeLessThanOrEqual(255); + } + }); +}); +``` + +Run: `bun test` +Expected: FAIL + +**Step 2: Update constants.ts** + +Replace the bit-layout exports with material ID constants. These must match the IDs in the material registry. Keep the old constants temporarily with deprecation comments if other files reference them — but the goal is to remove them once all shaders are migrated. + +```typescript +// src/constants.ts +// material IDs — must match MaterialRegistry built-in IDs +export const MAT_AIR = 0; +export const MAT_SAND = 1; +export const MAT_DIRT = 2; +export const MAT_ROCK = 3; +export const MAT_FOOD = 4; +export const MAT_HOME = 5; + +// GLSL #defines injected via getCommonMaterialDefines() +// old bit-layout defines kept until all shaders are migrated +``` + +**Step 3: Run tests** + +Run: `bun test` +Expected: PASS (old constants tests removed/replaced) + +**Step 4: Commit** + +Message: `Replace bit-packed cell flags with material ID constants` + +--- + +### Task 2.2: Update world.frag for material IDs + +**Files:** +- Modify: `src/shaders/world.frag` +- Modify: `src/shaders/worldBlur.frag` +- Modify: `src/shaders/antsCompute.frag` +- Modify: `src/shaders/antsDiscretize.frag` +- Modify: `src/shaders/draw.frag` +- Modify: `src/shaders/screenWorld.frag` + +This is a large coordinated shader migration. The key change: everywhere that reads/writes world.R as bit-packed float now reads/writes it as a material ID (integer stored as float). + +**Overview of changes per shader:** + +**world.frag:** Instead of preserving bits via AND/OR masks, just preserve the material ID in R. Food clearing (when ant picks up food) changes `R` from `MAT_FOOD` to `MAT_AIR` instead of clearing a bit. + +**worldBlur.frag:** R channel (material ID) must NOT be blurred — only G, B, A (pheromones) get diffused. Currently the blur samples all channels. Change to: pass R through unmodified from center sample, blur G/B/A only. + +**antsCompute.frag:** Replace `isFood()` / `isHome()` / `isObstacle()` bit-check functions with material ID comparisons: `cellR == float(MAT_FOOD)`, `cellR == float(MAT_HOME)`, `cellR == float(MAT_ROCK)`. The `tryGetFood` and `tryDropFood` functions change accordingly. + +**antsDiscretize.frag:** Unchanged conceptually — it writes pheromone deposit amounts and clear flags to the discrete texture. No direct interaction with material ID. + +**draw.frag:** Instead of setting/clearing bits, write the material ID directly. Drawing food writes `MAT_FOOD` to R, drawing obstacle writes `MAT_ROCK`, erase writes `MAT_AIR`. + +**screenWorld.frag:** Instead of reading bits to determine cell color, read the material ID and sample the material color lookup texture. This makes rendering data-driven — new materials automatically get correct colors. + +**Step 1:** Update each shader file. Inject material ID constants via `getCommonMaterialDefines()`. + +**Step 2:** Update `getCommonMaterialDefines()` in the TS side to emit `#define MAT_AIR 0`, `#define MAT_SAND 1`, etc. + +**Step 3:** Run `just check` — must pass typecheck and lint + +**Step 4:** Visual verification in browser — existing food/home/obstacle painting should still work, ants should still forage. Colors may change (now driven by material color texture). + +**Step 5: Commit** + +Message: `Migrate world texture R channel from bit flags to material ID` + +--- + +### Task 2.3: Update DrawScene for material palette + +**Files:** +- Modify: `src/scenes/DrawScene.ts` +- Modify: `src/scenes/ScreenScene.ts` (keybindings) +- Modify: `src/shaders/draw.frag` + +Extend draw modes beyond food/home/obstacle/erase. Add sand, dirt, rock as paintable materials. Update keybindings. + +**Changes:** +- DrawScene drawMode becomes a material ID (number) instead of an enum. 0 = erase (writes MAT_AIR), other values write that material ID. +- ScreenScene key handler: Q=home, W=food, E=rock, R=erase, 1=sand, 2=dirt (extend as needed) +- draw.frag receives `uDrawMaterial` uniform (int), writes it to R channel within brush radius + +**Step 1:** Implement changes +**Step 2:** Run `just check` +**Step 3:** Visual verification — paint sand, dirt, rock in browser +**Step 4: Commit** + +Message: `Extend draw tools to support material palette` + +--- + +## Phase 3: Sand Physics Pass + +The core new feature. Margolus block CA running as a fragment shader pass. + +### Task 3.1: Margolus block offset logic + +**Files:** +- Create: `src/sand/margolus.ts` +- Test: `src/__tests__/sand.test.ts` + +**Step 1: Write failing tests** + +```typescript +import { describe, expect, test } from "bun:test"; +import { getBlockOffset } from "../sand/margolus"; + +describe("Margolus block offset", () => { + test("even frame has offset (0, 0)", () => { + expect(getBlockOffset(0)).toEqual({ x: 0, y: 0 }); + expect(getBlockOffset(2)).toEqual({ x: 0, y: 0 }); + }); + + test("odd frame has offset (1, 1)", () => { + expect(getBlockOffset(1)).toEqual({ x: 1, y: 1 }); + expect(getBlockOffset(3)).toEqual({ x: 1, y: 1 }); + }); +}); +``` + +Run: `bun test src/__tests__/sand.test.ts` +Expected: FAIL + +**Step 2: Implement** + +```typescript +// src/sand/margolus.ts +export function getBlockOffset(frame: number): { x: number; y: number } { + const parity = frame & 1; + return { x: parity, y: parity }; +} +``` + +Run: `bun test src/__tests__/sand.test.ts` +Expected: PASS + +**Step 3: Commit** + +Message: `Add Margolus block offset calculation` + +--- + +### Task 3.2: Sand physics fragment shader + +**Files:** +- Create: `src/shaders/sandPhysics.frag` +- Create: `src/shaders/sandPhysics.vert` (passthrough, copy from world.vert) + +This is the most technically interesting shader. It implements the 2x2 Margolus block CA. + +**Algorithm per fragment:** + +1. Determine which 2x2 block this pixel belongs to (based on `uBlockOffset`) +2. Determine this pixel's position within the block (0-3: top-left, top-right, bottom-left, bottom-right) +3. Read all 4 cells of the block from the world texture +4. For each cell, look up its material behavior from the material properties texture +5. Apply physics rules within the block: + - POWDER cells above AIR cells: swap (gravity) + - If directly below is occupied, try diagonal (randomized via hash) + - LIQUID: same as powder + horizontal spreading + - SOLID: never moves + - GAS: swap upward with heavier materials + - Density comparison for displacement between different material types +6. Write this pixel's new material ID to output R channel +7. Pass through G, B, A (pheromones) unchanged + +**Key details:** +- `uBlockOffset` uniform: vec2, set from `getBlockOffset(frame)` +- `uFrame` uniform: int, for seeding pseudo-random within shader +- Random hash function for diagonal preference: `hash(uvec2(blockX, blockY) ^ uvec2(uFrame))` — deterministic per block per frame but spatially varying +- The shader reads from ping-pong input and writes to output (same pattern as existing world passes) + +**GLSL pseudocode:** + +```glsl +uniform sampler2D uWorld; // current world state +uniform sampler2D uMaterialProps; // 256x1 lookup +uniform vec2 uBlockOffset; // (0,0) or (1,1) +uniform int uFrame; // for random seed +uniform float uPixelSize; // 1.0 / worldSize + +void main() { + ivec2 pixel = ivec2(gl_FragCoord.xy); + ivec2 blockBase = ((pixel - ivec2(uBlockOffset)) / 2) * 2 + ivec2(uBlockOffset); + ivec2 localPos = pixel - blockBase; + int localIndex = localPos.x + localPos.y * 2; // 0-3 + + // read all 4 cells + vec4 cells[4]; // .r = material ID, .gba = pheromones + for (int i = 0; i < 4; i++) { + ivec2 p = blockBase + ivec2(i % 2, i / 2); + cells[i] = texelFetch(uWorld, p, 0); + } + + // read material behaviors for each cell + float behaviors[4]; + float densities[4]; + for (int i = 0; i < 4; i++) { + vec4 props = texelFetch(uMaterialProps, ivec2(int(cells[i].r), 0), 0); + behaviors[i] = props.r; // behavior type + densities[i] = props.g; // density + } + + // apply gravity rules within block + // [0][1] top-left, top-right + // [2][3] bottom-left, bottom-right + // powder at [0] or [1] falls to [2] or [3] if empty/lighter + + // ... (full swap logic based on behavior types and densities) + // randomize diagonal preference per block using hash + + fragColor = cells[localIndex]; // output this pixel's (possibly swapped) state +} +``` + +**Step 1:** Write the full shader. Reference GPU-Falling-Sand-CA for the exact Margolus swap patterns. + +**Step 2:** Create matching .vert (passthrough) + +**Step 3: Commit** + +Message: `Add Margolus block CA sand physics shader` + +--- + +### Task 3.3: SandPhysicsScene class + +**Files:** +- Create: `src/scenes/SandPhysicsScene.ts` + +Follows the same pattern as `WorldBlurScene` — extends `AbstractScene`, sets up a `ShaderMaterial` with the sand physics shader, takes world texture as input, writes to output. + +**Uniforms:** +- `uWorld` — sampler2D, current world state (ping-pong input) +- `uMaterialProps` — sampler2D, material property lookup texture +- `uBlockOffset` — vec2, from `getBlockOffset(frame)` +- `uFrame` — int, current frame number +- `uPixelSize` — float, `1.0 / worldSize` + +**Step 1:** Implement SandPhysicsScene following AbstractScene pattern +**Step 2:** Run `just check` +**Step 3: Commit** + +Message: `Add SandPhysicsScene for Margolus block CA` + +--- + +### Task 3.4: Wire sand physics into render pipeline + +**Files:** +- Modify: `src/Renderer.ts` +- Modify: `src/App.ts` + +Insert `SandPhysicsScene` as the FIRST pass in `renderSimulation()`, before `WorldBlurScene`. It reads from `worldRenderTarget` and writes to a new `sandPhysicsRenderTarget` (or reuse the blur target with an extra ping-pong step). + +**Pipeline becomes:** +1. **SandPhysicsScene** — material movement (gravity, displacement) +2. **WorldBlurScene** — pheromone diffusion (reads sand physics output) +3. (rest unchanged) + +**Key detail:** Sand physics must NOT touch pheromone channels (G, B, A). It only swaps/moves material IDs in the R channel. Pheromones in moved cells should move WITH the material (if sand falls, its pheromone value falls too). This means the shader swaps entire RGBA, not just R — the pheromones travel with the material. + +Wait — that means pheromones diffuse AND move with sand. This is actually correct behavior: a sand grain carrying pheromone falling downward should bring the pheromone with it. The blur pass then diffuses it from the new position. + +**Step 1:** Add `sandPhysicsRenderTarget` to Renderer (same size/format as world) +**Step 2:** Create SandPhysicsScene instance, pass material textures +**Step 3:** Insert into render loop, increment frame counter per tick +**Step 4:** Run `just check` +**Step 5:** Visual verification — paint sand above empty space, watch it fall +**Step 6: Commit** + +Message: `Wire sand physics pass into render pipeline` + +--- + +## Phase 4: Side View Camera and Gravity + +### Task 4.1: Add gravity direction to Config + +**Files:** +- Modify: `src/Config.ts` + +Add `gravityDirection` to Config: `"down"` for side view (default), `"none"` for top-down mode. The sand physics pass checks this — if `"none"`, skip the pass entirely (existing top-down behavior preserved). + +Also add `viewMode: "side" | "top"` to Config. + +**Step 1:** Add config fields +**Step 2:** Run `just check` +**Step 3: Commit** + +Message: `Add gravity direction and view mode to Config` + +--- + +### Task 4.2: Side view world initialization + +**Files:** +- Modify: `src/scenes/DrawScene.ts` or create `src/WorldInit.ts` + +When simulation resets with `viewMode: "side"`: +- Fill bottom 60% of world with MAT_SAND +- Top 40% is MAT_AIR +- Place MAT_HOME marker on the sand surface near center +- Scatter a few MAT_FOOD patches on the surface + +When `viewMode: "top"` — keep existing initialization (empty world, user paints). + +**Step 1:** Implement initialization function that writes to world texture +**Step 2:** Wire into reset flow +**Step 3:** Run `just check` +**Step 4:** Visual verification — reset shows sand/sky split +**Step 5: Commit** + +Message: `Add side-view world initialization with sand and sky` + +--- + +### Task 4.3: Ant surface spawning + +**Files:** +- Modify: `src/shaders/antsCompute.frag` + +When `viewMode: "side"`, ants initialize on the sand surface (y = ~0.6 in normalized coords, wherever the sand/air boundary is) instead of at (0.5, 0.5). The init logic in the shader checks the world texture to find the highest sand cell at a random x position and spawns there. + +**Step 1:** Modify ant init block in antsCompute.frag +**Step 2:** Visual verification — ants appear on sand surface, not in midair or buried +**Step 3: Commit** + +Message: `Spawn ants on sand surface in side view mode` + +--- + +## Phase 5: Ant Digging Behavior + +### Task 5.1: Ant carry strength and material weight check + +**Files:** +- Modify: `src/shaders/antsCompute.frag` + +Extend the existing food pickup mechanic. Currently ants check `isFood()` and pick up. Now: + +- Ants have a carry strength (constant for now, e.g., `ANT_CARRY_STRENGTH = 1.0`) +- When an ant contacts a material cell, it reads the material's hardness from the lookup texture +- If `hardness <= ANT_CARRY_STRENGTH` AND material is a powder type, ant can pick it up +- Picked-up material stored as a new field in ant state (cargo material ID, replaces cargo quality in texture 1) +- The world cell becomes MAT_AIR where the ant picked up + +**Ant state texture 1 change:** +- `[personality, cargoMaterialId, pathIntDx, pathIntDy]` +- `cargoMaterialId` is 0 when not carrying, otherwise the material ID + +**Step 1:** Add `uMaterialProps` uniform to AntsComputeScene +**Step 2:** Modify pickup logic in antsCompute.frag +**Step 3:** Visual verification — ants pick up sand grains (sand cell disappears) +**Step 4: Commit** + +Message: `Add material-aware ant pickup with strength check` + +--- + +### Task 5.2: Ant deposit carried material + +**Files:** +- Modify: `src/shaders/antsCompute.frag` +- Modify: `src/shaders/antsDiscretize.frag` +- Modify: `src/shaders/world.frag` + +When a carrying ant reaches the surface (y above sand line) or a deposit target: +- Write the carried material ID to the world cell at current position +- Clear carry state +- This creates the anthill mound effect — ants carry sand up and drop it on the surface + +For the discretize pass, encode the cargo material ID in the discrete texture so world.frag can place it. + +**Step 1:** Modify ant deposit logic +**Step 2:** Update discretize to encode material deposits +**Step 3:** Update world.frag to place deposited material +**Step 4:** Visual verification — ants dig sand, carry it up, drop it on surface forming a mound +**Step 5: Commit** + +Message: `Add ant material deposit and anthill mound formation` + +--- + +### Task 5.3: Digging direction logic + +**Files:** +- Modify: `src/shaders/antsCompute.frag` + +Ants should prefer digging downward (following gravity) and avoid digging straight up unless returning to surface. The digging decision: + +- If not carrying and not targeting food: look for diggable material ahead/below +- Prefer cells with fewer occupied neighbors (loose grains — Jenga heuristic) +- When carrying: move upward toward surface, deposit at top +- Don't follow pheromones for digging — this is local, physical behavior + +**Step 1:** Implement dig direction sampling in antsCompute.frag +**Step 2:** Visual verification — ants create downward tunnels, not random holes +**Step 3: Commit** + +Message: `Add gravity-aware digging direction for ants` + +--- + +## Phase 6: Pheromone-Sand Interaction + +### Task 6.1: Block pheromone diffusion through solid materials + +**Files:** +- Modify: `src/shaders/worldBlur.frag` + +Pheromones should only diffuse through air (MAT_AIR) and along tunnel walls. Currently the blur pass samples all neighbors equally. Change: when sampling a neighbor for blur, check if it's a solid material. If so, use zero pheromone from that sample (don't diffuse through sand/rock). This naturally confines pheromone trails to tunnels and the surface. + +**Step 1:** Add `uMaterialProps` texture to WorldBlurScene +**Step 2:** In worldBlur.frag, check material type before including neighbor in blur average +**Step 3:** Visual verification — pheromone trails visible in tunnels but don't bleed through sand +**Step 4: Commit** + +Message: `Block pheromone diffusion through solid materials` + +--- + +## Phase 7: Screen Rendering Updates + +### Task 7.1: Material-driven world coloring + +**Files:** +- Modify: `src/shaders/screenWorld.frag` +- Modify: `src/scenes/ScreenScene.ts` + +Replace the hardcoded cell coloring (red=food, blue=home, gray=obstacle) with a lookup from the material color texture. The shader reads material ID from world.R, samples the color texture at that ID, and outputs the color. Pheromone overlay still tints the base color. + +**Step 1:** Add `uMaterialColors` uniform to ScreenScene +**Step 2:** Update screenWorld.frag to sample color texture +**Step 3:** Visual verification — sand is tan, dirt is brown, rock is gray, etc. +**Step 4: Commit** + +Message: `Render world colors from material color lookup texture` + +--- + +### Task 7.2: Stats overlay + +**Files:** +- Modify: `src/scenes/ScreenScene.ts` or create `src/StatsOverlay.ts` + +Add an HTML overlay (not rendered in canvas) showing: +- Cursor position (x, y in grid coords) +- Active particle count (non-air cells) +- TPS (simulation ticks per second, measure actual frame timing) +- Material name under cursor + +Use a simple DOM element positioned over the canvas, updated each frame from JS. + +**Step 1:** Create overlay element in ScreenScene or a new class +**Step 2:** Track TPS via frame timing +**Step 3:** Read material under cursor via raycasting (already have pointer position) +**Step 4:** Visual verification — stats display updates in real-time +**Step 5: Commit** + +Message: `Add stats overlay with cursor position, particle count, and TPS` + +--- + +### Task 7.3: Camera view toggle + +**Files:** +- Modify: `src/scenes/ScreenScene.ts` +- Modify: `src/GUI.ts` + +Add a keybinding (Tab or V) to toggle between side view and top-down view. In side view, camera is oriented normally (Y up = screen up). In top-down view, the camera could show the same data but with a rotated color scheme or different Y interpretation — but for now, just keep it as a toggle that flips `Config.viewMode` and resets the camera position. + +Future: top-down could render a separate surface-only projection. + +**Step 1:** Add toggle keybinding +**Step 2:** Add GUI dropdown for view mode +**Step 3:** Reset camera on toggle +**Step 4: Commit** + +Message: `Add camera view mode toggle between side and top-down` + +--- + +## Phase 8: Polish and Integration + +### Task 8.1: GUI updates for materials + +**Files:** +- Modify: `src/GUI.ts` + +Add a materials section to the GUI panel: +- Material palette selector (dropdown or buttons for draw mode) +- Display current brush material name +- Ant count and carrying count from ColonyStats + +**Step 1:** Extend GUI with material controls +**Step 2: Commit** + +Message: `Add material palette to GUI panel` + +--- + +### Task 8.2: Update CLAUDE.md with new architecture + +**Files:** +- Modify: `CLAUDE.md` + +Document: +- Material system (registry, lookup textures, hybrid architecture) +- Sand physics pass (Margolus block CA) +- New world texture encoding (R = material ID) +- Side view as primary camera +- New render pipeline order + +**Step 1:** Update CLAUDE.md +**Step 2: Commit** + +Message: `Update CLAUDE.md with sand physics architecture` + +--- + +## Future Phases (not implemented now, captured for reference) + +### Tier 2 Gravity: Angle of Repose +- Add angle-of-repose check to sand physics shader +- Per-material angle from lookup texture +- Probability-based diagonal slide gated by local slope measurement + +### Tier 3 Gravity: Pressure Propagation +- Additional render pass for pressure field +- Cells accumulate weight from above +- Overhang collapse when pressure exceeds material strength +- Granular arching emerges from force chain simulation + +### Material Degradation +- Ant presence counter per cell (or proximity timer) +- When count exceeds threshold, material downgrades (rock -> gravel -> sand) +- Multiple ants at same face accelerate degradation + +### Multi-Ant Cooperation +- Use antsPresenceRenderTarget (already exists, currently stub) +- Count ants adjacent to a heavy material cell +- Combined strength = sum of individual ant strengths +- If combined > material weight, one ant picks it up + +### Liquid Materials +- Water behavior in LIQUID shader branch +- Wet sand reactions (sand + water -> wet sand, higher cohesion) +- Mud (high viscosity liquid) + +### Ants as Droppable Element +- Add MAT_ANT to registry +- Drawing ants creates new ant state entries +- Ants spawned this way join the colony + +### Top-Down Surface Projection +- Separate render pass scanning top N rows of world +- Renders surface-level view as minimap or alternate camera