Brandon Romano
// September 12, 2026
Making Interactions Feel Instant
Hiding network latency with client-side prediction
Client-Server Architecture covered how the client and server talk: the client only expresses intent, and the server decides what actually happened. The client sends "move north" and waits to be told whether it moved.
This model is simple, but it has a cost: latency. A message from your machine to the server and back is somewhere between 20 and 150 milliseconds depending on where you live relative to the server. If the client waited for the server to confirm every step before animating it, you'd press a key and your character would stand still for a tenth of a second. It doesn't sound like much, but for movement that delay starts to feel like something is wrong.
The fix is client-side prediction: show the result immediately on the assumption that the server will approve it, and reconcile later if it doesn't.
Your character is a NetworkEntity like everyone else on screen, with a PlayerController attached as a child to turn input into requests (more detail in Client-Server Architecture). Because every entity uses the same scene and script, predicting movement takes very little code. When a movement key is pressed, the PlayerController does two things in order:
func _move_player(direction: Constants.Direction):
if !_traverser.can_traverse(client_coordinates, direction):
return
var to_coordinates_3d := ...
# 1. Ask the server.
Globals.websocket_manager.send_message_to_websocket(
WebsocketOutboundMoveMessage.as_string(direction)
)
# 2. Pretend the server already said yes.
Globals.websocket_manager.synthesize_message_from_websocket(
WebsocketInboundMoveMessage.synthesize(
Globals.player_id, client_coordinates, to_coordinates_3d
)
)
_locked = true
The client synthesizes the exact move message the server would have broadcast if it accepted the step, and pushes it into the same parser and message_received signal used for live socket traffic:
func synthesize_message_from_websocket(message: String):
_handle_websocket_message(message)
From there, nothing in the client can tell the difference. The NetworkEntityManager forwards the message to your NetworkEntity by actor ID, and your NetworkEntity begins interpolating toward the destination tile exactly as it would for any other player's move. Your character consumes the same message shape it would have received from the server, so it shares every line of movement code with everyone else on screen.
Note
Predicting your own input is standard. Most games do it with a separate local-player path: your character moves immediately in client logic, while everyone else only moves from network messages. I went the other way and synthesize the inbound move instead, so prediction rides the same pipeline as live traffic.
It also only works cleanly because this game is grid-locked. Each step is one discrete tile, so the client can synthesize the exact move the server would have sent: the same from and to. In a free-moving 3D game, the server often sends a compressed or summed motion over a window of time rather than every individual input, and forging that message ahead of time is a different problem.
The server never writes your own move back over the WebSocket. The player entity still receives the message from the chunk topic like everyone else does, because its state machine needs to flip from Idle to Moving, but it skips the WebSocket write for that one action. Everyone nearby gets the broadcast; your client already synthesized it.
When the move is valid, a step costs one message up and nothing back. The only traffic the server generates for your own movement goes to other people.
Occasionally, the server disagrees. Another player stepped onto that tile a few milliseconds before your request arrived, or a modified client is sending moves faster than its legs allow. In either case the server leaves the entity where it is and sends back a snap_to_coordinates with the true position:
if !moved {
p.OnPubsubMessageReceived(ctx, &messages.BaseMessage{
Actor: p,
Action: actions.SnapToCoordinates,
Data: actions.SnapToCoordinatesData{To: p.GetCoordinates()},
})
}
The client's NetworkEntity handles snap_to_coordinates by cancelling whatever it was doing and teleporting to the given tile. You see your character slide part of a step forward and pop back. It's a little jarring, and it's meant to be rare: the client mirrors the server's traversal rules locally, so the can_traverse check at the top of _move_player catches blocked steps before they're ever sent. The correction is always there for the cases it can't catch, and the server's version always wins.
For the walk animation to match, the client and server have to agree on how long a step takes. The server calculates traversal time from the distance between tiles (accounting for elevation) divided by entity speed, and the client animates using the same formula.
The PlayerController locks input during each step and unlocks only when the NetworkEntity reaches the destination tile. Limiting movement to one step in flight at a time keeps held-key movement smooth without queuing up extra inputs. It also means an honest client never sends its next step early; the only thing that can make one arrive early is network jitter.
The server allows for that jitter. The player's Moving state occupies the server's state machine for 90% of the step duration before returning to Idle. That 10% buffer lets honest clients with slight network variance send their next input without being rejected, while capping a modified client to at most about 11% faster than normal movement. That's a trade I'm happy with.
Important
The window is an attack surface for speed hacks. I have server-side checks that detect systemic abuse over time and ban offending accounts, but the window itself is necessary to keep movement feeling responsive on real networks.
When you drag an item between two slots in your backpack, the client sends an inventory_move and updates both slots immediately. It renders them slightly greyed out to mark them unverified. When the server's inventory_slots_updated arrives with the same contents, the slots un-grey. If the server rejects the move (the item has a level requirement you don't meet, say), it rebroadcasts the original slot contents instead, and the UI snaps back.
Movement has a dedicated rejection message (snap_to_coordinates) because the server is silent on success, so silence can't also mean failure. Inventory doesn't have that problem: the server answers an inventory_move with inventory_slots_updated either way, and the client applies whatever it says.
The request is still intent, not fact. When either side of the move is a chest, the server checks that the player has it open and is still in range. A modified client can't pull items from a chest it never opened, or one it has walked away from.
Message synthesis also covers things that never go to the server. When you inspect an item, its description comes from the static-db bundled with the client; the server has nothing to add. So the client synthesizes a send_server_message, and the chat panel renders it without the item menu needing to know the chat panel exists.
The pipeline built for server traffic doubles as an event bus for the client's own systems.
I only predict when the delay would be noticeable. Movement and inventory both qualify. Most actions don't. Starting a crafting recipe waits for crafting_accepted. Chopping a tree waits for the server to move you into the woodcutting state. For those, the client sends the request and does nothing until the server replies.
Tip
I always develop with a synthetic delay of around 150 milliseconds that varies like real latency. While I'm building, I know right away if an action needs prediction, because I feel the same wait players will feel.
"If you want to write fast software, use a slow computer" - Dominic Tarr (opens in new tab)
I spent a lot of time going back through old MMOs to see which actions wait for the server. In World of Warcraft (opens in new tab), the cast bar doesn't show until the server accepts (though the character starts the casting animation right away). In RuneScape (opens in new tab), most world interactions don't begin until the next server tick: fishing, woodcutting, and so on.
I'll likely keep this on movement, combat, and UI, because those need to feel snappy. The same approach works for any interaction, so I can use it elsewhere later when I actually have a game worthy of optimizing.
