Rewind
Keeps a short position history per player so the server can validate stamped shots and cast rays at where targets stood when the shooter fired.
- Realm
- Server
- Require
ServerScriptService.ChloeKernelServer .AntiExploit .Rewind - Since
- v0.1.0
- Source
- AntiExploit/Rewind.luau
Rewind lets the server answer where each player was when a shooter fired; current state only says where they are now.
It keeps a short position history per player, validates each stamped shot claim against positions sampled at the
claimed time, and casts rays at capsule hitboxes placed at those rewound positions. A 150 ms player hits what they aimed
at, and a claim that could not have happened is rejected with a machine-readable reason. The client never reports a hit. It sends
an aim claim (a direction and a timestamp), and the server rewinds, validates, casts and decides.
WeaponKit runs this whole pipeline when given a Rewind instance, and
Projectiles uses it to validate fire claims. Server moves reset the history through the same
AntiExploit.Forgive topic the Movement Monitor uses.
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 of (time, position) samples:
Capacity = math.ceil(SampleHz * HistorySeconds) -- 20 samples at defaultsOne Scheduler job at Priority.Low pushes a sample per player every 1 / SampleHz seconds (50 ms
at the default 20 Hz). The job is created when the first session is tracked and cancelled when the last one ends, so an
empty server has no Rewind wakeups. The HumanoidRootPart is cached per character and looked up again only when the
character changes or the cached root loses its parent, and the Humanoid is looked up until it is found for that root.
Pushing past capacity overwrites the oldest slot, so memory per player is constant.
The ring stores positions without orientation. Hitboxes are vertical capsules, and a capsule is rotation-invariant, so the history never needs to know which way a target faced.
Sampling at a timestamp
Section titled “Sampling at a timestamp”Rewind.sampleAt(buffer, timestamp) resolves a claimed time to a position:
- At or after the newest sample, it returns the newest position. “Now” and small future skew both resolve to where the target is.
- Between two samples, it walks back from the head and interpolates linearly between the bracketing samples. At 20 Hz the
reconstruction error stays inside what
HitToleranceStudsabsorbs. - Older than the oldest sample, it returns nil. The history cannot answer, so the claim cannot pass.
The spec pins all three with exact values, plus wraparound order after the ring overwrites old slots and an empty buffer.
When history resets
Section titled “When history resets”A buffer whose samples span a discontinuity would interpolate across it and produce positions nobody occupied. Three events therefore discard a player’s history:
- A respawn (
CharacterAdded) gives the player a new, empty buffer. A buffer spanning death and spawn would interpolate across the map and reject honest shots after the respawn asOriginMismatch. AntiExploit.Forgiveon the Bus gives the player a new buffer. Server teleports corrupt history the same way respawns do, and the publish that pardons the Movement Monitor covers Rewind too.- A sample that finds no
HumanoidRootPart, or a Humanoid atHealth <= 0, empties the buffer instead of pushing. A corpse has no capsule, so a shot passes through it to a live target behind, and a player without a character (a lobby,CharacterAutoLoads = false) is never hit. A root without any Humanoid still samples.
After a reset the player has partial history for the next HistorySeconds, and claims older than the oldest sample
reject as NoShooterHistory or NoTargetHistory until the buffer refills.
The shared clock
Section titled “The shared clock”Timestamps work only when both sides stamp with the same clock. The client stamps workspace:GetServerTimeNow() at the
moment it fires, and the server’s default Clock is the same function. GetServerTimeNow() is the engine’s
server-synchronized clock, so the client’s stamp is already in server time and no round-trip estimate or handshake is
needed. Clock is configurable so specs can drive time by hand.
How far a shot may rewind
Section titled “How far a shot may rewind”The timestamp is chosen by the client. If the only bound were the length of the history, a 20 ms player could backdate
every shot by a full second and hit targets where they stood a second ago. validateShot therefore bounds the rewind per
shooter:
MaxPast = min(HistorySeconds, Ping + InterpolationSeconds + PastSlackSeconds)Ping is the shooter’s round trip in seconds. InterpolationSeconds (0.15) is how far behind the server clients render
other players, and PastSlackSeconds (0.1) covers frame time and ping jitter, so with defaults a shot may be as old as
the round trip plus 0.25 s, never more than 1 s. A 50 ms player gets a 0.3 s window: a 0.15 s-old shot passes and a
0.6 s-old one rejects as StaleTimestamp.
The ping comes from PingProvider(player). The default reads NetGovernor’s median ping when
a governor is attached (steady through jitter spikes) and falls back to Player:GetNetworkPing(), which the engine
documents as the round trip in seconds. A ping that cannot be read (the provider throws, or returns a non-finite or
negative number) keeps the HistorySeconds cap.
Validating a shot
Section titled “Validating a shot”lag:validateShot(claim) checks a stamped claim and returns (ok, reason?). The checks run in this order and the first
failure wins:
| Order | Reason | Rejects when |
|---|---|---|
| 1 | InvalidTimestamp |
Timestamp is not a finite number. |
| 2 | InvalidOrigin |
Origin is not a Vector3 with finite components. |
| 3 | InvalidDirection |
Direction is not a finite Vector3, or its magnitude is at most 1e-6. |
| 4 | InvalidRange |
MaxRange is not finite, or is at most 0. |
| 5 | InvalidHitPosition |
HitPosition is present and not a finite Vector3. |
| 6 | IncompleteHitClaim |
Only one of TargetPlayer and HitPosition is present. |
| 7 | FutureTimestamp |
Timestamp > Now + MaxFutureSeconds. The 0.2 s allowance absorbs real clock skew. |
| 8 | StaleTimestamp |
Now - Timestamp > MaxPast (see How far a shot may rewind). This is also the replay bound. |
| 9 | NoShooterHistory |
The shooter has no sample at the timestamp: just joined, respawned, forgiven, or dead before the shot was validated. |
| 10 | OriginMismatch |
(Origin - ShooterAt).Magnitude > OriginToleranceStuds. The claimed muzzle is not where the shooter was. |
| 11 | OutOfRange |
Hit claims only: (HitPosition - Origin).Magnitude > MaxRange. |
| 12 | DirectionMismatch |
Hit claims only: Direction.Unit:Dot((HitPosition - Origin).Unit) < DirectionDotMin (0.94, about 20 degrees). Skipped when either vector is shorter than 1e-3. |
| 13 | NoTargetHistory |
Hit claims only: the target has no sample at the timestamp. |
| 14 | HitMismatch |
Hit claims only: (HitPosition - TargetAt).Magnitude > HitToleranceStuds. |
Checks 11 to 14 run only when the claim carries both TargetPlayer and HitPosition. The reasons are strings so a
violation policy can treat them differently. The Invalid* reasons mean the claim carried non-finite or degenerate
values, which an honest client stamping GetServerTimeNow() and a camera direction never produces. StaleTimestamp, NoShooterHistory and OriginMismatch do happen to honest players (a
lag spike, a death between firing and validation, a tolerance that is too tight), so count them for tuning before
punishing them.
The claim shape:
export type ShotClaim = { Shooter: Player, Origin: Vector3, Direction: Vector3, Timestamp: number, -- the client's workspace:GetServerTimeNow() when it fired MaxRange: number, TargetPlayer: Player?, -- optional claimed hit, cross-checked by checks 11 to 14 HitPosition: Vector3?,}Hit detection against rewound positions
Section titled “Hit detection against rewound positions”lag: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. It returns (player?, hitPosition?, distance?).
- Players with an empty history at that time are skipped. That covers the dead and the characterless, whose buffers were emptied by the sampler.
- The default capsule is
Radius = 1.6,HalfHeight = 2.4(Rewind.DefaultHitboxRadiusandRewind.DefaultHitboxHalfHeight). Passhitbox = { Radius?, HalfHeight? }per weapon to widen or tighten it. - A ray that starts inside a capsule hits at distance 0 (point blank).
- The shooter’s own history 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 capsules it tested, green
for the hit target and red for the misses. They are ForceField parts under workspace.CKHitboxDebug, destroyed after
0.4 s. The drawing never affects results. Rewind.drawHitbox(position, radius, halfHeight, color) draws one capsule on
demand.
WeaponKit with Rewind = Rewind.attach(kernel) covers standard hitscan weapons. A game with its own
fire path declares the channel:
Fire = { Kind = "Intent", Schema = { "Vector3F24", "NumberF64" }, RateLimit = 8, Validate = { [2] = { Range = { 0, math.huge } } }, -- no NaN or negative stamps; validateShot bounds the age},and runs the pipeline in a service:
--!strict-- ShootingService: a hand-written hitscan fire path over Rewind.
local Players = game:GetService("Players")local ServerScriptService = game:GetService("ServerScriptService")
local Rewind = require(ServerScriptService.ChloeKernelServer.AntiExploit.Rewind)
local Range = 300local MuzzleOffset = Vector3.new(0, 1.5, 0)
local ShootingService = { Name = "ShootingService",}
local function unitDirection(value: any): boolean return typeof(value) == "Vector3" and value.X == value.X and value.Y == value.Y and value.Z == value.Z and value.Magnitude > 0.99 and value.Magnitude < 1.01end
function ShootingService.init(self: any, kernel: any) self.Kernel = kernel self.Lag = Rewind.attach(kernel) -- World pass: colliding geometry only, never a character self.WorldParams = RaycastParams.new() self.WorldParams.RespectCanCollide = trueend
function ShootingService.start(self: any) local Kernel = self.Kernel local Lag = self.Lag self.StopValidator = Kernel.Hooks:on("Intent.Fire", function(context) return unitDirection(context.Args[1]) end, 10)
self.Net.Fire.on(function(session, direction: Vector3, timestamp: number) local Character = session.Player.Character local Root = Character and Character:FindFirstChild("HumanoidRootPart") :: BasePart? if not Root then return end -- Anchor the muzzle to where the shooter was at the claimed time. The current -- root rejects honest high-ping shots on the origin tolerance. local ShooterAt = Lag:getPositionAt(session.Player, timestamp) local Origin = (ShooterAt or Root.Position) + MuzzleOffset local Ok, Reason = Lag:validateShot({ Shooter = session.Player, Origin = Origin, Direction = direction, Timestamp = timestamp, MaxRange = Range, }) if not Ok then Kernel.Bus:publish("Shooting.Rejected", session.Player, Reason) return end
local Target, HitPosition, Distance = Lag:castRay(session.Player, Origin, direction, Range, timestamp) if not Target or not Distance then return end -- Walls do not rewind. Bodies standing in the path now must not block a shot -- aimed at where the target was, so the world pass skips every character. local Characters = {} for _, Other in Players:GetPlayers() do if Other.Character then table.insert(Characters, Other.Character) end end self.WorldParams.ExcludeInstances = Characters if workspace:Raycast(Origin, direction * Distance, self.WorldParams) == nil then Kernel.Bus:publish("Shooting.Hit", session.Player, Target, HitPosition) end end)end
function ShootingService.stop(self: any) if self.StopValidator then self.StopValidator() end self.Lag:detach()end
return ShootingServiceThe client’s only job is to aim and stamp:
-- In a controller: stamp with the shared clock at the moment of firingself.Net.Fire.fire(workspace.CurrentCamera.CFrame.LookVector, workspace:GetServerTimeNow())No hit report, target id or damage number travels from client to server. When the shooter has no history at the
timestamp, the origin falls back to the current root and validateShot returns the precise reason (StaleTimestamp,
NoShooterHistory). WeaponKit also caches the character list and rebuilds it only after a join, leave or respawn.
Configuration
Section titled “Configuration”Rewind.attach(kernel, config?) merges the table over these defaults:
| Option | Default | What it controls |
|---|---|---|
SampleHz |
20 |
Position samples per second per player. |
HistorySeconds |
1 |
Ring length, and the absolute cap on how far a shot may rewind. |
InterpolationSeconds |
0.15 |
How far behind the server clients render other players. Part of the per-shooter rewind bound. |
PastSlackSeconds |
0.1 |
Frame time and ping jitter on top of the round trip. Part of the per-shooter rewind bound. |
OriginToleranceStuds |
8 |
Allowed distance between the claimed origin and the shooter’s rewound position: muzzle offset plus one sample of movement. |
HitToleranceStuds |
6 |
Allowed distance between a claimed hit and the target’s rewound position: body volume plus interpolation error. |
MaxFutureSeconds |
0.2 |
How far ahead of the server clock a timestamp may sit. |
DirectionDotMin |
0.94 |
Minimum dot between the claimed direction and the origin-to-hit vector for hit claims. |
ShowHitboxes |
false |
Draw the tested capsules on every castRay. |
Clock |
workspace:GetServerTimeNow() |
Must match the clock the client stamps with. Configurable for specs. |
PingProvider |
NetGovernor median, else GetNetworkPing() |
(player) -> number, the round trip in seconds. |
With HistorySeconds = 1, the per-shooter bound reaches the cap at a 750 ms round trip. Raising HistorySeconds helps
only shooters above that, and costs a longer window in which a captured stamp stays valid for them.
| Member | Description |
|---|---|
Rewind.attach(kernel, config?) |
Returns the instance. Provisions a buffer per session, resets it on respawn and on AntiExploit.Forgive, and samples only while a session is tracked. |
lag:validateShot(claim) |
Validates a ShotClaim. Returns (true, nil) or (false, reason) with one of the fourteen reasons above. |
lag:castRay(shooter, origin, direction, range, timestamp, hitbox?) |
Nearest lag-compensated capsule hit among tracked players with history at timestamp, shooter excluded. Returns (player?, hitPosition?, distance?). World obstruction is the caller’s job. |
lag:getPositionAt(player, timestamp) |
The rewound position, or nil when the history cannot answer. |
lag:showHitboxes(enabled) |
Toggles the debug capsule display. |
lag:detach() |
Cancels the sample job, disconnects the session and Bus subscriptions, and clears every buffer. |
Rewind.rayCapsule(origin, unitDirection, range, center, radius, halfHeight) |
Pure ray and vertical capsule intersection. Returns the distance, 0 when the origin is inside, or nil. |
Rewind.drawHitbox(position, radius, halfHeight, color) |
Draws one debug capsule that lives 0.4 s. |
Rewind.newBuffer(capacity) |
A pure ring buffer, for tests and custom history tracking. |
Rewind.push(buffer, time, position) |
Appends a sample, overwriting the oldest once full. |
Rewind.sampleAt(buffer, timestamp) |
Interpolated position at timestamp, the newest position at or after the head, nil when too old or empty. |
Rewind.DefaultHitboxRadius |
1.6, the default capsule radius. |
Rewind.DefaultHitboxHalfHeight |
2.4, the default half height of the capsule body. |
Hooks & bus topics
Section titled “Hooks & bus topics”| Kind | Name | Payload | Notes |
|---|---|---|---|
| Bus (consumed) | AntiExploit.Forgive |
player |
Resets that player’s history. Publish it before any server-initiated teleport; the same publish pardons the Movement Monitor. |
Rewind publishes nothing. Rejections are return values, because the response is per-game policy. WeaponKit turns them
into Weapon.ShotRejected(player, reason).