Rewind (Lag Compensation)
Rewind answers one question the server otherwise cannot: where was this player at the moment the shooter fired? It keeps a short position history per player and validates every stamped shot against positions sampled at the claimed timestamp — so a 150ms player hits what they actually aimed at, and a shot that could not have happened rejects with a machine-readable reason. The doctrine underneath it: the client never reports hits. The client sends an aim claim (origin, direction, timestamp); the server rewinds, raycasts, and adjudicates.
Mental model
Section titled “Mental model”The position-history ring buffer
Section titled “The position-history ring buffer”Each session gets a fixed-capacity ring buffer of (time, position) samples:
Capacity = math.ceil(SampleHz × HistorySeconds) -- 20 samples at defaultsA Scheduler task pushes one sample per player every 1 / SampleHz seconds (50ms at the default 20Hz) at Priority.Low, reading the cached HumanoidRootPart (the root is re-resolved only when the character changes, not per sample). Pushing past capacity overwrites the oldest slot — memory per player is constant and tiny.
Only positions are stored, no orientation. That is deliberate: hitboxes are vertical capsules, and capsules are rotation-invariant, so history never needs to remember which way a target was facing.
Sampling at a timestamp
Section titled “Sampling at a timestamp”sampleAt(buffer, timestamp) resolves a claimed time to a position:
- At or after the newest sample — clamps to the newest position. “Now” and small future skew both resolve to where the target currently is.
- Between two samples — walks back from the head and linearly interpolates (
Lerp) between the bracketing samples. A 20Hz buffer plus interpolation keeps the reconstruction error small enough thatHitToleranceStudsabsorbs it. - Older than the oldest sample — returns
nil. History is exhausted; the claim cannot be checked, so it cannot pass.
All three behaviors, plus wraparound ordering after the ring overwrites old slots, are spec-verified with exact values.
When history resets
Section titled “When history resets”A buffer whose samples span a discontinuity would interpolate across it and produce positions nobody ever occupied. Two events therefore throw the buffer away and start fresh:
- Respawn (
CharacterAdded) — a buffer spanning death → spawn would interpolate across the map and reject honest post-respawn shots asOriginMismatch. AntiExploit.Forgive(bus) — server teleports corrupt history the same way respawns do. The same publish that pardons the Movement Monitor resets the Rewind buffer, so one announcement covers both systems.
The cost of a reset is honest: for the next HistorySeconds window the player has partial history, and claims older than the oldest sample reject as NoShooterHistory/NoTargetHistory until the buffer refills.
The shared clock
Section titled “The shared clock”Timestamps only work if both sides stamp with the same clock. The client stamps workspace:GetServerTimeNow() at the moment of firing; the server’s default Clock is the same function. GetServerTimeNow() is Roblox’s server-synchronized clock, so the client’s stamp is already in server time — no RTT estimation, no handshake. Clock is injectable in config purely so specs can drive time deterministically.
Validating a shot
Section titled “Validating a shot”validateShot(claim) checks a stamped claim and returns (ok, reason?). The checks run in a fixed order; the first failure wins:
| Order | Reason | Rejects when |
|---|---|---|
| 1 | FutureTimestamp |
Timestamp > Now + MaxFutureSeconds — a stamp from the future. 0.2s of allowance absorbs real clock skew. |
| 2 | StaleTimestamp |
Now - Timestamp > HistorySeconds — older than the buffer can answer for. Also the replay bound: a captured stamp dies in one second. |
| 3 | NoShooterHistory |
The shooter has no sample at the timestamp (just joined, just respawned, just forgiven). |
| 4 | OriginMismatch |
` |
| 5 | OutOfRange |
Claimed-hit only: ` |
| 6 | NoTargetHistory |
Claimed-hit only: the target has no sample at the timestamp. |
| 7 | HitMismatch |
Claimed-hit only: ` |
Checks 5–7 only run when the claim carries TargetPlayer and HitPosition. Reasons are machine-readable strings precisely so your violation policy can differentiate — FutureTimestamp is essentially always hostile; OriginMismatch from a live player might be tolerance tuning (see the Status note below).
The claim shape:
export type ShotClaim = { Shooter: Player, Origin: Vector3, Direction: Vector3, Timestamp: number, -- client's workspace:GetServerTimeNow() at fire time MaxRange: number, TargetPlayer: Player?, -- optional claimed-hit cross-check HitPosition: Vector3?,}Hit detection against rewound positions
Section titled “Hit detection against rewound positions”castRay(shooter, origin, direction, range, timestamp, hitbox?) performs the lag-compensated cast: for every tracked player except the shooter, it rewinds their position to timestamp and intersects the ray with a vertical capsule (cylinder body plus sphere caps) centered there. The nearest hit wins. Returns (player?, hitPosition?, distance?).
- Default capsule:
Radius = 1.6,HalfHeight = 2.4(exported asRewind.DefaultHitboxRadius/Rewind.DefaultHitboxHalfHeight). Passhitbox = { Radius?, HalfHeight? }per weapon — widen for forgiving weapons, tighten for precision ones. - A ray starting inside a capsule hits at distance
0(point blank). - The shooter’s own buffer is never a target.
- The capsule math is the pure function
Rewind.rayCapsule— spec-verified for surface hits, grazes, cap domes, point blank, and range limits.
Debug visualization
Section titled “Debug visualization”lag:showHitboxes(true) (or ShowHitboxes = true in config) makes every castRay draw the rewound capsules it tested — green for the hit target, red for misses — as ForceField-material parts under workspace.CKHitboxDebug, auto-destroyed after 0.4s. Purely visual; it never affects results. Rewind.drawHitbox(position, radius, halfHeight, color) draws one on demand.
The full pipeline, modeled on WeaponKit’s fire path (pass Rewind = Lag to WeaponKit or Projectiles and they run this for you):
local Root = game:GetService("ServerScriptService").ChloeKernelServerlocal Rewind = require(Root.AntiExploit.Rewind)
return function(kernel) local Lag = Rewind.attach(kernel) local Net = kernel:net()
Net:defineIntent("Fire", { "Vector3F24", "NumberF64" }, { RateLimit = 8 }) kernel.Hooks:on("Intent.Fire", function(context) local Direction, Timestamp = context.Args[1], context.Args[2] return typeof(Direction) == "Vector3" and Direction.Magnitude > 0.99 and Direction.Magnitude < 1.01 and type(Timestamp) == "number" and Timestamp == Timestamp end)
Net:onIntent("Fire", function(session, direction, timestamp) -- Anchor the muzzle to where the shooter WAS at claim time. Using the -- current root position rejects honest high-ping shots on origin tolerance. local ShooterAt = Lag:getPositionAt(session.Player, timestamp) if not ShooterAt then return end local Origin = ShooterAt + Vector3.new(0, 1.5, 0)
local Ok, Reason = Lag:validateShot({ Shooter = session.Player, Origin = Origin, Direction = direction, Timestamp = timestamp, MaxRange = 300, }) if not Ok then kernel.Bus:publish("Weapon.ShotRejected", session.Player, Reason) return end
-- Server adjudicates: rewound capsules first, then world obstruction. local Target, HitPosition, Distance = Lag:castRay(session.Player, Origin, direction, 300, timestamp) if Target and HitPosition and Distance then local Params = RaycastParams.new() local Ignore = {} for _, Other in game:GetService("Players"):GetPlayers() do if Other.Character then table.insert(Ignore, Other.Character) end end Params.FilterDescendantsInstances = Ignore Params.FilterType = Enum.RaycastFilterType.Exclude if workspace:Raycast(Origin, direction * Distance, Params) == nil then kernel.Bus:publish("Weapon.Hit", session.Player, Target, HitPosition) end end end)endThe client’s only job is aiming and stamping:
-- Client: stamp with the shared clock at the moment of firinglocal Fire = Net:intent("Fire", { "Vector3F24", "NumberF64" })Fire:Fire(Camera.CFrame.LookVector, workspace:GetServerTimeNow())No hit report, no target id, no damage number ever travels client → server.
Configuration
Section titled “Configuration”Rewind.attach(kernel, config?) merges your table over these defaults:
| Option | Default | What it controls |
|---|---|---|
SampleHz |
20 |
Position samples per second per player. |
HistorySeconds |
1 |
How far back claims can reach. Also the staleness bound and the replay window. |
OriginToleranceStuds |
8 |
Allowed distance between claimed origin and the shooter’s rewound position — budget for muzzle offset plus one sample of legit movement. |
HitToleranceStuds |
6 |
Allowed distance between a claimed hit and the target’s rewound position — budget for character volume plus interpolation error. |
MaxFutureSeconds |
0.2 |
How far ahead of the server clock a timestamp may sit. Absorbs real clock skew. |
ShowHitboxes |
false |
Draw tested capsules on every castRay. |
Clock |
workspace:GetServerTimeNow() |
Must match the clock the client stamps with. Injectable for deterministic specs. |
Raising HistorySeconds extends the rewind reach for very-high-ping players, at the cost of a wider window in which stale-but-captured timestamps remain valid. One second covers ~500ms of one-way latency with room to spare.
| Member | Description |
|---|---|
Rewind.attach(kernel, config?) → Rewind |
Starts the sample loop, provisions per-session buffers, subscribes AntiExploit.Forgive. |
lag:validateShot(claim) → (boolean, string?) |
Validates a ShotClaim; on failure the string is one of the seven reasons above. |
lag:castRay(shooter, origin, direction, range, timestamp, hitbox?) → (Player?, Vector3?, number?) |
Nearest lag-compensated capsule hit among tracked players (shooter excluded). World obstruction is the caller’s job. |
lag:getPositionAt(player, timestamp) → Vector3? |
Raw rewound position; nil when history cannot answer. |
lag:showHitboxes(enabled) |
Toggles the debug capsule display. |
lag:detach() |
Cancels the sample loop, disconnects subscriptions, clears buffers. |
Rewind.rayCapsule(origin, unitDirection, range, center, radius, halfHeight) → number? |
Pure ray-vs-capsule intersection distance; 0 when starting inside. |
Rewind.drawHitbox(position, radius, halfHeight, color) |
Draws one debug capsule (0.4s lifetime). |
Rewind.newBuffer(capacity) / Rewind.push(buffer, time, position) / Rewind.sampleAt(buffer, timestamp) → Vector3? |
The pure ring-buffer primitives, exported for tests and custom history tracking. |
Rewind.DefaultHitboxRadius / Rewind.DefaultHitboxHalfHeight |
1.6 / 2.4 — the default capsule. |
Hooks & bus topics
Section titled “Hooks & bus topics”| Kind | Name | Payload | Notes |
|---|---|---|---|
| Bus (consumed) | AntiExploit.Forgive |
player |
Resets that player’s history buffer. Publish before any server-initiated teleport — the same publish pardons the Movement Monitor. |
Rewind publishes nothing itself: rejects are return values, because the right response is per-game policy. WeaponKit, for example, turns them into Weapon.ShotRejected bus events.