‹ Back to Tutorials Intermediate

Roblox Luau Tutorial: Understanding CFrame

Learn how CFrame combines position and rotation into a single transform, why multiplication order actually matters when you combine two CFrames, and how to use LookVector, relative offsets, and CFrame.lookAt to move and orient objects correctly instead of by trial and error.


1. What is a CFrame? (Position + Rotation, Together)

A Vector3 only ever stores a position - three numbers, nothing about which way something is facing. A CFrame ("Coordinate Frame") stores both: a position and a full 3D rotation, packed into one value.

Every Part's real transform in the world is a CFrame. Part.Position isn't a separate thing - it's just a convenient shortcut that reads the position out of Part.CFrame:

local part = script.Parent

print(part.Position)       -- e.g. 0, 5, 0
print(part.CFrame.Position) -- exactly the same value

You'll also see Part.Orientation, which stores rotation as three human-readable degree angles. It's easier to read in the Properties panel, but CFrame is what the engine actually uses internally, and it's what you'll reach for in code any time position and rotation need to change together.


2. Setting Up the Scene in Roblox Studio

  1. Insert two Parts:
    • Press Ctrl + I (Windows) or Cmd + I (Mac) to open the Insert Object menu, twice.
    • Rename one to MainPart and the other to Target. Move Target a short distance away from MainPart in the viewport (position doesn't matter, just make them not overlap).
  2. Insert a Script inside MainPart:
    • Select MainPart, press Ctrl + I / Cmd + I again, and insert a Script.

3. Creating CFrames

local identity = CFrame.new()                    -- no position, no rotation - the "blank" CFrame
local positioned = CFrame.new(Vector3.new(0, 5, 0)) -- at (0,5,0), facing the default direction

-- Positioned AND facing toward another point in one step
local facing = CFrame.lookAt(Vector3.new(0, 5, 0), Vector3.new(10, 5, 0))

-- A pure rotation, no position change - 90 degrees around the Y axis
local spin = CFrame.Angles(0, math.rad(90), 0)

Important: CFrame.Angles() takes radians, not degrees. This is the single most common CFrame mistake - always wrap a plain degree value in math.rad() (or convert the other way with math.deg() when reading angles back out).


4. CFrame vs Position - Why Rotating Needs CFrame

Setting Position alone never touches rotation. Setting CFrame replaces the whole transform - position and rotation both - so to rotate a part in place, you rebuild its CFrame from its current position plus a new rotation:

local part = script.Parent

-- Rotate 45 degrees around Y, keeping the part exactly where it already is
part.CFrame = CFrame.new(part.Position) * CFrame.Angles(0, math.rad(45), 0)

That CFrame.new(part.Position) * CFrame.Angles(...) pattern - "start at this position, then apply this rotation" - is worth memorizing, because it's exactly how CFrame multiplication works in general, which is the next (and most important) section.


5. Combining CFrames - Multiplication and Why Order Matters

* composes two CFrames into one. It is not commutative - A * B and B * A give different results - because A * B means "starting from A's position and rotation, apply B relative to that."

This matters most when you offset a part relative to its own facing direction, instead of relative to the world:

local part = script.Parent

-- WORLD-SPACE move: always moves toward -Z on the world's axes,
-- no matter which way the part is facing.
part.Position = part.Position + Vector3.new(0, 0, -5)

-- LOCAL-SPACE move: moves 5 studs "forward" relative to the part's
-- OWN rotation - if the part is facing sideways, so is this move.
part.CFrame = part.CFrame * CFrame.new(0, 0, -5)

Both lines move the part 5 studs in the -Z direction - but the first one always means world -Z, while the second means the part's own -Z, wherever it's currently facing. Rotate the part 90° and run each again to feel the difference: the world-space version keeps sliding the same world direction; the local-space version starts sliding sideways with it.

Rule of thumb: put the "current transform" on the left and the "relative offset" on the right - part.CFrame * CFrame.new(offset) - any time you mean "relative to me," and use plain Vector3 math any time you mean "relative to the world."


6. LookVector, RightVector, and UpVector

Every CFrame exposes its own local axes as ready-made world-space direction vectors:

Property Local axis Meaning
LookVector -Z The direction the CFrame is "facing"
RightVector +X The CFrame's own right
UpVector +Y The CFrame's own up
local part = script.Parent
local speed = 4

-- Step forward relative to however the part is currently facing.
-- (+) offsets a CFrame's POSITION in world space while keeping its
-- rotation untouched - different from * CFrame.new(...), which offsets
-- in the part's LOCAL space instead. Both are useful; know which one you mean.
part.CFrame = part.CFrame + part.CFrame.LookVector * speed

This is the standard way to move something "forward" for whatever direction it happens to be facing - an NPC, a car, a camera rig - without caring what that direction actually is in world terms.


7. Making One Part Face Another - CFrame.lookAt

Pointing something at a target is common enough that Roblox gives it a dedicated constructor:

local mainPart = script.Parent
local target = workspace:WaitForChild("Target")

mainPart.CFrame = CFrame.lookAt(mainPart.Position, target.Position)

This keeps mainPart exactly where it is and only changes which way it's facing - handy for turrets, NPC heads, or a camera that needs to track something.


8. World Space vs Object Space

Sometimes you need to flip between "where is this in the world" and "where is this relative to a specific part." :PointToObjectSpace() and :PointToWorldSpace() do exactly that conversion:

local mainPart = script.Parent
local target = workspace:WaitForChild("Target")

-- Where is `target` relative to mainPart's own facing direction?
local relative = mainPart.CFrame:PointToObjectSpace(target.Position)

if relative.Z < 0 then
    print("Target is in front of MainPart") -- local -Z is "forward"
else
    print("Target is behind MainPart")
end

Because local -Z is "forward" (Section 1), a negative local Z after this conversion means the target lies somewhere in front of mainPart, regardless of which way mainPart happens to be rotated in the world. :PointToWorldSpace() does the reverse conversion - taking a point defined relative to a CFrame and returning its real world position.


9. Smooth Rotation with CFrame:Lerp

Lerping Position and Orientation separately can look wrong when both change together. CFrame:Lerp() interpolates position and rotation as one correct, smooth blend:

local part = script.Parent
local target = workspace:WaitForChild("Target")

local startCFrame = part.CFrame
local goalCFrame = CFrame.lookAt(part.Position, target.Position)

for i = 0, 1, 0.05 do
    part.CFrame = startCFrame:Lerp(goalCFrame, i)
    task.wait(0.03)
end

This smoothly turns part to face target instead of snapping to face it instantly.


10. Putting It Together - Turning to Face and Walking Toward a Target

Combining Sections 6, 7, and 9: smoothly turn toward a target, then step forward along the direction actually being faced, every frame, until close enough to stop.

local part = script.Parent
local target = workspace:WaitForChild("Target")
local turnSpeed = 0.1  -- 0-1, how much of the way to turn each frame
local moveSpeed = 6    -- studs per second
local stopDistance = 2

game:GetService("RunService").Heartbeat:Connect(function(deltaTime)
    local toTarget = target.Position - part.Position
    local distance = toTarget.Magnitude

    if distance <= stopDistance then
        return
    end

    -- Turn a fraction of the way toward facing the target each frame
    local goalCFrame = CFrame.lookAt(part.Position, target.Position)
    part.CFrame = part.CFrame:Lerp(goalCFrame, turnSpeed)

    -- Walk forward along whichever way that turn left it facing
    part.CFrame = part.CFrame + part.CFrame.LookVector * moveSpeed * deltaTime
end)

11. Try It Yourself

The CFrame Viewer tool lets you type expressions like the ones above and see them drawn live in 3D - drag any number to watch the gizmo move, and click an expression to see its actual 4×4 matrix, color-coded to match the Right/Up/Back arrows in the viewport. It's the fastest way to build a real feel for composition order and local vs. world space before you're debugging it in a live game.


12. Verification / Testing

  1. Make sure Target (Section 2) is positioned a good distance from MainPart, off to one side rather than directly ahead.
  2. Paste the Section 10 script into the Script inside MainPart.
  3. Click Run in Roblox Studio.
  4. Watch the viewport:
    • MainPart should smoothly rotate to face Target while walking toward it, then stop a couple of studs away - not snap-turn, and not slide sideways instead of turning.
  5. Comment out the part.CFrame:Lerp(...) turning line and re-run: MainPart should now slide toward Target using whatever direction it started facing, proving the LookVector step really does depend on current rotation.