Projectiles
Simulates travel-time projectiles on the server, with capsule hits, lag-compensated spawns and mid-flight steering, and renders them on clients from four packet kinds.
- Realm
- Server + Client
- Require
ServerScriptService.ChloeKernelServer .Projectiles - Since
- v0.1.0
- Source
- Projectiles.luauNet/ProjectileClient.luau
Projectiles simulates travel-time shots on the server. The server owns every trajectory, and clients receive four packet kinds: spawn, redirect, hit and expire. Both sides run the same integration math from the same spawn data, so mid-flight positions never cross the wire and a client can neither move nor invent a shot. Hazards and Sweeps are the sibling server-authoritative hit systems for area bands and big-rig melee.
One numeric id (1 to 255) pairs a server Definition with a client VisualDefinition. Keep the shared numbers (speed, gravity, curve functions) in one module that both sides require, so simulation and rendering cannot drift.
Mental model
Section titled “Mental model”The tick
Section titled “The tick”attach creates no timer. The first fire starts a Scheduler job at kernel.Priority.Normal, TickRate times per second (default 30), and the step that retires the last live shot cancels it. The elapsed-time baseline resets on every wake, so time spent idle never advances a shot.
Each tick steps every live shot by real elapsed time, clamped to 0.1 s. Deferred ticks keep true speed, and one server hitch cannot carry a shot through a wall in a single step. Integration is semi-implicit Euler, exported as a pure function so client visuals and your own prediction run identical math:
-- Projectiles.step(position, velocity, gravity, dt) -> (position, velocity)local NewVelocity = velocity - Vector3.new(0, gravity * dt, 0)return position + NewVelocity * dt, NewVelocityThe step walks a snapshot of the live set. OnHit, OnExpire and Projectile.Hit subscribers run inside the step and may fire, redirect or retire shots. A shot fired during a step starts moving on the next one, a shot retired during a step is skipped, and every other shot still steps exactly once.
Curved paths
Section titled “Curved paths”Definition.Path(seed, age, origin, velocity, flightSeconds?) -> Vector3 replaces integration for its definition. When a definition has a Path, the server calls it for each tick’s position instead of Projectiles.step, and Gravity is ignored. Speed still sets the magnitude of the velocity argument, so one curve scales with muzzle speed.
Every argument is data the client already holds from the spawn packet (seed is the wire serial, age is seconds since spawn), so the client evaluates the same function at zero extra wire cost. A 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 Duration = flightSeconds or 1 local Progress = math.clamp(age / Duration, 0, 1) local ArcHeight = 12 -- studs at the apex return origin + Flat * age + Vector3.new(0, ArcHeight * math.sin(Progress * math.pi), 0)end
Shots:define(2, { Speed = 70, MaxLifetime = 2, Path = lobArc, Hitbox = { Radius = 1.6, HalfHeight = 2.4 },})The hit sweep for a Path shot covers the segment from last tick’s Path position to this tick’s, so a grenade that lobs over a wall on screen also lobs over it for hit testing. PathOffset can add a cosmetic wobble on the client; it never reaches the hit sweep.
A Path that throws expires its own shot where it is: the server sends CKPJ_Expire, runs OnExpire, logs a warning and moves on. Without that isolation the error would repeat every tick and stall the shots stepped after it.
Firing: the validation chain
Section titled “Firing: the validation chain”fire(session, id, origin, direction, timestamp?, maxDistance?, speedMultiplier?) returns (ok, reason?). Reasons are strings, so an intent handler can apply its own strike policy per reason:
| Reason | Cause |
|---|---|
Destroyed |
destroy() already ran |
UnknownProjectile |
id has no definition |
NonFinite |
origin or direction contains NaN or an infinity |
ZeroDirection |
direction.Magnitude is below 1e-4 |
| Rewind’s reason | Rewind:validateShot rejected the claim; its reason is returned as is (OriginMismatch, StaleTimestamp, FutureTimestamp, NoShooterHistory, …) |
ProjectileSerialsExhausted |
65,535 shots are live |
The NaN gate runs first because Vector3F24 and Vector3F32 wire types decode NaN and infinity as sent, and NaN passes every < comparison after it.
With a Rewind instance attached and a timestamp passed (the client’s workspace:GetServerTimeNow() at fire time), the claimed muzzle origin must lie within Rewind’s origin tolerance of where the shooter stood at that time, with MaxRange set to Speed * (MaxLifetime or 3). Rewind accepts a timestamp only as old as the shooter’s round trip plus its interpolation and slack allowances (0.25 s by default), never more than HistorySeconds. A rejected claim never spawns and never broadcasts. Without a timestamp, or for a shot with no owner, fire skips Rewind.
Serials are NumberU16 values from a wrapping counter that skips serials still in use, so a long-lived shot is never overwritten when the counter comes back around.
Two optional arguments tune one shot without touching its definition. maxDistance caps the lifetime at maxDistance / speed, so a point-targeted shot (a click-to-aim spell) expires at its aim point instead of flying the full MaxLifetime. speedMultiplier scales this shot’s speed, with a floor of 0.1. Both ride the spawn packet as the shot’s velocity and flight seconds, so the wire format does not change.
Hit resolution: two modes
Section titled “Hit resolution: two modes”Each tick sweeps from the shot’s old position to its new one. Only colliding geometry stops a shot: both modes cast with RespectCanCollide = true, so zones, triggers and other non-colliding volumes never absorb one. The default raycast keeps its RaycastParams for the life of the manager and reassigns ExcludeInstances only when the exclude table’s identity changes, because the assignment is the expensive part.
Capsule mode (the definition has a Hitbox; use it for anything player-versus-player):
- Targets. Without a Spatial index, the manager builds one target list and one exclude list per tick and shares them across every shot: each player from
GetTargets()whose character has aHumanoidwith aRootPartandHealth > 0. With aSpatialcharacter index, each shot queries candidates within its swept distance plus the hitbox radius plus 8 studs (index positions can lag one refresh). Roots are read fresh, the scratch tables come from TablePool, and the shot keeps its exclude list while its neighborhood is unchanged so the cached ray filter holds. - The world ray excludes every candidate character. It tests geometry only.
- Player hits resolve as capsules through
Rewind.rayCapsuleagainst each target’s root position (Radiusdefault 1.6,HalfHeightdefault 2.4, the same model Rewind’s hitscan uses). The segment is clamped to the wall distance, so cover covers, and the nearest capsule hit wins. The owner is never a target. A shot with no owner (a turret, a hazard) excludes nobody.
A capsule miss is the only way a shot passes through a player. Hit registration does not depend on accessories, rig type or animation pose.
Legacy mode (no Hitbox): one solid-geometry ray with only the owner’s character excluded, then a second queryable ray up to that wall. A nearer part of a model that contains a Humanoid wins, because limbs do not collide by default and the solid ray would pass through them. The victim is Players:GetPlayerFromCharacter on that part’s ancestor model; an NPC stops the shot with no victim player. The owner filter is built once at fire time (and again when a redirect changes the owner), so the ray keeps one filter for the whole flight. Legacy mode suits PvE and physics props.
Resolution order per tick is victim, then wall, then lifetime. A victim hit sends CKPJ_Hit at the capsule surface point, a wall hit sends it at the ray position, and a shot past its lifetime sends CKPJ_Expire with its last position. OnHit and OnExpire run through task.spawn, so a throwing callback cannot stop the step.
With ShowHitboxes on the attached Rewind, capsule mode draws every tested capsule when a shot hits a player or a wall (green for the victim, red for misses). Drawing every 30 Hz step would be noise. Drawing never affects results.
Steering live shots
Section titled “Steering live shots”Deflections, captures, magnet pulls and mid-air collisions work on shots already in flight.
forEach(visit) calls visit(serial, shot) for every live shot. shot is a ShotView: DefId, Owner, Position, Velocity (the last integrated velocity; Path shots report their launch velocity), Age and Remaining (seconds until expiry). One view table is reused for every visit in a pass, so copy what you keep. forEach walks a snapshot of serials, so calling redirect or retire inside visit is safe.
redirect(serial, { Velocity, Position?, Owner?, Lifetime? }) restarts a shot’s flight from new data. Position (default: where the shot is now), velocity, launch time and lifetime all reset, so the next sweep covers the new segment and a Path shot evaluates its curve from the new origin at age zero. Velocity is used as given; it is not rescaled to the definition’s Speed. Lifetime is seconds from now and defaults to the time the shot had left. An Owner transfers hit credit (OnHit and Projectile.Hit receive the new owner), the capsule self-exclusion and the legacy owner filter. It returns (ok, reason?):
| Reason | Cause |
|---|---|
UnknownShot |
No live shot has that serial |
NonFinite |
Velocity is missing or not a Vector3, or it or Position contains NaN or an infinity |
ZeroVelocity |
Velocity.Magnitude is below 1e-4; a client tracer has no facing for it |
NoLifetime |
The lifetime is NaN, infinite or not above zero |
A successful redirect broadcasts CKPJ_Redirect and publishes Projectile.Redirected.
retire(serial, position?) removes a shot without running OnHit or OnExpire and broadcasts CKPJ_Expire at position (default: the shot’s current position), so clients play their impact cosmetics there. It returns false for an unknown serial. Use it when two shots destroy each other in the air.
-- A shield reflects every enemy shot within 8 studs and takes ownership of itlocal function reflect(shots: any, shield: BasePart, blocker: any) shots:forEach(function(serial: number, shot: any) if shot.Owner ~= blocker and (shot.Position - shield.Position).Magnitude <= 8 then shots:redirect(serial, { Velocity = -shot.Velocity, Owner = blocker }) end end)endWhat rides the wire
Section titled “What rides the wire”| Packet | Schema | Payload |
|---|---|---|
CKPJ_Spawn |
serial NumberU16, defId NumberU8, origin Vector3F32, velocity Vector3F24, flightSeconds NumberF16, caster Instance |
26 bytes plus one Instance reference |
CKPJ_Redirect |
serial NumberU16, position Vector3F32, velocity Vector3F24, flightSeconds NumberF16, owner’s player Instance |
25 bytes plus one Instance reference |
CKPJ_Hit |
serial NumberU16, position Vector3F24 |
11 bytes |
CKPJ_Expire |
serial NumberU16, position Vector3F24 |
11 bytes |
Byte counts are payload; each server-to-client frame adds its id byte and a varint body length (Packet). Instance values travel in the remote call’s own argument list, not the buffer. Origin uses full-precision Vector3F32 because a spawn error compounds over the whole flight; velocity tolerates Vector3F24. flightSeconds is the shot’s computed lifetime, and caster is the firing player, used for latency masking. An unsteered shot costs 37 payload bytes for its whole life, however long it flies.
The client layer: no invisible shots
Section titled “The client layer: no invisible shots”ProjectileClient renders the wire traffic. It steps on RunService.PreRender and connects only while a shot is live or a retired visual is lingering. It runs the same integration math as the server, so visuals land where hits land, and it draws every visual from a Pool, so continuous fire does not churn Instance.new. BasePart visuals move with one workspace:BulkMoveTo per frame (FireCFrameChanged); a Model visual moves with PivotTo. OnRender hooks run after the bulk move, against the CFrames it just wrote.
The render rule is a fairness rule:
- Every projectile renders. Up to
MaxVisualslive shots get the full visual from yourCreatefactory. - Past the cap, shots render as pooled tracers (0.35-stud neon balls), up to
MaxTracers. - A shot whose straight flight line passes within
ThreatRadiusstuds of the local character’sPrimaryPartrenders full regardless of either cap. Only a non-threatening shot past both caps is dropped.
threatens(origin, velocity, position, radius) is the pure gate: the closest approach of the flight ray to the position, with the ray parameter clamped to t >= 0 so a shot moving away never threatens. A velocity near zero falls back to plain distance. Gravity is ignored, and the straight line errs toward rendering full.
MaxVisuals defaults to the DeviceBench profile’s ProjectileVisuals (40 scaled by the device’s compute score, floor 8), so weak phones cap lower. Run the bench during your loading screen so the first fight does not pay for it. MaxTracers defaults to 300 and ThreatRadius to 15 studs.
Bookkeeping that keeps the pools honest:
- Each entry carries a safety TTL (
LifetimeSeconds, default 10 s), so a lostCKPJ_HitorCKPJ_Expirecannot hold a pool slot forever. A TTL retirement runs noOnImpact. - A spawn on a serial that is still live retires the old entry first. Serials wrap at 65,535, and an orphaned entry would strand its visual.
OnImpactruns throughtask.spawnfor hits and expiries with the server’s position, the shot’s last velocity and its serial.- Definition pools start with 4 instances; the shared tracer pool starts with 8.
- A zero velocity (a redirect that freezes a shot) keeps the last facing instead of building a NaN CFrame.
- A throwing
Path,PathOffset,OnRenderorOnAcquireretires only its own shot, with one warning per definition. The other shots keep rendering.
Pooled visuals: OnAcquire and Linger
Section titled “Pooled visuals: OnAcquire and Linger”A reused visual carries its last life: Trail segments and emitted particles stay where the shot died. OnAcquire(visual, serial) runs on every acquire of a full visual, new or reused, after its first CFrame write and parenting. The default is Pool.clearEffects(visual), which clears every Trail and ParticleEmitter under it. Running after the first CFrame write means no trail segment spans the old and new positions. A custom OnAcquire replaces the default; call Pool.clearEffects(visual) inside it to keep that reset. Tracers skip the hook.
Linger (seconds, default 0) keeps a retired full visual where it died instead of returning it to the pool at once. Pool.quietEffects(visual, true) disables its Trails, ParticleEmitters, Fire, Smoke and Sparkles, so existing segments and particles run out their own lifetimes, and hides its parts, decals and beams for the local client through LocalTransparencyModifier. When the linger passes, the stepper puts back exactly what it changed and releases the visual. Set Linger to the longest trail or particle lifetime on the visual so it fades instead of popping. Tracers never linger.
The cosmetic stack
Section titled “The cosmetic stack”These VisualDefinition hooks change how a shot looks without moving where it lands. Each one adds to the position the client already computed from the server’s math. Path is the exception: mirror the server definition’s curve here and the client evaluates the same authoritative position the server sweeps.
PathOffset(seed, age, velocity, flightSeconds?)runs every frame and returns aVector3added to the integrated (orPath) position: spirals, wobble, flourish curves.seedis the wire serial, so every client renders the same offset for the same shot. UseflightSecondsto collapse the offset to zero by arrival, because the visual must land where the server says it lands.OnRender(visual, seed, age, flightSeconds?)runs every frame after the visual’s CFrame is set. Spin a trail attachment or cull an emitter by distance here; it must not move the shot.CosmeticOrigin(caster, origin)masks latency at the muzzle. Return where this client sees the shot’s origin (the caster’s wand tip, a turret barrel) and the visual starts there, converging onto the authoritative path overOriginBlendSeconds(default 0.15 s, smoothstep). An offset over 25 studs is ignored and the visual starts at the wire origin, so a badly desynced replica never teleports the visual mid-flight.OnImpact(position, velocity?, serial?)receives the shot’s last velocity, so impact effects can face the surface, and its serial, so they can reproduce the per-shot rollPathOffsetused.OnRedirect(visual, position, velocity, serial, caster?)runs after a redirect; see below.
Facing: rigs with Path or PathOffset bank along their rendered motion. Every other rig faces the authoritative velocity. Banking a straight shot on its rendered motion would make it roll and pitch while the CosmeticOrigin blend converges, which reads as tumbling.
Landing without overshoot: when flightSeconds is known (the server always sends it), the client stops advancing the shot at age >= flightSeconds and holds the visual at its landing point until the hit or expire packet arrives, up to one network step later. Without this the bolt would fly past its target and snap back.
Redirects on the client
Section titled “Redirects on the client”A CKPJ_Redirect for a shot this client renders restarts that entry’s flight from the packet: position, velocity, launch time, flight seconds and the safety TTL. A full visual blends from its last rendered point onto the new path over OriginBlendSeconds, so a deflection bends instead of teleporting; if that point is more than 25 studs from the redirect position, the visual snaps. OnRedirect(visual, position, velocity, serial, caster) then runs through task.spawn for the cosmetic beat (a spark, a tint for the new owner); caster is the owner’s player after the redirect. Tracers re-aim without a blend or the hook. A redirect for a shot the client dropped past the tracer cap is ignored.
The shared numbers live in one module both sides require:
--!strict-- Shots: projectile ids and flight tuning shared by server and client.
return { Bolt = { Id = 1, Speed = 90, Gravity = 40, MaxLifetime = 4 },}The intent goes in src/Shared/Game/Channels.luau (Channels):
Throw = { Kind = "Intent", Schema = { "Vector3F32", "Vector3F24", "NumberF64" }, RateLimit = 5, Validate = { [3] = { Range = { 0, math.huge } } },},--!strict-- CombatService: server-simulated bolts with lag-compensated spawns.
local ReplicatedStorage = game:GetService("ReplicatedStorage")local ServerScriptService = game:GetService("ServerScriptService")
local Projectiles = require(ServerScriptService.ChloeKernelServer.Projectiles)local Rewind = require(ServerScriptService.ChloeKernelServer.AntiExploit.Rewind)local Shots = require(ReplicatedStorage.Game.Config.Shots)
local CombatService = { Name = "CombatService",}
function CombatService.init(self: any, kernel: any) self.Kernel = kernel -- Weak keys: an ended session's entry is collected with it self.LastThrow = setmetatable({}, { __mode = "k" }) :: { [any]: number } self.Lag = Rewind.attach(kernel) self.Shots = Projectiles.attach(kernel, { Rewind = self.Lag }) self.Shots:define(Shots.Bolt.Id, { Speed = Shots.Bolt.Speed, Gravity = Shots.Bolt.Gravity, MaxLifetime = Shots.Bolt.MaxLifetime, Hitbox = { Radius = 1.6, HalfHeight = 2.4 }, OnHit = function(_owner: any, _result: any, victim: Player?) local Character = victim and victim.Character local Humanoid = Character and Character:FindFirstChildOfClass("Humanoid") if Humanoid then Humanoid:TakeDamage(25) end end, })end
function CombatService.start(self: any) -- Server-side fire rate; the Validate rule above already rejected NaN timestamps self.Kernel.Hooks:on("Intent.Throw", function(context: any) local Last = self.LastThrow[context.Session] if Last and os.clock() - Last < 0.2 then return false end return nil end, 10) self.Net.Throw.on(function(session: any, origin: Vector3, direction: Vector3, timestamp: number) local Ok = self.Shots:fire(session, Shots.Bolt.Id, origin, direction, timestamp) if Ok then self.LastThrow[session] = os.clock() end end)end
function CombatService.stop(self: any) self.Shots:destroy() self.Lag:detach()end
return CombatService--!strict-- ProjectileController: renders bolts and throws one along the camera's look direction.
local Players = game:GetService("Players")local ReplicatedStorage = game:GetService("ReplicatedStorage")
local InputDriver = require(ReplicatedStorage.ChloeKernel.InputDriver)local ProjectileClient = require(ReplicatedStorage.ChloeKernel.Net.ProjectileClient)local Shots = require(ReplicatedStorage.Game.Config.Shots)
local ProjectileController = { Name = "ProjectileController",}
local function makeBolt(): Instance local Bolt = Instance.new("Part") Bolt.Shape = Enum.PartType.Ball Bolt.Size = Vector3.new(0.6, 0.6, 0.6) Bolt.Material = Enum.Material.Neon Bolt.Color = Color3.fromRGB(255, 170, 60) Bolt.Anchored = true Bolt.CanCollide = false Bolt.CanQuery = false Bolt.CanTouch = false local Top = Instance.new("Attachment") Top.Position = Vector3.new(0, 0.2, 0) Top.Parent = Bolt local Bottom = Instance.new("Attachment") Bottom.Position = Vector3.new(0, -0.2, 0) Bottom.Parent = Bolt local Trail = Instance.new("Trail") Trail.Attachment0 = Top Trail.Attachment1 = Bottom Trail.Lifetime = 0.25 Trail.Parent = Bolt return Boltend
function ProjectileController.init(self: any, kernel: any) self.Kernel = kernelend
function ProjectileController.start(self: any) self.Visuals = ProjectileClient.new({ [Shots.Bolt.Id] = { Gravity = Shots.Bolt.Gravity, Create = makeBolt, Linger = 0.25, -- the trail's Lifetime, so it fades out after impact }, }) self.Input = InputDriver.attach(self.Kernel) self.Input:bindAction("Throw", { Keyboard = Enum.KeyCode.F, Gamepad = Enum.KeyCode.ButtonR2, TouchButton = true, Handler = function(state: Enum.UserInputState) local Character = Players.LocalPlayer.Character local Root = Character and Character:FindFirstChild("HumanoidRootPart") local Camera = workspace.CurrentCamera if state == Enum.UserInputState.Begin and Root and Camera then self.Net.Throw.fire((Root :: BasePart).Position, Camera.CFrame.LookVector, workspace:GetServerTimeNow()) end end, })end
function ProjectileController.stop(self: any) if self.Input then self.Input:destroy() end if self.Visuals then self.Visuals:destroy() endend
return ProjectileControllerNPCKit fires the same definitions: a moveset action with Kind = "Projectile" and a DefId fires through this code path with the npc handle as the owner.
API reference: server
Section titled “API reference: server”Projectiles.attach(kernel, options?) options:
| Option | Default | Description |
|---|---|---|
Rewind |
nil |
A Rewind instance; timestamped fire calls are lag-compensation validated |
TickRate |
30 |
Steps per second while shots are live |
Raycast |
workspace rays | (origin, displacement, exclude, bodies?) -> RaycastResult?. Solid world only; bodies = true also takes a nearer queryable part of a Humanoid model (spec seam) |
GetTargets |
Players:GetPlayers() |
Capsule-mode roster, used when Spatial is absent |
Spatial |
nil |
A Spatial.characters index; capsule candidates come from a per-shot radius query |
PacketFactory |
Net.Packet |
Packet constructor (spec seam) |
Clock |
os.clock |
Time source (spec seam) |
SkipLoop |
false |
Never schedule the step; drive _step(dt) yourself |
Definition fields:
| Field | Default | Description |
|---|---|---|
Speed |
required | Muzzle speed in studs/s; also the magnitude of the velocity handed to Path |
Gravity |
0 |
Downward acceleration in studs/s²; ignored when Path is set |
MaxLifetime |
3 |
Seconds until expiry; also bounds the lag-compensation MaxRange |
Path |
nil |
(seed, age, origin, velocity, flightSeconds?) -> Vector3; replaces integration, and the hit sweep follows the curve |
Hitbox |
nil |
{ Radius?, HalfHeight? }; its presence selects capsule mode |
OnHit |
nil |
(ownerSession, result, victimPlayer?); result depends on the mode |
OnExpire |
nil |
(ownerSession, position?) on lifetime expiry, or when Path throws |
| Member | Description |
|---|---|
Projectiles.step(position, velocity, gravity, dt) -> (Vector3, Vector3) |
Pure integration step |
manager:define(id, definition) |
Registers a definition. id is 1 to 255; a second define of the same id errors |
manager:fire(session, id, origin, direction, timestamp?, maxDistance?, speedMultiplier?) -> (boolean, string?) |
Validates and spawns; broadcasts CKPJ_Spawn. session is nil for an owner-less shot |
manager:forEach(visit) |
Calls visit(serial, shotView) for every live shot; redirect and retire are safe inside it |
manager:redirect(serial, options) -> (boolean, string?) |
Restarts a live shot’s flight from { Velocity, Position?, Owner?, Lifetime? }; broadcasts CKPJ_Redirect |
manager:retire(serial, position?) -> boolean |
Removes a live shot without callbacks; broadcasts CKPJ_Expire at position |
manager:destroy() |
Cancels the step and expires every live shot on clients without running callbacks |
API reference: client
Section titled “API reference: client”ProjectileClient.new(definitions, options?), where definitions maps the server’s ids to VisualDefinition:
| Field | Default | Description |
|---|---|---|
Gravity |
0 |
Must match the server definition; ignored when Path is set |
LifetimeSeconds |
10 |
Safety TTL against lost hit and expire packets |
Create |
required | () -> Instance: a BasePart, or a Model moved by PivotTo. Runs only when the pool grows |
OnAcquire |
Pool.clearEffects |
(visual, serial) on every acquire, after the first CFrame write; replaces the default |
Linger |
0 |
Seconds a retired visual stays hidden in place while its trails and emitters run down |
Path |
nil |
The server definition’s curve; evaluated instead of integration, and the rig banks along it |
PathOffset |
nil |
(seed, age, velocity, flightSeconds?) -> Vector3 cosmetic offset per frame; the rig banks along it |
OnRender |
nil |
(visual, seed, age, flightSeconds?) per frame after the visual moves |
CosmeticOrigin |
nil |
(caster, origin) -> Vector3? muzzle override that blends into the true path |
OriginBlendSeconds |
0.15 |
Blend time for the muzzle offset and for redirects |
OnImpact |
nil |
(position, velocity?, serial?) on server-confirmed hits and expiries |
OnRedirect |
nil |
(visual, position, velocity, serial, caster?) after a redirect restarts the shot |
| Option | Default | Description |
|---|---|---|
MaxVisuals |
DeviceBench ProjectileVisuals |
Cap on full visuals; overflow becomes tracers |
MaxTracers |
300 |
Tracer ceiling; threatening shots ignore it |
ThreatRadius |
15 |
Studs from the local character that force a full visual |
| Member | Description |
|---|---|
ProjectileClient.threatens(origin, velocity, position, radius) -> boolean |
Pure threat gate: straight-line closest approach |
client:destroy() |
Disconnects the packets and the stepper, releases every visual and destroys every pool, lingering visuals included |
Bus topics
Section titled “Bus topics”| Topic | Payload | When |
|---|---|---|
Projectile.Hit |
ownerSession, victimPlayer?, defId, position |
A shot landed on a player or on geometry (victimPlayer nil) |
Projectile.Redirected |
ownerSession, serial, defId, position |
redirect restarted a shot; ownerSession is the owner after the redirect |
Projectile.Hit publishes after the wire broadcast and the OnHit spawn. Subscribe on the Bus for cross-cutting reactions (assists, analytics, aggro) instead of repeating them in every OnHit.
Design notes
Section titled “Design notes”- Capsule mode excludes characters from the world ray because that ray’s job is geometry. If limbs could block it, hit priority would depend on poses and accessories instead of the rotation-invariant capsule, which is the same reason Rewind stores positions without orientation.
- Real elapsed time with a clamp: nominal tick time would slow shots whenever ticks defer, and unclamped real time would let one hitch jump a shot through cover.
math.min(elapsed, 0.1)avoids both. - A custom
Raycastreceives exclude tables whose identity signals a change. Build a fresh table when the contents change; the default raycast never sees an in-place mutation. - StreamingEnabled does not affect results: the simulation runs on the server, and the client draws from wire data with pooled local parts.