Events
A script that runs once at startup cannot react to anything. Scripts have to observe user input, physics collisions, clock ticks, and incoming network packets while the simulation runs.
Luduvo offers three event mechanisms:
- Lifecycle functions: callbacks the engine invokes each frame or each physics step.
- Signals: in-process dispatchers between scripts on the same machine.
- EventTables: typed tables that carry structured rows across the client-server boundary.
How the engine dispatches and ships these inside: Replication and Events.
Signals compared to EventTables#
Signals stay inside one process; EventTables cross the network. The two differ in every other respect as well:
| Dimension | Signal | EventTable |
|---|---|---|
| Execution scope | In-process only (never crosses the network) | Inter-process (Client to Server) |
| Delivery timing | Immediate synchronous dispatch within the tick | Queued into network packet, delivered on the next tick |
| Data structure | Callback functions you connect | Typed columns you read by row index |
| Payload constraints | In-memory Luau tables (max 16 deep, 4096 nodes) | Fixed schema types (U8, I32, F32, Vec3, etc.) |
| Lifecycle | Permanent until disconnected or instance destroyed | Transient: rows live for one tick, cleared at tick end |
Lifecycle functions#
Lifecycle functions are specialized callbacks defined at the top level of any script. The engine’s simulation loop automatically picks them up and invokes them:
-- Runs once every rendered frame (variable delta dt)function Update(dt) self.Rotation = self.Rotation + Vector3.new(0, dt * 90, 0)end-- Runs once every fixed 1/60 s physics step (constant dt = 1/60)function PhysicsUpdate(dt) self:ApplyForce(Vector3.new(0, 50, 0))endChoosing between Update and PhysicsUpdate#
Update(dt): The engine invokes this during the logic phase of the simulation tick. UseUpdatefor general gameplay logic, UI updates, timers, camera interpolation, and processing rows that arrived viaEventTable.PhysicsUpdate(dt): The engine invokes this callback immediately before Box3D steps the physical world using a fixed time step (constant dt = 1/60 s). When applying forces (ApplyForce), setting linear velocities, or calculating character motor physics, place that logic insidePhysicsUpdatefor deterministic physical simulations.
The Simulation Tick shows where these callbacks slot into the frame loop.
Signals#
A Signal is an in-process publish-subscribe channel. When an event fires, the signal walks its list of callback closures and invokes them synchronously, in place:
-- Connect a callback to a GUI button's Activated signal (client only)button.Activated:Connect(function() print("Button clicked by local player!") -- Emit a custom named signal on this instance self:GetSignal("DoorTriggered"):Emit("PlayerApproached")end)-- Connect to a service-level signal (server only)game.Session.PlayerJoined:Connect(function(userId) print("Player joined the server:", userId)end)Key characteristics of Signals#
- In-process only: Signals never cross the network wire. Emitting a signal on the client fires callbacks exclusively on that local machine; emitting a signal on the server fires callbacks strictly on the server.
- Custom signals via
GetSignal:instance:GetSignal(name)returns a namedSignalobject. Attach listeners withsignal:Connect(fn)and broadcast events by callingsignal:Emit(...). - Payload constraints: Arguments to
Emitmust be pure data. Functions, coroutine threads, and raw engine userdata are rejected. Tables cannot nest deeper than 16 levels or hold more than 4,096 nodes, which keeps a bad payload from overflowing the stack.
EventTables#
Client and server run as separate programs, usually on separate machines across the internet. Neither side can call the other’s functions or fire a signal across the wire. EventTables carry events between them.
Declaring a table#
An EventTable carries rows of a fixed shape. Declaring the table fixes its fields and their types up front, and every row pushed through it carries exactly those fields:
local ET = game.EventTable-- Define an EventTable named "ToolEquip" directed from Client to Server-- Schema fields: slot (U8)local ToolEquip = ET("ToolEquip", ET.ToServer, { { "slot", ET.U8 }})Column types include F32, I32, U8, Bool, Vec3, Color, Entity, and String.
Client-to-server replication flow#
To push an action from the client to the server, invoke Push:
-- Hotbar.client.lua (Runs on client)slotButton.Activated:Connect(function() -- Stages a row for the next replication tick ToolEquip:Push(selectedSlotIndex)end)The server drains the batched rows in its logic update:
-- Tools.server.lua (Runs on server)function Update(dt) -- Process all rows that arrived this simulation tick for i = 1, ToolEquip.count do local senderId = ToolEquip.sender[i] local slot = ToolEquip.slot[i] applyToolEquip(senderId, slot) endendInstead of firing one callback per event, the server handles every row that arrived in a single loop.
The network tick lifecycle#
A row travels through the pipeline:
Calling ToolEquip:Push(...) stages the row on the sending side; nothing
leaves the machine at that moment. At the 30 Hz network replication tick, the
engine packs every staged row into the outgoing snapshot and sends it.
The rows surface in the next Update(dt) on the receiving side. ToolEquip.count reports exactly how many rows arrived, and each field is a
column read by row index: ToolEquip.slot[i], ToolEquip.sender[i]. The rows
are transient: they exist only for the duration of the tick in which they
arrive, and the engine clears them at the end of that tick. A row that no Update reads in that tick is gone.
Directionality: ToServer vs. ToClients#
ET.ToServer: Pushed by clients and processed on the server. On the server side, the engine automatically injects the authenticated player’suserIdintotable.sender[i]. Because the engine assigns this value authoritatively, clients can never spoof or tamper with thesendercolumn.ET.ToClients: Pushed by the server and received by clients. The server can broadcast to all connected clients usingtable:Push(...), or target an individual player viatable:PushTo(userId, ...).
Network limits and guardrails#
The engine caps every EventTable so that traffic stays predictable and a hostile client cannot flood a server:
- Max 16 fields per table schema.
- Max 256 bytes per individual serialized row.
- Max 64 rows per table per tick.
- Max 64 EventTables registered per place.
- Bandwidth guardrail: A client that pushes more than 16 KiB of event rows in a single tick, or sends a malformed wire buffer, gets disconnected immediately.
Server Authority and Replication covers client prediction and state synchronization.