Skip to content

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.

Each session gets a fixed-capacity ring buffer of (time, position) samples:

Capacity = math.ceil(SampleHz × HistorySeconds) -- 20 samples at defaults

A 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.

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 that HitToleranceStuds absorbs 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.

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 as OriginMismatch.
  • 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.

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.

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?,
}

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 as Rewind.DefaultHitboxRadius / Rewind.DefaultHitboxHalfHeight). Pass hitbox = { 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.

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").ChloeKernelServer
local 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)
end

The client’s only job is aiming and stamping:

-- Client: stamp with the shared clock at the moment of firing
local 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.

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.
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.