All writing

    May 19, 2026 / 7 min read

    Rolled a six, moved every token: keeping multiplayer state authoritative

    How leftover input, partial state broadcasts and update-only UIs add up to exploits and deadlocks in a real-time game, and how to fix them.

    In a real-time multiplayer game, the server is the referee. Clients send what they want to do, like "roll the dice" or "move this token". The server decides what's legal, updates the game state and tells everyone what happened. This is the server-authoritative model. It exists so a modified client can't cheat.

    The model only works if two things hold. The server's own state changes have to be complete, and clients have to rebuild what they show from what the server says. Small gaps in either cause two kinds of bug that look unrelated. Exploits let a player do something the rules forbid. Deadlocks leave an honest player stuck because the client and server disagree. In this post I'll go through what causes both, using a real Ludo game where one roll of a six could move every token.

    Clear used input on every path

    Most turn-based games have a piece of state that means "this player has earned an action and hasn't used it yet". In Ludo, it's the current dice value. Rolling sets it. Moving a token uses it up.

    go
    func (g *Game) MakeMove(player, token int) error {
        if g.LastDice == nil {
            return ErrNeedToRoll
        }
        g.move(player, token, *g.LastDice)
        // ... consume the roll
    }

    The LastDice == nil check is what stops a player from moving twice on one roll. But that check is only as good as the code that resets LastDice after a move. Turn logic usually branches. A normal move ends the turn. A capture, reaching home and rolling a six each give a bonus roll. If any one branch forgets to clear the used input, the check stops working on that branch.

    The general rule: when an action uses something up, clear it right where it's used, not in each outcome branch. If your code is already split by outcome, check every branch for the reset and write a test for each one.

    Deltas and snapshots

    A server can tell clients what happened in two ways. A delta is a single change. A snapshot is the whole state.

    MessageContainsStrengthWeakness
    DeltaOne event, e.g. "player 2 rolled a 4"Small, easy to animateUseless if missed
    SnapshotThe full game stateSelf-contained, safe to apply twiceLarger

    A protocol that only sends deltas assumes every client sees every message, in order. Real clients don't. They put the app in the background, lose mobile signal, reconnect, or join mid-game. A client that missed one delta now holds a state the server will never correct.

    The sturdy pattern is deltas for animation and snapshots for truth. The client should work out every interactive decision from the latest snapshot: whose turn it is, whether the dice can be tapped, and which tokens can move. Deltas can make the UI feel quick. They should never be the only source of a fact the UI depends on.

    A related mistake is sending snapshots only after some changes. Say the server sends a snapshot after a penalty but not after a normal roll. Then the normal case depends entirely on deltas, and any client that misses one stays out of date until the next turn.

    Defensive client checks can deadlock

    Clients often add their own checks so they don't send requests the server would reject. For example: "don't let the user roll if we think a roll is still unused". That check is a copy of server logic. When the client's copy of the state is out of date, the check doesn't just fail to help. It blocks the user:

    • The client thinks a roll is still unused, so the dice is locked. But the server has already used it and is waiting for a roll. The user can't roll.
    • The client thinks no roll is unused, so it hides movable tokens. But the server is waiting for a move. Tapping a token returns "you need to roll the dice first", or nothing at all.

    Neither side is wrong based on its own data. The game just freezes until a timer pushes the turn on.

    The fix isn't to remove client checks, because they make the UI clearer. It's to make sure every input to the check is refreshed from the server's snapshot. Nothing should be left over from a local event that may never get a follow-up.

    Testing the branches nobody ran

    Bugs like these survive because tests cover the common path. For state machines, it helps to list every transition on purpose:

    1. 1.List every branch that ends an action: normal move, capture, home, bonus roll, penalty.
    2. 2.For each, check the full state afterwards, including the "used" fields, not only the visible result.
    3. 3.Add one test per branch that tries the forbidden next action (a second move on the same roll) and expects a rejection.
    4. 4.On the client, test reconciliation: feed in a snapshot that contradicts local state and check the UI follows the snapshot.

    Where I ran into this

    I worked as a freelancer on Nepal Ludo League, a paid online Ludo tournament app with a Go server and a Flutter client. Teammates built the server engine and the gameplay socket. I fixed a bug that crossed both.

    The symptoms were one exploit and two deadlocks. After rolling a six, a player could keep sending moves and move every token with that same roll until the turn timer ran out. Code comments put that timer at about 30 seconds. Other players hit the opposite problem. Some saw dice that looked tappable but returned already_rolled. Others saw locked dice with tokens that answered "You need to roll the dice first".

    Four causes added up.

    1. 1.In MakeMove, the rolled-six path gave an extra turn but never cleared gs.LastDice, unlike the capture and home paths. So the LastDice == nil check never fired.
    2. 2.The server sent the full game.state after a roll only on the three-sixes penalty, so normal rolls depended on deltas.
    3. 3.The client cleared its local dice only when the turn changed. A move after a six keeps the same turn and the server sends last_dice: 0, so the old six stuck around.
    4. 4.Tokens became tappable only on the roll-result delta, which isn't replayed after a reconnect.

    The server fixes were small. First, clear the roll on the six path. The consecutive-sixes counter is kept on purpose, so the three-sixes penalty still works:

    go
    if rolledSix {
        canRollAgain = true
        extraTurnReason = "six"
        // ConsecutiveSixes intentionally preserved so the 3-sixes penalty still fires.
        gs.LastDice = nil
    }

    Second, send a snapshot after every roll, not only on the penalty:

    go
    // before
    if result.ThreeSixesPenalty {
        h.broadcastGameState(matchID)
    }
    
    // after
    h.broadcastGameState(matchID)

    On the client, the sync service now clears the dice whenever the snapshot says so, whether or not the turn changed:

    dart
    // before
    } else if (turnChanged) {
      state.clearDiceForSync(notify: false);
    
    // after
    } else {
      state.clearDiceForSync(notify: false);

    The dice now depends on both the turn and whether a roll is unused. Movable tokens are rebuilt from the snapshot with the rules engine, instead of waiting for a delta:

    dart
    // before
    ? (controller?.isMyTurn ?? false)
    
    // after
    ? ((controller?.isMyTurn ?? false) && !hasOutstandingRoll)

    No test had ever run the rolled-six branch of the move logic, which is how the bug survived. After the change I ran the Flutter suite and all 58 of 58 tests passed. The code change across server and client is small. Most of the work was tracing why each side's view of the game drifted apart.

    Checklist

    • Clear used input right where it's used, or check every outcome branch for the reset.
    • Keep counters that span actions (consecutive sixes, combo streaks) apart from per-action input, and reset each one on purpose.
    • Send a full snapshot after every state change that affects what a player may do.
    • Build interactive UI state (turn, dice availability, movable pieces) from the latest snapshot.
    • Treat deltas as animation hints, never as the only source of a fact.
    • Test the forbidden next action on every branch, and test client reconciliation against a contradicting snapshot.

    Takeaways

    • Server authority only holds if every server state change is complete.
    • One missing reset on one branch turns a check into an exploit.
    • Delta-only UIs break on reconnects. Snapshots let clients recover.
    • Client checks copied from server rules deadlock when their inputs are out of date.
    • Bugs hide in the branches no test runs, so list your branches on purpose.