Ant Colony Simulation — Emergent Swarm Intelligence Through Pheromones
Go deeper on this topic
Creative Coding
Mathematical simulations, nature visualization, and generative art in the browser
Article 11 of 12 in this series.
Next reads
60fps Particles with Canvas API — Smooth Animation Design for 200+ Particles
Design techniques for running 200+ particles at 60fps using Canvas API with requestAnimationFrame, fixed timestep, DPI scaling, and spatial partitioning.
The World of Cellular Automata — From Game of Life to Wave Function Collapse
Design patterns for rule-based cellular automata and grid optimization with Uint8Array, explored through implementations of Game of Life, WaveFunctionCollapse, and SandFall.
Fluid Simulation in the Browser — Real-Time Computation on a 100x60 Grid
Running a real-time fluid simulation by simplifying the Navier-Stokes equations on a 100x60 grid. Implementation details of diffusion, advection, and pressure solver.
Use the topic hub as a map, then continue to the next article in the series. New here? Start at the home hub →
Why an Ant Simulation?
A single ant follows simple rules. It searches for food, and when it finds some, carries it back to the nest. That's it. But when dozens of ants follow the same rules, "intelligence" emerges — shortest path discovery, efficient division of labor. This phenomenon is called Stigmergy, achieved not through direct communication between individuals but through indirect information sharing via the environment.
The AntFarm component reproduces ant swarm intelligence on Canvas API using just three mechanisms: pheromone secretion, evaporation, and trail following.
Data Structures — Grid and Agents
The world is represented by two Typed Arrays:
const grid = new Uint8Array(cols * rows) // Cell types
const pheromone = new Float32Array(cols * rows) // Pheromone concentrationUint8Array uses 1 byte per cell. Five cell types — air, dirt, tunnel, food, and nest — are managed with an enum:
enum CellType {
Air = 0,
Dirt = 1,
Tunnel = 2,
Food = 3,
Nest = 4,
Grass = 5,
}Pheromone concentration uses Float32Array for continuous values from 0.0 to 1.0. Uint8Array can only represent 256 levels, making the decay curve look like a staircase. Float32Array was chosen to achieve smooth decay.
Ants are managed as an array of objects:
enum AntState {
Wander = 0, // Searching for food
CarryFood = 1, // Carrying food
}
interface Ant {
x: number
y: number
state: AntState
dx: number // Movement direction X
dy: number // Movement direction Y
digCooldown: number
}Each ant has only two states: "searching" and "carrying." This minimal state transition drives the behavior of the entire simulation.
Three Pheromone Mechanisms
1. Deposit
An ant that has found food secretes pheromone at its current position as it returns to the nest:
const PHEROMONE_STRENGTH = 0.8
// Executed every tick by ants in CarryFood state
const pi = ant.y * cols + ant.x
pheromone[pi] = Math.min(pheromone[pi] + PHEROMONE_STRENGTH, 1)Math.min clamps the value to a maximum of 1.0. Without this, the value would increase without bound every time multiple ants pass through the same cell.
2. Evaporation
Every tick, the pheromone concentration of all cells decays:
const PHEROMONE_DECAY = 0.997
for (let i = 0; i < pheromone.length; i++) {
if (pheromone[i] > 0.001) {
pheromone[i] *= PHEROMONE_DECAY
} else {
pheromone[i] = 0
}
}A decay rate of 0.997 means the concentration halves in about 230 ticks (0.997^230 ≈ 0.5). If decay is too fast, trails disappear. If too slow, stale information persists and ants crowd inefficient routes.
Truncating values below the 0.001 threshold to zero is a performance optimization. Multiplying tiny values every frame across tens of thousands of cells is wasteful.
3. Following
Searching ants are attracted toward higher pheromone concentrations. However, in AntFarm's current implementation, simplicity takes priority — Wander-state ants use random walk with a downward bias. Only CarryFood-state ants follow a direction vector toward the nest:
// Calculate direction to nest
const tdx = nest.x - ant.x
const tdy = nest.y - ant.y
let moveDx = tdx > 0 ? 1 : tdx < 0 ? -1 : 0
let moveDy = tdy > 0 ? 1 : tdy < 0 ? -1 : 0
// 20% chance of deviating in a random direction
if (Math.random() < 0.2) {
const d = randomDir()
moveDx = d.dx
moveDy = d.dy
}This "20% random deviation" is crucial. If ants follow the shortest path perfectly, they all take the same route and can't discover new paths. Noise ensures exploration diversity.
Environmental Interaction — Digging and Obstacles
Ants dig through dirt to create tunnels:
const nx = ant.x + ant.dx
const ny = ant.y + ant.dy
if (inBounds(nx, ny)) {
const ci = ny * cols + nx
const cell = grid[ci]
if (cell === CellType.Dirt && ant.digCooldown <= 0) {
grid[ci] = CellType.Tunnel
ant.digCooldown = 3 // 3-tick cooldown
}
}digCooldown limits digging speed. Without it, ants would dig through multiple cells per tick, preventing the natural "gradually expanding tunnel" effect.
In the initial state, a single tunnel is pre-dug from the nest to the surface. Starting from a state completely covered in dirt would cause ants to get stuck around the nest:
let cx = nestX
let cy = surfaceY
while (cy < nestY - 2) {
for (let ox = -1; ox <= 1; ox++) {
if (inBounds(cx + ox, cy)) {
const ci = cy * cols + (cx + ox)
if (grid[ci] === CellType.Dirt) {
grid[ci] = CellType.Tunnel
}
}
}
cy++
// 30% chance of shifting left or right → natural meandering
if (Math.random() < 0.3) {
cx = clamp(cx + (Math.random() < 0.5 ? -1 : 1), 2, cols - 3)
}
}Pixel-Level Rendering with ImageData
Rendering thousands of cells plus pheromone overlays plus ants every frame is too slow with fillRect. Instead, we manipulate ImageData directly:
const imgData = ctx.createImageData(w * dpr, h * dpr)
const data = imgData.data
for (let y = 0; y < rows; y++) {
for (let x = 0; x < cols; x++) {
const gi = y * cols + x
const cell = grid[gi]
const ph = pheromone[gi]
let r = 0, g = 0, b = 0
// Determine color based on cell type...
// Pheromone overlay (linear interpolation)
if (ph > 0.01 && (cell === CellType.Tunnel || cell === CellType.Air)) {
const alpha = Math.min(ph, 0.6)
r = Math.floor(r * (1 - alpha) + 120 * alpha)
g = Math.floor(g * (1 - alpha) + 180 * alpha)
b = Math.floor(b * (1 - alpha) + 255 * alpha)
}
// Scale cells and write to ImageData
}
}
ctx.putImageData(imgData, 0, 0)Pheromone visualization uses hand-calculated alpha blending. Instead of using globalAlpha or layer compositing, we perform direct linear interpolation on RGB values. Transfer to the GPU happens with a single putImageData call.
Ant rendering also rasterizes circles directly onto the same ImageData buffer. This is faster than drawing paths with arc() and keeps everything in a single buffer.
Speed Control and Frame Rate
The mechanism for switching simulation speed between 1x/2x/4x:
const loop = () => {
const ticks = speedRef.current
for (let i = 0; i < ticks; i++) {
simulate()
}
render()
rafRef.current = requestAnimationFrame(loop)
}simulate() is called multiple times per frame, but render() is called only once. Since rendering is the most expensive operation, increasing simulation iterations doesn't change the rendering load.
Design Principles for Agent-Based Models
Lessons learned from the AntFarm implementation:
- Keep state minimal: Ants have only two states — Wander and CarryFood. The more states there are, the harder debugging becomes
- Store information in the environment: Ants don't communicate directly. Pheromone as "environmental memory" enables indirect coordination
- Build noise into the design: Perfect optimization leads to local optima. Randomness broadens the exploration space
- Use Typed Arrays to represent space: For grid-based simulations,
Uint8Array/Float32Arrayare orders of magnitude faster than object arrays
Summary: Tools for Pheromone Simulation
To deeply understand the background theory of agent-based models and swarm intelligence, the books on the tool shelf are ideal. The mechanism by which complex behavior emerges from simple rules is fundamental to game AI and applies broadly to simulating the natural world.