Skip to content

Projectiles

Projectiles is the server-authoritative travel-time projectile system. The server owns every trajectory — integration, gravity, sweep, hit resolution — and clients receive exactly three packets per projectile: spawn, hit, expire. Everything between spawn and impact is computed independently on both sides with the same math, so mid-flight positions never ride the wire and a client can neither move nor invent a projectile.

One numeric id (1–255) pairs a server Definition with a client VisualDefinition. Keep both in one shared module so simulation and rendering cannot drift.

attach schedules the simulation on the Scheduler at kernel.Priority.Normal, TickRate times per second (default 30). Each tick steps every live projectile by real elapsed time, not nominal tick time — if ticks defer under load, projectiles keep true speed instead of slowing down. The step is clamped to 0.1 s so a server hitch cannot tunnel a projectile through a wall.

Integration is semi-implicit Euler, exposed as a pure function so anything (client visuals, your own prediction) can run the identical math:

-- position, velocity = Projectiles.step(position, velocity, gravity, dt)
local NewVelocity = velocity - Vector3.new(0, gravity * dt, 0)
return position + NewVelocity * dt, NewVelocity

Definition.Path(seed, age, origin, velocity, flightSeconds?) → Vector3 (new in 0.6.3) is an alternative to gravity/speed integration, not an addition to it: when a definition carries a Path, the server calls it for every tick’s new position instead of running Projectiles.step at all, and Gravity is ignored outright — the curve is the trajectory. Speed still matters, though: it sets the magnitude of the velocity argument Path receives at spawn, so the same curve function scales with muzzle speed.

Path is a pure function of data every client already holds from the spawn packet — seed (the wire serial), age (seconds since spawn), the spawn origin, the spawn velocity, and flightSeconds — so it evaluates identically on server and client for zero extra wire bytes. A simple lobbed arc:

local function lobArc(seed: number, age: number, origin: Vector3, velocity: Vector3, flightSeconds: number?): Vector3
local Flat = Vector3.new(velocity.X, 0, velocity.Z)
local Straight = origin + Flat * age -- horizontal travel; no gravity term
local Duration = flightSeconds or 1
local Progress = math.clamp(age / Duration, 0, 1)
local ArcHeight = 12 -- studs at the apex
return Straight + Vector3.new(0, ArcHeight * math.sin(Progress * math.pi), 0)
end
PJ:define(2, {
Speed = 70,
MaxLifetime = 2,
Path = lobArc, -- replaces integration entirely for this definition
Hitbox = { Radius = 1.6, HalfHeight = 2.4 },
})

Because the per-tick hit sweep (below) runs against whatever Path returns, the arc’s hitbox follows the visual exactly — a grenade that lobs over a wall on screen is a grenade that lobs over the wall for hit-testing, not a straight shot that merely renders curved. PathOffset can still layer a purely cosmetic wobble on top of a Path-driven shot; it never reaches the hit sweep either way.

fire(session, id, origin, direction, timestamp?, maxDistance?, speedMultiplier?) returns (ok, reason?). Reasons are machine-readable so your intent handler can apply per-reason strike policies:

Reason Cause
UnknownProjectile id has no definition
NonFinite origin or direction contains NaN or ±inf
ZeroDirection direction.Magnitude below 1e-4
(Rewind reason) Rewind:validateShot rejected — returned verbatim (OriginMismatch, FutureTimestamp, …)
ProjectileSerialsExhausted 65,535 projectiles already live

The NaN gate exists because Vector3F24/Vector3F32 wire types reconstruct NaN and infinity verbatim, and NaN slips past every < comparison downstream — it must be rejected before the magnitude and range gates silently pass it.

When a Rewind instance is attached and the caller passes a timestamp, the spawn is lag-compensation validated: the claimed muzzle origin must match where the shooter actually stood at that timestamp, with MaxRange derived as Speed * (MaxLifetime or 3). A rejected claim never spawns and never broadcasts.

Serials are NumberU16 values allocated by a wrapping counter that skips still-live serials — a projectile that outlives 65,534 later spawns is never overwritten (spec-verified).

Per-shot flight control. Two optional arguments tune an individual shot without touching its Definition: maxDistance caps this shot’s lifetime to maxDistance / speed, so a point-targeted projectile (a click-to-aim spell, an explicitly ranged throw) expires exactly at its aim point instead of flying the definition’s full MaxLifetime; speedMultiplier scales this shot’s speed (floor 0.1) with no wire-format change — velocity and the derived lifetime simply ride the spawn packet at the scaled values. Both default to leaving the definition’s own Speed/MaxLifetime unchanged.

Each tick sweeps a raycast from the old position to the new one. When the definition has a Path, “the new one” is Path(t) and “the old position” is last tick’s Path(t - dt) — the segment being swept follows the curve exactly, tick by tick, instead of a straight line. Without a Path, it’s the straight-line result of Projectiles.step. What the sweep means once you have that segment depends on whether the definition carries a Hitbox:

Capsule mode (Hitbox present — the mode player-versus-player games should use):

  1. Once per tick — not per projectile — the manager snapshots targets. Without a Spatial index this is every character from GetTargets() with a HumanoidRootPart and a living Humanoid, plus one shared exclude list, built once and reused by every projectile that tick. Pass a Spatial character index instead and each projectile queries its own candidates by a per-projectile radius (swept distance plus hitbox radius plus an 8-stud pad for drift) — the broad-phase narrows candidates, but roots are re-read fresh from the query results so hit math stays exact; both the exclude list and the pooled candidate/target scratch tables come from TablePool in this path.
  2. The world raycast runs with every character excluded. Geometry only.
  3. Player hits resolve as capsules: Rewind.rayCapsule against each target’s root position (Radius default 1.6, HalfHeight default 2.4 — the same capsule model the Rewind hitscan path uses). The nearest capsule hit inside the swept segment wins; the segment is clamped to the wall distance, so cover actually covers. The owner is never a target.

A capsule miss is the only way a shot passes through a player — bodies are never resolved by raw geometry raycasts, so hit registration does not depend on accessory parts, rig type, or animation pose.

Legacy mode (no Hitbox): one raw geometry raycast with only the owner’s character excluded. The victim, if any, is resolved via Players:GetPlayerFromCharacter on the hit instance’s ancestor model. Fine for PvE and physics props; capsule mode supersedes it for anything competitive.

Resolution order per tick: victim, then wall, then lifetime. A victim hit fires CKPJ_Hit at the capsule surface point; a wall hit fires it at the raycast position; a projectile past DiesAt fires CKPJ_Expire. All three remove the projectile from the live set. OnHit/OnExpire run in task.spawn so a throwing callback cannot stall the simulation loop.

If the attached Rewind has ShowHitboxes enabled, capsule mode draws every tested capsule on the landing step only (green for the victim, red for misses) — per-step drawing at 30 Hz would be noise. Visualization never affects results.

Packet Schema Payload
CKPJ_Spawn serial NumberU16, defId NumberU8, origin Vector3F32, velocity Vector3F24, flightSeconds NumberF16, caster Instance 26 bytes + one Instance reference
CKPJ_Hit serial NumberU16, position Vector3F24 11 bytes
CKPJ_Expire serial NumberU16, position Vector3F24 11 bytes

Origin is full-precision Vector3F32 because spawn error compounds over the whole flight; velocity tolerates Vector3F24. flightSeconds (the shot’s computed lifetime) and caster (the firing player, for client-side latency masking) ride the spawn packet too — the Instance argument travels over the RemoteEvent’s own engine-side argument slot, not the kernel’s packed buffer, so it costs no buffer bytes (only the debug panel’s byte accounting changed to reflect this; the wire itself always worked this way). CKPJ_Expire now carries the expiry position alongside the serial, so expiry VFX can play where the shot actually died. A projectile costs at most ~48 buffer bytes for its entire life plus one Instance reference, regardless of how long it flies.

ProjectileClient renders the wire traffic. It runs the same integration math on Heartbeat, so visuals land where hits land, and draws every visual from a Pool — continuous fire never touches Instance.new churn.

The render rule is a fairness invariant, not a quality setting:

  1. Every projectile renders. Up to MaxVisuals live projectiles get the full cosmetic visual from your Create factory.
  2. Overflow becomes tracers. Past the cap, shots render as pooled minimal tracers (0.35-stud neon balls) instead — up to MaxTracers of them.
  3. Threatening shots bypass the cap. A shot whose straight flight line passes within ThreatRadius studs of the local character renders full regardless of the visual cap, and threatening shots ignore the tracer ceiling too. Only a non-threatening shot past both caps is dropped.

threatens(origin, velocity, position, radius) is the pure gate: closest approach of the flight ray to the position, with the parameter clamped to t >= 0 so a shot moving away never threatens, and a degenerate (near-zero) velocity falls back to plain distance. Gravity is ignored — the straight-line approximation errs toward rendering full (spec-verified).

Defaults: MaxVisuals comes from the DeviceBench device profile (profile().ProjectileVisuals — base 40, scaled by the device’s compute score, floor 8), so weak phones cap lower automatically. Run the bench during your loading screen so the first fight doesn’t pay for it. MaxTracers defaults to 300, ThreatRadius to 15 studs.

Bookkeeping details that keep the pools honest:

  • Each visual entry carries a safety TTL (LifetimeSeconds, default 10 s): a lost CKPJ_Hit/CKPJ_Expire packet must not leak its pool slot forever.
  • A spawn arriving on a serial that is somehow still live retires the old entry first — U16 serials wrap, and an orphaned entry would strand its pooled visual.
  • OnImpact fires (via task.spawn) for both hits and expiries, with the server’s impact/expiry position and the shot’s last velocity — wire impact VFX to face the surface it died on.
  • Per-definition pools start at 4 instances; the shared tracer pool starts at 8. destroy() disconnects everything and releases every pool.

Four VisualDefinition hooks (ported up from Withcraft&Wizardry) let a visual look and feel different from its underlying physics without ever moving where a hit actually lands — every one of them is purely additive to the position ProjectileClient already computed from the same integration math as the server. A fifth field, Path, isn’t cosmetic at all: mirror the server Definition’s curve function here and the client evaluates the identical authoritative position instead of integrating, exactly as the server does.

  • PathOffset(seed, age, velocity, flightSeconds?) runs every frame and returns a Vector3 added to the integrated (or Path-evaluated) position — flourish curves, spirals, wobble. seed is the wire serial, so every client renders the identical path for the same shot; flightSeconds, when known, lets an offset collapse to zero by the time the shot is due to land, since visuals must land where the server says they land.
  • OnRender(visual, seed, age, flightSeconds?) runs every frame after the visual’s CFrame is set — spin a trail attachment, cull an emitter by distance, anything that never itself moves the projectile.
  • CosmeticOrigin(caster, origin) is latency masking for the muzzle: return where this client currently sees the shot’s origin (the caster’s wand tip, a turret barrel), and the visual spawns there instead of at the wire origin, converging onto the authoritative path over OriginBlendSeconds (default 0.15 s, smoothstep-eased). The offset is capped at 25 studs (MaxCosmeticOriginStuds) — a badly desynced replica loses to the authoritative origin outright rather than visibly teleporting the visual mid-flight.
  • OnImpact(position, velocity?, serial?)velocity is the shot’s last velocity at impact, so impact VFX can orient itself to the surface the shot died on instead of always facing the same way. serial (new in 0.6.3) is the wire seed — the same value PathOffset receives — so impact cosmetics can reproduce the exact per-shot random roll the visual flew with (a themed impact effect that varies shot-to-shot but has to match the flourish the flight visual already showed).

Facing (changed in 0.6.3): only curved rigs — a VisualDefinition with Path and/or PathOffset defined — bank their orientation along the rendered motion tangent (Rendered - LastRendered each frame). Every other rig now orients on the true heading instead: the authoritative velocity direction, unaffected by any cosmetic offset. Previously every rig banked along rendered motion, which meant a short-range straight shot with no curve hooks at all (a blade slash) rolled and pitched wildly for the whole flight while CosmeticOrigin’s muzzle-blend was still converging — the blend nudges the rendered position around, and banking on that nudge reads as the visual tumbling instead of just pointing where it’s actually going. Straight shots now point at their true heading throughout the blend; only shots that are actually curved bank on the curve.

Landing without overshoot. When a shot’s flightSeconds is known (the server always sends it now), the client stops integrating that shot’s position once age >= flightSeconds and parks the visual at its landing point — holding there, still rendering, until the authoritative CKPJ_Hit/CKPJ_Expire arrives up to one network step later. Without this, a visual using only client-side integration would keep flying past where the server already resolved the shot, reading as the bolt overshooting its target before snapping back.

-- src/Server/Bootstrap.luau
local Root = game:GetService("ServerScriptService").ChloeKernelServer
local Projectiles = require(Root.Projectiles)
local Rewind = require(Root.AntiExploit.Rewind)
return function(kernel)
local Lag = Rewind.attach(kernel)
local PJ = Projectiles.attach(kernel, { Rewind = Lag })
PJ:define(1, {
Speed = 90,
Gravity = 40, -- studs/s² downward; match the client visual def
MaxLifetime = 4,
Hitbox = { Radius = 1.6, HalfHeight = 2.4 }, -- capsule mode
OnHit = function(ownerSession, result, victimPlayer)
if victimPlayer then
local Character = victimPlayer.Character
local Humanoid = Character and Character:FindFirstChildOfClass("Humanoid")
if Humanoid then
Humanoid:TakeDamage(25)
end
end
end,
})
local Net = kernel:net()
Net:defineIntent("Throw", { "Vector3F32", "Vector3F24", "NumberF64" }, {
RateLimit = 5,
Handler = function(session, origin, direction, timestamp)
local Ok = PJ:fire(session, 1, origin, direction, timestamp) -- lag-comp validated
if Ok then
session.Data.LastThrow = os.clock()
end
end,
})
-- Intents are fail-closed: a handler with no validator rejects everything.
kernel.Hooks:on("Intent.Throw", function(context)
return os.clock() - (context.Session.Data.LastThrow or 0) >= 0.2 -- server-side fire rate
end)
end

NPCKit fires the same definitions — a moveset action with Kind = "Projectile" and a DefId rides the npc handle as the owner session through this exact code path. One server simulation, two kinds of trigger finger.

Projectiles.attach(kernel, options?) options:

Option Default Description
Rewind nil A Rewind instance; enables lag-comp validation of timestamped fire calls
TickRate 30 Simulation steps per second
Raycast workspace raycast Injectable (origin, displacement, exclude) → RaycastResult? (spec seam)
GetTargets Players:GetPlayers() Target roster for capsule mode (ignored when Spatial is set)
Spatial nil A Spatial character index (new in 0.6.0); replaces the per-tick full-roster scan with a per-projectile radius query
PacketFactory Net.Packet Injectable packet constructor (spec seam)
SkipLoop false Do not schedule the tick; drive _step(dt) yourself

Definition fields:

Field Default Description
Speed required Muzzle speed, studs/s — still sets the velocity magnitude handed to Path, if one is set
Gravity 0 Downward acceleration, studs/s² — ignored entirely when Path is set
MaxLifetime 3 Seconds until expiry; also bounds the lag-comp MaxRange
Path nil (seed, age, origin, velocity, flightSeconds?) → Vector3 (new in 0.6.3) — authoritative curve; when set, replaces Projectiles.step integration entirely for this definition, and the per-tick hit sweep tests the curve’s segment instead of a straight line
Hitbox nil { Radius?, HalfHeight? } — presence selects capsule mode
OnHit nil (ownerSession, result, victimPlayer?) — result shape differs by mode (see above)
OnExpire nil (ownerSession) on lifetime expiry
Member Description
Projectiles.step(position, velocity, gravity, dt) → (position, velocity) Pure integration step
manager:define(id, definition) Register a definition; ids are 1–255, redefinition errors
manager:fire(session, id, origin, direction, timestamp?, maxDistance?, speedMultiplier?) → (ok, reason?) Validate and spawn; broadcasts CKPJ_Spawn

ProjectileClient.new(definitions, options?)definitions maps the same numeric ids to VisualDefinition:

Field Default Description
Gravity 0 Must match the server definition or visuals drift from hits; ignored when Path is set
LifetimeSeconds 10 Client-side safety TTL against lost hit/expire packets
Create required () → Instance factory; instances are pooled and reused
Path nil (seed, age, origin, velocity, flightSeconds?) → Vector3 (new in 0.6.3) — mirror the server Definition’s curve; replaces integration for this shot and makes it a curved rig for facing purposes
OnImpact nil (position, velocity?, serial?) on server-confirmed hits and expiries — velocity is the shot’s last velocity; serial (new in 0.6.3) is the wire seed, the same value PathOffset receives, letting impact cosmetics reproduce this shot’s per-shot roll
PathOffset nil (seed, age, velocity, flightSeconds?) → Vector3 per-frame cosmetic offset added to the integrated (or Path-evaluated) position; also makes the rig curved for facing purposes
OnRender nil (visual, seed, age, flightSeconds?) per-frame hook after the visual’s CFrame is set
CosmeticOrigin nil (caster, origin) → Vector3? latency-masking muzzle override; blends into the true origin over OriginBlendSeconds
OriginBlendSeconds 0.15 Blend duration for CosmeticOrigin’s offset decaying to zero
Option Default Description
MaxVisuals device profile (ProjectileVisuals) Cap on full cosmetic visuals; overflow becomes tracers
MaxTracers 300 Tracer ceiling; threatening shots bypass it
ThreatRadius 15 Studs from the local character’s root that force a full visual
Member Description
ProjectileClient.threatens(origin, velocity, position, radius) → boolean Pure threat gate (straight-line closest approach)
client:destroy() Disconnect packets and Heartbeat, release and destroy every pool
Topic Payload When
Projectile.Hit ownerSession, victimPlayer?, defId, position A projectile landed — on a player (capsule or legacy resolution) or on geometry (victimPlayer nil)

Published on the Bus after the wire broadcast and the OnHit spawn. Subscribe here for cross-cutting reactions (assists, analytics, aggro) instead of stuffing them into every OnHit.

  • Why the server excludes characters from the world raycast in capsule mode: the raycast’s job is geometry only. If characters could block the ray, hit priority would depend on limb poses and accessories instead of the rotation-invariant capsule — the same reason Rewind stores positions without orientation.
  • Why real elapsed time with a clamp: nominal tick time would slow projectiles whenever ticks defer; unclamped real time would let one hitch teleport a projectile through cover. min(elapsed, 0.1) gets both properties.
  • Exclude-list identity matters. The default raycast reassigns FilterDescendantsInstances only when the exclude table’s identity changes, because the assignment is the expensive part. If you inject a custom Raycast or build exclude lists yourself, pass a fresh table when contents change — mutating the old one is invisible.
  • StreamingEnabled is a non-issue: the simulation is fully server-side and the visual layer operates on wire data and pooled local parts — see StreamingEnabled.