Curved Roads & Path System

Traffic Overtaker is an endless game, but the world is built from only a handful of road segments. The illusion of an infinite highway is produced by a treadmill: a small pool of curving road pieces is spawned once, and whenever the camera passes the rear-most piece it is teleported to the front of the chain and re-shaped. Three cooperating managers drive this: TO_CurvedRoadManager owns the road pool and the recycling logic, TO_PathManager collects the road's bone transforms into a single drivable centerline and answers "where is the closest point on the path to the player?", and TO_LaneManager offsets that centerline sideways into the two physical lanes the cars actually follow. This page documents the system and its tunable parameters — the road mesh and visual art are authored by the project owner and are out of scope here. If you are reskinning the game, you will mostly change spawn counts, distances, and curve ranges; you rarely touch this code.

All three managers live in Assets/TO/Scripts/Roads and Path/, use the TO_ prefix, and sit in the default Assembly-CSharp in the BoneCrackerGames namespace. Each is a lazy singleton exposed through a static Instance property.

How the endless treadmill works

The road system is entirely event-driven. TO_CurvedRoadManager fires two static C# events as it builds and recycles segments, and both TO_PathManager and TO_LaneManager subscribe to them in OnEnable(). This decoupling is deliberate: the path layer and the lane layer never poll the road manager — they simply react when a road is created or moved, which keeps the work to one segment per event instead of rebuilding everything each frame.

TO_CurvedRoadManager.CreateRoads()        -> fires OnAllRoadsAligned(spawnedRoads)
        |                                          |
        |                                          +-> TO_PathManager: AddPath() for each road (collect bones)
        |                                          +-> TO_LaneManager: CreateWaypoints() (build both lanes)
        |
TO_CurvedRoadManager.ReAlignRoad(road)    -> fires OnRoadAligned(road)
                                                   |
                                                   +-> TO_PathManager: ProcessPath() (refresh closest point)
                                                   +-> TO_LaneManager: UpdateWaypoints() (re-align lane points)

TO_PathManager runs at [DefaultExecutionOrder(-20)] and TO_LaneManager at [DefaultExecutionOrder(-10)], so the path centerline is rebuilt before the lanes that depend on it each time the order matters. The road manager itself runs at default order and drives the chain in Update().

1. Building the initial pool

On Awake(), TO_CurvedRoadManager deactivates any road prefabs left in the scene (to avoid duplicates) and calls CreateRoads(). That method finds or creates a parent GameObject named TO_CurvedRoads, then instantiates one live copy for every entry in the roads array, calling RandomizeCurve() on each so no two runs look identical. The copies are snapped nose-to-tail: each road's transform is placed at the endPoint transform (position and rotation) of the road before it, so the chain bends continuously. A safety loop guarantees a minimum of 3 segments — if your roads array has fewer entries it keeps instantiating from the array until at least three exist. When the chain is ready it fires OnAllRoadsAligned(spawnedRoads).

2. Recycling a segment

Every frame, Update() calls AnimateRoads(), which walks spawnedRoads and tests each one with HasCameraPassedRoad(). A road counts as "behind the camera" when it is both farther than a 50 m distance threshold and the dot product of the camera's forward vector against the direction to the road's endPoint is negative. To avoid visible jitter, only one road is repositioned per frame — the loop breaks after the first re-alignment.

ReAlignRoad() does the teleport: it snaps the recycled road onto the endPoint of the current lastRoad (the most recently moved segment), updates the lastRoad reference, and calls transform.SetAsLastSibling() so hierarchy order tracks travel order. It then decides the new shape: if the segment is landing near the floating-origin reset threshold it calls StraightenRoad() (so the player is on a straight when the world shifts back to the origin); otherwise it calls RandomizeCurve() for a fresh bend. Finally it fires OnRoadAligned(road), which prompts the path and lane managers to patch just that segment.

3. Floating origin (keeping coordinates small)

Because the player drives forever in +Z, world coordinates would grow without bound and introduce physics precision errors. TO_FixFloatingOrigin (also in this folder) watches the active player's Z position and, once it crosses zLimit (default 2000 m), shifts the road container, traffic container, lane manager, player, and camera all back toward the origin by the player's Z offset, then calls Physics.SyncTransforms() and refreshes the path. TO_CurvedRoadManager.straightenBeforeDistance (default 500 m) defines the band on either side of zLimit inside which freshly-recycled roads are forced straight so the shift is invisible.

TO_CurvedRoadManager — road pool

TO_CurvedRoadManager is the only component you place in the gameplay scene for the treadmill; it is one of the root objects validated by TO_SceneManager (see 04_SystemArchitecture.md). Its inspector fields control how many segments exist and how aggressively they are straightened near the origin shift.

Field Type Default Purpose
roads RoadObjects[] Array of road prefabs to pool. Each RoadObjects wraps one TO_CurvedRoad reference. One live instance is spawned per entry (minimum 3 enforced).
spawnedRoads List<TO_CurvedRoad> empty Runtime list of all live segment instances. Read-only in practice — populated by CreateRoads().
straightenBeforeDistance float 500 Distance (m) before/after the floating-origin zLimit within which recycled roads are forced straight, preventing artifacts during the shift.
spawnedRoadsContainer GameObject auto Parent GameObject (TO_CurvedRoads) holding every spawned segment; located or created in CreateRoads().

Static events you can subscribe to from your own systems:

Event Signature Fires when
OnAllRoadsAligned void(List<TO_CurvedRoad> allRoads) The initial chain has been built and aligned.
OnRoadAligned void(TO_CurvedRoad road) A single segment has been recycled to the front of the chain.

TO_CurvedRoad — a single segment

Each pooled segment is a TO_CurvedRoad. The mesh is a skinned mesh deformed by a row of bone transforms; randomizing the curve simply offsets those bones along an AnimationCurve weighting and re-bakes a MeshCollider from the deformed SkinnedMeshRenderer. This is why you can reshape the road every frame without authoring new geometry — the art stays fixed and the bones bend it.

Field Type Default Purpose
bones Transform[] The deformable spine. Bone 0 is the anchor; the rest are offset to curve the segment.
useRandomizedCurves bool true If false, RandomizeCurve() is a no-op and the segment stays as authored.
minimumCurveAngle float (Min 0) 30 Lower bound (degrees) of the random bend applied on each recycle.
maximumCurveAngle float (Min 0) 90 Upper bound (degrees) of the random bend.
endPoint Transform Marks the segment's end; the next segment snaps here. Auto-built by the Set EndPosition context menu.
curve AnimationCurve (0,0)(.5,1)(1,0) Per-bone offset weighting — zero at the ends, peak in the middle, so segments join smoothly.
curveVector Vector3 (1,1,1) Max random offset range per axis applied to bones when randomizing.
roadWidth float (Min 1) 5.5 Half-width (m) of the road, used for out-of-road boundary checks.
skinnedColliders SkinnedColliders[] Pairs of SkinnedMeshRenderer + MeshCollider; the deformed mesh is baked into the collider each update.

Key methods: RandomizeCurve() picks a random angle/offset and calls UpdateEverything(); StraightenRoad() zeroes the curve input for a perfectly straight piece; UpdateEverything() resets bones, applies the weighted offset, re-orients each bone toward the next, and re-bakes colliders. The editor-only context-menu helpers — Set EndPosition, Find Bones, Check Bones Order, Smooth Bones — are authoring tools for when the project owner builds a new road prefab; they are not called at runtime.

TO_PathManager — the drivable centerline

TO_PathManager turns the bones of every live road into one ordered list of waypoints (pathPoints) representing the road's centerline, then answers spatial queries against it. The player camera and the AI driver both rely on closestPathPointToPlayer and the projection helpers to know where the road goes next. Because a brute-force search over every point would be wasteful, the manager maintains a smaller closestPathPointsToPlayer subset (points within maxCloseDistance) and queries that first, falling back to the full list only when the subset is too small.

Field Type Default Purpose
player TO_Player The tracked vehicle. Auto-assigned via TO_Events.OnPlayerSpawned.
pathPoints List<Transform> empty All centerline waypoints (road bones), appended by AddPath() and kept Z-sorted.
closestPathPointsToPlayer List<Transform> empty The near-player subset, maintained by CheckPathPoints().
closestPathPointToPlayer Transform The single nearest waypoint, refreshed in ProcessPath().
interval float 0.5 Seconds between ProcessPath() calls — the path is re-evaluated twice a second, not every frame.
maxCloseDistance float 125 Distance (m) within which a waypoint is considered "close" and kept in the subset.
minFrontDistance float 20 Below this distance a point is treated as "in front" automatically, avoiding duplicate-add churn.

Useful public methods for reskinners who hook into the path:

TO_LaneManager — the two lanes

The centerline is a single line down the road's middle; cars never drive on it directly. TO_LaneManager clones that centerline twice, offset sideways, into the two lanes that define gameplay. Lanes are wrapped TO_Lane components parented under the manager, and each lane regenerates its own waypoints from TO_PathManager.pathPoints whenever roads move (CreateWaypoints() on first build, UpdateWaypoints() on each recycle, preserving the horizontal offset).

Field Type Default Purpose
lanes Lane[] seeded by Reset() Array of lane wrappers, each holding one TO_Lane.

The component's Reset() (run when you add the component, or via the inspector's Reset) seeds the canonical two-lane layout:

Lane GameObject initialDistance / X offset leftSide Role
Lane_Left -1.61 m true Oncoming traffic lane — the overtaking danger.
Lane_Right +1.61 m false The player's forward-travel lane.

Lane identity is decided by the leftSide flag on TO_Lane, not by array index or the sign of the offset — TO_TrafficManager, TO_TrafficCar, and TO_Player all branch on leftSide, so the array order above is purely cosmetic. Each TO_Lane exposes points (its waypoint list), initialDistance (the X offset; a -1 sentinel means "auto-capture my current X on first build"), and leftSide. TO_LaneManager.FindClosestPointOnLane(lane, targetPosition, out pathDirection) projects a position onto a specific lane, which is how traffic and the player query their lane rather than the shared centerline. See 08_TrafficSystem.md for how traffic cars consume these lanes and how the "nearest car ahead" query feeds the overtake AI.

Tuning for a reskin

For most reskins you will not edit this code at all — you change values in the inspector. To make the road feel calmer, lower TO_CurvedRoad.maximumCurveAngle (or both angle bounds) so segments bend less; to make it wilder, raise them. To extend draw distance / how far ahead road exists, add more entries to TO_CurvedRoadManager.roads (the pool grows by one live segment per entry, minimum 3). If you widen or narrow the road mesh, update TO_CurvedRoad.roadWidth and the two lane offsets in TO_LaneManager so cars sit in the visible asphalt. Leave straightenBeforeDistance and TO_FixFloatingOrigin.zLimit alone unless you see physics jitter on very long runs — they exist to keep coordinates small and are correct for the shipping highway. Always verify a clean compile after any edit, and play-test with the traffic container disabled first so you can confirm the road/lane behavior in isolation before adding traffic back in.

See Also