ants/docs/plans/2026-03-11-ant-farm-implementation.md

33 KiB

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

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

// 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

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

// 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<number, Material> = new Map();
    private byName: Map<string, Material> = 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

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

// 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

// 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.

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.

// 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

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

// 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:

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