How do you path 500 units without dropping frames?

Stop running A* once per unit. On a 512x512 grid with 500 units heading to one goal, per-unit A* took 904 ms and a flow field took 5.17 ms — 175x — and the flow field was already ahead at six units. Measured across four map types, five heuristics, three open-set structures and the no-path worst case

How do you path 500 units without dropping frames?

You stop pathing them individually: with 500 units heading to one goal on a 512x512 grid, running A per unit took 904 ms while one flow field took 5.17 ms — 175x faster.* The number that actually changes how you write the code is smaller and stranger: the flow field was already the cheaper option at six units. Not five hundred. Six.

Hardware: Apple M3, 8 cores, 16 GB, macOS 26.4.1, Node v23.5.0. Plain JavaScript, typed arrays, one thread, no dependencies, no engine. This is not a C++ engine and the absolute milliseconds are not the point — a Burst-compiled Unity job beats every figure here. What transfers is the relative scaling: where the crossovers sit, and how many nodes each algorithm expands. Node counts are exact and reproduce from the seed; times are medians of three to five runs under normal desktop load.

The maps, generated deterministically from mulberry32 seeds: open field (128x128 and 512x512, no walls); maze (513x513 recursive-backtracker, one-cell corridors, 131,071 open cells, one route between any two); choke point (512x512 split by a solid wall with one 1-cell gap, 261,633 open cells); and scatter (512x512, 30% randomly blocked, unreachable cells turned to wall, 178,326 open) — closest to a real tower-defense map. Movement is 4-connected with unit step cost unless stated.

The short answer

  • A flow field beat per-unit A from six units upward.* On a 512x512 open field: 1 unit — A* 0.96 ms, flow field 4.26 ms. 500 units — A* 904.31 ms, flow field 5.17 ms. It does one pass over the grid however many units read it.
  • A one-line tie-breaker on the heuristic was worth 52.7x. Multiplying Manhattan by 1 + 1/1000 cut 100 searches on an open 512x512 map from 2,480,898 expanded nodes to 47,046, and 163.68 ms to 3.93 ms, with the paths exactly the same length — 47,046 either way.
  • An inadmissible weighted heuristic buys speed with path quality, and the exchange rate is bad above 1.5x. On scatter: weight 1.5 expanded 8.3x fewer nodes for paths 9.28% longer; weight 5 expanded 15.2x fewer for 28.03% longer.
  • A goal with no path is the real disaster. One A* at a walled-in goal expanded all 262,135 reachable cells; 500 units took 8,532 ms. One flood fill answering all 500 took 0.17 ms — about 50,000x.
  • JPS expanded 199 nodes where A expanded 1.9 million, and was still 100x slower in wall-clock.* The work moved out of the open list and into the scan, which is not what the node counts advertise.
One shared flow field beats per-unit A* from only six units onward

When does a flow field beat A*?

Three candidates, same goal, same map. A per unit*: one search each, Manhattan heuristic, binary heap. Dijkstra from the goal: one pass over the reachable area storing a next pointer per cell, which every unit walks. Flow field: the same wavefront built with a plain BFS queue (uniform step costs, so no priority queue needed) plus one direction byte per cell, which every unit reads under its feet.

Open field 512x512, all units heading for (510,510), total ms for the batch:

Units A* per unit Dijkstra from goal Flow field
1 0.96 ms 12.68 ms 4.26 ms
10 9.67 ms 13.13 ms 4.17 ms
100 146.48 ms 13.65 ms 4.33 ms
500 904.31 ms 14.87 ms 5.17 ms
1000 1756.35 ms 14.50 ms 5.91 ms

A* per unit is linear in unit count and the constant is brutal: 1.76 seconds for 1000 units, on a map where every unit wants essentially the same path. The flow field grows 1.65 ms across the entire range, because the only per-unit work is following pointers. Sweeping N one at a time to find where the lines cross:

Map Crossover
Open 128x128 7 units
Open 512x512 6 units
Maze 513x513 3 units
Choke 512x512 5 units

Three to seven units. Flow fields are filed in most people's heads under "optimisation for when you have hundreds of units", and on these maps that is off by two orders of magnitude. If more than half a dozen things are walking to the same place, the shared field is already the cheaper code, and the shorter code. The caveat that makes it a decision rather than a rule: it holds when the units share a goal. A field is per-destination — ten squads with ten targets need ten fields, and the arithmetic starts again.

Does that hold on every map?

Not in the way you would guess. Total milliseconds for 500 units:

Map A* per unit Dijkstra from goal Flow field
Open 128x128 46.97 ms 0.86 ms 0.61 ms
Open 512x512 903.31 ms 12.70 ms 5.18 ms
Maze 513x513 618.64 ms 17.10 ms 31.16 ms
Choke 512x512 1129.61 ms 14.22 ms 5.32 ms

On the maze the flow field lost to the Dijkstra tree, 31.16 ms against 17.10 ms, contradicting what I expected when I wrote the harness. The BFS build is the faster build; it is the following that costs. Average maze path here is about 9,880 cells, so 500 units walking step by step is five million pointer hops, and the flow field does more work per hop (read direction, decode to an offset, add) than the tree (read the next index). Short paths, build dominates and the field wins; enormous paths, the follow decides. Both still beat per-unit A* by 20-36x.

Per-unit A* looks worst on the choke map — 1,129 ms and 40.4 million nodes for 500 units — because every search rediscovers the same single gap independently. That is the shape of a tower-defense map.

Which heuristic should you use?

Manhattan, nothing clever, unless you have measured that you can afford worse paths. Scatter map, 100 units, against a Dijkstra ground truth so path quality is exact rather than assumed:

Heuristic Time Nodes expanded Path cost vs optimal
Dijkstra (h=0) 1066.04 ms 13,205,607 52,855 +0.00%
Manhattan 125.55 ms 1,719,001 52,855 +0.00%
Euclidean 534.70 ms 5,404,991 52,855 +0.00%
Chebyshev 538.54 ms 6,331,820 52,855 +0.00%
Manhattan x1.25 43.34 ms 391,204 55,531 +5.06%
Manhattan x1.5 22.71 ms 207,117 57,759 +9.28%
Manhattan x2 16.16 ms 145,179 60,881 +15.18%
Manhattan x5 12.13 ms 112,789 67,669 +28.03%

Euclidean distance on a 4-connected grid is a 4.3x mistake. It stays admissible, so paths are optimal, but it systematically underestimates — you cannot move diagonally, so straight-line distance is always short of the truth — and A* pays for a weak heuristic in expanded nodes. The heuristic must match the movement rules: 4-way wants Manhattan, 8-way wants octile.

The weighted rows are the trade nobody puts numbers on. Weight 1.5 is genuinely attractive: 8.3x fewer nodes, 5.5x faster, units walking 9.3% further, which in a game where they already steer around each other is usually invisible. Weight 5 is not — another 1.8x of speed for 28% longer routes, which reads on screen as obviously silly detours.

Worth stating plainly because it surprised me: on the open, maze and choke maps, weighting up to 5x cost exactly zero path quality — +0.00% on all three. On a uniform 4-connected grid every monotone staircase between two points is the same length, so a greedy heuristic has nothing to get wrong, and in a one-cell-wide maze there is only one route. The penalty appears only where the map offers choices. Benchmark weighted A* on an empty grid and you will conclude it is free. It is not.

What does a one-line tie-breaker actually do?

A heuristic tie-breaker cut nodes explored by 103x with no change in path cost

More than anything else here. On an open grid huge numbers of cells share the same f, and a plain heap breaks those ties arbitrarily, fanning the search across the whole diamond between start and goal. Nudging the heuristic by a fraction of a percent makes the tie-break prefer cells nearer the goal. Open 512x512, 100 units, f = g + h * (1 + p):

p Time Nodes expanded Path cost
0 163.68 ms 2,480,898 47,046
0.000001 3.97 ms 47,046 47,046
0.0001 3.77 ms 47,046 47,046
0.001 3.93 ms 47,046 47,046
0.01 4.19 ms 47,046 47,046
0.1 4.20 ms 47,046 47,046

52.7x fewer nodes, 41x faster, identical path cost, one multiplication. At p = 0.000001 the search expands exactly 47,046 nodes for 100 units — 470 each, which is the path length. It walks straight there.

The honest asterisk: on the cluttered scatter map the same tie-breaker expanded 10% fewer nodes but ran slower (167.12 ms against 125.55 ms), because the float multiply per node costs more than it saves when obstacles are already breaking the ties. Tie-breaking is an open-terrain optimisation — ship it anyway, since open terrain is what spikes your frame time.

What happens when there is no path?

The failure mode that arrives as "the game freezes when I wall off the exit". I sealed a goal inside a one-cell wall ring on an open 512x512 map and ran A* at it.

Units Naive A* Nodes expanded Flood-fill check
1 18.57 ms 262,135
10 184.03 ms 2,621,350
100 1737.81 ms 26,213,500
500 8532.18 ms 131,067,500 0.17 ms

A single failed search expanded 262,135 nodes — every reachable open cell on the map — because A* cannot know it is beaten until the open list empties, and every unit repeats it. 500 units is 8.5 seconds of freeze, or 511 dropped frames at 60 Hz.

The fix is not a smarter search. One flood fill from the goal labels which cells can reach it; the per-unit check is then a single array read — 0.17 ms for all 500, about 50,000x less work. Where the path does exist that flood fill costs 4.45 ms, which you were going to pay anyway: it is the flow field.

Do diagonals and JPS pay for themselves?

Diagonals yes, JPS no — and the reason is instructive. 100 units, corner cutting allowed in both A* and JPS so the movement rules match; the two agree on total travel distance to the decimal, which is how I know the comparison is fair.

Map Variant Time Nodes expanded Total travel distance
Open 512 A* 4-way + tiebreak 3.86 ms 47,046 47,046.0
Open 512 A* 8-way octile 292.98 ms 1,891,568 37,994.4
Open 512 A* 8-way octile + tiebreak 4.69 ms 31,594 37,994.4
Open 512 JPS 8-way 482.87 ms 199 37,994.4
Scatter 512 A* 4-way + tiebreak 146.44 ms 1,327,932 52,755.0
Scatter 512 A* 8-way octile + tiebreak 176.94 ms 966,339 41,554.8
Scatter 512 JPS 8-way 187.00 ms 633,536 41,554.8

Diagonals shortened total travel by 19.2% on the open map and 21.2% on scatter, for 21% more time once the tie-breaker is in place. Straightforward trade for anything meant to look like it is walking.

JPS expanded 199 nodes across 100 searches — two per unit — and took 482.87 ms where tie-broken A took 4.69 ms.* Both found the identical route. JPS does not remove the work, it moves it into a recursive scan that walks the grid cell by cell hunting jump points, and on an empty map each diagonal step scans a whole row and column. Node counts are a terrible proxy for runtime. JPS earns its reputation with precomputed jump distances (JPS+) — a different algorithm with a build step, and if you will precompute per map, a flow field is simpler and already beat everything here.

Which open-set structure is worth writing?

100 units, tie-break on for the heap and sorted array so they expand identical node counts, three warm-up rounds before timing:

Map Binary heap Sorted array Bucket queue
Open 128x128 0.77 ms 1.85 ms 0.67 ms
Open 512x512 3.53 ms 7.96 ms 2.68 ms
Scatter 512x512 142.56 ms 169.61 ms 64.12 ms

The sorted array is only 1.2-2.4x behind the heap, less than its reputation suggests: splice on a small array is fast, and the open list stays small once the heuristic works. The bucket queue is the interesting one — step costs are integers, so f is an integer and priorities bucket with O(1) push and pop. On scatter it ran 2.2x faster than the binary heap while expanding 4.8% more nodes (1,392,279 against 1,327,932): more searching, more cheaply. For uniform or small-integer costs, write this one.

What does smoothing the path cost?

Raw grid paths look wrong; units walk in stair-steps. String-pulling — skipping ahead to the furthest waypoint with clear Bresenham line of sight — fixed 100 scatter-map paths in 21.74 ms, turning 52,855 waypoints into 6,123, 8.6x fewer. That is 0.22 ms per path, comparable to the search itself, and it applies to flow-field paths too. Budget for it: it is the difference between "pathfinding works" and "pathfinding looks right".

Check it yourself

One file, no dependencies, Node 18 or newer. It reproduces the headline table, finds the crossover, measures heuristics against a Dijkstra ground truth, and runs the no-path case.

node pathbench.mjs      # 14.7 s on the M3 above
// pathbench.mjs — node 23, no dependencies.  node pathbench.mjs
const W = 512, H = 512, SIZE = W * H;
const rnd32 = (a) => () => { a |= 0; a = (a + 0x6D2B79F5) | 0; let t = Math.imul(a ^ (a >>> 15), 1 | a); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; };
const med = (a) => [...a].sort((x, y) => x - y)[a.length >> 1];
const DX = [1, -1, 0, 0], DY = [0, 0, 1, -1];

function openMap() { return new Uint8Array(SIZE); }
function scatterMap(p, seed) {                       // random obstacles, goal-reachable only
  const w = new Uint8Array(SIZE), r = rnd32(seed);
  for (let i = 0; i < SIZE; i++) if (r() < p) w[i] = 1;
  for (let x = 0; x < W; x++) { w[x] = 1; w[(H - 1) * W + x] = 1; }
  for (let y = 0; y < H; y++) { w[y * W] = 1; w[y * W + W - 1] = 1; }
  w[(H - 3) * W + (W - 3)] = 0;
  const ff = flow(w, W - 3, H - 3);
  for (let i = 0; i < SIZE; i++) if (ff.cost[i] === 0x7fffffff) w[i] = 1;
  return w;
}
function sealedMap(gx, gy) {                          // goal walled in: no path exists
  const w = new Uint8Array(SIZE);
  for (let dy = -1; dy <= 1; dy++) for (let dx = -1; dx <= 1; dx++)
    if (dx || dy) w[(gy + dy) * W + gx + dx] = 1;
  return w;
}

class Heap {
  constructor() { this.n = new Int32Array(1024); this.f = new Float64Array(1024); this.size = 0; }
  clear() { this.size = 0; }
  push(v, f) {
    if (this.size === this.n.length) { const a = new Int32Array(this.size * 2); a.set(this.n); this.n = a; const b = new Float64Array(this.size * 2); b.set(this.f); this.f = b; }
    let i = this.size++; this.n[i] = v; this.f[i] = f;
    while (i) { const p = (i - 1) >> 1; if (this.f[p] <= this.f[i]) break;
      const tn = this.n[p], tf = this.f[p]; this.n[p] = this.n[i]; this.f[p] = this.f[i]; this.n[i] = tn; this.f[i] = tf; i = p; }
  }
  pop() {
    const top = this.n[0], last = --this.size; this.n[0] = this.n[last]; this.f[0] = this.f[last];
    for (let i = 0; ;) { const l = 2 * i + 1, r = l + 1; let s = i;
      if (l < this.size && this.f[l] < this.f[s]) s = l;
      if (r < this.size && this.f[r] < this.f[s]) s = r;
      if (s === i) return top;
      const tn = this.n[s], tf = this.f[s]; this.n[s] = this.n[i]; this.f[s] = this.f[i]; this.n[i] = tn; this.f[i] = tf; i = s; }
  }
}
const g = new Float64Array(SIZE), stamp = new Int32Array(SIZE), closed = new Int32Array(SIZE), parent = new Int32Array(SIZE);
const heap = new Heap(); let RUN = 0;

function astar(walls, sx, sy, gx, gy, weight = 1, tie = 0) {
  const run = ++RUN; heap.clear();
  const s = sy * W + sx, goal = gy * W + gx;
  g[s] = 0; stamp[s] = run; parent[s] = -1;
  heap.push(s, (Math.abs(sx - gx) + Math.abs(sy - gy)) * weight * (1 + tie));
  let expanded = 0;
  while (heap.size) {
    const cur = heap.pop();
    if (closed[cur] === run) continue;
    closed[cur] = run;
    if (cur === goal) return { cost: g[goal], expanded, found: true };
    expanded++;
    const cx = cur % W, cy = (cur / W) | 0, gc = g[cur];
    for (let i = 0; i < 4; i++) {
      const nx = cx + DX[i], ny = cy + DY[i];
      if (nx < 0 || ny < 0 || nx >= W || ny >= H) continue;
      const ni = ny * W + nx;
      if (walls[ni]) continue;
      const ng = gc + 1;
      if (stamp[ni] === run && g[ni] <= ng) continue;
      stamp[ni] = run; g[ni] = ng; parent[ni] = cur;
      heap.push(ni, ng + (Math.abs(nx - gx) + Math.abs(ny - gy)) * weight * (1 + tie));
    }
  }
  return { cost: Infinity, expanded, found: false };
}

function dijkstra(walls, gx, gy) {                    // shortest-path tree from the goal
  const cost = new Float64Array(SIZE).fill(Infinity), next = new Int32Array(SIZE).fill(-1);
  const done = new Uint8Array(SIZE), hp = new Heap(), goal = gy * W + gx;
  cost[goal] = 0; hp.push(goal, 0);
  while (hp.size) {
    const cur = hp.pop(); if (done[cur]) continue; done[cur] = 1;
    const cx = cur % W, cy = (cur / W) | 0;
    for (let i = 0; i < 4; i++) {
      const nx = cx + DX[i], ny = cy + DY[i];
      if (nx < 0 || ny < 0 || nx >= W || ny >= H) continue;
      const ni = ny * W + nx; if (walls[ni] || done[ni]) continue;
      const ng = cost[cur] + 1;
      if (ng < cost[ni]) { cost[ni] = ng; next[ni] = cur; hp.push(ni, ng); }
    }
  }
  return { cost, next, bytes: cost.byteLength + next.byteLength + done.byteLength };
}

function flow(walls, gx, gy) {                        // integration field + direction byte
  const cost = new Int32Array(SIZE).fill(0x7fffffff), dir = new Int8Array(SIZE).fill(-1);
  const q = new Int32Array(SIZE); let head = 0, tail = 0;
  const goal = gy * W + gx; cost[goal] = 0; q[tail++] = goal;
  while (head < tail) {
    const cur = q[head++], cx = cur % W, cy = (cur / W) | 0, nc = cost[cur] + 1;
    for (let i = 0; i < 4; i++) {
      const nx = cx + DX[i], ny = cy + DY[i];
      if (nx < 0 || ny < 0 || nx >= W || ny >= H) continue;
      const ni = ny * W + nx;
      if (walls[ni] || cost[ni] !== 0x7fffffff) continue;
      cost[ni] = nc; dir[ni] = i ^ 1; q[tail++] = ni;
    }
  }
  return { cost, dir, bytes: cost.byteLength + dir.byteLength };
}
const follow = (ff, sx, sy) => { let i = sy * W + sx, n = 0; while (ff.cost[i]) { const d = ff.dir[i]; if (d < 0) return -1; i += DY[d] * W + DX[d]; n++; } return n; };
const walkTree = (t, sx, sy) => { let i = sy * W + sx, n = 0; while (t.cost[i]) { i = t.next[i]; if (i < 0) return -1; n++; } return n; };

function starts(walls, n, seed, goal) {
  const cells = []; for (let i = 0; i < SIZE; i++) if (!walls[i]) cells.push(i);
  const r = rnd32(seed), out = [];
  while (out.length < n) { const c = cells[(r() * cells.length) | 0]; if (c !== goal) out.push([c % W, (c / W) | 0]); }
  return out;
}

// ---------------------------------------------------------------- 1. scaling
const open = openMap(), GX = W - 2, GY = H - 2, all = starts(open, 1000, 4242, GY * W + GX);
console.log(`open ${W}x${H}, all units heading to (${GX},${GY})\n`);
console.log('    N    A* per unit   Dijkstra tree   flow field');
for (const N of [1, 10, 100, 500, 1000]) {
  const st = all.slice(0, N), ta = [], td = [], tf = [];
  for (let r = 0; r < 3; r++) {
    let s = performance.now(); for (const [x, y] of st) astar(open, x, y, GX, GY); ta.push(performance.now() - s);
    s = performance.now(); const t = dijkstra(open, GX, GY); for (const [x, y] of st) walkTree(t, x, y); td.push(performance.now() - s);
    s = performance.now(); const f = flow(open, GX, GY); for (const [x, y] of st) follow(f, x, y); tf.push(performance.now() - s);
  }
  console.log(`${String(N).padStart(5)}  ${med(ta).toFixed(2).padStart(9)} ms  ${med(td).toFixed(2).padStart(9)} ms  ${med(tf).toFixed(2).padStart(9)} ms`);
}

// ---------------------------------------------------------------- 2. crossover
let cross = 0;
for (let N = 1; N <= 32; N++) {
  const st = all.slice(0, N), ta = [], tf = [];
  for (let r = 0; r < 5; r++) {
    let s = performance.now(); for (const [x, y] of st) astar(open, x, y, GX, GY); ta.push(performance.now() - s);
    s = performance.now(); const f = flow(open, GX, GY); for (const [x, y] of st) follow(f, x, y); tf.push(performance.now() - s);
  }
  if (med(tf) < med(ta)) { cross = N; break; }
}
console.log(`\ncrossover: flow field wins from N = ${cross} units`);

// ---------------------------------------------------------------- 3. heuristic
const scat = scatterMap(0.30, 7), SGX = W - 3, SGY = H - 3;
const st100 = starts(scat, 100, 4242, SGY * W + SGX);
const truth = dijkstra(scat, SGX, SGY);
let optimal = 0; for (const [x, y] of st100) optimal += truth.cost[y * W + x];
console.log(`\nscatter map, 30% blocked, 100 units. optimal total path cost = ${optimal}`);
console.log('  heuristic              ms    expanded   path cost   vs optimal');
for (const [name, wt, tie] of [['Manhattan', 1, 0], ['Manhattan +tiebreak', 1, 1e-3], ['Manhattan x1.5', 1.5, 0], ['Manhattan x2', 2, 0], ['Manhattan x5', 5, 0]]) {
  const ts = []; let e = 0, c = 0;
  for (let r = 0; r < 3; r++) { const s = performance.now(); e = 0; c = 0; for (const [x, y] of st100) { const q = astar(scat, x, y, SGX, SGY, wt, tie); e += q.expanded; c += q.cost; } ts.push(performance.now() - s); }
  console.log(`  ${name.padEnd(20)} ${med(ts).toFixed(1).padStart(6)} ${String(e).padStart(10)} ${String(c).padStart(11)}   +${((c / optimal - 1) * 100).toFixed(2)}%`);
}

// ---------------------------------------------------------------- 4. no path
const sealed = sealedMap(400, 400), st500 = starts(sealed, 500, 4242, 400 * W + 400);
let ts = []; let exp = 0;
for (let r = 0; r < 3; r++) { const s = performance.now(); exp = 0; for (const [x, y] of st500.slice(0, 100)) exp += astar(sealed, x, y, 400, 400).expanded; ts.push(performance.now() - s); }
console.log(`\nunreachable goal, 100 units: naive A* ${med(ts).toFixed(1)} ms, ${exp} nodes expanded`);
ts = [];
for (let r = 0; r < 5; r++) { const s = performance.now(); const f = flow(sealed, 400, 400); let bad = 0; for (const [x, y] of st500) if (f.cost[y * W + x] === 0x7fffffff) bad++; ts.push(performance.now() - s); }
console.log(`unreachable goal, 500 units: one flood fill + 500 lookups ${med(ts).toFixed(2)} ms`);

// ---------------------------------------------------------------- 5. memory
const f = flow(open, GX, GY), d = dijkstra(open, GX, GY);
console.log(`\nresident bytes at ${W}x${H}: flow field ${f.bytes / 1024} KiB, dijkstra tree ${d.bytes / 1024} KiB, A* scratch ${(g.byteLength + stamp.byteLength + closed.byteLength + parent.byteLength) / 1024} KiB`);

On the M3 above that prints:

open 512x512, all units heading to (510,510)

    N    A* per unit   Dijkstra tree   flow field
    1       0.96 ms      12.68 ms       4.26 ms
   10       9.67 ms      13.13 ms       4.17 ms
  100     146.48 ms      13.65 ms       4.33 ms
  500     904.31 ms      14.87 ms       5.17 ms
 1000    1756.35 ms      14.50 ms       5.91 ms

crossover: flow field wins from N = 6 units

scatter map, 30% blocked, 100 units. optimal total path cost = 52855
  heuristic              ms    expanded   path cost   vs optimal
  Manhattan             116.3    1719001       52855   +0.00%
  Manhattan +tiebreak   156.1    1539786       52855   +0.00%
  Manhattan x1.5         19.9     207117       57759   +9.28%
  Manhattan x2           13.8     145179       60881   +15.18%
  Manhattan x5           10.4     112789       67669   +28.03%

unreachable goal, 100 units: naive A* 1577.0 ms, 26213500 nodes expanded
unreachable goal, 500 units: one flood fill + 500 lookups 0.14 ms

resident bytes at 512x512: flow field 1280 KiB, dijkstra tree 3328 KiB, A* scratch 5120 KiB

The last line is worth a glance. The flow field is the smallest of the three — one Int32Array of costs plus one Int8Array of directions, 1,280 KiB at 512x512, confirmed against process.memoryUsage() at 1,280.7 KiB. A*'s scratch is 5,120 KiB, the same 5 MB for one unit or a thousand because it is reused. The fastest option here also holds the least memory, which is not how these trades usually go.

Change W, H and the obstacle density at the top and re-run. Two rows decide your frame budget: the crossover line, and the +tiebreak row.


Elsewhere in the game loop: what a sprite atlas actually saves you, where shelf packing matched MaxRects at 1/1500th of the CPU, and what a collision broadphase costs — the other per-unit loop that decides your frame time. For the animation side, CST Animator handles sprite timelines and export.