Lesson 20: Lag Compensation
You line up a perfect shot on an enemy, click, and the server says you missed, because by the time your shot arrived the enemy had moved on. In this lesson you teach the server to rewind the world to the moment you actually saw, so aiming well is rewarded at any ping, within limits you choose.
๐ฏ Learning Objectives
By the end of this lesson, you will be able to:
- Explain, in ticks, how far in the past a shooter's view is when they fire and when their shot reaches the server.
- Build a server-side history of recent ticks and look up a blended position at any fractional tick, refusing ticks it no longer has.
- Judge a hitscan shot with a real ray-versus-circle test against the rewound target.
- Compare favor-the-shooter with judging in the present, and justify a maximum rewind.
Project: Rewind and Hit: a pygame shooter whose HUD compares the server's verdict with and without rewinding.
In This Lesson
๐ฏ You Always Shoot at the Past
Looking at a star, you see light that left it long ago; the star may have moved since. In the Client Prediction & Reconciliation lesson you drew other players a few ticks in the past, on purpose, so they move smoothly. That makes every remote player a little like that star. Count the delays for one shot, in server ticks at 60 ticks per second, with a 50 ms one-way delay (3 ticks) and an interpolation delay of 6 ticks:
| Delay | Why | Ticks |
|---|---|---|
| Snapshot travel, server to client | The newest snapshot you have is already one-way latency old | 3 |
| Interpolation delay | You draw remote players behind the newest snapshot | 6 |
| Age of what you see when you click | about RTT รท 2 + interpolation | 9 |
| Shot travel, client to server | Your shot takes one-way latency to arrive | 3 |
| Rewind needed when the shot arrives | about RTT + interpolation | 12 |
An enemy running at 260 px/s covers 52 px in 12 ticks. If the server tests your shot against where the enemy is now, the enemy is 52 px from where you aimed, farther than its whole 40 px width (a 20 px radius), so a perfect shot across its path misses cleanly. Even on a local network the 6-tick interpolation delay alone moves it 26 px, and across a continent the gap grows with every millisecond of ping.
The diagram's numbers wobble by a tick or two because snapshots, ticks and frames never line up exactly. That is why the lab doesn't estimate the rewind from the ping at all, as the next sections show.
๐ฐ๏ธ Keep a History of Ticks
To judge a shot in the past, the server must remember the past. After every tick it stores a copy of each target's position with the tick number, and forgets anything older than it will ever need. A collections.deque with maxlen does the forgetting for you:
HISTORY_TICKS = TICK_RATE # keep one second of the past
self.history = deque(maxlen=HISTORY_TICKS)
def step(self):
self.tick += 1
self.bot += self.velocity * TICK_DT
self.history.append((self.tick, tuple(self.bot))) # a tuple: a copy, not a reference
Two rules from the last lesson apply here too. Store copies (a tuple), or every entry will point to the same moving Vector2. And count in ticks, not milliseconds: the history, the shot and the rewind limit all use the same unit, so there is nothing to convert and no way to mix seconds with milliseconds.
โช Rewind to the Tick the Shooter Saw
How does the server know which tick to rewind to? There are two approaches:
- Estimate it: rewind from the arrival tick by the player's measured RTT plus their interpolation delay. It needs a good RTT estimate, and splitting it into "RTT รท 2 each way" is wrong whenever the two directions differ.
- Ask the client: the shot carries the (fractional) tick the client was drawing when it fired. The server then only has to check that the claim is believable. The lab does this.
@dataclass(frozen=True)
class Shot:
origin: tuple
aim: tuple # the point the player clicked on
render_tick: float # the tick the client was DRAWING when it fired
Looking up a fractional tick blends the two history entries around it, with the lerp you already know. The edge cases are where the bugs live:
def state_at(history, tick):
"""The bot's position at a fractional tick, blended from the history.
history holds (tick, (x, y)) pairs, oldest first. Returns None when the tick is
older than anything we kept: the server can't know, so the shot is refused.
"""
if not history or tick < history[0][0]:
return None
if tick >= history[-1][0]:
return pygame.Vector2(history[-1][1]) # newest we have (never the oldest!)
for (ta, pa), (tb, pb) in zip(history, list(history)[1:]):
if ta <= tick <= tb:
span = tb - ta
t = (tick - ta) / span if span else 0.0 # guard: two entries for one tick
return pygame.Vector2(pa).lerp(pb, t)
return pygame.Vector2(history[-1][1])
- Too old: return
None. Falling back to the oldest entry would judge the shot against a moment the shooter never saw. - Newer than the history: the newest entry is the closest truth there is. (The server also refuses ticks from the future, below.)
- Zero span: if two entries ever share a tick, dividing by
tb - tawould crash; the guard avoids it.
The client must also report only ticks it has actually drawn. If its tick clock runs ahead of the snapshots it has received, it should clamp the render tick to the newest snapshot, or its claim won't match the picture the player aimed at.
๐ฅ A Real Hit Test
A hitscan weapon hits instantly along a straight line, so the question is "does the ray from the muzzle toward the aim point pass within the target's radius?" Project the target's center onto the ray to find the ray's closest point, then compare distances:
def ray_hits_circle(origin, aim, center, radius, max_range=RANGE):
"""Hitscan test: does the ray from origin toward aim pass within radius of center?"""
origin, center = pygame.Vector2(origin), pygame.Vector2(center)
direction = pygame.Vector2(aim) - origin
if direction.length_squared() == 0:
return False
direction = direction.normalize()
along = max(0.0, min(max_range, (center - origin).dot(direction)))
closest = origin + direction * along # nearest point of the ray to the center
return closest.distance_squared_to(center) <= radius * radius
Clamping along to 0 stops the ray from hitting targets behind the shooter, and clamping to max_range gives the weapon a range. A full game would also rewind walls, doors and every other player and check that nothing blocked the ray at that tick; the principle is the same test against more shapes.
Putting it together, the server's judge refuses implausible ticks, rewinds, then tests:
def judge(self, shot):
"""Return (verdict, rewound position). Verdicts: 'hit', 'miss', 'too old', 'future'."""
age = self.tick - shot.render_tick
if age < 0:
return "future", None
if age > MAX_REWIND_TICKS:
return "too old", None
past = state_at(self.history, shot.render_tick)
if past is None:
return "too old", None
if ray_hits_circle(shot.origin, shot.aim, past, BOT_RADIUS):
return "hit", past
return "miss", past
โ Growth Mindset: Off-by-One Tick Is Still Progress
Almost everyone's first rewind is off by a few ticks: shots that should hit clip the edge, or the green ring sits a little beside the blue target. That isn't failure; it's a measurement, and it tells you exactly where to look. Print the render tick the client sent, the server's tick on arrival, and both positions. When the numbers line up, the rings line up. Debugging netcode is mostly patient counting, and you are getting better at counting every time you do it.
โ๏ธ Favor the Shooter, Within Limits
Rewinding is a policy called favor the shooter: if you hit what you saw, you hit. It has a cost, and it lands on the target. From the target's point of view, they may already have ducked behind a wall on their own screen when a shot "from the past" hits them. Neither player did anything wrong; they simply saw different moments. Judging every shot in the server's present would move the whole cost onto shooters, and every moving target would be nearly impossible to hit at any real ping.
Limits keep the policy fair:
- A maximum rewind. The lab refuses shots older than 18 ticks (0.3 s). A player with a very high ping gets fewer compensated shots instead of making everyone else get shot around corners. Where you set it is a design choice: raise the latency in the demo below until shots are refused.
- No shots from the future. A render tick newer than the server's own tick can't be true, so it is refused.
- Trust, but verify. The client chooses the render tick, so a cheater could pick the most convenient moment inside the window. The cap bounds how much that gains them; the server can also compare each claim with that player's recent latency and flag outliers.
This lesson covers instant hitscan shots. Slow projectiles such as rockets or arrows travel for many ticks and raise different questions (whose present does the rocket fly through?), so they are a separate design problem.
๐ซ Try It: Fire at What You See
"Fire" shoots exactly at the blue bot, the one the shooter sees, and sends the tick being drawn. When the shot arrives, the server judges it twice: against the bot rewound to that tick, and against the red ring, where the bot is now. Without rewind, a hit is down to luck and angle, and it gets rarer as the latency rises; the rewound verdict keeps hitting until the shot is older than the limit.
๐๏ธ Practice Exercise: Rewind and Hit
Objective: finish a pygame shooter where the server keeps a one-second history, rewinds each shot to the tick its shooter was drawing, and reports hits with and without rewinding.
Time: about 35 minutes. Starter file: rewind_starter.py (your instructor has it). The server, the client's tick clock and the links are done. Its numbered comments match the steps below. Space fires at the bot you see, a click fires at the mouse, and L changes the latency.
- Run the starter and press Space a few times. Every shot is a miss, with or without rewind. (โ 2 min)
- Write
state_at():Nonefor a tick older than the history, the newest entry for a tick at or past the end, otherwise a lerp between the two entries around the tick, guarding a zero span (comment 1). (โ 12 min) - Write
ray_hits_circle()with the projection from A Real Hit Test (comment 2). Now rewound shots hit. (โ 8 min) - In
judge(), refuse shots from the future and shots older thanMAX_REWIND_TICKSbefore rewinding (comment 3). (โ 5 min) - Press L until the latency is 120 ms and fire again. Explain the "refused" count with the table in You Always Shoot at the Past. (โ 5 min)
You are done when:
- at 30 and 50 ms, every Space shot is a hit with rewind, while most are misses without it;
- the green ring lands on the spot where the blue bot was when you fired;
- at 120 ms, shots are refused as too old;
- closing the window prints the shot counts.
๐ก Hint
History entries are (tick, (x, y)) pairs, so history[0][0] is the oldest tick and history[-1][1] the newest position. In the ray test, (center - origin).dot(direction) is how far along the ray the target's center lies; clamp it before you use it.
โ Example Solution
If your instructor hands you the lab file, you will see a few extra lines marked lab runtime near the top and and frame_budget() in the loop. They let the instructor's checker run the program automatically for a fixed number of frames; when you run it yourself they do nothing.
"""Rewind and Hit: Advanced Lesson 20 practice exercise (solution).
A hitscan shooter (you, bottom center) fires at a bot that only the server moves.
Like every client, you see the bot a little in the past: the blue bot is your
interpolated view. The server keeps one second of history and, when your shot
arrives, rewinds the bot to the tick you were looking at before it tests the hit.
* red outline = where the bot is on the server right now
* green / gray = where the server rewound it for your last shot (hit / miss)
Click to fire at the mouse, SPACE to fire at the bot you see, L to change latency.
The HUD compares the verdict with rewind against the verdict without it.
"""
from collections import deque
from dataclasses import dataclass
import pygame
WIDTH, HEIGHT = 800, 450
TICK_RATE = 60 # server ticks per second
TICK_DT = 1 / TICK_RATE
INTERP_TICKS = 6 # clients draw others 6 ticks (0.1 s) in the past
MAX_REWIND_TICKS = 18 # the server rewinds at most 0.3 s
HISTORY_TICKS = TICK_RATE # keep one second of the past
LATENCIES = [0.03, 0.05, 0.12] # one-way delays to cycle through, seconds
BOT_RADIUS = 20
BOT_SPEED = 260.0 # px/s
SHOOTER = (WIDTH / 2, HEIGHT - 30)
RANGE = 900.0 # px a hitscan shot can reach
@dataclass(frozen=True)
class Shot:
origin: tuple
aim: tuple # the point the player clicked on
render_tick: float # the tick the client was DRAWING when it fired
class LaggyLink:
"""One direction of a connection: in order, nothing lost, `delay` seconds late."""
def __init__(self, delay):
self.delay = delay
self.queue = deque()
def send(self, now, message):
self.queue.append((now + self.delay, message))
def receive(self, now):
out = []
while self.queue and self.queue[0][0] <= now:
out.append(self.queue.popleft()[1])
return out
def state_at(history, tick):
"""The bot's position at a fractional tick, blended from the history.
history holds (tick, (x, y)) pairs, oldest first. Returns None when the tick is
older than anything we kept: the server can't know, so the shot is refused.
"""
if not history or tick < history[0][0]:
return None
if tick >= history[-1][0]:
return pygame.Vector2(history[-1][1]) # newest we have (never the oldest!)
for (ta, pa), (tb, pb) in zip(history, list(history)[1:]):
if ta <= tick <= tb:
span = tb - ta
t = (tick - ta) / span if span else 0.0 # guard: two entries for one tick
return pygame.Vector2(pa).lerp(pb, t)
return pygame.Vector2(history[-1][1])
def ray_hits_circle(origin, aim, center, radius, max_range=RANGE):
"""Hitscan test: does the ray from origin toward aim pass within radius of center?"""
origin, center = pygame.Vector2(origin), pygame.Vector2(center)
direction = pygame.Vector2(aim) - origin
if direction.length_squared() == 0:
return False
direction = direction.normalize()
along = max(0.0, min(max_range, (center - origin).dot(direction)))
closest = origin + direction * along # nearest point of the ray to the center
return closest.distance_squared_to(center) <= radius * radius
class Server:
def __init__(self):
self.tick = 0
self.bot = pygame.Vector2(120, 150)
self.velocity = pygame.Vector2(BOT_SPEED, 0)
self.history = deque(maxlen=HISTORY_TICKS)
self.history.append((self.tick, tuple(self.bot)))
def step(self):
self.tick += 1
self.bot += self.velocity * TICK_DT
if self.bot.x > WIDTH - 60:
self.bot.x, self.velocity.x = WIDTH - 60, -BOT_SPEED
elif self.bot.x < 60:
self.bot.x, self.velocity.x = 60, BOT_SPEED
self.history.append((self.tick, tuple(self.bot))) # a tuple: a copy, not a reference
return self.tick, tuple(self.bot)
def judge(self, shot):
"""Return (verdict, rewound position). Verdicts: 'hit', 'miss', 'too old', 'future'."""
age = self.tick - shot.render_tick
if age < 0:
return "future", None
if age > MAX_REWIND_TICKS:
return "too old", None
past = state_at(self.history, shot.render_tick)
if past is None:
return "too old", None
if ray_hits_circle(shot.origin, shot.aim, past, BOT_RADIUS):
return "hit", past
return "miss", past
def judge_without_rewind(self, shot):
return "hit" if ray_hits_circle(shot.origin, shot.aim, self.bot, BOT_RADIUS) else "miss"
class Client:
def __init__(self):
self.snapshots = deque(maxlen=64) # (tick, (x, y))
self.clock = None # estimated newest snapshot tick, float
def on_snapshot(self, snap):
self.snapshots.append(snap)
if self.clock is None or abs(self.clock - snap[0]) > INTERP_TICKS:
self.clock = float(snap[0])
def advance_clock(self, dt):
if self.clock is not None:
self.clock += dt * TICK_RATE
def render_tick(self):
"""The tick we draw: INTERP_TICKS behind our clock, but only ticks we have received."""
if self.clock is None:
return None
newest, oldest = self.snapshots[-1][0], self.snapshots[0][0]
return max(oldest, min(newest, self.clock - INTERP_TICKS))
def bot_view(self):
rt = self.render_tick()
return None if rt is None else state_at(self.snapshots, rt)
def main():
pygame.init()
screen = pygame.display.set_mode((WIDTH, HEIGHT))
pygame.display.set_caption("Rewind and Hit")
clock = pygame.time.Clock()
font = pygame.font.Font(None, 24)
server, client = Server(), Client()
latency_index = 1
up = LaggyLink(LATENCIES[latency_index])
down = LaggyLink(LATENCIES[latency_index])
now, tick_timer = 0.0, 0.0
counts = {"shots": 0, "rewind hits": 0, "plain hits": 0, "refused": 0}
last = None # (verdict, rewound position)
tracer = None # (aim point, seconds left)
running = True
while running:
dt = min(clock.tick(60) / 1000, 0.1)
now += dt
for event in pygame.event.get():
aim = None
if event.type == pygame.QUIT:
running = False
elif event.type == pygame.MOUSEBUTTONDOWN and event.button == 1:
aim = event.pos
elif event.type == pygame.KEYDOWN and event.key == pygame.K_SPACE:
view = client.bot_view()
aim = tuple(view) if view is not None else None
elif event.type == pygame.KEYDOWN and event.key == pygame.K_l:
latency_index = (latency_index + 1) % len(LATENCIES)
up.delay = down.delay = LATENCIES[latency_index]
if aim is not None and client.render_tick() is not None:
up.send(now, Shot(SHOOTER, tuple(aim), client.render_tick()))
tracer = (aim, 0.25)
tick_timer += dt
while tick_timer >= TICK_DT:
tick_timer -= TICK_DT
down.send(now, server.step())
for shot in up.receive(now):
verdict, past = server.judge(shot)
counts["shots"] += 1
if verdict == "hit":
counts["rewind hits"] += 1
elif verdict in ("too old", "future"):
counts["refused"] += 1
if server.judge_without_rewind(shot) == "hit":
counts["plain hits"] += 1
last = (verdict, past)
for snap in down.receive(now):
client.on_snapshot(snap)
client.advance_clock(dt)
if tracer is not None:
tracer = (tracer[0], tracer[1] - dt) if tracer[1] > dt else None
screen.fill((16, 20, 32))
pygame.draw.circle(screen, (240, 90, 90), server.bot, BOT_RADIUS, 2)
view = client.bot_view()
if view is not None:
pygame.draw.circle(screen, (90, 160, 250), view, BOT_RADIUS)
if last is not None and last[1] is not None:
color = (90, 230, 120) if last[0] == "hit" else (150, 150, 160)
pygame.draw.circle(screen, color, last[1], BOT_RADIUS, 3)
if tracer is not None:
pygame.draw.line(screen, (255, 230, 120), SHOOTER, tracer[0], 2)
pygame.draw.circle(screen, (230, 230, 240), SHOOTER, 10)
one_way = LATENCIES[latency_index]
rewind_ticks = 2 * one_way * TICK_RATE + INTERP_TICKS
lines = [
f"One-way {one_way * 1000:.0f} ms, round trip {2 * one_way * 1000:.0f} ms; "
f"rewind about {rewind_ticks:.0f} ticks (limit {MAX_REWIND_TICKS}) [L]",
f"Shots {counts['shots']}: with rewind {counts['rewind hits']} hits, "
f"without rewind {counts['plain hits']} hits, refused {counts['refused']}",
f"Last verdict: {last[0] if last else '-'} (SPACE: fire at what you see, click: fire at mouse)",
]
for i, line in enumerate(lines):
screen.blit(font.render(line, True, (225, 228, 235)), (12, 10 + i * 22))
pygame.display.flip()
pygame.quit()
print(f"Shots judged: {counts['shots']}")
print(f"With rewind: {counts['rewind hits']} hits, refused {counts['refused']}")
print(f"Without rewind: {counts['plain hits']} hits")
if __name__ == "__main__":
main()
๐ Learning Journal
Take five minutes to write in your learning journal (a notebook or a plain text file works). Jot down:
- Key concepts you learned today
- Techniques that clicked (and the ones that haven't, yet)
- Questions or confusion to bring to the next session
- Ideas to try in your own game
- Progress and feelings: how did this lesson go for you?
โ๏ธ This lesson's prompts:
- Redo the delay table for a player with a 100 ms one-way delay. Would your lab's rewind limit accept their shots?
- You are designing a game's rules. Write two sentences to players explaining why they were sometimes hit just after reaching cover.
๐ Summary
Every shooter sees remote players in the past: about RTT รท 2 plus the interpolation delay when they click, and about RTT plus the interpolation delay by the time the shot reaches the server. Lag compensation keeps a short history of ticks on the server, rewinds each target to the tick the shooter reports drawing, and tests the hit there with a real ray-versus-circle check. It favors the shooter on purpose, and a maximum rewind, a refusal of future ticks and ticks it no longer has keep that favor fair.
๐ Key Takeaways
- Measure network delays in ticks, and keep history, shots and limits in the same unit.
- Store a copy of each tick's positions in a
deque(maxlen=...); forget what you will never rewind to. - Blend between the two history entries around a fractional tick; return nothing for ticks you no longer have and never fall back to the oldest.
- A hitscan hit is the closest point of the ray within the target's radius, with the ray clamped to start at the muzzle.
- Favor the shooter within a maximum rewind, and refuse claims from the future.
๐ญ Looking Ahead
Your networked game now feels responsive and judges shots fairly. Next, in Lobby Server & Client, you build the part players see first: rooms to create and join, ready checks, and a pygame lobby on top of your framing layer. If you want to shrink your snapshots first, read the optional State Sync & Bandwidth.
โ Common Questions
Isn't trusting the client's render tick a security hole?
It is a bounded one. The tick must be in the past and within the rewind limit, and a cheater can only choose a moment they could have seen anyway. Servers that want more protection compare each claim with the player's measured latency and flag claims that don't fit.
Why rewind to the render tick instead of subtracting the ping?
Ping is the round trip. Splitting it in half assumes both directions are equally fast, and the interpolation delay and tick timing add more error. The client knows exactly which tick it drew, so sending that tick removes the guesswork.
How much memory does one second of history cost?
One entry per tick per tracked object. At 60 ticks per second with 32 players, that is under 2,000 small tuples, which is tiny next to the rest of a game. You rarely need more than your maximum rewind plus a little margin.
Do I also rewind the shooter?
The shooter's own position is predicted and reconciled, so the server already knows where they were when they fired to within the latest inputs. A simple choice is the shooter's current server position as the ray's origin; the lab keeps the shooter still to focus on the target.
What if the target died on the server before the shot arrived?
That is a rule for your game to decide. A common choice is that a target who was alive at the rewound tick can still be hit, which is favor-the-shooter again. Whatever you pick, apply it the same way every time and tell players.
๐ฏ Quick Quiz
Question 1: One-way delay is 50 ms each way and remote players are drawn 100 ms behind the newest snapshot. About how old is what the shooter sees when they click?
Question 2: Why does the shot carry a server tick number instead of the client's clock time?
Question 3: A shot names a tick older than the oldest entry in the history. What should state_at() do?
Question 4: Why does the server refuse shots older than MAX_REWIND_TICKS?
Question 5: A target reaches cover on their screen and is hit anyway by a rewound shot. Why do games accept this?
๐ Going Further
- Walls: add a rectangle to the arena and make
judge()reject shots whose ray crosses it before reaching the target. - Two targets: keep a history per bot and hit only the nearest one along the ray.
- Latency check: have the client send each shot's render tick along with the server tick it had most recently received, and flag shots whose claimed age is far from the player's usual age.
- Read more: Valve's developer article Source Multiplayer Networking describes interpolation and lag compensation in a shipped engine.
- Coming up in Game Dev III: Advanced: Lobby Server & Client and Matchmaking & Ratings get players into the same match in the first place.