SpellKit
Mana, regeneration, cooldowns and channelled casts as a kernel service, where channels run as killable Processes and costs apply only when a cast completes.
- Realm
- Server
- Require
ServerScriptService.ChloeKernelServer .Kits .SpellKit - Since
- v0.1.0
- Source
- Kits/SpellKit.luau
SpellKit is ability casting as a kernel service: mana with regeneration, per-spell cooldowns, and channelled casts that damage interrupts. A channel runs as a killable kernel Process that sleeps on a scheduler deadline, so damage, death, a respawn, the caster leaving and the service stopping all end it through one kill. It is a Kit.service kit. WeaponKit and MeleeKit cover guns and close combat.
The decision that defines it: mana and cooldown apply only when the channel completes. An interrupted cast never charges and never starts its cooldown. Interruption is the punishment already, and charging for it too would make every channelled spell worse than an instant one. 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”When a session starts, the kit sets session.Data.Mana = MaxMana and an empty session.Data.SpellCooldowns table, keyed by spell wire id, whose values are absolute deadlines on the kit clock (os.clock by default). Mana is combat state, not progression, so it lives in session.Data and is not saved.
Regeneration runs only when there is something to refill. A completed cast that leaves the caster below MaxMana adds that session to a regenerating set and starts one Scheduler:every(1, ...) job at Priority.Background if none is running. Each second the job adds ManaRegenPerSecond to every session in the set, clamped to MaxMana, and drops sessions that reached the cap. When the set is empty the job cancels itself. A server where nobody has cast recently runs no regeneration work.
The cast pipeline
Section titled “The cast pipeline”- The client fires
SK_Cast(spellId: NumberU8), rate limit 8 per second. - The kit’s validator on the fail-closed
Intent.SK_Casthook chain runs at priority 50. It rejects a dead or replaced caster, an unknown spell id, a cooldown still running,Mana < ManaCost, and a caster who is already channelling. Game rules (silence debuffs, no-cast zones) go in validators with lower priority numbers. - The handler checks the caster’s living Humanoid again, then spawns a kernel Process named
SK_Cast_{spellId}. The body callsProcess.sleep(EndsAt - Clock())untilChannelSecondshave passed.Process.sleepparks the process on one scheduler deadline instead of resuming it every frame. - While the channel runs, three connections can kill it:
Humanoid.HealthChangedwhen health drops below its last value,Player.CharacterRemovingfor the casting character, and the session ending. - When the channel ends, the process checks that the caster still has the same living Humanoid, and that mana and cooldown still allow the cast. If either changed, it kills itself with
"character changed"or"resources changed". Otherwise it stops watching for damage, deducts mana, starts regeneration if needed, stampsSpellCooldowns[spellId] = Clock() + Cooldown, runs yourCast(kernel, session, spell)and publishesSpell.Cast(session, spellId). - A killed process exits with state
"Killed", and the kit publishesSpell.Interrupted(session, spellId). Completion never ran, so no mana was spent and no cooldown started.
The watch stops before the payload runs. Damage that the completed spell causes, including damage to the caster, is not a channel interruption.
A spell with ChannelSeconds = 0 is instant: the sleep loop body never runs, and the checks, costs and Cast happen on the process’s first scheduler step. Instant spells use the same pipeline, so the “already channelling” check and the interrupt wiring stay uniform.
Interruption comes from actual health loss. Healing never interrupts, and neither does a hit that a shield or immunity absorbed before it reached Humanoid.Health. The kit still listens to a game-published Combat.Hit topic for compatibility: a Combat.Hit whose victim is mid-channel kills the cast. New code does not need to publish it, because a hit that lowers health interrupts on its own.
| Kill reason | Cause |
|---|---|
"damaged" |
The caster’s health dropped during the channel. |
"character removed" |
The casting character was removed. |
"character changed" |
At completion, the caster no longer had the same living Humanoid. |
"resources changed" |
At completion, mana or the cooldown no longer allowed the cast. |
"interrupted" |
A Combat.Hit named the caster as victim. |
"left" |
The caster’s session ended. |
"stopped" |
service:stop() ran. |
Every kill publishes Spell.Interrupted. The reason stays on the process as its result; the Bus topic does not carry it.
The WizardDuel starter template casts a damage bolt, a curse and a ward. Spell payloads are plain functions: fire Projectiles, apply Effects, teleport. The kit owns the resource math, not the payload.
--!strict-- Spells: SpellKit config. Wire ids are the table keys (1 to 255).
local Players = game:GetService("Players")
local function nearestOpponent(session: any): Player? 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 Players:GetPlayers() do local OtherCharacter = Other ~= session.Player and Other.Character local OtherRoot = OtherCharacter and OtherCharacter.PrimaryPart local OtherHumanoid = OtherCharacter and OtherCharacter:FindFirstChildOfClass("Humanoid") if OtherRoot and OtherHumanoid and OtherHumanoid.Health > 0 then local Distance = (OtherRoot.Position - CasterRoot.Position).Magnitude if Distance < BestDistance then Best, BestDistance = Other, Distance end end end return Bestend
local function strike(session: any, damage: number) local Victim = nearestOpponent(session) local Character = Victim and Victim.Character local Humanoid = Character and Character:FindFirstChildOfClass("Humanoid") if Humanoid then Humanoid:TakeDamage(damage) endend
return { MaxMana = 100, ManaRegenPerSecond = 5, Spells = { [1] = { Name = "Firebolt", ManaCost = 20, Cooldown = 2, ChannelSeconds = 0.4, Cast = function(_kernel: any, session: any, _spell: any) strike(session, 30) end, }, [2] = { Name = "Meteor", ManaCost = 60, Cooldown = 12, -- Any damage to the caster in these 2.5 s cancels the cast for free ChannelSeconds = 2.5, Cast = function(_kernel: any, session: any, _spell: any) strike(session, 75) end, }, },}--!strict-- Every server service, one require per module in Services. ServiceManager-- orders boot by each service's Dependencies.
local ServerScriptService = game:GetService("ServerScriptService")
local SpellKit = require(ServerScriptService.ChloeKernelServer.Kits.SpellKit)
return { require(script.Parent.Services.ComponentService), SpellKit.service(require(script.Parent.Config.Spells)),} :: { any }--!strict-- SpellController: Q casts Firebolt, R casts Meteor.
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local InputDriver = require(ReplicatedStorage.ChloeKernel.InputDriver)local NetClient = require(ReplicatedStorage.ChloeKernel.Net.Client)
local SpellController = { Name = "SpellController",}
function SpellController.init(self: any, kernel: any) self.Kernel = kernelend
function SpellController.start(self: any) local Cast = NetClient.new():intent("SK_Cast", { "NumberU8" }) self.Input = InputDriver.attach(self.Kernel) local function bindSpell(action: string, spellId: number, key: Enum.KeyCode, button: Enum.KeyCode) self.Input:bindAction(action, { Keyboard = key, Gamepad = button, TouchButton = true, Handler = function(state: Enum.UserInputState) if state == Enum.UserInputState.Begin then Cast.fire(spellId) end end, }) end bindSpell("Firebolt", 1, Enum.KeyCode.Q, Enum.KeyCode.ButtonX) bindSpell("Meteor", 2, Enum.KeyCode.R, Enum.KeyCode.ButtonY)end
function SpellController.stop(self: any) if self.Input then self.Input:destroy() self.Input = nil endend
return SpellControllerFor instant feedback (cast bars, a hand glow), wrap the intent with Prediction: show the local cosmetic on send and roll it back if the server rejects the cast. Predict only local, cosmetic state. The spell’s real effect always happens on the server 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 to 255, sent as NumberU8). |
MaxMana |
number? |
100 |
Mana cap. Sessions start full. |
ManaRegenPerSecond |
number? |
5 |
Added once per second to sessions below the cap after a cast, clamped to MaxMana. |
Clock |
(() -> number)? |
os.clock |
Injectable monotonic clock for cooldowns and channel deadlines. |
Each SpellConfig:
| Field | Type | Default | Description |
|---|---|---|---|
Name |
string |
required | Display name. Validation does not read it. |
ManaCost |
number |
required | Checked in validation and again at completion. Deducted only on completion. |
Cooldown |
number |
required | Seconds. The deadline is stamped only on completion. |
ChannelSeconds |
number? |
0 |
Channel duration. 0 is instant. |
Cast |
(kernel, session, spell) -> () |
required | The payload, run on the server after the costs are paid. |
| Member | Description |
|---|---|
SpellKit.service(config) -> ServiceDefinition |
Returns a service named "SpellKit" for the game Registry.luau or kernel:registerService. |
service:init(kernel) |
Kernel-called. Seeds sessions, defines SK_Cast, installs the validator at priority 50 and the handler, and subscribes to Combat.Hit. |
service:beginCast(session, spellId, spell) -> boolean |
Starts a channel without the intent path, for server-driven casts (a scripted boss, a tutorial). Returns false when the caster has no living Humanoid or is already channelling. It does not check mana or cooldown before the channel; completion does. |
service:stop() |
Kernel-called at close and shutdown. Disconnects the session listener, cancels regeneration, unhooks the validator, disconnects Combat.Hit, and kills every active cast with reason "stopped". The handler goes inert. |
Get the running service with kernel:getService("SpellKit").
Wire channel
Section titled “Wire channel”| Channel | Schema | Rate limit | Direction |
|---|---|---|---|
SK_Cast |
{ "NumberU8" }: spellId |
8 per second | client to server, fail-closed |
| Point | Mode | Context | Fired |
|---|---|---|---|
Intent.SK_Cast |
fail-closed | { Name, Session, Player, Args = { spellId } } |
Every cast intent, before the handler. The kit validator at priority 50 rejects a dead caster, an unknown id, a running cooldown, low mana and a caster 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. No costs were paid. |
Combat.Hit |
attacker, victim |
consumed | Legacy interrupt. victim is a Player or a session. A hit on a caster mid-channel kills the cast. |
Design notes
Section titled “Design notes”- Cooldowns are per spell and per session. There is no global cooldown or shared school cooldown. Build those as a validator on
Intent.SK_Castwith a lower priority number. - One channel per player. The validator rejects a cast while another is channelling, and there is no cast queue.
- Interrupts cost nothing because nothing was charged. If an interrupt should cost something (half the mana, a short lockout), do it in a
Spell.Interruptedsubscriber. - The kit pushes no state to clients. Replicate
session.Data.Manato its owner with a Replica or a State channel if the HUD shows a live bar.