Origin Shifting
Solve the floating point precision problem by periodically shifting the entire world back toward the origin — enabling truly infinite gameplay without visual artifacts.
The Floating Point Precision Problem
Computers store decimal numbers using a format called floating point. In C# and Unity, the standard float type uses 32 bits to represent a number. This gives approximately 7 digits of precision. That sounds like a lot, but it creates a subtle and devastating problem for infinite runner games.
Here's the key insight: those 7 digits of precision are total, not after the decimal point. As numbers get larger, the precision after the decimal point gets worse:
| Position (Z) | Smallest Representable Step | Visual Effect |
|---|---|---|
| 1.0 | 0.0000001 (7 decimal places) | Perfect — no visible issues |
| 100.0 | 0.00001 (5 decimal places) | Still fine |
| 10,000.0 | 0.001 (3 decimal places) | Slight jitter visible if you look closely |
| 100,000.0 | 0.01 (2 decimal places) | Objects visibly snap between positions |
| 1,000,000.0 | 0.1 (1 decimal place) | Severe jitter, physics break down, unplayable |
In our infinite runner, the player moves forward at ~20 units per second. After just 500 seconds (about 8 minutes), the player is at Z = 10,000 and precision starts degrading. After 83 minutes, they're at Z = 100,000 and the game looks broken. For a game that's supposed to be infinite, this is a critical problem.
At large positions, you'll see: objects jittering (vibrating rapidly between positions), mesh faces z-fighting (flickering between visible and invisible), physics collisions becoming unreliable (objects passing through each other or bouncing erratically), and animations stuttering. The worst part? These bugs are intermittent and hard to reproduce because they only happen after the player has been running for a while. Many developers ship games with this bug and never realize it because they only test for a few minutes at a time.
Why This Matters Specifically for Infinite Runners
Most game genres don't have this problem because the player stays within a bounded area. In a first-person shooter, the map might be 1,000 units across. In an RPG, the open world might be 10,000 units. These are fine for float precision.
But an infinite runner is different. The player moves in one direction forever. There is no upper bound on how far they can travel. And in our 3-lane runner, precision matters enormously because:
- Lane switching requires exact positioning (X = -2, 0, or 2). Any jitter makes the player look like they're wobbling between lanes.
- Obstacle collision depends on precise trigger volumes. Imprecise physics means unfair deaths or missed collisions.
- Chunk alignment must be seamless. Precision loss causes visible gaps between chunks.
- Camera follow amplifies jitter. If the player jitters by 0.01 units, the camera jitters too, and on a large screen, that's very noticeable.
The Solution: Origin Shifting
The fix is elegant: instead of letting the player run infinitely far from the origin, we periodically move the entire world back so the player is near the origin again. If the player has traveled 1,000 units forward, we subtract 1,000 from the Z position of every single object in the scene — the player, all chunks, all obstacles, all collectibles, the camera, everything.
Because everything moves by the same amount at the same time, the relative positions between objects don't change. The player doesn't notice anything happened. But now the player is at Z = 0 instead of Z = 1,000, and we have full float precision again.
Step-by-Step: What Happens During a Shift
- The player reaches Z = 1,000 (our threshold).
- The OriginShifter calculates the shift amount:
(0, 0, -1000). - The OriginShifter moves EVERY root-level GameObject in the scene by (0, 0, -1000).
- The player is now at Z = 0. The chunk that was at Z = 950 is now at Z = -50. The chunk that was at Z = 1,100 is now at Z = 100.
- The OriginShifter fires an event to notify other systems (WorldGenerator, PlayerController) to update their internal tracking values.
- Gameplay continues as if nothing happened.
The shift threshold is a balance between frequency and safety. A threshold of 1,000 units means a shift every ~50 seconds at typical runner speeds. That's frequent enough that precision never degrades noticeably, but infrequent enough that the (very small) cost of shifting is negligible. Some developers use 5,000 or 10,000 units, which is also fine. The important thing is that it happens well before precision becomes visible (around 10,000 units).
The OriginShifter Script
Here's the complete implementation. It's simpler than you might expect — the concept is more complex than the code.
using System;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.SceneManagement;
namespace InfiniteRunner.World
{
/// <summary>
/// Periodically shifts all objects in the scene back toward the origin
/// to prevent floating point precision loss.
///
/// This script monitors the player's Z position. When it exceeds the
/// threshold, everything is shifted back by that amount.
///
/// Other systems must subscribe to OnOriginShift to update their
/// internal position tracking.
/// </summary>
public class OriginShifter : MonoBehaviour
{
[Header("References")]
[Tooltip("The player's Transform. Shift triggers based on this position.")]
[SerializeField] private Transform playerTransform;
[Header("Settings")]
[Tooltip("How far from origin (Z-axis) the player can get before a shift. " +
"Lower = more frequent shifts. 1000 is a good default.")]
[SerializeField] private float shiftThreshold = 1000f;
[Tooltip("If true, the shift only occurs along the Z-axis (forward). " +
"Disable for games with free movement in all directions.")]
[SerializeField] private bool zAxisOnly = true;
[Header("Debug")]
[Tooltip("Log a message every time a shift occurs.")]
[SerializeField] private bool logShifts = true;
[Tooltip("Track total distance shifted (for debugging).")]
[SerializeField] private float totalShifted = 0f;
// ---- Events ----
/// <summary>
/// Fired after an origin shift occurs.
/// The Vector3 parameter is the shift amount (negative Z in our case).
/// Systems that track absolute positions must subscribe to this
/// and adjust their values accordingly.
/// </summary>
public static event Action<Vector3> OnOriginShift;
// Count total shifts for debugging
private int shiftCount = 0;
// ---- Lifecycle ----
private void LateUpdate()
{
// We check in LateUpdate so all movement for this frame is done.
// This prevents other scripts from moving objects AFTER the shift,
// which would put them at wrong positions.
if (playerTransform == null) return;
Vector3 playerPos = playerTransform.position;
// Check if we've exceeded the threshold
if (zAxisOnly)
{
if (Mathf.Abs(playerPos.z) >= shiftThreshold)
{
PerformShift(new Vector3(0f, 0f, -playerPos.z));
}
}
else
{
if (playerPos.magnitude >= shiftThreshold)
{
PerformShift(-playerPos);
}
}
}
// ---- Core Logic ----
/// <summary>
/// Shifts all root GameObjects in the active scene by the given amount.
/// Then notifies all subscribers via the OnOriginShift event.
/// </summary>
/// <param name="shiftAmount">How much to move everything (typically negative Z).</param>
private void PerformShift(Vector3 shiftAmount)
{
// Step 1: Shift all root-level GameObjects in the scene
ShiftAllObjects(shiftAmount);
// Step 2: Track for debugging
shiftCount++;
totalShifted += Mathf.Abs(shiftAmount.z);
if (logShifts)
{
Debug.Log(
$"OriginShifter: Shift #{shiftCount} by {shiftAmount}. " +
$"Player was at Z={playerTransform.position.z - shiftAmount.z:F1}. " +
$"Now at Z={playerTransform.position.z:F1}. " +
$"Total shifted: {totalShifted:F0} units."
);
}
// Step 3: Notify all subscribers
// This is how the WorldGenerator, PlayerController, etc.
// know to update their internal values.
OnOriginShift?.Invoke(shiftAmount);
}
/// <summary>
/// Moves every root-level GameObject in the active scene.
/// Child objects move automatically because they're parented.
/// </summary>
private void ShiftAllObjects(Vector3 shiftAmount)
{
// Get all root GameObjects in the active scene
Scene activeScene = SceneManager.GetActiveScene();
List<GameObject> rootObjects = new List<GameObject>();
activeScene.GetRootGameObjects(rootObjects);
foreach (GameObject rootObj in rootObjects)
{
// Skip objects that shouldn't move (e.g., lights, event systems)
// We move everything by default because it's simpler and safer.
// Only skip objects explicitly marked to stay put.
if (rootObj.CompareTag("OriginStatic"))
{
continue;
}
rootObj.transform.position += shiftAmount;
}
}
// ---- Public API ----
/// <summary>
/// Returns the total distance the world has been shifted.
/// Useful for calculating the player's true distance traveled.
/// </summary>
public float GetTotalShifted()
{
return totalShifted;
}
/// <summary>
/// Returns how many shifts have occurred since game start.
/// </summary>
public int GetShiftCount()
{
return shiftCount;
}
/// <summary>
/// Resets shift tracking. Call when starting a new run.
/// </summary>
public void ResetTracking()
{
totalShifted = 0f;
shiftCount = 0;
}
}
}Let's break down the critical parts:
Why LateUpdate?
We check for the shift in LateUpdate() rather than Update(). This is important because Update() is where movement happens (the player moves forward, obstacles move, etc.). If we shifted during Update(), some objects might have already moved for this frame while others haven't yet, causing a single frame where positions are inconsistent. LateUpdate() runs after all Update() calls are done, so all movement is complete before we shift.
Why Shift Root Objects Only?
We only shift root-level GameObjects (objects with no parent). Child objects automatically move with their parents. So when we move a chunk root, all its spawn points, obstacle children, and ground mesh move along with it. This means we only need to iterate over ~20-30 root objects rather than every single object in the scene.
The OriginStatic Tag
Some objects genuinely shouldn't be shifted — for example, a directional light that represents the sun doesn't need to move because its position doesn't affect its lighting direction. We use a custom tag "OriginStatic" to skip these. In practice, you'll rarely need this; most objects should be shifted.
The OnOriginShift Event
After shifting all objects, we fire a static event. This is how other systems learn about the shift and can adjust their internal bookkeeping. Let's look at how each system handles it.
Handling the Shift in Other Systems
WorldGenerator
The WorldGenerator tracks nextSpawnZ — the Z position where the next chunk should be placed. After a shift, this value needs to be adjusted.
private void OnEnable()
{
OriginShifter.OnOriginShift += HandleOriginShift;
}
private void OnDisable()
{
OriginShifter.OnOriginShift -= HandleOriginShift;
}
private void HandleOriginShift(Vector3 shiftAmount)
{
// Adjust the next spawn position by the shift amount.
// If the shift was (0, 0, -1000), nextSpawnZ decreases by 1000.
nextSpawnZ += shiftAmount.z;
}Notice that we subscribe in OnEnable() and unsubscribe in OnDisable(). This is a critical pattern. If you subscribe but forget to unsubscribe, the event will try to call a method on a destroyed object, causing a NullReferenceException. Always pair += with -=.
PlayerController (Distance Tracking)
The PlayerController tracks how far the player has traveled for the score display. After a shift, the player's Z position resets to near zero, but the score shouldn't reset. We use the totalShifted value to calculate true distance.
// Add these fields to your PlayerController:
private float distanceOffset = 0f;
private void OnEnable()
{
OriginShifter.OnOriginShift += HandleOriginShift;
}
private void OnDisable()
{
OriginShifter.OnOriginShift -= HandleOriginShift;
}
private void HandleOriginShift(Vector3 shiftAmount)
{
// When the world shifts back by 1000, we need to ADD 1000 to our
// distance offset so the displayed distance doesn't jump.
distanceOffset -= shiftAmount.z;
}
/// <summary>
/// Returns the true distance the player has traveled since game start,
/// accounting for all origin shifts.
/// </summary>
public float GetTrueDistance()
{
return transform.position.z + distanceOffset;
}The math works like this: if the player is at Z = 50 and the offset is 2000 (meaning two shifts of 1000 each have occurred), the true distance is 2050. The shift moves the player's position back, but the offset remembers how far we've shifted, so the sum is always correct.
Camera System
If you're using Cinemachine (which we set up in Chapter 10), the camera handles origin shifts automatically. Cinemachine follows a target transform, and since we shift the target (the player) along with everything else, the camera just follows along. The relative offset between the camera and the player never changes.
If you're using a custom camera script, you need to handle the shift the same way as the player — the camera's position gets shifted along with all other root objects, so no special handling is needed unless your camera script caches absolute positions.
Cinemachine works based on the relative position between the camera and its target. Since origin shifting moves both the camera and the target by the same amount, the relative position doesn't change. Cinemachine doesn't even notice the shift happened. This is one of the benefits of using Cinemachine — it handles edge cases like this gracefully.
Keeping Physics Stable During a Shift
Unity's physics engine (PhysX) tracks object positions internally. When we teleport objects by modifying transform.position, the physics engine notices the sudden position change and can react unexpectedly — objects might briefly collide with things they shouldn't, or velocity calculations might produce wrong results.
For our infinite runner, physics stability during shifts is manageable because:
- We shift in LateUpdate — Physics simulation (
FixedUpdate) is already done for this frame. The next physics step will see the shifted positions as the "new normal." - We shift everything uniformly — Since all objects move by the same amount, relative velocities and positions don't change. Two objects that were 5 units apart before the shift are still 5 units apart after.
- Our runner has simple physics — We use triggers (not complex rigid body interactions). Triggers just check overlap, and since everything shifts together, overlaps don't change.
Unity's particle systems simulate in world space by default. When you shift the particle system's transform, existing particles don't move — they stay at their old world positions, which are now wrong. There are two fixes: (1) Set particle systems to Simulation Space: Local in the Particle System component, which makes particles relative to the emitter. (2) Or manually call ParticleSystem.Simulate() to reposition existing particles after a shift. Option 1 is much simpler and works perfectly for our runner.
Common Pitfalls and How to Avoid Them
Pitfall 1: Forgetting to Shift Something
If even one object is missed during the shift, it will suddenly appear to teleport 1,000 units in the wrong direction. This is immediately visible. To prevent this:
- Use the scene's root object list (which we do) rather than tagging objects manually.
- Test by lowering the shift threshold to 50 units during development, so shifts happen frequently and you'll notice missed objects quickly.
Pitfall 2: Caching Absolute Positions
If any script stores a world position in a variable (like targetPosition = new Vector3(0, 0, 500)) and doesn't update it after a shift, the script will reference a position that no longer exists. Always either:
- Subscribe to
OnOriginShiftand adjust cached positions, or - Use relative positions (offset from a transform) instead of absolute ones.
Pitfall 3: UI Position Offsets
World-space UI canvases are affected by origin shifting (they're GameObjects too). Screen-space canvases are not, because they use screen coordinates, not world coordinates. Make sure your UI uses Screen Space - Overlay or Screen Space - Camera mode (not World Space) unless you specifically want it to move with the world.
Pitfall 4: Audio Listeners and Sources
3D audio sources calculate volume based on distance from the Audio Listener. Since both the listener (usually on the camera) and the sources shift together, distances don't change and audio is unaffected. This "just works" with our approach.
Pitfall 5: NavMesh and AI Pathfinding
If you're using Unity's NavMesh system (we're not in this project), origin shifting will invalidate baked navmeshes. NavMesh agents won't know where to go. This is a known limitation. For games that need NavMesh + origin shifting, you'd need to rebake at runtime, which is complex. Fortunately, our infinite runner doesn't use NavMesh.
Testing Origin Shifting
Origin shifting bugs are sneaky because they only appear after the player has traveled far enough to trigger a shift. Here's how to test thoroughly:
Test 1: Frequent Shifts
- Temporarily set
shiftThresholdto 50 units. - Play the game. Shifts will occur every few seconds.
- Watch the Console for shift log messages. Verify they're happening.
- Look for any visual glitches: objects jumping, gaps appearing between chunks, particles in wrong positions.
- Check the score display. The distance should increase smoothly without any jumps when shifts occur.
Test 2: Speed Test
- Set the player speed very high (e.g., 200 units per second) to quickly reach large Z values.
- With origin shifting enabled, the game should look smooth at Z = 100,000 because positions are always being reset to near zero.
- Disable origin shifting (uncheck the component) and repeat. At large Z values, you should see jitter. This confirms the shift is working.
Test 3: Game Over and Restart
- Play until several shifts have occurred.
- Trigger a game over.
- Restart the game. Verify the world resets cleanly to Z = 0 and the shift counter resets.
- Check that no objects are at old (pre-shift) positions.
using UnityEngine;
namespace InfiniteRunner.World
{
/// <summary>
/// Attach to the player to display origin shift debug info on screen.
/// Delete before shipping.
/// </summary>
public class OriginShiftTester : MonoBehaviour
{
[SerializeField] private OriginShifter originShifter;
private float trueDistance = 0f;
private float distanceOffset = 0f;
private GUIStyle style;
private void OnEnable()
{
OriginShifter.OnOriginShift += OnShift;
}
private void OnDisable()
{
OriginShifter.OnOriginShift -= OnShift;
}
private void OnShift(Vector3 amount)
{
distanceOffset -= amount.z;
}
private void Update()
{
trueDistance = transform.position.z + distanceOffset;
}
private void OnGUI()
{
if (style == null)
{
style = new GUIStyle(GUI.skin.label)
{
fontSize = 16,
normal = { textColor = Color.white }
};
}
float y = Screen.height - 120f;
float lineH = 22f;
GUI.Label(new Rect(10, y, 500, lineH),
$"Current Z: {transform.position.z:F2}", style);
y += lineH;
GUI.Label(new Rect(10, y, 500, lineH),
$"True Distance: {trueDistance:F0} m", style);
y += lineH;
if (originShifter != null)
{
GUI.Label(new Rect(10, y, 500, lineH),
$"Total Shifted: {originShifter.GetTotalShifted():F0} m", style);
y += lineH;
GUI.Label(new Rect(10, y, 500, lineH),
$"Shift Count: {originShifter.GetShiftCount()}", style);
}
}
}
}Setting Up Origin Shifting in Unity
- Create an empty GameObject called
OriginShifterin your scene (or add the component to an existing manager object). - Add the
OriginShifterscript component. - Assign the Player Transform reference.
- Set the Shift Threshold to 1000 (or lower for testing).
- Leave "Z Axis Only" checked (our runner only moves on Z).
- Enable "Log Shifts" during development, disable for production.
- Go to Edit > Project Settings > Tags and Layers and add the tag
OriginStatic. Apply this tag to any object that should NOT be shifted (typically just your Directional Light, if that). - Make sure all particle systems in your project are set to Simulation Space: Local.
What We Built
Origin shifting is the final piece of our world generation puzzle. With this system in place, our infinite runner is truly infinite — the player can run for hours without any visual degradation.
- OriginShifter.cs — Monitors the player's position and shifts the entire world back toward the origin when a threshold is exceeded. Uses
LateUpdateto ensure all movement is done before shifting. Fires an event to notify other systems. - WorldGenerator integration — Adjusts
nextSpawnZwhen a shift occurs. - PlayerController integration — Uses a
distanceOffsetto calculate true distance traveled despite shifts. - Camera handling — Cinemachine handles it automatically; custom cameras shift with everything else.
- Physics safety — Shifting in LateUpdate and moving everything uniformly keeps physics stable.
Part 3 is complete. We now have a full world generation pipeline: modular chunks, object pooling for zero-allocation spawning, procedural world generation, and origin shifting for infinite precision. In the next chapter, we start Part 4 (Gameplay) by building the obstacle system.
Origin shifting is done. The entire world generation system is complete! Time to merge our feature branch back into main.
git add .
git commit -m "Implement origin shifting for infinite precision"
# Now merge the feature branch back into main
git checkout main
git merge feature/world-generation
git log --oneline
We just merged our feature branch into main. All the commits we made on feature/world-generation are now part of main. Run git log --oneline and you'll see the full history. This branch workflow — create branch, work, commit several times, merge back — is how professional teams work. It keeps main stable while you experiment.