SideKick
Book a Consult
Three.js
WebGL
Game Dev
Performance

Simulating 160 Battling Units in the Browser at 60 FPS

August 11, 2026 · 5 min read

By Rex Okonkwo · Lead Game Engineer

Two armies, up to 80 units each, all pathfinding, targeting, firing projectiles, and blowing up in area-of-effect splashes — in a browser tab, at 60 frames per second, on a mid-range laptop. That's WarGaze, and getting 160 autonomous units to fight smoothly in WebGL is a fun performance problem. Here's how the real-time loop actually works.

The frame budget is the whole game

At 60 FPS you get 16.6 milliseconds per frame to do everything: update every unit's AI, move it, resolve combat, advance projectiles, run effects, and render. Blow the budget and the frame drops. So every decision in the loop is really a question of "can this run 160+ times per frame and stay under budget?"

The answer is almost never "make the code fancier." It's "do less work, and reuse everything."

Reuse vectors, never allocate in the loop

The number one killer of a JavaScript game loop is the garbage collector. Allocate a fresh vector for every unit every frame and you generate thousands of throwaway objects per second — the GC kicks in, and you get a stutter exactly when the action peaks. So we allocate scratch vectors once and reuse them:

const _v = new THREE.Vector3();   // module-scope scratch, allocated once
const _v2 = new THREE.Vector3();

function seek(unit, target) {
  _v.copy(target.position).sub(unit.position);  // reuse _v, no new object
  _v.y = 0;
  const dist = _v.length();
  // ...steer using _v
}

The rule across the whole engine: no new inside the per-frame path. Vectors, temp math, everything hot is pre-allocated and copied into. It's not elegant, but a smooth 60 FPS isn't about elegant — it's about not making garbage.

Nearest-enemy is the expensive part — so throttle it

Every unit needs a target, and "find the nearest enemy" is an O(n) scan. Do that for all 160 units every frame and you're at 25,000+ distance checks per frame just for targeting. That's the budget gone.

Two moves fix it:

  • Don't retarget every frame. A unit keeps its target until it dies or drifts out of range, then re-scans. We stagger re-scans with a small random timer per unit so they don't all recompute on the same frame — spreading the cost across frames instead of spiking it.
  • Compare squared distances. Math.sqrt is expensive and you don't need the actual distance to know which enemy is closest — comparing squared lengths gives the same answer without the square root. Tiny change, runs tens of thousands of times, adds up.

The pattern generalizes: the way you afford an expensive per-unit operation is to not run it every frame for every unit. Amortize it.

Projectiles and effects are pooled

An archer volley or a mage fireball spawns projectiles constantly. Same GC trap as vectors — so projectiles and hit effects come from a pool. When a projectile lands, it doesn't get destroyed; it goes back in the pool to be reused by the next shot. The scene's object count stays roughly flat even during a chaotic melee, which keeps both the GC and the renderer happy.

Anti-stalemate: the sudden-death frenzy

A real-time battle sim has a design bug hiding in it: two balanced armies can grind to a near-stalemate where the last few units chip at each other forever. Nobody wants to watch that. So the combat has a frenzy mechanic — after about 20 seconds without resolution, a frenzy value ramps from 0 to 1 and multiplies all damage:

frenzy = Math.min(Math.max((killClock - 20) / 15, 0), 1);
// applied at damage time:
victim.hp -= dmg * (1 + frenzy * 0.5);

Damage scales up the longer a fight drags, so a stalemate becomes mathematically impossible — battles always resolve, and they resolve more dramatically the longer they run. It's a gameplay fix and a performance fix at once: fights end, units clear, the unit count comes back down.

What to copy (for any real-time browser sim)

  1. The frame budget is 16.6ms. Every hot-path decision serves it. Do less, reuse more.
  2. Never allocate in the per-frame loop. Pre-allocate scratch vectors and objects; the GC is your enemy.
  3. Throttle O(n) work like targeting — cache the result, re-scan on a staggered timer, compare squared distances instead of calling sqrt.
  4. Pool projectiles and effects so the scene object count stays flat during chaos.
  5. Design out the degenerate case — a ramping frenzy multiplier kills stalemates and keeps the unit count bounded.

None of this needs a game engine or WASM. It's plain Three.js and disciplined JavaScript. The trick to hundreds of units at 60 FPS isn't raw power — it's refusing to waste a single millisecond or allocate a single object you didn't have to.

FAQ

How many objects can Three.js animate at once? More than people expect if you avoid the two classic traps: per-frame allocation (GC stutter) and unbatched O(n) work. WarGaze runs ~160 autonomous units plus projectiles and effects at 60 FPS on mid-range hardware using pooling, scratch vectors, and throttled targeting — no WASM, no custom engine.

Why does allocating objects in a game loop cause frame drops? Every new in the hot path creates garbage the JS engine must later collect. During heavy action you generate thousands of throwaway objects per second, and the garbage collector pauses execution to clean up — right when you can least afford a pause. Pre-allocating and reusing objects avoids the pause entirely.

How do you keep a battle simulation from stalemating? WarGaze ramps a global damage multiplier ("frenzy") the longer a fight runs unresolved. After ~20 seconds, damage scales up over the next 15, so two balanced armies can't grind forever — the math forces a resolution, which also keeps the on-screen unit count bounded for performance.


Building a browser game, an agent simulation, or any real-time WebGL experience? Squeezing hundreds of moving parts into a frame budget is exactly our kind of problem.


Rex Okonkwo

Rex Okonkwo

LEAD GAME ENGINEER

Rex builds SideKick's games and simulations. Writes about game AI, real-time systems, and making machines feel like they have a mind.

Building something like this?

We design and ship AI agents and LLM integrations for real products. Tell us what you're working on.

Book a Consult