Movement and abilities
Recipes for predicted abilities, movement-gated moves, keybinds players can remap on every device, and SpellKit casting with mana and cooldowns.
Abilities live on both sides of the wire. The client makes them feel instant, and the server decides whether they happened. Server examples are services in src/Server/Game/Services/, client examples are controllers in src/Client/Game/Controllers/, and game channels come from src/Shared/Game/Channels.luau as self.Net (see Services and controllers). Every action binds keyboard, gamepad and touch.
Make abilities feel instant
Section titled “Make abilities feel instant”The client applies the dash at once, the server validates it, and a rejection rolls it back:
-- No arguments to validate: Open acknowledges that, and the Intent.Dash hook still gates itDash = { Kind = "PredictedIntent", Schema = {}, RateLimit = 4, Open = true },--!strict-- DashService: server gate for the predicted dash.
local DashCooldown = 2
local DashService = { Name = "DashService",}
function DashService.init(self: any, kernel: any) self.Kernel = kernel self.Scope = kernel:createScope()end
function DashService.start(self: any) self.Scope:add(self.Kernel.Hooks:on("Intent.Dash", function(context) local ReadyAt = context.Session.Data.DashReadyAt or 0 return os.clock() >= ReadyAt and context.Player.Character ~= nil end, 10))
self.Net.Dash.on(function(session) session.Data.DashReadyAt = os.clock() + DashCooldown -- The client already moved: pardon that sample so the movement monitor does not flag it self.Kernel.Bus:publish("AntiExploit.Forgive", session.Player) end)end
function DashService.stop(self: any) self.Scope:destroy()end
return DashService--!strict-- DashController: predicted dash on every input device.
local Players = game:GetService("Players")local ReplicatedStorage = game:GetService("ReplicatedStorage")
local InputDriver = require(ReplicatedStorage.ChloeKernel.InputDriver)
local DashController = { Name = "DashController",}
function DashController.init(self: any, kernel: any) self.Kernel = kernelend
function DashController.start(self: any) local Dash = self.Net.Dash.predict({ Predict = function() local Character = Players.LocalPlayer.Character local Root = Character and Character.PrimaryPart if not Root then return nil end local Before = Root.CFrame Root.CFrame += Root.CFrame.LookVector * 12 return function() Root.CFrame = Before -- the server said no end end, })
self.Input = InputDriver.attach(self.Kernel) self.Input:bindAction("Dash", { Keyboard = Enum.KeyCode.Q, Gamepad = Enum.KeyCode.ButtonB, TouchButton = true, Title = "Dash", Handler = function(state) if state == Enum.UserInputState.Begin then Dash.fire() end end, })end
function DashController.stop(self: any) if self.Input then self.Input:destroy() endend
return DashControllerPrediction.wrap (which .predict calls) prepends a sequence number to every send. The server acks each one after the hook chain and the handler run, and the client keeps or rolls back its effect. A missing ack also rolls back, which covers a dropped packet and the silence a rate-limited send gets. The timeout defaults to max(2, 3 x round trip + 0.5) seconds, read from LocalPlayer:GetNetworkPing() at each fire, so honest acks on slow links still land; TimeoutSeconds fixes it. Dash.OnResolved:connect(function(seq, accepted, timedOut) end) reports each verdict.
Predict only local, cosmetic state: your own character’s CFrame, a sound, a UI flash. The Intent.Dash hook checks server state (the ready time the handler stored), never a client claim. A handler that throws acks false.
Go deeper: Prediction, Registry, Movement monitor.
Gate abilities on movement state
Section titled “Gate abilities on movement state”MoveKit triggers an ability on “jump pressed while airborne”, where a key binding only knows “jump pressed”. It covers the double jump, air step and air dash family, with charges that refill on landing:
--!strict-- MovesController: double jump, air steps and an air dash.
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local InputDriver = require(ReplicatedStorage.ChloeKernel.InputDriver)local MoveKit = require(ReplicatedStorage.ChloeKernel.MoveKit)local NetClient = require(ReplicatedStorage.ChloeKernel.Net.Client)
local MovesController = { Name = "MovesController",}
function MovesController.init(self: any, kernel: any) self.Kernel = kernelend
function MovesController.start(self: any) -- The Dash action publishes Input.Dash on the Bus; MoveKit listens to that topic self.Input = InputDriver.attach(self.Kernel) self.Input:bindAction("Dash", { Keyboard = Enum.KeyCode.Q, Gamepad = Enum.KeyCode.ButtonB, TouchButton = true, Title = "Dash", })
self.Moves = MoveKit.attach(self.Kernel, { Net = NetClient.new() }) self.Moves:define("DoubleJump", MoveKit.recipes.doubleJump({ Power = 1.2 })) self.Moves:define("AirStep", MoveKit.recipes.airStep({ Steps = 3 })) self.Moves:define("AirDash", { Input = "Dash", -- an InputDriver action; "Jump" uses the jump request itself When = "Airborne", -- or "Grounded", "Rising", "Falling", or function(context) Charges = 1, Cooldown = 1.5, Run = function(context) context.Root.AssemblyLinearVelocity = context.Root.CFrame.LookVector * 90 end, })end
function MovesController.stop(self: any) if self.Moves then self.Moves:destroy() end if self.Input then self.Input:destroy() endend
return MovesController--!strict-- MovesService: server charge books for movement abilities.
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local MoveKit = require(ReplicatedStorage.ChloeKernel.MoveKit)
local MovesService = { Name = "MovesService",}
function MovesService.init(self: any, kernel: any) self.Meter = MoveKit.server(kernel, { Abilities = { DoubleJump = { Charges = 1, Cooldown = 0.2 }, AirStep = { Charges = 3, Cooldown = 0.15 }, AirDash = { Charges = 1, Cooldown = 1.5, Forgive = true }, -- a horizontal burst: pardon the monitor sample }, })end
function MovesService.stop(self: any) self.Meter.destroy()end
return MovesServiceThe client runs the ability: it owns its character’s physics, so the velocity write is legitimate. The server meters each use through the CKMove intent (rate limit 20) with its own charge and cooldown books. A modified client can spam requests, but it cannot make the server accept a fourth air step. Charges refill only when the server sees the character grounded: a 1-stud sphere cast 3.5 studs down that hits colliding parts only, so a ledge edge or a thin rail counts as ground and a non-colliding trigger volume does not. While a player holds spent charges, the server repeats that probe every 0.05 s. Forgive = true publishes AntiExploit.Forgive on each accepted use, so the burst never trips the movement monitor. A respawn resets the player’s books.
Run receives Humanoid, Root, State, AirTime, Rising, UseIndex and ChargesLeft; UseIndex lets the second air step play a different sound from the first. Presses earlier than MinAirTime (0.1 s) after takeoff are ignored. UserInputService.JumpRequest fires several times per press, so a Jump input counts a new press only after JumpPressGap (0.05 s) without one, and holding the button never spends a charge. Climbing, swimming, sitting, ragdolled and dead characters read as State = "Other": every built-in When fails there and charges keep their count until a real landing. Moves:trigger(name) fires an ability from your own code. Client Bus: Move.Triggered(name, context). Server Bus: Move.Used(session, name, position), Move.Rejected(player, name, reason) with the reason "Charges" or "Cooldown".
Go deeper: MoveKit, InputDriver.
Add keybinds players can remap
Section titled “Add keybinds players can remap”--!strict-- ControlsController: sprint and block bindings, remappable from the settings screen.
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local InputDriver = require(ReplicatedStorage.ChloeKernel.InputDriver)
local ControlsController = { Name = "ControlsController",}
function ControlsController.init(self: any, kernel: any) self.Kernel = kernel self.Scope = kernel:createScope()end
function ControlsController.start(self: any) local Input = self.Scope:add(InputDriver.attach(self.Kernel), "destroy") self.Input = Input
Input:bindAction("Sprint", { Keyboard = Enum.KeyCode.LeftShift, Gamepad = Enum.KeyCode.ButtonL3, TouchButton = true, Title = "Sprint", Handler = function(state) -- End and Cancel both read as released self.Kernel.Bus:publish("Controls.Sprinting", state == Enum.UserInputState.Begin) end, })
-- Outrank other bindings and keep the key: without Sink, Space would also jump Input:bindAction("Block", { Keyboard = Enum.KeyCode.Space, Gamepad = Enum.KeyCode.ButtonL2, TouchButton = true, Title = "Block", Priority = Enum.ContextActionPriority.High.Value, Sink = true, })
self.Scope:add(self.Kernel.Bus:subscribe("Input.PreferredChanged", function(_topic, preferred) self.Kernel.Bus:publish("Controls.ShowGamepadPrompts", preferred == Enum.PreferredInput.Gamepad) end))end
-- The settings screen calls thesefunction ControlsController.remap(self: any, action: string, keyNames: { string }) self.Input:rebind(action, "Keyboard", InputDriver.parseKeys(keyNames))end
function ControlsController.keyLabel(_self: any, key: Enum.KeyCode): string return InputDriver.label(key, true) -- "LCtrl", and the real key on AZERTY layoutsend
function ControlsController.stop(self: any) self.Scope:destroy()end
return ControlsControllerActions bind through ContextActionService under a CK_ prefix. rebind(name, device, keys) swaps the keys at runtime and keeps the handler. Each press publishes Input.<Action>(state, input) on the client Bus first, so systems can listen without owning the binding (this is the topic MoveKit Input reads), then runs the handler on its own thread: a handler may yield (a Request, a tween) without ContextActionService sinking the input, and a throwing handler is logged and isolated. Input passes on to lower bindings and the default scripts unless the action sets Sink. A hold action must treat Enum.UserInputState.Cancel as a release: ContextActionService sends Cancel, never End, when a rebind, unbindAction, destroy or a newer binding takes the key while it is held.
KeyCodes are QWERTY positions, so label keys with InputDriver.label, which asks the engine for the key on the player’s layout. getBindings() returns key names (strings) ready to save. InputDriver.parseKeys turns saved names back into KeyCodes and drops unknown, deprecated and non-string names, so a stale or tampered keymap never errors. Save keymaps with Player settings (see Save player settings and keybinds). ContextActionService draws at most 7 touch buttons.
Go deeper: InputDriver.
Cast spells with cooldowns
Section titled “Cast spells with cooldowns”SpellKit is a kernel service that owns mana, regeneration, per-spell cooldowns and channelled casts. Spells are data plus a Cast body, and the kit goes into the registry like any service:
--!strict-- Spells: SpellKit configuration. Wire ids 1..255 key the table.
local Players = game:GetService("Players")
local function nearestOpponent(session: any, maxDistance: number): Player? local Character = session.Player.Character local Root = Character and Character.PrimaryPart if not Root then return nil end local Best, BestDistance = nil, maxDistance for _, Other in Players:GetPlayers() do local OtherRoot = Other ~= session.Player and Other.Character and Other.Character.PrimaryPart if OtherRoot then local Distance = (OtherRoot.Position - Root.Position).Magnitude if Distance < BestDistance then Best, BestDistance = Other, Distance end end end return Bestend
return { MaxMana = 100, ManaRegenPerSecond = 5, Spells = { [1] = { Name = "Firebolt", ManaCost = 20, -- spent only when the cast completes Cooldown = 2, ChannelSeconds = 0, Cast = function(_kernel, session, _spell) local Victim = nearestOpponent(session, 60) local Humanoid = Victim and Victim.Character and Victim.Character:FindFirstChildOfClass("Humanoid") if Humanoid then Humanoid:TakeDamage(30) end end, }, [2] = { Name = "HexBolt", ManaCost = 35, Cooldown = 6, ChannelSeconds = 1.2, -- taking damage during the channel interrupts it Cast = function(kernel, session, _spell) local Victim = nearestOpponent(session, 60) local VictimSession = Victim and kernel:getSession(Victim) if VictimSession then kernel:getService("StatusService").Buffs:apply(VictimSession, "Hex") end end, }, },}--!strict-- Every server service, one entry per system. ServiceManager orders boot by Dependencies.
local ServerScriptService = game:GetService("ServerScriptService")
local SpellKit = require(ServerScriptService.ChloeKernelServer.Kits.SpellKit)
return { require(script.Parent.Services.ComponentService), require(script.Parent.Services.StatusService), SpellKit.service(require(script.Parent.Config.Spells)),} :: { any }HexBolt applies a Hex effect, defined in the StatusService from Apply buffs and debuffs with Buffs:define("Hex", { Duration = 6, Category = "Curse", TickSeconds = 1, OnTick = ... }). The client fires the kit’s intent:
--!strict-- SpellController: spell keys on every input device.
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" }) -- SpellKit defines the server side self.Input = InputDriver.attach(self.Kernel) for SpellId, Binding in { { Enum.KeyCode.Q, Enum.KeyCode.ButtonX }, { Enum.KeyCode.E, Enum.KeyCode.ButtonY } } do self.Input:bindAction(`Spell{SpellId}`, { Keyboard = Binding[1], Gamepad = Binding[2], TouchButton = true, Handler = function(state) if state == Enum.UserInputState.Begin then Cast.fire(SpellId) end end, }) endend
function SpellController.stop(self: any) if self.Input then self.Input:destroy() endend
return SpellControllerThe kit defines SK_Cast (rate limit 8) and registers its validator on the fail-closed Intent.SK_Cast chain at priority 50. It rejects an unknown spell id, a cooldown still running, too little mana, a cast already channelling, and a caster with no living humanoid. Add your own rules (silences, no-cast zones) as hooks with a lower priority number; they run first.
Every cast runs as a killable kernel Process. A channel parks on Process.sleep until ChannelSeconds pass, then re-checks mana and cooldown, spends them, and runs Cast. Real health loss, death, a new character or leaving the game kills the channel and publishes Spell.Interrupted, and because mana and cooldown are spent only on completion, an interrupted cast costs nothing. A Combat.Hit publish naming the caster still interrupts, for games that already send it. Mana regenerates by ManaRegenPerSecond once per second, and only for sessions a completed cast left below MaxMana; code that lowers session.Data.Mana directly does not start regeneration until that player’s next completed cast. A Cast that moves the caster must publish AntiExploit.Forgive first (see Teleport players without tripping the anti-cheat).
Bus: Spell.Cast(session, spellId), Spell.Interrupted(session, spellId). The WizardDuel starter template builds on this recipe with MeleeKit parries and double jumps.