Skip to content

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.

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:

  • Windup expires → the hit frame resolves (sweep, parry checks, damage), then Recovery begins — unless a parry staggered the attacker mid-resolution.
  • Recovery expiresChain resets, back to Idle.
  • Staggered expires → back to Idle.

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.

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.

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.

Per victim inside Range and Arc, in order:

  1. Melee.CanHit fires (fail-open) — false skips this victim. TeamKit registers friendly-fire vetoes here.
  2. Armed manual parry window checked first, consumed on use.
  3. No manual parry and the victim is an NPC handle with a defend function → the kit rolls victim: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.
  4. Parried → attacker staggers, Melee.Parried and Melee.Staggered publish, the sweep stops.
  5. Otherwise: block scaling or guard-break, then OnHit (if declared, it replaces Humanoid:TakeDamage), then Melee.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").ChloeKernelServer
local 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 open
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.Model
Melee:attack(Npc, "StaffJab") -- from your behavior tick
-- NPC victims parry automatically: the kit rolls Npc:defend() at hit frames

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

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
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
  • The server clock owns everything. Client animations are presentation: play the windup on send, but treat Melee.State and Melee.Hit as truth — a rejected intent means no swing happened, whatever the local animation showed.
  • OnHit replaces the default Humanoid:TakeDamage call. Take over damage entirely (armor tables, knockback impulses) or call TakeDamage yourself inside it.
  • Melee.Blocked publishes even when BlockScale chips for meaningful damage — the block succeeded mechanically; the payload’s damage says 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.