Effects
Applies buffs and debuffs with durations, stacking, periodic ticks, cleanse categories and immunity, composing stat multipliers from captured base values.
- Realm
- Server
- Require
ServerScriptService.ChloeKernelServer .Effects - Since
- v0.1.0
- Source
- Effects.luau
Effects is the server’s status system: buffs and debuffs with durations, stacks, periodic ticks, dispel categories and immunity wards. The defining decision is how stats apply. Every stat multiplier composes multiplicatively across all active effects and stacks and is recomputed from a captured base value, so removal restores the exact base. There is no “add 4, later subtract 4” bookkeeping to drift when effects overlap, refresh or expire out of order. Hazards and Zones apply effects on their enter topics and remove them on leave.
Mental model
Section titled “Mental model”State and the sweep
Section titled “State and the sweep”attach keeps one state table per session (its active effects and captured base stats) in a weak-keyed table, so a departed session’s state is collectable even if the leave cleanup never ran. The cleanup bound to each session clears it normally.
The sweep runs only while there is timed work. A session enters the sweep set when it has an effect with a duration or a tick period, and leaves it when the last such effect goes. While the set is non-empty, a Scheduler job runs every SweepSeconds (default 0.1 s) at kernel.Priority.Low; when it empties, the job is cancelled. A server whose effects are all permanent and have no tick period (a VIP flag, a companion aura) runs no timer: apply, remove and character events maintain them.
Each sweep does two jobs for the sessions in the set:
- Ticks. An effect with
TickSecondsfiresOnTick(session, stacks)once per period. The first tick lands one full period after the apply, so an effect applied and cleansed at once ticks zero times. After a stall, overdue ticks run on the next sweep, at most 8 per effect per sweep; any still overdue run on the sweeps after that, so no tick is lost, but one sweep never delivers a burst larger than 8. - Expiry. Effects past their expiry are collected during the scan and removed after it, because
removepublishesEffects.Expiredsynchronously and a subscriber that re-applies an effect would otherwise change the tables mid-iteration.
OnTick, OnApply and OnExpire run through task.spawn, so a throwing callback cannot stop the sweep.
The stat pipeline
Section titled “The stat pipeline”Stats on a definition maps stat names to a per-stack multiplier. The composed multiplier for a stat is:
local Multiplier = 1for Name, Active in ActiveEffects do local Stats = Definitions[Name].Stats local PerStack = Stats and Stats[StatName] if PerStack then Multiplier *= PerStack ^ Active.Stacks endendTwo stacks of a WalkSpeed = 0.5 chill plus a WalkSpeed = 1.5 haste compose to 0.5 ^ 2 * 1.5 = 0.375, in any order.
Three humanoid stats apply automatically on every change: WalkSpeed, JumpPower and JumpHeight. The first recompute that finds a Humanoid captures its current values as the base; every later recompute writes Base * multiplier. On CharacterAdded, the base is captured again from the new humanoid one deferred step later, once the character is assembled.
Every other stat is read where it matters:
local FinalDamage = BaseDamage * Buffs:statMultiplier(Session, "Damage")Apply, stack, refresh
Section titled “Apply, stack, refresh”apply(session, name, overrides?) returns the stack count after the apply:
- A new effect starts at 1 stack, expiring
Durationseconds from now, or never when there is no duration. - Applying an active effect adds a stack up to
MaxStacks(default 1) and resets the expiry. Applying at the cap still resets it. The tick schedule is not reset. overrides.Durationreplaces the definition’s duration for this apply.
apply errors for an unknown name and after detach().
Cleanse categories
Section titled “Cleanse categories”Category puts an effect in a dispel group. cleanse(session, category?):
- With a category, removes every active effect in it and returns the count.
- With no category, removes every effect that has a category.
- Effects without a
Category(VIP flags, permanent passives) are never cleansed by either form.
Each removed effect runs its OnExpire and publishes Effects.Expired. Effects.Cleansed publishes afterwards, and only when at least one effect was removed.
Immunity wards
Section titled “Immunity wards”Immune = { "Curse" } on a definition blocks applies of every effect whose Category is listed, for as long as the ward is active. A blocked apply returns 0, changes nothing and publishes Effects.Blocked(player, name, byEffect) so the attacker’s UI can show the resist. A ward is an ordinary effect: a 20-second anti-curse ward is one define with a Duration, and it can carry its own Category so a dispel can strip it.
The check runs at apply time only. A ward does not remove a curse that is already active; pair it with cleanse when the design is “the ward purges existing curses”.
The CKEffects state push
Section titled “The CKEffects state push”When the kernel has a NetDriver, attach defines a CKEffects state channel and sends the owner a full snapshot after every change: apply, stack, refresh, remove, cleanse and expiry.
{ [effectName] = { Stacks = number, Remaining = number? } }Remaining is seconds until expiry, absent for effects without one. Status icons are a render of this table: no client timers to drift and no per-effect wiring. A kernel without net() (spec fakes) runs everything except the push.
--!strict-- StatusService: buffs, curses and wards.
local ServerScriptService = game:GetService("ServerScriptService")
local Effects = require(ServerScriptService.ChloeKernelServer.Effects)
local StatusService = { Name = "StatusService",}
function StatusService.init(self: any, kernel: any) self.Kernel = kernel local Buffs = Effects.attach(kernel) Buffs:define("Haste", { Duration = 5, Stats = { WalkSpeed = 1.5 } }) Buffs:define("Chill", { Duration = 3, MaxStacks = 3, Stats = { WalkSpeed = 0.85 }, Category = "Slow" }) Buffs:define("Hex", { Duration = 6, Category = "Curse", TickSeconds = 1, OnTick = function(session: any, stacks: number) local Character = session.Player.Character local Humanoid = Character and Character:FindFirstChildOfClass("Humanoid") if Humanoid then Humanoid:TakeDamage(3 * stacks) end end, }) Buffs:define("Ward", { Duration = 20, Immune = { "Curse" } }) Buffs:define("VIP", { Stats = { Damage = 1.1 } }) -- no Duration and no Category: permanent, never cleansed self.Buffs = Buffsend
-- Other services call these through kernel:getService("StatusService")function StatusService.curse(self: any, session: any): number return self.Buffs:apply(session, "Hex")end
function StatusService.antidote(self: any, session: any): number return self.Buffs:cleanse(session, "Curse")end
function StatusService.stop(self: any) self.Buffs:detach()end
return StatusService--!strict-- StatusController: keeps the latest CKEffects snapshot for the status bar.
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local NetClient = require(ReplicatedStorage.ChloeKernel.Net.Client)
local StatusController = { Name = "StatusController",}
function StatusController.init(self: any, kernel: any) self.Kernel = kernel self.Active = {} :: { [string]: { Stacks: number, Remaining: number? } }end
function StatusController.start(self: any) local Net = NetClient.new() -- A full snapshot, not a diff: replace the table, do not merge into it Net:onState("CKEffects", { "Any" }, function(snapshot: any) self.Active = snapshot self.Kernel.Bus:publish("Status.Changed", snapshot) end)end
return StatusControllerA screen subscribes to Status.Changed (a topic this controller owns) and draws one icon per entry, counting Remaining down locally between pushes.
How kits consume Effects
Section titled “How kits consume Effects”- SpellKit: curses are the pattern the WizardDuel template ships. A HexBolt spell’s
Castapplies aCategory = "Curse"ticking effect to the victim, and a WardSelf spell applies anImmune = { "Curse" }effect to the caster. The spell system never knows what a curse is. - CompanionKit: a companion’s
Aurais an effect name held on the owner while the companion is summoned, applied with no duration on summon and removed on dismiss or at the companion’s death. CompanionKit asserts at attach that it has an Effects instance when any companion declares an aura. - Zone and hazard auras: apply on
Zone.EnteredorHazard.Entered, remove on the matching leave topic.
API reference
Section titled “API reference”Effects.attach(kernel, options?) options:
| Option | Default | Description |
|---|---|---|
SweepSeconds |
0.1 |
Tick and expiry sweep interval while timed work exists |
SkipLoop |
false |
Never schedule the sweep; call _sweep(now) yourself (spec seam) |
EffectDefinition fields:
| Field | Default | Description |
|---|---|---|
Duration |
nil |
Seconds until expiry; nil stays until removed |
MaxStacks |
1 |
Stack ceiling; applying at the cap still resets the expiry |
Stats |
nil |
Per-stack multiplier by stat name; WalkSpeed, JumpPower and JumpHeight apply to the humanoid |
WalkSpeed |
nil |
Legacy alias for Stats.WalkSpeed, used only when Stats is absent |
Category |
nil |
Cleanse group; nil is never cleansed |
Immune |
nil |
Categories this effect blocks while active |
TickSeconds |
nil |
OnTick period; nil never ticks |
OnTick |
nil |
(session, stacks) once per period, catching up at most 8 per sweep |
OnApply |
nil |
(session, stacks) after every apply or stack |
OnExpire |
nil |
(session) on expiry, remove or cleanse |
| Member | Description |
|---|---|
manager:define(name, definition) |
Registers a definition; a second define of the same name errors |
manager:apply(session, name, overrides?) -> number |
Applies or stacks; returns the stack count, or 0 when an active ward blocks it |
manager:remove(session, name) |
Removes now: runs OnExpire and publishes Effects.Expired |
manager:cleanse(session, category?) -> number |
Removes matching categorized effects; returns the count |
manager:has(session, name) -> boolean |
Whether the effect is active |
manager:stacks(session, name) -> number |
Current stacks, 0 when inactive |
manager:statMultiplier(session, statName) -> number |
Composed multiplier across active effects and stacks |
manager:detach() |
Cancels the sweep, disconnects the session hook and clears all state; later applies error |
Bus topics
Section titled “Bus topics”| Topic | Payload | When |
|---|---|---|
Effects.Applied |
player, name, stacks |
Applied or stacked; stacks is the count after the apply |
Effects.Expired |
player, name |
Expired, removed or cleansed |
Effects.Blocked |
player, name, byEffect |
An active ward blocked an apply |
Effects.Cleansed |
player, category?, count |
A cleanse removed at least one effect |
Design notes
Section titled “Design notes”remove, expiry and cleanse share one path: all three runOnExpireand publishEffects.Expired. To tell “dispelled” from “ran out”, listen forEffects.Cleansedas well.- Session end drops a session’s effects without running
OnExpireor publishingEffects.Expired. Put leave-time cleanup insession:bind, not inOnExpire. - Each Effects instance owns the humanoid stats of the sessions it touches. Attach one per game and share it through a service; two instances would each capture a base and overwrite the other’s writes.