Quick answer: Multiplayer desync is caused by divergent game state between the server and clients, or between peers in a peer-to-peer architecture.

Learning how to debug multiplayer desync issues in games is a common challenge for game developers. Two players are in the same match. On one screen, the enemy is standing still. On the other, it is charging forward. Neither player is wrong — their simulations have diverged, and now the game is playing two different realities. This is desync, and it is one of the hardest problems in multiplayer game development. This guide walks through the tools and techniques that make desync debuggable, from state hashing to replay comparison to deterministic simulation.

Understanding Why Desync Happens

Desync occurs when the game state on one machine diverges from the game state on another. In a client-server architecture, this means the client’s predicted state no longer matches the server’s authoritative state. In peer-to-peer lockstep, it means two peers executed the same tick and arrived at different results. Either way, the root cause falls into a small number of categories.

Floating-point non-determinism is the most common culprit. IEEE 754 floating-point arithmetic is not guaranteed to produce identical results across different compilers, optimization levels, or CPU architectures. A physics simulation that uses float for position and velocity will drift across machines, sometimes within seconds. Input ordering matters too: if two players submit actions on the same tick but their inputs are processed in a different order on different machines, the resulting state will diverge. Packet loss and reordering can cause a client to miss an input entirely or process it on the wrong tick. Frame-rate-dependent logic — anything that uses delta_time inside a simulation that should be deterministic — introduces per-machine variance. And race conditions in state reconciliation, where the client applies a server correction while simultaneously processing a local prediction, can corrupt state in subtle ways.

The first step in debugging desync is accepting that the cause is always deterministic in hindsight. Something specific happened on one machine that did not happen on the other, or happened in a different order. Your job is to find that divergence point.

Desync Detection with State Hashing

You cannot fix what you cannot detect. The foundation of desync debugging is a hashing system that computes a checksum of your game state at regular intervals and compares it across participants. When the hashes diverge, you know the exact tick where the problem started.

The implementation is straightforward. At the end of each simulation tick, serialize the relevant game state into a byte buffer and compute a hash. Send this hash alongside your normal network traffic. When the server or a peer receives a hash that does not match its own for the same tick, flag a desync event.

using System.Security.Cryptography;
using System.Text;

public class StateHasher
{
    private SHA256 _hasher = SHA256.Create();

    public byte[] ComputeHash(GameState state)
    {
        var buffer = new StringBuilder();

        // Hash entity positions, healths, and flags in deterministic order
        foreach (var entity in state.Entities.OrderBy(e => e.Id))
        {
            buffer.Append(entity.Id);
            buffer.Append(entity.Position.x.ToString("R"));
            buffer.Append(entity.Position.y.ToString("R"));
            buffer.Append(entity.Health);
            buffer.Append(entity.IsAlive);
        }

        var bytes = Encoding.UTF8.GetBytes(buffer.ToString());
        return _hasher.ComputeHash(bytes);
    }

    public bool CompareHashes(byte[] local, byte[] remote)
    {
        return local.SequenceEqual(remote);
    }
}

A few important details. First, always iterate entities in a canonical order (by ID, not by memory address or insertion order). Second, include every field that affects gameplay — positions, velocities, health, cooldown timers, buff durations, inventory contents. If you miss a field, you will miss desyncs that originate from it. Third, do not hash every tick in production. Hash every 10th or 30th tick to keep bandwidth and CPU costs manageable. In a debug build, hash every tick.

When a mismatch is detected, log the tick number, both hashes, and trigger a full state dump. This gives you the exact starting point for investigation.

Replay Comparison for Pinpointing Divergence

State hashing tells you when desync happened. Replay comparison tells you what diverged. The technique is simple in concept: record every input from every player along with the tick number, then replay those inputs on two separate instances and compare the resulting state tick by tick.

To implement this, you need an input recording system that captures all player inputs and associates them with simulation ticks:

class_name InputRecorder
extends Node

var _log: Array[Dictionary] = []
var _current_tick: int = 0

func record_input(player_id: int, action: String, value: Variant) -> void:
    _log.append({
        "tick": _current_tick,
        "player": player_id,
        "action": action,
        "value": value
    })

func save_replay(path: String) -> void:
    var file := FileAccess.open(path, FileAccess.WRITE)
    file.store_var(_log)
    file.close()

func load_replay(path: String) -> Array[Dictionary]:
    var file := FileAccess.open(path, FileAccess.READ)
    var data: Array[Dictionary] = file.get_var()
    file.close()
    return data

func advance_tick() -> void:
    _current_tick += 1

Once you have input logs from both sides of a desync, replay them on a single machine. Run the simulation forward tick by tick, and after each tick, compare the full game state against a snapshot captured from the other participant. The first tick where the states diverge is your culprit. From there, inspect which specific field changed — was it an entity position, a health value, a random number seed?

For large game states, automate the diffing. Serialize both states to JSON or a structured format and run a field-by-field comparison. The output should tell you something like: "Tick 4,237: Entity 42 position.x is 152.0034 on client A but 152.0031 on client B." That precision difference is the fingerprint of floating-point non-determinism.

Building a Deterministic Simulation

The permanent fix for most desync issues is making your simulation deterministic. A deterministic simulation produces identical output given identical input, regardless of which machine runs it. This is the foundation of lockstep networking and is also required for reliable client-side prediction with server reconciliation.

Achieving determinism requires discipline in several areas:

Fixed-point arithmetic. Replace float and double with fixed-point integers for all simulation math. A common approach is to use 64-bit integers with 16 bits of fractional precision, giving you a range of roughly −32,768 to 32,767 with sub-pixel accuracy. This eliminates cross-platform floating-point variance entirely.

public struct FixedPoint
{
    private const int SHIFT = 16;
    private const long ONE = 1L << SHIFT;

    public long RawValue;

    public static FixedPoint FromInt(int value)
        => new FixedPoint { RawValue = (long)value << SHIFT };

    public static FixedPoint operator +(FixedPoint a, FixedPoint b)
        => new FixedPoint { RawValue = a.RawValue + b.RawValue };

    public static FixedPoint operator *(FixedPoint a, FixedPoint b)
        => new FixedPoint { RawValue = (a.RawValue * b.RawValue) >> SHIFT };

    public float ToFloat()
        => (float)RawValue / ONE;

    public override string ToString()
        => ToFloat().ToString("F4");
}

Fixed timestep. All simulation logic must run at a fixed tick rate. Do not use Time.deltaTime or delta in simulation code. Use a constant like 1.0 / 60.0 (or better yet, the fixed-point equivalent). Rendering can interpolate between ticks for smooth visuals, but the simulation itself must advance in identical steps on all machines.

Canonical input ordering. When multiple players submit inputs for the same tick, process them in a consistent order. Sort by player ID, not arrival time. Arrival time varies by latency; player ID does not.

Seeded random numbers. Every random number generator in the simulation must be seeded identically across all participants. Use a single shared RNG that advances in lockstep with the simulation tick. Never use the system RNG for gameplay logic.

// Seeded RNG that stays in sync across all clients
public class DeterministicRNG
{
    private ulong _state;

    public DeterministicRNG(ulong seed)
    {
        _state = seed;
    }

    public ulong Next()
    {
        // xorshift64 — fast, deterministic, good distribution
        _state ^= _state << 13;
        _state ^= _state >> 7;
        _state ^= _state << 17;
        return _state;
    }

    public int Range(int min, int max)
    {
        return (int)(Next() % (ulong)(max - min)) + min;
    }
}

Determinism is not free. It constrains your architecture and makes certain patterns (like physics engines that use floats internally) much harder to use. But for games that rely on lockstep or rollback networking, it is not optional — it is the foundation.

Network Logging and Packet Inspection

Not all desyncs come from non-deterministic simulation. Some come from the network layer itself: dropped packets, out-of-order delivery, or incorrect serialization. A structured network logging system is essential for diagnosing these problems.

Every packet sent and received should be logged with a tick number, a sequence ID, the payload type, and a summary of the contents. In a debug build, log the full payload. In production, log enough metadata to reconstruct the timeline.

class_name NetworkLogger
extends RefCounted

enum Direction { SENT, RECEIVED }

var _entries: Array[Dictionary] = []

func log_packet(direction: Direction, tick: int, seq_id: int,
        packet_type: String, payload_summary: String) -> void:
    _entries.append({
        "time": Time.get_ticks_msec(),
        "direction": "SENT" if direction == Direction.SENT else "RECV",
        "tick": tick,
        "seq": seq_id,
        "type": packet_type,
        "payload": payload_summary
    })

func dump_log(path: String) -> void:
    var file := FileAccess.open(path, FileAccess.WRITE)
    for entry in _entries:
        file.store_line(JSON.stringify(entry))
    file.close()

func find_gaps() -> Array[Dictionary]:
    # Find missing sequence IDs that indicate dropped packets
    var received := _entries.filter(
        func(e): return e["direction"] == "RECV"
    )
    var gaps: Array[Dictionary] = []
    for i in range(1, received.size()):
        var expected_seq: int = received[i - 1]["seq"] + 1
        if received[i]["seq"] != expected_seq:
            gaps.append({
                "after_tick": received[i - 1]["tick"],
                "missing_seqs": range(expected_seq, received[i]["seq"])
            })
    return gaps

When investigating a desync, pull the network logs from both participants and align them by tick. Look for three things: missing packets (a sequence gap in the received log), reordered packets (a sequence ID that arrives after a higher one), and latency spikes (a sudden jump in the time delta between consecutive received packets). Any of these can cause a client to process inputs on the wrong tick or miss them entirely.

For packet inspection in development, consider adding a debug overlay that shows real-time network stats: round-trip time, packet loss rate, jitter, and the last hash comparison result. This lets you spot desync conditions as they develop, rather than after the match is over.

Putting It All Together: A Desync Debugging Workflow

When a desync report comes in, follow this workflow. First, check the state hash logs to find the exact tick where hashes diverged. Second, pull the full state snapshots from both sides at that tick and diff them field by field to identify which entity or variable diverged. Third, check the network logs around that tick for dropped or reordered packets. If the network looks clean, the problem is in simulation determinism. Fourth, replay the input logs from the start of the match up to the divergent tick on a single machine and verify that the output matches one of the two participants. If the replay matches neither, you have a recording or replay bug. If it matches one, the other participant experienced a different execution path — look for floating-point variance, unordered iteration, or unseeded randomness.

Build these tools early in development. Retrofitting desync debugging into a mature multiplayer game is an order of magnitude harder than building it alongside the networking layer from the start. Invest the time upfront and you will save weeks of painful debugging later.

“Desync is never random. It feels random because the divergence point is invisible. Make it visible with hashing, and the randomness disappears.”

Related Issues

If you are building multiplayer with Godot’s built-in networking, see our guides on fixing MultiplayerSynchronizer desync and debugging RPCs not reaching peers. For issues with spawning networked entities, check MultiplayerSpawner not syncing. If desync manifests as physics jitter, our guide on process vs physics_process jitter covers the fixed timestep side of the problem.

Hash your state every tick in debug builds. The ten minutes you spend adding state hashing will save you ten days of staring at two screens wondering why they show different things.