Luduvo Contents
Theme
On this page

The Server Tick and Physics

A server advances its world at three different rates. Scripts run once per frame, the physics solver requires a delta time that never changes, and replication requires packets often enough to show continuous motion and few enough to fit a home connection. The server tick reconciles those three rates. It runs at 60 Hz, and the stages on this page all run inside one tick.

This page covers the internals: the order of the stages, the accumulator that drives the fixed-step loop, what Box3D does during the physics step, and what the tail of the tick does afterwards. If you are looking for the script-facing API instead, read The Simulation Tick and Entity and Script Lifecycle.

Simulation runs at 60 Hz and every physics step spans exactly 1/60 s, never a measured frame time. Replication runs at 30 Hz as a consequence of that rate: every second tick ends by packing and broadcasting a snapshot, so the 30 Hz figure holds for as long as the server sustains its 60 Hz tick. The client renders as fast as the display hardware allows and feeds the elapsed real time into an accumulator that steps its local simulation at the same fixed 1/60 s cadence. The step count follows elapsed time rather than frame count, so a crate integrates 60 steps per second of real time, minus the remainder left in the accumulator, on a machine drawing 144 frames per second and on one drawing 40.

One tick end to end#

The first tick runs once the place file is loaded. Loading it assigns a 64-bit network ID to every entity in the file and queues every script entity for execution; no script code has run yet. The tick then opens by loading those pending scripts, which is also how a script saved in the editor reaches the running world: it waits in the queue until the start of the next tick.

The stage order follows from three constraints. Gameplay code sets its values before the solver integrates, so all script work that runs at frame rate runs first; the solver reads gravity, the sleep threshold and the collision matrix out of the physics world rather than off each body, so those are pushed down immediately before it; and snapshot packing reads world transforms, which have to be recomputed from the bodies the solver just moved. That fixes the stage order:

Scripts and gameplayPhysics and tail1 Script loadingPending scripts load2 Update hookFrame delta per instance3 Default systemsWorld.System Default phase4 Player joinsFires Session.PlayerJoined5 Death verificationHealth, height, transform6 Physics configurationGravity, sleep, collisions7 Physics stepPhysicsUpdate, then Box3DFixed 1/60 s, 4 substeps8 Hierarchy updateTransforms down the tree9 Event cleanupEventTable rows cleared10 Lifecycle and networkingWelds, lifetimes, delta

After the pending scripts load, Update(dt) runs on every active script instance with the frame delta, then the systems registered with World.System(name, query, fn, "Default"). Player joins come next: the engine processes everyone who connected since the last tick and fires their Session.PlayerJoined events. Death verification then scans the entities that carry Character and Health without a CharacterDead tag for lethal conditions: health at zero, a position below Physics.FallenPartsDestroyHeight, or an invalid transform. Physics configuration closes the gameplay half by pushing Physics.Gravity, the World component’s SleepVelocityThreshold and the collision matrix into the active physics world.

Systems run in their own stage rather than inside the per-instance hook, because of how the engine passes their data. A query does not call back once per entity: it passes the matched components as packed columns, q.count alongside q.Entity[i] and q.Position[i], each column a contiguous array indexed from 1 to q.count, so a single loop over them reads memory sequentially. The engine crosses into a script environment once per query dispatch; a per-instance callback crosses once per matched entity.

The two hook families differ in scope. Both Update and PhysicsUpdate run on the client as well as the server, but registered ECS systems run on the server only, so a World.System callback never fires in a client script.

For a script author the practical consequence is ordering. Values written in Update reach the solver in the same tick, assuming the accumulator holds at least one whole 1/60 s that tick. The write-back that ends the physics step runs after Update has returned, so the earliest Update that observes an integrated position is the next tick’s. The hooks themselves are documented in The Simulation Tick.

The fixed-step loop#

Real time does not arrive in 1/60 s pieces. A tick may take 11 ms or 25 ms, and if we handed that measured number to the solver as its delta time, the same stack of crates would settle differently depending on how busy the server was that second. So the measured time is added to an accumulator instead, and the loop runs one pass for every whole 1/60 s the accumulator holds, subtracting 1/60 s per pass. What is left over is under 1/60 s and stays in the accumulator for the next tick. A tick that ran long therefore runs the loop more than once, and a tick that arrived early does not run it at all. Nothing in that rule bounds how many passes one tick may run, and the passes themselves are part of the time the next tick measures, so a tick whose passes cost more real time than they consume raises the pass count of the tick after it.

Every pass runs the same stages in the same order. Queued player input is converted into each character’s move intent. PhysicsUpdate(dt) runs on every script instance with a constant dt = 1/60. The systems registered with the "Physics" phase run. Then the physics step itself closes the pass.

That order is why forces belong in PhysicsUpdate: it runs immediately before integration, so a force applied there is still accumulated on the body when the solver reads it, and the dt passed to it is the same number the solver uses. The client runs the same loop against its own accumulator, so its prediction integrates the same constant 1/60 s steps rather than a frame time. Matching the server value for value would also require both machines to produce identical floating-point results for the same operations, which the client does not assume: it reconciles its predicted state against the authoritative snapshot, described in Server Authority and Replication.

The limit an author hits here is that PhysicsUpdate and Update do not run the same number of times. Update runs once per tick, while PhysicsUpdate runs once per pass, and the number of passes is the number of whole 1/60 s intervals the accumulator held that tick, which can be zero. So per-frame counters belong in Update and per-step forces belong in PhysicsUpdate.

What Box3D does in a substep#

The physics step includes more than the solver. It starts by flushing the transforms scripts wrote during the pass. Writing Position or Orientation, or calling Rotate, marks the entity dirty rather than moving its rigid body at the point of assignment; the flush copies the transform onto the body at the start of the next physics step, before the first substep. Read Position back inside the same callback and you get the value you just assigned, because the integrated transform only enters the component at the write-back stage that ends the step. The step itself then runs in this order:

1 Flush dirty transformsScript writes reach bodies2 Floor and locomotionCharacters push their floor3 Box3D, 4 substepsVelocity and position passes4 Skeletal animationPoses advance one step5 Write backPosition, Rotation, Velocity

With the bodies up to date, characters evaluate their floor geometry and apply their locomotion forces, which turns a move intent produced at the top of the pass into forces the solver integrates. Box3D then advances the 1/60 s step as 4 sequential velocity and position substeps rather than one 1/60 s step. Each substep solves against the positions the previous substep produced, so a box resting on a box resting on the ground is solved four times with contact data recomputed per substep instead of once. The solver is iterative, so this reduces the residual penetration and constraint error one large step leaves rather than producing an exact solution. Skeletal animations advance after the solver, and the step ends by writing body transforms and linear and angular velocities back into the Position, Rotation and Velocity components.

The substeps run on top of the broadphase. It stores an axis-aligned bounding box per active body and derives a collision pair list from those boxes; the detailed contact tests only run on pairs taken from that list. A static body does not move, so its bounding box stays valid and its pairs need no recalculation for as long as its motion type does not change.

The physics queries also avoid per-call heap allocation. Physics.OverlapBox and Physics.OverlapSphere write their matches into an internal fixed buffer of up to 64 entity handles rather than allocating one per call, and each body appears in it at most once. That buffer size is the cap: a query whose region contains more than 64 distinct bodies returns 64 and drops the rest, and which 64 it keeps is not documented. The signatures are in The Simulation Tick.

For an author this means a transform you assign reaches the body at the flush stage of the next physics step rather than at assignment, and the value you read back before then is the one you wrote.

Sleeping and waking#

Most bodies in a world are at rest. A hundred crates that have not moved in ten seconds would still cost contact solving in every substep, repeating the same resolution each time. So the engine takes them out of that work: a body whose linear speed drops below the World component’s SleepVelocityThreshold, 0.6 studs/s by default, goes to sleep and leaves the active solver set. The comparison is against linear speed, and no angular term is documented, so a body rotating in place with a linear speed under the threshold meets the sleep condition.

The threshold is not stored per body. It is pushed into the physics world during physics configuration, which runs after Update and before the first pass, so a value written from Update takes effect in the same tick, and one written later in the tick, from PhysicsUpdate for instance, takes effect in the next. It is a property of the world, so it applies to every body at once.

A dynamic body moves between awake and sleeping like this:

speed below sleep thresholdimpulse, velocity or transformAwakeSleeping

Waking takes a write: an impulse, a velocity write, or a transform write returns the body to the active set, and those are the wake paths the engine documents. Physics.IsSleeping(e) reports the current state at any time.

A force accumulated on a sleeping body is not integrated while the body stays asleep, because integration happens in the solver work it has been taken out of. To move a sleeping body, wake it with an impulse or a velocity write first.

Anchored parts#

An Anchored part is a static body, and static bodies are the cheapest case for the broadphase: their bounding boxes do not change, so their pairs are not recalculated. A transform write from Lua breaks that condition. Rather than reject the write, the engine promotes the static body to a kinematic one and drives it toward the target transform. If no further write arrives for 8 consecutive frames, it demotes the body back to static; those are frames of the tick loop rather than substeps, so the demotion lands about 0.13 s after the last write, assuming the server holds its 60 Hz tick.

The motion type therefore follows the writes it receives:

script writes transform8 frames without writesStaticKinematic

Both transitions rebuild broadphase state. Promotion rebuilds the collision pair list for the bodies near the part, and the demotion 8 frames later rebuilds those pairs again. A part written once costs two rebuilds; a part written at least once every 8 frames does not demote, so it costs no further rebuild after the promotion; a part written in bursts costs two rebuilds per burst. Those counts are the rebuilds this one part triggers, assuming no other body near it changes motion type. Promotion is intended for one-off placement.

For continuous animation, set the motion type explicitly instead. Unanchoring the part and setting its motion type keeps it kinematic with no promotion or demotion:

Luau
part.Anchored = falsepart:SetBodyMotion("kinematic")

Switching to "kinematic" zeroes the body’s linear and angular velocity, so a part converted mid-flight keeps the transform it had at that moment and moves only where later transform writes put it.

After physics#

Physics moved the bodies and the write-back stage copied their transforms into Position and Rotation, which hold coordinates local to the parent. So the first thing the tail does is walk the parent-child hierarchy from the roots down, recomputing each entity’s world transform from its parent’s already recomputed one. This is why Position and Rotation read back as local coordinates immediately after a reparent: the world transform for that entity is not recomputed until this stage runs. Running it after integration also means the values the tail is about to pack are the ones the solver just produced, not last tick’s.

Event cleanup follows and clears this tick’s inbound EventTable rows. Rows persist for exactly one tick, which is what makes an event table safe to read with a plain loop: whatever arrived is present for the whole tick and cleared at this stage, so the table does not grow across ticks and there is nothing to unsubscribe.

The tick then processes welds, lifetimes and death notifications, and on every second tick packs all dirty state into a delta snapshot and transmits it to connected clients. That is where the 30 Hz replication rate comes from: not a separate timer, only the tail of every other 60 Hz tick. The wire format is covered in Server Authority and Replication.

For an author this bounds replication latency. A change made in Update is packed by the tail of the same tick when that tick is a packing tick and by the tail of the next one otherwise, so it waits at most one extra tick, about 17 ms at 60 Hz. A change made after the tail has run this tick waits for the packing after that. What Destroy does to an entity, and when its removal reaches clients, is in Entity and Script Lifecycle.