Effects
Effects is the server’s status system: buffs and debuffs with durations, stacking, periodic ticks, dispel categories, and immunity. The defining decision is how stats are applied — every stat multiplier composes multiplicatively across all active effects and stacks, and recomputes from the captured base value. Removal restores the exact base. There is no “add 4, later subtract 4” bookkeeping to drift out of sync when effects overlap, refresh, or expire in odd orders.
Mental model
Section titled “Mental model”State and the sweep
Section titled “State and the sweep”attach keeps one state table per session — Active effects plus captured BaseStats — keyed weakly, so a departed session’s state is collectable even if the leave-cleanup bind never ran. A Scheduler loop at kernel.Priority.Low sweeps every SweepSeconds (default 0.1 s) and does two jobs:
- Ticks. An active effect with
TickSecondsfiresOnTick(session, stacks)each period. The first tick lands one full period after apply — an effect applied and immediately cleansed ticks zero times. If sweeps stall (server hitch), the overdue ticks run on the next sweep, bounded to 8 per sweep so a long freeze cannot dump a minute of poison in one frame. Both behaviors are spec-verified. - Expiry. Effects past
ExpiresAtare collected first and removed after the scan, becauseremovepublishesEffects.Expiredsynchronously and a subscriber that re-applies an effect would otherwise mutate tables under iteration.
OnTick, OnApply, and OnExpire all run via task.spawn — a throwing callback cannot halt 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 any stat is:
Multiplier = 1for each active effect with Stats[statName]: Multiplier *= PerStack ^ StacksTwo stacks of Chill at WalkSpeed = 0.5 plus Haste at WalkSpeed = 1.5 compose to 0.5² × 1.5 = 0.375 — order-independent, no drift (spec-verified).
Three humanoid stats auto-apply on every change: WalkSpeed, JumpPower, JumpHeight. The first recompute captures the humanoid’s current values as BaseStats; every later recompute writes Base * statMultiplier. On CharacterAdded the bases re-capture from the fresh humanoid (deferred one frame so the character is assembled).
Every other stat is a read, not a write — gameplay pulls the composed multiplier 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 apply:
- A new effect starts at 1 stack with
ExpiresAt = now + Duration(ornil— active until removed). - A re-apply increments stacks up to
MaxStacks(default 1) and refreshes the duration — re-applying at the cap still resets the timer. overrides.Durationreplaces the definition’s duration for this application.
Cleanse categories
Section titled “Cleanse categories”Category puts an effect in a dispel group. cleanse(session, category?):
- With a category: removes every active effect whose
Categorymatches; returns the count. - With no category: removes every categorized effect.
- Effects without a
Category— VIP flags, permanent passives — are never cleansed by either form.
Effects.Cleansed publishes only when at least one effect was removed. Each removed effect also runs its own OnExpire and publishes Effects.Expired — a cleanse is a batch of removals, not a separate lifecycle.
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, applies nothing, and publishes Effects.Blocked(player, name, byEffect) so the attacker’s UI can show the resist. Wards are ordinary effects — a 20-second anti-curse ward is one define with a Duration, and it can itself carry a Category so a dispel can strip it.
The check runs at apply time only: an already-active curse is not removed when a ward lands. Pair the ward with a cleanse if “ward purges existing curses” is the design.
The CKEffects state push
Section titled “The CKEffects state push”When the kernel has a NetDriver, attach defines a CKEffects state channel and pushes the owner a full snapshot after every change — apply, stack, refresh, remove, cleanse, expiry:
{ [effectName] = { Stacks = number, Remaining = number? } }Remaining is seconds until expiry, absent for until-removed effects. Status-icon UI is a pure render of this table — no client timers to drift, no per-effect wiring. The snapshot is small (active effects only) and event-driven, not polled.
-- src/Server/Bootstrap.luaulocal Root = game:GetService("ServerScriptService").ChloeKernelServerlocal Effects = require(Root.Effects)
return function(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, stacks) 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", {}) -- no Duration, no Category: permanent, uncleansable
kernel:onSession(function(session) Buffs:apply(session, "Haste") end)
-- The antidote item: -- Buffs:cleanse(session, "Curse")end-- src/Client/Bootstrap.luau — status icon UI is a render of the snapshotlocal ReplicatedStorage = game:GetService("ReplicatedStorage")local NetClient = require(ReplicatedStorage.ChloeKernel.Net.Client)
return function(kernel) local Net = NetClient.new()
local ActiveEffects = {} -- your UI renders from this table
Net:onState("CKEffects", { "Any" }, function(snapshot) -- The push is a full snapshot of active effects, not a diff: -- replace, don't merge. { [name] = { Stacks, Remaining? } } ActiveEffects = snapshot for Name, Entry in snapshot do -- Entry.Remaining is seconds until expiry; nil = until removed print(Name, Entry.Stacks, Entry.Remaining) end end)endHow kits consume Effects
Section titled “How kits consume Effects”- SpellKit — curses are the canonical pattern (the WizardDuel starter template ships it): a HexBolt spell’s
Castapplies aCategory = "Curse"ticking effect to the victim’s session, and a WardSelf spell applies anImmune = { "Curse" }effect to the caster. The spell system never knows what a curse is; Effects owns duration, ticking, blocking, and the UI push. - CompanionKit — a companion’s
Aurais an Effects name held on the owner while the companion is summoned (applied with no duration on summon, removed on dismiss or death). CompanionKit asserts at attach that it was given an Effects instance if any companion declares an aura. - Zone auras — pair with Zones: apply on
Zone.Entered, remove onZone.Left.
API reference
Section titled “API reference”Effects.attach(kernel, options?) options:
| Option | Default | Description |
|---|---|---|
SweepSeconds |
0.1 |
Tick/expiry sweep cadence |
SkipLoop |
false |
Do not schedule the sweep; drive _sweep(now) yourself (spec seam) |
EffectDefinition fields:
| Field | Default | Description |
|---|---|---|
Duration |
nil |
Seconds until expiry; nil = active until removed |
MaxStacks |
1 |
Stack ceiling; re-applies at the cap still refresh duration |
Stats |
nil |
Per-stack multiplier keyed by stat name; WalkSpeed/JumpPower/JumpHeight auto-apply |
WalkSpeed |
nil |
Legacy alias for Stats.WalkSpeed |
Category |
nil |
Cleanse group; nil = never cleansed |
Immune |
nil |
Categories this effect blocks while active |
TickSeconds |
nil |
OnTick period; nil = no ticks |
OnTick |
nil |
(session, stacks) per period, bounded catch-up after stalls |
OnApply |
nil |
(session, stacks) after every apply/stack |
OnExpire |
nil |
(session) on expiry, removal, or cleanse |
| Member | Description |
|---|---|
manager:define(name, definition) |
Register a definition; redefinition errors |
manager:apply(session, name, overrides?) → stacks |
Apply or stack; returns 0 when blocked by an active ward |
manager:remove(session, name) |
Remove now — runs OnExpire, publishes Effects.Expired |
manager:cleanse(session, category?) → count |
Remove matching categorized effects |
manager:has(session, name) → boolean |
Is the effect 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() |
Cancel the sweep, disconnect the session hook, clear state |
Bus topics
Section titled “Bus topics”| Topic | Payload | When |
|---|---|---|
Effects.Applied |
player, name, stacks |
Applied or stacked (payload carries the post-apply count) |
Effects.Expired |
player, name |
Expired, removed, or cleansed |
Effects.Blocked |
player, name, byEffect |
An apply was blocked by an active ward |
Effects.Cleansed |
player, category?, count |
A cleanse removed at least one effect |
Design notes
Section titled “Design notes”removeand expiry share one path. Manual removal, duration expiry, and cleanse all runOnExpireand publishEffects.Expired. If your handler must distinguish “dispelled” from “ran out”, listen forEffects.Cleansedalongside.- Immunity is apply-time. Applying a ward does not strip active covered effects; combine with
cleansefor purge-on-cast semantics. - Weak session keys are a backstop, not the contract. Session cleanup normally clears state via the session bind; the weak table just guarantees no leak if that bind never ran (spec fakes, partial teardowns).
- The state push is wired only on real kernels. Spec fakes without
kernel:net()run everything except theCKEffectssnapshot — the module degrades to a pure server system.