Skip to content

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.

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:

  1. Ticks. An active effect with TickSeconds fires OnTick(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.
  2. Expiry. Effects past ExpiresAt are collected first and removed after the scan, because remove publishes Effects.Expired synchronously 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.

Stats on a definition maps stat names to a per-stack multiplier. The composed multiplier for any stat is:

Multiplier = 1
for each active effect with Stats[statName]:
Multiplier *= PerStack ^ Stacks

Two 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(session, name, overrides?) returns the stack count after apply:

  • A new effect starts at 1 stack with ExpiresAt = now + Duration (or nil — 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.Duration replaces the definition’s duration for this application.

Category puts an effect in a dispel group. cleanse(session, category?):

  • With a category: removes every active effect whose Category matches; 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.

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.

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.luau
local Root = game:GetService("ServerScriptService").ChloeKernelServer
local 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
  • SpellKit — curses are the canonical pattern (the WizardDuel starter template ships it): a HexBolt spell’s Cast applies a Category = "Curse" ticking effect to the victim’s session, and a WardSelf spell applies an Immune = { "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 Aura is 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 on Zone.Left.

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
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
  • remove and expiry share one path. Manual removal, duration expiry, and cleanse all run OnExpire and publish Effects.Expired. If your handler must distinguish “dispelled” from “ran out”, listen for Effects.Cleansed alongside.
  • Immunity is apply-time. Applying a ward does not strip active covered effects; combine with cleanse for 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 the CKEffects snapshot — the module degrades to a pure server system.