Skip to content

Combat and anti-exploit

Recipes for movement checks, bans, lag-compensated hits, server teleports, projectiles you can steer, melee with parries, and status effects.

The server owns every outcome on this page. Clients send intents (a direction, a timestamp, an attack name), the server checks them against its own state and decides what happened. Both sides share one clock, workspace:GetServerTimeNow(). Server examples are services in src/Server/Game/Services/, listed in src/Server/Game/Registry.luau; game channels live in src/Shared/Game/Channels.luau and reach a service as self.Net (see Services and controllers).

Attach the movement monitor and ban players who keep failing it:

src/Server/Game/Services/AntiCheatService.luau
--!strict
-- AntiCheatService: movement checks, and bans for players who keep failing them.
local ServerScriptService = game:GetService("ServerScriptService")
local Consequences = require(ServerScriptService.ChloeKernelServer.AntiExploit.Consequences)
local Movement = require(ServerScriptService.ChloeKernelServer.AntiExploit.Movement)
-- Strikes only count consecutive flagged samples: 10 is two seconds of violations at the 0.2 s sweep
local BanAtStrikes = 10
local AntiCheatService = {
Name = "AntiCheatService",
}
function AntiCheatService.init(self: any, kernel: any)
self.Kernel = kernel
self.Scope = kernel:createScope()
self.Monitor = self.Scope:add(Movement.attach(kernel), "detach")
self.Bans = self.Scope:add(Consequences.attach(kernel), "detach")
end
function AntiCheatService.start(self: any)
self.Scope:add(self.Kernel.Hooks:on("AntiExploit.MovementViolation", function(context)
if context.Strikes >= BanAtStrikes then
self.Bans:ban(context.Player, {
DisplayReason = "Banned for exploiting.",
Violation = context.Type,
Strikes = context.Strikes,
})
end
end))
end
function AntiCheatService.stop(self: any)
self.Scope:destroy()
end
return AntiCheatService

The monitor samples every character’s root every 0.2 seconds, and only while at least one session is tracked. Each sample is pure math. Horizontal speed is compared against the humanoid’s WalkSpeed times SpeedTolerance (1.4). Displacement in one sample is compared against TeleportStuds (80), prorated up when a sweep runs late so a missed frame never flags normal running. WalkSpeed is client-owned and forgeable, so it is trusted only up to MaxTrustedWalkSpeed (100). A sample with a non-finite position reports the type "Invalid".

After RubberbandStrikes (2) consecutive violations the monitor moves the character back to its last valid position and keeps its facing. Any clean sample resets the strike count, and strikes also reset after 10 quiet seconds, so a ban threshold counts sustained cheating rather than scattered lag spikes. A flagged position never becomes the new baseline, so a teleport cannot be laundered through strikes. Respawns, seated players and forgiven moves reset the baseline instead of being evaluated. Unanchored platforms the server simulates (elevators, boats) add their velocity to the allowance. The monitor never calls SetNetworkOwner, so the client keeps ownership of its character.

The hook is fail-open and its context carries Player, Session, Type, HorizontalSpeed, Displacement and Strikes. The same verdict publishes as AntiExploit.MovementViolation(player, violationType, strikes) for systems that only count. Under Server Authority, attach with RubberbandStrikes = math.huge: strikes, the hook and the Bus topic still fire, but the monitor never writes a CFrame that would fight the engine’s rollback.

Go deeper: Movement monitor, Consequences, Scope.

A kick lets the exploiter rejoin at once. Consequences bans through Players:BanAsync, so the account (and by default its suspected alt accounts) cannot join any server of the experience, and each repeat offense earns a longer ban. This start() replaces the one in the service above and adds the failure path, plus an unban for support staff. The requires go at the top of the file:

src/Server/Game/Services/AntiCheatService.luau
local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Logger = require(ReplicatedStorage.ChloeKernel.Logger)
local Log = Logger.scope("AntiCheatService")
function AntiCheatService.start(self: any)
self.Scope:add(self.Kernel.Hooks:on("AntiExploit.MovementViolation", function(context)
if context.Strikes >= BanAtStrikes then
self.Bans:ban(context.Player, {
DisplayReason = "Banned for exploiting.", -- shown when they try to join
Violation = context.Type, -- kept in the private note with the strike count
Strikes = context.Strikes,
})
end
end))
self.Scope:add(self.Kernel.Bus:subscribe("AntiExploit.Banned", function(_topic, userId, duration, record)
Log.info("banned", userId, duration, record.PriorBans)
end))
-- BanningEnabled is off or the ban service is down: the ban never applied,
-- so remove the player from this server and keep the reason in the log
self.Scope:add(self.Kernel.Bus:subscribe("AntiExploit.BanFailed", function(_topic, userId, reason)
Log.warn("ban failed", userId, reason)
local Player = Players:GetPlayerByUserId(userId)
if Player then
Player:Kick("Removed for exploiting.")
end
end))
end
-- Support tooling: lifts the ban and its device block
function AntiCheatService.pardon(self: any, userId: number)
self.Bans:unban(userId)
end

ban() never yields. It checks the engine limits at the call (DisplayReason 1 to 400 characters, Duration whole seconds or -1 for permanent) and raises on a bad value, then runs the HTTP work on its own thread, at most 50 ids per engine call. Each id ends in AntiExploit.Banned(userId, duration, record) or AntiExploit.BanFailed(userId, reason). Without a Duration, the length is Escalation[priorBans + 1], counted from GetBanHistoryAsync, with the last step repeating. The default escalation is { 3600, 86400, -1 }: one hour, one day, then permanent. A second ban for an id whose ban is still in flight is ignored, so a hook that keeps firing during the HTTP call cannot escalate twice.

unban(userId, applyToUniverse?) lifts only a ban with the same scope, and publishes AntiExploit.Unbanned or AntiExploit.UnbanFailed. history(userId) yields and returns the raw ban records. Roblox’s ban guidelines ask you to publish your rules and an appeal path.

Go deeper: Consequences, Logger.

The server keeps a short position history per player and checks each shot against where the target was at the shooter’s timestamp. The channel carries only what the client aimed:

src/Shared/Game/Channels.luau
FireShot = {
Kind = "Intent",
Schema = { "Vector3F32", "Vector3F24", "NumberF64" }, -- origin, direction, server-clock timestamp
RateLimit = 10,
Validate = { [3] = { Range = { 0, 1e10 } } }, -- Range also rejects NaN
},
src/Server/Game/Services/GunService.luau
--!strict
-- GunService: validates shots against rewound positions and applies damage.
local ServerScriptService = game:GetService("ServerScriptService")
local Rewind = require(ServerScriptService.ChloeKernelServer.AntiExploit.Rewind)
local MaxRange = 300
local Damage = 34
local GunService = {
Name = "GunService",
}
function GunService.init(self: any, kernel: any)
self.Kernel = kernel
self.Scope = kernel:createScope()
self.Lag = self.Scope:add(Rewind.attach(kernel), "detach")
self.Walls = RaycastParams.new()
self.Walls.RespectCanCollide = true
end
function GunService.start(self: any)
self.Scope:add(self.Kernel.Hooks:on("Intent.FireShot", function(context)
local Ok = self.Lag:validateShot({
Shooter = context.Player,
Origin = context.Args[1],
Direction = context.Args[2],
Timestamp = context.Args[3],
MaxRange = MaxRange,
})
return Ok
end, 10))
self.Net.FireShot.on(function(session, origin, direction, timestamp)
local Shooter = session.Player.Character
if not Shooter then
return
end
local Victim, _, Distance = self.Lag:castRay(session.Player, origin, direction, MaxRange, timestamp)
if not Victim or not Distance then
return
end
-- Rewind moves players back in time, not the map: check walls up to the rewound hit
local Exclude = { Shooter }
if Victim.Character then
table.insert(Exclude, Victim.Character)
end
self.Walls.ExcludeInstances = Exclude
if workspace:Raycast(origin, direction.Unit * Distance, self.Walls) then
return
end
local Humanoid = Victim.Character and Victim.Character:FindFirstChildOfClass("Humanoid")
if Humanoid and Humanoid.Health > 0 then
Humanoid:TakeDamage(Damage)
end
end)
end
function GunService.stop(self: any)
self.Scope:destroy()
end
return GunService

The client fires self.Net.FireShot.fire(muzzle, direction, workspace:GetServerTimeNow()) and never reports hits.

Rewind samples every character’s root at 20 Hz into a one-second history (HistorySeconds). validateShot returns false and a reason, which the hook turns into a rejection before the handler runs. A shot may rewind at most the shooter’s round trip plus InterpolationSeconds (0.15) and PastSlackSeconds (0.1), and never more than HistorySeconds, so a 20 ms player cannot backdate a shot by a full second. The round trip is the NetGovernor median when one is attached, else Player:GetNetworkPing(). The claimed muzzle must sit within OriginToleranceStuds (8) of the rewound shooter. OutOfRange, DirectionMismatch, NoTargetHistory and HitMismatch apply only when the claim also names TargetPlayer and HitPosition. A shooter who is dead or has no character has no history and gets NoShooterHistory.

castRay sweeps a capsule (radius 1.6, half-height 2.4) at each target’s rewound position and returns the victim, the hit position and the distance. Static geometry is not rewound, which is why the handler casts its own wall check. Respawns and AntiExploit.Forgive clear a player’s history, so a buffer never interpolates across a death or a server teleport. WeaponKit ships this whole pipeline (fire gates, validation, rewound hit detection, damage) as one service.

Go deeper: Rewind, WeaponKit, Registry.

Teleport players without tripping the anti-cheat

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

A server-initiated move (a teleporter pad, a blink spell, a cutscene) looks like a teleport to the movement monitor. Movement.teleport does the move the right way:

src/Server/Game/Services/TeleporterService.luau
--!strict
-- TeleporterService: pads that move players to their linked destination.
local ServerScriptService = game:GetService("ServerScriptService")
local Movement = require(ServerScriptService.ChloeKernelServer.AntiExploit.Movement)
local Zones = require(ServerScriptService.ChloeKernelServer.Zones)
local TeleporterService = {
Name = "TeleporterService",
}
function TeleporterService.init(self: any, kernel: any)
self.Kernel = kernel
self.Scope = kernel:createScope()
self.Regions = self.Scope:add(Zones.attach(kernel), "destroy")
self.Regions:add("PadA", workspace.Teleporters.PadA)
end
function TeleporterService.start(self: any)
self.Scope:add(self.Kernel.Bus:subscribe("Zone.Entered", function(_topic, zoneName, player)
if zoneName == "PadA" then
Movement.teleport(self.Kernel, player, workspace.Teleporters.PadB.CFrame + Vector3.new(0, 4, 0))
end
end))
end
function TeleporterService.stop(self: any)
self.Scope:destroy()
end
return TeleporterService

Movement.teleport(kernel, player, cframe, options?) returns at once and runs on its own thread. It asks the engine to stream the destination in for that player (Player:RequestStreamAroundAsync, capped by StreamTimeout, default 5 seconds; 0 skips it), then, if the same character is still in the world, publishes AntiExploit.Forgive and pivots the character. Without the stream request, a StreamingEnabled client can land somewhere it has not loaded. Without the Forgive, the monitor rubberbands the move.

AntiExploit.Forgive(player) pardons the player’s next sample. Rewind subscribes to the same topic and clears that player’s history, because a teleport breaks hit validation the same way a respawn does. For a move that cannot wait for streaming (knockback), publish it yourself:

Kernel.Bus:publish("AntiExploit.Forgive", player)
character:PivotTo(destination)

For a ride that moves the player for a while without a seat (a broom, a scripted vehicle), publish AntiExploit.Exempt(player, true) when it starts and false when it ends. Repeated Forgives would also clear the rider’s Rewind history on every sample and reject every shot they fire. MoveKit abilities with Forgive = true publish the Forgive for you.

Go deeper: Movement monitor, Zones.

The server simulates every trajectory (gravity, swept raycasts, lifetime); clients draw pooled visuals from the same math. Keep ids and physics numbers in one shared module so the two sides cannot drift:

src/Shared/Game/Config/Spears.luau
--!strict
-- Spears: projectile ids and physics shared by the server simulation and client visuals.
return {
Id = 1,
Speed = 90,
Gravity = 40,
MaxLifetime = 4,
}
src/Shared/Game/Channels.luau
ThrowSpear = {
Kind = "Intent",
Schema = { "Vector3F32", "Vector3F24", "NumberF64" },
RateLimit = 4,
Validate = { [3] = { Range = { 0, 1e10 } } },
},
src/Server/Game/Services/SpearService.luau
--!strict
-- SpearService: server-simulated thrown spears.
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local ServerScriptService = game:GetService("ServerScriptService")
local Projectiles = require(ServerScriptService.ChloeKernelServer.Projectiles)
local Rewind = require(ServerScriptService.ChloeKernelServer.AntiExploit.Rewind)
local Spears = require(ReplicatedStorage.Game.Config.Spears)
local SpearService = {
Name = "SpearService",
}
function SpearService.init(self: any, kernel: any)
self.Kernel = kernel
self.Scope = kernel:createScope()
local Lag = self.Scope:add(Rewind.attach(kernel), "detach")
self.Projectiles = self.Scope:add(Projectiles.attach(kernel, { Rewind = Lag }), "destroy")
self.Projectiles:define(Spears.Id, {
Speed = Spears.Speed,
Gravity = Spears.Gravity,
MaxLifetime = Spears.MaxLifetime,
Hitbox = { Radius = 1.8 },
OnHit = function(_ownerSession, _result, victim: Player?)
local Humanoid = victim and victim.Character and victim.Character:FindFirstChildOfClass("Humanoid")
if Humanoid then
Humanoid:TakeDamage(25)
end
end,
})
end
function SpearService.start(self: any)
self.Net.ThrowSpear.on(function(session, origin, direction, timestamp)
if session.Player.Character == nil then
return
end
self.Projectiles:fire(session, Spears.Id, origin, direction, timestamp)
end)
end
function SpearService.stop(self: any)
self.Scope:destroy()
end
return SpearService
src/Client/Game/Controllers/SpearController.luau
--!strict
-- SpearController: draws spears the server simulates.
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local ProjectileClient = require(ReplicatedStorage.ChloeKernel.Net.ProjectileClient)
local Spears = require(ReplicatedStorage.Game.Config.Spears)
local SpearController = {
Name = "SpearController",
}
function SpearController.init(self: any, _kernel: any)
self.Template = ReplicatedStorage.Assets.Spear
end
function SpearController.start(self: any)
self.Visuals = ProjectileClient.new({
[Spears.Id] = {
Gravity = Spears.Gravity,
Create = function()
return self.Template:Clone() -- runs only when the pool grows
end,
Linger = 0.3, -- the body hides while its trail fades out
},
})
end
function SpearController.stop(self: any)
if self.Visuals then
self.Visuals:destroy()
end
end
return SpearController

The flight job wakes on the first fire and sleeps when the last shot retires. It steps at 30 Hz with the real elapsed time, clamped to 0.1 s so a hitch cannot carry a shot through a wall, and sweeps a raycast along each segment. Only colliding world geometry stops a shot, so zones and trigger volumes never absorb one. With Rewind attached and a timestamp passed, fire validates the claimed origin and timestamp first and returns false and the reason on failure; non-finite vectors are refused before any math runs. Hitbox switches player hits to capsule tests at the players’ current positions. Hits publish Projectile.Hit(ownerSession, victimPlayer?, defId, position).

Clients receive spawn, hit, expire and redirect packets and integrate the same pure step function, so the visual lands where the hit resolved. Create runs only when the pool grows. OnAcquire(visual, serial) runs on every reuse and by default clears old trail segments and particles (Pool.clearEffects). OnImpact(position, velocity?, serial?) plays impact effects at the position the server confirmed.

Go deeper: Projectiles, Pool.

Shots stay steerable after they leave the hand. forEach visits live shots, redirect re-aims one and can hand it to a new owner, and retire removes one without running its callbacks. This ward bats incoming spears back at whoever threw them, for three seconds:

src/Server/Game/Services/SpearService.luau
local WardRadius = 10
local WardSeconds = 3
-- Called from whatever grants the ward: a SpellKit Cast, an InventoryKit OnUse
function SpearService.startWard(self: any, session: any)
if self.Wards[session] then
return
end
local Ward = self.Kernel:createScope()
self.Wards[session] = Ward
Ward:add(function()
self.Wards[session] = nil
end)
-- Runs only while this ward is up
Ward:add(self.Kernel.Scheduler:every(0.05, function()
local Character = session.Player.Character
local Root = Character and Character.PrimaryPart
if not Root then
return
end
self.Projectiles:forEach(function(serial, shot)
if shot.Owner == session or (shot.Position - Root.Position).Magnitude > WardRadius then
return
end
local Thrower = shot.Owner and shot.Owner.Player and shot.Owner.Player.Character
local Aim = if Thrower then Thrower:GetPivot().Position - shot.Position else -shot.Velocity
self.Projectiles:redirect(serial, {
Velocity = Aim.Unit * shot.Velocity.Magnitude,
Owner = session, -- hit credit and the thrower's self-exclusion move to the ward's owner
})
end)
end))
Ward:add(self.Kernel.Scheduler:after(WardSeconds, function()
Ward:destroy()
end))
session:bind(function()
Ward:destroy()
end)
end

Add self.Wards = {} to the service’s init. Two shots that meet in the air can both end at the meeting point:

self.Projectiles:retire(serialA, midpoint)
self.Projectiles:retire(serialB, midpoint)

A redirect restarts the shot’s flight from the new position and velocity (a Path shot evaluates its curve from there too), so the next sweep covers the new segment. Lifetime defaults to the time the shot had left. It returns false and UnknownShot, NonFinite, ZeroVelocity or NoLifetime when it cannot apply. The forEach view (DefId, Owner, Position, Velocity, Age, Remaining) is one table reused for every visit of a pass: copy what you need and never keep it. redirect and retire are safe to call from inside the visit.

Redirects ride a CKPJ_Redirect packet. On the client the visual blends from where that player last saw it onto the new path over OriginBlendSeconds (0.15), and the optional OnRedirect(visual, position, velocity, serial, caster) hook adds the cosmetic beat. retire sends the ordinary expiry packet, so impact effects play at the retire position. Bus: Projectile.Redirected(ownerSession, serial, defId, position).

Go deeper: Projectiles, Scope, Scheduler.

MeleeKit runs the timing game on the server: windup, hit frame and recovery, combos that cancel recovery, blocks that take chip damage, guard breaks that go through blocks, and parries that beat attacks.

src/Server/Game/Services/BrawlService.luau
--!strict
-- BrawlService: server-authoritative melee for every player.
local ServerScriptService = game:GetService("ServerScriptService")
local MeleeKit = require(ServerScriptService.ChloeKernelServer.Kits.MeleeKit)
local BrawlService = {
Name = "BrawlService",
}
function BrawlService.init(self: any, kernel: any)
self.Kernel = kernel
self.Scope = kernel:createScope()
self.Melee = self.Scope:add(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 },
Shockwave = { Damage = 10, Range = 12, Arc = 360, Windup = 0.5, RequireLineOfSight = false },
},
ParryWindow = 0.18,
StaggerSeconds = 1.1,
}), "destroy")
end
function BrawlService.stop(self: any)
self.Scope:destroy()
end
return BrawlService
src/Client/Game/Controllers/BrawlController.luau
--!strict
-- BrawlController: melee input on keyboard, gamepad and touch.
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local InputDriver = require(ReplicatedStorage.ChloeKernel.InputDriver)
local NetClient = require(ReplicatedStorage.ChloeKernel.Net.Client)
local BrawlController = {
Name = "BrawlController",
}
function BrawlController.init(self: any, kernel: any)
self.Kernel = kernel
end
function BrawlController.start(self: any)
-- MeleeKit defines its own channels; the client names them
local Net = NetClient.new()
local Attack = Net:intent("ML_Attack", { "String" })
local Parry = Net:intent("ML_Parry", {})
local Block = Net:intent("ML_Block", { "Boolean8" })
self.Input = InputDriver.attach(self.Kernel)
self.Input:bindAction("Jab", {
Keyboard = Enum.KeyCode.Z,
Gamepad = Enum.KeyCode.ButtonR1,
TouchButton = true,
Handler = function(state)
if state == Enum.UserInputState.Begin then
Attack.fire("Jab")
end
end,
})
self.Input:bindAction("Parry", {
Keyboard = Enum.KeyCode.X,
Gamepad = Enum.KeyCode.ButtonL1,
TouchButton = true,
Handler = function(state)
if state == Enum.UserInputState.Begin then
Parry.fire()
end
end,
})
self.Input:bindAction("Block", {
Keyboard = Enum.KeyCode.C,
Gamepad = Enum.KeyCode.ButtonL2,
TouchButton = true,
Handler = function(state)
-- Cancel arrives instead of End when a rebind or another binding takes the key
Block.fire(state == Enum.UserInputState.Begin)
end,
})
end
function BrawlController.stop(self: any)
if self.Input then
self.Input:destroy()
end
end
return BrawlController

The rules form a triangle. A parry beats an attack and staggers the attacker for StaggerSeconds. A block beats an attack and scales its damage by BlockScale (0.25). A guard break beats a block (full damage, the guard drops) but loses to a parry with a longer stagger (GuardBreakStaggerScale, 1.5). Every timing runs on the server clock. Parry windows carry LatencySlack (0.075 s) so an honest defender gets their frames back, and every attempt spends ParryCooldown (0.6 s) whether it connects or not, so mashing parry cannot cover a swing.

Swings do not pass through walls. A victim inside Range and Arc is hit only when one ray from the attacker’s chest to the victim’s root crosses no colliding geometry. Set RequireLineOfSight = false on an attack that should ignore cover, as the Shockwave does.

Players drive the kit through three intents it wires itself, ML_Attack (rate limit 15), ML_Block (20) and ML_Parry (10), validated against the combatant’s state machine, so an attack while staggered is rejected before any handler runs. ComboNext lets an attack cancel recovery only along its chain, and Melee.Combo(attacker, chain) counts the string. NPC handles register with Melee:register(npc) and attack with Melee:attack(npc, "Jab"); an NPC victim without an armed parry rolls its own defend() against the swing. The fail-open hook Melee.CanHit (Attacker, Victim, Attack) vetoes friendly fire. Bus: Melee.Hit, Melee.Blocked, Melee.GuardBroken, Melee.Parried, Melee.Staggered, Melee.Combo, Melee.State.

Go deeper: MeleeKit, NPCKit, InputDriver.

src/Server/Game/Services/StatusService.luau
--!strict
-- StatusService: buffs, debuffs and wards for players.
local ServerScriptService = game:GetService("ServerScriptService")
local Effects = require(ServerScriptService.ChloeKernelServer.Effects)
local StatusService = {
Name = "StatusService",
}
function StatusService.init(self: any, kernel: any)
self.Scope = kernel:createScope()
local Buffs = self.Scope:add(Effects.attach(kernel), "detach")
self.Buffs = Buffs
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 Character = session.Player.Character
local Humanoid = Character and Character:FindFirstChildOfClass("Humanoid")
if Humanoid then
Humanoid:TakeDamage(2)
end
end,
})
Buffs:define("Ward", { Duration = 30, Immune = { "Curse", "Toxin" } })
Buffs:define("Might", { Duration = 10, Stats = { Damage = 1.25 } })
end
-- Other services call these through kernel:getService("StatusService")
function StatusService.poison(self: any, session: any)
self.Buffs:apply(session, "Poison")
end
function StatusService.cure(self: any, session: any)
self.Buffs:cleanse(session, "Toxin") -- removes every Toxin-category effect
end
function StatusService.damageMultiplier(self: any, session: any): number
return self.Buffs:statMultiplier(session, "Damage")
end
function StatusService.stop(self: any)
self.Scope:destroy()
end
return StatusService

WalkSpeed is recomputed from the humanoid’s base across every active effect and stack, multiplied together, so overlapping buffs and removals always land on the right value and removal restores the exact base. The movement monitor reads live WalkSpeed, so a hasted player is never flagged.

TickSeconds with OnTick runs periodic ticks (poison, regeneration), catching up at most 8 overdue ticks after a stall. Category makes an effect dispellable: cleanse(session, "Toxin") removes every matching effect, cleanse(session) removes every categorized one, and uncategorized effects (a VIP flag) never cleanse. Immune blocks applies of the listed categories while the effect holds, so a ward is one define with a duration; a blocked apply returns 0. Stats multipliers stack the same way and are read with statMultiplier. The sweep runs only while an effect is active.

The owner receives a CKEffects state push after every change ({ [name] = { Stacks, Remaining? } }) for status icons. Bus: Effects.Applied(player, name, stacks), Effects.Expired(player, name), Effects.Blocked(player, name, byEffect), Effects.Cleansed(player, category?, count).

Go deeper: Effects.

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