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.
Catch speedhackers and teleporters
Section titled “Catch speedhackers and teleporters”Attach the movement monitor and decide your own punishment policy:
-- src/Server/Bootstrap.luaulocal Root = game:GetService("ServerScriptService").ChloeKernelServerlocal 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)endDetection 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.
Validate hits with lag compensation
Section titled “Validate hits with lag compensation”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.luaulocal Root = game:GetService("ServerScriptService").ChloeKernelServerlocal 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)endRewind 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.luaureturn 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)endWithout 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.
Fire projectiles with travel time
Section titled “Fire projectiles with travel time”The server simulates every trajectory (gravity, swept raycasts, lifetime); clients render pooled visuals running the same math:
-- src/Server/Bootstrap.luaulocal Root = game:GetService("ServerScriptService").ChloeKernelServerlocal 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.luaulocal 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, }, })endThe 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.
Fight hand to hand, parries included
Section titled “Fight hand to hand, parries included”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.luaulocal 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.luaulocal ChloeKernel = game:GetService("ReplicatedStorage").ChloeKernellocal 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 })endThe 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.
Apply buffs and debuffs
Section titled “Apply buffs and debuffs”-- src/Server/Bootstrap.luaulocal 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 effectendWalkSpeed 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.