Sweeps
Server-authoritative attack volumes for big rigs, authored as root-local capsule tracks, rebuilt from the live root every tick and sub-stepped so a fast swing cannot skip a target.
- Realm
- Server
- Require
ServerScriptService.ChloeKernelServer .Sweeps - Since
- v0.7.0
- Source
- Sweeps.luau
Sweeps gives big rigs hitboxes that do not lag. A dragon’s tail swipe or a giant’s club covers too much ground per tick for a single-position overlap check, and physics parts are the wrong source: during fast animation a monster’s parts trail its true server pose, exactly when the hit matters. Sweeps never reads a physics part. An attack’s volume is authored as root-local capsule tracks (pure functions of swing phase), rebuilt in world space from the rig’s live root CFrame every tick, and tested with segment math against each candidate. No Touched, no engine overlap query. Projectiles covers travel-time shots and Hazards covers area bands; MeleeKit covers humanoid-scale arc-and-range combat.
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”| System | Answers | Victim position | Attacker volume |
|---|---|---|---|
| Rewind | Did a player’s claimed ranged shot land, given latency | Rewound to the claimed time | A ray |
| Projectiles | Did a server-simulated shot’s path cross a player | Current | A swept segment per tick |
| MeleeKit | Did a timed melee swing land, and who wins block and parry | Current | A range and arc at the hit frame |
| Sweeps | Did a big rig’s authored attack volume pass through a player | Current | Root-local capsule tracks, sub-stepped |
Sweeps is for attacks that are large or fast enough that an arc or a ray cannot describe them, and that the server starts (a monster decides to swing). There is no client claim, so there is no timestamp to rewind; “now” is the right instant to test.
Phase drives the track
Section titled “Phase drives the track”Each definition has a Duration in seconds. A swing’s phase is age / Duration, clamped to 0..1, where age is Clock() - StartedAt (os.clock by default). A capsule track is a pure function (phase) -> Capsule that never sees wall time. Feed it 0.3 and it returns the capsule 30 percent through the swing, whatever the tick rate. That makes a track testable on its own.
Root-local capsules, rebuilt every tick
Section titled “Root-local capsules, rebuilt every tick”export type Capsule = { From: Vector3, -- root-local To: Vector3, -- root-local Radius: number,}From and To are offsets in the rig’s own space (“3 studs forward, 2 up”), never world positions. Each tick resolves the swing’s root pose and converts the points:
local From = Root:PointToWorldSpace(Shape.From)local To = Root:PointToWorldSpace(Shape.To)Only phase progress carries between ticks. A giant that turns or is knocked back mid-swing carries its capsule with it, because the capsule is rebuilt from where the root is now.
Root accepts:
| Root value | Resolves to |
|---|---|
BasePart |
.CFrame |
Model |
:GetPivot() |
() -> CFrame |
Called every tick |
When the root resolves to nil (an Instance that is neither a BasePart nor a Model, or a function that returns nil), the swing is dropped that tick without an error. A root function or capsule track that throws stops only its own swing with a warning naming the attack; the other swings keep sweeping. Without that isolation the error would repeat every tick and keep the timer awake.
A destroyed root part still reports its last CFrame, so a swing on a despawned rig runs out its Duration in place. Call Stop() when the rig dies.
The hit test is segment math
Section titled “The hit test is segment math”The exact test is the closest approach between two 3D segments. Sweeps.segmentDistanceSquared(a1, a2, b1, b2) returns the squared distance between the closest points of the two segments, each clamped to its own ends. Two capsules overlap exactly when their core segments are within the sum of their radii. Sweeps.capsuleHitsVictim builds the victim as a vertical segment HalfHeight above and below the root and checks:
Sweeps.segmentDistanceSquared(from, to, Bottom, Top) <= (radius + victimRadius) ^ 2Both functions are pure. This is a different primitive from Rewind’s rayCapsule, which tests a ray against a static capsule; Sweeps tests a moving capsule against a static one and does not require Rewind.
The only engine query Sweeps makes is the optional line-of-sight ray below, and only for a victim the capsules already caught.
Sub-stepping: the anti-tunneling mechanic
Section titled “Sub-stepping: the anti-tunneling mechanic”Testing only the current phase would let a fast arc pass a target between two ticks. Each tick instead samples the phase interval since the previous tick:
local SubPhase = Swing.LastPhase + (Phase - Swing.LastPhase) * (Substep / Swing.Substeps)For each candidate, Substep runs from its start to Substeps (default 3), evaluating every track at each interpolated phase and stopping at the first overlap. A whole swing that completes within one tick still gets Substeps samples across its arc.
The spec’s snap case makes this concrete: a Duration = 0.1 swing whose 180-degree arc elapses in one 0.2 s clock jump, with Substeps = 8, catches a victim sitting 90 degrees into the arc, a point testing only phase 0 and phase 1 would never sample. Sub-stepping is still discrete sampling at a finer grain, so a large or very fast arc needs a larger Substeps.
Sub-step 0 of a tick is the same phase as the last sub-step of the previous tick, so only the first tick tests it:
local FirstSubstep = if Swing.LastPhase > 0 then 1 else 0Line of sight (opt in)
Section titled “Line of sight (opt in)”With RequireLineOfSight = true on a definition, a victim the capsules caught must also be visible from the rig: Sweeps casts one solid-world ray (RespectCanCollide = true) from the root’s position to the victim’s root. Colliding geometry between them blocks the hit. The ray excludes the victim’s character and the rig: the root part’s ancestor model (or the root part alone when it has none), or the root model itself. A function root excludes nothing of its own. One RaycastParams is reused for every check.
A blocked victim is not added to the hit set, so it stays eligible for the rest of the swing: if the sight line clears on a later tick while the capsule still overlaps, the hit lands then. A tail that sweeps over a wall does not hit the player crouched behind it; the same tail rounding the wall does.
Per-swing dedupe
Section titled “Per-swing dedupe”Each swing has its own HitSet, created in start. A candidate already in it is skipped; a victim is added before Sweep.Hit publishes. A slow arc that keeps a victim inside for several ticks registers one hit. A second start of the same attack gets a new, empty set, so the next swing can hit the same victim again.
Spatial broad phase
Section titled “Spatial broad phase”With a Spatial option (a Spatial character index), each tick queries candidates within Reach (default 64) of the root’s position instead of taking every player. Reach must cover the farthest any track’s capsule extends from the root, plus the victim radius: a tail whose tip sits 45 studs out with a 2.5-stud radius needs at least 49.5 against the default victim. Without an index, the default GetTargets returns every player with a character, rebuilt each tick.
Candidates must be player-shaped: the victim position is Character.HumanoidRootPart.Position. Spatial.characters builds exactly that, keyed by Player.
Multiple tracks, multiple swings
Section titled “Multiple tracks, multiple swings”Capsules is a list, so one attack can carry several volumes at the same phase (a tail and a trailing wing). A victim that overlaps any track counts for that tick; the tracks share the swing’s Duration, Reach and HitSet.
start returns a new swing each call, and the active set is keyed by swing, so a giant’s club swing and a stomp can run at once, each with its own clock and hit set. Swing.Stop() removes a swing from the active set at once; it never publishes another hit, even with Duration left. A stagger that interrupts an attack is one Stop() call.
The timer
Section titled “The timer”attach creates no timer. The first start starts a Scheduler job at kernel.Priority.Normal, TickRate times per second (default 30). The step, or the Stop(), that leaves no active swing cancels it.
A giant’s club: a windup that pulls the club back, then a wide forward arc.
--!strict-- GiantService: the giant's club swing as a Sweeps attack.
local ReplicatedStorage = game:GetService("ReplicatedStorage")local ServerScriptService = game:GetService("ServerScriptService")
local Spatial = require(ReplicatedStorage.ChloeKernel.Spatial)local Sweeps = require(ServerScriptService.ChloeKernelServer.Sweeps)
-- Root-local track: windup, then strike. Offsets from the giant's root, never world positionslocal function greatClubTrack(phase: number): Sweeps.Capsule if phase < 0.4 then local Pull = phase / 0.4 return { From = Vector3.new(0, 3, 1), To = Vector3.new(0, 3, 1 - Pull * 2), Radius = 2 } end local Progress = (phase - 0.4) / 0.6 local Angle = math.rad(-70) + math.rad(140) * Progress return { From = Vector3.new(0, 3, 0), To = Vector3.new(math.sin(Angle) * 9, 3, -math.cos(Angle) * 9), Radius = 2.5, }end
local GiantService = { Name = "GiantService",}
function GiantService.init(self: any, kernel: any) self.Kernel = kernelend
function GiantService.start(self: any) self.Characters = Spatial.characters(self.Kernel) self.Attacks = Sweeps.attach(self.Kernel, { Spatial = self.Characters }) self.Attacks:define("GiantClub", { Duration = 0.9, Reach = 14, -- the 9-stud tip, the 2.5 club radius, the 2 victim radius Capsules = { greatClubTrack }, RequireLineOfSight = true, }) -- Damage stays game-side: Sweeps reports who was hit and where self.HitConnection = self.Kernel.Bus:subscribe( "Sweep.Hit", function(_topic: string, swing: any, victim: Player, _position: Vector3) local Character = victim.Character local Humanoid = Character and Character:FindFirstChildOfClass("Humanoid") if swing.Name == "GiantClub" and Humanoid and Humanoid.Health > 0 then Humanoid:TakeDamage(35) end end )end
-- Called by the giant's behavior when it decides to swing; keep the handle to Stop() itfunction GiantService.swingClub(self: any, giant: Model): any? return self.Attacks:start("GiantClub", { Root = giant })end
function GiantService.stop(self: any) if self.HitConnection then self.HitConnection:disconnect() end if self.Attacks then self.Attacks:destroy() end if self.Characters then self.Characters:destroy() endend
return GiantServiceThe track’s -math.cos(Angle) puts the arc in front of the giant: a CFrame looks down its negative Z axis.
Two volumes in one swing, stopped early
Section titled “Two volumes in one swing, stopped early”A dragon’s tail and wing sweep together and stop the instant the dragon is staggered:
Attacks:define("TailAndWing", { Duration = 0.6, Reach = 30, Capsules = { function(phase: number): Sweeps.Capsule -- the tail: long and narrow local Angle = math.rad(160) * phase return { From = Vector3.new(0, 1, 0), To = Vector3.new(math.sin(Angle) * 22, 1, math.cos(Angle) * 22), Radius = 2.5, } end, function(phase: number): Sweeps.Capsule -- the wing: shorter and wider local Angle = math.rad(100) * phase return { From = Vector3.new(0, 6, 0), To = Vector3.new(math.sin(Angle) * 14, 6, math.cos(Angle) * 6), Radius = 4, } end, },})
local Swing = Attacks:start("TailAndWing", { Root = Dragon })-- in the stagger handler:if Swing then Swing.Stop()endThe client’s part is cosmetic: play the windup and strike animation on cue (AnimKit). Whether the attack lands never depends on what plays locally.
API reference
Section titled “API reference”Sweeps.attach(kernel, options?) options:
| Option | Default | Description |
|---|---|---|
TickRate |
30 |
Steps per second while a swing is active, at kernel.Priority.Normal |
Spatial |
nil |
A Spatial.characters index; candidates come from a Reach query around the root |
GetTargets |
players with a Character |
Roster when Spatial is absent |
Clock |
os.clock |
Swing age clock (spec seam) |
Raycast |
workspace:Raycast |
(origin, direction, params) -> RaycastResult? for the line-of-sight check (spec seam) |
SkipLoop |
false |
Never schedule the step; call :step() yourself |
| Member | Description |
|---|---|
sweeps:define(name, definition) |
Registers an attack. Asserts Duration > 0 and at least one capsule track; a second define of the same name errors |
sweeps:start(name, options) -> Swing? |
Starts a swing; nil for an unknown name or after destroy() |
sweeps:step() |
Advances every active swing one tick: rebuild, sub-step, test, publish, retire. Scheduled automatically unless SkipLoop |
sweeps:destroy() |
Cancels the step and clears every active swing |
swing.Stop() |
Ends the swing now; no further hits |
Sweeps.segmentDistanceSquared(a1, a2, b1, b2) -> number |
Pure: squared closest distance between two segments |
Sweeps.capsuleHitsVictim(from, to, radius, victimPosition, victimRadius, victimHalfHeight) -> boolean |
Pure: whether a world-space capsule overlaps a vertical victim capsule |
Definition fields:
| Field | Default | Description |
|---|---|---|
Duration |
required, above 0 | Swing length in seconds |
Capsules |
required, non-empty | Root-local tracks (phase) -> Capsule, evaluated at every sub-step phase |
Reach |
64 |
Broad-phase radius around the root for the Spatial query |
VictimRadius |
2 |
Victim capsule radius |
VictimHalfHeight |
3 |
Victim capsule half-height |
RequireLineOfSight |
false |
A caught victim must have clear solid-world sight to the root |
SwingOptions (start):
| Field | Default | Description |
|---|---|---|
Root |
required | BasePart, Model or () -> CFrame, read every tick |
OnHit |
nil |
(victim, position) through task.spawn for each new victim, alongside Sweep.Hit |
Substeps |
3 |
Phase samples between the last tick’s phase and this tick’s |
Bus topics
Section titled “Bus topics”| Topic | Payload | When |
|---|---|---|
Sweep.Hit |
swing, victim, position |
A victim was hit for the first time this swing. swing is the swing table (Name, Definition, Root, Rig, OnHit, Substeps, StartedAt, LastPhase, HitSet, Stop); position is the victim’s root position at the hit |
Damage stays game-side, as with MeleeKit and Projectiles: apply damage, knockback or effects from Sweep.Hit or OnHit. Sweeps never calls Humanoid:TakeDamage.