Sweeps
Sweeps solves phantom hitboxes for big rigs. A dragon’s tail swipe, a giant’s club swing — these attack volumes move too far per frame for a single-position overlap check to reliably catch a target, and building them out of real physics Parts is expensive to simulate and laggy to replicate (a monster’s parts trail its true server-side pose during fast animation, exactly when the hit matters most). Sweeps never touches a physics Part. An attack’s hit volume is authored as root-local capsule tracks — pure functions of swing phase — and rebuilt in world space from the rig’s live root CFrame every tick, then tested with closest-approach segment math against each candidate victim. No Touched events, no engine overlap queries, no dependency on any Instance existing in the world at all.
The defining decision, same family as MeleeKit’s positional sweeps: the hitbox is math, not geometry. MeleeKit answers this for humanoid-scale, arc-and-range combat on a windup/recovery state machine; Sweeps answers it for attacks whose reach and speed break a single-sample test — a tail that covers 180 degrees in one tick, a club whose tip travels faster than a bullet’s worth of studs per frame at monster scale.
Mental model
Section titled “Mental model”Where Sweeps fits next to Rewind, Projectiles, and MeleeKit
Section titled “Where Sweeps fits next to Rewind, Projectiles, and MeleeKit”All four hit-validation systems refuse to trust a client-reported hit, but they answer different questions:
| System | Answers | Victim position | Attacker volume |
|---|---|---|---|
| Rewind | Did this player’s claimed ranged shot land, given latency? | Rewound to the claimed timestamp | A ray |
| Projectiles | Did a server-simulated travel-time shot’s path cross a player? | Current (or Rewind-validated spawn origin) |
A swept ray segment per tick |
| MeleeKit | Did a state-machine-timed melee swing land, and who wins the block/parry triangle? | Current | A range-and-arc cone at the hit frame |
| Sweeps | Did a big rig’s authored attack volume, moving arbitrarily fast through arbitrary shapes, pass through a player? | Current | Root-local capsule track(s), sub-stepped |
Sweeps is the answer when the attacker is large or fast enough that a cone-and-range check (MeleeKit) or a single ray (Rewind, Projectiles) can’t describe the volume, and the attack is server-initiated (a monster deciding to swing), not a client’s aim claim — so there is no timestamp to rewind against.
Phase, not world time
Section titled “Phase, not world time”Every Definition declares a Duration in seconds. A swing’s phase is age / Duration, clamped to 0..1 — a normalized progress through the swing, not a raw timestamp. age is Now - StartedAt, where Now comes from the attached Clock (os.clock by default, injectable for specs). A Capsules track is a pure function (phase: number) -> Capsule — it never sees wall-clock time, only where in the swing it’s being asked about. That’s what makes a track reusable and testable in isolation: feed it 0.3, get back the capsule 30% of the way through, regardless of how fast or slow the swing actually plays out in a given tick’s Duration.
Root-local capsules, rebuilt fresh every tick
Section titled “Root-local capsules, rebuilt fresh every tick”A Capsule is:
export type Capsule = { From: Vector3, -- root-local To: Vector3, -- root-local Radius: number,}From and To are offsets in the attacking rig’s own local space — “3 studs forward and 2 up from the root,” never a world position. Each tick, Sweeps.step resolves the swing’s live root pose (rootCFrame, below) and converts the authored local points to world space fresh:
local From = Root:PointToWorldSpace(Shape.From)local To = Root:PointToWorldSpace(Shape.To)Nothing is cached between ticks except the phase progress itself. If the giant’s root has moved or turned since the last tick — chasing a fleeing target mid-swing, staggered by knockback — the capsule follows without any extra bookkeeping, because it is reconstructed from wherever the root actually is right now, not from a snapshot taken when the swing started.
Root accepts three shapes, resolved by rootCFrame:
Root value |
Resolves to |
|---|---|
BasePart |
.CFrame |
Model |
:GetPivot() |
() -> CFrame |
called fresh each tick |
duck-typed table with a .CFrame field |
.CFrame (this is how the spec drives it without a live Instance) |
If rootCFrame returns nil — the part was destroyed, the model has no valid pivot — the swing is dropped from the active set on that tick with no error. A giant that dies or despawns mid-swing just has its attack fizzle.
Tested mathematically, not with a physics query
Section titled “Tested mathematically, not with a physics query”There is no workspace:GetPartsInPart, no OverlapParams, no raycast anywhere in Sweeps. The exact test is closest-point-of-approach between two 3D line segments:
Sweeps.segmentDistanceSquared(a1, a2, b1, b2) returns the squared distance between the closest points on segment a1-a2 and segment b1-b2, clamping both parametric points to 0..1 along their segments (the classic capsule-vs-capsule primitive: two capsules overlap exactly when the distance between their core segments is at most the sum of their radii). Sweeps.capsuleHitsVictim builds the victim’s capsule as a vertical segment from victimPosition - (0, halfHeight, 0) to victimPosition + (0, halfHeight, 0) and checks:
Sweeps.segmentDistanceSquared(from, to, Bottom, Top) <= (radius + victimRadius) ^ 2Both functions are pure — no Instance access, no globals — which is why the spec exercises the segment and capsule math directly with hand-picked Vector3 triples instead of building a scene.
Sub-stepping between phases: the anti-tunneling mechanic
Section titled “Sub-stepping between phases: the anti-tunneling mechanic”A swing that only evaluated its capsule track at the current tick’s phase could have its hitbox skip past a target between two ticks — a tail that sweeps 180 degrees across only two or three ticks can have a target sitting exactly where the arc passed between those samples, and a naive “test only where the phase is right now” check would never see it.
Sweeps.step closes that gap by sub-stepping the phase interval covered since the previous tick, not just testing the current instant:
local SubPhase = Swing.LastPhase + (Phase - Swing.LastPhase) * (Substep / Swing.Substeps)For each candidate victim, it walks Substep from a starting value up to Swing.Substeps (default 3), evaluating every Capsules track at each interpolated SubPhase and testing it against the victim — stopping at the first hit found. Because SubPhase linearly interpolates between LastPhase (where the swing was last tick) and Phase (where it is now), a wide phase jump in a single tick — the extreme case being an entire swing completing within one tick — still gets Substeps interior samples across its full arc instead of one.
The spec’s Snap case makes this concrete: a Duration = 0.1 swing whose entire 180-degree arc elapses in a single 0.2s clock jump, tested with Substeps = 8. The victim sits at exactly 90 degrees into the arc — a position the swing’s arc passes directly through, but which would never be sampled by testing only phase 0 and phase 1 (the tick boundaries). With 8 interpolated sub-steps between those boundaries, one of them lands close enough to the 90-degree point for the capsule test to catch the victim. Sub-stepping does not achieve continuous coverage — it is still discrete sampling, just at a finer grain proportional to Substeps — so a large enough Substeps budget matters more the faster (or larger, in Duration terms per tick) a swing is relative to its own scale.
One more subtlety in the loop bounds: substep 0 of a given tick is the same phase point as substep Substeps of the previous tick (both equal LastPhase), so re-testing it every tick would waste work and could double-count a graze right at a tick boundary. Sweeps.step starts at substep 1 on every tick except the very first, where LastPhase is still 0 and substep 0 has never been tested by any prior tick:
local FirstSubstep = if Swing.LastPhase > 0 then 1 else 0Per-swing victim dedupe
Section titled “Per-swing victim dedupe”Swing.HitSet is a fresh { [victim]: true } table created in Sweeps.start, scoped to that one Swing instance. Before testing a candidate, step skips it if Swing.HitSet[Victim] is already set; the moment a victim is hit, it’s added to HitSet before Sweep.Hit publishes. That holds for the swing’s entire lifetime — however many ticks and sub-steps it spans — so a slow-moving tail arc that keeps a victim inside its capsule for several consecutive ticks still only registers one hit.
The dedupe set lives and dies with the Swing table: it starts empty on every :start() call and is discarded (along with the rest of the swing) once the swing retires at Phase >= 1 or is stopped early via Swing.Stop(). A second, separate :start("SameAttackName", ...) call gets its own brand-new HitSet — the dedupe is per swing instance, not per attack name, so the same victim can be hit again by the giant’s next swing of the identical attack.
Spatial broad phase
Section titled “Spatial broad phase”When Sweeps.attach is given a Spatial option (a Spatial character index), step narrows candidates with self.Spatial:radius(Root.Position, Definition.Reach or 64) instead of scanning every tracked character. This is the same broad-phase-then-exact-filter pattern Spatial itself documents: the radius query is cheap and approximate (it decides which candidates get exact-tested, never which ones pass), and the segment/capsule math above is the exact filter that actually decides hits. Reach is centered on the swing owner’s current root position, so it needs to cover the farthest any authored capsule track’s To point reaches from that root — a tail capsule that extends 45 studs needs a Reach of at least 45, not the default 64 assumed safe for every rig.
Without a Spatial index, step falls back to GetTargets() — by default every Player with a live Character, gathered fresh each tick. Either path assumes each candidate is player-shaped: victimPosition reads victim.Character.HumanoidRootPart.Position, so a Spatial index handed to Sweeps needs to key its ids by Player (exactly what Spatial.characters(kernel) builds).
Multiple tracks, multiple simultaneous swings
Section titled “Multiple tracks, multiple simultaneous swings”Capsules is an array, not a single function, so one Definition can carry more than one hit volume active at the same phase — a dragon’s tail and its trailing wing, each with its own shape, tested independently at every sub-step. step walks every entry in Definition.Capsules for a given candidate and stops at the first one that overlaps; a victim only needs to touch any one of the authored tracks to count as hit for that tick.
Sweeps.start returns a new Swing table every call, and self.Active is a set keyed by that table — nothing about step assumes only one swing runs at a time. A giant can have its club swing and a separate stomp both active and sub-stepping independently, each with its own StartedAt, LastPhase, and HitSet, and each tested against candidates every tick regardless of what else is active. The returned handle’s Stop() is how a swing ends early — it does self.Active[Swing] = nil directly, so the swing disappears from the next step() call onward with no partial-tick cleanup and no further Sweep.Hits from it, even if its Duration hadn’t elapsed. Interrupting a monster’s attack (staggered by a parry, for instance) is exactly Swing.Stop() — the same Swing table :start() returned.
Zero dependence on physics parts or replication
Section titled “Zero dependence on physics parts or replication”Every input Sweeps.step needs is something the server already trusts and owns outright: the live root CFrame (read directly off a BasePart/Model, or supplied by a function you control) and the server’s own swing clock. There is no hitbox Part that must exist, replicate, or stay in sync with an animation; no physics simulation step Sweeps waits on; no client report of anything. Victims are judged at their current server position — read fresh every tick, not rewound — because these are server-initiated monster attacks: there is no client claim being validated, so there is nothing for a Rewind-style position-history rewind to answer for. That is the sharp line between Sweeps and Rewind: Rewind exists because a client claims a shot happened at some past timestamp and the server must reconstruct what was true then; a monster’s swing is decided by the server in the present tick, so “now” is always the right instant to test against.
A giant’s club: a windup that pulls the club back near the body, then a wide forward strike arc.
local Root = game:GetService("ServerScriptService").ChloeKernelServerlocal Sweeps = require(Root.Sweeps)local Spatial = require(Root.Spatial)
-- Root-local capsule track: pure function of phase, windup then strike.-- From/To are offsets from the giant's own root, never world positions.local function GreatClubTrack(phase: number) if phase < 0.4 then -- Windup: club barely leaves the body, pulling back local Pull = phase / 0.4 return { From = Vector3.new(0, 3, 1), To = Vector3.new(0, 3, 1 - Pull * 2), Radius = 2, } end -- Strike: the club arcs forward through a wide swing local Swing = (phase - 0.4) / 0.6 local Angle = math.rad(-70) + math.rad(140) * Swing local Tip = Vector3.new(math.sin(Angle), 3, math.cos(Angle)) * 9 return { From = Vector3.new(0, 3, 0), To = Tip, Radius = 2.5, }end
return function(kernel) -- One shared character index; Sweeps uses it for broad-phase candidates local Characters = Spatial.characters(kernel) local Melee = Sweeps.attach(kernel, { Spatial = Characters })
Melee:define("GiantClub", { Duration = 0.9, Reach = 14, -- covers the 9-stud tip plus victim capsule radius and margin Capsules = { GreatClubTrack }, })
-- Damage stays entirely game-side: Sweeps only reports who got hit and where kernel.Bus:subscribe("Sweep.Hit", function(swing, victim, position) if swing.Name ~= "GiantClub" then return end local Humanoid = victim.Character and victim.Character:FindFirstChildOfClass("Humanoid") if Humanoid then Humanoid:TakeDamage(35) end end)
-- Fired from the giant's own behavior tick when it decides to swing local function swingClub(giantModel: Model) return Melee:start("GiantClub", { Root = giantModel, OnHit = function(victim, position) print(victim.Name, "clipped by the club near", position) end, }) end
return { SwingClub = swingClub }endMultiple hit volumes in one swing, stopped early
Section titled “Multiple hit volumes in one swing, stopped early”A dragon’s tail-and-wing sweep needs two independently shaped capsules active at the same phase, and needs to end the instant the dragon is staggered — before Duration naturally elapses:
Melee:define("TailAndWing", { Duration = 0.6, Reach = 30, Capsules = { function(phase) -- the tail: a long, narrow arc local Angle = math.rad(160) * phase local Tip = Vector3.new(math.sin(Angle), 1, math.cos(Angle)) * 22 return { From = Vector3.new(0, 1, 0), To = Tip, Radius = 2.5 } end, function(phase) -- the trailing wing: a shorter, wider sweep local Angle = math.rad(100) * phase local Tip = Vector3.new(math.sin(Angle), 6, math.cos(Angle) * 0.4) * 14 return { From = Vector3.new(0, 6, 0), To = Tip, Radius = 4 } end, },})
local ActiveSwing: any = nil
local function beginTailAndWing(dragonModel: Model) ActiveSwing = Melee:start("TailAndWing", { Root = dragonModel })end
-- Called from wherever the dragon's stagger logic liveslocal function onDragonStaggered() if ActiveSwing then ActiveSwing.Stop() -- no further Sweep.Hit from this swing, even mid-Duration ActiveSwing = nil endendA victim only has to overlap one of the two tracks to count as hit that tick — step tries the tail track first, then the wing track, per candidate, per sub-step, and stops at the first overlap. Each track still shares the same Duration, Reach, and per-swing HitSet, so a victim clipped by the tail early in the swing cannot also register a wing hit later in the same swing.
The client’s job here is purely cosmetic — play the windup and strike animation on cue, whether that’s a plain AnimKit clip or a full timed sequence — nothing about whether the attack lands ever depends on what plays locally.
API reference
Section titled “API reference”Sweeps.attach
Section titled “Sweeps.attach”Sweeps.attach(kernel, options?) -> SweepsSweepsOptions field |
Type | Default | Description |
|---|---|---|---|
TickRate |
number? |
30 |
Sweep loop rate on the Scheduler, kernel.Priority.Normal |
Spatial |
any? |
nil |
A Spatial character index; when set, replaces GetTargets() with a per-swing radius query |
GetTargets |
(() -> { any })? |
every Player with a live Character |
Injectable roster, used only when Spatial is absent |
Clock |
(() -> number)? |
os.clock |
Swing age clock. Injectable for deterministic specs |
SkipLoop |
boolean? |
false |
Skip scheduling the automatic loop; specs call :step() manually |
Instance methods
Section titled “Instance methods”| Member | Description |
|---|---|
sweeps:define(name, definition) |
Registers a named attack. Asserts Duration > 0 and at least one entry in Capsules; errors if name is already defined |
sweeps:start(name, options) -> Swing? |
Starts a swing of a defined attack. Returns nil if name is unknown. Returns a handle { Stop = () -> () } |
sweeps:step() |
Advances every active swing by one tick: reconstructs capsules, sub-steps, tests candidates, publishes hits, retires finished swings. Called automatically unless SkipLoop |
sweeps:destroy() |
Cancels the scheduler loop (if any) and clears all active swings |
Definition (sweeps:define)
Section titled “Definition (sweeps:define)”| Field | Type | Default | Description |
|---|---|---|---|
Duration |
number |
required, > 0 |
Swing length in seconds. phase = age / Duration, clamped 0..1 |
Capsules |
{ (phase: number) -> Capsule } |
required, non-empty | Root-local capsule tracks, evaluated at every sub-step phase. The first track-and-substep combination that overlaps a candidate wins for that victim this tick |
Reach |
number? |
64 |
Broad-phase radius in studs around the root, passed to Spatial:radius |
VictimRadius |
number? |
2 |
Victim capsule radius when no per-weapon hitbox is otherwise known |
VictimHalfHeight |
number? |
3 |
Victim capsule half-height |
Capsule (returned by a track function)
Section titled “Capsule (returned by a track function)”| Field | Type | Description |
|---|---|---|
From |
Vector3 |
Root-local segment start |
To |
Vector3 |
Root-local segment end |
Radius |
number |
Capsule radius at this phase |
SwingOptions (sweeps:start)
Section titled “SwingOptions (sweeps:start)”| Field | Type | Default | Description |
|---|---|---|---|
Root |
BasePart | Model | (() -> CFrame) |
required | Live root pose provider, re-read every tick |
OnHit |
((victim: Player, position: Vector3) -> ())? |
nil |
Fired via task.spawn per newly hit victim, alongside Sweep.Hit |
Substeps |
number? |
3 |
Phase samples interpolated between the last tick’s phase and this tick’s |
Pure functions
Section titled “Pure functions”| Member | Description |
|---|---|
Sweeps.segmentDistanceSquared(a1, a2, b1, b2) -> number |
Squared closest-approach distance between two 3D segments, clamped to both segments |
Sweeps.capsuleHitsVictim(from, to, radius, victimPosition, victimRadius, victimHalfHeight) -> boolean |
Whether a world-space capsule (from-to, radius) overlaps a vertical victim capsule centered at victimPosition |
Bus topics
Section titled “Bus topics”| Topic | Payload | When |
|---|---|---|
Sweep.Hit |
swing, victim, position |
A candidate’s capsule test passed for the first time this swing. swing is the full swing table (Name, Definition, Root, Substeps, StartedAt, LastPhase, HitSet, Stop); position is the victim’s server position at the moment of the hit |
Damage stays entirely game-side, the same separation MeleeKit and Projectiles use: Sweeps only tells you who got hit and where. Apply damage, knockback, or status effects from Sweep.Hit (or the per-swing OnHit callback) — Sweeps never calls Humanoid:TakeDamage itself.