Talos docs

Script performance: writing behaviours that stay cheap

Since 0.7

Behaviours run on QuickJS, an interpreter.

Plain text

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 ofWriteWhy
query({ near, radius }), then reading .position of every result to find the nearestquery({ 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.positionconst 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 toctx.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 entitiesupdateAll(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 loopconst x = a[0] + (b[0] - a[0]) * t (and y, z) in number localsEach helper returns a new array; locals allocate nothing
reading ctx.entity.position several times in one callread it once into a localEvery 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.

BehaviourIdiomatic (allocating helpers)Number localsNumber locals, no engine callsupdateAll
sway, 24 objects (4 engine calls each)3.82.61.02.0 per object
spring follow, 8 followers4.22.7
kart-like controller, 8 karts (~32 vector operations)9.64.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.jsms a tickengine calls a tick
as checked in: query({ near, radius }), .position of each result10.916,883
the results' positions in one ctx.world.positions(foes)8.12,165
query({ near, radius, sort: "distance", limit: 1 })2.12,166
that, and emitTo(best, …) instead of emit2.1839

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.