Skip to content

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.

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

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

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

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

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 as OriginMismatch.
  • AntiExploit.Forgive on 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 at Health <= 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.

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.

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.

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

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

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:

src/Shared/Game/Channels.luau
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:

src/Server/Game/Services/ShootingService.luau
--!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 = 300
local 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.01
end
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 = true
end
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 ShootingService

The client’s only job is to aim and stamp:

-- In a controller: stamp with the shared clock at the moment of firing
self.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.

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

ChloeKernel v0.9.1. Access to the framework is private and granted individually by Chloe. Games built with it credit CryptedChloes in their description.