Skip to main content

Lesson 20: Lag Compensation

  • Module 10: Real-time Multiplayer
  • Lesson 20 of 27
  • โฑ๏ธ About 1 h 30 min (instruction + lab)

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:

DelayWhyTicks
Snapshot travel, server to clientThe newest snapshot you have is already one-way latency old3
Interpolation delayYou draw remote players behind the newest snapshot6
Age of what you see when you clickabout RTT รท 2 + interpolation9
Shot travel, client to serverYour shot takes one-way latency to arrive3
Rewind needed when the shot arrivesabout RTT + interpolation12

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.

sequenceDiagram participant S as Server participant C as Shooter's client S->>C: snapshot of tick 100, arrives at server tick 103 Note over C: client clock 100, draws tick 100 minus 6 = 94, player clicks C->>S: FIRE, I was drawing tick 94 Note over S: arrives at tick 106, rewinds 12 ticks to tick 94

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:

  1. 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.
  2. 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 - ta would 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.

  1. Run the starter and press Space a few times. Every shot is a miss, with or without rewind. (โ‰ˆ 2 min)
  2. Write state_at(): None for 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)
  3. Write ray_hits_circle() with the projection from A Real Hit Test (comment 2). Now rewound shots hit. (โ‰ˆ 8 min)
  4. In judge(), refuse shots from the future and shots older than MAX_REWIND_TICKS before rewinding (comment 3). (โ‰ˆ 5 min)
  5. 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:

  1. Redo the delay table for a player with a 100 ms one-way delay. Would your lab's rewind limit accept their shots?
  2. 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.