Skip to content

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.

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:

  1. A context exists: the Humanoid and root were found.
  2. The When gate passes: "Grounded", "Airborne" (the default), "Rising", "Falling" or your own function(context).
  3. When airborne, AirTime >= MinAirTime (default 0.1 s). This debounce stops the jump that started the flight from also spending the double jump.
  4. 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).

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:

  1. An unknown ability name rejects. An ability the server was never given does not exist, whatever the client claims. No topic publishes.
  2. A player with no books (no session, or a destroyed meter) rejects, also silently.
  3. If IsGrounded(player) sees ground at use time, all of the player’s books refill first.
  4. Spent charges reject and publish Move.Rejected(player, name, "Charges").
  5. A running cooldown rejects and publishes Move.Rejected(player, name, "Cooldown"). The check subtracts CooldownSlack (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.

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:

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

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.

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.
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.
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.
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.
  • Announce = false means unmetered. Cosmetic abilities (a flip animation) can skip the wire, but anything that moves the character should announce, for metering and so Forgive can 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"), and CooldownSlack covers 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, use Cooldown with a high Charges.
  • The Options type declares an InputBus field, but the kit subscribes to kernel.Bus. Pass the kernel whose Bus InputDriver publishes on.
  • Meter.destroy() does not unhook the CKMove validator. With the books cleared, the validator rejects every later use, so the channel is closed in effect.

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