Log inGet started

ECS

Updated 4 September 2026
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.insertSync attach or upsert — they create the component, or re-sync the fields you pass on one that's already there. Safe to re-apply.
  • ecs.set patches the fields you pass on a component that's already attached, and raises if it's absent — so reach for set as a guarded patch when you know the component is there, and insert when it might not be.
  • the handle from ecs.get reads and writes fields liveh.visible to read, h.visible = false to write, and h:set("visible", false) for the same write in method form. Both write the component, so the read after either answers what the component holds. Use it when you touch the same entity repeatedly. Some components also carry behaviour on the handle, like ecs.get(id, ecs.Physics):applyImpulse(...).

A component reads back into the constructor it was read through, so changing one field never means re-stating the rest:

local h = ecs.get(id, ecs.Light)
h.intensity = 0
ecs.set(id, ecs.Light(h))     -- lightType, color, castsShadows and the rest keep what they held

ecs.<Name>(...) takes the ecs.get handle, the field table handle:read() and ecs.snapshot(id).<Name> give back, and a value it built earlier — each of them names the component it holds, so one belonging to another component says so and names the constructor that takes it.

A field holds what its type can hold. Give one a value it cannot — a string outside the set an enum field takes, a shape the field does not read — and that field keeps what it had while the rest of the write lands. ecs.insert, ecs.insertSync and ecs.insertMany warn, naming the component, the field under its engine-side name and the value it refused. ecs.set warns under the name you wrote it as, and returns it as well: local ok, rejected = ecs.set(id, ecs.Light { lightType = "Blinding" }) answers false, { "lightType" }, and true, nil when every field landed. A write through the ecs.get handle — h.lightType = "Blinding" or h:set("lightType", "Blinding") — warns under the name you wrote it as, and the field reads back what it kept. ecs.Light:schema() lists the fields a component takes, each under both names.

(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

task.loop(function(dt)
  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, 1 / 30)

task.loop(fn, dt?) is the everyday per-frame driver. fn is called as fn(delta)delta is the seconds elapsed since the previous tick, the step to move by. The optional second arg sets the interval between ticks (here 1/30); 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).

The bag is authored state: a scene save writes each entity's attributes into its record and a load restores them, so a key you set while editing is there the next time the scene loads. A key beginning with _ is the engine's own namespace — a system stamps one to track the entity through the session it is running in, and it lives on the entity for that session alone.

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 .component assets, 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 / internal / 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, internal, 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, internal, 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". Transform also answers to the names a proxy uses — position / rotation are world space, localPosition / localRotation / localScale are the raw local fields — and they carry that space through every call shape, so a position write under a parent lands where the parent puts it. 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.create stays resident until you renderer.destroy(handle),
  • an entity stays until entity.despawn(id) — or entity.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.*.
  • assetsasset.resolve, :handle(), and the asset system the handles come from.
  • components — authoring reusable script components (including marker components to query by).
  • documentation
  • guide
  • ecs
  • entity
  • component
  • transform
  • mesh
  • material
  • light
  • camera
  • physics
  • audio
  • systems
  • rendering