Fix a Roblox Rig Whose Face Points the Wrong Way
Your imported rig walks sideways because the geometry faces one way and the root's -Z faces another. Find which of the three layers holds the offset — root, Motor6D joints, or keyframes — fix it there, and save the corrected rig as the source.
AI-assisted draft, reviewed, tested and edited by a human before publishing. See our Editorial Policy. · Reviewed by Gustavo Cantino, 9/4/26 Editorial Policy
Your model's face points one way and the fix is not rotating it in Studio until it looks right. In Roblox the front of any CFrame is the -Z axis: what the HumanoidRootPart calls forward is its LookVector, and LookVector always points down -Z. Your face orientation problem is that the imported geometry looks one way while the root's -Z looks another, and that difference got baked into one of three layers — the root, the Motor6D joints, or the animation keyframes. Find out which layer, fix it there, and save the corrected rig as the source instead of patching every copy.
Where the angle offset costs you
While the root and the geometry disagree, everything in your game that reads direction reads it wrong. Humanoid:MoveTo rotates the root and lets AutoRotate align the character, so if the geometry is 90 degrees off, the NPC strafes the entire path sideways. The aim raycast that starts at HRP.CFrame.LookVector fires into a wall while the model stares straight at the player. The BillboardGui you offset with CFrame.new(0, 0, -3) spawns behind the head. Animations with root motion push the character along the wrong axis. None of that is a scripting bug — it is one deviation propagating through five systems, which is why fixing it in one system never sticks.
The second cost is repairing per instance. Select model, rotate by hand, hit Play, watch it snap back, reposition again: 3 to 5 minutes per model when you are paying attention. Across 40 NPCs that is 2 hours to 3 hours 20 minutes, and the whole pass evaporates the next time the artist re-exports the FBX, because the offset lives in the source file and not in your scene.
There is one symptom that confuses people and deserves a name. You rotate the parts in edit mode, save, hit Play, and the character goes crooked again. That is not Studio sabotaging you. The Humanoid re-evaluates the Motor6D chain on the first frame and puts every part back exactly where its C0 says it belongs. Rotating a BasePart inside a jointed rig is scribbling over a value that is about to be overwritten.
Before you touch anything, take the measurement in the Command Bar:
local npc = workspace.Zombie
print(npc:GetPivot().LookVector)
print(npc.PrimaryPart and npc.PrimaryPart.Name or "NO PrimaryPart")If PrimaryPart comes back empty, or points at a leg, you already found half the problem: the model's pivot is one thing and the rig's root is another, and GetPivot has been lying to every system that called it.
Find which of the three layers is wrong
1. Materialize the -Z. Direction is hard to judge by eye. With the model in the Workspace, paste this into the Command Bar and look at where the cube spawns:
local m = workspace.Zombie
local p = Instance.new("Part")
p.Size = Vector3.new(1, 1, 1)
p.Anchored, p.CanCollide = true, false
p.Color = Color3.fromRGB(255, 0, 0)
p.CFrame = m:GetPivot() * CFrame.new(0, 0, -4)
p.Parent = workspaceCube in front of the face: layer 1 is clean. Cube at the ear: 90 degrees off. Cube behind the head: 180. Write the number down, it becomes the parameter for the fix.
2. Layer 1 — root versus geometry. This is the common case with imported models, and the correct fix has two halves. Rotate the root around Y, and subtract that same rotation from the C0 of every Motor6D whose Part0 is the root. World space stays pixel-identical, but the root's -Z now points where the face looks. Do only the first half and the whole rig swings 90 degrees. Do only the second half and the body twists off its own root. It is a two-step operation that hands get wrong and a script gets right — which is precisely what the module in the next section does.
3. Layer 2 — the Motor6D joints. Symptom: the test cube spawns correctly in front, but the character bends the moment an Animation plays — arm backwards, head sideways, leg inverted. Each Motor6D's C0 is the joint's position and rotation in Part0's space, and an animation stores rotations relative to that joint, not positions in the world. If you animated on a rig whose shoulder C0 carried an extra 90 degrees and you play that track on a rig with the stock C0, the arm comes out twisted and no amount of root rotation fixes it. Compare the two rigs directly:
local a = game.ServerStorage.AnimatedRig.UpperTorso.LeftShoulder
local b = workspace.Zombie.UpperTorso.LeftShoulder
print(a.C0)
print(b.C0)If the numbers diverge, the cheap way out is to reimport the animation onto the rig it was authored on. Rewriting C0 at runtime to match one animation is a debt you pay again on every new track.
4. Layer 3 — the keyframes. The rig is aligned in T-pose, then the first frame of the animation spins it 90 degrees and it stays there until the track ends. The offset is baked into the animation file, on the root track. Two options: open the Animation Editor and fix the track keyframe by keyframe, or go back to Blender, correct the orientation and re-export. A 2-second animation at 30 fps is 60 frames per track, and there is rarely only one track, so re-exporting is the predictable path. And remember that republishing produces a new AnimationId. The old asset stays live and your script keeps playing the old one until you swap the ID by hand. That is the step everybody forgets right before swearing the fix did not work.
5. Blender, where the offset is born. If you rotated the object in Object Mode and never applied it, Blender keeps that rotation as a pending transform, and the FBX exporter resolves it differently depending on the options you picked. Press Ctrl+A and apply Rotation and Scale before exporting, or rotate the mesh in Edit Mode where nothing is left pending. Blender is Z-up and Roblox is Y-up: the exporter's Forward and Up settings handle that vertical axis conversion, but none of them can guess which way your character was modeled. Run the axis test once — stick a cone on the nose, export, import, look — and save the working combination as a preset next to the .blend file, because that is information you will want six months from now with a different artist on the file.
6. Save the source, not the copy. Once the rig in the Workspace is correct, publish it again as an asset or save the .rbxm and replace the model in ServerStorage. If the fix only lives on the instance sitting in your scene, the next Clone brings the problem right back.
The module that measures the offset and fixes the root
The artifact below is a ModuleScript that does the two things hands get wrong: measure the angle, and apply the correction while compensating the Motor6D C0 values so nothing moves in world space. It reads the rig's visual front from an Attachment — either one you add named FrontMarker, pointing out of the face, or the FaceFrontAttachment that already exists on the Head of any R15 rig. From there it computes the signed Y angle between the root's -Z and the real front, rotates the root by that angle, subtracts it from the C0 of every joint leaving the root, and re-seats every part by walking the joint chain, because in edit mode nothing re-seats itself. Only the Humanoid does that, and only at runtime.
Drop the file in ServerScriptService, name it RigOrientation, and run this in the Command Bar with the rig already in the Workspace:
local RO = require(game.ServerScriptService.RigOrientation)
print(RO.audit(workspace.Zombie))
print(RO.fix(workspace.Zombie, true))The second argument snaps the measured offset to the nearest multiple of 90. Use it when the marker is not perfectly centered and the reading comes back as 88.4 degrees. Leave it out and the correction is exact to the hundredth of a degree. audit changes nothing, returns the angle plus a printable line, and is safe to run across a whole folder before you decide what to repair. After running fix, redo the red cube test: it has to spawn in front of the face. If the cube lands correctly but the rig still twists during playback, you are on layer 2, not layer 1.
The expensive mistake: masking the offset at runtime
The patch that looks like a solution and bills you later is this one:
-- WRONG: an offset pasted on top of the error
npc.PrimaryPart.CFrame = target.CFrame * CFrame.Angles(0, math.rad(90), 0)It passes your test and breaks in four places. It breaks when a Humanoid with AutoRotate enabled rewrites the root CFrame on the same frame. It breaks when the character sits in a Seat, which imposes its own alignment. It breaks when the animation carries root motion. And above all, it fixes nothing that reads direction: the aim, the NPC's field of view, the ProximityPrompt facing, the billboard — they all keep reading the wrong LookVector, because the LookVector is still wrong. You rotated the appearance, not the data.
There is a machine cost too. Once the patch turns into a per-frame correction, 200 NPCs at 60 Hz is 12,000 PivotTo calls per second, every one of them replicating from server to clients, purely to disguise a value that a single fix on the asset would have made correct.
The way out is the reverse order. Delete the offset from the code first, let the model go visibly crooked again, and only then fix the right layer. While the patch is still running you cannot measure whether the real fix worked, and you will end up stacking a 90-degree correction on top of a 90-degree correction and calling it 180 degrees of bad luck.
What to measure next
Loop audit over every rig in ServerStorage and count how many come back with more than 1 degree of offset. That count is your ruler: it has to reach zero and stay at zero. A warn at server start listing the misaligned rigs costs a few milliseconds on boot and catches the next crooked FBX the day it lands, not three weeks later when QA reports that an NPC walks sideways.
Then put the FrontMarker attachment on the project's base rig, the one every other character is derived from. With the attachment in place, any new model is auditable in one command and nobody has to eyeball a character and guess the angle.
Finally, do the arithmetic that decides the rest of the work: list how many AnimationId values were exported from the crooked rig. Each one needs a cycle of re-export, reimport, publish, and ID swap in the script. Three animations and you finish the same afternoon. Thirty and you should fix layer 1 on the base rig right now, then schedule the animation redo as its own task with the old and new ID list written down before you start — because losing track of one ID mid-swap is the easiest way to reintroduce the same bug under a different name.
Ready-to-use artifact
--!strict
-- RigOrientation
-- Measures the angle between the root's -Z (LookVector) and the rig's real visual
-- front, then rotates the root and compensates every Motor6D C0 so nothing moves
-- in world space. Run it from the Command Bar with the rig in the Workspace:
--
-- local RO = require(game.ServerScriptService.RigOrientation)
-- print(RO.audit(workspace.Zombie))
-- print(RO.fix(workspace.Zombie, true))
local RigOrientation = {}
-- The visual front is read from an Attachment. Add one named "FrontMarker"
-- pointing out of the face, or rely on the FaceFrontAttachment that ships on the
-- Head of any R15 rig.
local FRONT_NAMES = { "FrontMarker", "FaceFrontAttachment" }
local function getRoot(model: Model): BasePart?
if model.PrimaryPart then
return model.PrimaryPart
end
local hrp = model:FindFirstChild("HumanoidRootPart")
if hrp and hrp:IsA("BasePart") then
return hrp
end
return nil
end
local function getFront(model: Model): Attachment?
for _, name in FRONT_NAMES do
local found = model:FindFirstChild(name, true)
if found and found:IsA("Attachment") then
return found
end
end
return nil
end
local function getJoints(model: Model): {Motor6D}
local joints: {Motor6D} = {}
for _, d in model:GetDescendants() do
if d:IsA("Motor6D") and d.Part0 and d.Part1 then
table.insert(joints, d)
end
end
return joints
end
-- Signed Y angle, in radians, from the root's -Z to the marker direction.
local function measure(root: BasePart, front: Attachment): number
local p = root.CFrame:PointToObjectSpace(front.WorldPosition)
if math.abs(p.X) < 1e-4 and math.abs(p.Z) < 1e-4 then
return 0 -- marker sits on the root axis: nothing to measure
end
return math.atan2(-p.X, -p.Z)
end
-- Re-seats every part from the joint chain. In edit mode Studio will not do this
-- for you; only the Humanoid does, and only on the first runtime frame.
local function reseat(root: BasePart, joints: {Motor6D})
local placed: {[BasePart]: boolean} = {}
placed[root] = true
local moved = true
while moved do
moved = false
for _, j in joints do
local p0, p1 = j.Part0, j.Part1
if p0 and p1 and placed[p0] and not placed[p1] then
p1.CFrame = p0.CFrame * j.C0 * j.C1:Inverse()
placed[p1] = true
moved = true
end
end
end
end
-- Read-only. Returns the offset in degrees and a printable line.
function RigOrientation.audit(model: Model): (number, string)
local root = getRoot(model)
if not root then
return 0, model.Name .. ": no PrimaryPart and no HumanoidRootPart"
end
local front = getFront(model)
if not front then
return 0, model.Name .. ": no FrontMarker / FaceFrontAttachment"
end
local deg = math.deg(measure(root, front))
local status = if math.abs(deg) <= 1 then "aligned" else "OFF"
return deg, string.format("%s: %.2f deg %s", model.Name, deg, status)
end
-- Rotates the root so its -Z matches the marker, compensating every C0 that
-- leaves the root. snapTo90 rounds the reading to the nearest multiple of 90.
function RigOrientation.fix(model: Model, snapTo90: boolean?): (number, string)
local root = getRoot(model)
if not root then
return 0, model.Name .. ": no root, nothing to fix"
end
local front = getFront(model)
if not front then
return 0, model.Name .. ": add a FrontMarker attachment first"
end
local theta = measure(root, front)
if snapTo90 then
theta = math.rad(math.floor(math.deg(theta) / 90 + 0.5) * 90)
end
if math.abs(math.deg(theta)) <= 0.01 then
return 0, model.Name .. ": already aligned, no change"
end
local spin = CFrame.Angles(0, theta, 0)
local inverse = spin:Inverse()
local joints = getJoints(model)
-- C0' = spin:Inverse() * C0 keeps every child exactly where it already is.
for _, j in joints do
if j.Part0 == root then
j.C0 = inverse * j.C0
end
end
root.CFrame = root.CFrame * spin
reseat(root, joints)
if model.PrimaryPart ~= root then
model.PrimaryPart = root -- so GetPivot stops lying to everything else
end
local applied = math.deg(theta)
return applied, string.format("%s: root rotated %.2f deg, %d joints compensated", model.Name, applied, #joints)
end
return RigOrientation