Skip to content

SpellKit

SpellKit is ability casting as a kernel service: mana with regen, per-spell cooldowns, and channelled casts that can be interrupted by damage. A channel is not a task.wait — it runs as a killable kernel Process, so a hit on the caster, the caster leaving, or the service stopping all end it cleanly through one mechanism.

The defining decision: mana and cooldown apply only when the channel completes. An interrupted cast never charges and never starts its cooldown — interruption is already the punishment, and double-charging it would make channelled spells strictly worse than instant ones. All validation lives in the fail-closed Intent.SK_Cast chain, so an illegal cast dies before any process spawns.

On session start the kit seeds session.Data.Mana = MaxMana and an empty session.Data.SpellCooldowns table (keyed by wire id, values are absolute os.clock() deadlines). A regen loop runs once per second at Background Scheduler priority, adding ManaRegenPerSecond up to the cap for every live session. Mana is transient by design — it is combat state, not progression, so it does not persist through the profile.

  1. Wire. The client fires SK_Cast(spellId: NumberU8) — rate limit 8/s.
  2. Validation — the kit’s gate on the fail-closed Intent.SK_Cast hook chain, priority 50. Rejects: unknown spell id, cooldown still hot, Mana < ManaCost, or the player is already channelling. Your game rules (silence debuffs, no-cast zones) prepend at lower priority numbers.
  3. Channel. The handler spawns a kernel Process named SK_Cast_{spellId} that yields every frame until ChannelSeconds elapse. Because it yields through Process.yield(), it is killable at every frame boundary.
  4. Completion. Mana is deducted, the cooldown deadline is stamped (os.clock() + Cooldown), your Cast(kernel, session, spell) callback runs, and Spell.Cast(session, spellId) publishes.
  5. Interruption. The kit subscribes to Combat.Hit on the bus; a hit whose victim is mid-channel kills the cast process. The process OnExit state "Killed" publishes Spell.Interrupted(session, spellId) — and because completion never ran, no mana was spent and no cooldown started.

A spell with ChannelSeconds = 0 is instant: the channel loop body never runs, and costs, cooldown, and Cast apply in the process’s first resumption. Instant spells still route through the same pipeline, so the validator’s “already channelling” check and the interrupt machinery stay uniform.

Kill paths beyond damage: the caster’s session ending kills the cast with reason "left", and service:stop() kills every active cast with reason "stopped" — both surface as Spell.Interrupted.

From the WizardDuel starter template — a damage bolt, a curse, and a ward, plus a channelled example. Spell payloads are plain functions: fire Projectiles, apply Effects, teleport — the kit owns the resource math, not the payload.

local Root = game:GetService("ServerScriptService").ChloeKernelServer
local Effects = require(Root.Effects)
local SpellKit = require(Root.Kits.SpellKit)
local Buffs = Effects.attach(Kernel)
Buffs:define("Hex", {
Duration = 6,
Category = "Curse",
TickSeconds = 1,
OnTick = function(session)
local Character = session.Player.Character
local Humanoid = Character and Character:FindFirstChildOfClass("Humanoid")
if Humanoid then
Humanoid:TakeDamage(3)
end
end,
})
Buffs:define("Ward", { Duration = 20, Immune = { "Curse" } })
local function nearestOpponent(session)
local Character = session.Player.Character
local CasterRoot = Character and Character.PrimaryPart
if not CasterRoot then
return nil
end
local Best, BestDistance = nil, 60
for _, Other in game:GetService("Players"):GetPlayers() do
local OtherRoot = Other ~= session.Player and Other.Character and Other.Character.PrimaryPart
if OtherRoot then
local Distance = (OtherRoot.Position - CasterRoot.Position).Magnitude
if Distance < BestDistance then
Best, BestDistance = Other, Distance
end
end
end
return Best
end
Kernel:registerService(SpellKit.service({
MaxMana = 100,
ManaRegenPerSecond = 5,
Spells = {
[1] = {
Name = "Firebolt",
ManaCost = 20,
Cooldown = 2,
ChannelSeconds = 0, -- instant
Cast = function(_kernel, session, _spell)
local Victim = nearestOpponent(session)
local Humanoid = Victim and Victim.Character:FindFirstChildOfClass("Humanoid")
if Humanoid then
Humanoid:TakeDamage(30)
end
end,
},
[2] = {
Name = "HexBolt",
ManaCost = 35,
Cooldown = 6,
ChannelSeconds = 0,
Cast = function(kernel, session, _spell)
local Victim = nearestOpponent(session)
local VictimSession = Victim and kernel:getSession(Victim)
if VictimSession then
Buffs:apply(VictimSession, "Hex")
end
end,
},
[3] = {
Name = "Meteor",
ManaCost = 60,
Cooldown = 12,
ChannelSeconds = 2.5, -- interruptible: a hit during these 2.5s cancels it free of charge
Cast = function(_kernel, session, _spell)
local Victim = nearestOpponent(session)
local Humanoid = Victim and Victim.Character:FindFirstChildOfClass("Humanoid")
if Humanoid then
Humanoid:TakeDamage(75)
end
end,
},
},
}))
-- SpellKit interrupts on Combat.Hit. Nothing publishes that topic for you —
-- bridge your damage sources into it (see the caution below).
Kernel.Bus:subscribe("Melee.Hit", function(_, attacker, victim)
-- MeleeKit player victims are sessions; NPC handles carry no .Player
local VictimPlayer = if type(victim) == "table" then victim.Player else nil
if VictimPlayer then
Kernel.Bus:publish("Combat.Hit", attacker, VictimPlayer)
end
end)

For instant-feel feedback — cast bars, muzzle flashes, self-highlights — wrap the intent with Prediction: predict the local cosmetic on send and roll it back if the server rejects (out of mana, cooldown hot). Predict only local, cosmetic state; the spell’s real payload always happens server-side in Cast.

SpellKit.service(config) takes a KitConfig:

Field Type Default Description
Spells { [number]: SpellConfig } required Spell definitions keyed by wire id (1–255, sent as NumberU8)
MaxMana number? 100 Mana cap; sessions spawn at full
ManaRegenPerSecond number? 5 Added once per second, clamped to MaxMana

Each SpellConfig:

Field Type Default Description
Name string required Display name; not used in validation
ManaCost number required Gated in validation; deducted only on completion
Cooldown number required Seconds; the deadline stamps only on completion
ChannelSeconds number required Channel duration. 0 = instant. Required — omitting it errors the cast process at runtime
Cast (kernel, session, spell) -> () required The payload, run server-side on completion
Channel Schema Rate limit Direction
SK_Cast { "NumberU8" } — spellId 8/s client → server, fail-closed
Point Mode Context Fired
Intent.SK_Cast fail-closed { Session, Player, Args = { spellId } } Every cast intent, before the handler. Kit gate at priority 50 rejects unknown id / hot cooldown / low mana / already channelling
Topic Payload Direction When
Spell.Cast session, spellId published A cast completed: costs paid, payload ran
Spell.Interrupted session, spellId published The channel was killed — by damage, the caster leaving, or stop(). No costs were paid
Combat.Hit attacker, victimPlayer consumed A hit on victimPlayer kills their active channel
  • Cooldowns are per spell, per session, wall-clock. SpellCooldowns[spellId] stores an absolute os.clock() deadline; there is no global cooldown or shared school cooldown — build those as a prepended Intent.SK_Cast validator.
  • One channel per player. The validator rejects a cast while another is channelling; there is no cast queue. Instant spells channel for zero frames but still occupy the slot for that instant, keeping the invariant simple.
  • Interrupts refund by never charging. If you want interrupts to cost something (half mana, a short lockout), do it in a Spell.Interrupted subscriber — the kit deliberately does not.
  • Mana UI. The kit pushes no state; replicate session.Data.Mana to the owner with a Replica or your own state push if the HUD needs a live bar.