I'm one developer making an open-wheel racing game, Apex GP. I can't commission a track artist for every circuit, and a hand-modelled Spa is months of work on its own. So the circuits come from geography instead: the layouts are real, surveyed OpenStreetMap data, and a Python script turns them into drivable tracks.
I'll say up front what this post is not. Apex GP isn't "everything generated by code". The cars are licensed third-party models, and part of the ground texture is CC0 photo scans. I'll cover that below, because it's in the repo and you can check it. The circuits are the part I think is worth writing up.
Every claim here points at a file in the repo.
Stage 1: OSM ways become a closed loop
Raw OpenStreetMap doesn't contain "a circuit". It contains many highway=raceway ways: pieces of the grand prix loop, pit lanes, alternative layouts, kart tracks. tools/fetch_track_data.py downloads the raceway ways, plus buildings, water, woods and roads in a box around the circuit, through Overpass. The results are committed under tools/data/osm/, so builds work offline.
tools/osm_track.py then tries three strategies, in order:
- The official circuit relation. Where OSM has a
type=circuitrelation,relation_loop()chains its member ways end to end into one loop, flipping each way as needed. Pit and alternative-layout members are skipped by role. A gap over 25 m means the chain is rejected. - A raceway graph. Otherwise
gp_loop()builds a graph of all raceway ways, drops pit lanes and kart tracks by name (theBADlist covers several languages, e.g. "boxengasse", "voie des stands"), compresses it to junctions, and runs a depth-first search for simple cycles. It scores each cycle by closeness to the official lap length and by mean distance from a reference outline. - Street circuits. Monaco's lap runs on ordinary streets.
street_loop()snaps a coarse reference outline onto the street graph with Dijkstra, with a penalty for straying from the outline. It then cuts out-and-back spurs where legs overlap.
The reference outline is tools/data/f1-circuits.geojson (MIT, from bacinger/f1-circuits). It only votes: the geometry comes from OSM. In tools/gen_circuits.py, a relation loop is accepted only if its length is within 1.5% of the official lap length and its mean deviation from the reference is under 25 m. Otherwise it falls back to the strategies above.
Tunnels come from the OSM tags (tunnel=yes or a negative layer). They're carried through as a per-point mask, which Monaco needs.
top-down plot of one circuit's raw OSM raceway ways, with pit lane and alternative-layout ways in a different colour from the chosen loop
Stage 2: a rough polyline becomes a racing surface
A surveyed centreline isn't a usable track. tools/gen_circuits.py does this:
- Resample to a section every 2 m (
SP = 2.0), then smooth with a periodic Gaussian applied in the frequency domain (smooth_closed()). - Fix tight corners with
fix_radius(). Plain smoothing collapses a hairpin toward its chord, and the code comment records that Monaco's Loews hairpin lost about 220 m this way. Instead, points in a too-tight window are pushed outward along the normal, so the hairpin keeps its position, turn angle and length. A smoothing pass (fix_radius_smooth()) handles whatever is left. - Choose a start line (
choose_start()): the point nearest the data's start that has at least 180 m of straight behind it. If none qualifies, it takes the middle of the longest straight. - Mark kerbs where the curvature exceeds a radius threshold, widened by 8 sections each side.
- Validate: corner radius, section clearance (so runoff and walls from different parts of the lap don't overlap), grade and a straight start line. A failure aborts the build.
The racing line the AI follows is a minimum-curvature solve: lateral offsets within the road, relaxed by Gauss-Seidel iteration. See racing_line() in tools/blender/track_layout.py.
the final smoothed 2 m centreline of one circuit overlaid on its raw OSM polyline, zoomed on a hairpin, showing the corner kept but opened up
Stage 3: elevation from a DEM
Elevation is sampled from EU-DEM (25 m) or SRTM (30 m) via OpenTopoData, at every tenth section, so every 20 m. The values are cached in tools/data/osm/<id>.elev.json. Digital surface models contain trees and buildings, so osm_elevation() applies a median filter and then a Gaussian of about 40 m. Tunnel sections are interpolated in a straight line between the portals. A grade limiter then relaxes any step steeper than the per-circuit maximum (default 13%). Monaco also clamps the road to stay above the harbour's sea level.
tools/gen_circuits.py also contains an authored elevation profile (elevation_profile(), with keys its comments call approximate). The DEM path above is what osm_elevation() uses for OSM-derived loops.
Stage 4: the mesh is built at runtime, in Godot
This is where the pipeline differs from what the repo layout suggests. tools/blender/build_track.py can build a circuit as a .glb from tools/blender/track_layout.py, which is a hand-placed Catmull-Rom spline. That was the original track. It is no longer the shipped path. The assets() step in build.sh runs tools/gen_circuits.py, which writes one compact JSON per circuit under game/assets/tracks/: centreline, normals, racing line, kerb mask, runoff widths, terrain height grid and placements. game/scripts/circuit_builder.gd builds road, kerbs, runoff, walls, terrain and colliders from that JSON when the circuit loads.
The header of tools/gen_circuits.py gives the reason: 11 tracks cost about 0.4 MB each as JSON, against about 8 MB each as GLB.
in-game wide shot of a circuit from a high chase camera, the same corner as the previous plot, with kerbs, runoff and walls visible
Textures and assets: what is generated and what isn't
tools/blender/build_textures.py generates tileable asphalt, grass, gravel and concrete maps with numpy. The core idea is to filter white noise in the frequency domain, so everything tiles seamlessly by construction (band()). Asphalt adds distance-field "pebbles" for the aggregate, and the normal maps come from the height fields. It runs headless in Blender (flatpak run org.blender.Blender --background --factory-startup --python ...), as the docstring shows.
Some textures are also scans. tools/data/README.md lists Poly Haven and ambientCG CC0 scans in the ground and wall materials, a CC0 sky HDRI, and photo-based foliage composed by tools/gen_foliage.py. The crack and tar-snake overlay (tools/gen_asphalt_detail.py) and the crowd atlas are procedural.
The cars are the largest exception. The shipped car models are de-branded derivatives of CC-BY 4.0 Sketchfab models, each credited on the site's credits page. tools/blender/build_car.py is a procedural open-wheel car generator that I wrote earlier, and it still exists in the repo. The shipped cars are not what it produces. The front-end key art and intro film were generated with xAI Grok Imagine, which tools/gen_ui_media.py calls, and the credits page says so too.
side-by-side of a tileable asphalt albedo from build_textures.py and the same surface on the road in-game
What's still rough
The seams are in the code. Relation chaining accepts gaps up to 25 m, so a badly mapped relation can still produce a visible kink. The graph search has a hard cutoff of 400,000 nodes visited. The DEM is coarse, so small crests and dips are smoothed away. Next I'd build a visual diff tool that overlays each generated centreline on its OSM source and flags where the smoothing moved the line most.
The game
Apex GP is at apexgp.racing. Circuit names are parodies of the real places, and all teams, drivers and sponsors are fictional.
AI assistance
The code, shaders and some assets in Apex GP were produced with AI tools, under my direction. The circuit geometry and the procedural textures come from deterministic numeric code (the scripts above), not from a generative model. Some front-end media and the announcer voice lines were generated with AI models, and the credits page marks them as such.
— Phil