Move your cursor through the school above. It splits around you, streams past, and closes back into one shoal behind you like water around a stone. Nothing up there is choreographed. There is no path, no leader, no animation timeline. Every fish is following the same three rules, looking only at the handful of neighbours near it, and the shoal is what happens when a few hundred of them do that at once.
This is boids, Craig Reynolds' 1986 flocking model, and it runs the ambient sea life in Privateer, my hex grid naval tactics game. A live battle has around 2,000 fish drifting over the water, tearing open around ship hulls and scattering when a shark cuts through. None of it touches gameplay. All of it has to be nearly free. This article is how you get from three rules to two thousand fish that cost almost nothing.
Three Rules, No Leader
Each fish steers by three urges, and that's the whole behaviour model:
- Separation. Don't crowd the fish right next to you. Steer away from anyone inside your personal space.
- Alignment. Point roughly where your neighbours are pointing. Match the average heading of the fish around you.
- Cohesion. Don't get left behind. Drift toward the average position of your neighbours.
That's it. No fish knows where the school is going, because the school isn't going anywhere in particular. The graceful shoal you see is emergent: it exists at the level of the group, but it's nowhere in the code for a single fish. In the sim above, the three sliders are exactly these three weights. Crank separation and the school turns to loose static. Crank cohesion and it balls up into a nervous knot. Leave them near the middle and you get fish.
In code, each rule is a steering vector, accumulated into one acceleration:
if (separationCount > 0)
acceleration += Steer(separation / separationCount, vel) * SeparationWeight;
if (neighborCount > 0)
{
acceleration += Steer(alignment / neighborCount, vel) * AlignmentWeight;
float2 toCenter = (cohesion / neighborCount) - pos;
acceleration += Steer(toCenter, vel) * CohesionWeight;
}
separation, alignment and cohesion are sums gathered from nearby fish: the push away from close ones, the summed velocity of neighbours, the summed position of neighbours. Steer just turns a desired direction into a force relative to where the fish is already heading. Weight each one, clamp the total so a fish can't turn on a dime, integrate, done. The behaviour is trivial. The word "nearby" is where all the trouble lives.
The Neighbour Trap
Every fish needs the fish near it. The obvious way to find them is to loop over every other fish and keep the close ones. Write that and it works beautifully at 200 fish. Then you turn the count up.
The cost is O(n squared). At 2,000 fish, every fish checking every other fish is about four million distance tests, every frame, sixty times a second. That's a quarter of a billion square roots a second to make some background decoration wiggle. The profiler does not find this funny, and neither will a mid range phone.
The fix is to stop asking fish that are obviously too far away. Bucket the world into a grid of cells the size of a fish's neighbour radius, drop each fish into the cell it sits in, and then a fish only has to check its own cell and the eight around it. Nine cells instead of the whole ocean. This is a spatial hash, and building it is itself a parallel job:
public void Execute(int index)
{
float2 p = Positions[index];
int cx = (int)math.floor(p.x / CellSize);
int cy = (int)math.floor(p.y / CellSize);
Hash.Add(BoidsJob.HashCell(cx, cy), index);
}
Then the flocking loop walks the 3x3 block of cells around each fish instead of the entire array:
int baseCx = (int)math.floor(pos.x / CellSize);
int baseCy = (int)math.floor(pos.y / CellSize);
for (int ox = -1; ox <= 1; ox++)
for (int oy = -1; oy <= 1; oy++)
{
int key = HashCell(baseCx + ox, baseCy + oy);
if (!SpatialHash.TryGetFirstValue(key, out int other, out var it)) continue;
do
{
if (other == index) continue;
float2 diff = pos - PositionsIn[other];
float distSqr = math.lengthsq(diff);
if (distSqr > neighborSqr || distSqr < 1e-6f) continue;
// ...accumulate separation / alignment / cohesion...
} while (SpatialHash.TryGetNextValue(out other, ref it));
}
The cell key comes from a cheap integer hash that survives negative coordinates, (x * 73856093) ^ (y * 19349663), the classic Teschner spatial hash. Because the school is spread over the sea rather than piled in one cell, each fish now compares itself against a small, roughly constant handful of neighbours. The four million tests collapse to something close to linear. Same behaviour, same look, and now it fits in the frame budget with room to spare. Notice the distance check uses lengthsq, never sqrt: comparing squared distances gives the same answer and skips the square root on the hottest line in the whole system.
Off the Main Thread
Linear is not the same as free. Two thousand fish, each reading a chunk of the spatial hash, is still real work, and I did not want a single frame of it running on the main thread where it competes with actual gameplay. So none of it does.
The whole simulation is a chain of Burst compiled jobs. Building the hash, hunting for sharks, the flocking step, and baking the mesh all run as IJobParallelFor across worker threads. Two details make that safe. The state is double buffered: every fish reads last frame's positions and writes next frame's into a separate array, so a thousand threads reading and writing at once never step on each other. And the chain is scheduled in Tick but only completed in LateTick:
// Tick: kick the jobs off and immediately return.
_handle = meshJob.Schedule(_fishCount, 64, handle);
_jobsScheduled = true;
// LateTick: the work has been running on workers all frame; collect it now.
public void LateTick()
{
if (!_jobsScheduled) return;
_handle.Complete();
_readIsA = !_readIsA; // what we wrote becomes next frame's read buffer
...
}
Between those two calls, Unity is off doing everything else a frame does, and the fish are being simulated in parallel the entire time. By the time LateTick asks for the result, it's usually already sitting there. The fish sim costs the main thread almost nothing but the schedule call and the flip of a boolean.
One Mesh, No GameObjects
Here's the mistake I'm glad I didn't make. The instinct is one prefab per fish: a GameObject, a SpriteRenderer, a Transform, times two thousand. That's two thousand draw calls, two thousand transform writes a frame, and a hierarchy that makes the editor cry. Two thousand of anything with a SpriteRenderer is a bad afternoon.
Instead the entire school is a single dynamic mesh. Four vertices per fish, one big vertex buffer, one renderer, one draw call. A parallel job writes every fish's quad straight into that buffer, orienting each one along its heading and mirroring it when the fish swims left so it never ends up belly up:
// Face along the on-screen heading; mirror "up" when swimming left.
float angle = math.atan2(v.y * VerticalSquash, v.x);
math.sincos(angle, out float sa, out float ca);
float flip = v.x < 0f ? -1f : 1f;
float2 right = new float2(ca, sa);
float2 up = new float2(-sa, ca) * flip;
int b = index * 4;
Vertices[b + 0] = new float3(centre - rW - uH, Z);
Vertices[b + 1] = new float3(centre + rW - uH, Z);
Vertices[b + 2] = new float3(centre + rW + uH, Z);
Vertices[b + 3] = new float3(centre - rW + uH, Z);
The UVs, the per fish tint and the triangle indices are baked once at spawn and never touched again. Only the vertex positions change per frame, written by the job and uploaded to the GPU in one call in LateTick. There are no fish GameObjects anywhere in the scene. The school is one object that happens to look like two thousand.
Keeping Them in the Sea
Fish that swim onto land break the whole illusion, and this is the part I got wrong first. My first attempt was a look ahead probe: sample the water mask a step in front of each fish, and if the next step is land, reflect the velocity. It half worked. Fish near a straight coast turned fine, but a straggler near a corner would hit the shore, bounce, drift back, hit it again, and sit there vibrating against the rock like a trapped moth. The bounce had no memory and no early warning.
The fix was to give the shoreline a smooth gradient the fish can read from far off. At spawn I bake a distance to shore field: a two pass chamfer transform over the sea and land bitmap that stamps every water cell with its approximate distance to the nearest land.
// Forward pass: top-left to bottom-right.
if (x > 0) d = math.min(d, dist[i - 1] + Ortho);
if (y > 0) d = math.min(d, dist[i - width] + Ortho);
if (x > 0 && y > 0) d = math.min(d, dist[i - width - 1] + Diag);
// ...then a backward pass sweeps bottom-right to top-left.
Two sweeps over the grid, ortho steps cost 1 and diagonal steps cost 1.41, and every water cell ends up knowing how far it is from the beach. The gradient of that field points straight toward deeper water. So instead of reacting at the last moment, a fish within a margin of the shore just steers up the gradient, gently, with a force that ramps up the closer it gets:
float dShore = WaterMask.DistanceWorld(ShoreDistance, ToWorld(pos));
if (dShore < ShoreAvoidMargin)
{
float2 grad = GradientToDeepWater(pos);
float strength = 1f - dShore / ShoreAvoidMargin;
acceleration += Steer(grad, vel) * WaterKeepWeight * strength;
}
No bounce, no reflection, no memory needed. The gradient never cancels itself out the way a reactive probe does, so a fish curves off the coast in a smooth arc instead of orbiting it. The jittering stragglers were gone the moment the field went in. There's still a hard safety net for the rare case a fish would step onto land anyway, but with the gradient doing the real work it almost never fires.
A Few Sharks
Once the fish existed, adding predators was almost free, and that's the quiet payoff of building it this way. A handful of sharks run their own tiny job that reads the same spatial hash the fish built, finds the nearest fish in range, and steers at it. The fish already flee anything in their flee list, so I just add the shark positions to it. Same hash, same water mask, same shore gradient, same mesh trick with a darker sprite. A predator that tears a hole through the school, and it reuses machinery that was already there.
The sim at the top of the page has a Sharks slider wired to exactly this. Drop one in and watch the school bend away from it and reknit behind, the same hole a ship opens but chasing you on purpose. Every shark you add is reading the fish hash that already exists, so a predator costs about as much as one more fish.
The Takeaway
Boids is the same lesson as a lot of good procedural work: global behaviour you never wrote, falling out of local rules you did. Nobody authored the shoal, the same way nobody authored a coastline in the map generator. Three urges per fish, and a school appears. That part is Reynolds' 1986 insight and it still feels like a magic trick.
But the reason there are two thousand of them and not two hundred isn't the idea, it's the engineering wrapped around it. A spatial hash so "nearby" doesn't mean "everyone". Burst jobs so the whole thing runs off the main thread. One mesh so the GPU sees one object. A distance field so the shoreline is a gentle slope instead of a wall to bounce off. The flocking is a weekend. Making it disappear into the frame budget is the actual work, and it's the part worth getting right.