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