Skip to content

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.

The client applies the dash at once, the server validates it, and a rejection rolls it back:

src/Shared/Game/Channels.luau
-- No arguments to validate: Open acknowledges that, and the Intent.Dash hook still gates it
Dash = { Kind = "PredictedIntent", Schema = {}, RateLimit = 4, Open = true },
src/Server/Game/Services/DashService.luau
--!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
src/Client/Game/Controllers/DashController.luau
--!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 = kernel
end
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()
end
end
return DashController

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

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:

src/Client/Game/Controllers/MovesController.luau
--!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 = kernel
end
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()
end
end
return MovesController
src/Server/Game/Services/MovesService.luau
--!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 MovesService

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

src/Client/Game/Controllers/ControlsController.luau
--!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 these
function 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 layouts
end
function ControlsController.stop(self: any)
self.Scope:destroy()
end
return ControlsController

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

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:

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

src/Client/Game/Controllers/SpellController.luau
--!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 = kernel
end
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,
})
end
end
function SpellController.stop(self: any)
if self.Input then
self.Input:destroy()
end
end
return SpellController

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

Go deeper: SpellKit, Effects, Hooks.

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