Skip to content

Combat & Anti-Exploit

Every recipe here rides the same spine: the server owns the outcome, validation is fail-closed, and both sides share one clock (workspace:GetServerTimeNow()). Server snippets go in src/Server/Bootstrap.luau, client snippets in src/Client/Bootstrap.luau — see Getting Started for the bootstrap shape.

Attach the movement monitor and decide your own punishment policy:

-- src/Server/Bootstrap.luau
local Root = game:GetService("ServerScriptService").ChloeKernelServer
local Movement = require(Root.AntiExploit.Movement)
return function(kernel)
local Monitor = Movement.attach(kernel) -- defaults: 1.4× speed tolerance, 80-stud teleport
kernel.Hooks:on("AntiExploit.MovementViolation", function(context)
-- context: Player, Session, Type ("Speed"|"Teleport"), HorizontalSpeed, Displacement, Strikes
if context.Strikes >= 5 then
context.Player:Kick("Suspicious movement")
end
end)
end

Detection is the kernel’s job; the punishment policy is yours. The monitor sweeps every 0.2 seconds and evaluates each sample with pure math: horizontal speed against the humanoid’s WalkSpeed times SpeedTolerance (1.4), and single-sample displacement against TeleportStuds (80, prorated up when a sweep stretches so a missed frame never flags normal running). WalkSpeed is client-owned and forgeable, so it is only trusted up to MaxTrustedWalkSpeed (100) — reporting math.huge buys nothing.

Rubberbanding is automatic after repeated strikes (RubberbandStrikes, default 2): the character’s position rewinds to the last valid sample while keeping its facing. Strikes decay after 10 quiet seconds. And a flagged position never becomes the trusted baseline — exploiters can’t launder a teleport through strikes. Fresh spawns and seated players reset the baseline instead of being evaluated, and SetNetworkOwner is never called, so the client keeps movement ownership throughout.

The same verdict also publishes on the bus as AntiExploit.MovementViolation(player, violationType, strikes) for systems that only want to count. Deep dive: Movement Monitor.

The server keeps a position history ring and validates shots against where targets were at the client’s timestamp — high-ping players hit what they aimed at; impossible shots are rejected:

-- src/Server/Bootstrap.luau
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("FireShot", { "Vector3F32", "Vector3F24", "NumberF64" }, { RateLimit = 10 })
kernel.Hooks:on("Intent.FireShot", function(context)
local Ok, Reason = Lag:validateShot({
Shooter = context.Player,
Origin = context.Args[1],
Direction = context.Args[2],
Timestamp = context.Args[3], -- client stamps workspace:GetServerTimeNow()
MaxRange = 300,
})
if not Ok then
return false -- Reason: "OriginMismatch", "StaleTimestamp", "OutOfRange", ...
end
end, 10)
Net:onIntent("FireShot", function(session, origin, direction, timestamp)
-- server raycasts against REWOUND capsules; the client never reports hits
local Victim, HitPosition = Lag:castRay(session.Player, origin, direction, 300, timestamp)
if Victim then
local Humanoid = Victim.Character and Victim.Character:FindFirstChildOfClass("Humanoid")
if Humanoid then
Humanoid:TakeDamage(34)
end
end
end)
end

Rewind samples every character’s HumanoidRootPart at 20Hz into a one-second ring buffer per player. validateShot interpolates between samples at the claimed timestamp and rejects with a machine-readable reason: FutureTimestamp (more than 0.2s ahead of the server clock), StaleTimestamp (older than the buffer), NoShooterHistory, OriginMismatch (claimed muzzle more than OriginToleranceStuds — default 8 — from the rewound shooter), OutOfRange, NoTargetHistory, HitMismatch. Reasons are machine-readable so hooks can apply per-reason strike policies. castRay then sweeps a rotation-invariant capsule (radius 1.6, half-height 2.4 by default) at each target’s rewound position.

Respawns and server teleports reset the buffer — a ring spanning death → spawn would interpolate across the gap and reject honest post-respawn shots. If you’d rather not wire this yourself, WeaponKit ships the whole pipeline (fire gates, validation, rewound-hitbox hit detection, damage) as one service.

Deep dive: Rewind.

Teleport players without tripping the anti-cheat

Section titled “Teleport players without tripping the anti-cheat”

Any legit server-initiated move (teleporter pads, blink spells, knockback, cutscenes) should announce itself first:

-- src/Server/Bootstrap.luau
return function(kernel)
local function teleport(player, destination)
local Character = player.Character
local RootPart = Character and Character:FindFirstChild("HumanoidRootPart")
if not RootPart then
return
end
kernel.Bus:publish("AntiExploit.Forgive", player)
RootPart.CFrame = destination
end
-- example: a teleporter pad
workspace.PadA.Touched:Connect(function(hit)
local Player = game:GetService("Players"):GetPlayerFromCharacter(hit.Parent)
if Player then
teleport(Player, workspace.PadB.CFrame + Vector3.new(0, 4, 0))
end
end)
end

Without the announcement, the movement monitor treats the server-initiated move as a violation and rubberbands it. AntiExploit.Forgive pardons that player’s next movement sample — the baseline resets to wherever they land. Rewind subscribes to the same topic and resets the player’s position history, because a teleport corrupts hit-validation history the same way a respawn does. One publish pardons both.

Two framework paths publish it for you: MoveKit server abilities with Forgive = true, and any SpellKit Cast that moves the caster should publish it first. Deep dive: Movement Monitor.

The server simulates every trajectory (gravity, swept raycasts, lifetime); clients render pooled visuals running the same math:

-- src/Server/Bootstrap.luau
local Root = game:GetService("ServerScriptService").ChloeKernelServer
local Projectiles = require(Root.Projectiles)
local Rewind = require(Root.AntiExploit.Rewind)
return function(kernel)
local Lag = Rewind.attach(kernel)
local PJ = Projectiles.attach(kernel, { Rewind = Lag })
PJ:define(1, {
Speed = 90,
Gravity = 40,
MaxLifetime = 4,
OnHit = function(ownerSession, result, victimPlayer)
if victimPlayer then
local Humanoid = victimPlayer.Character and victimPlayer.Character:FindFirstChildOfClass("Humanoid")
if Humanoid then
Humanoid:TakeDamage(25)
end
end
end,
})
local Net = kernel:net()
Net:defineIntent("ThrowSpear", { "Vector3F32", "Vector3F24", "NumberF64" }, { RateLimit = 4 })
kernel.Hooks:on("Intent.ThrowSpear", function(context)
return context.Player.Character ~= nil -- add your own arm/equip gates here
end, 10)
Net:onIntent("ThrowSpear", function(session, origin, direction, timestamp)
PJ:fire(session, 1, origin, direction, timestamp) -- lag-comp validated
end)
end
-- src/Client/Bootstrap.luau
local ProjectileClient = require(game:GetService("ReplicatedStorage").ChloeKernel.Net.ProjectileClient)
return function(kernel)
local function makeProjectilePart()
local Part = Instance.new("Part")
Part.Shape = Enum.PartType.Ball
Part.Size = Vector3.new(0.6, 0.6, 0.6)
Part.Material = Enum.Material.Neon
Part.Anchored = true
Part.CanCollide = false
Part.CanQuery = false
return Part
end
ProjectileClient.new({
[1] = {
Gravity = 40, -- match the server definition
Create = makeProjectilePart, -- pooled; reset state here
OnImpact = function(position)
-- impact VFX at the server-confirmed position
end,
},
})
end

The server steps every live projectile at 30Hz with real elapsed time (clamped so a hitch can’t tunnel one through a wall), sweeping a raycast along each segment. With Rewind attached, PJ:fire validates the claim first — origin, timestamp, and range gates all apply — and adding Hitbox = { Radius = 1.8 } to a definition switches player hits to the same rewound capsules guns use. Non-finite vectors reject before any math runs, and hits publish Projectile.Hit(ownerSession, victimPlayer?, defId, position) on the bus.

Clients receive spawn/hit/expire packets only and integrate the identical pure step function over pooled visuals, so the tracer lands exactly where the hit resolved. Keep ids and physics params in one shared module so server sim and client visuals can’t drift. Deep dive: Projectiles.

MeleeKit is the timing game, server-authoritative: windup, hit frame, recovery — with combos that cancel recovery, blocks that chip, guard-breaks that shatter blocks, and timed parries that beat everything:

-- src/Server/Bootstrap.luau
local MeleeKit = require(game:GetService("ServerScriptService").ChloeKernelServer.Kits.MeleeKit)
return function(kernel)
local Melee = MeleeKit.attach(kernel, {
Moveset = {
Jab = { Damage = 8, Range = 7, Arc = 120, Windup = 0.15, Recovery = 0.25, ComboNext = { "Cross" } },
Cross = { Damage = 12, Windup = 0.2, Recovery = 0.3, ComboNext = { "Uppercut" } },
Uppercut = { Damage = 20, Windup = 0.3, Recovery = 0.5 },
Haymaker = { Damage = 25, Windup = 0.6, Recovery = 0.5, GuardBreak = true },
},
ParryWindow = 0.18,
StaggerSeconds = 1.1,
})
kernel.Bus:subscribe("Melee.Parried", function(_, victim, attacker, attackName)
-- clang VFX + the attacker is Staggered: the punish window is open
end)
end
-- src/Client/Bootstrap.luau
local ChloeKernel = game:GetService("ReplicatedStorage").ChloeKernel
local InputDriver = require(ChloeKernel.InputDriver)
local NetClient = require(ChloeKernel.Net.Client)
return function(kernel)
local Net = NetClient.new()
local Attack = Net:intent("ML_Attack", { "String" })
local Parry = Net:intent("ML_Parry", {})
local Block = Net:intent("ML_Block", { "Boolean8" })
local Input = InputDriver.attach(kernel)
Input:bindAction("Jab", { Keyboard = Enum.KeyCode.Z, Handler = function(state)
if state == Enum.UserInputState.Begin then
Attack.fire("Jab")
end
end })
Input:bindAction("Parry", { Keyboard = Enum.KeyCode.X, Handler = function(state)
if state == Enum.UserInputState.Begin then
Parry.fire()
end
end })
Input:bindAction("Block", { Keyboard = Enum.KeyCode.C, Handler = function(state)
if state == Enum.UserInputState.Begin then
Block.fire(true)
elseif state == Enum.UserInputState.End then
Block.fire(false)
end
end })
end

The triangle: parry beats attack (the attacker staggers — a free punish window), block beats attack (damage scales to chip via BlockScale, default 0.25), guard-break beats block (full damage, the guard drops) but loses to parry HARDER (GuardBreakStaggerScale, default 1.5× stagger). Every timing runs on the server clock, and parry windows carry LatencySlack (default 0.075s) so honest defenders get their frames back — spam still can’t cover a swing because attempts consume ParryCooldown (default 0.6s) whether they connect or not.

Players ride three fail-closed intents (ML_Attack/ML_Block/ML_Parry, auto-wired per session): “attack while staggered” dies in validation, never in a handler. Combos are declared on the moveset — ComboNext cancels recovery along the chain only, and Melee.Combo(attacker, chain) counts the string. And it’s unified with NPCs the same way everything else is: register an npc handle and its behaviors call Melee:attack(npc, "Jab"), while npc victims with no manual parry automatically roll their own skill-scaled npc:defend() — a Perfect duelist reads your haymaker like a book, a Bad one eats it. Hook point Melee.CanHit (fail-open) vetoes friendly fire. Bus: Melee.Hit/Blocked/GuardBroken/Parried/Staggered/Combo/State.

Deep dives: MeleeKit, NPCKit.

-- src/Server/Bootstrap.luau
local Effects = require(game:GetService("ServerScriptService").ChloeKernelServer.Effects)
return function(kernel)
local Buffs = Effects.attach(kernel)
Buffs:define("Haste", { Duration = 5, WalkSpeed = 1.5 })
Buffs:define("Chill", { Duration = 3, MaxStacks = 3, WalkSpeed = 0.85 }) -- stacks multiply
Buffs:define("Poison", {
Duration = 6,
Category = "Toxin",
TickSeconds = 1,
OnTick = function(session)
local Humanoid = session.Player.Character
and session.Player.Character:FindFirstChildOfClass("Humanoid")
if Humanoid then
Humanoid:TakeDamage(2)
end
end,
})
Buffs:define("Ward", { Duration = 30, Immune = { "Curse", "Toxin" } })
-- in your combat code:
-- Buffs:apply(session, "Haste")
-- if Buffs:has(session, "Chill") then ... end
-- Buffs:cleanse(session, "Toxin") -- the antidote: removes every Toxin-category effect
end

WalkSpeed recomputes from the humanoid’s base across all active effects and stacks (multiplicatively), so overlapping buffs and removals always land on the right value — removal restores the exact base. And the movement anti-exploit reads live WalkSpeed, so hasted players are never false-flagged.

Effects is a full status system. TickSeconds + OnTick fire periodic ticks (poison damage, regen heals) with bounded catch-up after a stall (at most 8 overdue ticks per sweep). Category makes an effect dispellable: Buffs:cleanse(session, "Toxin") removes every matching effect, cleanse(session) removes every categorized one, and uncategorized effects (VIP flags) never cleanse. Immune = { "Curse" } on an effect blocks applies of the covered categories while it holds — wards are effects, so an anti-curse ward is one define with a duration. Non-humanoid stats work too: define Stats = { Damage = 1.25 } and read Buffs:statMultiplier(session, "Damage") from your combat code.

Owners receive a CKEffects state push after every change ({ [name] = { Stacks, Remaining? } }) for status-icon UI. Bus: Effects.Applied(player, name, stacks), Effects.Expired(player, name), Effects.Blocked(player, name, byEffect), Effects.Cleansed(player, category?, count). Deep dive: Effects.