MoveKit
MoveKit binds abilities to movement, not just keys. It answers “jump pressed WHILE AIRBORNE” instead of “jump pressed” — the double-jump/air-step/dash family, where an ability is a trigger plus a context gate plus a charge pool plus a cooldown, and charges refill when the humanoid lands.
Unlike every other kit, MoveKit lives in Shared/ChloeKernel because it has two halves. The client half executes: it owns its character’s physics, so the velocity write happens instantly with zero round-trip. The server half meters: every announced use rides the fail-closed CKMove intent against server-side charge books, cooldowns, and the server’s own grounded probe. A hacked client can spam requests; it cannot make the server bless a fourth air-step.
Mental model
Section titled “Mental model”Client half: context, gates, charges
Section titled “Client half: context, gates, charges”MoveKit.attach tracks the local character. Humanoid.StateChanged drives a two-state model: Freefall and Jumping mean Airborne (stamping AirborneSince); Landed and Running mean Grounded — and landing refills every ability’s charge pool.
trigger(name) runs an ability through its gates, in order:
- A context must exist (humanoid and root present).
- The
Whengate passes —"Grounded","Airborne"(the default),"Rising","Falling", or your ownfunction(context). - If airborne,
AirTime >= MinAirTime(default 0.1s) — the takeoff-press debounce, so the jump that started the flight doesn’t also consume the double jump. - Charges remain (
Used < Charges) and the cooldown has elapsed.
All gates pass: the book increments, Run(context) executes, Move.Triggered(name, context) publishes on the client bus, and — when a Net client was attached and Announce isn’t false — the CKMove intent fires so the server can meter and replicate.
The Context handed to Run:
| Field | Type | Meaning |
|---|---|---|
Humanoid |
Humanoid |
The local humanoid |
Root |
BasePart |
HumanoidRootPart — where velocity writes go |
State |
string |
"Grounded" or "Airborne" |
AirTime |
number |
Seconds since going airborne (0 when grounded) |
Rising |
boolean |
AssemblyLinearVelocity.Y > 0.5 |
UseIndex |
number |
Use number within the current flight — how the second air step gets a different sound than the first |
ChargesLeft |
number |
Charges remaining after this use |
Triggers come from three sources: Input = "Jump" connects UserInputService.JumpRequest directly; any other string subscribes to the InputDriver bus topic Input.<Action> (firing on Begin); Input = nil means only your own code calls kit:trigger(name).
Server half: the meter
Section titled “Server half: the meter”MoveKit.server(kernel, options) defines CKMove ({ "String" }, rate limit 20/s) and keeps a per-player book per ability. The fail-closed validator, per use:
- Unknown ability name → reject. Fail closed: an ability the server was never told about does not exist, whatever the client claims.
- If the server’s
IsGroundedprobe (default: a 4.5-stud downward raycast from the root, excluding the character) sees ground at use time, all of the player’s books refill first. - Spent charges → reject, publish
Move.Rejected(player, name, "Charges"). - Hot cooldown → reject, publish
Move.Rejected(player, name, "Cooldown"). The check subtractsCooldownSlack(default 0.05s) so wire jitter never rejects an honestly-paced use.
An accepted use stamps the book, publishes Move.Used(session, name, position) — your replication hook for showing the VFX to everyone else — and, when the ability sets Forgive = true, publishes AntiExploit.Forgive(player) so the movement monitor pardons the velocity spike and Rewind resets the player’s history buffer.
Why it feels instant without prediction
Section titled “Why it feels instant without prediction”The ownership guarantee does the work. The client owns its character’s physics, so its velocity writes are legitimate replication — the ability happens the frame the key is pressed, no round-trip, nothing to predict. This is not Prediction-style optimistic execution with rollback: there is no server-owned state to roll back. A use the server refuses to bless simply is not blessed — the exploiting client still moved (it always could), but the server never confirms it, Move.Used never publishes, and Move.Rejected feeds whatever strike policy you run. Reserve Prediction for server-owned state; movement needs only the meter.
The client defines what abilities do; the server defines what it will bless. From the WizardDuel and Obby starter templates, plus a custom dash:
local ChloeKernel = game:GetService("ReplicatedStorage").ChloeKernel
local InputDriver = require(ChloeKernel.InputDriver)local MoveKit = require(ChloeKernel.MoveKit)local NetClient = require(ChloeKernel.Net.Client)
local Net = NetClient.new()local Moves = MoveKit.attach(Kernel, { Net = Net })
-- InputDriver publishes "Input.Dash" on the kernel bus; MoveKit subscribeslocal Input = InputDriver.attach(Kernel)Input:bindAction("Dash", { Keyboard = Enum.KeyCode.LeftShift })
-- Recipes: the common anime kit, preconfiguredMoves: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 picks the sound end,}))
-- Custom ability: an air dash on an InputDriver actionMoves:define("AirDash", { Input = "Dash", -- 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,})local MoveKit = require(game:GetService("ReplicatedStorage").ChloeKernel.MoveKit)
-- The meter. Unknown abilities reject, spent charges reject, and pools-- refill only when the SERVER's own ground probe sees feet touch down.MoveKit.server(Kernel, { Abilities = { DoubleJump = { Charges = 1, Cooldown = 0.2 }, AirStep = { Charges = 3, Cooldown = 0.15 }, -- Horizontal burst: pardon the anti-cheat sample and reset rewind history AirDash = { Charges = 1, Cooldown = 1.5, Forgive = true }, },})
Kernel.Bus:subscribe("Move.Used", function(_, session, name, position) -- replicate the VFX to everyone else hereend)Kernel.Bus:subscribe("Move.Rejected", function(_, player, name, reason) -- reason is "Charges" or "Cooldown" — feed your strike policyend)Recipes
Section titled “Recipes”Two preconfigured abilities cover the common cases. Both ride Input = "Jump" and use MoveKit.jumpVelocity(humanoid) — JumpPower directly, or JumpHeight converted via v = sqrt(2gh) — so they scale with whatever jump settings the game uses.
| Recipe | Defaults | Behavior |
|---|---|---|
MoveKit.recipes.doubleJump(options?) |
Power 1, Charges 1, Cooldown 0.2, When "Airborne" |
Sets vertical velocity to jumpVelocity * Power (horizontal kept) and enters the Jumping state. Charges = air jumps per flight |
MoveKit.recipes.airStep(options?) |
Boost 0.75, Steps 3 (the Charges), Cooldown 0.15, When "Falling" |
Cancels fall velocity with jumpVelocity * Boost. OnStep(position, useIndex) receives the foothold position (3 studs below the root) for VFX |
airStep gates on "Falling" deliberately: pressing jump while still rising does nothing (Rising means vertical velocity above 0.5), so steps read as footholds on the way down, not extra jumps — 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 registers the CKMove intent on it. Without one the kit never announces (purely local abilities) |
Clock |
(() -> number)? |
os.clock |
Injectable clock for deterministic specs |
Humanoid, Root, SkipCharacterTracking |
injectables | — | Spec doubles; normally the kit tracks LocalPlayer.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" = JumpRequest; other strings = InputDriver topic Input.<Action>; nil = trigger() only |
When |
string | (Context) -> boolean |
"Airborne" |
Context gate: "Grounded" / "Airborne" / "Rising" / "Falling" / 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 — the takeoff-press debounce |
Announce |
boolean? |
true |
Send the CKMove intent on use. false = purely 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 will bless — names must match the client’s define names |
IsGrounded |
((player) -> boolean)? |
4.5-stud down raycast | The server’s own ground truth for refills |
CooldownSlack |
number? |
0.05 |
Subtracted from cooldown checks so wire jitter never rejects honest pacing |
Clock |
(() -> number)? |
os.clock |
Injectable clock |
Each ServerAbility:
| Field | Type | Default | Description |
|---|---|---|---|
Charges |
number? |
1 |
Uses per pool; refills when the probe sees ground at use time |
Cooldown |
number? |
0 |
Seconds, checked minus CooldownSlack |
Forgive |
boolean? |
false |
Publish AntiExploit.Forgive on each valid use — set it on any ability that teleports or bursts velocity |
| Member | Description |
|---|---|
MoveKit.attach(kernel, options?) -> Moves |
Client half: character tracking, ability registry, the CKMove announce intent |
Moves:define(name, ability) |
Register an ability and wire its trigger source |
Moves:trigger(name) -> boolean |
Fire an ability through its gates from your own code; true when it ran |
Moves:destroy() |
Disconnect input and bus connections |
MoveKit.jumpVelocity(humanoid) -> number |
Launch speed for the humanoid’s jump mode (JumpPower, or sqrt(2 * gravity * JumpHeight)) |
MoveKit.recipes.doubleJump(options?) -> Ability |
Preconfigured mid-air jump |
MoveKit.recipes.airStep(options?) -> Ability |
Preconfigured falling foothold steps |
MoveKit.server(kernel, options) -> Service |
Server half: the CKMove meter. Service.Books exposes the per-player books for debugging |
Wire channel
Section titled “Wire channel”| Channel | Schema | Rate limit | Direction |
|---|---|---|---|
CKMove |
{ "String" } — ability name |
20/s | client → server, fail-closed |
| Point | Mode | Context | Fired |
|---|---|---|---|
Intent.CKMove |
fail-closed | { Session, Player, Args = { name } } |
Every announced use. The kit’s validator handles identity/charges/cooldown; prepend game rules (no dashing while rooted) at lower priority numbers |
Bus topics
Section titled “Bus topics”| Topic | Side | Payload | When |
|---|---|---|---|
Move.Triggered |
client | name, context |
An ability ran locally — hook local VFX and sounds here (context.UseIndex differentiates repeats) |
Move.Used |
server | session, name, position |
The server blessed a use — replicate VFX to other players from here |
Move.Rejected |
server | player, name, reason |
Metering refused: reason is "Charges" or "Cooldown". Unknown ability names reject silently (fail closed, no topic) |
AntiExploit.Forgive |
server | player |
Published for Forgive = true abilities — consumed by the movement monitor and Rewind |
Input.<Action> |
client | state |
Consumed: non-"Jump" trigger sources subscribe to these InputDriver topics |
Design notes
Section titled “Design notes”Announce = falsemeans unmetered. Purely cosmetic abilities (a flip animation) can skip the wire — but anything that moves the character should announce, both for metering and soForgivecan pardon the anti-cheat.- Server cooldowns should mirror client cooldowns. The client gate is UX (the ability just doesn’t fire); the server gate is law. Setting the server looser than the client is harmless; tighter means honest clients rack up
Move.Rejected("Cooldown")— theCooldownSlackonly covers jitter, not disagreement. - Grounded abilities refill on landing too. The books are shared: a
When = "Grounded"dash spends from the same refill cycle. For ground abilities that should recharge on a timer instead, useCooldownand a highChargescount. - The
Optionstype declares anInputBusfield, but the implementation subscribes tokernel.Bus— pass the kernel whose bus InputDriver publishes on rather than relying on that field.