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

fire(session, id, origin, direction, timestamp?) 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).

Each tick sweeps a raycast from the old position to the new one. What that sweep means 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: every character from GetTargets() with a HumanoidRootPart and a living Humanoid, plus one exclude list containing all characters.
  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 24 bytes
CKPJ_Hit serial NumberU16, position Vector3F24 11 bytes
CKPJ_Expire serial NumberU16 2 bytes

Origin is full-precision Vector3F32 because spawn error compounds over the whole flight; velocity tolerates Vector3F24. A projectile costs at most 26–35 bytes for its entire life, 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) only for hits, with the server’s impact position — wire your impact VFX there.
  • Per-definition pools start at 4 instances; the shared tracer pool starts at 8. destroy() disconnects everything and releases every pool.
-- 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
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
Gravity 0 Downward acceleration, studs/s²
MaxLifetime 3 Seconds until expiry; also bounds the lag-comp MaxRange
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?) → (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
LifetimeSeconds 10 Client-side safety TTL against lost hit/expire packets
Create required () → Instance factory; instances are pooled and reused
OnImpact nil (position) on server-confirmed hits
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.