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
materialRegistryinstance - 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
uDrawMaterialuniform (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:
- Determine which 2x2 block this pixel belongs to (based on
uBlockOffset) - Determine this pixel's position within the block (0-3: top-left, top-right, bottom-left, bottom-right)
- Read all 4 cells of the block from the world texture
- For each cell, look up its material behavior from the material properties texture
- 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
- Write this pixel's new material ID to output R channel
- Pass through G, B, A (pheromones) unchanged
Key details:
uBlockOffsetuniform: vec2, set fromgetBlockOffset(frame)uFrameuniform: 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 textureuBlockOffset— vec2, fromgetBlockOffset(frame)uFrame— int, current frame numberuPixelSize— 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:
- SandPhysicsScene — material movement (gravity, displacement)
- WorldBlurScene — pheromone diffusion (reads sand physics output)
- (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.tsor createsrc/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_STRENGTHAND 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]cargoMaterialIdis 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.tsor createsrc/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