Most games are full of things that don’t matter. The crowd behind the fence, the birds, the cars in the next lane over — none of it is gameplay, but all of it is the difference between a world and a set. The catch is that “doesn’t matter” has to also mean “doesn’t cost much,” because the moment your ambient decoration starts eating frame time you’ve paid a lot for something the player was never looking at directly.
I recently built a lane of background traffic that had to read as a believable stop-and-go jam, play engine audio, and physically block the player under one specific condition — all while staying cheap enough to run on mobile WebGL. The three problems it solved are ones you hit with almost any ambient system: how to make many agents move convincingly, how to make them sound right without a wall of audio sources, and how to give them physics without paying for it every frame.
Here’s how each one works, with the game-specific parts filed off.
One controller, no per-agent scripts
Before any of the tricks: the whole system is a single MonoBehaviour that owns a flat list of lightweight structs. There are no MonoBehaviours on the individual agents, no per-agent Update, and — until the very end — no colliders. Every agent is just a Transform plus a few floats.
class Agent {
public Transform t;
public float halfLength; // measured from renderer bounds at spawn
public float speed; // current, always >= 0
public bool rolling; // in a "go" phase, or paused?
public float moveBudget; // distance still owed this go-phase
public float stopTimer; // seconds left in the current pause
}
This matters more than it looks. A hundred Update() calls have real overhead in the engine’s script dispatch, and a hundred MonoBehaviours is a hundred managed objects the GC has to know about. One loop over a List<Agent> is cache-friendly, trivially cheap, and — a nice side effect — completely deterministic if you feed it your own RNG instead of the global one.
1. A jam that moves like a jam
The naive version of moving traffic is a conveyor: every car drives at the same constant speed, spacing never changes. It reads as “cars exist,” never as “traffic.” A jam is the opposite — dense, mostly stopped, lurching forward in fits. Getting that feel taught me two non-obvious lessons.
Pack tight, brake late
The obvious model is car-following: each agent looks at the gap to the one ahead and picks a safe speed from it. The standard safe-following formula is
float safe = Mathf.Sqrt(2f * brake * Mathf.Max(0f, gapAhead - stopDistance));
which is just “how fast can I go and still brake to a stop before I hit them.” Reasonable. But look at what it does when cars are packed bumper-to-bumper: gapAhead ≈ stopDistance, so safe ≈ 0. The default state of a tightly packed line is frozen. Nothing moves unless something ahead of it opens room first, which means motion can only ever trickle backward from whatever agent happens to have open road in front of it.
That’s fine for a line whose leader has somewhere to go. It is fatal for a line whose leader doesn’t — say, oncoming traffic in a world that scrolls past the player, where the “front” of the line is constantly being consumed at the edge before it can pull anyone. One lane flows, the mirror-image lane sits frozen for ten seconds at a time. Same code, opposite behaviour, purely because of which end has runway.
The fix is to stop conflating two different distances:
- Packing distance — how close cars sit when stopped (visual density).
- Braking distance — how close a car gets before it actually brakes.
Make the braking distance much smaller than the packing distance:
// packingGap ≈ 2.5 (how dense the jam looks)
// brakeGap ≈ 0.6 (how close before braking)
float safe = Mathf.Sqrt(2f * brake * Mathf.Max(0f, gapAhead - brakeGap));
Now a car sitting at its normal packed spacing still has packingGap - brakeGap of headroom, so it can creep at full crawl speed. The line stops behaving like a gridlock (“frozen unless pulled”) and starts behaving like a conveyor (“moving, braking only to avoid a bump”). Direction stops mattering. Every lane flows.
This is the kind of bug that’s invisible in the math and obvious the moment you watch it: the model was technically correct and produced a dead lane.
Commit to a distance, not a duration
For stop-and-go, each agent alternates between “go” and “stop” phases. The tempting way to time a “go” phase is a clock: drive for N seconds, then pause. Don’t. In a jam an agent spends most of a wall-clock “go” phase blocked — pressed up behind a stopped car, burning down its timer while going nowhere. By the time the road clears it has almost no “go” left, so it lurches forward a few centimetres and stops again. The whole jam does a nervous little shuffle.
Commit each move to a distance instead, and spend that budget only with actual forward progress:
if (!a.rolling) {
a.stopTimer -= dt;
if (a.stopTimer <= 0f) {
a.rolling = true;
a.moveBudget = Rand(0.8f, 2f) * (2f * a.halfLength); // 0.8–2 body-lengths
}
}
float target = a.rolling ? crawlSpeed : 0f;
float desired = Mathf.Min(safe, target);
a.speed = Mathf.MoveTowards(a.speed, desired, (desired > a.speed ? accel : brake) * dt);
float movedBefore = a.pos;
a.pos += a.speed * dt;
ClampBehindNeighbour(ref a.pos, brakeGap); // never overlap the one ahead
float actuallyMoved = a.pos - movedBefore; // could be ~0 if blocked
if (a.rolling) {
a.moveBudget -= Mathf.Max(0f, actuallyMoved); // blocked time doesn't count
if (a.moveBudget <= 0f) {
a.rolling = false;
a.stopTimer = Rand(0.4f, 1.2f);
}
}
Because the budget only drains when the agent genuinely advances, every move covers its full one-or-two body-lengths no matter how long the agent waited for the gap to open. Blocked cars wait patiently; when the road clears they pull forward a real, visible distance and then brake. The randomised stop durations, staggered across the line, produce the backward-travelling “wave” that reads unmistakably as a jam.
An endless belt from a finite list
You never need more agents than fit on screen. Anchor a window to the camera; spawn agents to fill it; when one falls off the trailing edge, teleport it to the leading edge and re-pack it against the current front of the line. Because agents in a single lane can’t overtake (a follower always brakes before passing), their order never changes — which means recycling is just “pop the tail, push the head,” no sorting, no bookkeeping. A few dozen agents give you an infinite road.
2. Audio that only plays when it should
Give every agent a looping AudioSource and press play, and you get two problems at once: dozens of voices competing for mixer time, and a muddy drone that never stops. Neither is what “a car passing you” sounds like.
The better model treats the clip as a steady bed and does all the shaping in code. Each frame, decide whether this agent should be audible right now — is it actually moving, and is it within earshot of the listener — and drive the volume toward that:
bool audible = a.speed > movingEps
&& (a.t.position - listener.position).sqrMagnitude < earshot * earshot;
float target = audible ? baseVolume : 0f;
src.volume = Mathf.MoveTowards(src.volume, target, baseVolume / fadeTime * dt);
if (src.volume > 0.0005f) { if (!src.isPlaying) src.Play(); }
else if (src.isPlaying) src.Stop();
Three things fall out of this:
-
You only ever pay for audible voices. A source that isn’t near the listener never gets
Play()d, so it costs nothing. In practice, out of ~80 agents only a handful are ever playing at once. 3D spatial blend and distance rolloff still handle the actual attenuation; the earshot check just avoids spinning up voices you’d never hear. -
Motion gates sound. A stationary agent stays silent even if the listener is right next to it. That’s a deliberate design choice — a stopped car doesn’t make a pass-by noise — and it’s a one-line consequence of the
a.speed > movingEpsterm. -
The fade is free and continuous.
MoveTowardsramps the volume up when the agent starts moving and down when it stops, so the sound lasts exactly as long as the motion, with no clicks.
One trap worth calling out: your loop clip must be constant-volume. A clip with a baked fade-in/fade-out envelope (very common for one-shot “whoosh” sounds) will pulse when you loop it, and pulse even worse when several overlap. If you’re repurposing one-shot assets into loops, flatten them to constant RMS and trim the quiet tails first, so the only volume shaping is the one you’re doing in code. Otherwise you’ll spend an afternoon convinced your fade logic is broken when it’s the source material.
3. Physics you don’t pay for until you need it
The system’s agents move every single frame. That detail alone dictates the entire physics approach, because of a mistake that’s very easy to make: a bare collider that moves is a static collider that moves, and moving a static collider forces the physics engine to rebuild its static broadphase tree — every frame, for every collider that moved. With a lot of moving colliders that’s a genuine frame-killer, and it’s worse on single-threaded WebGL builds.
The rule is simple: if a collider moves, it needs a kinematic Rigidbody. That tells the engine “this thing moves, treat it as a moving body,” and the static-tree rebuild goes away.
But there’s a second, bigger optimisation available if collisions only matter sometimes. In my case the agents only ever needed to physically block the player under one rare condition. During normal play they were pure decoration. So the colliders start disabled — zero broadphase cost — and only wake up, near the player, when that condition fires:
// At spawn: a disabled, kinematic collider on a dedicated layer.
var col = go.AddComponent<BoxCollider>();
col.enabled = false; // costs nothing until enabled
var rb = go.AddComponent<Rigidbody>();
rb.isKinematic = true; // moving collider, no static-tree churn
rb.useGravity = false;
go.layer = agentLayer;
// On the rare event, wake only what's near the actor — and keep following it,
// because the actor is moving too.
IEnumerator WakeColliders(Transform actor, float seconds) {
float end = Time.time + seconds;
float r2 = wakeRadius * wakeRadius;
while (Time.time < end) {
Vector3 p = actor.position;
foreach (var a in agents)
if (a.col != null && !a.col.enabled
&& (a.t.position - p).sqrMagnitude <= r2)
a.col.enabled = true;
yield return null;
}
}
So the physics bill is: nothing for the entire normal run, and then a handful of small kinematic boxes for a few seconds around one event. A box collider is the cheapest shape there is, and waking three of them near the player is not something any profiler will ever flag.
Two supporting details make this behave:
- Freeze the movement while the colliders are live. If your agents recycle or teleport (like the endless belt above), an agent the player is leaning against could suddenly warp away mid-contact. Halting agent movement for the duration of the event keeps the collision honest.
- Use a dedicated layer and the collision matrix. Put the agents on their own physics layer and configure the matrix so that layer only interacts with the one actor that matters. This isn’t about performance so much as correctness: it stops the player’s trigger volumes (the ones that detect pickups, gates, whatever) from firing on your ambient props. A physics layer is a filter, and the cheapest collision is the pair the broadphase never even considers.
The through-line
None of these is a clever algorithm. They’re all the same instinct applied three ways: spend effort only where the player can perceive the result, and never a frame before.
- The jam runs from one controller over a flat list, and only cares about the window on screen.
- Audio spins up a voice only for agents that are both moving and within earshot, and lets everything else stay silent for free.
- Physics stays completely inert until a specific moment, then wakes only the few colliders near the player.
Ambient systems are where budgets quietly go to die, because “it’s just background” makes it easy to justify a little waste per object, and then you have four hundred objects. The fix isn’t heroic optimisation — it’s noticing that the player can only look at, listen to, and bump into a tiny slice of the world at any instant, and building each system so that slice is the only part that costs anything.
The post Background agents that feel alive and cost almost nothing appeared first on Richard Fu.

Top comments (0)