MeleeKit
MeleeKit is the timing game, server-authoritative. Every combatant — player or NPC — runs a per-entity state machine on the server clock: Idle -> Windup -> hit frame -> Recovery, plus Staggered for punished attackers. The client sends button presses; when the hit lands, how much it deals, and whether a parry deflected it are all decided in one server-side timeline loop. Nothing about a swing trusts the client — “attack while staggered” dies in intent validation, never in a handler.
The defining decision: hit frames are positional sweeps, not physical hitboxes. At the end of a windup the server sweeps every registered combatant within the attack’s Range and facing Arc — no Touched events, no client-reported contacts, nothing an exploiter can fabricate.
Mental model
Section titled “Mental model”The state machine
Section titled “The state machine”Each registered combatant carries State, StateUntil, CurrentAttack, a combo Chain counter, a Blocking flag, and parry timestamps. A timeline loop steps every TickSeconds (default 0.05s) on the Scheduler at low priority:
Windupexpires → the hit frame resolves (sweep, parry checks, damage), thenRecoverybegins — unless a parry staggered the attacker mid-resolution.Recoveryexpires →Chainresets, back toIdle.Staggeredexpires → back toIdle.
Every transition publishes Melee.State(entity, state) — that topic is your animation driver. The server owns timing; the client renders it.
attack(entity, name) is legal from Idle, or during Recovery when name appears in the current attack’s ComboNext — the recovery cancel that makes combo strings faster than mashing from neutral. Attacking always clears the attacker’s guard. block(entity, true) raises the guard from Idle only. parry(entity) arms a timed window and is refused during Windup or Staggered.
The triangle
Section titled “The triangle”| Interaction | Outcome |
|---|---|
| Attack vs block | Damage scales by BlockScale (default 0.25) — chip damage; Melee.Blocked publishes with the scaled amount |
| Guard-break attack vs block | Full damage, the guard drops (Blocking = false), Melee.GuardBroken publishes |
| Attack vs parry | Zero damage; the attacker is Staggered for StaggerSeconds (default 1.1s) — a free punish window |
| Guard-break vs parry | Loses harder: stagger scales by GuardBreakStaggerScale (default 1.5×, so 1.65s) |
Parry beats attack, block beats attack, guard-break beats block, parry beats guard-break hardest. Spam cannot cover a swing: a parry attempt consumes ParryCooldown (default 0.6s) whether it connects or not.
Parry timing, exactly
Section titled “Parry timing, exactly”A parry press arms a window of ParryWindow + LatencySlack seconds — defaults 0.18 + 0.075 = 0.255s of effective cover. At an incoming hit frame the server checks Now - ParryAt <= ParryWindow + LatencySlack. The slack exists because the press travelled the wire: without it, a defender with 80ms of latency loses 80ms of their window to physics they cannot control. The slack refunds honest defenders their frames; it does not help spammers, because attempts still consume the cooldown.
A connected parry is consumed (ParryAt resets) — one parry per swing, and the swing ends immediately: a staggered attacker hits no further victims in that sweep.
Hit frame resolution order
Section titled “Hit frame resolution order”Per victim inside Range and Arc, in order:
Melee.CanHitfires (fail-open) —falseskips this victim. TeamKit registers friendly-fire vetoes here.- Armed manual parry window checked first, consumed on use.
- No manual parry and the victim is an NPC handle with a
defendfunction → the kit rollsvictim:defend({ Type = "Melee", Attacker, Action }). Success counts as a parry — NPCKit handles roll it skill-scaled, so a Perfect duelist reads your haymaker and a Bad one eats it. - Parried → attacker staggers,
Melee.ParriedandMelee.Staggeredpublish, the sweep stops. - Otherwise: block scaling or guard-break, then
OnHit(if declared, it replacesHumanoid:TakeDamage), thenMelee.Hit.
Distance and arc are measured flat — the Y axis is zeroed — so height differences neither extend nor deny reach.
From the WizardDuel starter template — staff swings with a guard-break slam:
local Root = game:GetService("ServerScriptService").ChloeKernelServerlocal MeleeKit = require(Root.Kits.MeleeKit)
local Melee = MeleeKit.attach(Kernel, { Moveset = { StaffJab = { Damage = 10, Range = 8, Windup = 0.15, Recovery = 0.25, ComboNext = { "StaffSlam" } }, StaffSlam = { Damage = 22, Windup = 0.35, Recovery = 0.5, GuardBreak = true }, },})
Kernel.Bus:subscribe("Melee.Parried", function(_, victim, attacker, attackName) -- clang VFX; the attacker is Staggered — the punish window is openend)local ChloeKernel = game:GetService("ReplicatedStorage").ChloeKernel
local InputDriver = require(ChloeKernel.InputDriver)local NetClient = require(ChloeKernel.Net.Client)
local Net = NetClient.new()local Attack = Net:intent("ML_Attack", { "String" })local Parry = Net:intent("ML_Parry", {})local Block = Net:intent("ML_Block", { "Boolean8" })
local Input = InputDriver.attach(Kernel)local function onBegin(fire: () -> ()) return function(state: Enum.UserInputState) if state == Enum.UserInputState.Begin then fire() end endendInput:bindAction("Jab", { Keyboard = Enum.KeyCode.Z, Handler = onBegin(function() Attack:Fire("StaffJab")end) })Input:bindAction("ParryAction", { Keyboard = Enum.KeyCode.X, Handler = onBegin(function() Parry:Fire()end) })Input:bindAction("BlockAction", { Keyboard = Enum.KeyCode.C, Handler = function(state: Enum.UserInputState) if state == Enum.UserInputState.Begin then Block:Fire(true) elseif state == Enum.UserInputState.End then Block:Fire(false) end end,})NPCs use the same machine — register the handle and call the same methods players ride:
Melee:register(Npc) -- an NPCKit handle: position/facing resolve from Npc.ModelMelee:attack(Npc, "StaffJab") -- from your behavior tick-- NPC victims parry automatically: the kit rolls Npc:defend() at hit framesConfiguration
Section titled “Configuration”MeleeKit.attach(kernel, options):
| Option | Type | Default | Description |
|---|---|---|---|
Moveset |
{ [string]: Attack } |
required | The named attacks. Combos, damage, and timing all live here |
BlockScale |
number |
0.25 |
Blocked damage multiplier |
ParryWindow |
number |
0.18 |
Seconds a parry press stays armed |
ParryCooldown |
number |
0.6 |
Seconds between attempts — consumed regardless of outcome |
StaggerSeconds |
number |
1.1 |
Attacker lockout when parried |
GuardBreakStaggerScale |
number |
1.5 |
Stagger multiplier for parried GuardBreak attacks |
LatencySlack |
number |
0.075 |
Added to parry windows for wire travel |
TickSeconds |
number |
0.05 |
Timeline loop cadence |
Intents |
boolean |
true |
Wire the ML_* player intents and auto-register sessions |
Clock, GetPosition, GetFacing, SkipLoop |
injectables | — | Deterministic clocks and custom rigs for specs and non-Humanoid entities |
Each Attack:
| Field | Type | Default | Description |
|---|---|---|---|
Damage |
number |
required | Applied at the hit frame (scaled if blocked) |
Range |
number? |
7 |
Studs from the attacker, measured flat |
Arc |
number? |
120 |
Degrees centered on facing; victims outside the cone are missed |
Windup |
number? |
0.2 |
Seconds until the hit frame |
Recovery |
number? |
0.3 |
Seconds after the hit frame before Idle |
ComboNext |
{ string }? |
nil |
Attack names that may cancel this attack’s recovery |
GuardBreak |
boolean? |
false |
Ignores block (drops the guard); staggers 1.5× longer when parried |
OnHit |
(attacker, victim, damage) -> ()? |
nil |
Replaces Humanoid:TakeDamage — custom damage pipelines, knockback |
| Member | Description |
|---|---|
MeleeKit.attach(kernel, options) -> Melee |
Create the kit, define the Melee.CanHit point, start the timeline loop, wire intents |
Melee:register(entity, overrides?) -> combatant |
Add a combatant. entity may be a session, Player, character Model, or NPC handle; overrides = { Humanoid } injects a custom damage sink |
Melee:unregister(entity) |
Remove a combatant |
Melee:attack(entity, name) -> boolean |
Start a swing if legal (Idle, or recovery-cancel along ComboNext). Clears the attacker’s guard |
Melee:block(entity, down) -> boolean |
Raise (true, from Idle only) or lower (false) the guard |
Melee:parry(entity) -> boolean |
Arm the parry window. Refused during Windup/Staggered or inside ParryCooldown |
Melee:canAttack(entity, name) -> boolean |
Pure legality check — the same function the intent validator uses |
Melee:state(entity) -> string? |
"Idle" | "Windup" | "Recovery" | "Staggered", or nil if unregistered |
Melee:destroy() |
Cancel the loop and clear combatants |
Wire channels
Section titled “Wire channels”With Intents on (the default), sessions auto-register on join and unregister on leave, and three fail-closed channels wire up:
| Channel | Schema | Rate limit | Validator (priority 50) |
|---|---|---|---|
ML_Attack |
{ "String" } — attack name |
15/s | canAttack(session, name) — unknown names and illegal states reject |
ML_Block |
{ "Boolean8" } — guard up/down |
20/s | Session must be a registered combatant |
ML_Parry |
{} |
10/s | Session must be a registered combatant |
| Point | Mode | Context | Fired |
|---|---|---|---|
Intent.ML_Attack / Intent.ML_Block / Intent.ML_Parry |
fail-closed | { Session, Player, Args } |
Every player input, before the handler. Prepend game rules at lower priority numbers |
Melee.CanHit |
fail-open | { Attacker, Victim, Attack } |
Per victim at each hit frame, before parry/damage resolution. TeamKit friendly-fire vetoes register here |
Bus topics
Section titled “Bus topics”| Topic | Payload | When |
|---|---|---|
Melee.Hit |
attacker, victim, name, damage |
Damage applied (post block-scaling) |
Melee.Blocked |
victim, attacker, name, damage |
A non-guard-break landed on a raised guard; damage is the chip amount |
Melee.GuardBroken |
victim, attacker, name |
A GuardBreak attack shattered a raised guard |
Melee.Parried |
victim, attacker, name |
A parry (manual or NPC defend()) deflected the swing |
Melee.Staggered |
entity, seconds |
The attacker entered Staggered — punish-window VFX hook |
Melee.Combo |
attacker, chain |
A recovery cancel extended the string; chain counts the hits including the new one |
Melee.State |
entity, state |
Every state transition — drive animations from this |
Design notes
Section titled “Design notes”- The server clock owns everything. Client animations are presentation: play the windup on send, but treat
Melee.StateandMelee.Hitas truth — a rejected intent means no swing happened, whatever the local animation showed. OnHitreplaces the defaultHumanoid:TakeDamagecall. Take over damage entirely (armor tables, knockback impulses) or callTakeDamageyourself inside it.Melee.Blockedpublishes even whenBlockScalechips for meaningful damage — the block succeeded mechanically; the payload’sdamagesays how much got through.- Timings are spec-verified against an injected clock: hit frames land after windup inside range and arc only, combos cancel along the declared chain only, stale parries whiff, and parried guard-breaks stagger 1.5× longer.