Learn when to use spatial partition vs traditional patterns
Implement a level editor using Stack data structure
Create dynamic track segment loading/unloading system
Build ScriptableObject-based level configuration
Optimize memory usage for infinite racing tracks
Developer's Note: "Never Panic Early"
โ ๏ธ What to Expect During Implementation
As we implement this system, we'll be creating multiple files in a specific order.
You will see errors in Unity and your IDE until all files are complete.
How to Handle Development Errors
Don't Ignore: Note the errors - they're telling you something
Don't Panic: These are expected until all files are created
Stay Calm: Follow the implementation order and errors will resolve
As Apollo 13's Fred Haise said: "Never Panic Early"
This is a critical skill for software development. Note problems, but stay focused on the implementation path.
๐งฉ Spatial Partitioning Overview
Definition
Spatial partitioning is an optimization technique that divides
game space into manageable regions to improve performance.
Key Benefits:
Reduced Calculations: Only process visible/nearby objects
Memory Efficiency: Load/unload regions dynamically
Faster Queries: Quickly find objects in specific areas
Scalability: Handle massive worlds efficiently
Note: This is an optimization technique, not a Gang of Four design pattern!
๐ Partitioning Techniques
1. Grid Partitioning (Uniform Grid)
Divide space into equal-sized cells
Simple to implement, fast lookups
Best for evenly distributed objects
2. Binary Space Partitioning (BSP)
Recursively divide space with planes
Used in 3D rendering (Doom, Quake)
Excellent for static geometry
3. Quadtree (2D) / Octree (3D)
Hierarchical tree structure
Adapts to object density
Good for dynamic worlds
4. Segment-Based (Our Approach)
Load/unload linear segments
Perfect for racing games
Uses Stack data structure
๐ Level Editor Design Goals
Challenge: Building Infinite Tracks
Racing games need long, varied tracks without consuming massive memory.
Our Solution:
Segment-Based Track: Divide track into reusable pieces
Dynamic Loading: Load segments ahead of player
Automatic Cleanup: Destroy segments behind player
Designer-Friendly: Configure tracks via ScriptableObjects
Memory Efficient: Only 3-5 segments in memory at once
Key Insight: Player bike stays stationary; track moves toward player!
๐ Stack Fundamentals (LIFO)
Last In, First Out (LIFO)
A stack is a collection where the last item added is the first one removed.
Core Operations:
Push(item) - Add item to top of stack
Pop() - Remove and return top item
Peek() - View top item without removing
Count - Number of items in stack
C# Example:
Stack<GameObject> segments = new Stack<GameObject>();
segments.Push(segment1); // Add to stack
segments.Push(segment2); // Add another
GameObject top = segments.Pop(); // Returns segment2// segment1 is still in stack
Stack<T> โ generic collection, T is the type of items stored. GameObject means it holds Unity scene objects.
new Stack<>() โ creates an empty stack. Items added/removed from the "top".
Push() โ adds to top. Pop() โ removes and returns the top item. LIFO order.
Think of it like a stack of pancakes โ you always grab from the top.
Why Stack? Segments loaded in reverse order = natural LIFO behavior
๐๏ธ System Architecture
Three Core Components:
Track ScriptableObject
Stores segment prefabs
Defines segment length
Designer-friendly configuration
TrackController
Manages segment loading/positioning
Uses Stack to store segment queue
Responds to player progress
SegmentMarker
Triggers when player passes segment
Signals cleanup to destroy old segments
Requests new segment loading
๐ฆ Track ScriptableObject
๐ File Structure Note - PRODUCTION CODE
Create a new file: Assets/Scripts/Configs/Track.cs
ScriptableObject that defines track configuration and segments. โ ๏ธ This code goes into your Unity project for Blade Racer.
Designer-Friendly Level Configuration
using UnityEngine;
using System.Collections.Generic;
[CreateAssetMenu(fileName = "New Track", menuName = "Track")]publicclass Track : ScriptableObject
{publicstring trackName;
publicfloat segmentLength = 40f;
public List<GameObject> segments;
}
[CreateAssetMenu] โ Unity attribute that adds a "Create โ Track" right-click menu in the Project panel.
: ScriptableObject โ inherits from Unity's data container class. Lives as a .asset file, not in a scene.
fileName โ default name when the asset is created. menuName โ the path in the Create menu.
List<GameObject> โ ordered collection of segment prefabs. Designers drag prefabs into this list.
No MonoBehaviour, no scene object โ ScriptableObject data lives in the Project panel as a reusable asset.
How It Works:
trackName - Identifies the track (e.g., "Desert Circuit")
segmentLength - Distance between segments (40 units)
segments - List of prefabs to randomly select from
Workflow: Designers create Track assets, add prefabs, no code needed!
๐จ Designer Workflow
Steps to Create a Track:
Right-click in Project: Create โ Track
Name the asset: "DesertTrack", "CityTrack", etc.
Set segment length: Usually 40-60 units
Add segment prefabs: Drag prefabs into list
Straight segments
Curved segments
Obstacles
Special features
Assign to TrackController: Drag Track asset to controller
Create a new file: Assets/Scripts/Controllers/TrackController.cs
MonoBehaviour that manages segment loading and unloading. โ ๏ธ This code goes into your Unity project for Blade Racer.
using UnityEngine;
using System.Collections.Generic;
publicclass TrackController : MonoBehaviour
{public Track track;
public BikeController bikeController;
private Stack<GameObject> _segStack;
private Transform _segParent;
privatefloat _zPos;
privateint _segCount;
voidStart()
{
_segParent = GameObject.Find("Track").transform;
_segStack = new Stack<GameObject>();
// Initialize stack with segments in REVERSE orderfor (int i = track.segments.Count - 1; i >= 0; i--)
{
_segStack.Push(track.segments[i]);
}LoadSegment(3); // Load first 3 segments}}
Fields โ track and bikeController are public Inspector references. _segStack holds the segment prefabs to spawn. _zPos tracks where the next segment should be placed in world space.
GameObject.Find("Track") โ locates the "Track" empty GameObject in the scene by name. Returns its Transform so we can parent segments to it.
Reverse loop โ iterates the segment list from the end to index 0. Because Stack is LIFO, pushing in reverse means Pop() later returns items in original list order.
LoadSegment(3) โ called in Start() to pre-load the first 3 segments before gameplay begins.
The reverse push is the key trick โ think of loading a magazine backwards so bullets fire in the right order.
๐ Stack Loading Strategy
The Reverse Order Problem
Challenge: We want segments to appear in list order,
but Stack is LIFO (Last In, First Out).
Solution: Load in Reverse!
// If list is: [Segment1, Segment2, Segment3]// Push in reverse:
_segStack.Push(Segment3); // Bottom of stack
_segStack.Push(Segment2); // Middle
_segStack.Push(Segment1); // Top// Now Pop() returns Segment1 first! โ
Key Insight: Reversing during initialization gives us correct order during gameplay
๐ฅ LoadSegment Method
publicvoidLoadSegment(int amount)
{for (int i = 0; i < amount; i++)
{if (_segStack.Count > 0)
{// Get next segment from stack
GameObject segment = Instantiate(
_segStack.Pop(),
Vector3.zero,
Quaternion.identity
);
// Set as child of Track parent
segment.transform.SetParent(_segParent);
// Position segment ahead of player
segment.transform.localPosition =
newVector3(0, 0, _zPos);
// Move position for next segment
_zPos += track.segmentLength;
_segCount++;
}}}
for (int i = 0; i < amount; i++) โ loops amount times, creating one segment per iteration. Calling LoadSegment(3) at start loads the first 3.
Instantiate(prefab, position, rotation) โ spawns a copy of the prefab at world origin. Position and rotation are set manually right after.
SetParent(_segParent) โ makes the new segment a child of the "Track" GameObject so all segments move together.
localPosition โ sets position relative to the parent. _zPos advances by segmentLength each iteration, placing segments end-to-end.
_zPos is the running "cursor" โ it marks where the next segment's front edge should begin.
๐ Positioning Segments
Track Layout Example
// Assume segmentLength = 40
LoadSegment(1): Position at Z = 0
LoadSegment(1): Position at Z = 40
LoadSegment(1): Position at Z = 80
// _zPos increments by 40 each time// Creates continuous track ahead of player
Visual Representation:
Player (stationary at Z=0)
โ
[Segment 0]โโโโโ[Segment 1]โโโโโ[Segment 2]
Z=0 Z=40 Z=80
Track moves BACKWARD toward player
Remember: Player doesn't move; track comes to player!
โป๏ธ Refilling the Stack
Repopulate When Empty
publicvoidLoadSegment(int amount)
{for (int i = 0; i < amount; i++)
{// Refill stack if empty before poppingif (_segStack.Count == 0)
RepopulateStack();
GameObject segment = Instantiate(
_segStack.Pop(),
Vector3.zero,
Quaternion.identity
);
segment.transform.SetParent(_segParent);
segment.transform.localPosition =
newVector3(0, 0, _zPos);
_zPos += track.segmentLength;
_segCount++;
}}privatevoidRepopulateStack()
{for (int i = track.segments.Count - 1; i >= 0; i--)
_segStack.Push(track.segments[i]);
}
if (_segStack.Count == 0) โ checked before every Pop. If the stack is empty we refill it first, then continue. This replaces the if (Count > 0) guard from the previous slide โ instead of skipping, we guarantee the segment is always loaded.
RepopulateStack() โ re-pushes all segments from the Track asset (in reverse) so the cycle begins again. The track "repeats" automatically.
Infinite loop โ every time the player exhausts all segments, the stack silently refills. From the player's view the track never ends.
This is the heart of the infinite-track illusion โ the cycle is invisible to the player.
Result: Infinite track by cycling through segments!
๐ด Moving the Track
Track Moves Toward Player
voidUpdate()
{// Move entire track backward at bike's current speed
_segParent.Translate(
Vector3.back *
bikeController.CurrentSpeed *
Time.deltaTime,
Space.World
);
}
Translate(direction, Space.World) โ moves the Transform by a vector each frame. Space.World means the direction is in world coordinates, not local.
Vector3.back โ shorthand for (0, 0, -1). Moving the track in the negative Z direction makes it appear to come toward the player.
_segParent โ moving the parent moves all child segments together in one call.
The player's bike never moves. The entire track slides toward the camera โ a classic racing game illusion.
How It Works:
Vector3.back = negative Z direction (toward player)
CurrentSpeed = player's velocity
Time.deltaTime = frame-independent movement
All segments move together (parented to Track object)
Illusion: Player feels like they're racing forward!
๐ฉ SegmentMarker Class
๐ File Structure Note - PRODUCTION CODE
Create a new file: Assets/Scripts/Markers/SegmentMarker.cs
Component that triggers segment loading/cleanup on collision. โ ๏ธ This code goes into your Unity project for Blade Racer.
Automatic Segment Cleanup
using UnityEngine;
publicclass SegmentMarker : MonoBehaviour
{public TrackController trackController;
privatevoidOnTriggerExit(Collider other)
{// Did the bike pass through this marker?if (other.GetComponent<BikeController>())
{// Load 1 new segment ahead
trackController.LoadSegment(1);
// Destroy this entire segmentDestroy(transform.parent.gameObject);
}}}
OnTriggerExit(Collider other) โ Unity callback fired when another collider leaves a trigger zone. "Exit" fires after the bike fully passes through, not when it enters.
GetComponent<BikeController>() โ checks if the exiting object has a BikeController. Returns null if not, so non-bike colliders (environment, etc.) are ignored.
LoadSegment(1) โ tells TrackController to spawn the next segment ahead. One in = one out keeps 3 segments active at all times.
Destroy(transform.parent.gameObject) โ destroys the parent segment prefab, not just the marker. Since marker is a child, this removes the whole segment in one call.
The marker is a "trip wire" โ when the bike crosses it, new content loads ahead and old content disappears behind.
LoadSegment(int amount) โ public so SegmentMarker can call it. Loops amount times; refills stack if empty before each pop.
Instantiate โ SetParent โ localPosition โ the three-step pattern for every new segment: create it, parent it, place it at _zPos, then advance _zPos.
RepopulateStack() โ private helper; re-adds all segments in reverse order so the track cycles endlessly.
These two methods are the engine of the whole system โ everything else just calls them.
โ๏ธ Unity Setup Steps
1. Create Track Parent
Empty GameObject named "Track"
Position at (0, 0, 0)
All segments will be children
2. Create Track ScriptableObject
Right-click โ Create โ Track
Add segment prefabs to list
Set segment length (e.g., 40)
3. Add TrackController Script
Attach to Track GameObject
Assign Track asset
Assign BikeController reference
4. Configure Segment Prefabs
Add SegmentMarker to each prefab
Set Box Collider as trigger
Assign TrackController reference
๐ธ Screenshot: Unity Inspector showing TrackController component with Track asset and BikeController reference assigned
๐งช Testing Your Track
What to Verify:
Initial Load: 3 segments appear at start
Track Movement: Track moves backward smoothly
Segment Loading: New segment appears when passing marker
Cleanup: Old segment destroys after passing
Stack Refill: Segments cycle when stack empties
Performance: Consistent FPS with only 3 segments active
Debug Tips:
// In LoadSegment():
Debug.Log($"Loaded segment {_segCount}, Stack count: {_segStack.Count}");
// In SegmentMarker.OnTriggerExit():
Debug.Log("Segment destroyed, loading next...");
โ ๏ธ Common Pitfalls
1. Forgetting Reverse Order
// โ Wrong - segments load backwards!for (int i = 0; i < track.segments.Count; i++)
_segStack.Push(track.segments[i]);
// โ Correct - reverse for LIFO behaviorfor (int i = track.segments.Count - 1; i >= 0; i--)
_segStack.Push(track.segments[i]);
2. Marker Not a Child
If SegmentMarker is separate, only marker destroys
Segment stays in scene โ memory leak!
Solution: Destroy transform.parent.gameObject
3. Trigger Not Set
Forgetting "Is Trigger" checkbox
Marker acts as solid wall, blocking player
๐ฒ Adding Randomization
Problem:
Segments always appear in same order = predictable tracks
Solution: Random Selection
privatevoidRepopulateStack()
{// Create shuffled copy of segments
List<GameObject> shuffled = new List<GameObject>(track.segments);
// Shuffle using Fisher-Yates algorithmfor (int i = shuffled.Count - 1; i > 0; i--)
{int j = Random.Range(0, i + 1);
GameObject temp = shuffled[i];
shuffled[i] = shuffled[j];
shuffled[j] = temp;
}// Push shuffled segmentsfor (int i = shuffled.Count - 1; i >= 0; i--)
_segStack.Push(shuffled[i]);
}
Fisher-Yates Shuffle โ a well-known algorithm that produces an unbiased random permutation. Each element swaps with a random element at or before its position.
new List<>(track.segments) โ creates a copy of the original list so the Track asset's order is preserved for next time.
Random.Range(0, i + 1) โ picks a random index from 0 to i (inclusive). The + 1 is because Range's max is exclusive.
temp swap โ classic three-variable swap: save one value, overwrite it, restore from temp. No built-in swap in C#.
Pushing the shuffled list in reverse maintains LIFO order โ same trick as initialization, applied to the shuffled copy.
๐ Progressive Difficulty
Increase Challenge Over Time
publicclass Track : ScriptableObject
{public List<GameObject> easySegments;
public List<GameObject> mediumSegments;
public List<GameObject> hardSegments;
}// In TrackController:privatevoidRepopulateStack()
{
List<GameObject> segments;
if (_segCount < 10)
segments = track.easySegments;
elseif (_segCount < 30)
segments = track.mediumSegments;
else
segments = track.hardSegments;
// Push selected difficulty segmentsfor (int i = segments.Count - 1; i >= 0; i--)
_segStack.Push(segments[i]);
}
Three segment lists โ easySegments, mediumSegments, hardSegments replace the single segments list. Designers fill each list with appropriately themed prefabs.
_segCount โ the running total of segments spawned. Used as a proxy for "how far into the game" the player is.
if / else if / else โ selects the difficulty tier based on segment count. < 10 = easy start, < 30 = mid-game, else = late game. Tune these thresholds per game.
This is a simple but effective difficulty ramp โ no separate level system needed.
โป๏ธ Object Pool Integration
Problem: Instantiate/Destroy is Expensive
Creating and destroying segments every few seconds causes GC spikes.
Solution: Pool Segments
// Add to TrackController fields:public ObjectPool _segmentPool; // Assign your pool in Inspector// Then in LoadSegment, instead of Instantiate:
GameObject segment = _segmentPool.GetObject();
segment.transform.SetParent(_segParent);
segment.transform.localPosition = newVector3(0, 0, _zPos);
segment.SetActive(true);
// Instead of Destroy in SegmentMarker:privatevoidOnTriggerExit(Collider other)
{if (other.GetComponent<BikeController>())
{
trackController.LoadSegment(1);
// Return to pool instead of destroying
_segmentPool.ReturnObject(transform.parent.gameObject);
}}
๐จ Procedural Track Generation
Beyond Prefabs: Generate Segments
Instead of pre-made segments, create them algorithmically.
Advantages:
Infinite unique tracks
Smaller game file size
Adjustable difficulty parameters
Concept:
private GameObject GenerateSegment()
{
GameObject segment = newGameObject("Generated Segment");
// Add road mesh based on rulesAddRoadMesh(segment, GetRandomCurve());
// Procedurally place obstaclesAddObstacles(segment, Random.Range(3, 8));
// Add decorationsAddScenery(segment);
return segment;
}
Examples: No Man's Sky, Minecraft, Spelunky
๐ Beyond Racing Tracks
Where Else to Use Spatial Partitioning?
1. Open World Games
Load/unload city blocks as player moves
Skyrim, GTA, Zelda: BotW
2. Multiplayer Games
Only send updates for nearby players
Interest management in MMOs
3. Physics Optimization
Only check collisions within same grid cell
Massive performance boost
4. AI Perception
NPCs only "see" objects in their quadrant
Reduced AI queries
5. Rendering Culling
Don't render objects outside camera frustum
Occlusion culling in Unity
๐ง Unity's Spatial Tools
Unity Already Uses Spatial Partitioning!
1. Occlusion Culling
Don't render objects blocked by other objects
Window โ Rendering โ Occlusion Culling
2. LOD (Level of Detail) Groups
Switch to lower-poly meshes when far away
Distance-based optimization
3. Lightmap UVs
Pre-bake lighting into texture atlas
Spatial UV layout optimization
4. Physics Layers
Control which objects can collide
Reduces collision checks
Takeaway: Learn from Unity's optimizations!
๐ Performance Metrics
Before Spatial Partitioning:
Track Length: 5000 units
Segment Count: 125 segments (40 units each)
Active Objects: 125 segments always rendered
Memory: ~500 MB
FPS: 15-20 (unplayable)
After Spatial Partitioning:
Track Length: Infinite
Segment Count: 3 active at a time
Active Objects: 3 segments dynamically loaded
Memory: ~15 MB
FPS: 60+ (smooth gameplay)
Key Improvements:
97% memory reduction
300% FPS increase
Unlimited track length
๐ซ When to Skip Spatial Partitioning
Not Always Necessary!
Skip if:
Small Scenes: 10-20 objects total? Just render everything
Static Cameras: Fixed camera angle shows same view always
Consistent Segment Size: Makes calculations predictable
Tune Loading Distance: Load far enough ahead to avoid pop-in
Pool Objects: Combine with Object Pool pattern
Use ScriptableObjects: Designer-friendly configuration
Add Visual Feedback: Loading spinner during heavy operations
Test Edge Cases: Empty lists, null references
Document Decisions: Why Stack? Why 3 segments?
Remember: Spatial partitioning is about performance, not patterns
Gaming History Moment ๐น๏ธ
Indie Renaissance: Minecraft's Chunk System (2011)
In 2009, Markus "Notch" Persson created Minecraft - a game with procedurally generated infinite worlds. The technical challenge was massive: how do you render a world that's literally endless? Notch's solution: spatial partitioning through chunks. The world is divided into 16x256x16 block chunks that load and unload based on player position.
When you move, Minecraft loads chunks ahead and unloads chunks behind. Only visible chunks are kept in memory and rendered. This chunk system - a spatial hash grid - allows virtually unlimited worlds on modest hardware. The "render distance" setting controls how many chunks load around you. 8 chunks = 128 block radius. 32 chunks = 512 block radius. Players with powerful PCs see further; weaker systems stay performant with smaller grids.
Connection to Spatial Partition Pattern
Minecraft's chunk system is THE textbook example of Spatial Partitioning in action! Just like our RaceSegmentManager divides a race track into segments and loads/unloads them based on player position, Minecraft divides the infinite world into 16x16 chunks and manages them spatially. Both systems optimize by only processing nearby areas. Without spatial partitioning, Minecraft would try to render millions of blocks simultaneously - impossible! Chunks make infinity playable.