start / blog / netzwerk
Veröffentlicht: 30. Juni 2026 13 Min. Lesezeit netzwerk
Lag-Kompensation: Warum dein perfekter Schuss nicht gezählt hat

Lag-Kompensation: Warum dein perfekter Schuss nicht gezählt hat

Wie serverseitige Lag‑Kompensation für Shooter tatsächlich gebaut wird: der Rückspul‑Puffer, die Zahlen, die ihn abstimmen, und der Punkt, an dem das Fenster stoppen muss.

Drei Treffer, die im Playtest verschwanden

Im Februar führten wir einen internen Playtest eines 32‑Spieler‑Shooter‑Prototyps durch. Ein Tester verband sich aus Frankfurt mit einem RTT von 78 ms und schrieb nach jeder Runde dieselbe Zeile: zwei Kopfschüsse, keiner gezählt. Die Server‑Logs bestätigten das. In dem Moment, in dem er schoss, stand das Ziel mittig auf seinem Bildschirm, während die Welt‑Server‑Version es 1,4 Meter nach links kannte.

Im multiplayer game development ist das kein Bug, das ist das, was Latenz bewirkt. Der Spieler sieht immer die Vergangenheit, der Server kennt nur die Gegenwart. Jeder Shooter, der diese Lücke nicht schließt, bestraft High‑Ping‑Spieler systematisch. Die Strafe skaliert mit dem Ping, und der Spieler interpretiert sie als schlechtes Zielen statt als Netzwerkproblem.

Der nervige Teil ist, dass es nie wie ein Defekt aussieht. Niemand erstellt einen Crash‑Report, niemand schreibt Reproduktionsschritte; Spieler sagen einfach, das Spiel fühle sich schlecht an und gehen. Solange du es nicht misst, hast du nur den Beschwerdetext.

Also bestand die erste Aufgabe darin, diese Beschwerde in eine Zahl zu übersetzen. Wir fügten ein kleines serverseitiges Log hinzu, das aufzeichnet, durch welche Actor jeder Schuss‑Ray gegangen ist. Für Spieler mit mehr als 40 ms Latenz intersectierten 12 Prozent der Schüsse überhaupt nichts auf dem Server; für Spieler im lokalen Netzwerk waren es 2 Prozent. Diese zehn Punkte waren reine, nicht kompensierte Latenz.

Warum der Server eine Hitbox‑Historie führt

Die Lösung ist lag compensation, aufgebaut auf Server‑Rewind. Jeder Tick schreibt der Server die Hitboxes jedes Charakters in einen Ring‑Buffer, einen Datensatz, den wir FHitboxSnapshot nannten und in LagCompensationComponent.cpp verwalteten. Wenn ein Schuss‑Packet ankommt, rewindet der Server diese Hitboxes zu dem Moment, den der Bildschirm des Spielers zeigte, führt den Ray‑Test dort aus und stellt sofort die Gegenwart wieder her.

Der Buffer hält 64 Ticks, was bei einer 60 Hz‑Simulation 1066 ms Historie entspricht. Ein Snapshot kostet 18 Kapseln mal 32 Byte, also 576 Byte pro Spieler, und bei 32 Spielern über 64 Ticks summiert sich das auf 1,1 MB. Ein Megabyte pro Server‑Instanz ist nichts im Vergleich zu dem, was die fehlenden Treffer kosten.

Während des Rewinds bewegen wir nicht die ganze Welt, nur die Actor, deren Volumen den Ray schneiden kann. Ein gesweeptes FBoxSphereBounds Pre‑Filter reduzierte die Anzahl der zurückgespielten Actor in einem 32‑Spieler‑Match auf durchschnittlich 2,3. Die erste Version, die alle wiederherstellte, brauchte 0,9 ms pro Schuss; nach dem Filter waren es 0,08 ms.

Wann du den Snapshot machst, ist genauso wichtig wie was du speicherst. Unsere erste Version erfasste ihn zu Beginn des Ticks, bevor die Animation aktualisiert wurde, sodass bei einem sprintenden Ziel die Arm‑ und Kopf‑Kapseln ein Frame hinter dem Mesh zurückblieben. Das Verschieben der Erfassung in die PostUpdateWork Tick‑Gruppe entfernte einen systematischen Versatz von 6 bis 9 cm bei schnell bewegenden Zielen.

LagCompensationComponent.cpp
// Rewinds every relevant hitbox to the shooter's view of the world. bool ULagCompensationComponent::RewindAndTrace(const FShotRequest& Shot, FHitResult& OutHit) { // rewind = RTT/2 + client interpolation buffer, clamped on the server const float Rewind = FMath::Clamp( 0.5f * GetMeasuredRtt(Shot.Shooter) + InterpolationDelay, 0.0f, MaxRewindSeconds); // MaxRewindSeconds = 0.200f const int32 Tick = ClampRewindTick(Shot.Tick, Rewind); if (Tick == INDEX_NONE) { // Requested tick sits outside the trusted window: reject and log it. ReportSuspiciousRewind(Shot.Shooter, Shot.Tick); return false; } // Only actors whose swept bounds touch the ray are worth restoring. TArray<AActor*, TInlineAllocator<8>> Candidates; GatherCandidates(Shot.Origin, Shot.Direction, Candidates); for (AActor* Actor : Candidates) Snapshots[Actor].ApplyAt(Tick); const bool bHit = TraceShot(Shot, OutHit); for (AActor* Actor : Candidates) Snapshots[Actor].RestoreCurrent(); return bHit; }
cppLagCompensationComponent.cpp

Die RTT‑ und Interpolations‑Verzögerungs‑Mathe

Wie weit du rewindest, wird nicht durch Ping, sondern durch die Summe zweier Terme entschieden: Einweg‑Latenz und den Interpolations‑Puffer des Clients. Die Formel, die wir verwenden, lautet rewind = RTT / 2 + interpolationDelay. Das Vergessen des zweiten Terms ist der häufigste Fehler, den ich hier sehe.

Ein Client verzögert eingehende Snapshots bewusst, um zwischen ihnen glatt interpolieren zu können. Wenn du Snapshots mit 20 Hz sendest, ist der sichere Puffer zwei Pakete, also 100 ms. Für unseren 78 ms‑Tester war das korrekte Rewind 39 + 100 = 139 ms; die erste Version rewindete nur 39 ms, und das erklärte allein die verschwundenen Treffer.

Nimm diese Zahl niemals vom Client. Ein modifizierter Client kann sein eigenes interpolationDelay aufblasen, um einen Schuss noch weiter in der Vergangenheit aufgelöst zu bekommen. Wir verwenden das RTT, das der Server selbst misst, plus einen festen Puffer, abgeleitet von der Snapshot‑Rate, zu der der Client abonniert hat; das Einzige, was der Client sendet, ist der Tick des Schusses, und selbst das wird begrenzt.

RTT ist ebenfalls keine Konstante, es ist eine rauschende Serie. Statt einer einzelnen Probe mitteln wir das 25. bis 75. Perzentil der letzten 20 Pongs; ein Spike konnte das Rewind auf 300 ms treiben und Treffer‑Ergebnisse zufällig erscheinen lassen. Bei Verbindungen mit mehr als 15 ms Jitter runden wir das Ergebnis nach unten statt nach oben, weil die Kosten des Über‑rewindens vom Spieler getragen werden, der beschossen wird.

Wo das Rewind‑Fenster enden muss

Wir haben es auf 200 ms begrenzt. Unterhalb dieser Schwelle verhält sich die hit registration so, wie Spieler es erwarten; darüber hinaus wird Latenz zum Vorteil und man beginnt Ziele zu töten, die bereits um die Ecke gebrochen sind und hinter einer Wand stehen.

Die Beschwerde, hinter Deckung zu sterben, ist der direkte Preis für den Gewinn des kompensierten Spielers. Null‑Komensation bestraft den High‑Ping‑Spieler, unbegrenzte Komensation bestraft den Low‑Ping‑Spieler. Für uns war 200 ms der Punkt, an dem sich die beiden Beschwerdekurven kreuzten: Bei 250 ms verdoppelten sich die Deckungsbeschwerden, bei 150 ms sank die Trefferquote ausländischer Spieler um 6 Prozent. Den Wert in der Konfiguration behalten, weil Kartenmaßstab und Projektilgeschwindigkeit diesen Schnittpunkt verschieben können.

Das Fenster ist zudem eine Angriffsfläche. Wenn der Tick vom Client kommt, kann ein cheatender Client einen Tick von vor drei Sekunden senden und die alte Position des Ziels anvisieren. ClampRewindTick() prüft sowohl das absolute Limit als auch einen gleitenden Durchschnitt, der aus den letzten 32 Paketen dieses Clients gebaut wird; überschreitet die Abweichung 60 ms, wird die Anfrage abgelehnt und das Ereignis geloggt.

Dann gibt es den Schuss, der nach dem Tod landet. In der zurückgespulten Welt ist das Ziel noch am Leben, in der aktuellen Welt ist es vor 40 ms gestorben. Das akzeptieren wir: War der Schütze noch am Leben, als das Paket den Server erreichte, bleibt der Treffer gültig. Die Gegenregel würde jede Kugel eines High‑Ping‑Spielers zu einem unsichtbaren Würfelwurf machen.

Wie es sich zu Prediction und Reconciliation verhält

Lag‑Compensation steht nicht für sich allein; sie muss eine Timeline mit Client‑Prediction teilen. Wenn der Client seine eigene Bewegung für Tick N vorhersagt, während der Server Tick N‑8 verarbeitet, muss der für das Rewind genutzte Tick diesen Offset berücksichtigen. Dieser Offset wird als Konstante eingebrannt und die Kompensation driftet, sobald die Serverlast steigt, ohne dass jemand den Grund benennen kann.

In unserem Build laufen beide über einen einzigen Zähler. UPredictedMovementComponent versieht jede Eingabe mit einer Tick‑Nummer, dieselbe Nummer wird im Schuss‑Packet mitgeschickt, und während der server reconciliation nutzt der Server sie, um Bewegung zu validieren und Hitboxes zurückzuspulen. In einem früheren Versuch mit zwei separaten Zeitquellen erzeugte das ein‑zu‑zwei‑Tick‑Driften etwa 30 cm Zielungsfehler bei schnellen Strafes.

Verwechsle das nicht mit rollback netcode. Rollback spult die gesamte Simulation zurück und spielt sie erneut, was in einem Fighting‑Game machbar ist. In einem Shooter rollen wir nur Hitboxes zurück; Physik, Projektile und Spielzustand laufen weiter vorwärts. Als wir Full‑Rollback mit 32 Spielern probierten, stieg der Server‑Tick von 4,1 ms auf 19 ms, was die Diskussion beendete.

Wenn der Server antwortet, ist das Teil derselben Kette. Man kann einen Tracer, den der Client bereits für einen Schuss gezeichnet hat, den der Server ablehnt, nicht unspielen, also senden wir die Trefferbestätigung in einem eigenen kleinen Packet und zeichnen den Hitmarker erst, wenn der Server zugestimmt hat. Bei einer 78‑ms‑Verbindung bedeutet das, dass der Marker 40 ms zu spät ankommt, was immer noch viel weniger Beschwerden erzeugt als ein falscher Marker.

Wo man anfangen sollte und was man messen muss

Das erste, was man bauen sollte, ist ein Diagnose‑Tool, das man mit eigenen Augen sehen kann. Ein DrawDebugCapsule-Layer, der sowohl die aktuellen als auch die zurückgespulten Hitboxes zum Zeitpunkt eines Treffers zeichnet, zeigte mir mehr als jede Vermutung: etwa 70 Prozent der fehlenden Treffer kamen von ignoriertem Interpolationspuffer, der Rest von Hitboxes, die sich eine Frame zu spät an die Animationspose binden.

Danach testet man unter synthetischer Latenz. Mit clumsy fügen wir 60, 120 und 250 ms Einweg‑Verzögerung hinzu und wiederholen das gleiche geskriptete Schussszenario 200 mal; bleibt die Trefferquote über alle drei Profile hinweg innerhalb einer 3‑Prozent‑Bandbreite, macht die Kompensation ihren Job. Diese Laufzeit in die Pre‑Release‑Checkliste aufnehmen, weil jede Änderung am Bewegungs‑Code sie brechen kann.

Jede abgelehnte Rewind‑Anfrage zusammen mit dem Account, der sie gesendet hat, speichern. Dieses Log liefert uns vier bis fünf Accounts pro Monat, und keiner davon wurde von clientseitiger Erkennung erwischt. Das Begrenzen des Fensters hält das Spiel nicht nur fair, sondern macht Cheat‑Detection zu etwas, das man aus Daten liest statt zu raten.

Ein letzter Hinweis: Die Erklärung des Kompensationsfensters für Spieler funktionierte besser, als es zu verbergen. Seit wir eine einzelne Zeile auf dem Todesbildschirm hinzugefügt haben, die die Latenz des Killers anzeigt, haben sich die Deckungsbeschwerden nicht reduziert, aber ihr Ton hat sich geändert. Die Leute hörten auf, anzunehmen, es sei Cheating.

← Alle Beiträge