Multiplayer Netcode skill for Claude Code
Instant input, honest servers, no rubber-banding. Claude picks the right networking model for the genre, then builds it properly: inputs with sequence numbers, a predicted local player that reconciles by replaying unacknowledged inputs, interpolated remote entities, and capped server-side lag compensation. It validates every client command, trims bandwidth with quantization and interest management, and tests under simulated latency and loss.
- name
multiplayer-netcode - size156 lines · ≈2,300 tokens
- files2
- licenseMIT
- updated
What Claude sees before loading it
This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.
description: Designs and debugs real-time multiplayer netcode with authoritative servers, client-side prediction and server reconciliation, entity interpolation, lag compensation, rollback for fighting games, deterministic lockstep, snapshot and delta compression, interest management, and cheat-resistant input validation. Use when building online multiplayer in any engine or custom stack, when players rubber-band, remote characters jitter, hits do not register, clients desync, bandwidth is too high, or when choosing between client-server, lockstep, and rollback. Try asking
- Players rubber-band whenever ping goes above 100 ms, find out why our prediction fails
- Add client-side prediction and reconciliation to this Node.js game server and browser client
- Should our 1v1 fighting game use rollback or delay-based netcode? Sketch the architecture
Use it when
- Building real-time online multiplayer in any engine or custom stack
- Rubber-banding, jittery remote players or missed hits
- Lockstep or rollback clients desync
- Per-client bandwidth is too high
Skip it when
- Chat, presence or dashboards over WebSockets with no simulation — use realtime-websockets
- Local physics bugs that also happen offline — use game-physics
How to check the result
Two clients under 150 ms RTT, jitter and 3% loss move smoothly with near-zero prediction error, hits register from the shooter's view, and bandwidth stays within budget.
The common mistake it prevents
Running client and server movement with different timesteps or code paths, so every reconciliation finds an error and the player rubber-bands.
Done well means: the server owns the truth, clients send inputs rather than state, the local player responds instantly through prediction and reconciles without visible snaps, remote entities interpolate smoothly, hits are judged from the shooter’s point of view within a capped window, and all of it has been played under simulated 150 ms latency, jitter, and packet loss.
Ground rules
- Never trust the client. Clients send intent (inputs, commands with sequence numbers); the server validates and simulates. Client-authoritative positions are a cheat vector.
- Simulation runs on a fixed tick on both sides; prediction is impossible to reconcile
if client and server step with different
dt. - Design for the network from the first prototype. Retrofitting prediction onto a game whose movement code reads the frame delta and mutates state everywhere is a rewrite.
- Test under bad conditions every time: 100-200 ms RTT, 20-40 ms jitter, 1-5% loss. Localhost hides every bug in this skill.
- Pick the engine’s netcode stack before writing your own; custom netcode is justified for custom engines, browser games, or unusual models (rollback, huge entity counts).
Choose the model
| Game type | Model | Why |
|---|---|---|
| Shooter, action, MMO, co-op | Authoritative server + client prediction + interpolation + lag compensation | Cheat resistance, tolerates many players |
| Fighting game, 2-4 player precise action | Rollback (GGPO-style) over P2P or relay | Input-delay-free feel, tiny state |
| RTS with thousands of units | Deterministic lockstep (inputs only) | Bandwidth independent of unit count |
| Turn-based, card, board | Reliable messages over WebSocket/HTTP; server validates moves | Latency tolerant, simplicity |
| Casual co-op with friends | Listen server/host + relay | Cheap; accept host advantage and host migration complexity |
Workflow
- [ ] 1. Pick the model (table above) and the stack (engine netcode, library, or custom)
- [ ] 2. Make the simulation a pure function: state + inputs + fixed dt -> next state
- [ ] 3. Define messages: input commands (seq, tick, buttons), snapshots (tick, entities), acks
- [ ] 4. Implement prediction + reconciliation for the local player
- [ ] 5. Implement interpolation for remote entities
- [ ] 6. Add lag compensation for hit detection (if hitscan or fast projectiles)
- [ ] 7. Add server validation and rate limits on every client message
- [ ] 8. Measure bandwidth per client; quantize, delta-compress, cull by relevance
- [ ] 9. Play-test under network simulation; fix snaps and jitter
Core techniques
| Technique | What it solves | Key parameters |
|---|---|---|
| Client prediction | Input latency for the local player | Buffer of unacked inputs with sequence numbers |
| Server reconciliation | Prediction errors | On snapshot: reset to server state for acked seq, replay pending inputs |
| Error smoothing | Visible snaps after reconciliation | Blend visual position toward corrected position over 100-200 ms; snap if error is large |
| Entity interpolation | Remote entities jitter between snapshots | Render 2-3 snapshot intervals in the past (about 100 ms at 20-30 Hz) |
| Extrapolation (dead reckoning) | Late snapshots | Cap at 100-250 ms, then freeze; never for long |
| Lag compensation | “I hit him on my screen” | Server keeps hitbox history about 1 s; rewind to shooter’s view time, cap rewind at 200-250 ms |
| Input redundancy | Lost input packets | Send the last 3-5 inputs in each packet (unreliable channel) |
| Rollback | Fighting-game responsiveness | Save state per frame; 1-3 frames input delay; roll back up to about 7-8 frames |
For complete client, server, interpolation-buffer, lag-compensation, and rollback loops in code, read references/prediction-reconciliation.md when implementing or debugging any of these by hand.
Tick rates and channels
- Server simulation 30-60 Hz for action games (fighting games run at render rate, 60 Hz); snapshot send rate 10-30 Hz; client input send rate equal to the client tick.
- Unreliable sequenced channel for inputs and snapshots (newest wins); reliable ordered channel for chat, inventory changes, match events. Never send per-tick state over a reliable stream; head-of-line blocking turns 2% loss into seconds of stall.
- Transports: UDP-based (ENet, Steam Networking Sockets, Unity Transport, engine defaults); browsers use WebRTC data channels or WebTransport for unreliable delivery, WebSocket only where TCP latency is acceptable.
Bandwidth
- Quantize: positions to centimeters in a bounded range (e.g. 18-20 bits per axis), rotations as smallest-three quaternions or a yaw byte, velocities to fixed point.
- Delta-compress snapshots against the last snapshot the client acknowledged; send full state when no ack is recent.
- Interest management: send only entities relevant to each client (distance, line of sight, team). It also prevents wallhack data leaks.
- Budget example: 64 players x 30 Hz x about 40 bytes per entity in view must fit the per-client downstream target (for many games, under about 100-200 kbps).
Security and validation
- Validate every command server-side: movement speed and acceleration limits, cooldowns, ammo, line of sight, ownership of the target entity.
- Rate-limit messages per connection; drop and log clients that exceed it.
- Never send hidden information (enemy positions behind walls, opponent’s hand) to the client; filter at the server.
- Hit validation: client may claim a hit with the tick it saw; the server rewinds and re-tests, rejecting claims outside the compensation window.
Engine stacks
| Engine | Starting point |
|---|---|
| Unity | Netcode for GameObjects (simple co-op), Netcode for Entities (prediction built in), or third-party (Fish-Net, Mirror, Photon Fusion) |
| Unreal | Built-in replication; Character Movement Component prediction; Gameplay Ability System for predicted abilities |
| Godot 4 | ENetMultiplayerPeer, MultiplayerSpawner, MultiplayerSynchronizer, RPCs; prediction is your job |
| Web / custom | Colyseus or custom Node/Go/Rust server; Geckos.io or WebTransport for unreliable messaging |
Network simulation for testing
# Linux: add 100 ms +/- 20 ms delay and 2% loss on loopback (undo with 'del')
sudo tc qdisc add dev lo root netem delay 100ms 20ms loss 2%
sudo tc qdisc del dev lo root
- Windows: clumsy. macOS: Network Link Conditioner (Xcode additional tools).
- Unreal console:
Net PktLag=150,Net PktLoss=3. Unity Transport and Netcode tools include a network simulator component. - Confirm with the user before running
tccommands on a shared or production machine.
Symptom table
| Symptom | Likely cause | Fix |
|---|---|---|
| Local player rubber-bands constantly | Client and server simulations differ (variable dt, different collision, float order) | Share the exact movement code and fixed tick; log predicted vs server state per seq |
| Rubber-banding only under loss | Inputs lost, server simulates “no input” | Input redundancy; server repeats last input for a few ticks |
| Remote players jitter or teleport | Rendering latest snapshot directly | Interpolation buffer with jitter allowance |
| Hits miss that looked clean | No lag compensation, or wrong rewind time | Rewind to (server tick - client interpolation delay - RTT/2), cap the window |
| Getting shot behind cover | Lag compensation favors shooter | Expected trade-off; lower the cap or tick-align cover checks |
| Lockstep desync after minutes | Nondeterminism: float differences, unordered iteration, unseeded RNG | Per-tick state hash exchange, find first diverging tick |
| Actions fire twice | Reliable message replayed or input applied in both predict and replay paths | Idempotent commands keyed by sequence number |
Gotchas
- Clock sync: estimate RTT and server tick offset continuously (moving average, discard outliers); the client should run slightly ahead of the server so inputs arrive in time.
- Spawning predicted entities (projectiles) on the client needs IDs that the server can match; otherwise you get duplicates or disappearing bullets.
- Physics engines are rarely deterministic enough for rollback or lockstep across machines; use a deterministic engine/config or simple custom movement.
- NAT traversal fails for a meaningful share of players; P2P designs need a relay.
- Host migration is hard; prefer dedicated or relay-hosted authority if sessions are long.
Verification
- Two or more clients under 150 ms RTT, 30 ms jitter, 3% loss: local movement feels immediate, corrections are not visible in normal play, remote players move smoothly.
- Prediction error log (predicted vs authoritative per input seq) is near zero in steady movement.
- Hit registration test: shots on a strafing target at 150 ms RTT register when the crosshair was on target in the shooter’s view.
- Bandwidth per client measured at peak player count stays inside the budget.
- Fuzz the server with malformed and out-of-range commands; nothing crashes, cheats are rejected and logged.
Bundled files
Loaded only when a step in the skill needs them. Review them here before installing.
references/prediction-reconciliation.md
# Prediction, reconciliation, interpolation, lag compensation, rollback
Reference loops in TypeScript-style pseudocode. They assume one shared, deterministic
`simulate(state, input, dt)` used by both client and server; that shared function is the
single most important property of working netcode.
```ts
const TICK = 1 / 60;
type Input = { seq: number; tick: number; moveX: number; moveY: number; buttons: number };
type PlayerState = { x: number; y: number; vx: number; vy: number };
function simulate(s: PlayerState, i: Input, dt: number): PlayerState {
const speed = 6;
const vx = i.moveX * speed, vy = i.moveY * speed;
return { x: s.x + vx * dt, y: s.y + vy * dt, vx, vy };
}
```
## 1. Client: prediction and reconciliation
```ts
class PredictedPlayer {
state: PlayerState = { x: 0, y: 0, vx: 0, vy: 0 };
pending: Input[] = []; // sent but not yet acknowledged by the server
seq = 0;
visualOffset = { x: 0, y: 0 }; // for smoothing corrections
tick(raw: { moveX: number; moveY: number; buttons: number }, clientTick: number) {
const input: Input = { seq: ++this.seq, tick: clientTick, ...raw };
this.pending.push(input);
send({ type: 'input', inputs: this.pending.slice(-4) }); // redundancy: last 4 inputs
this.state = simulate(this.state, input, TICK); // predict immediately
}
onServerSnapshot(authoritative: PlayerState, lastProcessedSeq: number) {
const before = { x: this.state.x, y: this.state.y };
// 1. drop inputs the server has processed
this.pending = this.pending.filter((i) => i.seq > lastProcessedSeq);
// 2. rewind to server truth, 3. replay the rest
let s = authoritative;
for (const i of this.pending) s = simulate(s, i, TICK);
this.state = s;
// 4. smooth small corrections, snap large ones
const ex = before.x - s.x, ey = before.y - s.y;
if (Math.hypot(ex, ey) < 2) { this.visualOffset.x += ex; this.visualOffset.y += ey; }
else this.visualOffset = { x: 0, y: 0 };
}
renderPosition(dtFrame: number) {
const decay = Math.exp(-dtFrame / 0.1); // ~100 ms smoothing
this.visualOffset.x *= decay; this.visualOffset.y *= decay;
return { x: this.state.x + this.visualOffset.x, y: this.state.y + this.visualOffset.y };
}
}
```
Debug aid: log `(seq, predicted, authoritative)` pairs. Non-zero error in straight-line
movement means the two simulations differ; find the divergent input, not a bigger smoothing
factor.
## 2. Server: authoritative tick
```ts
class ServerPlayer {
state: PlayerState = { x: 0, y: 0, vx: 0, vy: 0 };
queue = new Map<number, Input>(); // seq -> input
lastProcessedSeq = 0;
lastInput: Input | null = null;
receive(inputs: Input[]) {
for (const i of inputs) {
if (i.seq <= this.lastProcessedSeq || this.queue.has(i.seq)) continue; // duplicate
if (!isValid(i)) { flag(i); continue; } // clamp axes to [-1,1], known buttons only
this.queue.set(i.seq, i);
}
}
tick() {
const next = this.queue.get(this.lastProcessedSeq + 1);
const input = next ?? (this.lastInput && { ...this.lastInput, seq: this.lastProcessedSeq });
if (next) { this.queue.delete(next.seq); this.lastProcessedSeq = next.seq; this.lastInput = next; }
if (input) this.state = simulate(this.state, input, TICK);
}
}
// every snapshot interval: send each client its own state + lastProcessedSeq,
// and other entities' states stamped with the server tick
```
Process one input per tick per player. If a client floods inputs (speed hack by sending
more than one per tick), the queue grows; cap it (e.g. 8) and drop the excess.
## 3. Remote entity interpolation
```ts
type Snap = { serverTime: number; x: number; y: number };
class InterpolatedEntity {
buffer: Snap[] = [];
push(s: Snap) { this.buffer.push(s); if (this.buffer.length > 32) this.buffer.shift(); }
sample(renderServerTime: number): { x: number; y: number } | null {
// renderServerTime = estimatedServerNow - interpDelay (e.g. 100 ms)
const b = this.buffer;
for (let i = b.length - 1; i > 0; i--) {
const a = b[i - 1], c = b[i];
if (a.serverTime <= renderServerTime && renderServerTime <= c.serverTime) {
const t = (renderServerTime - a.serverTime) / (c.serverTime - a.serverTime);
return { x: a.x + (c.x - a.x) * t, y: a.y + (c.y - a.y) * t };
}
}
const last = b[b.length - 1];
return last ?? null; // starved: hold last (or extrapolate briefly, capped)
}
}
```
Choose `interpDelay` as about 2 snapshot intervals plus observed jitter. Track how often
the buffer starves; adapt the delay upward when it does.
## 4. Server-side lag compensation
```ts
type Hitbox = { id: number; x: number; y: number; r: number };
const history: { time: number; boxes: Hitbox[] }[] = []; // ring buffer, ~1 s
const MAX_REWIND = 0.25; // seconds
function recordHistory(now: number, boxes: Hitbox[]) {
history.push({ time: now, boxes: boxes.map((b) => ({ ...b })) });
while (history.length && now - history[0].time > 1) history.shift();
}
function validateShot(now: number, shooterViewTime: number, ray: Ray): number | null {
// shooterViewTime = time the client was rendering remote entities at when it fired
const t = Math.max(shooterViewTime, now - MAX_REWIND);
const frame = closestFrames(history, t); // interpolate between two records
return raycastHitboxes(ray, frame); // id of the hit entity or null
}
```
The client sends with each fire command its current interpolation render time (or the tick
pair and fraction it was rendering). Never trust a client-supplied time beyond the cap.
## 5. Rollback loop (GGPO-style)
```ts
const INPUT_DELAY = 2; // frames
const MAX_ROLLBACK = 8; // frames
let frame = 0;
const states = new Map<number, GameState>(); // frame -> snapshot (save every frame)
const localInputs = new Map<number, PadInput>();
const remoteInputs = new Map<number, PadInput>(); // confirmed
let lastConfirmedRemote = -1;
function advance() {
localInputs.set(frame + INPUT_DELAY, readPad());
send({ frame: frame + INPUT_DELAY, input: localInputs.get(frame + INPUT_DELAY) });
if (frame - lastConfirmedRemote > MAX_ROLLBACK) return; // stall: too far ahead
const remote = remoteInputs.get(frame) ?? predictRemote(frame); // usually repeat last
states.set(frame, save(current));
current = step(current, localInputs.get(frame)!, remote);
frame++;
}
function onRemoteInput(f: number, input: PadInput) {
remoteInputs.set(f, input);
lastConfirmedRemote = Math.max(lastConfirmedRemote, f);
if (f < frame && input !== predictedFor(f)) {
current = load(states.get(f)!); // roll back
for (let g = f; g < frame; g++) {
states.set(g, save(current));
current = step(current, localInputs.get(g)!, remoteInputs.get(g) ?? predictRemote(g));
}
}
}
```
Requirements: `step` is deterministic and fast enough to run MAX_ROLLBACK + 1 times per
frame; `save`/`load` are cheap (flat arrays, no allocation); audio and particles are
triggered idempotently so a resimulated frame does not replay sounds. Exchange a state
checksum every N frames to detect desync early.
## 6. Clock and tick sync
- Ping/pong with timestamps every 1-2 s; RTT = now - sentTime; keep a median of the last
10 samples.
- Server time estimate = serverTimestamp + RTT / 2.
- Client tick target = estimated server tick + (RTT / 2) / TICK + 1-2 ticks of safety, so
inputs arrive just before the server needs them. Nudge by speeding or slowing the client
tick by 1-5% rather than jumping.
About this skill
Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.