ECS
You work with the ECS layer through ecs. and entity. — two views of the same thing. entity. spawns and finds the entities; ecs. attaches and reads the native components that give them their…
local e = entity.spawn("ball") -- a proxy you drive; e.id is its stable id
local e2 = entity.find("ball") -- look one up later by name
The ecs.* verbs take a proxy or its id, interchangeably. (The entity itself — names, ids, hierarchy, ownership — is the entities guide.)
Putting a model on screen
A rendered model is an entity carrying three components:
Transform— position, rotation, scale.Mesh— a GPU mesh handle: the shape.Material— a GPU material handle: the shading.
The renderer draws every entity that has all three. You attach them with the typed constructors:
local e = entity.spawn("ball")
e.position = { 0, 1, 0 }
local mesh = asset.resolve("@builtin::meshes.sphere", "mesh"):handle()
local mat = asset.resolve("@builtin::materials.default", "material"):handle()
ecs.insertSync(e, ecs.Mesh { mesh = mesh })
ecs.insertSync(e, ecs.Material { material = mat })
ecs.Mesh / ecs.Material take a GPU handle — get one from a mesh, material, or texture with :handle(), or make one with renderer.mesh.create / renderer.material.create / renderer.texture.create. ecs.insertSync attaches the component, visible this frame. With a light and a camera already in the scene, the ball renders lit and casting a shadow; ecs.Light and ecs.Camera below are how you add your own.
Transform is on every entity. Set it through the entity (e.position = { 0, 1, 0 }) or through the component handle, which uses the same field name: ecs.get(e, ecs.Transform).position. (Its rust name — what the batch path below passes — is "translation".)
The same move for everything else
Each capability is a component you attach the same way:
ecs.Light— a light the lighting system reads,ecs.Camera— a camera the renderer draws the world through,ecs.Physics/ecs.Collider— a body the physics system steps,ecs.AudioSource— a sound the audio system places by its transform.
Attach the component, set its fields, and the system that owns it takes over.
Reading and changing components
ecs.insertSync(id, ecs.Mesh { mesh = handle }) -- attach (or upsert) now, visible this frame
ecs.insert(id, ecs.Material { material = handle }) -- attach async; returns a promise you can await
ecs.set(id, ecs.Mesh { visible = false }) -- patch fields on a component already attached
local h = ecs.get(id, ecs.Mesh) -- a handle you read and write live
ecs.has(id, ecs.Mesh) -- is it attached?
ecs.remove(id, ecs.Light) -- take it off
The verbs take an entity (a proxy or its id) and a component value (ecs.Mesh { ... }) or a component type (ecs.Mesh). There are three ways to write, for three needs:
ecs.insert/ecs.insertSyncattach or upsert — they create the component, or re-sync the fields you pass on one that's already there. Safe to re-apply.ecs.setpatches the fields you pass on a component that's already attached, and raises if it's absent — so reach forsetas a guarded patch when you know the component is there, andinsertwhen it might not be.- the handle from
ecs.getreads and writes fields live —h.visibleto read,h.visible = falseto write. Use it when you touch the same entity repeatedly. Some components also carry behaviour on the handle, likeecs.get(id, ecs.Physics):applyImpulse(...).
(For the transform specifically, the entity proxy is the everyday shorthand: e.position = { 0, 5, 0 }.) A vec3 field is a { x, y, z } array or a { x =, y =, z = } table — interchangeable on the way in; reads come back as { x =, y =, z = }.
ecs.insertSync lands this frame; ecs.insert is the async form — the resource may still be loading — and returns a promise handle you can await from inside a task:
task.spawn(function()
await(ecs.insert(id, ecs.Mesh { mesh = mesh }))
-- the component is attached now
end)
To see what an entity is carrying:
ecs.components(id) -- { "Transform", "Mesh", "Material", ... } : the components attached
ecs.snapshot(id) -- { Mesh = {...}, Material = {...}, ... } : each component with its fields
Finding entities by component
ecs.query(ecs.Mesh) -- { proxy, ... } : every entity that has a Mesh
ecs.query(ecs.Mesh, ecs.Material) -- entities that have ALL the listed components
ecs.queryIds(ecs.Light) -- the same, as bare ids (cheaper for large sets)
ecs.query hands back proxies ready to drive; ecs.queryIds returns ids when you want to stay light. This is how you act on a whole class of entity — every light, every body — without tracking them yourself.
A query matches by component type, so to pick out your own class — your troops, not every entity with a Mesh — give them a component that marks them (a script component, authored in the components guide) and query that; or hold onto the id array a batch spawn already handed you.
Where your logic runs
You attach data; the systems that consume it — the renderer, the physics step, the audio mixer — are the engine's, and you do not write them here. Your own per-frame logic lives in a loop you launch with task.loop. Drive a large set by crossing the boundary once for the whole set — entity.batchRead / entity.batchWrite — not once per entity:
local troops = entity.batchSpawn(1000, "troop") -- keep the id array
local targets = {} -- per-troop game data, script-side
local dt = 1 / 30
task.loop(function()
local pos = entity.batchRead(troops, "Transform", "translation") -- one crossing
for i, p in ipairs(pos) do
local t = targets[troops[i]]
if t then pos[i] = stepToward(p, t, dt) end -- your movement math, no crossings
end
entity.batchWrite(troops, "Transform", "translation", pos) -- one crossing
end, dt)
task.loop(fn, dt?) is the everyday per-frame driver — dt is the seconds between ticks, the fixed step you move by; omit it to run each frame. A per-iteration error is caught so the loop carries on, and it returns a handle for task.cancel. For a loop that keeps running while the editor pauses gameplay (input ticking, watchers), task.spawnSystem runs a coroutine that survives the pause. (Coroutines, tasks, and timing are the scripting-and-tasks guide.)
That loop drives a thousand entities in two crossings a frame. When you have only a handful, or your logic needs a proxy's methods, iterating proxies is fine instead: for _, e in ipairs(ecs.query(ecs.Mesh)) do ... end. Per-entity game state you touch every frame — a target, hp — lives in a plain script-side table keyed by id (above) or in a component you batchRead / batchWrite; the e.attribute bag (below) is for sparse, occasional data, not a per-frame hot loop.
Arbitrary data on an entity
Beyond the typed components, every entity carries a key/value bag for your own data. A value can be a string, a number, or a table:
e.attribute.set("team", "red")
e.attribute.set("hp", 100)
e.attribute.set("target", { x = 9, y = 0, z = 4 })
e.attribute.get("hp") -- 100
e.attribute.list() -- { "team", "hp", "target" }
e.attribute.remove("team")
Reads and writes are per entity, so this is the place for sparse, occasional data — a unit's faction, a chest's contents — not state you sweep every frame at scale (keep that script-side or in a component you batchRead / batchWrite).
Working at scale
The cost of building entities is the FFI crossing, so the way to make thousands is to author once and hand the whole batch across in a single call.
Spawn N bare entities at once:
local ids = entity.batchSpawn(10000, "bullet") -- 10 000 entities, one crossing
Attach a component to many at once:
ecs.insertMany(ids, ecs.Mesh { mesh = mesh }) -- lands this frame, like insertSync
ecs.insertMany(ids, ecs.Material { material = mat })
Or author a package once and stamp out copies — an entity plus its components in one bulk operation. A template holds two kinds of component, added by different registries:
ecs— native components (ecs.X{...}— Mesh, Material, AudioSource, …): the reflect-registry data the engine's systems read. The fast path; nothing script-side runs.components— script components (authored.componentassets, keyed by name): each carries its own Luau lifecycle. Use these when the copies need authored behaviour.
local tpl = entity.template({
ecs = { ecs.Mesh { mesh = mesh }, ecs.Material { material = mat } },
-- components = { Spinner = { rpm = 90 } }, -- script components, same idea
temporary = true, -- shared config; also active / hidden / attributes
})
local ids = entity.instantiate(tpl, 10000, function(i)
return {
name = "bullet_" .. i,
position = { i % 100, 0, i // 100 },
-- per-instance overrides of anything the template sets, e.g.:
-- ecs = { ecs.Material { material = mats[i] } },
-- attributes = { slot = i },
}
end)
entity.template validates and converts the package once. def carries the two component kinds (ecs, components) plus shared spawn config — temporary (non-persistent, skip-on-save), active, hidden, attributes. entity.instantiate(handle, count, fn?) spawns count copies in one crossing; fn(i) returns per-instance overrides — name, position, rotation, scale, parent, temporary, active, hidden, attributes, and components / ecs data (merged over the template's for that component). Each override supersedes the template's shared value. Ten thousand copies cost one call.
For a batch large enough to stall a frame, wrap it in queue — the writes defer and materialise over the next frames instead of all at once:
queue(function()
for i = 1, 100000 do entity.spawn("particle_" .. i) end
end)
task.batched(count, fn) is the same idea for a loop, yielding every so many iterations so the frame keeps breathing.
To read or write one field across a whole set in a single crossing, entity.batchRead / entity.batchWrite. These are the substrate fast path: they take the component and field by their rust names — what ecs.describe reports in its rust fields, not an ecs.Type. Some rust names match the type (ecs.Transform is "Transform"), some don't (ecs.Mesh is "MeshInstance"); the transform position field is "translation". batchRead returns one value per entity, parallel to ids; batchWrite takes one value per entity the same way. A missing or despawned id reads back nil at its slot, and results stay index-aligned to the ids you pass — so rebuild your id list after a batchDespawn before the next batch call. Two crossings for the whole set, not two per entity:
local pos = entity.batchRead(ids, "Transform", "translation") -- { {x=,y=,z=}, ... }
for i, p in ipairs(pos) do pos[i] = { x = p.x, y = p.y, z = p.z + 1 } end
entity.batchWrite(ids, "Transform", "translation", pos) -- one crossing for all
And to remove many at once, entity.batchDespawn(ids) — the teardown counterpart to batchSpawn, one crossing for the whole set.
You own the lifecycle
The ECS layer runs nothing for you on its own — what you create, you release:
- a GPU resource from
renderer.mesh.create/renderer.material.create/renderer.texture.createstays resident until yourenderer.destroy(handle), - an entity stays until
entity.despawn(id)— orentity.batchDespawn(ids)for many at once, - a component stays until
ecs.remove(id, ecs.Mesh), - a task runs until
task.cancel(handle).
No hook fires when you're done — a handle you don't destroy sits on the GPU for the session, and an entity or task you don't stop keeps running. Releasing is yours.
Finding the fields
The shapes are a runtime call, so you never guess them:
ecs.types() -- every component you can attach
ecs.describe() -- the full catalog: each component, its fields, its handle methods
ecs.describe(ecs.Mesh) -- one component's fields and types
Reach for ecs.describe whenever you're about to build a component value — it's the running engine's own answer for what that component takes.
See also
- entities — the entity itself: ids, names, hierarchy, ownership, active / visible / temporary.
- scripting and tasks — coroutines,
task.*,await,queue, timing, where entrypoints live. - input — mouse, keyboard, gamepad: cursor position and clicks, for selecting and ordering entities.
- rendering — meshes, materials, lights, cameras (including projecting a screen point into the world),
renderer.*. - assets —
asset.resolve,:handle(), and the asset system the handles come from. - components — authoring reusable script components (including marker components to query by).