Defining projectiles
Every cast names a type. The World resolves that name against the definitionsModule — a ModuleScript you pass to createWorld that returns { [string]: ProjectileDefinition }.
-- ReplicatedStorage/Shared/Definitions.lua
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Hindsight = require(ReplicatedStorage.Hindsight)
local Bullet: Hindsight.ProjectileDefinition = {
velocity = 100,
gravity = Vector3.new(0, -8, 0),
lifetime = 5,
power = 50,
angle = 20,
loss = 0,
collaterals = false,
filter = function(caster, victim, character, extra)
if victim == caster or character == caster then return true end
if victim and caster:IsA("Player") and caster.Team == victim.Team then
return true
end
return false
end,
onImpact = function(ctx) end,
onIntersection = function(ctx) ctx.character.Humanoid:TakeDamage(10) end,
onDestroyed = function(ctx) end,
}
return { Bullet = Bullet }
Field reference
See the ProjectileDefinition API page for the exact type.
| Field | Meaning |
|---|---|
velocity | Initial speed in studs/second. Projectile path only. |
gravity | World-space gravity vector applied every step. Projectile path only. |
lifetime | Seconds before the projectile self-destructs. Projectile path only. |
power | Penetration budget. Spent against material hardness when crossing surfaces. |
angle | Maximum impact angle (in degrees) that allows a ricochet. 360 always bounces; 0 never does. |
loss | Speed lost per ricochet. |
range | Maximum scan distance in studs for the hitscan path. Ignored by world:cast. Defaults to 1024. |
collaterals | If true, continues through players instead of stopping on the first intersection. |
raycastFilter | Optional RaycastParams overriding world.defaultRaycastFilter for this type. |
filter | Parallel-context per-character skip predicate (projectile path) / main-thread predicate (hitscan path). Read-only — no instance writes, no Humanoid reads in parallel context. |
onImpact | Called on the main thread when the cast hits world geometry. |
onIntersection | Called on the main thread when the cast intersects a captured character. |
onDestroyed | Called on the main thread when the projectile expires or is fully consumed. Projectile path only — never fired by world:hitscan. |
Why callbacks must live in the definitions module
The simulation runs in an Actor — its own Lua VM. Functions can't cross VM boundaries as data: if you tried to send filter inside a modifier table to world:cast, Roblox would strip it.
Each actor requires definitionsModule in its own VM. So the function value exists in every VM that needs it without ever being marshalled. This is also why definitions are immutable after createWorld — changing the module at runtime won't update the actors.
Per-cast tweaks: ProjectileModifier
You can override any numeric / data field per cast:
world:cast({
caster = player,
type = "Bullet",
origin = origin,
direction = direction,
timestamp = workspace:GetServerTimeNow(),
modifier = {
velocity = 250, -- this shot is faster
power = 100, -- more penetration
extra = { weapon = "rifle", damage = 30 },
},
})
extra is an open { [string]: any } table that travels with the projectile. It surfaces on every callback's ctx.extra, and your filter sees it too — use it for damage tables, weapon ids, or anything else the callback needs to know.
Functions cannot live on modifier — they'd be stripped at the actor boundary. Put alternate behaviour on a different type instead.
See ProjectileModifier.
Filter rules in detail
filter runs inside the simulation actor in parallel context. The rules:
- Read-only on Instances. No
:Destroy(), no property writes, noSetAttribute. - No Humanoid reads.
humanoid.Health,humanoid.RootPart, etc. are not safe in parallel. If you need Humanoid state, do it inonIntersection, which runs aftertask.synchronize(). - Returning
trueskips the candidate. The projectile continues past that character. Returningfalse(ornil) accepts the hit. - The arguments you get:
caster,victim(thePlayer?if the character is owned by one),character, andextra(the modifier's table or{}).
A common shape:
filter = function(caster, victim, character, extra)
-- skip self
if character == caster or victim == caster then return true end
-- skip teammates
if victim and caster:IsA("Player") and caster.Team ~= nil and caster.Team == victim.Team then
return true
end
-- skip on a per-shot whitelist (rocket marker, friendly-fire override, ...)
if extra.ignore and extra.ignore[character] then return true end
return false
end
Penetration and ricochet recap
Both behaviours fall out of three numbers on the definition:
poweris the total penetration budget. When the projectile crosses a medium, it spendsthickness * averageHardness, where hardness is per-material (seePenetrationConfig.surfaceHardness). Whenpowerdrops below the cost of the next medium, the projectile dies on the surface.anglesets the maximum impact angle (in degrees, measured between the surface normal and the ray) that allows a ricochet. Glancing hits bounce; head-on hits don't.angle = 360always bounces;angle = 0never does.lossis how much speed the projectile loses per ricochet.
Ricochet additionally requires the surface to be hard enough — see PenetrationConfig.ricochetHardness. Wood doesn't bounce bullets; concrete does.
Hitscan ammo
The same definitions module feeds both cast paths. A definition aimed at world:hitscan sets range, leaves velocity / gravity / lifetime at any harmless values (they are ignored), and omits onDestroyed (the hitscan path never fires it):
local Laser: Hindsight.ProjectileDefinition = {
velocity = 0, -- ignored by world:hitscan
gravity = Vector3.zero, -- ignored
lifetime = 0, -- ignored
range = 500, -- max scan distance in studs
power = 50, -- penetration budget — same semantics as projectiles
angle = 0, -- no ricochet
loss = 0,
collaterals = false,
filter = bulletFilter,
onImpact = onImpact,
onIntersection = onIntersection,
}
Then on the server:
world:hitscan({
caster = player,
type = "Laser",
origin = origin,
direction = direction,
timestamp = rewindTime,
})
world:hitscan resolves the ray on the main thread in a single call — no actor dispatch, no per-frame stepping. Server-side, it queries the same rollback store as the projectile path; client-side, character intersections are skipped (the client World has no rollback) and only world-geometry hits fire onImpact. Penetration and ricochet behave identically to the projectile path.