---
title: "ECS"
description: "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…"
section: "Core"
slug: "core-ecs"
canonical: "https://origozero.ai/docs/core-ecs"
updated: "2026-09-04T19:42:51.685373140+00:00"
tags: ["documentation", "guide", "ecs", "entity", "component", "transform", "mesh", "material", "light", "camera", "physics", "audio", "systems", "rendering"]
---

# ECS

```lua
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:

```lua
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

```lua
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 live** — `h.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:

```lua
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:

```lua
task.spawn(function()
  await(ecs.insert(id, ecs.Mesh { mesh = mesh }))
  -- the component is attached now
end)
```

To see what an entity is carrying:

```lua
ecs.components(id)   -- { "Transform", "Mesh", "Material", ... } : the components attached
ecs.snapshot(id)     -- { Mesh = {...}, Material = {...}, ... } : each component with its fields
```

## Finding entities by component

```lua
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:

```lua
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:

```lua
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:

```lua
local ids = entity.batchSpawn(10000, "bullet")   -- 10 000 entities, one crossing
```

Attach a component to many at once:

```lua
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.

```lua
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:

```lua
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:

```lua
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:

```lua
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).
