Simulation / Presentation Split
Core rule: game-state mutations happen in the Simulation loop (
FixedUpdate). Rendering, VFX, and UI happen in the Presentation loop (Update/LateUpdate). Never mix the two.
Three-Loop Architecture
| Loop | Unity Callback | Update Groups | Typical Work |
|---|---|---|---|
| Simulation | FixedUpdate |
perTimeStepUpdateSystem (every tick), perThreeTimeStepsUpdateSystem (every 3rd tick) |
Physics, game logic, state mutations, network tick processing |
| Presentation | Update |
perFrameUpdateSystem (every frame), perSecondUpdateSystem (once/sec) |
Rendering, animations, VFX, UI, audio, interpolated visuals |
| Late Presentation | LateUpdate |
perFrameUpdateSystem (every late frame), perSecondUpdateSystem (once/sec late) |
Camera follow, final visual adjustments |
Timing Diagram
gantt
title Three-Loop Timing
dateFormat X
axisFormat %s
section Simulation
FixedUpdate tick :a1, 0, 2
FixedUpdate tick :a2, 4, 6
FixedUpdate tick :a3, 8, 10
FixedUpdate tick :a4, 12, 14
section Presentation
Update (render) :b1, 2, 8
Update (render) :b2, 10, 16
section Late Presentation
LateUpdate :c1, 8, 10
LateUpdate :c2, 16, 18
Interpolation Between Ticks
Simulation runs at a fixed rate; presentation runs at the display refresh rate. Bridge the gap with interpolation.
// Alpha value: how far we are between the last tick and the next
public static float updateInterpolationAlpha
=> Mathf.Clamp01(accumulatedDeltaTime / FixedDeltaTime);
// In Presentation loop — smooth between two simulation snapshots
float alpha = SimulationTime.updateInterpolationAlpha;
var renderPosition = Vector3.Lerp(previousPosition, currentPosition, alpha);
var renderRotation = Quaternion.Slerp(previousRotation, currentRotation, alpha);
| Do | Don't |
|---|---|
Store previousPosition at the start of each tick |
Read transform.position directly in Update for game logic |
| Lerp/Slerp in Presentation only | Mutate authoritative state in Update |
Use updateInterpolationAlpha for smooth visuals |
Assume fixed and variable dt are the same |
ExecutionMode Flags
Controls which code paths run for a given entity based on its role in the network topology.
namespace PixelEngine.Simulation
{
[Flags]
public enum ExecutionMode
{
None = 1 << 0,
Input = 1 << 1, // Collects player input
Simulation = 1 << 2, // Runs game-state logic
Presentation = 1 << 3, // Renders visuals / audio
}
}
Per-Role Execution Table
| Role | Input | Simulation | Presentation | Notes |
|---|---|---|---|---|
| Local Player | Yes | Yes | Yes | Full authority — predicts, simulates, and renders |
| Remote Player | No | No | Yes | Presentation only — interpolates replicated state |
| Hosted Player | No | Yes | Yes | Server-side entity that also renders (listen server) |
| Dedicated Server | No | Yes | No | Headless — no rendering, no audio |
Usage Example
public void Tick(ExecutionMode mode)
{
if (mode.HasFlag(ExecutionMode.Input))
CollectInput();
if (mode.HasFlag(ExecutionMode.Simulation))
RunSimulation();
if (mode.HasFlag(ExecutionMode.Presentation))
UpdateVisuals();
}
Burst-Compiled Tick Jobs
Performance-critical simulation work (physics queries, spatial hashing, state rollback) should use Unity's Burst compiler via IJobEntity or IJobChunk. Keep Burst jobs in the Simulation loop only — Presentation code typically does not need Burst.
[BurstCompile]
public partial struct MovementTickJob : IJobEntity
{
public float DeltaTime;
public void Execute(ref Position pos, in Velocity vel)
{
pos.Value += vel.Value * DeltaTime;
}
}
Quick Rules
| # | Rule |
|---|---|
| 1 | Never mutate game state in Update or LateUpdate. |
| 2 | Never read Time.deltaTime in FixedUpdate — use Time.fixedDeltaTime. |
| 3 | Always interpolate between ticks for smooth visuals. |
| 4 | Always guard code paths with ExecutionMode flags. |
| 5 | Always store previous-tick state before advancing simulation. |
| 6 | Use Burst for hot-path simulation jobs. |
| 7 | Keep Presentation code free of side-effects on authoritative state. |