Luduvo Contents
Theme
On this page

Replication and Events

Two machines hold one shared world state, and the wire between them adds latency a player can see. This page covers how Luduvo resolves that: which side is authoritative, what the client predicts, how a world state turns into bytes, and how script-level events get from one address space to the other.

The tick rates fix the timing of everything below. The server simulates at 60 Hz and packs a snapshot on every second tick, so snapshots leave the server at 30 Hz, one per 1/30 s or 33.3 ms. Arrival spacing is less regular than the send spacing, because the wire adds jitter, and the client covers the interval between snapshots itself.

This is the engine-side view. For the script-facing rules - which method errors on which side, which properties are client-only, how to declare a table - read Server Authority and Replication and Events. This page covers what the engine does underneath them and why it was built that way.

Server authority#

The cheap way to network a game is to let every client simulate its own character and broadcast the result. That costs almost nothing to write, but it trusts the client: an edited packet puts the character wherever the client says it is, with whatever health it claims.

So the server is authoritative for all of it. It runs the physics, computes and applies damage, spawns and destroys entities, and transmits the resulting state changes to connected clients 30 times a second. A client renders what it receives and sends input, not resulting state.

The two rates exist for different reasons. Physics runs a fixed 1/60 s step because contacts and constraints are only stable when the step size is fixed. Serialization exists to keep bandwidth bounded. Packing a delta on every second tick is the compromise: the solver keeps its 60 Hz and the packet count halves. The byte count falls by less than half, because a delta spanning two steps carries the change accumulated over both.

For a script author this is visible as an error message rather than a rate. Destroy, ApplyForce, TakeDamage and the rest of the mutating calls do not exist on the client, because a client that could call them would violate server authority by trusting the client. Server Authority and Replication lists which call errors on which side.

Prediction and reconciliation#

A client that waited for the server before moving the local character would delay every keypress by the round trip plus up to one 33.3 ms snapshot interval. Input latency is directly visible to the player, so the client predicts instead of waiting for the snapshot.

The client predicts exactly one entity: the local character. On each 1/60 s physics step it samples local input, steps its own copy of the character, and writes both the input and the predicted physics state - torso position, rotation, linear velocity and angular velocity - into a 128-entry circular ring buffer. The same input goes out to the server as an input packet. At 60 Hz, 128 slots cover 128/60 s, that is 2.13 s of history, after which the oldest slot is overwritten by the newest step.

Then the authoritative snapshot arrives, carrying the server’s version of a step the client simulated at least one round trip earlier. Comparing the two gives a position error and a velocity error, and their magnitudes select what the client does about it:

loop [every 1/60 s step]alt [error over 4 studs][over 0.01 stud or 0.1 studs/s][within both thresholds]Input and state into the ringInput packetAuthoritative snapshot, 30 HzSnap to the server positionRoll back, replay up to 90 stepsKeep the predictionClientServer

The branches are exclusive and tested in that order: the 4-stud test first, then the tolerance band. The middle branch is the ordinary correction path. Once the torso position deviates by more than 0.01 studs, or velocity by more than 0.1 studs/s, the client resets to the authoritative state and replays the inputs recorded after that step out of the ring, up to 90 physics steps, to return to the present moment. Assuming the two thresholds are compared against the error magnitude rather than per axis, either one alone triggers the replay.

Ninety steps is the cap, not the buffer length: the ring holds 128 steps, so the replay bound is reached first and the client never has to replay from a slot that has already been overwritten.

The thresholds exist because replay costs CPU time. At the cap, 90 steps replayed against 30 snapshots a second is 2700 physics steps per second on top of the 60 the client already runs; that is the worst case, not the steady one. Predicted and authoritative state are not expected to agree bit for bit after a round trip, so a tolerance band is required: inside it the prediction stands and no replay runs.

The top branch is the fallback. Past 4 studs the client treats the divergence as a stall, a teleport or a desync rather than drift, and replaying from the corrected state would reproduce the divergent path, so it snaps to the server position instead.

Remote entities are never predicted. The runtime interpolates them between incoming snapshots with a delay tuned dynamically between 2 and 8 network ticks, which at 30 Hz is 66.7 to 266.7 ms of deliberate delay; that delay bounds how late a snapshot may arrive before the interpolator runs out of samples.

What a script author observes: the local character responds on the next 1/60 s physics step, within 16.7 ms and without waiting for a round trip, and corrects itself when a snapshot disagrees, while every other entity is smooth but 2 to 8 network ticks behind the last snapshot. game.Session.LocalCharacter() is the entity on the predicted path; everything else is interpolated, and Server Authority and Replication says what each side is allowed to write.

What goes on the wire#

Sending a full world state 30 times a second would spend most of the packet on unchanged data: most of a world is not moving, and most of what is moving has not moved much. So the server sends a binary delta against the client’s last acknowledged packet, and the unchanged bytes are omitted entirely. What stays in the packet:

Entity deltasChanged bytes onlyAgainst last acked packetQuantized positions1/1024 stud stepMotion inside one step writes nothingPacked rotations32-bit quaternion4 bytes instead of 16EventTable columnsStaged rows packed denseOne array per field

The baseline is the last packet the client acknowledged, not the last one the server sent. A dropped packet or a dropped acknowledgement therefore does not desynchronize the client: the server keeps encoding against the older acknowledged state, and the next delta covers everything that changed since that state. The cost of the loss is a larger delta, and it stays larger until an acknowledgement advances the baseline.

The quantizations account for most of the savings. Position deltas are quantized to 1/1024 stud, a step of about 0.00098 stud, and a field is written only when its quantized value changes. Motion that stays inside one quantization step therefore adds no bytes, though the comparison itself still runs over every entity on every second tick. Sub-step motion is not unconditionally silent either: an oscillation that straddles a bucket boundary alternates between two codes and writes a delta on each crossing.

A character rotation travels as a 32-bit packed quaternion, 4 bytes against the 16 that four raw f32 components occupy, on data that changes on nearly every step of nearly every character. Holding it as one 32-bit scalar also keeps the delta comparison a single word compare. The precision follows from the bit budget: a smallest-three layout spends 2 bits on the index of the dropped component and 10 bits on each of the three it stores, so a component is quantized in steps of about 0.0014 across the range -0.707 to 0.707, which puts the angular resolution near a tenth of a degree. That is coarse against f32, and it is fixed regardless of how fast the character turns.

Entities are included in this stream when they have a network id, and what replicates is the core entity state - transform, hierarchy, physics properties, appearance, character components, tags - plus the sparse attr table for anything without a declared schema. With Streaming.Enabled set, an entity also has to be inside that client’s view radius to appear in that client’s packet. Server Authority and Replication lists the full set. Keeping arbitrary key-value data in Attr rather than in the core record is what keeps that record fixed-width and contiguous, so the delta pass over every entity on every second tick compares fixed offsets in sequence instead of walking a per-entity hash table.

Deletions do not fit that scheme - a record that no longer exists writes no bytes - so they travel in their own array. When an entity is destroyed, the change tracker appends its 64-bit network id to a deletion array, together with the entity’s client visibility mask, and the tombstone persists for one tick. The next delta packet carries the removal to the clients the mask names, and the id is then retired: network ids are never recycled during a session, so a stale reference on a client cannot later resolve to a different entity.

For a script author the practical consequence is that a property write which does not change the stored value adds nothing to the packet - it still costs the write and the comparison - and that a Position jittering inside one 1/1024 stud bucket is invisible to everyone else, while one jittering across a bucket boundary is not.

EventTables as columns#

Snapshots keep state in sync. Gameplay also uses messages - a client equipping a slot, a server announcing a hit - and those never travel as signals, because a signal is a list of Luau closures in one address space and closures do not serialize.

Many multiplayer engines send a remote procedure call here: one message per event, each one serializing a key-value dictionary or a JSON blob. Under load that fragments memory and spends most of its time inside the serializer, and the receiving side fires one script closure per event on top of that.

An EventTable inverts the layout. The schema is fixed when the table is declared, so the width of every field is known before any row exists, and it stores the rows as contiguous typed column arrays - a structure of arrays: one array per field, element i of every column belonging to row i, each column advancing at the stride of its own field type.

Luau
local ET = game.EventTablelocal ToolEquip = ET("ToolEquip", ET.ToServer, {	{ "slot", ET.U8 }})

Calling ToolEquip:Push(slotIdx) appends the arguments into a local staging batch buffer; nothing leaves the machine at that moment. At the 30 Hz replication tick boundary the engine packs every staged buffer into its dense column arrays, serializes them, and compresses them into the outgoing snapshot alongside the entity deltas. One packet per tick, not one packet per event.

The receiving side unpacks straight back into column buffers, and the drain loop reads them by index:

Luau
function Update(dt)	for i = 1, ToolEquip.count do		applyEquip(ToolEquip.sender[i], ToolEquip.slot[i])	endend

The loop enters no closure per row and allocates no table per event, and each column is one contiguous run: ToolEquip.slot is a U8 array and ToolEquip.count is how many of its entries are live, so the reads advance at a fixed stride. Assuming a column base is 64-byte aligned, a U8 column packs 64 rows into a single 64-byte cache line, which is the whole per-tick row cap in one line, fetched by one miss and then resident.

Density is not latency. A row read across n columns touches n separate allocations, so a drain that reads every column inside one iteration costs up to one miss per column on the first rows, until the prefetchers pick up the parallel streams. The layout pays best when a pass reads one column end to end; a loop that reassembles a row from every column per iteration is an array of structures again, with the columns scattered.

Field types are F32, I32, U8, Bool, Vec3, Color, Entity and String. All except String are fixed-width, which is what lets a column be addressed as base plus index times width. A String column has no constant stride, so it costs an indirection per row rather than an offset computation; a schema that carries an id or an enum instead keeps every column addressable by arithmetic.

The sender column is written by the engine, not by the pusher - it takes the authenticated user id of the connection the row arrived on, so a pushed row cannot set it to another user - and only the server reads it.

Rows live for exactly one tick. In the event cleanup stage, which runs after the hierarchy update near the tail of the tick, the engine resets every inbound batch buffer back to zero. The buffers are therefore allocated once and reused for the life of the place, and a row that no Update reads during the tick it arrives in is unreachable afterwards. Events has the API and the direction rules.

The limits and their reasons#

Every EventTable is capped, so that traffic stays predictable and a hostile client cannot flood a server. The guide states the budget as:

  • Up to 16 fields per table
  • A maximum of 256 bytes per row
  • At most 64 rows per table per tick
  • A maximum of 64 distinct tables per place
  • If a client exceeds 16 KiB of row data within a single tick or transmits a malformed payload, the server immediately drops the connection

These network constraints multiply to form a single overall budget.

Up to 16 fields per table. The schema is fixed at declaration and every field becomes its own column buffer, allocated once and reused for the life of the place. The cap bounds the column buffers one declaration allocates at 16 and, taken with the 256-byte row cap, bounds the average serialized field to 16 bytes.

A maximum of 256 bytes per row. The cap is on the serialized row, so the unpacker knows the largest row it can be asked to decode before it reads one. A row of fixed-width fields reaches it only by averaging 16 bytes across all 16 columns, which the scalar types (U8, Bool, F32, I32, Entity) sit far below, so the cap is reached in practice by a row carrying String data.

At most 64 rows per table per tick. 64 rows at 256 bytes is exactly 16 KiB of serialized row data, the same figure as the per-client guardrail, counting rows alone and no packet overhead. One table, pushed to its row limit with maximum-size rows, accounts for the entire per-tick allowance on its own. The row cap keeps a runaway Push loop inside one table’s share of that allowance instead of letting it consume what the other tables need.

A maximum of 64 distinct tables per place. Tables are declared at load, and the engine holds their column buffers resident rather than allocating per packet. The cap bounds that resident set: 64 tables at 16 columns each is at most 1024 column buffers per place, an allocation that can be made once at load and never revisited.

16 KiB in a tick. The other caps bound each table on its own, and a place sitting at all of them at once would be 64 tables times 16 KiB, exactly 1 MiB of client-to-server row data per tick from a single client. The per-client guardrail is what bounds that: cross 16 KiB of row data in a tick, or send a malformed wire buffer, and the connection is dropped. There is no throttling step in between, so the server never queues the excess. At 30 Hz, 16 KiB per tick is 480 KiB/s, that is 3.9 Mbit/s of upstream from a single player; the figure is the ceiling at which traffic is treated as an attack, not a rate gameplay rows are expected to reach.

A script author meets these as hard limits and, in the last case, as a dropped connection, rather than as a server that gets slower under load; the budget is fixed in advance to produce that behaviour. Server Authority and Replication covers the same limits from the API side.