MeleeKit
Server-authoritative melee with a windup, hit frame and recovery state machine, declared combos, the block, guard break and parry triangle, and line-of-sight checked hits.
- Realm
- Server
- Require
ServerScriptService.ChloeKernelServer .Kits .MeleeKit - Since
- v0.3.0
- Source
- Kits/MeleeKit.luau
MeleeKit runs close combat on the server clock. Every combatant, player or NPC, has a state machine on the server: Idle, Windup, a hit frame, Recovery, plus Staggered for a punished attacker. The client sends button presses. When the hit lands, how much it deals and whether a parry turned it are decided in one server-side timeline, and an illegal press (“attack while staggered”) dies in intent validation. It is a Kit.attach kit, called from the owning service’s init. WeaponKit covers hitscan guns, and NPCKit handles register here to swing and parry with the same rules as players.
The decision that defines it: hit frames are positional sweeps, not physical hitboxes. When a windup ends, the server tests every registered combatant against the attack’s Range and facing Arc, then casts one line-of-sight ray. There are no Touched events and no client-reported contacts to forge.
Mental model
Section titled “Mental model”The state machine
Section titled “The state machine”Each registered combatant carries State, StateUntil, CurrentAttack, a combo Chain, a Blocking flag, the character that raised the guard, and parry timestamps. A timeline job on the Scheduler steps every TickSeconds (default 0.05 s) at Priority.Low, and only while some combatant is out of Idle:
Windupexpires: the hit frame resolves (sweep, sight, parry, damage), thenRecoverystarts, unless a parry staggered the attacker during resolution.Recoveryexpires:Chainresets and the combatant returns toIdle.Staggeredexpires: back toIdle.
The first transition out of Idle starts the job. When a step finds every combatant in Idle, it cancels itself, so a server where nobody is swinging runs no melee work. Guards and parries need no job of their own: they are checked at the attacker’s hit frame, which keeps the job awake.
Every transition publishes Melee.State(entity, state). Drive animations from that topic. The server owns the timing and the client renders it.
attack(entity, name) is legal from Idle, or during Recovery when name is in the current attack’s ComboNext. That recovery cancel is what makes a combo string faster than attacking from neutral. Attacking lowers 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.
Each step also cancels a combatant who died, or whose character changed since the swing started: the attack, the guard, the armed parry and the chain are cleared, and the combatant returns to Idle. A player who respawns mid-windup does not land a swing from the new body.
The triangle
Section titled “The triangle”| Interaction | Outcome |
|---|---|
| Attack against a block | Damage scales by BlockScale (default 0.25). Melee.Blocked publishes with the scaled amount. |
| Guard-break attack against a block | Full damage, the guard drops, and Melee.GuardBroken publishes. |
| Attack against a parry | No damage. The attacker is Staggered for StaggerSeconds (default 1.1 s). |
| Guard-break attack against a parry | The stagger scales by GuardBreakStaggerScale (default 1.5, so 1.65 s). |
Parry beats attack, block beats attack, guard break beats block, and parry punishes guard break hardest. Spamming parry does not cover a swing: every attempt spends ParryCooldown (default 0.6 s) whether it connects or not.
A guard belongs to the character that raised it. block records that character, and the hit frame applies block scaling only when the victim’s current character is the one that blocked. A player who raised a guard, died and respawned is not still blocking with the new body.
Parry timing
Section titled “Parry timing”A parry press arms a window of ParryWindow + LatencySlack seconds. With the defaults that is 0.18 + 0.075 = 0.255 s. At an incoming hit frame the server checks Now - ParryAt <= ParryWindow + LatencySlack. The slack exists because the press crossed the wire: without it, a defender with 80 ms of latency loses 80 ms of window to travel time. It refunds honest defenders and does not help spammers, since attempts still spend the cooldown.
A connected parry is consumed (ParryAt resets), so one parry answers one swing. The swing ends at once: a staggered attacker hits no further victims in that sweep.
Hit frame resolution order
Section titled “Hit frame resolution order”For each other living combatant, in order:
- The full 3D distance from the attacker’s root to the victim’s root must be within
Range. Height counts, so an attacker cannot hit someone standing on a ledge far above. - The victim must sit inside the facing
Arc. The arc is measured on the horizontal plane (the Y axis is zeroed), so looking up or down does not narrow it. - Unless the attack sets
RequireLineOfSight = false, one ray runs from 1 stud above the attacker’s root (chest height) to the victim’s root. Both characters are excluded and the ray respectsCanCollide, so colliding world geometry between them blocks the hit and non-colliding volumes do not. - The fail-open
Melee.CanHithook fires withAttacker,VictimandAttack.falseskips this victim. TeamKit registers its friendly-fire veto here. - The hook may yield, so the kit checks again that the attacker is alive on the character that started the swing and the victim is alive on the same character. A dead or respawned attacker cancels the swing. A changed victim is skipped.
- An armed manual parry window is checked and consumed on use. With no armed window, a victim table with a
defendfunction (an NPCKit handle) rollsvictim:defend({ Type = "Melee", Attacker, Action }), andtruecounts as a parry. - On a parry, the attacker staggers,
Melee.ParriedandMelee.Staggeredpublish, and the sweep stops. - Otherwise the kit applies block scaling or the guard break, checks both characters once more, then calls
OnHitif the attack declares one (it replacesHumanoid:TakeDamage), and publishesMelee.Hit.
The WizardDuel starter template uses staff swings with a guard-break slam:
--!strict-- DuelService: staff melee for players, with a clang effect on every parry.
local ServerScriptService = game:GetService("ServerScriptService")
local MeleeKit = require(ServerScriptService.ChloeKernelServer.Kits.MeleeKit)
local DuelService = { Name = "DuelService",}
function DuelService.init(self: any, kernel: any) self.Kernel = kernel self.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 }, -- A shockwave that passes through walls Quake = { Damage = 15, Range = 14, Arc = 360, Windup = 0.6, RequireLineOfSight = false }, }, })end
function DuelService.start(self: any) self.ParryConnection = self.Kernel.Bus:subscribe("Melee.Parried", function(_topic, _victim, _attacker, _attackName) -- Play the clang here. The attacker is Staggered: the punish window is open end)end
function DuelService.stop(self: any) if self.ParryConnection then self.ParryConnection:disconnect() end self.Melee:destroy()end
return DuelService--!strict-- DuelController: jab, parry and hold-to-block on keyboard, gamepad and touch.
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local InputDriver = require(ReplicatedStorage.ChloeKernel.InputDriver)local NetClient = require(ReplicatedStorage.ChloeKernel.Net.Client)
local DuelController = { Name = "DuelController",}
function DuelController.init(self: any, kernel: any) self.Kernel = kernelend
function DuelController.start(self: any) local Net = NetClient.new() local Attack = Net:intent("ML_Attack", { "String" }) local Parry = Net:intent("ML_Parry", {}) local Block = Net:intent("ML_Block", { "Boolean8" })
self.Input = InputDriver.attach(self.Kernel) self.Input:bindAction("Jab", { Keyboard = Enum.KeyCode.Z, Gamepad = Enum.KeyCode.ButtonR1, TouchButton = true, Handler = function(state: Enum.UserInputState) if state == Enum.UserInputState.Begin then Attack.fire("StaffJab") end end, }) self.Input:bindAction("Parry", { Keyboard = Enum.KeyCode.X, Gamepad = Enum.KeyCode.ButtonL1, TouchButton = true, Handler = function(state: Enum.UserInputState) if state == Enum.UserInputState.Begin then Parry.fire() end end, }) self.Input:bindAction("Block", { Keyboard = Enum.KeyCode.C, Gamepad = Enum.KeyCode.ButtonL2, TouchButton = true, Handler = function(state: Enum.UserInputState) if state == Enum.UserInputState.Begin then Block.fire(true) elseif state == Enum.UserInputState.End or state == Enum.UserInputState.Cancel then -- Cancel arrives instead of End when another binding takes the input Block.fire(false) end end, })end
function DuelController.stop(self: any) if self.Input then self.Input:destroy() self.Input = nil endend
return DuelControllerNPCs use the same machine. Register the handle and call the methods players reach through intents:
Melee:register(Npc) -- an NPCKit handle: position and facing resolve from Npc.ModelMelee:attack(Npc, "StaffJab") -- from your NPC tick-- NPC victims parry on their own: 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 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 parry attempts, spent whether or not the parry connects. |
StaggerSeconds |
number? |
1.1 |
Attacker lockout when parried. |
GuardBreakStaggerScale |
number? |
1.5 |
Stagger multiplier for a parried GuardBreak attack. |
LatencySlack |
number? |
0.075 |
Added to the parry window for wire travel. |
TickSeconds |
number? |
0.05 |
Timeline cadence while anyone is mid-action. |
Intents |
boolean? |
true |
Wire the ML_* intents and register every session automatically. |
Clock, GetPosition, GetFacing, Raycast, SkipLoop |
injectables | A deterministic clock, custom rigs, a sight-ray double, and manual stepping for specs. |
Each Attack:
| Field | Type | Default | Description |
|---|---|---|---|
Damage |
number |
required | Applied at the hit frame, scaled if blocked. |
Range |
number? |
7 |
Studs from the attacker’s root, measured in 3D. |
Arc |
number? |
120 |
Degrees centered on facing, measured on the horizontal plane. |
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 and drops a raised guard. Staggers longer when parried. |
RequireLineOfSight |
boolean? |
true |
Colliding world geometry between attacker and victim blocks the hit. false lets the swing reach through walls. |
OnHit |
(attacker, victim, damage) -> ()? |
nil |
Replaces Humanoid:TakeDamage: armor tables, knockback. |
| Member | Description |
|---|---|
MeleeKit.attach(kernel, options) -> Melee |
Creates the kit, defines the Melee.CanHit point and wires the intents. Starts no job until someone acts. |
Melee:register(entity, overrides?) -> combatant |
Adds a combatant. entity is a session, Player, character Model or NPC handle. overrides = { Humanoid } injects a custom damage sink. |
Melee:unregister(entity) |
Removes a combatant. |
Melee:attack(entity, name) -> boolean |
Starts a swing if legal (Idle, or a recovery cancel along ComboNext). Lowers the attacker’s guard. |
Melee:block(entity, down) -> boolean |
Raises (true, from Idle only) or lowers (false) the guard. |
Melee:parry(entity) -> boolean |
Arms the parry window. Refused during Windup or Staggered and inside ParryCooldown. |
Melee:canAttack(entity, name) -> boolean |
The legality check the ML_Attack validator uses. No side effects. |
Melee:state(entity) -> string? |
"Idle", "Windup", "Recovery" or "Staggered", or nil when unregistered. |
Melee:destroy() |
Unhooks the three intent validators, makes the handlers inert, cancels the timeline job, disconnects the session listener and clears every combatant. |
Wire channels
Section titled “Wire channels”With Intents on (the default), sessions register on join and unregister when the session ends, and three fail-closed channels are defined:
| Channel | Schema | Rate limit | Kit validator (priority 50) |
|---|---|---|---|
ML_Attack |
{ "String" }: attack name |
15 per second | canAttack(session, name): unknown names, dead combatants and illegal states reject. |
ML_Block |
{ "Boolean8" }: guard up or down |
20 per second | The session is a registered combatant. |
ML_Parry |
{} |
10 per second | The session is a registered combatant. |
| Point | Mode | Context | Fired |
|---|---|---|---|
Intent.ML_Attack, Intent.ML_Block, Intent.ML_Parry |
fail-closed | { Name, Session, Player, Args } |
Every player input, before the handler. Game rules go at lower priority numbers. |
Melee.CanHit |
fail-open | { Attacker, Victim, Attack } (Attack is the attack name) |
Per victim at each hit frame, after reach, arc and sight, before parry and damage. |
Bus topics
Section titled “Bus topics”| Topic | Payload | When |
|---|---|---|
Melee.Hit |
attacker, victim, name, damage |
Damage applied, after block scaling. |
Melee.Blocked |
victim, attacker, name, damage |
A non-guard-break attack landed on a raised guard. damage is the chip amount. |
Melee.GuardBroken |
victim, attacker, name |
A GuardBreak attack dropped a raised guard. |
Melee.Parried |
victim, attacker, name |
A manual parry or an NPC defend() turned the swing. |
Melee.Staggered |
entity, seconds |
The attacker entered Staggered. |
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, including a cancel back to Idle. |
Design notes
Section titled “Design notes”- Attacking lowers the guard. A client UI that shows a shield icon should follow
Melee.Stateand its own attack sends. - Client animations are presentation. Play the windup on send if you like, but treat
Melee.StateandMelee.Hitas the truth: a rejected intent means no swing happened. OnHitreplaces the defaultHumanoid:TakeDamage. Take over damage entirely, or callTakeDamageinside it.Melee.Blockedpublishes whenever a guard absorbs a swing, including whenBlockScalelets real damage through. The payload’sdamageis what got through.