How I Built a Procedural Race Track Generator in Godot
Every time you press play in Oliver's Racers, the track is different. Not "we shuffled the cones around" different — the entire road layout, every curve, every bridge, is brand-new. And if you're racing online with 29 other people, you're all driving the same circuit. That's the part that almost broke me.
The seed trick
The whole thing hangs on one integer: a random seed.
var s := Net.build_seed(map_id)
rng.seed = sbuild_seed() either pulls the room's agreed-upon seed from the network layer, or — if you're solo — rolls a fresh one with randi() & 0x3fffffff. From that point on, every single call to rng is deterministic. Same seed, same track. I seed everything through that one RandomNumberGenerator instance. No randf() calls. No randi() on the global scope. One slip and two players get different tracks, and ghosts start driving through walls.
I learned that the hard way. Twice.
Control points and Catmull-Rom
The track starts as 9–13 control points arranged in a wobbly oval:
func _generate_track() -> void:
var n := rng.randi_range(9, 13)
var base_r := rng.randf_range(240.0, 320.0)
var ecc := rng.randf_range(0.72, 1.0)
var rot := rng.randf_range(0.0, TAU)
half_w = rng.randf_range(8.0, 10.5)
var ctrl: Array = []
for i in range(n):
var a := TAU * float(i) / float(n)
var r := base_r * rng.randf_range(0.62, 1.36)
ctrl.append(Vector3(cos(a) * r, 0.0, sin(a) * r * ecc).rotated(Vector3.UP, rot))Each control point sits at a random radius along a circle. The ecc value squashes the circle into an ellipse (between 0.72 and 1.0), and the whole thing gets rotated by a random angle so the track doesn't always face the same direction.
Then I run Catmull-Rom interpolation over those control points with 40 segments between each pair:
func _catmull(p0: Vector3, p1: Vector3, p2: Vector3, p3: Vector3, t: float) -> Vector3:
var t2 := t * t
var t3 := t2 * t
return 0.5 * ((2.0 * p1) + (-p0 + p2) * t
+ (2.0 * p0 - 5.0 * p1 + 4.0 * p2 - p3) * t2
+ (-p0 + 3.0 * p1 - 3.0 * p2 + p3) * t3)Catmull-Rom is perfect here because it passes through the control points (unlike Bézier which only approximates them) and it's cheap to evaluate. The result is a PackedVector3Array of around 400 points that form a smooth, closed loop.
Why not Bézier?
I tried cubic Bézier first. The problem: Bézier curves don't pass through their control points — they're attracted toward them. So a tight hairpin in the control polygon becomes a gentle sweeper in the curve. You can fight it by pushing control handles, but then you need twice as many data points and a bunch of tangent math. Catmull-Rom gave me what I wanted out of the box: the curve hits every waypoint, and the tangent at each point is derived from its neighbours. Done.
Bridges and tunnels
Here's where it got interesting. Sometimes the track loops back and nearly crosses itself — that's inevitable with a random closed loop. Instead of trying to avoid self-crossings (which turns into a gross constraint satisfaction problem), I lean into it.
The _carve_crossing() function finds the two points on the loop that are closest together but far apart in index (so they're actually on different "arms" of the track). Then it pulls them onto the same XZ position and splits them in height:
const CROSS_H := 12.0 # bridge deck height above the tunnel
# One arc rises to become the bridge...
pb.y = lerpf(pb.y, CROSS_H, w)
# ...the other stays at ground level as the tunnel
pj.y = lerpf(pj.y, 0.0, w)The w value is a cosine blend that smoothly ramps the elevation over a window of samples each side of the crossing, so you get a gradual rise and fall, not a cliff. The result: a bridge arches over a tunnel. It looks intentional. It even feels intentional to drive. But it's completely emergent from the random layout.
10 checkpoints, always
After the track is generated, I place GATE_COUNT = 10 checkpoint gates evenly around the circuit. The finish line is always gate 0. The track bounds are computed from all the sample points plus some padding, and those bounds feed directly into the minimap (more on that in a future post).
const GATE_COUNT := 10Ten felt right after a lot of testing. Fewer and the laps are too short to build a rhythm. More and it starts to feel like a chore, especially on tighter tracks.
Tangent computation
For road building, the car's off-road detection, and the minimap, I need tangent vectors at every sample. The calculation is dead simple — central differencing:
for i in range(m):
var a: Vector3 = track_pts[(i - 1 + m) % m]
var b: Vector3 = track_pts[(i + 1) % m]
track_tans[i] = (b - a).normalized()track_pts and track_tans are both PackedVector3Array, which Godot packs tightly in memory. Fast to iterate, fast to pass around, and they serialize for free.
What I'd do differently
If I started over, I'd probably add elevation within the Catmull-Rom pass, not as a post-process. Right now the base circuit is totally flat (y = 0) and only the bridge/tunnel carving adds height. Rolling hills would make some tracks way more interesting. But flat + bridge-over-tunnel is so clean for the collision model that I'm not sure the tradeoff is worth it yet.
The procedural track is my favourite piece of this project. It means I never have to design a level. And nobody else has to wait for me to design one either.
Written by Oliver
Indie game developer building 3D web experiences in Godot and Next.js. I write about creative coding, procedural generation, and game feel.