Skip to content

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.

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:

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

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

local Multiplier = 1
for Name, Active in ActiveEffects do
local Stats = Definitions[Name].Stats
local PerStack = Stats and Stats[StatName]
if PerStack then
Multiplier *= PerStack ^ Active.Stacks
end
end

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

  • A new effect starts at 1 stack, expiring Duration seconds 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.Duration replaces the definition’s duration for this apply.

apply errors for an unknown name and after detach().

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.

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”.

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.

src/Server/Game/Services/StatusService.luau
--!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 = Buffs
end
-- 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

A screen subscribes to Status.Changed (a topic this controller owns) and draws one icon per entry, counting Remaining down locally between pushes.

  • SpellKit: curses are the pattern the WizardDuel template ships. A HexBolt spell’s Cast applies a Category = "Curse" ticking effect to the victim, and a WardSelf spell applies an Immune = { "Curse" } effect to the caster. The spell system never knows what a curse is.
  • CompanionKit: a companion’s Aura is 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.Entered or Hazard.Entered, remove on the matching leave topic.

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
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
  • remove, expiry and cleanse share one path: all three run OnExpire and publish Effects.Expired. To tell “dispelled” from “ran out”, listen for Effects.Cleansed as well.
  • Session end drops a session’s effects without running OnExpire or publishing Effects.Expired. Put leave-time cleanup in session:bind, not in OnExpire.
  • 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.

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