Build a labelled racing circuit with a script

A whole racing circuit from one short list of corner points: a road ribbon on rolling terrain, a harbour setting built from a handful of shared shapes, and — the part that matters to a game — every object given a name and classes at the moment it is created, so a game engine can find the start line, the checkpoints and the pickups by name. Open the editor, open the Shell tab, and paste each block in order, or run the whole generator, circuit-build.js (step 8).

An aerial view of a racing circuit: a looping asphalt road with red and white kerbs on green rolling terrain, a grandstand and a pit garage, a harbour quay with stacked shipping containers and a crane along the bottom, and low hills behind.
The finished circuit running in strata-play: 19,052 triangles, ten shared materials, 436 named nodes.
Two Shell facts this page relies on. The Shell does not keep variables between pastes (a const from one block is gone in the next), so each block ends by storing what later blocks need on window.c1 and begins by reading it back. And if Enter seems to do nothing after a long paste, press Esc first (it closes the autocomplete dropdown that was swallowing the keypress), then Enter. On a non-empty scene, run clearScene() first.

1 Plan the lap as numbers

Decide the track before placing anything. The lap is thirteen corner points on a ground plane (x east, z south, metres). Every corner is rounded with an arc that is tangent to both straights, and a sign tells you which way it turns, so the turn balance is something you can read off instead of eyeball. The centreline is then sampled into points that carry their position, tangent and distance along the lap. Everything after this — road, kerbs, gates, start grid — is computed from those samples:

const P = [[-58,-40],[36,-30],[84,-74],[146,-56],[164,6],[128,60],[72,24],[22,12],[-30,30],[-66,70],[-126,66],[-166,18],[-122,-72]];
const ROAD_W = 14.5, CURB_W = 1.2, R_MAX = 70, n = P.length;

const unit = (a, b) => { const dx = b[0] - a[0], dz = b[1] - a[1], l = Math.hypot(dx, dz); return [dx / l, dz / l, l]; };
const corners = P.map((p, i) => {
  const prev = P[(i + n - 1) % n], next = P[(i + 1) % n];
  const [d0x, d0z, l0] = unit(prev, p), [d1x, d1z, l1] = unit(p, next);
  const turn = Math.atan2(d0x * d1z - d0z * d1x, d0x * d1x + d0z * d1z);   // positive = right turn
  const phi = Math.abs(turn), sgn = Math.sign(turn), tanHalf = Math.tan(phi / 2);
  const r = Math.min(R_MAX, 0.45 * Math.min(l0, l1) / tanHalf), t = r * tanHalf;
  const A = [p[0] - d0x * t, p[1] - d0z * t], B = [p[0] + d1x * t, p[1] + d1z * t];   // arc start and end
  const inN = sgn > 0 ? [-d0z, d0x] : [d0z, -d0x];
  return { i, p, d0: [d0x, d0z], phi, sgn, r, A, B, C: [A[0] + inN[0] * r, A[1] + inN[1] * r] };
});
const balance = { right: corners.filter(c => c.sgn > 0).length, left: corners.filter(c => c.sgn < 0).length };
if (Math.min(...corners.map(c => c.r)) < ROAD_W / 2 + CURB_W + 2) throw new Error('corner radius too tight for the road width');

// Sample the centreline: the straight before each corner, then its arc. s = metres along the lap.
const pts = [], runs = [];
let s = 0;
corners.forEach((c, i) => {
  const from = corners[(i + n - 1) % n].B, len = Math.hypot(c.A[0] - from[0], c.A[1] - from[1]);
  const steps = Math.max(1, Math.ceil(len / 12)), [dx, dz] = c.d0;
  runs.push({ corner: i, s0: s, len });
  for (let k = 0; k < steps; k++) { pts.push({ x: from[0] + dx * len * k / steps, z: from[1] + dz * len * k / steps, tx: dx, tz: dz, s }); s += len / steps; }
  const arcLen = c.r * c.phi, asteps = Math.max(3, Math.ceil(arcLen / 3)), a0 = Math.atan2(c.A[1] - c.C[1], c.A[0] - c.C[0]);
  for (let k = 0; k < asteps; k++) {
    const u = k / asteps, a = a0 + c.sgn * c.phi * u, h = c.sgn * c.phi * u;
    pts.push({ x: c.C[0] + c.r * Math.cos(a), z: c.C[1] + c.r * Math.sin(a), tx: c.d0[0] * Math.cos(h) - c.d0[1] * Math.sin(h), tz: c.d0[0] * Math.sin(h) + c.d0[1] * Math.cos(h), s });
    s += arcLen / asteps;
  }
});
const LAP = s, N = pts.length;

// Position + tangent at any distance along the lap (binary search, then interpolate)
const frameAt = (sq) => {
  sq = ((sq % LAP) + LAP) % LAP;
  let lo = 0, hi = N - 1; while (lo < hi) { const m = (lo + hi + 1) >> 1; if (pts[m].s <= sq) lo = m; else hi = m - 1; }
  const a = pts[lo], b = pts[(lo + 1) % N], sb = lo + 1 < N ? b.s : LAP, u = (sq - a.s) / Math.max(1e-6, sb - a.s);
  let tx = a.tx + (b.tx - a.tx) * u, tz = a.tz + (b.tz - a.tz) * u; const tl = Math.hypot(tx, tz); tx /= tl; tz /= tl;
  return { x: a.x + (b.x - a.x) * u, z: a.z + (b.z - a.z) * u, tx, tz };
};
window.c1 = { ROAD_W, pts, runs, LAP, N, frameAt };
({ balance, lapMetres: Math.round(LAP), samples: N });

2 One creation helper that labels at birth

The Shell's selector language, $S, edits and queries what exists; it cannot create the first object. Creation goes through the editor. Rather than create everything and then run a labelling pass, one small helper, make(), takes the id and classes as arguments and sets them before the object ever enters the scene. Ids are PascalCase and unique; classes are kebab-case words that say what a thing is (surface, trigger, checkpoint). Ten shared materials, no textures, and a seeded random generator so a layout is repeatable:

const mulberry = (a) => () => { a |= 0; a = a + 0x6D2B79F5 | 0; let t = Math.imul(a ^ a >>> 15, 1 | a); t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t; return ((t ^ t >>> 14) >>> 0) / 4294967296; };
const rand = mulberry(20241024);   // every "random" layout choice comes from this, never Math.random()

const mat = (name, o) => { const m = new THREE.MeshStandardMaterial({ roughness: 0.9, metalness: 0, ...o }); m.name = name; return m; };
const M = {
  Asphalt: mat('M_Asphalt', { color: 0x3a3d42, roughness: 0.95 }),
  Grass: mat('M_Grass', { color: 0x4f8a3c, roughness: 1 }),
  Paint: mat('M_Paint', { vertexColors: true, roughness: 0.8 }),
  Sea: mat('M_Sea', { color: 0x1d6f9e, roughness: 0.08, metalness: 0.2 }),
  Concrete: mat('M_Concrete', { color: 0x9a9a94, roughness: 0.95 }),
  Glass: mat('M_Glass', { color: 0x7fd6ff, roughness: 0.1, metalness: 0.1, transparent: true, opacity: 0.35 }),
  PickupCore: mat('M_PickupCore', { color: 0xffb020, emissive: 0xff7a00, emissiveIntensity: 0.6, roughness: 0.3, metalness: 0.5 }),
  Gate: mat('M_GateVolume', { color: 0xff3df2, transparent: true, opacity: 0.14, roughness: 1, side: THREE.DoubleSide, depthWrite: false }),
};

// THE creation primitive: id, classes and parent are given here, once.
const make = (id, classes, geometry, material, parent, pos, ry) => {
  const o = geometry ? new THREE.Mesh(geometry, material) : new THREE.Group();
  o.name = id; o.userData.label = id; o.userData.customClasses = classes.split(' ');
  if (pos) o.position.set(pos[0], pos[1], pos[2]);
  if (ry) o.rotation.y = ry;
  if (parent) parent.add(o);
  return o;
};
const two = (v) => String(v).padStart(2, '0');
const withColor = (g, hex) => { const c = new THREE.Color(hex), a = new Float32Array(g.attributes.position.count * 3); for (let i = 0; i < a.length; i += 3) { a[i] = c.r; a[i + 1] = c.g; a[i + 2] = c.b; } g.setAttribute('color', new THREE.BufferAttribute(a, 3)); return g; };

// Two scene roots. Gameplay sits at the root with an identity transform, so its children's positions are world positions.
const course = make('Circuit1', 'course');
const track = make('Track', 'surface', null, null, course);
const trackside = make('Trackside', 'trackside', null, null, course);
const environment = make('Environment', 'scenery', null, null, course);
const gameplay = make('Gameplay', 'gameplay');
editor.execute(new AddObjectCommand(editor, course));
editor.execute(new AddObjectCommand(editor, gameplay));
Object.assign(window.c1, { rand, M, make, two, withColor, track, trackside, environment, gameplay });
Object.keys(M).length;   // 8

3 The road, the terrain and the sea

A ribbon is a strip along the centreline between two lateral offsets: two vertices per sample, one quad between neighbours, with uv.v running along the lap every 10 m. The ground follows the same height function H(x, z) as the road, so the road always sits on the terrain, and the relief fades to flat toward the edges:

const { ROAD_W, pts, N, make, M, track, environment } = window.c1;

const smooth = (a, b, v) => { const t = Math.min(1, Math.max(0, (v - a) / (b - a))); return t * t * (3 - 2 * t); };
const H = (x, z) => (3.2 * Math.sin(x / 62 + 0.6) * Math.cos(z / 58) + 1.6 * Math.sin((x + z) / 37)) * (1 - smooth(70, 98, Math.abs(z)));
const rightOf = (p) => [-p.tz, p.tx];

// a strip along the lap between two lateral offsets (positive = right of travel)
const ribbon = (o0, o1, dy) => {
  const pos = [], uv = [], idx = [];
  for (let k = 0; k < N; k++) {
    const p = pts[k], r = rightOf(p);
    for (const o of [o0, o1]) { const x = p.x + r[0] * o, z = p.z + r[1] * o; pos.push(x, H(x, z) + dy, z); }
    uv.push(0, p.s / 10, 1, p.s / 10);
  }
  for (let k = 0; k < N; k++) { const a = 2 * k, b = 2 * ((k + 1) % N); idx.push(a, a + 1, b, a + 1, b + 1, b); }
  const g = new THREE.BufferGeometry();
  g.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3)); g.setAttribute('uv', new THREE.Float32BufferAttribute(uv, 2));
  g.setIndex(idx); g.computeVertexNormals();
  if (g.attributes.normal.getY(0) < 0) { const ix = g.index.array; for (let i = 0; i < ix.length; i += 3) { const t = ix[i + 1]; ix[i + 1] = ix[i + 2]; ix[i + 2] = t; } g.computeVertexNormals(); }   // wound the wrong way: flip
  return g;
};
make('Road', 'surface road drivable', ribbon(-ROAD_W / 2, ROAD_W / 2, 0.03), M.Asphalt, track);

// terrain: a plane displaced by the same height function
const tg = new THREE.PlaneGeometry(400, 210, 40, 21).rotateX(-Math.PI / 2), tp = tg.attributes.position;
for (let i = 0; i < tp.count; i++) tp.setY(i, H(tp.getX(i), tp.getZ(i)) - 0.12);
tg.computeVertexNormals();
make('Terrain', 'surface grass offroad', tg, M.Grass, track);

make('Sea', 'scenery water', new THREE.PlaneGeometry(900, 170).rotateX(-Math.PI / 2), M.Sea, environment, [0, -0.8, 190]);
make('Quay', 'scenery', new THREE.BoxGeometry(400, 2, 14).translate(0, -0.2, 0), M.Concrete, environment, [0, 0, 98]);
Object.assign(window.c1, { H, rightOf });
editor.signals.sceneGraphChanged.dispatch();
({ roadTriangles: $S('#Road').nodes[0].geometry.index.count / 3 });

The road is 454 triangles: one quad per ~3 m of arc and ~12 m of straight is all a drivable surface needs. The full script adds the kerbs, the centre dashes and the chequered start strip the same way, as extra ribbons and merged boxes (circuit-build.js, sections 7–8).

4 Shared geometry: the container stacks

The harbour is dozens of near-identical stacks of shipping containers. Define each shape once and place it many times: every mesh that uses the same geometry object shares one copy in memory and, later, one copy in the exported file. Colour comes from per-vertex colour on a single shared M_Paint material, so four colours cost four small geometries and still one material. The seeded rand() decides where gaps go, so the quay looks irregular but is identical every run:

const { rand, M, make, two, withColor, trackside } = window.c1;

const colours = ['#c0392b', '#2471a3', '#d4a017', '#2e8b57'];
const stackGeo = colours.map(hex => withColor(new THREE.BoxGeometry(12, 2.6, 2.4).translate(0, 1.3, 0), hex));   // four shapes, defined once

let stack = 0;
for (const zRow of [94.5, 100.5]) for (let x = -186; x <= 186; x += 13) {
  if (rand() < 0.25) continue;
  stack++;
  make('ContainerStack' + two(stack), 'trackside wall', stackGeo[Math.floor(rand() * 4)], M.Paint, trackside, [x, 0.8, zRow]);
}
editor.signals.sceneGraphChanged.dispatch();
({ stacks: stack, sharedGeometries: new Set($S('.trackside').nodes.filter(o => o.geometry).map(o => o.geometry)).size });

In the full script the same idea builds the crane, the grandstand with its crowd, the pit garage, thirty-six shrubs, tyre barriers and the hills: a few shared shapes, placed by the seeded generator.

5 The part a game engine wires: gates, grid, racing line, pickups

Everything a game needs to run a lap is an anchor: a named node at a known place. The scene contains no game logic; it only says where things are. All anchors live under the Gameplay root in world space:

Anything with a direction gets forward: '+z' written into its data, which exports as extras.forward: the file states its own convention instead of leaving the next author to guess.

const { LAP, runs, frameAt, make, M, two, gameplay, H, rightOf } = window.c1;
const yawOf = (p) => Math.atan2(p.tx, p.tz);                     // rotation.y that points local +z along the lap
const fwd = (o) => { o.userData.forward = '+z'; return o; };
const startS = runs[1].s0 + runs[1].len * 0.3;                    // start line on the straight before the second corner

// one gate shape, placed eight times
const gate = new THREE.BoxGeometry(14.5 + 4, 5, 1.2);
const f0 = frameAt(startS);
fwd(make('StartLine', 'trigger start-line', gate, M.Gate, gameplay, [f0.x, H(f0.x, f0.z) + 2.5, f0.z], yawOf(f0)));
for (let k = 1; k <= 7; k++) {
  const c = frameAt(startS + LAP * k / 8);
  fwd(make('Checkpoint' + two(k), 'trigger checkpoint', gate, M.Gate, gameplay, [c.x, H(c.x, c.z) + 2.5, c.z], yawOf(c)));
}

// eight grid slots behind the line, two abreast
for (let k = 0; k < 8; k++) {
  const g = frameAt(startS - 9 - Math.floor(k / 2) * 7 - (k % 2) * 3.5), r = rightOf(g), o = (k % 2 ? 1 : -1) * 3.2;
  const x = g.x + r[0] * o, z = g.z + r[1] * o;
  fwd(make('SpawnPoint' + two(k + 1), 'spawn-point', null, null, gameplay, [x, H(x, z) + 0.3, z], yawOf(g)));
}

// racing line: empty nodes down the road centre in driving order; the last is followed by the first
const count = Math.round(LAP / 5), line = make('RacingLine', 'racing-line', null, null, gameplay);
for (let k = 0; k < count; k++) {
  const f = frameAt(startS + LAP * k / count);
  fwd(make('RacingLine' + String(k + 1).padStart(3, '0'), 'racing-line-point', null, null, line, [f.x, H(f.x, f.z) + 0.03, f.z], yawOf(f)));
}
line.userData.closed = true;

// item boxes: one definition (a glass shell round a core), a row of four across the longest straight
const shellGeo = new THREE.BoxGeometry(1.7, 1.7, 1.7), coreGeo = new THREE.BoxGeometry(0.8, 0.8, 0.8).rotateY(Math.PI / 4);
const itemBoxes = make('ItemBoxes', 'pickup-set', null, null, gameplay);
const run = runs.filter((r, i) => i !== 1).sort((a, b) => b.len - a.len)[0];
const fm = frameAt(run.s0 + run.len / 2), rm = rightOf(fm);
[-5.1, -1.7, 1.7, 5.1].forEach((o, i) => {
  const id = 'ItemBox' + two(i + 1), x = fm.x + rm[0] * o, z = fm.z + rm[1] * o;
  const b = make(id, 'pickup item-box trigger', null, null, itemBoxes, [x, H(x, z) + 1.5, z], yawOf(fm));
  make(id + 'Shell', 'item-box-part', shellGeo, M.Glass, b);
  make(id + 'Core', 'item-box-part', coreGeo, M.PickupCore, b);
});
editor.signals.sceneGraphChanged.dispatch();
({ gates: $S('.checkpoint').count(), grid: $S('.spawn-point').count(), racingLinePoints: count, itemBoxes: $S('.item-box').count() });
Why Gameplay is its own root, not a child of the circuit. strata-play's physics harness copies each labelled node's position straight onto a body and assumes the node is an untransformed child of the scene root. Keeping the anchors at the root with an identity transform makes their positions world positions in every runtime, so nothing has to guess an offset.

6 Light, and the $S passes

Now $S earns its keep: after creation, settings that apply to a class of things are one-liners. Receiving shadows belongs to everything classed surface; casting to scenery and trackside objects; the invisible trigger volumes do neither. The sun needs one extra line that is easy to miss, explained below:

const { environment } = window.c1;
const sunlight = new THREE.DirectionalLight(0xfff1d6, 2.2);
sunlight.name = 'Sunlight'; sunlight.userData.label = 'Sunlight'; sunlight.userData.customClasses = ['effect'];
sunlight.position.set(-150, 220, -120);
sunlight.lookAt(0, 0, 0);          // see the note: the export needs a rotation, not just a position
sunlight.castShadow = true;
Object.assign(sunlight.shadow.camera, { left: -230, right: 230, top: 140, bottom: -140, near: 10, far: 600 });
sunlight.shadow.mapSize.set(2048, 2048);
const sky = new THREE.HemisphereLight(0xbfe3ff, 0x6a7f4a, 0.9);
sky.name = 'SkyLight'; sky.userData.label = 'SkyLight'; sky.userData.customClasses = ['effect'];
environment.add(sunlight, sky);
setBackground('#a9d4ee');

$S('.surface').receiveShadow(true);
$S('mesh.scenery').castShadow(true);
$S('mesh.trackside').castShadow(true);
$S('.trigger').castShadow(false);
editor.signals.sceneGraphChanged.dispatch();
'ok';
The sun trap. A glTF directional light shines down its node's local −z. In the editor a light's direction comes from its target, so a light with only a position looks right on screen, but the exported file stores the node's rotation and the target is not exported. Without lookAt, the sun in the exported file points along −z, edge-on to the ground, and lights almost nothing. The exporter prints "Light direction may be lost" whether or not you added it, so the warning is not the check: read the file (step 9).

7 Check it

Before exporting, confirm the two properties a downstream engine depends on: every labelled object resolves by name, and the whole set can be listed from the scene alone. Two lines do it:

const names = [];
editor.scene.traverse(o => { if (o !== editor.scene && o.userData.label) names.push(o.name); });
const notAddressable = names.filter(nm => $S('#' + nm).count() !== 1);
({ labelled: names.length, notAddressable: notAddressable.length, triggers: $S('.trigger').count(), gameplayIsATrigger: $S('.trigger').nodes.some(o => o.name === 'Gameplay') });

The full check, circuit-verify.js, adds a third property — totality: it fires 21 sampled operations at the scene (some deliberately wrong, such as scale with factor: 'big') and requires each to either apply to every matched node or fail cleanly, never half-apply and never write a NaN. It earned its place: its first run found that move, scale, rotate and duplicate reported success while writing NaN into transforms when an argument was missing or not a number. That was an editor bug, now fixed. The check tests this scene, not the editor in general: a pass does not prove the operations are safe everywhere.

8 Run the whole thing, and get the same bytes every time

The blocks above are the skeleton. circuit-build.js is the complete generator: the same code plus kerbs and markings, the harbour crane, grandstand and crowd, pit garage, shrubs, hills, tyre barriers, all 18 item boxes and 5 boost pads, and the camera. On a cleared scene, paste it into the Shell (it takes a few seconds). It is deterministic, which is worth designing for from the start:

Built and serialised twice, the scene came out byte-identical (1,952,098 bytes, SHA-256 beginning 3d248fb9). If yours differs, something in your run depended on time or order.

// from circuit-build.js: the two seeded generators
const SEED = 20241024, UUID_SEED = 7777;
const rand = mulberry(SEED);
const realRandom = Math.random;
Math.random = mulberry(UUID_SEED);
try { /* ...the whole build... */ } finally { Math.random = realRandom; }

9 Export it and read the file back

Open the Export tab and click the GLB label. Then check the file itself, not the editor's idea of it. This reads a GLB's JSON chunk from a file on disk; save it as inspect-glb.mjs and run node inspect-glb.mjs <the downloaded file> (the download is named after the scene: the file it was loaded from, the scene's own name, or the one thing in it, and scene.glb only when none of those says anything):

import { readFileSync } from 'node:fs';
const b = readFileSync(process.argv[2]);
const j = JSON.parse(b.toString('utf8', 20, 20 + b.readUInt32LE(12)));   // the JSON chunk follows the 12-byte header and 8-byte chunk header
const sun = j.nodes.find(x => x.name === 'Sunlight');
console.log({
  nodes: j.nodes.length, meshes: j.meshes.length,
  materials: [...new Set(j.materials.map(m => m.name))].sort(),
  sunForward: sun && sun.matrix ? [-sun.matrix[8], -sun.matrix[9], -sun.matrix[10]].map(v => +v.toFixed(2)) : null,
  logic: j.scenes[j.scene].extras && j.scenes[j.scene].extras['strata:logic'],
});

For the full script's scene expect nodes: 436, ten M_ materials, a sunForward that points down and toward the middle of the track (about [0.51, -0.75, 0.41]), meshes: 41 and logic: 'outputs/track.js'. The step-by-step blocks alone give a smaller scene (244 nodes, eight materials, no logic, since only the full script sets it) but the same sun direction. Each check below is a trap that was hit while building it:

10 Play it in strata-play

Commit the GLB to a GitHub repo and open it in strata-play by repo and file: #repo=<owner>/<repo>&file=outputs/track.glb. With no entry module it falls back to an orbit view, which is a good way to check the scene by eye. Every label should resolve from the page's console; this is the same query a game uses:

// in strata-play's sandbox frame
[ctx.$S('#StartLine').count, ctx.$S('.checkpoint').count, ctx.$S('.spawn-point').count, ctx.$S('.trigger').count]
// → [1, 7, 8, 31]   (31 = StartLine + 7 checkpoints + 18 boxes + 5 pads; 32 would mean a material is named like a class)
The same circuit viewed from the same aerial camera with no tone mapping, no shadows and flat lighting: flat colours, no cast shadows.
As exported, with the runtime's defaults: flat light, no shadows.
The same circuit from the same camera with tone mapping, cast shadows from trees, containers and the crane, a sky environment and a light post-processing pass.
The same file with tone mapping, class-based shadows, a generated sky and light post-processing applied by code at runtime.
What to try next. Change a corner point in step 1 and re-run: the road, kerbs, gates, grid and racing line all follow, and the scene is still byte-identical for the same corner list. Add a class to the vocabulary (say pit-lane), label a few nodes with it in make(), and query it with $S('.pit-lane'): that is the whole mechanism a game uses to find things.

Credits and licences

People

Assets

None used.

Tools

None used.