Skip to content

Movement & Abilities

Abilities live on both sides of the wire: the client makes them feel instant, the server makes them honest. These recipes cover the four layers — optimistic prediction, movement-context gating, remappable input, and a full casting service. Server snippets go in src/Server/Bootstrap.luau, client snippets in src/Client/Bootstrap.luau — see Getting Started for the bootstrap shape.

Optimistic prediction: the client acts immediately, the server validates, rejections roll back. No round-trip lag on button presses, no trust in the client:

-- src/Server/Bootstrap.luau
return function(kernel)
local Net = kernel:net()
-- a predicted intent acks every sequence number
Net:definePredictedIntent("Dash", {}, {
Handler = function(session)
session.Data.LastDash = os.clock()
end,
})
kernel.Hooks:on("Intent.Dash", function(context)
return os.clock() - (context.Session.Data.LastDash or 0) >= 2 -- cooldown
end)
end
-- src/Client/Bootstrap.luau
local ChloeKernel = game:GetService("ReplicatedStorage").ChloeKernel
local InputDriver = require(ChloeKernel.InputDriver)
local NetClient = require(ChloeKernel.Net.Client)
local Prediction = require(ChloeKernel.Net.Prediction)
return function(kernel)
local Net = NetClient.new()
local LocalPlayer = game:GetService("Players").LocalPlayer
local Dash = Prediction.wrap(Net, "Dash", {}, {
Predict = function()
local Character = LocalPlayer.Character
local RootPart = Character and Character.PrimaryPart
if not RootPart then
return nil
end
local Before = RootPart.CFrame
RootPart.CFrame += RootPart.CFrame.LookVector * 12 -- instantly
return function()
RootPart.CFrame = Before -- rollback if the server says no
end
end,
})
local Input = InputDriver.attach(kernel)
Input:bindAction("Dash", { Keyboard = Enum.KeyCode.Q, Handler = function(state)
if state == Enum.UserInputState.Begin then
Dash.fire()
end
end })
end

Prediction.wrap prepends a sequence number to every send; the server acks each one after validators and handler run, and the client keeps or rolls back the optimistic effect accordingly. No ack within the timeout (default 2s — dropped packet, or the server’s deliberate rate-limit silence) also rolls back, so the client always converges on the server’s truth.

Predict only local/cosmetic state — your own character’s CFrame, a sound, a UI flash. The server remains authoritative: the Intent.Dash hook chain is fail-closed, so the cooldown check on server-side state (never client claims) is what actually gates the dash. Dash.OnResolved:connect(function(seq, accepted, timedOut) ... end) is there when you need to react to the verdict.

Deep dives: Prediction, NetDriver.

MoveKit answers “jump pressed WHILE AIRBORNE” instead of just “jump pressed” — the double-jump/air-step/dash family, with charges that refill on landing:

-- src/Client/Bootstrap.luau
local ChloeKernel = game:GetService("ReplicatedStorage").ChloeKernel
local MoveKit = require(ChloeKernel.MoveKit)
local NetClient = require(ChloeKernel.Net.Client)
return function(kernel)
local Net = NetClient.new()
local Moves = MoveKit.attach(kernel, { Net = Net })
Moves:define("DoubleJump", MoveKit.recipes.doubleJump({ Power = 1.2 }))
Moves:define("AirStep", MoveKit.recipes.airStep({
Steps = 3, -- anime stair-running: three footholds per fall
OnStep = function(position, useIndex)
-- spawn a glow pad at the foothold; useIndex differentiates step 1/2/3
end,
}))
Moves:define("AirDash", {
Input = "Dash", -- an InputDriver action; "Jump" rides the jump request itself
When = "Airborne", -- or "Grounded" / "Rising" / "Falling" / function(context)
Charges = 1,
Cooldown = 1.5,
Run = function(context)
context.Root.AssemblyLinearVelocity = context.Root.CFrame.LookVector * 90
end,
})
end
-- src/Server/Bootstrap.luau: the meter. Unknown abilities reject, spent charges
-- reject, and the pool refills only when the SERVER's own ground probe sees
-- feet touch down.
local MoveKit = require(game:GetService("ReplicatedStorage").ChloeKernel.MoveKit)
return function(kernel)
MoveKit.server(kernel, {
Abilities = {
DoubleJump = { Charges = 1, Cooldown = 0.2 },
AirStep = { Charges = 3, Cooldown = 0.15 },
AirDash = { Charges = 1, Cooldown = 1.5, Forgive = true }, -- horizontal burst: pardon the anti-cheat sample
},
})
kernel.Bus:subscribe("Move.Used", function(_, session, name, position)
-- replicate the VFX to everyone else here
end)
end

The ownership guarantee holds: the client executes (it owns its character’s physics — the velocity writes are legitimate), the server meters through the fail-closed CKMove intent with its own charge/cooldown books. A hacked client can spam requests; it cannot make the server bless a fourth air-step. Charges refill only when the server’s own ground probe (a 4.5-stud down raycast by default) sees the character grounded at use time, and Forgive = true publishes AntiExploit.Forgive on valid use so a horizontal burst never trips the movement monitor.

Run receives {Humanoid, Root, State, AirTime, Rising, UseIndex, ChargesLeft}UseIndex is how the second air-step gets a different sound than the first. Triggers debounce the takeoff press (MinAirTime, default 0.1s), and Moves:trigger(name) fires abilities from your own code. Client bus: Move.Triggered(name, context); server bus: Move.Used(session, name, position) / Move.Rejected(player, name, reason).

Deep dives: MoveKit, InputDriver.

-- src/Client/Bootstrap.luau
local InputDriver = require(game:GetService("ReplicatedStorage").ChloeKernel.InputDriver)
return function(kernel)
local Input = InputDriver.attach(kernel)
Input:bindAction("Sprint", {
Keyboard = Enum.KeyCode.LeftShift,
Gamepad = Enum.KeyCode.ButtonL3,
Handler = function(state)
-- state is Begin on press, End on release
end,
})
-- bind one action to several physical inputs: pass an array per device
Input:bindAction("Cast", { Keyboard = { Enum.KeyCode.E, Enum.KeyCode.F } })
-- settings menu
Input:rebind("Sprint", "Keyboard", Enum.KeyCode.LeftControl)
local Snapshot = Input:getBindings() -- serialize into the player's profile
end

Actions bind over ContextActionService with a CK_ prefix, and rebind swaps device keys at runtime by unbinding and rebinding the same action — handlers survive the swap untouched. A throwing handler is isolated (wrapped in xpcall), so one broken callback can’t eat input for everything else.

Every action also publishes Input.<Action> on the client bus, so systems can listen without owning the binding — that topic is exactly what MoveKit’s Input field consumes. getBindings() snapshots key names (strings, not EnumItems) ready to persist; pair it with Player Settings to save keymaps across sessions.

Deep dive: InputDriver.

SpellKit is a kernel service owning mana, regen, per-spell cooldowns, and channelled casts — you declare spells as data and write only the Cast bodies:

-- src/Server/Bootstrap.luau
local Root = game:GetService("ServerScriptService").ChloeKernelServer
local Effects = require(Root.Effects)
local SpellKit = require(Root.Kits.SpellKit)
return function(kernel)
local Buffs = Effects.attach(kernel)
Buffs:define("Hex", {
Duration = 6,
Category = "Curse",
TickSeconds = 1,
OnTick = function(session)
local Humanoid = session.Player.Character
and session.Player.Character:FindFirstChildOfClass("Humanoid")
if Humanoid then
Humanoid:TakeDamage(3)
end
end,
})
local function nearestOpponent(session)
local Character = session.Player.Character
local RootPart = Character and Character.PrimaryPart
if not RootPart 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 - RootPart.Position).Magnitude
if Distance < BestDistance then
Best, BestDistance = Other, Distance
end
end
end
return Best
end
kernel:registerService(SpellKit.service({
MaxMana = 100, -- per-session pool; regenerates at ManaRegenPerSecond (default 5)
Spells = {
[1] = {
Name = "Firebolt",
ManaCost = 20, -- deducted only when the cast completes
Cooldown = 2, -- enforced server-side per session
ChannelSeconds = 0, -- instant
Cast = function(k, session, spell)
-- effect code; every gate already passed
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 = 1.2, -- channelled: damage during the channel interrupts it
Cast = function(k, session, spell)
local Victim = nearestOpponent(session)
local VictimSession = Victim and k:getSession(Victim)
if VictimSession then
Buffs:apply(VictimSession, "Hex")
end
end,
},
},
}))
-- publish this from your damage code; SpellKit kills the victim's channel on it
-- kernel.Bus:publish("Combat.Hit", attackerPlayer, victimPlayer)
end
-- src/Client/Bootstrap.luau: cast wiring
local ChloeKernel = game:GetService("ReplicatedStorage").ChloeKernel
local InputDriver = require(ChloeKernel.InputDriver)
local NetClient = require(ChloeKernel.Net.Client)
return function(kernel)
local Net = NetClient.new()
local Cast = Net:intent("SK_Cast", { "NumberU8" }) -- the kit defined the server side
local Input = InputDriver.attach(kernel)
Input:bindAction("Firebolt", { Keyboard = Enum.KeyCode.Q, Handler = function(state)
if state == Enum.UserInputState.Begin then
Cast.fire(1)
end
end })
Input:bindAction("HexBolt", { Keyboard = Enum.KeyCode.E, Handler = function(state)
if state == Enum.UserInputState.Begin then
Cast.fire(2)
end
end })
end

The kit wires SK_Cast with a rate limit and registers its own validator in the fail-closed Intent.SK_Cast chain at priority 50: unknown spell id, hot cooldown, insufficient mana, or an already-running channel all reject before any handler runs. Prepend your own rules (silence debuffs, zone restrictions) at lower priority numbers without forking the kit.

Every cast — instant or channelled — runs as a killable kernel Process: the channel yields each scheduler step until ChannelSeconds elapse, then deducts mana, arms the cooldown, and runs Cast. A Combat.Hit bus publish naming the caster as victim kills the Process mid-channel and publishes Spell.Interrupted — and because mana and cooldown are deducted only on completion, an interrupted cast costs nothing. Combat.Hit is yours to publish from whatever deals damage; nothing fires it automatically. A Cast that moves the player must publish AntiExploit.Forgive first (see the movement monitor).

Bus: Spell.Cast(session, spellId), Spell.Interrupted(session, spellId). The WizardDuel starter template is this exact recipe plus MeleeKit parries and double jumps.

Deep dives: SpellKit, Effects, Hooks.