Skip to content

Authoring tracks ​

Tracks are the most complex of the three asset categories — they pair gameplay data (gates, AI spline, pickups, start pose) with optional environment geometry (cliffs, mesas, hand-modeled props). There are three intended workflows:

WorkflowSource of truthBest for
Blender-driventracks-src/<id>.blend (via the in-Blender addon)Hand-modelled terrain, roads, tunnels, ramps, downtown grids — anything with real geometry. → See Blender → Your first track.
Spec-drivenspecs/tracks/<id>.jsonCalibration-style declarative tracks with axis-aligned slabs and analytic AI splines.
Editor-drivenpublic/tracks/<id>.json (live-edited via ?edit=1)Tuning gate / pickup / boost placement visually, often layered on top of a Blender-authored environment.

Mixing them is the common case: you author terrain + roads + tunnels in Blender, click Export Track to Game, then jump into the in-app editor to drag individual gates and pickups into place. The .blend owns geometry + the racing line; the editor owns hand-placed gameplay objects.

New here? Start with the level-making hub

Making a level: start here carries the newcomer reading order, the which-doc-for-whom map, and the export-ownership contract that explains which edits Blender owns vs. the editor. Read it first, then come back for the spec / editor mechanics below.

Authoring in Blender?

The Blender section covers the full pipeline — addon panels, every operator, scene conventions — plus a blank-scene-to-playable-map tutorial. The rest of this page covers the spec-driven and editor-driven flows.

Spec-driven authoring ​

For declarative, calibration-style tracks. Spec → Blender headless → GLB + gameplay JSON.

bash
# 1. Copy an existing spec
cp specs/tracks/test-ring.json specs/tracks/sandbar.json

# 2. Edit it — see "Spec format" below

# 3. Build
pnpm gen:tracks
#    → public/assets/tracks/sandbar.glb           (environment geometry)
#    → public/tracks/sandbar.json                 (gameplay data — gates, spline, pickups)
#    → tracks-src/sandbar.blend                   (for follow-up Blender authoring)

# 4. Try it in-game
open http://localhost:5191/?track=sandbar

Existing JSON is preserved by default

pnpm gen:tracks won't overwrite an existing public/tracks/<id>.json — once you've tuned a track in the in-app editor, the spec is no longer the source of truth for placement. To force-overwrite, set HOVERBIKE_FORCE_GAMEPLAY_JSON=1.

Spec format ​

json
{
  "$schema": "../_schema/track.json",
  "id": "test-ring",
  "displayName": "Test Ring",
  "lapsToFinish": 3,
  "surface": {
    "size": [60, 60]
  },
  "water": {
    "center": [0, 0, 0],
    "extents": [120, 120, 4],
    "waveHeight": 0.6,
    "waveFreq": 0.4
  },
  "checkpoints": [
    { "x":  20, "y":   0, "z": 1.5, "halfWidth": 6, "height": 4 },
    { "x":   0, "y":  20, "z": 1.5, "halfWidth": 6, "height": 4 },
    { "x": -20, "y":   0, "z": 1.5, "halfWidth": 6, "height": 4 },
    { "x":   0, "y": -20, "z": 1.5, "halfWidth": 6, "height": 4 }
  ],
  "aiSpline": [
    [ 20,   0, 0.5],
    [ 14,  14, 0.5],
    [  0,  20, 0.5],
    [-14,  14, 0.5],
    [-20,   0, 0.5],
    [-14, -14, 0.5],
    [  0, -20, 0.5],
    [ 14, -14, 0.5],
    [ 20,   0, 0.5]
  ],
  "starts": [
    [19, -8, 0.5, 3.14159],
    [21, -8, 0.5, 3.14159]
  ],
  "pickups": [
    [ 14,  14, 1.0],
    [-14, -14, 1.0]
  ]
}

Coordinate system ​

Specs use Blender axes — runtime uses three.js axes

The track spec is consumed by Blender headless. Inside the spec, X = Blender X (right), Y = Blender Y (forward), Z = Blender Z (up). The exporter swaps these to three.js (X right, Y up, Z forward) at GLB-write time.

So a checkpoint with { x: 20, y: 0, z: 1.5 } lands at three.js position (20, 1.5, 0). The pattern:

  • Spec X → world right
  • Spec Y → world forward (the track's "down the straight" direction)
  • Spec Z → world up (height)

Pickups, AI-spline points, and start poses follow the same convention.

Field reference ​

FieldNotes
lapsToFinish1–99. Default in code: 3.
surface.size[width, length] of the drivable slab in Blender X/Y.
surface.thicknessSlab thickness in metres. Default 1.0; do not set 0. A 0-thickness plane trimesh is tunnel-prone in Rapier 0.19's discrete broadphase.
water.center[x, y, z] of the water volume center.
water.extents[ex, ey, ez] half-extents of the water box.
water.waveHeightBlender-preview-only amplitude multiplier for the in-viewport wave preview. No longer exported to the runtime JSON (the runtime never read it — live amplitude comes from sky.seaStateBeaufort + waveZones).
water.waveFreqBlender-preview-only frequency multiplier. Same deal as waveHeight.
checkpoints[].x/y/zCenter of the gate (Blender axes). x defaults to 0 if omitted.
checkpoints[].halfWidthLateral half-width of the gate window.
checkpoints[].heightVertical height of the gate window.
aiSplineArray of [x, y, z] (Blender axes), at least 2 points. The runtime resamples this into a dense polyline at boot. Loops automatically.
startsArray of [x, y, z] or [x, y, z, yaw] (yaw in radians, 0 = facing three.js +Z). At least one.
pickupsArray of [x, y, z] pickup spawn locations.

Why surface thickness matters ​

Set the slab thickness to 1 m or more (default works). M9.27 / M9.28 fixed a class of "bike falls through the track" bugs by extruding the surface into a real slab and enabling CCD on the bike rigid body. A 0-thickness trimesh is a portal — the bike will tunnel through it on the first downward step at top speed.

Editor-driven authoring ​

For visual / non-axis-aligned tracks, use the in-app editor. It owns the placement gameplay — gates, pickups, boost pads, props, prop-lines, wave zones (the editor-owned JSON keys). The racing line (aiSplines) and the start pose are Blender-owned, so shape those in Blender — a Blender re-export overwrites editor spline/start edits. Pair the editor with a Blender-authored environmentGlb for collidable terrain.

http://localhost:5191/?edit=1                      # opens lagoon-edit (default)
http://localhost:5191/?track=<id>&edit=1           # opens a specific track

<id> can be:

  • lagoon-edit — JSON snapshot of the procedural Lagoon Loop. Regenerate with node tools/snapshot_lagoon.mjs if lagoon-loop.ts changes.
  • An existing JSON track in public/tracks/<id>.json — opens for editing.
  • A new id (e.g. ?track=mybeach&edit=1) — opens an empty draft. Hit Save to write public/tracks/mybeach.json.

The two procedural tracks (lagoon, cliffside) are built in code and are not editable here.

Workflow ​

  1. Select an entity by clicking its row in the Outliner. Three.js TransformControls gizmo appears.
  2. Switch gizmo mode with the panel buttons or hotkeys (W move, E rotate, R scale).
  3. Drag a handle to move / rotate / scale.
  4. Place new entities with the +Gate / +Pickup / +Boost / +Spline pt / +Asset buttons. The next ground click drops it.
    • New gates auto-bind to the spline at the click's nearest curve point.
    • New spline anchors insert into the segment closest to the click.
  5. Undo with Ctrl/Cmd+Z. 50-deep stack.
  6. Save writes public/tracks/<id>.json via the dev middleware.
  7. Play reloads without ?edit=1.

Gizmo modes ​

ModeHotkeyAffects
MoveWTranslate (X/Y/Z)
RotateEYaw around Y (gates + pads only)
ScaleRResize (gates: halfWidth + height; pads: halfWidth + halfDepth)
  • Gates + Boost Pads support all three modes.
  • Wave Zones + Prop Lines are placeable and editable here too (drop, then move/rotate/scale).
  • Spline-bound gates (the default for new gates) translate by sliding along the spline. Rotation is locked (derived from the curve tangent); scale still works.
  • Pickups + Spline anchors are translate-only.

When scaling, the gizmo stretches during the drag. On drag end the scale is baked into the entity's halfWidth / halfDepth / height and reset to 1. Min sizes clamp to 0.5 m.

Camera ​

ActionEffect
Left-dragOrbit
Right-dragPan
WheelZoom

OrbitControls — damped, polar-clamped to keep the camera above the water plane.

Wiring an env GLB ​

Set environmentGlb in the JSON to the public URL of your Blender export:

json
{
  "id": "mybeach",
  "environmentGlb": "/assets/tracks/mybeach.glb",
  "checkpoints": [],
  "anchors": [],
  "pickups": []
}

In editor mode the GLB is not loaded — you author against the bare water plane without parallax distractions. It loads on Play.

For environment geometry workflow, see docs/blender-pipeline-guide.md.

Splines: anchors vs. legacy points ​

The runtime AI controller follows a dense polyline of ~100+ points. Authors don't want to drag 100 dots, so the JSON supports two formats:

  • anchors (preferred) — sparse Catmull-Rom control points (8–12 is plenty for a stadium loop). The loader resamples them at boot.
  • points (legacy) — the dense polyline as-is. Older tracks (e.g. calibration.json) use this. The editor will show every point as a small dot — workable but tedious. Convert to anchors by hand-editing the JSON when convenient.

When a track has anchors, the dense points field is regenerated on every save and stored as [].

AI spline shape is Blender-owned ​

The editor lets you nudge AI-spline anchors, but aiSplines is a Blender-owned key in the export round-trip: re-running Export Track to Game from the .blend overwrites any spline-anchor edits you made in the editor. Rule of thumb: shape the racing line in Blender, and use the editor for gate / pickup / boost / prop placement — those keys are editor-owned and survive a re-export. (Hand-placed gates are editor-owned too; only the spline shape gets stomped.)

Shortcut routes ​

A track can give its AI shortcuts. Add full-loop lines to aiSplines next to main, with ids starting route: (for example route:slot). Each one is the whole lap with a single shortcut taken. At the start, a few AI riders are assigned a route: one on Casual, one per route on Standard, two per route on Hard. They follow it all the way round; everyone else follows main. The player's autopilot always follows main.

A route line has to obey two rules (tests/unit/route-choice.test.ts checks every track):

  • It crosses every gate, in order. Put gates only where the routes rejoin, never on a stretch that one route skips.
  • Its first point is at the start line. The AI finds its place on a line by searching forward from point 0.

A route line counts as a racing line for the out-of-bounds leash too: a rider is in bounds near main or near any route:* line, so a shortcut can stray from main as far as it needs. Open alt:* lines are for the map tool only and don't count.

Because a Blender re-export rewrites aiSplines, keep route lines in a track whose racing line isn't Blender-owned, or regenerate them after each export. The layout generators in tools/track-gen/ write them for you.

Limits to know about ​

  • No diff vs. saved. The status appears under the Save button, but the underlying file isn't watched for outside changes.

Made with VitePress.