Skip to content

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.

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:

  • Windup expires: the hit frame resolves (sweep, sight, parry, damage), then Recovery starts, unless a parry staggered the attacker during resolution.
  • Recovery expires: Chain resets and the combatant returns to Idle.
  • Staggered expires: back to Idle.

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.

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.

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.

For each other living combatant, in order:

  1. 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.
  2. 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.
  3. 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 respects CanCollide, so colliding world geometry between them blocks the hit and non-colliding volumes do not.
  4. The fail-open Melee.CanHit hook fires with Attacker, Victim and Attack. false skips this victim. TeamKit registers its friendly-fire veto here.
  5. 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.
  6. An armed manual parry window is checked and consumed on use. With no armed window, a victim table with a defend function (an NPCKit handle) rolls victim:defend({ Type = "Melee", Attacker, Action }), and true counts as a parry.
  7. On a parry, the attacker staggers, Melee.Parried and Melee.Staggered publish, and the sweep stops.
  8. Otherwise the kit applies block scaling or the guard break, checks both characters once more, then calls OnHit if the attack declares one (it replaces Humanoid:TakeDamage), and publishes Melee.Hit.

The WizardDuel starter template uses staff swings with a guard-break slam:

src/Server/Game/Services/DuelService.luau
--!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

NPCs 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.Model
Melee:attack(Npc, "StaffJab") -- from your NPC tick
-- NPC victims parry on their own: 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 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.

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.
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.
  • Attacking lowers the guard. A client UI that shows a shield icon should follow Melee.State and its own attack sends.
  • Client animations are presentation. Play the windup on send if you like, but treat Melee.State and Melee.Hit as the truth: a rejected intent means no swing happened.
  • OnHit replaces the default Humanoid:TakeDamage. Take over damage entirely, or call TakeDamage inside it.
  • Melee.Blocked publishes whenever a guard absorbs a swing, including when BlockScale lets real damage through. The payload’s damage is what got through.

ChloeKernel v0.9.1. Access to the framework is private and granted individually by Chloe. Games built with it credit CryptedChloes in their description.