Builtin Library
README.md
ecs
Public, typed, discoverable API for native ECS components. Generic verbs (insert, get, set, has, remove, query, ...) operate on any component, while per-component typed constructors (ecs.Mesh, ecs.Material, ecs.Transform, ...) build the Rust-shaped value the verbs apply. Each constructor is minted at module load from a spec under ecs.components.<Name>.
Typed constructors
ecs.<Name>{...} builds a component value: it runs the compiled conversion plan (asset-handle resolution, camelCase → snake_case field mapping, vec3 expansion) and raises on an unknown field. The result is a value the upsert verbs accept.
local ecs = require("modules.api.engine.ecs")
local value = ecs.Transform{ position = { x = 0, y = 1, z = 0 } }
ecs.insertSync(id, value)
The constructor also takes every shape the component is read back as, so a read hands straight to the constructor it came from:
- the
ecs.gethandle —ecs.Light(ecs.get(id, ecs.Light)), - the engine-side field table
handle:read()andecs.snapshot(id).<Name>give back, - a value the constructor already built (copied, so the two are separately writable),
- a table keyed by public field names, engine-side field names, or both.
That makes changing one field a read, an assignment and a write back:
local h = ecs.get(id, ecs.Light)
h.intensity = 0
ecs.set(id, ecs.Light(h)) -- every other field keeps what it held
Each of those shapes names the component it holds, so handing one to another component's constructor says which component it is and which constructor takes it. The ecs.get handle writes its own fields as well — h.intensity = 0 is already the whole write — so reach for the round trip when you want the component's value in hand.
Verbs
Every verb accepts an entity proxy (what entity.spawn returns) or a raw id string.
ecs.insert(id, value) -> promise— async upsert; await for same-tick visibility.ecs.insertSync(id, value)— upsert visible this tick; raises on failure.ecs.insertMany(ids, value) -> number— insert one value into many entities in a single crossing; returns the count applied. The three insert verbs warn per field a component could not hold the value it was given, naming the component, the entity, the field under its engine-side name and the value; the component holds that field's default and every other field lands.ecs.get(id, ecs.<Name>) -> handle?— retained handle over a present component (nilwhen absent). Seehandle.ecs.set(id, value) -> ok, rejected— patch fields of an already-present component; raises if the component is absent.okis false andrejectedlists the public field names that did not land when a field cannot hold the value it was given (a string outside an enum field's set, a shape the field's type does not read). Each name appears once and is warned once, with the component, the entity and every value that name refused — under the name you wrote it as, where the insert verbs name the engine-side one. Every field is attempted, so one refusal leaves the rest applied.ecs.has(id, ecs.<Name>) -> booleanecs.len(id, ecs.<Name>, field) -> number?— element count of a collection-valued field (list/array/map/set), counted without building any element;nilwhen the component is absent or the field is not a collection. Reading the field to measure it converts everything in it —#h.boneson a 50-boneSkeletoncosts ~1ms against one crossing here — so ask this whenever the question is how many, or whether any.ecs.remove(id, ecs.<Name>)ecs.components(id) -> {string}?— public component names present on the entity.ecs.snapshot(id) -> table?— every native component on the entity, nested and keyed by public name, each holding that component's fields under the names the component itself uses. A catalogued component's field table names the component it came from, so it goes straight to that constructor.ecs.queryIds(...) -> {string}— ids holding ALL the givenecs.<Name>types.ecs.query(...) -> {proxy}— likequeryIdsbut returns entity proxies.ecs.bind(id, component, field, mode?)— retained component-field accessor by rust component name.ecs.bindEntities(ids).
Discovery
The engine is driven at runtime, so the catalog is introspectable in-engine:
ecs.types() -> {string}— public component names available asecs.<Name>.ecs.describe() -> { schema, ... }— the full catalog: every component with its fields.ecs.describe(name | ecs.<Name>)returns the schema for one component.ecs.<Name>:schema() -> { component, rust, fields = { {name, type, rust}, ... }, methods?, accepts }— runtime field discovery for a single component;acceptslists the calls the name is accepted by, the round trip among them; behavior-bearing components also list their handlemethods.
for _, schema in ecs.describe() do
print(schema.component, #schema.fields)
end
Saved native components
A saved record (a scene entity, a bundle entity_template entry) lists its native components in an ecs array beside components: ecs = { { type = "Mesh", data = { mesh = "<mesh asset guid>" } }, { type = "Material", data = { id = "<material asset guid>" } } }. type is the name under ecs and data the row ecs.snapshot answers, with every handle-typed field written as the guid of the asset the handle came from. The scene loader and bundle:instantiate resolve each guid to the handle the component holds (ecs.handles, see its README) and insert one value into every entity that states the same entry.