Luduvo Contents
Theme
On this page

Entity and Script Lifecycle

When developing in Luduvo, three primary concepts possess an active lifespan: entities, script instances, and player sessions. The authoritative server manages all three:

  • An entity exists from the tick of instantiation until destruction.
  • A script instance lives from the tick its code loads until its parent entity dies or an in-place code reload swaps it out.
  • A player session spans from the initial connection handshake through to client disconnection.

How the engine does this inside: Entities and Instances for the entity side and The Server Tick and Physics for the tick side.

Session lifecycle#

A connected game session interacts with these systems in chronological order:

  1. Place loading: The engine loads the place file, assigns a unique 64-bit network ID to every entity in the file, and queues every script entity for execution.
  2. Script initialization: Pending scripts load at the start of the next simulation tick. Each script executes its top-level chunk once, binds signals, and exports Update and PhysicsUpdate hooks.
  3. Player connection: A client connects to the server. The engine fires Session.PlayerJoined(userId). A server script then spawns an avatar prefab and invokes Session.BindCharacter.
  4. Tick execution: The server runs its 60 Hz tick loop: invoking Update, running default systems, firing PhysicsUpdate, advancing the Box3D physics step, updating world transforms, and broadcasting a delta snapshot every second tick.
  5. Character death: When a character dies, Session.CharacterDied fires once. The physical corpse remains in the scene until a script explicitly destroys it or it drops past the world’s fall threshold.
  6. Disconnection: When a player disconnects, Session.PlayerLeft fires to capture any final player state before the server destroys the associated character entity.
  7. Entity cleanup: Entities reach the end of their lives through explicit calls to Destroy, an expired Lifetime component, or falling out of the world. Clients learn about the removal with the next state update, and the network ID is never used again for the rest of the session.

Entities#

Instantiation#

In Luduvo, there is no generic Instance.new. Introduce entities into the world through one of two mechanisms:

CallResult
game.Prefabs.Spawn(name)The root entity of a newly minted prefab instance along with its entire descendant hierarchy, or nil if the requested prefab does not exist. Query game.Prefabs.Exists(name) beforehand to check availability.
instance:Clone()A deep copy of the existing instance and all descendants. The resulting copy acts as a scene root until assigned a Parent.

Both methods allocate fresh 64-bit network IDs for every entity within the cloned hierarchy. Calling Clone duplicates all scene components (excluding the network ID and stable ID), copies custom components and the attr table, and reconstructs parent-child relationships across the new subtree.

Spawning a prefab copies a stored template into the world, so a 40-part character rig costs roughly the same as a single primitive part:

Luau
local crate = game.Prefabs.Spawn("Crate")if crate == nil then	warn("no Crate prefab")	returnendcrate.Position = self.Position + Vector3.new(0, 4, 0)crate.Parent = self

A spawned entity becomes active the moment Spawn returns. Its transform takes effect on the next physics step like any other scene write, and the physics engine gives it a rigid body once it sees the newly attached RigidBody and Shape components.

Hierarchy and world transforms#

In Luduvo, modifying Parent is the sole mechanism for altering scene hierarchy. Assigning an instance establishes a ChildOf relationship; setting Parent = nil removes that relationship, promoting the entity to a place root.

Reparenting an entity does not alter its local coordinates: Position and Rotation represent local transforms relative to the parent. The engine recalculates world transforms down the hierarchy after physics finishes and before the tick’s state goes out to clients. Reading Position immediately after reparenting returns local coordinates, not the updated world position.

The runtime rejects reparenting and throws an error in two scenarios:

  • If the entity or its current parent belongs to the platform (Parent: the entity belongs to the platform).
  • If the target parent belongs to the platform (Parent: the new parent belongs to the platform).

Network IDs and scope#

Entity network IDs are 64-bit integers in two ranges:

  • Server IDs: a counter (>= 1) that increments with every server-created entity and never repeats during a session, so a stale handle can never point at a different entity. Clients in streaming range are told about the new entity with the next state update.
  • Client-local IDs: IDs at or above 2^62, assigned when a client script spawns or clones an entity. They never travel over the network and never reach the server, so they cannot collide with server IDs. Use them for cosmetic objects such as visual particle debris or UI viewport models.

Attaching an entity to a player using BindCharacter marks the entity as Transient. Transient entities exist only for the duration of the active session and are excluded, along with their descendant subtrees, from place saves.

Scripts#

The loading process#

Scripts in Luduvo attach directly to entities. Whenever a script entity exists in the world at the start of a tick, the runtime initializes an execution instance:

compile errorerror thrownNew sandboxed instanceSandboxed globals onlyBind self and handlesEntity and named instancesLoad the codeSyntax errors stop hereRun top-level chunkUnder instruction budgetCapture frame hooksUpdate, PhysicsUpdate globalsload errorCreation abortsinit errorInstance discarded

The script sees sandboxed globals only. self is the owning entity and handles holds the named instances registered through the editor. A syntax or compilation error reports a load error and aborts instance creation. The top-level chunk then runs once under the instruction budget, and an error there produces an init error and discards the instance. A surviving instance ends with the runtime capturing the global Update and PhysicsUpdate functions, if the script defined them.

The top level is where scripts connect event signals, declare event tables, open persistence stores, and register ECS systems. Scripts may mutate the world during their initial load; however, the studio reload test rejects top-level writes so that code reloads do not trigger unintended side effects.

Frame callbacks#

The runtime provides two primary execution hooks:

  • Update(dt) runs once per server tick with the frame delta time before entering the fixed-step simulation loop.
  • PhysicsUpdate(dt) runs once per physics step with a constant dt = 1/60, executing immediately before the solver integrates forces and movement intents.

Both hooks run on client and server environments alike; however, registered ECS systems run exclusively on the server. For a detailed breakdown of the complete tick sequence, see The Simulation Tick.

Signal management#

Calling instance:GetSignal(name):Connect(fn) registers a connection on the target entity.

The engine provides no explicit Disconnect method; it releases connections under two conditions:

  • When the connecting script instance retires (its parent entity is destroyed, or an in-place code reload replaces it).
  • When the target entity leaves the world and tears down its event sinks.

Signal handlers run under the instruction budget of the script that created the connection. The Emit method accepts pure data only: attempting to pass a function or a coroutine raises an immediate error. Emitting a custom signal with no active listeners causes the engine to log a warning. Handlers execute synchronously inside the emit call, meaning changes made by one handler are immediately visible to subsequent listeners.

Instruction budgets and error handling#

To safeguard performance, every entry into a script environment is granted a budget of 1,000,000 VM instructions. This limit applies to Update, PhysicsUpdate, system phase dispatches, initial top-level loads, and signal handlers. If execution exceeds this budget, the runtime raises script exceeded its instruction budget and aborts execution. Because instruction counting is not an asynchronous scheduler, wrapping code in a pcall will not let an over-budget loop continue running in that tick.

When a callback throws a runtime error, the engine increments the instance’s consecutive error counter; returning successfully resets the counter back to zero. If an instance fails 5 times consecutively, the engine prints a notice ('<name>' disabled after 5 consecutive errors) and disables that script’s Update, PhysicsUpdate, and system callbacks until a reload replaces it. Errors inside signal handlers and data store callbacks do not increment this counter; their errors are logged to the console while the handler remains connected.

Hot reload#

Saving a script in the studio triggers an in-place hot reload. The runtime builds a trial instance of the new code while leaving the existing instance running. If the new script defines a Migrate(old) function, the runtime calls it to transfer state, committing the swap only if every instance associated with the updated asset passes successfully.

Lua state does not persist across a reload unless explicitly transferred via Migrate. In contrast, all ECS state survives untouched because the script never owned that data directly. Performing a successful reload also clears errors and revives previously disabled scripts.

Players and characters#

Player joins#

When a player establishes a connection, the engine fires game.Session.PlayerJoined(userId) on the next tick, when the server admits players, before physics runs. If no script has connected to this signal yet, the engine queues the join event (holding up to 64 pending joins) and fires it as soon as a handler registers.

The core engine does not spawn avatars automatically; the platform’s default PlayerSpawner script or custom server logic handles avatar creation:

Luau
local function spawnFor(userId)	if game.Session.CharacterOf(userId) ~= nil then		return	end	local character = game.Prefabs.Spawn("Cadet")	character.Position = pickSpawnPosition()	game.Session.BindCharacter(userId, character)endgame.Session.PlayerJoined:Connect(spawnFor)

Calling BindCharacter(userId, character) orchestrates several operations atomically:

  • Assigns session ownership, exposing the player via Identity and DisplayName.
  • Attaches the movement intent component that translates queued player inputs into physical locomotion at the start of every physics step.
  • Attaches HumanoidState and FloorInfo components if the prefab lacks them, and seeds the avatar’s visual appearance.
  • Flags the entity as Transient.
  • Notifies the client which entity it owns, so game.Session.LocalCharacter() resolves correctly on that client. A user can control only one character at a time. Attempting to bind a second character without destroying the first raises an error: user <id> already owns a character; destroy it before binding another.

Handling death#

Once per tick, before physics runs, the engine checks every entity that has Character and Health components and lacks a CharacterDead tag:

ConditionWhat happens
Health <= 0The character receives the CharacterDead tag. TakeDamage(amount, attacker?) records the responsible entity in LastHit.
Position.y < Physics.FallenPartsDestroyHeightThe engine zeroes out health, clears the attacker, and applies the CharacterDead tag.
Position.y is NaN or infiniteEquivalent to falling out of the world: health is zeroed with no attributed attacker.
health reaches zerobelow destroy heightinvalid transformnext tickcorpse past destroy heightAliveCharacterDeadDiedAnnounced

On the following tick, every newly deceased character receives the DiedAnnounced tag and triggers game.Session.CharacterDied(character, userId, attacker) once, where attacker identifies the last damage dealer (or nil). While animations halt immediately upon death, the physical body remains in the world; a corpse is only removed once it drops past the destroy height.

Respawning is entirely under script control. For instance, the default platform spawner waits respawnDelay seconds inside Update, calls Destroy on the old character, and spawns and binds a fresh prefab.

Player departures and kicks#

When a client connection terminates, game.Session.PlayerLeft(character, userId) fires while the character is still fully intact in the scene graph. Once the callback completes, the server destroys the character and its child hierarchy. If the disconnecting user never possessed a character, the character argument is nil. Read any persistent data from the character inside this callback; once it returns, the instance is deleted.

Disconnect a player programmatically using game.Session.Kick(userId, reason?). This queues a disconnect command and returns true, or returns false if passed an invalid ID (<= 0) or if the kick queue already holds 64 pending kicks. The server processes this queue during the next network tick, routing the kicked client through the exact same disconnection flow.

Destruction and cleanup#

The instance:Destroy() method runs exclusively on the server and is fully idempotent: invoking it on an already destroyed entity is a safe no-op. Calling it on a platform-owned entity raises an error: Destroy: the entity belongs to the platform.

Invoking Destroy on an active entity triggers the following teardown sequence:

platform entityDestroy calledServer only, idempotentSubtree removedDescendants go with the rootResources releasedBodies, joints, signals, scriptsClients told next updateID retired, never reusedDestroy errorEntity belongs to the platform

Destroying an entity removes its whole subtree: every descendant goes with the root. The components that leave the world release what they own: Box3D destroys any attached rigid bodies, welds and mechanical joints unbind, all signal connections referencing the entity disconnect, and attached script instances retire along with their registered callbacks. Clients learn about the removal with the next state update, and the network ID is retired. Network IDs are never reused during a session.

Once destroyed, the Lua handle becomes invalid. Any subsequent property or method access throws an error: attempt to use a destroyed Instance, and passing the entity to Session.BindCharacter fails with entity is destroyed.

The engine also triggers this teardown pipeline through two automated mechanisms:

  • Component lifetimes: The Lifetime gameplay component exposes a single configurable field, Remaining, measured in seconds (from 0 to 3600). The server subtracts the elapsed tick duration at the end of every tick and destroys the entity hierarchy when this timer reaches zero. Scripts do not interact with this component directly.
  • Fallen-parts cleanup: During every network tick, the engine scans all unanchored networked entities. Any part falling below Physics.FallenPartsDestroyHeight, or exhibiting a NaN or infinite Position, is destroyed. Active characters are skipped during this initial pass to allow their death flow to process normally; once their death has been announced, their constituent parts are reaped identically.

Data persistence#

Because entities disappear when a place shuts down, use persistent data stores to preserve state across server sessions. In Luduvo, user IDs serve as the universal key for player persistence.

Local stores#

Open a place-local store by calling game.Store.open(name). This server-only API returns a handle to a store that lives with the server and survives a restart.

Both store:get(key) (which returns the decoded Lua table or nil) and store:put(key, table) operate synchronously. The call store:put returns as soon as the value is stored, even if writing it to disk fails. Stored values support Lua tables containing numbers, booleans, strings, Vector3 values, and nested tables up to 8 levels deep and 64 KiB in encoded size. A single store holds up to 4,096 keys, and a place can open up to 8 distinct stores.

Cloud stores#

To share state across every server in an entire experience, call game.CloudStore.open(name). All operations on a cloud store are asynchronous: calls queue a request and return immediately, while the provided callback runs during a later tick - never in the middle of another script.

The cloud store API provides the following operations:

CallCallback signatureDescription
cloud:fetch(key, cb)cb(value, version, err)Retrieves a record. If the key does not exist, value is nil and version returns 0.
cloud:put(key, value, expectVersion?, cb?)cb(version, err)Saves a record. When supplying expectVersion, a version mismatch fails with err = "conflict".
cloud:update(key, transform, cb?)cb(version, err)Atomically fetches a record, invokes transform(value, version), and writes back the returned table using the fetched version as an optimistic concurrency check. If a conflict occurs, it refetches and retries up to 3 times. If transform returns nil, the update is aborted.
cloud:remove(key, cb?)cb(ok, err)Deletes a record. If the key was missing, ok returns false.
cloud:keys(after?, limit?, cb)cb(keys, err)Enumerates keys in ascending order; limit defaults to 100.

Keys can be integer values or strings ranging from 1 to 50 printable characters. Values support the same types as local stores, but with an expanded payload ceiling of 4 MiB. A single script state can maintain up to 32 pending cloud requests simultaneously; issuing a 33rd raises cloud store request queue is full. The server operates under a global rate budget of 60 + 10 requests per minute per connected player, with up to 30 requests concurrently in flight. In the studio the cloud store runs locally, so persistence can be tested offline.

Loading on join, saving on leave, and checkpoints#

Here is the usual pattern for managing player data throughout a session:

Luau
local cloud = game.CloudStore.open("PlayerData")local loaded = {}game.Session.PlayerJoined:Connect(function(userId)	cloud:fetch(userId, function(data, version, err)		if err ~= nil then			warn("load failed for " .. userId .. ": " .. err)			return		end		loaded[userId] = data or { gold = 0 }		spawnFor(userId)	end)end)game.Session.PlayerLeft:Connect(function(character, userId)	local data = loaded[userId]	loaded[userId] = nil	if data ~= nil then		cloud:put(userId, data)	endend)

Trigger character spawning from within the fetch completion callback rather than directly inside the initial PlayerJoined handler. This guarantees that a character never enters the simulation with incomplete or uninitialized data.

Because PlayerLeft executes while the character is still alive in the scene, scripts can safely copy any final state out of the character before the handler concludes. A put queued during player departure completes on a subsequent tick. Write checkpoints with cloud:update during critical state transitions rather than deferring to player disconnection. Its compare-and-swap loop prevents concurrent servers from overwriting updates to the same player record.