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).
Catch speedhackers and teleporters
Section titled “Catch speedhackers and teleporters”Attach the movement monitor and ban players who keep failing it:
--!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 sweeplocal 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 AntiCheatServiceThe 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.
Ban a cheater across servers
Section titled “Ban a cheater across servers”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:
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 blockfunction AntiCheatService.pardon(self: any, userId: number) self.Bans:unban(userId)endban() 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.
Validate hits with lag compensation
Section titled “Validate hits with lag compensation”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:
FireShot = { Kind = "Intent", Schema = { "Vector3F32", "Vector3F24", "NumberF64" }, -- origin, direction, server-clock timestamp RateLimit = 10, Validate = { [3] = { Range = { 0, 1e10 } } }, -- Range also rejects NaN},--!strict-- GunService: validates shots against rewound positions and applies damage.
local ServerScriptService = game:GetService("ServerScriptService")
local Rewind = require(ServerScriptService.ChloeKernelServer.AntiExploit.Rewind)
local MaxRange = 300local 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 = trueend
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 GunServiceThe 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:
--!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 TeleporterServiceMovement.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.
Fire projectiles with travel time
Section titled “Fire projectiles with travel time”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:
--!strict-- Spears: projectile ids and physics shared by the server simulation and client visuals.
return { Id = 1, Speed = 90, Gravity = 40, MaxLifetime = 4,}ThrowSpear = { Kind = "Intent", Schema = { "Vector3F32", "Vector3F24", "NumberF64" }, RateLimit = 4, Validate = { [3] = { Range = { 0, 1e10 } } },},--!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--!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.Spearend
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() endend
return SpearControllerThe 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.
Steer a projectile mid-flight
Section titled “Steer a projectile mid-flight”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:
local WardRadius = 10local WardSeconds = 3
-- Called from whatever grants the ward: a SpellKit Cast, an InventoryKit OnUsefunction 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)endAdd 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.
Fight hand to hand, parries included
Section titled “Fight hand to hand, parries included”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.
--!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--!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 = kernelend
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() endend
return BrawlControllerThe 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.
Apply buffs and debuffs
Section titled “Apply buffs and debuffs”--!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 effectend
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 StatusServiceWalkSpeed 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.