Roblox Animation

Fix a rig whose face points the wrong way in Roblox Studio

Your imported rig's face looks one way while the root's -Z looks another, so every system that reads direction — MoveTo, aim raycasts, billboards — reads it wrong. Find out which of the three layers stored the offset, fix it there with a script that rotates the root and compensates the Motor6D C0, and save the corrected rig as the source.

by Gustavo Cantino13 min readAdvanced

AI-assisted draft, reviewed, tested and edited by a human before publishing. See our Editorial Policy. · Reviewed by Gustavo Cantino, 9/15/26 Editorial Policy

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

Your model's face is pointing the wrong 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 front is the LookVector, and it points at -Z, always. Your problem is that the imported geometry looks one way and the root's -Z looks another — and that difference got baked into one of three layers: into the root, into the Motor6D joints, or into the animation keyframes. Find out which one, fix it there, and save the corrected rig as the source instead of patching every copy.

Where that angle difference gets expensive

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: if the geometry is 90° off, the NPC walks sideways for the entire path. The aim raycast that starts at HRP.CFrame.LookVector points at the wall while the model stares straight at the player. The billboard you placed at CFrame.new(0, 0, -3) spawns on the back of the neck. Animations with root motion push the character along the wrong axis. None of that is a script bug — it is the same offset propagating through five systems.

The second cost is fixing it per instance. Select the model, rotate it by hand, hit Play, watch it snap back, reposition: that is 3 to 5 minutes per model, done carefully. Across 40 NPCs that lands between 2h and 3h20 — and the whole job evaporates the next time the artist re-exports the FBX, because the offset lives in the source file, not in your scene.

There is one symptom that confuses a lot of people and is worth naming: you rotate the model's parts in edit mode, save, hit Play, and the character goes crooked again. That is not Studio sabotaging you. It is the Humanoid re-evaluating the Motor6D chain on the first frame and putting every part back where C0 says it goes. Rotating BasePart instances in a rig with joints is scribbling on top of data that is about to be overwritten.

Before touching anything, take the measurement in the Command Bar:

Command Bar in Studio
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.

Find out which of the three layers is wrong

1. Materialize the -Z. Direction is hard to judge by eye. Paste this into the Command Bar with the model in the Workspace and look at where the cube spawns:

Command Bar in Studio
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 = workspace

Cube in front of the face: layer 1 is fine. Cube at the ear: 90° off. Cube on the back of the neck: 180°. Write the value down, it becomes the parameter of the fix.

2. Layer 1 — root versus geometry. This is the most common case in an imported model. The correct fix is to rotate the root around Y and compensate the same angle, inverted, in the C0 of every Motor6D that leaves it. The world stays exactly the same, but the root's -Z now points where the face looks. It is a two-step operation that hands get wrong and scripts get right — which is exactly what the module in the next section does.

3. Layer 2 — the joint Motor6Ds. Symptom: the test cube spawns nicely in front, but the character bends the moment the Animation plays — arm backwards, head sideways, leg inverted. The C0 of each Motor6D is the position and rotation of the joint in Part0 space, and an animation stores rotations relative to that joint, not world positions. If you animated on a rig whose shoulder C0 had an extra 90° and you play it on a rig with the default C0, the arm comes out crooked and no rotation on the root fixes it. Compare the two rigs:

Command Bar in Studio
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 re-importing the animation using the same rig it was made on, not rewriting C0 at runtime.

4. Layer 3 — the keyframes. Rig aligned in T-pose, but on the first frame of the animation it turns 90° and stays that way to the end. Then the offset is stored inside the animation file, in the root track. You have two routes: open the Animation Editor and fix the track keyframe by keyframe, or go back to Blender, fix the orientation and re-export. In a 2-second animation at 30 fps that is 60 frames per track — the predictable route is to re-export. And remember: republishing generates a new AnimationId. The old one stays live and your script keeps playing the old one until you swap the ID by hand. That is the step everybody forgets, and then swears the fix did not work.

5. Blender, which is 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. Hit Ctrl+A and apply Rotation & Scale before exporting, or rotate the mesh in Edit Mode, where no transform is left over. Blender is Z-up and Roblox is Y-up: the exporter's Forward and Up options handle that vertical-axis conversion, but none of them can guess which way your character was modeled. Run the axis test once — a cone stuck to the nose, export, import, look — and save the result as a preset next to the .blend, because that is information you will want six months from now.

6. Save the source, not the copy. Once the rig in the Workspace is corrected, publish it again as an asset or save the .rbxm and replace the model in ServerStorage. If the fix only lives in the instance sitting in your scene, the next Clone() brings the problem right back.

The module that detects 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 joints. It finds the visual front from the position of an Attachment — it accepts one named FrontMarker, which you create, or FaceFrontAttachment, which already exists on the Head of any R15 rig. From that it computes the angle between the root's -Z and the real front, rotates the root by the negative of that angle, and re-seats the parts from the joint chain, because in edit mode Studio does not re-seat anything on its own.

Drop the file in ServerScriptService under the name RigOrientation and run this in the Command Bar, with the rig already in the Workspace:

Command Bar in Studio
local RO = require(game.ServerScriptService.RigOrientation)
print(RO.audit(workspace.Zombie))
print(RO.fix(workspace.Zombie, true))

The true in the second parameter rounds the offset to the nearest multiple of 90 — use it when the marker is not perfectly centered and the measurement comes out at 88.4°. Without it, the correction is exact, down to a tenth of a degree. audit changes nothing and is there to sweep an entire folder before you decide what to fix. After running fix, redo the red cube test: it has to spawn in front of the face.

The most expensive mistake: masking the offset at runtime

The patch that looks like a fix and charges you later is this one:

Script in ServerScriptService
-- WRONG: an offset pasted on top of the error
npc.PrimaryPart.CFrame = target.CFrame * CFrame.Angles(0, math.rad(90), 0)

It works in testing and breaks in four places. It breaks when the Humanoid with AutoRotate on rewrites the root's CFrame in the same frame. It breaks when the character sits in a Seat, which imposes its own alignment. It breaks when the animation has root motion. And above all, it fixes nothing that reads direction: the aim, the NPC's field of view, the prompt and the billboard keep reading the wrong LookVector, because the LookVector is still wrong — you only rotated the appearance.

There is a machine cost too. If the patch turned into a per-frame correction, with 200 NPCs at 60 Hz you are making 12,000 PivotTo calls per second, all replicating from the server to the clients, just to disguise data that would be correct with a single fix on the asset.

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 it in the right layer. While the patch is on, you cannot measure whether the real fix worked.

What to measure next

Put audit in a loop over every rig in ServerStorage and count how many come back with an offset larger than 1°. That number is your ruler: it has to reach zero and stay at zero. A warn at server startup 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 the NPC walking sideways.

After that, put the FrontMarker on the project's base rig, the one every other rig is derived from. With the attachment there, any new model is auditable with a single command, without anyone having to stare at the character and guess the angle.

Finally, run the math 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, re-import, publish and swap the ID in the script. If it is three animations, you clear it in one afternoon. If it is thirty, fix layer 1 on the base rig now and schedule redoing the animations as its own task, with the list of old and new IDs written down before you start — because getting one ID wrong in the middle of the swap is the easiest way to reintroduce the same bug under a different name.

Ready-to-use artifact

ModuleScript named RigOrientation in ServerScriptService
lua
--!strict
-- RigOrientation
-- ModuleScript in ServerScriptService.
-- Roblox convention: the front of any CFrame is the -Z axis (CFrame.LookVector).
--
-- Use it from the Studio Command Bar, in edit mode, with the rig in the Workspace:
--   local RO = require(game.ServerScriptService.RigOrientation)
--   print(RO.audit(workspace.Zombie))
--   print(RO.fix(workspace.Zombie, true))  -- true = snap to the nearest multiple of 90

local RigOrientation = {}

-- Attachments accepted as "this is the visual front of the model".
-- FaceFrontAttachment already exists on the Head of any R15 rig.
local FRONT_MARKERS: { string } = { "FrontMarker", "FaceFrontAttachment" }

local function getRoot(model: Model): BasePart
	local hrp = model:FindFirstChild("HumanoidRootPart")
	if hrp and hrp:IsA("BasePart") then
		return hrp
	end
	local primary = model.PrimaryPart
	if primary then
		return primary
	end
	error("[RigOrientation] " .. model:GetFullName() .. " has no HumanoidRootPart and no PrimaryPart")
end

local function findFrontMarker(model: Model): Attachment?
	for _, name in FRONT_MARKERS do
		local found = model:FindFirstChild(name, true)
		if found and found:IsA("Attachment") then
			return found
		end
	end
	return nil
end

-- Yaw error, in degrees, between the root's -Z and the visual front marked by the attachment.
-- Positive = the real front sits to the right of the LookVector, seen from above.
function RigOrientation.detectYawError(model: Model, snapTo90: boolean?): number
	local root = getRoot(model)
	local marker = findFrontMarker(model)
	if not marker then
		error("[RigOrientation] no FrontMarker or FaceFrontAttachment on " .. model.Name)
	end

	local localPos = root.CFrame:PointToObjectSpace(marker.WorldPosition)
	local flat = Vector3.new(localPos.X, 0, localPos.Z)
	if flat.Magnitude < 0.05 then
		error("[RigOrientation] marker sits on the root axis; move it in front of the geometry")
	end

	local yaw = math.deg(math.atan2(flat.X, -flat.Z))
	if snapTo90 then
		yaw = math.round(yaw / 90) * 90
	end
	return yaw
end

-- Rotates the root around Y WITHOUT moving the geometry: compensates on the Motor6Ds leaving it.
-- Returns how many joints were compensated.
function RigOrientation.applyRootYaw(model: Model, degrees: number): number
	local root = getRoot(model)
	local yaw = CFrame.Angles(0, math.rad(degrees), 0)
	local touched = 0

	root.CFrame = root.CFrame * yaw

	for _, d in model:GetDescendants() do
		if d:IsA("Motor6D") then
			if d.Part0 == root then
				d.C0 = yaw:Inverse() * d.C0
				touched += 1
			elseif d.Part1 == root then
				warn("[RigOrientation] " .. d:GetFullName() .. " uses the root as Part1; check it by hand")
			end
		end
	end

	if touched == 0 then
		warn("[RigOrientation] no Motor6D leaves the root; for a loose part, adjust Model.WorldPivot instead")
	end
	return touched
end

-- In edit mode Studio does not reseat the parts: this walks the Motor6D chain
-- and puts each part back where C0/C1 say it belongs.
function RigOrientation.reseat(model: Model): ()
	local root = getRoot(model)
	local motors: { Motor6D } = {}
	for _, d in model:GetDescendants() do
		if d:IsA("Motor6D") then
			table.insert(motors, d)
		end
	end

	local placed: { [BasePart]: boolean } = {}
	placed[root] = true

	local moved = true
	while moved do
		moved = false
		for _, m in motors do
			local p0, p1 = m.Part0, m.Part1
			if p0 and p1 and placed[p0] and not placed[p1] then
				p1.CFrame = p0.CFrame * m.C0 * m.C1:Inverse()
				placed[p1] = true
				moved = true
			end
		end
	end
end

export type Report = {
	root: string,
	markerFound: boolean,
	yawError: number,
	motorCount: number,
	hasHumanoid: boolean,
}

-- Read only: changes nothing. Good for sweeping a whole folder.
function RigOrientation.audit(model: Model): Report
	local root = getRoot(model)
	local motorCount = 0
	for _, d in model:GetDescendants() do
		if d:IsA("Motor6D") then
			motorCount += 1
		end
	end

	local yaw = 0
	local ok, result = pcall(function()
		return RigOrientation.detectYawError(model, false)
	end)
	if ok and typeof(result) == "number" then
		yaw = result
	end

	return {
		root = root.Name,
		markerFound = ok,
		yawError = yaw,
		motorCount = motorCount,
		hasHumanoid = model:FindFirstChildOfClass("Humanoid") ~= nil,
	}
end

-- Measures, fixes and reseats. Returns the angle applied (0 = it was already aligned).
function RigOrientation.fix(model: Model, snapTo90: boolean?): number
	local yaw = RigOrientation.detectYawError(model, snapTo90)
	if math.abs(yaw) < 0.5 then
		return 0
	end
	RigOrientation.applyRootYaw(model, -yaw)
	RigOrientation.reseat(model)
	return -yaw
end

return RigOrientation
rig-export.json saved in the same folder as the base rig .blend
json
{
  "rigName": "npc_base_r15",
  "engine": { "up": "Y", "front": "-Z", "unit": "stud" },
  "blenderExport": {
    "format": "FBX",
    "forwardAxis": "-Z Forward",
    "upAxis": "Y Up",
    "applyTransform": true,
    "applyScalings": "FBX All",
    "applyRotationScaleInObjectMode": true,
    "bakeAnimation": false
  },
  "checklist": [
    "Ctrl+A > Rotation & Scale in Object Mode before exporting",
    "FrontMarker attachment 2 studs in front of the Head on the base rig",
    "After importing, RigOrientation.audit must return a yawError between -0.5 and 0.5",
    "Every animation exported from this rig, and only from this rig"
  ],
  "lastVerifiedYawError": 0,
  "animationIds": []
}
In this article

Keep reading