Skip to content

Batch Script Systems

A batch script system processes all entities matching a component query in a single VM call. Instead of calling update(entity, dt) once per entity, the engine gives the script typed-array views of entire component columns and the script runs a plain inner loop. This is Tier 3 of jEngine’s three-tier scripting model.

Use batch scripts forUse entity scripts for
Movement, steering, custom physicsComplex per-entity state machines
Crowd simulation, flockingUnique objects (bosses, doors)
Particle/projectile integrationGUI logic, cutscenes
Any system that runs on 1 000+ entitiesLogic that touches < ~100 entities

Performance reference (30k entities, 5 read / 3 write workload, measured by the perf harness):

BackendTime per frame
Lua table (recommended)~1.1 ms
JavaScript~8.4 ms
Lua buffer / userdata14–20 ms

A batch system replaces ~21× slower per-entity scripts at 20k entities (91 ms → 4 ms).

The native runtime currently loads Lua batch scripts in buffer mode, not table mode.

Add a batch_script component to any entity that should be processed by the system. All entities sharing the same path are batched together in one call.

{
"name": "Enemy",
"components": {
"transform": { "position": [0, 0], "rotation": 0, "scale": [1, 1] },
"velocity": { "x": 0, "y": 0 },
"batch_script": { "path": "scripts/systems/enemy_mover.lua", "lang": "lua" }
}
}
FieldTypeDefaultDescription
pathStringrequiredRelative path to the script, same as ScriptComponent.
lang"js" | "lua""js"Scripting language.

Declare the column contract in a system table (Lua) or const system (JS) at the top of the script:

-- Lua
system = {
query = {
read = { "Velocity2D" }, -- snapshot reads
write = { "Position2D" }, -- mutated in place
read_optional = {}, -- present on some archetypes (optional)
},
state = {
-- per-entity persistent storage (field: default_value)
-- e.g. acc = 0.0
},
tick_rate = 1, -- run every frame (default)
}
// JavaScript
const system = {
query: {
read: ["Velocity2D"],
write: ["Position2D"],
read_optional: [],
},
state: {},
tick_rate: 1,
};

The full list of components that can appear in read / write / read_optional:

Position2D Velocity2D Acceleration2D Drag2D Force2D Wind2D Impulse2D Torque2D

The combined column count across read + write + read_optional must not exceed 12 (MAX_BATCH_COLUMNS). Exceeding this limit is a load-time error.

The batch entry point receives a context object and a query object:

// JavaScript
function update(ctx, q) {
const dt = ctx.dt; // frame delta time (Float)
const n = q.count; // entity count in this batch
const vx = q.read.Velocity2D.x; // Float32Array, length n
const vy = q.read.Velocity2D.y;
const px = q.write.Position2D.x; // Float32Array, length n (mutate in place)
const py = q.write.Position2D.y;
for (let i = 0; i < n; i++) {
px[i] += vx[i] * dt;
py[i] += vy[i] * dt;
}
}
-- Lua (Table mode, 1-indexed)
function update(ctx, q)
local dt = ctx.dt
local n = q.count
local vx = q.read.Velocity2D.x
local vy = q.read.Velocity2D.y
local px = q.write.Position2D.x
local py = q.write.Position2D.y
for i = 1, n do
px[i] = px[i] + vx[i] * dt
py[i] = py[i] + vy[i] * dt
end
end
  • i is chunk-local — not an entity ID. When the worker pool is active, each worker receives a contiguous chunk starting at 0.
  • Writes to q.write.* are copied back to the ECS after update returns.
  • q.read.* is snapshot-only — writing to read columns has no effect.
  • Dirty-writeback elision: if a write column is bit-identical to its pre-call value the engine skips the copy, saving bandwidth.

Use read_optional when some entities have an extra component and some don’t:

const system = {
query: {
read: ["Velocity2D"],
write: ["Position2D"],
read_optional: ["Acceleration2D"],
},
};
function update(ctx, q) {
const dt = ctx.dt;
const vx = q.read.Velocity2D.x;
const vy = q.read.Velocity2D.y;
const px = q.write.Position2D.x;
const py = q.write.Position2D.y;
const acc = q.read_optional.Acceleration2D; // null if not present
for (let i = 0; i < q.count; i++) {
let ax = 0, ay = 0;
if (acc) { ax = acc.x[i]; ay = acc.y[i]; }
px[i] += (vx[i] + ax * dt) * dt;
py[i] += (vy[i] + ay * dt) * dt;
}
}

The dispatcher partitions by archetype so a single VM call always sees a uniform population — either every entity in the call has the optional component, or none do. Check once (if (acc) { ... }) before the inner loop.

Add a state field to the system header to give each entity a column of per-entity values that persist across frames:

-- Lua: a simple cooldown timer per entity
system = {
query = {
read = { "Velocity2D" },
write = { "Position2D" },
},
state = {
cooldown = 0.0,
},
}
function update(ctx, q)
local dt = ctx.dt
local cd = q.state.cooldown
for i = 1, q.count do
cd[i] = math.max(0, cd[i] - dt)
end
end

State is stored in BatchScriptComponent.state: Vec<f32> — serialised with the entity and loaded with the scene.

Batch scripts run with a deterministic per-frame RNG that shadows Math.random() (JS) and math.random() (Lua). Seeded from (scene_seed, frame, script_path_hash, chunk_id). This means the same scene, same frame, same worker count produces bit-identical results across runs.

To change the scene seed: BatchScriptPool::set_scene_seed(u64) (host side).

The native runtime runs batch scripts in direct mode: one in-process VM, no worker threads, which favours predictable startup. A threaded pool (BatchJsWorkerPool) exists and is what the perf harness measures; it scales sublinearly (JS: 1.3× at N=2, 1.7× at N=8) because the bottleneck at N=1 is already the payload allocation and scatter/append, not the compute. No runtime flag switches the shipped runtime to it.

Available via ScriptSystem::take_frame_metrics():

CounterMeaning
batch_run_usTotal microseconds in batch script update calls
batch_copy_in_bytesBytes scattered from ECS → mirror buffers
batch_copy_out_bytesBytes gathered back mirror buffers → ECS
batch_dirty_bytesPost-elision writeback (0 means no mutations)
batch_writeback_scatters_skippedColumns skipped due to dirty elision
script_worker_memory_bytes_maxPeak worker mirror buffer usage
script_worker_memory_bytes_totalSum across all workers