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.
Mental model
Section titled “Mental model”Session state
Section titled “Session state”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.
The cast pipeline
Section titled “The cast pipeline”- Wire. The client fires
SK_Cast(spellId: NumberU8)— rate limit 8/s. - Validation — the kit’s gate on the fail-closed
Intent.SK_Casthook 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. - Channel. The handler spawns a kernel Process named
SK_Cast_{spellId}that yields every frame untilChannelSecondselapse. Because it yields throughProcess.yield(), it is killable at every frame boundary. - Completion. Mana is deducted, the cooldown deadline is stamped (
os.clock() + Cooldown), yourCast(kernel, session, spell)callback runs, andSpell.Cast(session, spellId)publishes. - Interruption. The kit subscribes to
Combat.Hiton the bus; a hit whose victim is mid-channel kills the cast process. The processOnExitstate"Killed"publishesSpell.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 Bestend
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) endend)local ChloeKernel = game:GetService("ReplicatedStorage").ChloeKernel
local InputDriver = require(ChloeKernel.InputDriver)local NetClient = require(ChloeKernel.Net.Client)
local Net = NetClient.new()local Cast = Net:intent("SK_Cast", { "NumberU8" })
local Input = InputDriver.attach(Kernel)local function onBegin(fire: () -> ()) return function(state: Enum.UserInputState) if state == Enum.UserInputState.Begin then fire() end endendInput:bindAction("Firebolt", { Keyboard = Enum.KeyCode.Q, Handler = onBegin(function() Cast:Fire(1)end) })Input:bindAction("HexBolt", { Keyboard = Enum.KeyCode.E, Handler = onBegin(function() Cast:Fire(2)end) })Input:bindAction("Meteor", { Keyboard = Enum.KeyCode.R, Handler = onBegin(function() Cast:Fire(3)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.
Configuration
Section titled “Configuration”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 |
Wire channel
Section titled “Wire channel”| 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 |
Bus topics
Section titled “Bus topics”| 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 |
Design notes
Section titled “Design notes”- Cooldowns are per spell, per session, wall-clock.
SpellCooldowns[spellId]stores an absoluteos.clock()deadline; there is no global cooldown or shared school cooldown — build those as a prependedIntent.SK_Castvalidator. - 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.Interruptedsubscriber — the kit deliberately does not. - Mana UI. The kit pushes no state; replicate
session.Data.Manato the owner with a Replica or your own state push if the HUD needs a live bar.