Luduvo Contents
Theme
On this page

Entities and Instances

Everything in a place is an instance: a part, a light, a button, a player character. To a script, an instance is a value with properties and methods. To the engine, it is a node in one big tree: a record with an id, a slot in a parent’s child array, and a row in each component buffer its components occupy.

This page is about that second view. It covers what a node is made of, how a name resolves to a pointer, what the engine defers when we move a node, what it copies when we clone one, and what it unwinds when we destroy one. The API those operations expose, and the rules a script follows, are in Instances and Entity and Script Lifecycle; we do not repeat them here.

The order below follows the life of an entity: it exists in the tree, scripts find it, move it, copy it, and eventually tear it down. The last section covers the script instance itself, which is an entity with a thread attached.

The place tree in memory#

The engine arranges all instances into one n-ary tree, the place tree. The simulation walks it every tick, so its shape determines what the traversal touches. This is what one node holds:

Entity idUnique 64-bitIdentifies the entity recordParent pointerThe immediate ancestor nodeChild pointersOne contiguous arrayComponent buffersTransforms, Box3D handles, meshesMaterials, tags, script bindings

The id is a 64-bit integer that identifies the entity record. The network layer uses the same number, and the range it was minted from separates server ids from client-local ones; the network ids section below explains the two ranges.

The parent pointer references the node’s immediate ancestor. The child pointers are not separate allocations: they sit in one contiguous array, so iterating a node’s children reads that array sequentially. The nodes it points at are separate records, so reaching a child is still one dereference that can miss; the contiguity holds for the pointers, not for the children they address. Appending a child writes a pointer into the next free slot of that array, assuming the array has spare capacity.

The component buffers hold the rest: transform vectors, Box3D rigid body handles, mesh buffers, materials, tags, and script bindings. A node has rows only in the buffers its components require, and the property a script reads is a value in one of them.

The parent-child link serves two hierarchies at once. It is the spatial coordinate hierarchy, because a child’s transform is relative to its parent, and it is the lifecycle and visibility hierarchy, because a subtree detached from the tree is a subtree the systems skip. A script that assigns Parent is editing this structure directly, and both hierarchies change at once.

Handles#

Locating an object by path performs one linear scan of a child array per path segment: workspace.Buildings.RedTeam.Door scans four. Each scan compares the segment against the children of the node resolved so far, so the cost is the sum of those four child counts and not four constant-time steps. The path also stops resolving once an edit in the editor renames or moves a node along it.

The engine therefore provides a second lookup path. In the editor an instance can be given a handle name, such as LobbyDoor. During place startup the engine registers every named shortcut into a flat hash map and injects that map into the global handles table that scripts read.

Luau
local door = handles.LobbyDoordoor.Position = Vector3.new(0, 10, 0)

Reading handles.LobbyDoor hashes the name and probes the map: one probe when the slot holds that key, a short probe sequence when keys collide, so the lookup is expected O(1) rather than exactly one probe. The probe count does not grow with the node’s depth, and the name keeps resolving after the hierarchy around the door is rearranged, because the map holds the instance pointer against the name and not against a path.

FindFirstChild covers entities that were spawned at runtime and were never given a handle name. The naive implementation compares the query string against each child’s name, and each strcmp costs time proportional to the length of the prefix the two names share.

Names are interned instead. Calling FindFirstChild(name) hashes the query string once at the start of the call and looks it up in the world’s interned string pool through ldv_name_find, which yields a 32-bit Name.id. Assuming every live instance’s name was interned when it was assigned, a miss in the pool proves that no instance in the world carries that name, and the call returns nil without reading a single child. On a hit the engine iterates the contiguous child array and compares that 32-bit id against each child’s Name component, which is one integer compare per child plus the dereference that loads the component.

Integer comparisons are cheap, but the loop performs up to N of them: the search is linear in the number of direct children, it stops at the first match, and it reads that one level only. A handles lookup keeps its expected constant probe count as the level grows and FindFirstChild does not, so give frequently looked-up objects a handle name and cache what FindFirstChild returns instead of calling it every frame. The guide covers both calls in Accessing instances.

Reparenting and transforms#

Assigning Parent is the only way to move a node, and the pointer work is bounded by the two child arrays involved: the engine scans the current parent’s array for the node’s slot, removes it, and appends the pointer to the new parent’s array. Assuming the array keeps children in insertion order, the removal moves every pointer after that slot down by one, and the append reallocates the destination array when it is full.

The transform arithmetic costs more. A node’s Position and Rotation are local, relative to the parent, so moving the node under a different parent invalidates the world-space matrix of the node and of every node below it. Doing that work inside the property assignment would mean a script that reparents fifty objects in a loop performs fifty subtree walks, and any walk over nodes that a later assignment invalidates again was computed for nothing.

The engine flags the transform dirty instead and returns. During the transform pass, which runs after physics integration has finished and before the network delta is packed, the engine recomputes the world-space matrix of each flagged node and of its descendants, parent before child, because every matrix multiplies the one its current parent produced.

That deferral is observable. Position stores the local offset and the assignment does not rewrite it, so reading it straight after a reparent yields the same three numbers, now measured against the new parent; the world-space matrix stays stale until the pass runs.

Luau
self.Parent = handles.DebrisFolderprint(self.Position)

The printed vector is the retained local offset, which the transform pass will resolve against DebrisFolder, not the world position the object had before the assignment.

Setting Parent = nil unlinks the node and its whole subtree from the active hierarchy. On every pass after the unlink the Box3D solver and the renderer skip detached entities, but nothing is freed: the entity records stay allocated while a Luau reference to the instance remains reachable from the script environment, and the collector reclaims them only after the last such reference is dropped. Detaching is not destroying, and a script that pools objects relies on that distinction. The guide side is Managing the hierarchy.

Cloning#

Clone is a recursive deep copy of the target instance and its whole subtree. The engine allocates one fresh entity record per node it visits, then copies that node’s rows out of the serialized component buffers: spatial transforms, colors, materials, collision filters, custom components, and the attr table. Buffers in which the node has no row are not read. Parent-child relationships are rebuilt across the copy, so the new subtree has the same shape as the original.

Identity is not copied. Every cloned node is minted a 64-bit id that is unique within the session, and neither the network id nor the stable id of the source carries over; a clone is a new entity, not a second reference to an old one.

The returned root has Parent = nil. It is a detached subtree that belongs to no world until a script assigns its Parent, so a script can configure the copy before anything simulates it.

Luau
local projectile = handles.BulletTemplate:Clone()projectile.Position = self.Position + Vector3.new(0, 2, 0)projectile.Parent = handles.WorldFolder

For a script the cost of Clone is one record allocation per node plus one row copy per component on that node, so it scales with both counts, and the copy does not simulate until it is parented. See Creating instances.

Destroying#

Destroy has more to undo than a reparent, because a running entity is registered with several systems that hold their own references to it: the spatial grid, Box3D, the renderer, the signal tables, and the script runtime. The teardown order is fixed wherever one system would otherwise read a record that another has already dropped.

Teardown starts by traversing the subtree and removing every descendant from the spatial acceleration grid, so no spatial query issued after that removal can return an entity whose component rows are about to go away. The instance is then unlinked from its parent, its Parent is set to nil, and the property is locked: every later assignment to Parent raises instead of reparenting or resurrecting the destroyed instance.

Deleting the root entity then cascades through the ECS across all descendants, and the observers attached to the removed components perform the releases. Box3D destroys the rigid bodies, collision shapes and constraints, welds and mechanical joints unbind, the renderer releases vertex buffers, mesh resources and texture handles, running script threads bound anywhere in the subtree are halted, and every signal connection referencing the entity is disconnected so that no closure keeps the subtree alive. These releases are ordered against the grid removal and the tombstone, not against each other.

The change tracker closes the sequence. It appends the entity’s 64-bit network id to its deletion array together with the entity’s client visibility mask, and that tombstone survives one tick: the next delta packet carries the deletion to the clients the mask marks as able to see the entity, and the id is retired once that packet is built.

What is left on the Lua side is a handle with its internal pointer tagged as destroyed. Indexing or mutating it afterwards raises attempt to index a destroyed Instance, and handing it to Session.BindCharacter fails with entity is destroyed. Destroy itself is server-only and idempotent: a second call on an already destroyed instance returns without doing work, and a call from a client script raises. The script-facing rules are in Cleaning up.

Network ids#

The 64-bit id is also the entity’s identifier on the wire, which adds one constraint: a client that spawns its own entities must never mint an id the server might mint too. The engine partitions the number space rather than allocating ids through a server round trip:

Server idsMonotonic counter from 1Replicated to clientsClient-local idsUpper range from 2^62Never leaves the machine

Server ids come from a monotonic 64-bit counter that starts at 1 and increments with every server-created entity, so within a session it is strictly increasing and no two entities share an id; reaching 2^62 would take 4.6 x 10^18 spawns. Loading a place assigns one id to every entity in the file; creating an entity later assigns the next. The next outgoing delta packet sends a create command for that id to every client in streaming range.

Client-local ids come from the upper range, at or above 2^62, and the counter that hands them out lives on the client: an id from that range is never written into a packet. A client script that spawns or clones an entity therefore gets a strictly local object, such as visual debris or a viewport model. It generates no network traffic, and assuming the server counter stays below 2^62 the two ranges are disjoint, so a client id cannot collide with a server-authoritative one.

Retirement is one-way. A destroyed entity’s id goes into the deletion array as a tombstone, is sent in one delta packet, and is not reissued for the rest of the session, so a stale id arriving later refers to nothing rather than to a different entity. For a script, an id is stable for the lifetime of the session, and objects it creates on the client stay on the client. The guide covers the visible side in Network IDs and scope.

How a script instance starts#

A script in Luduvo attaches directly to an entity, and the runtime turns it into a running instance at the start of the first tick in which that entity exists in the world. Starting one is a short sequence with two places it can fail.

The runtime creates a fresh thread with sandboxed globals only, and binds two of them: self, the owning entity, and handles, the named instances registered at place startup. The host is reachable from that thread only through the globals the sandbox exposes.

Then it loads bytecode. Scripts are compiled ahead of time, so the runtime is not parsing Luau source during a tick; it loads a precompiled chunk. A syntax or compilation error reports a load error and aborts instance creation.

The top-level chunk runs once, under the instruction budget described below. An error thrown here reports an init error and the instance is discarded. After a chunk returns normally the runtime captures the global Update and PhysicsUpdate functions if the script defined them, and the tick calls those captured functions from then on.

The budget caps the work one entry into a script environment can do before the engine aborts it. Every entry is granted 1,000,000 VM instructions: the top-level load, each Update, each PhysicsUpdate, each system phase dispatch, and each signal invocation dispatched across threads gets its own fresh budget. The counter is per entry rather than per tick, so a script the engine enters four times in one tick may execute up to 4,000,000 instructions in it. Overrun raises script exceeded its instruction budget and aborts that entry.

Instruction counting is not an asynchronous scheduler, so an over-budget entry is not paused and resumed later. A pcall around the loop catches the error, but it does not refill the budget of the entry it runs in, so the loop does not continue in that tick. A script author meets the limit as an aborted entry and an error message rather than as a long frame. The lifecycle rules around it, including the consecutive error counter and hot reload, are in Scripts.