Lua Scripting

Raycasting in Roblox: the direction IS the length, and streaming can hide the target

A raycast sends an invisible ray from a point in a direction with a defined length. Two things catch beginners out: the direction vector carries the length, and with streaming on the client may not have the part you want to hit.

by Gustavo Cantino8 min readIntermediate

Produced with AI from the sources cited below, and checked by automated verification against those same sources. No person read this text before publication. Editorial Policy

Typographic plate with the Lua Scripting category in large type, the intermediate level below it, and a field of vertical lime green bars on a dark background.

Raycasting is the tool that answers "what is in that direction?" — and it is the basis of shooting, aiming, ground detection, line of sight and click selection. The API is small, and even so it has two details that catch almost everybody in the first week.

The first is that there is no range parameter. The second is that, with streaming on, the ray can pass through a wall that exists. This guide covers the whole API and ends on both.

The problem

The official definition is precise and already contains the trap: raycasting is sending an invisible ray from a Vector3 point in a specific direction with a defined length. Once cast, you can detect whether it hit a BasePart or a Terrain cell.

Notice that "direction" and "length" appear together. The cast is through the WorldRoot's Raycast method — in practice, workspace:Raycast(origin, direction) — and the signature has two geometry parameters, not three. There is nowhere to put the range.

The answer is written down: the direction parameter has to contain the desired length of the ray. And the example is literal: if the direction's magnitude is 10, the ray will also be 10 long.

That changes how you think about the vector. It is not an arrow pointing where to look; it is an arrow that reaches the point where the ray ends. Vector3.new(0, -1, 0) does not go down to the ground — it goes down one stud. To go down a hundred, it is Vector3.new(0, -100, 0), or the unit direction multiplied by the range.

There is a ceiling: the maximum length is fifteen thousand studs.

Step by step

1. Cast the ray. Origin, direction with the length built in. It is the simplest case and it covers ground detection, which is downward direction times the depth you care about.

2. When you have two points, subtract. The direction is the destination minus the origin — and its magnitude is already exactly the distance between them, which makes the ray end at the destination with nothing left over or missing.

The documentation highlights that this is useful when both points move, such as between one character and another. That is the line-of-sight case, the auto-aim case, the "can this enemy see me?" case. And here you do not normalise the vector: normalising would throw away precisely the distance you want.

3. Filter. Raycast accepts an optional RaycastParams, which tells it to include or exclude certain BaseParts, ignore water in Terrain or use a collision group. Four fields:

  • FilterDescendantsInstances — the array of objects whose descendants enter the filtering. Note the word: passing the character model is enough for all its parts to be included.
  • FilterType — Exclude, where every BasePart is considered except the descendants of the array's objects; or Include, where only those descendants are considered.
  • IgnoreWater — decides whether the Water material counts against Terrain.
  • CollisionGroup — the name of the collision group used in the operation.

The choice between Exclude and Include is usually decided by the size of the list. A shot that ignores the shooter is Exclude. A selection that only accepts objects from one folder is Include — and it is safer, because a new object in the scene does not become a target by accident.

4. Read the result. On hitting an eligible target, a RaycastResult object is returned. To test whether there was a hit, you confirm the result is not nil.

Four properties, and all four have real uses:

  • Instance — the BasePart or Terrain cell the ray intercepted.
  • Position — the Vector3 position of the intersection.
  • Distance — the distance between the origin and the intersection point.
  • Normal — the normal vector of the intercepted face.

Normal is the most underused. It is what lets you align a decal, a bullet mark or a dust effect to the surface hit, instead of sticking everything facing the same way.

Distance has a use that also goes unnoticed: it saves you an operation. Since the ray already measured the distance to the impact point, there is no need to compute the magnitude between origin and Position afterwards — and it is exactly that redundant calculation that appears in almost every piece of shooting code. Using Distance directly is shorter and does not risk disagreeing with the result.

And there is Material, which the documentation also exposes on the result: it is the Enum.Material of the part or terrain at the intersection point. It is the cheapest route to footstep sounds by surface, particles by ground type and a different impact mark on metal and on wood — all of that comes out of a property the ray already returned, with no parallel data structure saying what each part is made of.

The artifact

The module below wraps the three operations that appear in almost every game: casting with an explicit range, casting between two points, and finding the ground.

Two of its decisions are worth explaining. The first is the separation between cast and between: one normalises the direction and applies the range, the other does not normalise, because the distance between the points is already the right length. Mixing the two is the origin of half the rays with the wrong size.

The second is the RaycastParams created once and reused. In a firing loop, creating an object per shot is per-frame garbage for no reason at all — and the only field that really changes between calls is the ignore list.

Common mistakes

The champion mistake is the unit vector. Somebody writes direction.Unit to "normalise it properly", and the ray ends up one stud long. It hits nothing, the result is always nil, and there is no error at all — it looks as if raycasting does not work.

The reverse also happens, and it is harder to see: using destination - origin and then multiplying by a range. Then the ray is much longer than the distance between the points and hits things behind the target. Line of sight starts reporting the enemy as blocked by a wall that is on the other side of them.

The rule that settles it: ask whether you already know where the ray ends. If you do (there is a destination), use the subtraction and do not touch the vector. If you do not (it is a direction and a range), normalise and multiply.

The second mistake is streaming, and this one is serious because it produces a game that works in Studio and fails for the player. The documentation warns: with instance streaming on, the client may not have received distant parts, and the raycast may not detect a hit.

The detail that makes it worse is this: the client sees something there. The streaming system generates low-detail Terrain and models for the horizon — and they are purely visual, they are not eligible raycast targets. Meaning the player aims at a mountain that is on their screen and the ray passes through it.

The practical consequence is an architectural rule: a raycast that decides anything runs on the server. The client can cast rays for immediate feedback — bullet tracer, aiming, highlighting the object under the cursor. The server decides whether it hit, because the server has the whole world.

The third mistake is easier to fix: parts that should not be targets and are. A visual effect, a frame, an aura, a trigger hitbox — all of that blocks rays. You can exempt any BasePart from detection by setting its CanQuery to false, and that is cleaner and cheaper than adding the part to the filter list of every raycast in the game.

Next step

With the basics in place, it is worth sweeping the scene for CanQuery candidates. A decorative object that should never be hit, solved at the property, is an object that will never appear in a filter list again — and filter lists are exactly the kind of thing somebody forgets to update when they add a new effect.

After that, the natural topic is collision groups. When a game has several categories of ray — a shot that passes through glass, aiming that does not, a selection that only picks up items — the RaycastParams's CollisionGroup organises that in one go, instead of each call building its own list.

And it is worth testing early with streaming on, if your game uses it. It is the kind of difference that does not appear in Studio with the whole map loaded, and that every player runs into.

Sources

How we correct mistakes

Ready-to-use artifact

Raycast module with reused params, explicit range and the streaming case (ReplicatedStorage)
lua
-- ReplicatedStorage > ModuleScript "Aim"
--
-- Three things this module exists to get right:
--
--   1. THE DIRECTION VECTOR CARRIES THE LENGTH. There is no separate
--      range parameter. Unit direction times range.
--   2. The RaycastParams is REUSED. Creating one per shot in a firing
--      loop is per-frame garbage for no reason.
--   3. With streaming on, a ray cast ON THE CLIENT may not find a
--      distant part. The server decides hits.

local Aim = {}

local MAX_RANGE = 15000 -- the platform ceiling, in studs

-- One params per "kind of query", created once.
local shotParams = RaycastParams.new()
shotParams.FilterType = Enum.RaycastFilterType.Exclude
shotParams.IgnoreWater = true

-- Casts from `origin` IN THE DIRECTION of `direction`, for `range` studs.
-- `direction` does not have to arrive normalised: the function normalises
-- it and applies the range, which is exactly the step people forget.
function Aim.cast(origin, direction, range, ignore)
	if direction.Magnitude == 0 then
		return nil
	end

	range = math.clamp(range or 100, 0, MAX_RANGE)
	shotParams.FilterDescendantsInstances = ignore or {}

	return workspace:Raycast(origin, direction.Unit * range, shotParams)
end

-- Between TWO POINTS: the direction is destination minus origin, and its
-- magnitude is already the distance between them. Do not normalise here.
function Aim.between(origin, destination, ignore)
	local direction = destination - origin
	if direction.Magnitude == 0 or direction.Magnitude > MAX_RANGE then
		return nil
	end

	shotParams.FilterDescendantsInstances = ignore or {}
	return workspace:Raycast(origin, direction, shotParams)
end

-- Line of sight between two characters, ignoring both.
function Aim.canSee(fromCharacter, toCharacter)
	local a = fromCharacter and fromCharacter:FindFirstChild("HumanoidRootPart")
	local b = toCharacter and toCharacter:FindFirstChild("HumanoidRootPart")
	if not a or not b then
		return false
	end

	local r = Aim.between(a.Position, b.Position, { fromCharacter, toCharacter })
	-- nil = nothing in the way = can see.
	-- CAREFUL: on the CLIENT with streaming, nil can also mean
	-- "the wall exists and has not arrived here". This function is only
	-- valid on the server for deciding anything.
	return r == nil
end

-- The ground under a point. Returns position and normal, which is what you
-- use to align a decal, a bullet mark or dust to the surface.
function Aim.groundUnder(position, depth, ignore)
	local r = Aim.cast(position, Vector3.new(0, -1, 0), depth or 100, ignore)
	if not r then
		return nil
	end
	return r.Position, r.Normal, r.Instance, r.Material
end

return Aim
In this article

Keep reading