Skip to content

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.

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.

  1. The client fires SK_Cast(spellId: NumberU8), rate limit 8 per second.
  2. The kit’s validator on the fail-closed Intent.SK_Cast hook 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.
  3. The handler checks the caster’s living Humanoid again, then spawns a kernel Process named SK_Cast_{spellId}. The body calls Process.sleep(EndsAt - Clock()) until ChannelSeconds have passed. Process.sleep parks the process on one scheduler deadline instead of resuming it every frame.
  4. While the channel runs, three connections can kill it: Humanoid.HealthChanged when health drops below its last value, Player.CharacterRemoving for the casting character, and the session ending.
  5. 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, stamps SpellCooldowns[spellId] = Clock() + Cooldown, runs your Cast(kernel, session, spell) and publishes Spell.Cast(session, spellId).
  6. A killed process exits with state "Killed", and the kit publishes Spell.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.

src/Server/Game/Config/Spells.luau
--!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 Best
end
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)
end
end
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,
},
},
}
src/Server/Game/Registry.luau
--!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 }

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

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").

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.
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.
  • Cooldowns are per spell and per session. There is no global cooldown or shared school cooldown. Build those as a validator on Intent.SK_Cast with 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.Interrupted subscriber.
  • The kit pushes no state to clients. Replicate session.Data.Mana to its owner with a Replica or a State channel if the HUD shows a live bar.

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