MoveKit
Movement-context abilities such as double jumps, air steps and dashes, executed by the client at once and metered by the server through the fail-closed CKMove intent.
- Realm
- Shared
- Require
ReplicatedStorage.ChloeKernel .MoveKit - Since
- v0.3.0
- Source
- MoveKit.luau
MoveKit binds abilities to movement state as well as keys. It answers “jump pressed while airborne”, which is the double jump, air step and dash family. An ability is a trigger, a context gate, a charge pool and a cooldown, and charges refill when the character lands. InputDriver supplies the triggers, and the movement monitor is the anti-cheat that MoveKit’s Forgive option talks to.
MoveKit lives in Shared/ChloeKernel because it has two halves in one module. The client half (MoveKit.attach) executes: the client owns its character’s physics, so the velocity write happens the frame the key is pressed. The server half (MoveKit.server) meters: every announced use rides the fail-closed CKMove intent and is checked against server-side charge books, cooldowns and the server’s own ground probe. A modified client can send as many requests as it likes. The server still refuses a fourth air step from a pool of three.
Mental model
Section titled “Mental model”Client half: context, gates, charges
Section titled “Client half: context, gates, charges”MoveKit.attach tracks one player’s character: Players.LocalPlayer on a client, or the Player option. Humanoid.StateChanged drives a three-state model:
| Humanoid states | MoveKit State |
Effect |
|---|---|---|
Freefall, Jumping |
"Airborne" |
Stamps AirborneSince on entry. |
Landed, Running, RunningNoPhysics |
"Grounded" |
Refills every ability’s charge pool. |
Everything else: climbing, swimming, seated, platform standing, getting up, falling down, a ragdoll’s Physics, dead |
"Other" |
Every built-in gate fails, and charges keep their count until a real landing. |
Before 0.9.0 these other states kept whatever state came before, so a ladder or a pool could read as airborne and fire air abilities. Now the built-in gates fail on a ladder or in water, and those states do not refill charges.
The kit keeps exactly one StateChanged connection per character. Each respawn disconnects the old one, resets the state to "Grounded" and every book (uses and cooldown), then waits up to 10 seconds each for the new HumanoidRootPart and Humanoid. A generation counter discards a slow lookup from an older character, so a quick double respawn cannot store stale parts. The character that exists when attach runs is watched on its own thread, so attach never blocks.
trigger(name) runs an ability through its gates in order:
- A context exists: the Humanoid and root were found.
- The
Whengate passes:"Grounded","Airborne"(the default),"Rising","Falling"or your ownfunction(context). - When airborne,
AirTime >= MinAirTime(default 0.1 s). This debounce stops the jump that started the flight from also spending the double jump. - Charges remain (
Used < Charges) and the cooldown has passed.
When every gate passes, the book increments, Run(context) executes, Move.Triggered(name, context) publishes on the client Bus, and, when a Net client was given and Announce is not false, the CKMove intent fires so the server can meter the use.
The Context handed to Run and to custom When functions:
| Field | Type | Meaning |
|---|---|---|
Humanoid |
Humanoid |
The tracked Humanoid. |
Root |
BasePart |
HumanoidRootPart, where velocity writes go. |
State |
string |
"Grounded", "Airborne" or "Other". |
AirTime |
number |
Seconds since going airborne, 0 otherwise. |
Rising |
boolean |
Root.AssemblyLinearVelocity.Y > 0.5. |
UseIndex |
number |
Use number within the current flight, so the second air step can play a different sound. |
ChargesLeft |
number |
Charges remaining after this use. |
Triggers come from three sources. Input = "Jump" connects UserInputService.JumpRequest on the client. The engine fires that event several times per press, so a request counts as a new press only after JumpPressGap seconds (default 0.05) without one, tracked per ability. Any other string subscribes to the InputDriver Bus topic Input.<Action> and triggers on Begin. Input = nil means only your code calls kit:trigger(name).
Server half: the meter
Section titled “Server half: the meter”MoveKit.server(kernel, options) defines CKMove ({ "String" }, 20 per second) and keeps one book per player per ability. The kit’s validator runs at the default priority, 100:
- An unknown ability name rejects. An ability the server was never given does not exist, whatever the client claims. No topic publishes.
- A player with no books (no session, or a destroyed meter) rejects, also silently.
- If
IsGrounded(player)sees ground at use time, all of the player’s books refill first. - Spent charges reject and publish
Move.Rejected(player, name, "Charges"). - A running cooldown rejects and publishes
Move.Rejected(player, name, "Cooldown"). The check subtractsCooldownSlack(default 0.05 s) so wire jitter does not reject an honestly paced use.
An accepted use stamps the book and publishes Move.Used(session, name, position), where position is the character’s pivot. Replicate the effect to other players from there. With Forgive = true on the ability, it also publishes AntiExploit.Forgive(player): the movement monitor pardons the velocity spike and Rewind resets the player’s history.
Books also refill without a request. After an accepted use the player joins a set of players holding spent charges, and one Scheduler:every(GroundProbeSeconds) job (default 0.05 s, Priority.Low) probes only that set. A player whose probe finds ground gets a full refill and leaves the set. When the set is empty the job cancels. A server where nobody has spent a charge runs no probes. A respawn gives the player fresh books.
The default IsGrounded is a workspace:Spherecast with a 1-stud radius from the root, 3.5 studs down, so its bottom reaches 4.5 studs below the root. A sphere catches a ledge edge or a thin rail that a single vertical ray from the root would miss. It excludes the character and sets RespectCanCollide = true, so zones and trigger volumes do not count as ground. Each player gets one RaycastParams, rebuilt when their character changes.
Why it feels instant without prediction
Section titled “Why it feels instant without prediction”The client owns its character’s physics, so its velocity writes are legitimate replication. The ability happens the frame the key is pressed with nothing to predict. There is no server-owned state to roll back, so Prediction is the wrong tool here. A use the server refuses is not confirmed: the modified client still moved (it always could), but Move.Used never publishes and Move.Rejected feeds whatever strike policy you run. Keep Prediction for server-owned state.
The client defines what abilities do. The server defines which uses it confirms. From the WizardDuel and Obby starter templates, plus a custom dash:
--!strict-- MovementController: double jump, air steps and an air dash on Shift or ButtonB.
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 MovementController = { Name = "MovementController",}
function MovementController.init(self: any, kernel: any) self.Kernel = kernelend
function MovementController.start(self: any) -- InputDriver publishes "Input.Dash" on the kernel Bus; MoveKit subscribes to it self.Input = InputDriver.attach(self.Kernel) self.Input:bindAction("Dash", { Keyboard = Enum.KeyCode.LeftShift, Gamepad = Enum.KeyCode.ButtonB, TouchButton = true })
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, OnStep = function(_position: Vector3, _useIndex: number) -- Spawn a glow pad at the foothold; useIndex picks the sound end, }) ) self.Moves:define("AirDash", { Input = "Dash", When = "Airborne", Charges = 1, Cooldown = 1.5, Run = function(context) context.Root.AssemblyLinearVelocity = context.Root.CFrame.LookVector * 90 end, })end
function MovementController.stop(self: any) if self.Moves then self.Moves:destroy() end if self.Input then self.Input:destroy() endend
return MovementController--!strict-- MovementService: meters MoveKit abilities and replicates their effects.
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local MoveKit = require(ReplicatedStorage.ChloeKernel.MoveKit)
local MovementService = { Name = "MovementService",}
function MovementService.init(self: any, kernel: any) self.Kernel = kernel -- Unknown abilities reject, spent charges reject, and pools refill only -- when the server's own probe sees the character on the ground self.Meter = MoveKit.server(kernel, { Abilities = { DoubleJump = { Charges = 1, Cooldown = 0.2 }, AirStep = { Charges = 3, Cooldown = 0.15 }, -- A horizontal burst: pardon the anti-cheat sample and reset rewind history AirDash = { Charges = 1, Cooldown = 1.5, Forgive = true }, }, })end
function MovementService.start(self: any) self.UsedConnection = self.Kernel.Bus:subscribe("Move.Used", function(_topic, _session, _name, _position) -- Replicate the effect to everyone else here end) self.RejectedConnection = self.Kernel.Bus:subscribe("Move.Rejected", function(_topic, _player, _name, _reason) -- reason is "Charges" or "Cooldown": feed your strike policy end)end
function MovementService.stop(self: any) if self.UsedConnection then self.UsedConnection:disconnect() end if self.RejectedConnection then self.RejectedConnection:disconnect() end self.Meter.destroy()end
return MovementServiceRecipes
Section titled “Recipes”Two preconfigured abilities cover the common cases. Both use Input = "Jump" and MoveKit.jumpVelocity(humanoid): JumpPower directly, or JumpHeight converted with v = sqrt(2gh), so they scale with the game’s jump settings.
| Recipe | Defaults | Behavior |
|---|---|---|
MoveKit.recipes.doubleJump(options?) |
Power 1, Charges 1, Cooldown 0.2, When "Airborne" |
Sets vertical velocity to jumpVelocity * Power, keeps horizontal velocity, and enters the Jumping state. Charges is air jumps per flight. |
MoveKit.recipes.airStep(options?) |
Boost 0.75, Steps 3 (the charges), Cooldown 0.15, When "Falling" |
Replaces fall velocity with jumpVelocity * Boost. OnStep(position, useIndex) receives the foothold, 3 studs below the root, for effects. |
airStep gates on "Falling" on purpose. Pressing jump while still rising (vertical velocity above 0.5) does nothing, so steps read as footholds on the way down, and the cooldown paces the stairs.
Configuration
Section titled “Configuration”Client: MoveKit.attach(kernel, options?)
Section titled “Client: MoveKit.attach(kernel, options?)”| Option | Type | Default | Description |
|---|---|---|---|
Net |
NetClient? |
nil |
A NetClient. The kit creates the CKMove intent handle on it. Without one the kit never announces and abilities are purely local. |
Player |
Player? |
Players.LocalPlayer on clients |
The tracked player. On the server nothing is tracked unless you pass one. |
JumpPressGap |
number? |
0.05 |
Quiet seconds before a JumpRequest counts as a new press. |
Clock |
(() -> number)? |
os.clock |
Injectable clock. |
Humanoid, Root, SkipCharacterTracking |
injectables | Spec doubles. Normally the kit tracks CharacterAdded itself. |
MoveKit.new is a deprecated alias of attach.
Ability definition: Moves:define(name, ability)
Section titled “Ability definition: Moves:define(name, ability)”| Field | Type | Default | Description |
|---|---|---|---|
Input |
string? |
nil |
"Jump" for JumpRequest, another string for the InputDriver topic Input.<Action>, nil for trigger() only. |
When |
string or (Context) -> boolean |
"Airborne" |
"Grounded", "Airborne", "Rising", "Falling" or a predicate. |
Charges |
number? |
1 |
Uses per flight. Refills on landing. |
Cooldown |
number? |
0 |
Seconds between uses. |
MinAirTime |
number? |
0.1 |
Airborne seconds before triggers count. |
Announce |
boolean? |
true |
Send CKMove on use. false is cosmetic and unmetered. |
Run |
(Context) -> () |
required | The ability body: velocity writes on your own character. |
Server: MoveKit.server(kernel, options)
Section titled “Server: MoveKit.server(kernel, options)”| Option | Type | Default | Description |
|---|---|---|---|
Abilities |
{ [string]: ServerAbility } |
required | The abilities the server confirms. Names must match the client’s define names. |
IsGrounded |
((player) -> boolean)? |
1-stud Spherecast 3.5 studs down, colliding parts only | The server’s ground truth for refills. |
CooldownSlack |
number? |
0.05 |
Subtracted from cooldown checks. |
GroundProbeSeconds |
number? |
0.05 |
Probe cadence while any player holds spent charges. |
Clock |
(() -> number)? |
os.clock |
Injectable clock. |
Each ServerAbility:
| Field | Type | Default | Description |
|---|---|---|---|
Charges |
number? |
1 |
Uses per pool. Refills when the server sees ground. |
Cooldown |
number? |
0 |
Seconds, checked minus CooldownSlack. |
Forgive |
boolean? |
false |
Publish AntiExploit.Forgive on each accepted use. Set it on anything that teleports or bursts velocity. |
| Member | Description |
|---|---|
MoveKit.attach(kernel, options?) -> Moves |
Client half: character tracking, the ability registry and the CKMove announce handle. |
Moves:define(name, ability) |
Registers an ability and wires its trigger source. Errors without Run. |
Moves:trigger(name) -> boolean |
Fires an ability through its gates from your own code. true when it ran. Errors on an unknown name. |
Moves:destroy() |
Disconnects the state connection, the input connections and the Bus subscriptions. A respawn lookup still in flight stores nothing. |
MoveKit.jumpVelocity(humanoid) -> number |
Launch speed for the Humanoid’s jump mode. |
MoveKit.recipes.doubleJump(options?) -> Ability |
Preconfigured mid-air jump. |
MoveKit.recipes.airStep(options?) -> Ability |
Preconfigured falling footholds. |
MoveKit.server(kernel, options) -> Meter |
Server half: the CKMove meter. Meter.Books exposes the per-player books for debugging. |
Meter.destroy() |
Cancels the probe, disconnects the session listener and clears every book. Called with a dot. |
Wire channel
Section titled “Wire channel”| Channel | Schema | Rate limit | Direction |
|---|---|---|---|
CKMove |
{ "String" }: ability name |
20 per second | client to server, fail-closed |
| Point | Mode | Context | Fired |
|---|---|---|---|
Intent.CKMove |
fail-closed | { Name, Session, Player, Args = { name } } |
Every announced use. The kit validator runs at priority 100. Game rules (no dashing while rooted) use a lower number to run first. |
Bus topics
Section titled “Bus topics”| Topic | Side | Payload | When |
|---|---|---|---|
Move.Triggered |
client | name, context |
An ability ran locally. Local effects and sounds hook here. |
Move.Used |
server | session, name, position |
The server accepted a use. |
Move.Rejected |
server | player, name, reason |
The meter refused: "Charges" or "Cooldown". Unknown names reject without a topic. |
AntiExploit.Forgive |
server | player |
Published for Forgive = true abilities. The movement monitor and Rewind consume it. |
Input.<Action> |
client | state, input |
Consumed: non-"Jump" triggers subscribe to these InputDriver topics. |
Design notes
Section titled “Design notes”Announce = falsemeans unmetered. Cosmetic abilities (a flip animation) can skip the wire, but anything that moves the character should announce, for metering and soForgivecan pardon the anti-cheat.- Server cooldowns should match client cooldowns. The client gate is presentation; the server gate is the rule. A looser server is harmless. A tighter one gives honest clients
Move.Rejected("Cooldown"), andCooldownSlackcovers jitter only. - Grounded abilities spend from the same pools. A
When = "Grounded"dash spends charges that refill on the same landings. For ground abilities that recharge on a timer, useCooldownwith a highCharges. - The
Optionstype declares anInputBusfield, but the kit subscribes tokernel.Bus. Pass the kernel whose Bus InputDriver publishes on. Meter.destroy()does not unhook theCKMovevalidator. With the books cleared, the validator rejects every later use, so the channel is closed in effect.