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.

Movement

Synthesizing the server's response

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.

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.

Silence means success

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.

Rendering diagram…

When the move is valid, a step costs one message up and nothing back.

When the server rejects a move

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.

Agreeing on step duration

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. For each player it tracks when their next step is due, and it accepts a step that arrives up to 300 milliseconds early. Say one step's message gets held up for an extra 100 milliseconds. The next step was sent on time, so it arrives 100 milliseconds early. That's within the 300, so the server accepts it.

Being early doesn't build up, though. When a step arrives early, the server still counts the next one from when this step was due, not from when it showed up. So a modified client that sends every step early gets further ahead each time, until it's more than 300 milliseconds ahead and the server snaps it back. This is a standard rate-limiting algorithm called GCRA (opens in new tab).

Here are both cases with round numbers. Every step takes 500 milliseconds, step 1 arrives at 0, and the diagrams ignore the 95% (more on that below). Each step has a diamond for when it arrived and three blocks underneath. "Too early" means the server snaps the client back, "early but allowed" is the 300 milliseconds before the step is due, and "due or later" is everything after that. A step that lands in either of the last two is accepted.

Rendering diagram…

The honest client's step 2 gets held up and arrives late, which is fine. Step 3 is due 500 milliseconds after step 2 arrived, at 1,100, so it's accepted from 800 on. It was sent on time and arrives at 1,000, inside "early but allowed". Because step 3 was early, the schedule stays put: step 4 is due 500 milliseconds after step 3 was due (1,100), so at 1,600. Step 4 was also sent on time and arrives at 1,500, still 100 milliseconds early. The client stays 100 milliseconds ahead like this until the 95% below brings it back.

Rendering diagram…

The modified client sends every step 125 milliseconds early. An early step doesn't pull the schedule earlier, so each step is still due 500 milliseconds after the previous one was due. The "too early" block grows by 125 milliseconds every step. Steps 2 and 3 land in "early but allowed", but by step 4 the client is 375 milliseconds ahead, lands in "too early", and gets snapped back.

The server also counts each step as 95% of its real length. That gives the client a little slack if its animation runs slightly shorter than the server's math, and it caps a modified client at about 5% faster than normal movement. That's a trade I'm happy with. The same 95% brings the honest client back on schedule after a delay. A 500 millisecond step only moves the schedule forward 475 milliseconds, so the client is 25 milliseconds less early every step and is back on schedule after about 4 steps.

The 300 milliseconds is a fixed amount of time, not a fraction of the step, because network delay doesn't care how fast your character walks. If it were a fraction, it would shrink every time movement got faster.

Important

The 300 milliseconds and the 5% are room a speed hack can use, but the allowance is necessary to keep movement feeling responsive on real networks.

Inventory

Swapping two backpack items with 500 milliseconds of forced latency. The slots update immediately and stay grey until the server confirms.

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.

Rendering diagram…

When either side of the move is a chest, the server still 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.

Synthesis for local events

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.

When the client waits

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 prediction on movement, combat, and UI. I can use the same approach elsewhere later, when I actually have a game worthy of optimizing.

Come say hi

Come chat with me. I'll share extra learnings in the Discord.

  • The Discord just opened, so it's quiet and I'm excited to bounce ideas around with early members.
  • You'll be the first to know about an early alpha.
  • Technical folks are welcome, and so is anyone interested in playing the game.
Join the Discord