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.
Make abilities feel instant
Section titled “Make abilities feel instant”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.luaureturn 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.luaulocal ChloeKernel = game:GetService("ReplicatedStorage").ChloeKernellocal 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 })endPrediction.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.
Bind abilities to movement, not just keys
Section titled “Bind abilities to movement, not just keys”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.luaulocal ChloeKernel = game:GetService("ReplicatedStorage").ChloeKernellocal 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)endThe 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.
Add keybinds players can remap
Section titled “Add keybinds players can remap”-- src/Client/Bootstrap.luaulocal 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 profileendActions 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.
Cast spells with cooldowns
Section titled “Cast spells with cooldowns”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.luaulocal Root = game:GetService("ServerScriptService").ChloeKernelServerlocal 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 wiringlocal ChloeKernel = game:GetService("ReplicatedStorage").ChloeKernellocal 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 })endThe 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.