Script performance: writing behaviours that stay cheap
Behaviours run on QuickJS, an interpreter.
Behaviours run on QuickJS, an interpreter. A call into the engine (ctx.entity.position, a ctx.world.query result's .position, ctx.events.emit) costs far more than the arithmetic around it, and every [x, y, z] a helper returns is an allocation. A behaviour that does a little work for each of many things can cost milliseconds a frame without doing anything expensive. This page is what to write instead. Every pattern here uses the API as it is; none changes what a game does.
In one line: in a hot update, ask the engine for the answer (query({ near, sort: "distance", limit: 1 }), ctx.world.positions(list), ctx.events.emitTo) rather than reading every handle, and keep vector maths in number locals.
Find what costs first
GET /profile/frames (crates/query/API.md) names the biggest behaviour call of each frame (top_behaviour) and each frame's script_ms; GET /profile/trace opens the frames in Perfetto, one span per behaviour call. Measure before rewriting: a behaviour that runs once a second is not worth touching.
The patterns, biggest saving first
| Instead of | Write | Why |
|---|---|---|
query({ near, radius }), then reading .position of every result to find the nearest | query({ near, radius, sort: "distance", limit: 1 })[0] | The engine sorts; the script reads one handle, not every one. Each result handle and each .position read is an engine call |
for (const f of foes) f.position | const ps = ctx.world.positions(foes) (one Float32Array, 3 numbers each) | One call for the whole list |
ctx.events.emit("hit", { to }) with every receiver checking to | ctx.events.emitTo(target, "hit", data) | emit reaches every behaviour: N senders and N receivers is N × N handler calls |
one update per instance of a behaviour placed on hundreds of entities | updateAll(ctx, dt, batch) (docs/API.md, "One call for every instance") | One host call a tick instead of one per instance |
add(scale(sub(b, a), t), a) in a per-frame loop | const x = a[0] + (b[0] - a[0]) * t (and y, z) in number locals | Each helper returns a new array; locals allocate nothing |
reading ctx.entity.position several times in one call | read it once into a local | Every read is an engine call and a new array |
What it costs (measured)
Release build, one CPU core of the Triton workstation (wave117), crates/app/tests/script_cost.rs. Microseconds per behaviour call; lower is better. These are this machine's numbers, for comparing patterns, not a budget.
| Behaviour | Idiomatic (allocating helpers) | Number locals | Number locals, no engine calls | updateAll |
|---|---|---|---|---|
| sway, 24 objects (4 engine calls each) | 3.8 | 2.6 | 1.0 | 2.0 per object |
| spring follow, 8 followers | 4.2 | 2.7 | ||
| kart-like controller, 8 karts (~32 vector operations) | 9.6 | 4.9 |
One engine call costs about 0.4-0.7 µs; one allocating vector operation about 0.15 µs.
A worked case: 200 bots, each finding its nearest enemy
fixtures/netcode/arena-200's combat.js queries the enemies within 30 m of each bot every tick and reads every result's position to find the nearest (about 75 results a query). The same game, rewritten one pattern at a time (median of three runs; ms of combat.update a tick for all 200 bots):
combat.js | ms a tick | engine calls a tick |
|---|---|---|
as checked in: query({ near, radius }), .position of each result | 10.9 | 16,883 |
the results' positions in one ctx.world.positions(foes) | 8.1 | 2,165 |
query({ near, radius, sort: "distance", limit: 1 }) | 2.1 | 2,166 |
that, and emitTo(best, …) instead of emit | 2.1 | 839 |
Asking the engine for the nearest one is five times cheaper than finding it in the script: each result of a query is a handle the engine builds for the script, so a query that answers 75 handles costs 75 builds even when the script only wants one.