Log inGet started

Entities

Updated 4 September 2026
local e  = entity.spawn("lantern")   -- a bare entity; returns its proxy, ready to drive
local id = e.id                       -- the stable id string ("ent_…"), for when you need it
local e2 = entity(id)                 -- look a proxy up later by id only (entity.find(name) resolves names)

Inside a component, public.entity is this same proxy, already bound to the owning entity — so everything below works the same whether you spawned the entity or you're reacting to it from a component.

The proxy is sealed: reading or calling a name that doesn't exist raises a clear error that names the bad access and lists what's valid (EntityProxy: no property/method 'fovv'… Valid: …) — it never silently returns nil, so typos fail loud. The asset, component, and camera handles behave the same way.

Identity: id vs name

Every entity has an id (ent_…, stable and unique — this is what persists and what other entities reference) and a name (human-readable, not unique). entity("ent_…") looks one up exactly; entity.find("lantern") returns the first proxy with that name, or nil. Names are for you; ids are for the engine.

The transform

Position and rotation default to world space; prefix local for parent-relative. Scale is always local.

e.position = { 0, 2, 0 }                 -- world position
e.localPosition = { 0, 1, 0 }            -- relative to parent
e.rotation.eulerAngles = { 0, 90, 0 }    -- degrees
e.localScale = { 2, 2, 2 }
local p = e.position                      -- read back: p.x, p.y, p.z

The proxy reads are snapshots at the moment of the call — for a tight loop, read once into a local rather than re-reading e.position each iteration.

Each of these values reads its components off the entity when you ask, so one held past its entity's destruction answers with a marker instead of numbers — see A proxy whose entity is gone below.

Turning about an axis

To face an entity along an angle, name the axis to turn about and the angle to turn. setAxisAngle states the rotation outright; rotateAxisAngle adds to whatever the entity already has. Both take the angle in radians, where the eulerAngles above takes degrees — math.rad converts.

e.rotation.setAxisAngle(0, 1, 0, math.rad(90))    -- face 90° of yaw (Y is up)
e.rotation.setAxisAngle(1, 0, 0, math.pi / 4)     -- pitch, about X
e.rotation.setAxisAngle(0, 0, 1, math.pi / 4)     -- roll, about Z
e.rotation.setAxisAngle(1, 1, 0, math.pi)         -- any axis; length is ignored

e.rotation.rotateAxisAngle(0, 1, 0, math.rad(5))  -- turn 5° further each call

Calling setAxisAngle twice with the same angle leaves the entity where the first call put it; calling rotateAxisAngle twice turns it twice as far. Both pivot in place — the position is untouched. An axis of (0, 0, 0) names no direction and raises rather than resetting the entity to face forward.

Aiming at a point

lookAt turns the entity so its forward — its local -Z, the axis e.transform.forward reads back — points at a world position. The target is whatever you have in hand: three coordinates, one point table, or another entity, named by id, by name, or by proxy.

e:lookAt(0, 4.5, 8)               -- three coordinates
e:lookAt({ 0, 4.5, 8 })           -- one point
e:lookAt(subject)                 -- another entity, by proxy
e:lookAt("lantern")               -- ... or by name
e:lookAt(subject.position)        -- ... or wherever it stands right now

Everything here is world space, so a parent under either entity moves it and the aim still lands on the point named. A trailing up hint decides the roll around the aim — which way is up in the shot — and world +Y stands in when it is left out:

e:lookAt(target, { 0.2, 1, 0 })   -- the same aim, leaning into a dutch tilt

Straight up and straight down are ordinary cases. lookAt returns whether the rotation was written, plus the reason it was not ("unresolved", "no-transform", "incomplete-target", "incomplete-up", "degenerate"), so an aim at a name the scene does not carry says so rather than leaving the entity where it was and reading back as a success.

The same call is on the transform grouping — e.transform:lookAt(target) — and the orientation on its own, with no entity to write it to, is transform.lookRotation(eyeX, eyeY, eyeZ, atX, atY, atZ, upX?, upY?, upZ?), which hands back the four quaternion components a rotation setter takes:

e.rotation = { transform.lookRotation(0, 1.35, -16, 0, 4.5, 8) }

Hierarchy

Entities form a tree. Parenting keeps the child's world transform, so attaching something doesn't teleport it.

local body = entity.spawn("car")
local wheel = entity.spawn("wheel", { parent = body })   -- parented at spawn
wheel.setParent(body)                                     -- or later
wheel.getParent()                                         -- parent entity, or nil
body.getChildren()                                        -- { child, … }
body.getDescendants()                                     -- the whole subtree
wheel.unparent()                                          -- becomes a root

The subtree is also the unit render-layer membership is usually written in — a prop is put on a layer as one thing, not mesh by mesh:

tools.use("renderLayer", "set", "car", "stage_props", { tree = true })
--> { { name = "car", layers = { "stage_props" }, moved = 13, skipped = 0 } }

moved and skipped say how far that write reached, so the reply answers "did this cover the whole prop" without a walk over getDescendants(). The render-layers guide covers membership and the camera filter that reads it.

An entity is an id or a proxy — pick the one you want

The engine calls answer with proxies — the hierarchy reads (getChildren, getDescendants, getParent) and the lookups (find, findAll, spawn) alike — while id strings arrive from stored data and serialized scenes. entity.ids and entity.proxies coerce either shape — one entity, a list, a mix, or nil — into a list of the one you asked for, so consuming code states the shape it wants instead of tracking where its input came from.

for _, child in body.getChildren() do                     -- already proxies
    child.position = { 0, 1, 0 }
end

entity.proxies(savedIds)                                  -- ids -> proxies
entity.ids(entity.findAll("wheel"))                       -- proxies -> ids
entity.ids(body)                                          -- one entity -> { id }
entity.proxies(nil)                                       -- nothing -> { }

Every list-taking call (entity.batchRead, batchWrite, batchAddComponent, batchProxy, batchDespawn) takes ids and proxies interchangeably, so the coercion is for your own loops rather than for feeding them.

A proxy whose entity is gone

A proxy holds an id, not the entity. Once that entity is despawned the proxy is still a table you can hold, and asking it for anything refuses by raising, with a message naming the id and the member:

EntityProxy: entity 'ent_7f21' does not exist, so 'x' has no entity to answer
for. It was despawned, or the id names something else now.

e.exists is the one member that answers on such a proxy instead of refusing, so a consumer that holds proxies between frames asks it before it reads:

for _, e in cached do
    if e.exists then
        e.position = { e.position.x + dx, e.position.y, e.position.z }
    end
end

It is the confirmation every other member is gated on, handed back instead of enforced, so e.exists is true exactly when the rest of the proxy will answer, and it never raises — a proxy over an id nothing carries reads false rather than refusing. e.transform and e.attribute carry .exists too, on the same rule.

entity.exists(id) and entity(id) / entity.find(name) are the namespace ways to ask, and they agree with e.exists while the entity is there and once it is gone. They part through the teardown span: entity.exists reports an entity whose despawn is queued as already absent, while that entity is still present and its own onDestroying handler still reads it — so a proxy read inside that handler answers, and e.exists says so.

A transform value (e.position, e.rotation, e.localScale and their siblings) is itself such a proxy: its .x/.y/.z[/.w] come from the entity at the moment you read them. So a value you captured earlier and hand back later — a task's return value, an execute result, anything read out of Luau — carries that refusal into the read, and what arrives in place of { x, y, z } is a marker object:

{
    __unreadable = true,          -- always present, always true
    entity = "ent_7f21",          -- the id the proxy spoke for
    proxy  = "PositionProxy",     -- which transform value it was
    field  = "x",                 -- the first component that refused
    reason = "EntityProxy: entity 'ent_7f21' does not exist, …",
}

value.__unreadable is the test, and those five keys are the marker's whole shape — a caller reaching for .x on one gets nil, which is deliberate: components substituted with zero would read as an entity at the origin. field is the first component that refused; the read stops there. Everything beside the marker in the same payload arrives intact, so one dead leaf costs you that leaf and nothing else.

Values going the other way are reported rather than substituted: a component's data table carrying a dead proxy fails the call that passed it, naming the entity — at the call where the call is synchronous, and through the promise it handed back where it is not. A component written from a dead proxy is a value nothing downstream can correct, so the write never happens.

Components are where the work lives

Add capabilities by adding components; read and change them through the same proxy. The everyday way to give an entity a real, recognizable form is to reference an asset that packages one — a bundle (mesh + material + rig) — and spawn it, then work with its components:

asset.list("bundle")                                           -- survey: @builtin::avatars.humanoid, …
tools.use("entityOps", "fromAsset", "@builtin::avatars.humanoid", { name = "guard" })  -- reference one and spawn it
local guard = entity.find("guard")
guard.component.add("Physics")          -- add behaviour
guard.component.has("Physics")          -- boolean
guard.component.list()                   -- what's attached
guard.component.remove("Physics")

A Model component attaches a single mesh + material to a bare entity. The mesh + material are real assets — found in the library, generated, or authored:

e.component.add("Model", { model = "@builtin::avatars.humanoid" })   -- a real asset reference

Survey real content with asset.list("model") / asset.list("bundle") and the ZeroMind library first (the asset-system and worlds guides); when nothing fits, generate it (generating-assets-and-content). What a component is, its fields, and its lifecycle are in the components guide.

There, active, visible, temporary — four independent ideas

You want…UseEffect
To know whether the entity is still in the world at allread e.existsThe only member that answers once the entity is gone; every other one refuses
To disable an entity and its subtree (no ticks, no render)e.setActive(false) / read e.active, e.activeInHierarchyWhole-entity on/off; folds in parent state
To stop drawing it but keep it livee.hide() / e.show()Rendering only; it still exists and ticks
To keep it out of the saved worlde.setTemporary(true) / read e.temporaryExcluded from serialization (with its descendants); still live at runtime

Reach for the one that matches your intent — "gone", "disabled", "invisible", and "not saved" are genuinely different states, and a disabled entity is still there to answer for itself.

Locks — why some entities refuse to be removed

An entity (or one of its components) can be locked to refuse destructive operations. Locks are why a despawn or a component.remove sometimes comes back refused rather than silently doing nothing:

e.lock("entity-destroy")                          -- refuse despawn of this entity
e.locked                                           -- boolean: is anything locked on it?
e.locks()                                          -- { destroy = bool, components = { ["<type>"] = { remove, write } } }
e.unlock("entity-destroy")                          -- lift the lock
e.component.lock("Model", "component-remove")       -- per-component: "component-remove" / "component-write"

The engine locks the state it depends on — notably the player identity entity, which is destroy-locked and whose UserIdentity component is remove-locked. So entity.despawn(player) (or removing its UserIdentity component) comes back refused with a message telling you to :unlock() first — that's the engine protecting load-bearing state, not a bug. Unlock deliberately only when you truly mean to.

A shared, multiplayer world

The world is live and shared with everyone connected (see the engine and multiplayer guides). An entity tracks its ownership, and you choose which entities replicate:

e.setSynced(true)     -- this entity replicates to other peers
e.synced()            -- boolean — is it replicated?
e.isLocal()           -- boolean — does this peer own it?
e.owner()             -- owning peer id (0 = unowned/server)

Author with that in mind: gameplay entities that everyone should see get setSynced(true); per-player scratch can stay local.

Finding entities

entity.find("lantern")        -- first proxy with that name, or nil
entity.findAll("lantern")     -- every match, as proxies
entity.findAll()              -- every entity in the scene, as proxies
entity.exists(id)             -- boolean (e.exists is the same answer on the proxy)
queryEntitiesTable()          -- one cached snapshot of every entity { id, name, parentId, components }

Both lookups take a glob as well as an exact name: * stands for any run of characters and ? for any single one, matched against names.

entity.findAll("lantern_*")   -- every lantern, filtered inside the engine
entity.find("lantern_?")      -- the first single-suffix lantern

Reach for the glob rather than scanning findAll() and filtering in Luau — the engine walks the entity set once and builds a proxy only for the matches. A glob matches names, not ids. The argument is always a filter, so findAll("") asks for entities named "" and normally answers with none; it's findAll() with no argument that means everything.

queryEntitiesTable() is the cheap way to scan everything at once; entity(id)/find are for working with a specific entity.

The rest

The proxy carries more than this — attributes (a per-entity key/value bag via e.attribute), bone attachment, session scoping, duplication. The proxy's full method set is discoverable: read modules/api/engine source, or call and inspect (lsp.methods/lsp.describe, the discovering guide). The model above — id + transform + hierarchy + components, in a shared world — is the part you can't grep for.

  • documentation
  • guide