Skip to content

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.

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:

  1. A context must exist (humanoid and root present).
  2. The When gate passes — "Grounded", "Airborne" (the default), "Rising", "Falling", or your own function(context).
  3. 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.
  4. 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).

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:

  1. Unknown ability name → reject. Fail closed: an ability the server was never told about does not exist, whatever the client claims.
  2. If the server’s IsGrounded probe (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.
  3. Spent charges → reject, publish Move.Rejected(player, name, "Charges").
  4. Hot cooldown → reject, publish Move.Rejected(player, name, "Cooldown"). The check subtracts CooldownSlack (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.

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 subscribes
local Input = InputDriver.attach(Kernel)
Input:bindAction("Dash", { Keyboard = Enum.KeyCode.LeftShift })
-- Recipes: the common anime kit, preconfigured
Moves: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 action
Moves: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,
})

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.

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
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
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
  • Announce = false means unmetered. Purely cosmetic abilities (a flip animation) can skip the wire — but anything that moves the character should announce, both for metering and so Forgive can 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") — the CooldownSlack only 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, use Cooldown and a high Charges count.
  • The Options type declares an InputBus field, but the implementation subscribes to kernel.Bus — pass the kernel whose bus InputDriver publishes on rather than relying on that field.