Log inGet started

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.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. Use it when you touch the same entity repeatedly. Some components also carry behaviour on the handle, like ecs.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 .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 / 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.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