Talos docs

Script performance: writing behaviours that stay cheap

Since 0.7Changed in next

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

Recipe: a behaviour on many entities

Enemies, a crowd, pickups, coins, drones: one behaviour placed on dozens or hundreds of entities. Write it this way from the start; the starter templates' pickups and spinners do (crates/editor/templates), and the many-entities recipe (fixtures/recipes/many-entities, 200 drones) is a whole worked project.

  1. One call a tick for all of them: export updateAll(ctx, dt, batch) instead of update. batch.entities[k] and batch.states[k] are instance k's handle and its own ctx.state (docs/API.md, "One call for every instance").
  2. Copy props into state in init. A batch has each instance's state, not its props, so init copies what the batch needs (radius, speed, a home position) into ctx.state.
  3. Read positions in bulk: ctx.world.positions(batch.entities): one Float32Array, 3 numbers each.
  4. Ask the engine for the nearest: ctx.world.nearestMany(points, targets, radius) for every instance at once; a behaviour on one entity uses ctx.world.query({ near, radius, sort: "distance", limit: 1 })[0]. Never read every result's .position to find the nearest yourself.
  5. Tell one entity, not everyone: ctx.events.emitTo(target, name, data); emit reaches every behaviour in the scene, N senders by N listeners.
  6. Number locals in the loop, not vector helpers: each helper returns a new array.

ctx in updateAll is the first instance's, so per-instance work goes through batch.entities[k] and batch.states[k]. The budgets are per call and a throw disables the whole batch: keep the loop linear.

Measured, the collection-course pickup at 200 pickups (no player near, the common case; crates/app/tests/recipes.rs, many_entities_pickup_cost_per_entity_against_update_all, Mars dev build under load, 600 ticks):

Pickup written asscript ms a tick, median / p95
per-entity update, one query({ near, radius }) each (the template before)0.205 / 0.246
updateAll, positions + nearestMany (the template now)0.105 / 0.123

About 1.9 times cheaper with the same outcome; the saving grows with what each instance does (the 200-bot case below is five times).

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.