ants/docs/plans/2026-03-11-ant-behavior-overhaul.md
Jared Miller cd4f10bc80
Add ant behavior overhaul implementation plan
7 phases: config changes, world seeding, ant physics (gravity/surface/collision),
priority stack brain, unified brush with ant spawning, sand color variation,
and cleanup. Future phases B (digging pheromone) and C (colony dynamics) captured
as hooks.
2026-03-11 20:35:11 -04:00

33 KiB

Ant Behavior Overhaul — Implementation Plan

For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

Goal: Fix broken ant physics (gravity, surface movement, collision), replace the ant count system with a budget/activation model, unify the brush tool so ants are droppable like materials, rewrite the ant brain as a priority stack, and add sand color variation.

Architecture: Ant physics adds gravity and surface constraints to antsCompute.frag. Ant texture switches from power-of-2 sizing to a fixed budget pool with activation tracking. The brush tool gains an "ants" element that activates pooled ants at click positions. The ant behavioral priority stack replaces the current flat foraging logic with fall→deposit→navigate→forage→dig→wander.

Tech Stack: three.js (WebGL2), GLSL3 fragment shaders, TypeScript, bun test, biome

Design doc: docs/plans/2026-03-11-ant-behavior-overhaul-design.md


Phase 1: Config and Constants

Foundation layer. New config values, ant texture sizing changes.

Task 1.1: Replace antsCount with antsStartCount and antBudget

Files:

  • Modify: src/Config.ts
  • Test: src/__tests__/constants.test.ts (extend)

Step 1: Write failing tests

// add to src/__tests__/constants.test.ts
import { describe, expect, test } from "bun:test";
import { defaults } from "../Config";

describe("config defaults", () => {
    test("antsStartCount is a direct count, not an exponent", () => {
        expect(defaults.antsStartCount).toBe(4);
        expect(defaults.antsStartCount).toBeLessThanOrEqual(64);
    });

    test("antBudget determines texture pool size", () => {
        expect(defaults.antBudget).toBe(512);
    });

    test("seedWorld defaults to true", () => {
        expect(defaults.seedWorld).toBe(true);
    });

    test("old antsCount key does not exist", () => {
        expect("antsCount" in defaults).toBe(false);
    });
});

Run: bun test src/__tests__/constants.test.ts Expected: FAIL — antsStartCount not found, antsCount still exists

Step 2: Update Config.ts

Replace antsCount: 12 with:

antsStartCount: 4,
antBudget: 512,
seedWorld: true,

Remove antsCount entirely. Keep brushMaterial for now (removed in a later task).

Run: bun test src/__tests__/constants.test.ts Expected: FAIL — TypeScript errors in other files referencing Config.antsCount

Step 3: Update Renderer.ts ant texture sizing

In src/Renderer.ts, replace all references to Config.antsCount:

// initResources() and reset() — old:
const antTextureSize = Math.round(Math.sqrt(2 ** Config.antsCount));
// new:
const antTextureSize = Math.ceil(Math.sqrt(Config.antBudget));

There are two occurrences: initResources() (line 75) and reset() (line 328).

Step 4: Update GUI.ts

Replace the ant count slider:

// old:
simFolder.add(Config, "antsCount", 0, 22).name("Ants count    2^").step(1)...
// new:
simFolder.add(Config, "antsStartCount", 0, 64).name("Starting ants").step(1)...

Add seedWorld checkbox to a new World section (or Simulation folder):

simFolder
    .add(Config, "seedWorld")
    .name("Seed home + food")
    .onChange(() => this.saveAndEmit("reset"));

Step 5: Run tests and check

Run: bun test Expected: PASS

Run: just check Expected: PASS

Step 6: Commit

Message: Replace antsCount with antsStartCount and antBudget config


Task 1.2: Add ANTS_START_COUNT define to shaders

Files:

  • Modify: src/Renderer.ts

The ant compute shader needs to know how many ants to initialize on the first frame vs leaving dormant. Add a define.

Step 1: Add define to getCommonMaterialDefines()

In src/Renderer.ts getCommonMaterialDefines(), add:

ANTS_START_COUNT: String(Config.antsStartCount),
ANT_BUDGET: String(Config.antBudget),

Step 2: Run just check

Expected: PASS (defines are unused in shaders yet, just injected)

Step 3: Commit

Message: Add ANTS_START_COUNT and ANT_BUDGET shader defines


Phase 2: World Init and Seeding

Task 2.1: Configurable world seeding

Files:

  • Modify: src/WorldInit.ts
  • Test: src/__tests__/worldInit.test.ts (create)

Step 1: Write failing tests

import { describe, expect, test } from "bun:test";
import { generateSideViewWorld } from "../WorldInit";
import { MAT_AIR, MAT_FOOD, MAT_HOME, MAT_SAND } from "../constants";

describe("generateSideViewWorld", () => {
    const worldSize = 64;

    test("bottom 60% is sand", () => {
        const data = generateSideViewWorld(worldSize, true);
        const sandHeight = Math.floor(worldSize * 0.6);
        // check a cell in the middle of the sand region
        const midY = Math.floor(sandHeight / 2);
        const idx = (midY * worldSize + 10) * 4;
        expect(data[idx]).toBe(MAT_SAND);
    });

    test("top 40% is air", () => {
        const data = generateSideViewWorld(worldSize, true);
        const sandHeight = Math.floor(worldSize * 0.6);
        const airY = sandHeight + 5;
        const idx = (airY * worldSize + 10) * 4;
        expect(data[idx]).toBe(MAT_AIR);
    });

    test("seed=true places exactly one home and one food on surface", () => {
        const data = generateSideViewWorld(worldSize, true);
        let homeCount = 0;
        let foodCount = 0;
        for (let i = 0; i < data.length; i += 4) {
            if (data[i] === MAT_HOME) homeCount++;
            if (data[i] === MAT_FOOD) foodCount++;
        }
        expect(homeCount).toBe(1);
        expect(foodCount).toBe(1);
    });

    test("seed=true places home and food on the surface row", () => {
        const data = generateSideViewWorld(worldSize, true);
        const sandHeight = Math.floor(worldSize * 0.6);
        const surfaceRow = sandHeight - 1;
        let homeY = -1;
        let foodY = -1;
        for (let y = 0; y < worldSize; y++) {
            for (let x = 0; x < worldSize; x++) {
                const idx = (y * worldSize + x) * 4;
                if (data[idx] === MAT_HOME) homeY = y;
                if (data[idx] === MAT_FOOD) foodY = y;
            }
        }
        expect(homeY).toBe(surfaceRow);
        expect(foodY).toBe(surfaceRow);
    });

    test("seed=false places no home or food", () => {
        const data = generateSideViewWorld(worldSize, false);
        let homeCount = 0;
        let foodCount = 0;
        for (let i = 0; i < data.length; i += 4) {
            if (data[i] === MAT_HOME) homeCount++;
            if (data[i] === MAT_FOOD) foodCount++;
        }
        expect(homeCount).toBe(0);
        expect(foodCount).toBe(0);
    });

    test("seed=true places home and food at different x positions", () => {
        const data = generateSideViewWorld(worldSize, true);
        let homeX = -1;
        let foodX = -1;
        for (let y = 0; y < worldSize; y++) {
            for (let x = 0; x < worldSize; x++) {
                const idx = (y * worldSize + x) * 4;
                if (data[idx] === MAT_HOME) homeX = x;
                if (data[idx] === MAT_FOOD) foodX = x;
            }
        }
        // they should be at different positions (random, but not same cell)
        // note: could very rarely collide — use seeded random to avoid flakiness
        expect(homeX).not.toBe(foodX);
    });
});

Run: bun test src/__tests__/worldInit.test.ts Expected: FAIL — generateSideViewWorld doesn't accept a seed parameter

Step 2: Implement configurable seeding

Update src/WorldInit.ts:

import { MAT_FOOD, MAT_HOME, MAT_SAND } from "./constants";

// simple seeded PRNG for deterministic placement
function mulberry32(seed: number) {
    return () => {
        let t = (seed += 0x6d2b79f5);
        t = Math.imul(t ^ (t >>> 15), t | 1);
        t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
        return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
    };
}

export function generateSideViewWorld(
    worldSize: number,
    seed: boolean,
): Float32Array {
    const data = new Float32Array(worldSize * worldSize * 4);
    const sandHeight = Math.floor(worldSize * 0.6);
    const surfaceRow = sandHeight - 1;

    // fill bottom 60% with sand
    for (let y = 0; y < sandHeight; y++) {
        for (let x = 0; x < worldSize; x++) {
            const idx = (y * worldSize + x) * 4;
            data[idx] = MAT_SAND;
        }
    }

    if (seed) {
        const rng = mulberry32(Date.now());
        // place home at random surface position (middle 60% of world width)
        const margin = Math.floor(worldSize * 0.2);
        const homeX =
            margin + Math.floor(rng() * (worldSize - 2 * margin));

        const homeIdx = (surfaceRow * worldSize + homeX) * 4;
        data[homeIdx] = MAT_HOME;

        // place food at a different random surface position
        let foodX = homeX;
        while (foodX === homeX) {
            foodX =
                margin + Math.floor(rng() * (worldSize - 2 * margin));
        }
        const foodIdx = (surfaceRow * worldSize + foodX) * 4;
        data[foodIdx] = MAT_FOOD;
    }

    return data;
}

Step 3: Update Renderer.ts to pass seedWorld

In src/Renderer.ts reset() method, change:

// old:
const data = generateSideViewWorld(Config.worldSize);
// new:
const data = generateSideViewWorld(Config.worldSize, Config.seedWorld);

Step 4: Run tests

Run: bun test Expected: PASS

Run: just check Expected: PASS

Step 5: Commit

Message: Add configurable world seeding with random home and food placement


Phase 3: Ant Physics

The core bug fix. Gravity, surface constraint, collision displacement.

Task 3.1: Ant gravity — fall through air

Files:

  • Modify: src/shaders/antsCompute.frag

Step 1: Add gravity logic at the top of the movement section

After the init block (after if (pos == vec2(0)) { ... }) and before the existing movement logic, add a gravity/surface check. This replaces the flat movement model with physics-aware movement.

The new structure of main() after init becomes:

// --- GRAVITY AND SURFACE CHECK ---
// check if ant has any solid neighbor (cardinal directions)
vec2 cellCenter = roundUvToCellCenter(pos);
float belowMat = texture(tWorld, cellCenter - vec2(0., cellSize)).x;
float aboveMat = texture(tWorld, cellCenter + vec2(0., cellSize)).x;
float leftMat  = texture(tWorld, cellCenter - vec2(cellSize, 0.)).x;
float rightMat = texture(tWorld, cellCenter + vec2(cellSize, 0.)).x;
float currentMat = texture(tWorld, cellCenter).x;

bool belowSolid = (int(belowMat) != MAT_AIR);
bool aboveSolid = (int(aboveMat) != MAT_AIR);
bool leftSolid  = (int(leftMat) != MAT_AIR);
bool rightSolid = (int(rightMat) != MAT_AIR);
bool onSurface = belowSolid || aboveSolid || leftSolid || rightSolid;

// collision displacement: if current cell is now solid (sand fell on us), push to nearest air
if (int(currentMat) != MAT_AIR && int(currentMat) != MAT_HOME) {
    // try up first, then left, then right
    vec2 upPos = cellCenter + vec2(0., cellSize);
    vec2 leftPos = cellCenter - vec2(cellSize, 0.);
    vec2 rightPos = cellCenter + vec2(cellSize, 0.);
    if (int(texture(tWorld, upPos).x) == MAT_AIR) {
        pos = upPos;
    } else if (int(texture(tWorld, leftPos).x) == MAT_AIR) {
        pos = leftPos;
    } else if (int(texture(tWorld, rightPos).x) == MAT_AIR) {
        pos = rightPos;
    }
    // recalculate surface after displacement
    cellCenter = roundUvToCellCenter(pos);
    belowMat = texture(tWorld, cellCenter - vec2(0., cellSize)).x;
    belowSolid = (int(belowMat) != MAT_AIR);
    onSurface = belowSolid; // simplified re-check
}

bool isFalling = false;

// GRAVITY: if nothing solid below and not at world bottom, fall
if (!belowSolid && pos.y > cellSize) {
    pos.y -= cellSize;
    isFalling = true;
}

Step 2: Gate all steering logic behind !isFalling

Wrap the entire existing movement block (food seeking, pheromone following, digging bias, wandering) in:

if (!isFalling) {
    // ... all existing movement code from lines 126-238 ...
}

Step 3: Update isObstacle() to block ALL non-air materials

Change isObstacle() to treat all solid materials as obstacles (not just rock):

bool isObstacle(vec2 pos) {
    float materialId = texture(tWorld, roundUvToCellCenter(pos)).x;
    int matInt = int(materialId);
    // ants can't walk into any solid material (only through air and home)
    return matInt != MAT_AIR && matInt != MAT_HOME;
}

This prevents ants from walking through sand, dirt, food, or rock. They can only enter air and home cells.

Step 4: Run just check

Expected: PASS

Step 5: Commit

Message: Add ant gravity, surface constraint, and collision displacement


Task 3.2: Ant activation — budget pool model

Files:

  • Modify: src/shaders/antsCompute.frag

Currently all ants at pos == vec2(0) get initialized. We need only the first ANTS_START_COUNT ants to activate. The rest stay dormant at (0,0).

Step 1: Gate initialization by ant index

Replace the init block:

// calculate this ant's index from its UV position in the texture
ivec2 texSize = textureSize(tLastState, 0);
int antIndex = int(vUv.y * float(texSize.y)) * texSize.x + int(vUv.x * float(texSize.x));

if (pos == vec2(0)) {
    // only activate ants within the start count (or activated by spawn buffer later)
    if (antIndex >= ANTS_START_COUNT) {
        // dormant ant — output zeros and skip everything
        FragColor = vec4(0);
        FragColorExt = vec4(0);
        return;
    }

    #if VIEW_MODE_SIDE
    // spawn on sand surface (existing logic)
    float spawnX = rand(vUv * 10000.);
    float pixelSize = 1.0 / float(texSize.x);
    float surfaceY = 0.6;
    for (float scanY = 1.0; scanY > 0.0; scanY -= pixelSize) {
        float matId = texture(tWorld, vec2(spawnX, scanY)).x;
        if (matId > 0.5) {
            surfaceY = scanY + pixelSize;
            break;
        }
    }
    pos = vec2(spawnX, surfaceY);
    angle = -PI * 0.5;
    #else
    pos = vec2(0.5);
    angle = rand(vUv * 10000.) * 2. * PI;
    #endif
    isCarrying = 0.;
    storage = 0.;
    personality = rand(vUv * 42069.);
    cargoMaterialId = 0.;
    pathIntDx = 0.;
    pathIntDy = 0.;
}

Note: the antIndex variable will be reused in a later task for the spawn buffer. The pixelSize variable changes to use texSize from the world texture — need to keep the scan using world texture size, not ant texture size:

float pixelSize = 1.0 / float(textureSize(tWorld, 0).x);

Step 2: Run just check

Expected: PASS

Step 3: Commit

Message: Gate ant initialization by ANTS_START_COUNT budget


Phase 4: Ant Brain — Priority Stack

Task 4.1: Rewrite ant movement as priority stack

Files:

  • Modify: src/shaders/antsCompute.frag

This is the biggest single change. Replace the existing movement logic (lines 126-238 in the current shader) with the priority-based behavioral model.

Step 1: Rewrite the movement section

The new movement block (inside the if (!isFalling) { ... } gate from Task 3.1) becomes:

if (!isFalling) {
    bool acted = false;

    // ---- PRIORITY 1: DEPOSIT ----
    if (!acted && isCarrying == 1.) {
        float cellMatId = texture(tWorld, roundUvToCellCenter(pos)).x;

        // carrying food and at home -> drop food
        if (int(cargoMaterialId) == MAT_FOOD && tryDropFood(pos)) {
            isCarrying = 0.;
            cargoMaterialId = 0.;
            angle += PI;
            storage = getMaxScentStorage(vUv);
            acted = true;
        }

        // carrying powder and on surface (air above, solid below) -> deposit
        if (!acted && int(cargoMaterialId) != MAT_FOOD) {
            if (int(cellMatId) == MAT_AIR) {
                vec2 belowPos = pos - vec2(0., cellSize);
                float belowMatId = texture(tWorld, roundUvToCellCenter(belowPos)).x;
                vec2 abovePos = pos + vec2(0., cellSize);
                float aboveMatId = texture(tWorld, roundUvToCellCenter(abovePos)).x;
                if ((int(belowMatId) != MAT_AIR || belowPos.y <= 0.)
                    && int(aboveMatId) == MAT_AIR) {
                    isCarrying = 0.;
                    // keep cargoMaterialId set so discretize reads it this frame
                    angle += PI;
                    storage = getMaxScentStorage(vUv);
                    acted = true;
                }
            }
        }
    }

    // ---- PRIORITY 2: NAVIGATE (carrying) ----
    if (!acted && isCarrying == 1.) {
        if (int(cargoMaterialId) == MAT_FOOD) {
            // follow toHome pheromone
            float sAhead = smell(applyOffsetToPos(pos, vec2(cos(angle), sin(angle)) * sampleDistance), 1.);
            float sLeft = smell(applyOffsetToPos(pos, vec2(cos(angle - ANT_ROTATION_ANGLE), sin(angle - ANT_ROTATION_ANGLE)) * sampleDistance), 1.);
            float sRight = smell(applyOffsetToPos(pos, vec2(cos(angle + ANT_ROTATION_ANGLE), sin(angle + ANT_ROTATION_ANGLE)) * sampleDistance), 1.);

            if (sLeft > sAhead && sLeft > sRight) {
                angle -= ANT_ROTATION_ANGLE;
            } else if (sRight > sAhead && sRight > sLeft) {
                angle += ANT_ROTATION_ANGLE;
            }
        } else {
            // carrying powder: bias upward toward surface
            #if VIEW_MODE_SIDE
            float upwardBias = PI * 0.5;
            float angleDiff = upwardBias - angle;
            angleDiff = mod(angleDiff + PI, 2.0 * PI) - PI;
            angle += angleDiff * 0.3;
            #endif
        }
        // add wander noise to carrying ants too
        float noise2 = rand(vUv * 1000. + fract(uTime / 1000.) + 0.2);
        if (noise2 > 0.5) {
            angle += ANT_ROTATION_ANGLE;
        } else {
            angle -= ANT_ROTATION_ANGLE;
        }
        acted = true;
    }

    // ---- PRIORITY 3: FORAGE (not carrying, food scent detected) ----
    if (!acted && isCarrying == 0.) {
        float sAhead = smell(applyOffsetToPos(pos, vec2(cos(angle), sin(angle)) * sampleDistance), 0.);
        float sLeft = smell(applyOffsetToPos(pos, vec2(cos(angle - ANT_ROTATION_ANGLE), sin(angle - ANT_ROTATION_ANGLE)) * sampleDistance), 0.);
        float sRight = smell(applyOffsetToPos(pos, vec2(cos(angle + ANT_ROTATION_ANGLE), sin(angle + ANT_ROTATION_ANGLE)) * sampleDistance), 0.);

        float maxSmell = max(sAhead, max(sLeft, sRight));

        if (maxSmell > SCENT_THRESHOLD) {
            // follow food scent
            if (sLeft > sAhead && sLeft > sRight) {
                angle -= ANT_ROTATION_ANGLE;
            } else if (sRight > sAhead && sRight > sLeft) {
                angle += ANT_ROTATION_ANGLE;
            }
            acted = true;
        }
    }

    // ---- PRIORITY 4: DIG (not carrying, diggable material nearby below surface) ----
    #if VIEW_MODE_SIDE
    if (!acted && isCarrying == 0.) {
        // check cell below for diggable material
        vec2 belowUv = roundUvToCellCenter(pos - vec2(0., cellSize));
        float belowMat2 = texture(tWorld, belowUv).x;
        vec4 belowProps2 = texelFetch(uMaterialProps, ivec2(int(belowMat2), 0), 0);

        // suppress digging if on the surface (air above) — don't dig topsoil into sky
        vec2 aboveUv = roundUvToCellCenter(pos + vec2(0., cellSize));
        float aboveMat2 = texture(tWorld, aboveUv).x;
        bool onSurfaceTop = (int(aboveMat2) == MAT_AIR);

        if (!onSurfaceTop
            && belowProps2.r == BEHAVIOR_POWDER
            && belowProps2.b <= ANT_CARRY_STRENGTH) {
            // bias angle toward ~40 degrees below horizontal (angle of repose)
            // pick left-down or right-down based on current facing
            float targetAngle = (cos(angle) >= 0.)
                ? -0.7  // ~-40 degrees (right and down)
                : -(PI - 0.7);  // ~-(180-40) degrees (left and down)
            float angleDiff2 = targetAngle - angle;
            angleDiff2 = mod(angleDiff2 + PI, 2.0 * PI) - PI;
            angle += angleDiff2 * 0.2;
            acted = true;
        }
    }
    #endif

    // ---- PRIORITY 5: WANDER (fallback) ----
    if (!acted) {
        // random walk with noise
        if (noise < 0.33) {
            angle += ANT_ROTATION_ANGLE;
        } else if (noise < 0.66) {
            angle -= ANT_ROTATION_ANGLE;
        }
        float noise2 = rand(vUv * 1000. + fract(uTime / 1000.) + 0.2);
        if (noise2 > 0.5) {
            angle += ANT_ROTATION_ANGLE * 2.;
        } else {
            angle -= ANT_ROTATION_ANGLE * 2.;
        }
    }
}

Step 2: Keep the pickup logic after movement

The existing pickup block (lines 247-268 in current shader) stays mostly the same, after the pos = applyOffsetToPos(pos, offset) line. The deposit-at-home block (lines 271-279) is now handled in Priority 1, so remove the duplicate at lines 271-294 (both the home drop and the powder deposit sections — they're now in Priority 1).

The movement application and wall-bounce remain:

if (!isFalling) {
    vec2 offset = vec2(cos(angle), sin(angle));
    pos = applyOffsetToPos(pos, offset);

    if (fract(pos.x) == 0. || fract(pos.y) == 0. || (!wasObstacle && isObstacle(pos + offset * cellSize))) {
        angle += PI * (noise - 0.5);
    }

    // pickup logic (food and diggable powder)
    if (isCarrying == 0.) {
        float cellMatId = texture(tWorld, roundUvToCellCenter(pos)).x;
        int cellMatInt = int(cellMatId);

        if (cellMatInt == MAT_FOOD) {
            isCarrying = 1.;
            cargoMaterialId = cellMatId;
            angle += PI;
            storage = getMaxScentStorage(vUv);
        } else if (cellMatInt != MAT_AIR && cellMatInt != MAT_HOME) {
            vec4 props = texelFetch(uMaterialProps, ivec2(cellMatInt, 0), 0);
            float behavior = props.r;
            float hardness = props.b;
            if (behavior == BEHAVIOR_POWDER && hardness <= ANT_CARRY_STRENGTH) {
                isCarrying = 1.;
                cargoMaterialId = cellMatId;
                angle += PI;
                storage = getMaxScentStorage(vUv);
            }
        }
    }
}

Step 3: Run just check

Expected: PASS

Step 4: Commit

Message: Rewrite ant behavior as priority stack with suppressors


Phase 5: Unified Brush — Ants as Droppable Element

Task 5.1: Add ant spawn buffer texture

Files:

  • Modify: src/Renderer.ts

We need a small texture that the draw pass writes ant spawn positions to, and the ant compute pass reads from. A 1x1 DataTexture is enough — it stores the spawn position and count for this frame.

Actually, a simpler approach: use a uniform vec4 on AntsComputeScene that encodes (spawnX, spawnY, spawnCount, 0). The JS side sets this each frame based on pointer state + brush element selection.

Step 1: Add spawn uniform to AntsComputeScene

In src/scenes/AntsComputeScene.ts, add to the uniforms object:

uAntSpawn: { value: new THREE.Vector4(0, 0, 0, 0) },

Step 2: Add ant spawn uniform in Renderer.renderSimulation()

In src/Renderer.ts renderSimulation(), before the ant compute render call, add:

scenes.ants.material.uniforms.uAntSpawn.value =
    scenes.screen.antSpawnRequest;
// clear after reading
scenes.screen.antSpawnRequest.set(0, 0, 0, 0);

Step 3: Run just check

Expected: PASS

Step 4: Commit

Message: Add ant spawn uniform to AntsComputeScene


Task 5.2: Handle ant brush element in ScreenScene

Files:

  • Modify: src/scenes/ScreenScene.ts
  • Modify: src/Config.ts

Step 1: Add antSpawnRequest vector and keybinding

In src/scenes/ScreenScene.ts:

Add a public field:

public readonly antSpawnRequest: THREE.Vector4 = new THREE.Vector4(0, 0, 0, 0);

Add a constant for the "ants" brush element. Since material IDs are 0-255, use a sentinel value like 999 to represent "draw ants":

const BRUSH_ANTS = 999;

Add keybinding in the keydown handler:

case "KeyA": {
    this.drawMode = BRUSH_ANTS;
    break;
}

Export BRUSH_ANTS so GUI can reference it.

Step 2: Modify effectiveDrawMode to handle ants

When the effective draw mode is BRUSH_ANTS, don't pass it to the draw shader (which writes materials). Instead, populate antSpawnRequest with the pointer position and brush radius.

Add to the pointer move/down handler (or a per-frame update):

In the update() method (currently empty), or in the render loop — when effectiveDrawMode === BRUSH_ANTS and pointer is down:

public update() {
    if (this.effectiveDrawMode === BRUSH_ANTS && this.isPointerDown) {
        this.antSpawnRequest.set(
            this.pointerPosition.x,
            this.pointerPosition.y,
            Config.brushRadius,
            1, // flag: spawn requested
        );
    }
}

Override effectiveDrawMode to return -1 (no material drawing) when the ant brush is active:

public get effectiveDrawMode(): number {
    if (this.drawMode === BRUSH_ANTS) return -1;
    if (this.drawMode >= 0) return this.drawMode;
    if (this.isPointerDown && Config.brushMaterial >= 0) {
        if (Config.brushMaterial === BRUSH_ANTS) return -1;
        return Config.brushMaterial;
    }
    return -1;
}

public get isAntBrushActive(): boolean {
    if (this.drawMode === BRUSH_ANTS) return true;
    if (this.isPointerDown && Config.brushMaterial === BRUSH_ANTS) return true;
    return false;
}

Step 3: Update GUI to include ants in brush palette

In src/GUI.ts, add to brushLabels:

"ants  (A)": 999,

Step 4: Run just check

Expected: PASS

Step 5: Commit

Message: Add ant brush element with keybinding and GUI entry


Task 5.3: Activate dormant ants from spawn buffer in shader

Files:

  • Modify: src/shaders/antsCompute.frag

Step 1: Add spawn activation logic

Add uniform vec4 uAntSpawn; declaration near the other uniforms.

In the init block, after the dormant check (if (antIndex >= ANTS_START_COUNT)), add spawn buffer activation:

if (pos == vec2(0)) {
    // check if this ant should activate via spawn request
    bool spawnRequested = (uAntSpawn.w > 0.5);

    if (antIndex >= ANTS_START_COUNT && !spawnRequested) {
        // dormant ant, no spawn request — skip
        FragColor = vec4(0);
        FragColorExt = vec4(0);
        return;
    }

    if (antIndex >= ANTS_START_COUNT && spawnRequested) {
        // activate this ant at spawn position with some scatter
        float scatter = uAntSpawn.z / WORLD_SIZE; // brush radius in UV space
        float rngX = rand(vUv * 10000. + fract(uTime / 1000.));
        float rngY = rand(vUv * 20000. + fract(uTime / 1000.) + 0.5);
        pos = vec2(
            uAntSpawn.x + (rngX - 0.5) * scatter,
            uAntSpawn.y + (rngY - 0.5) * scatter
        );
        pos = clamp(pos, 0., 1.);
        angle = rand(vUv * 42069.) * 2.0 * PI;
        isCarrying = 0.;
        storage = 0.;
        personality = rand(vUv * 42069.);
        cargoMaterialId = 0.;
        pathIntDx = 0.;
        pathIntDy = 0.;
    } else {
        // normal init for starting ants (existing logic)
        #if VIEW_MODE_SIDE
        float spawnX = rand(vUv * 10000.);
        float pixelSize = 1.0 / float(textureSize(tWorld, 0).x);
        float surfaceY = 0.6;
        for (float scanY = 1.0; scanY > 0.0; scanY -= pixelSize) {
            float matId = texture(tWorld, vec2(spawnX, scanY)).x;
            if (matId > 0.5) {
                surfaceY = scanY + pixelSize;
                break;
            }
        }
        pos = vec2(spawnX, surfaceY);
        angle = -PI * 0.5;
        #else
        pos = vec2(0.5);
        angle = rand(vUv * 10000.) * 2. * PI;
        #endif
        isCarrying = 0.;
        storage = 0.;
        personality = rand(vUv * 42069.);
        cargoMaterialId = 0.;
        pathIntDx = 0.;
        pathIntDy = 0.;
    }
}

Note: this activates ALL dormant ants when a spawn request comes in. To limit activation to brushRadius count of ants per frame, we'd need a counter. For simplicity, we limit by checking if antIndex is within a small range of the "next available" slot. A simpler approach: only activate one ant per frame per spawn request, using the frame counter as a slot selector:

Actually, the simplest correct approach: each dormant ant uses a random check against brushRadius / antBudget probability to decide if it activates this frame. This naturally gives ~brushRadius activations per frame:

if (antIndex >= ANTS_START_COUNT && spawnRequested) {
    // probabilistic activation: ~brushRadius ants per frame
    float activationChance = uAntSpawn.z / float(ANT_BUDGET);
    float roll = rand(vUv * 50000. + fract(uTime / 1000.));
    if (roll > activationChance) {
        // not selected this frame — stay dormant
        FragColor = vec4(0);
        FragColorExt = vec4(0);
        return;
    }
    // ... activate at spawn position ...
}

Step 2: Run just check

Expected: PASS

Step 3: Commit

Message: Activate dormant ants from spawn buffer in shader


Phase 6: Sand Color Variation

Task 6.1: Add per-pixel hash noise to material rendering

Files:

  • Modify: src/shaders/screenWorld.frag

Step 1: Add hash function and noise to material color

Add a spatial hash function and apply it to material colors:

// add before main():
float hash(ivec2 p) {
    int h = p.x * 374761393 + p.y * 668265263;
    h = (h ^ (h >> 13)) * 1274126177;
    return float(h & 0x7fffffff) / float(0x7fffffff);
}

In main(), modify the material color section:

// old:
if (materialId != MAT_AIR) {
    vec4 matColor = texelFetch(uMaterialColors, ivec2(materialId, 0), 0);
    color = mix(matColor.rgb, t, a * 0.3);
}

// new:
if (materialId != MAT_AIR) {
    vec4 matColor = texelFetch(uMaterialColors, ivec2(materialId, 0), 0);
    // per-pixel color variation: +/-4% brightness noise
    float colorNoise = hash(ivec2(gl_FragCoord.xy)) * 0.08 - 0.04;
    vec3 variedColor = matColor.rgb + colorNoise;
    color = mix(variedColor, t, a * 0.3);
}

Step 2: Run just check

Expected: PASS

Step 3: Commit

Message: Add per-pixel hash noise to material colors


Phase 7: Cleanup and Polish

Task 7.1: Remove old brushMaterial config references

Files:

  • Modify: src/Config.ts

If brushMaterial was kept for backward compatibility, verify all references now use brushElement / the palette labels. If brushMaterial is still the backing store for the GUI dropdown (which it is — lil-gui writes to Config.brushMaterial via the onChange), then keep it but rename it mentally. Actually, the existing brushMaterial config key already stores a material ID (or -1 or 999 for ants). It works as-is. No change needed here — skip this task.


Task 7.2: Update stats overlay for new ant count

Files:

  • Modify: src/StatsOverlay.ts

The stats overlay shows "ants: 4096". With the budget model, it should show active ants vs budget. The ColonyStats CPU readback already counts ants — it reads the ant texture and counts non-zero positions.

Step 1: Check ColonyStats

Read src/ColonyStats.ts to see what it already reports. If it counts active ants, just make sure the overlay displays it correctly. If not, add a count of ants where pos != vec2(0).

Step 2: Update overlay display

In src/StatsOverlay.ts, change the ants display line to show active/budget:

// e.g., "ants    12 / 512"

Step 3: Run just check

Expected: PASS

Step 4: Commit

Message: Update stats overlay to show active ant count vs budget


Task 7.3: Update CLAUDE.md

Files:

  • Modify: CLAUDE.md

Update the architecture docs to reflect:

  • New config keys (antsStartCount, antBudget, seedWorld)
  • Ant physics model (gravity, surface constraint)
  • Priority stack behavioral model
  • Unified brush with ant spawning
  • Sand color variation

Step 1: Update relevant sections of CLAUDE.md

Step 2: Commit

Message: Update CLAUDE.md for ant behavior overhaul


Future Phases (not implemented now, hooks are in place)

Phase B: Digging Pheromone

  • Add a 4th pheromone channel or repurpose repellent (A channel) for "dig here" scent
  • Ants deposit dig pheromone when picking up material
  • Other ants attracted to dig pheromone → dig nearby
  • Pheromone decay rate controls tunnel compactness
  • Hook: the antsPresenceRenderTarget (already cleared each frame) can be populated with ant density per cell, enabling the chamber→tunnel density threshold transition

Phase C: Colony Dynamics

  • Age parameter per ant (repurpose or extend personality in texture 1)
  • Young ants: higher dig probability, slanted tunnel preference
  • Old ants: higher forage probability, vertical digging
  • Colony growth: activate new ants from budget when delivered-food count exceeds threshold
  • Expansion trigger: population / excavated-area ratio threshold
  • Emergency mode: all ants dig when nest area drops below expected
  • Queen mechanics: founding behavior, reproduction gating