Skip to content

Racing and vehicles ​

Goal ​

Build a solo three-lap circuit with a player car and a CPU rival, using only premade actors and race helpers. Copy templates/blank; the existing templates/race is a running obstacle race, and the kart template is still planned. Vehicle simulation is authoritative; client prediction, boat/aircraft movement and special drift boosts are outside this recipe.

Files you will edit ​

  • src/level.json
  • src/game.ts

Steps ​

  1. Create src/level.json. This flat practice circuit runs up +Z, left along −X, down −Z and back along +X. Level yaw is degrees; each gate's local +Z points forward along the course. Scale sets the gate's half extents. Empty car asset ids use the built-in debug chassis and primitive wheels.
json
{
  "format": 1,
  "actors": [
    {
      "class": "MeshActor",
      "position": [-15, -0.5, 15],
      "props": {
        "asset": "cube-muted",
        "scale": [70, 1, 70],
        "collision": "block",
        "halfExtents": [35, 0.5, 35]
      }
    },
    {
      "class": "WheeledVehicle",
      "name": "player-car",
      "position": [0, 1, 3],
      "props": { "handling": "grippy", "maxSpeed": 18 }
    },
    {
      "class": "WheeledVehicle",
      "name": "rival-car",
      "position": [-3, 1, 6],
      "props": { "handling": "grippy", "maxSpeed": 18 }
    },
    {
      "class": "CheckpointVolume",
      "name": "start",
      "position": [0, 1, 0],
      "scale": [6, 3, 1],
      "props": { "mode": "lap", "raceId": "circuit", "order": 0, "respawnOffset": [0, 1, 3] }
    },
    {
      "class": "CheckpointVolume",
      "name": "north",
      "position": [-15, 1, 30],
      "yaw": 270,
      "scale": [6, 3, 1],
      "props": { "mode": "lap", "raceId": "circuit", "order": 1, "respawnOffset": [0, 1, 3] }
    },
    {
      "class": "CheckpointVolume",
      "name": "west",
      "position": [-30, 1, 15],
      "yaw": 180,
      "scale": [6, 3, 1],
      "props": { "mode": "lap", "raceId": "circuit", "order": 2, "respawnOffset": [0, 1, 3] }
    },
    {
      "class": "CheckpointVolume",
      "name": "south",
      "position": [-15, 1, 0],
      "yaw": 90,
      "scale": [6, 3, 1],
      "props": { "mode": "lap", "raceId": "circuit", "order": 3, "respawnOffset": [0, 1, 3] }
    },
    { "class": "TargetPoint", "name": "line0", "position": [0, 1, 0] },
    { "class": "TargetPoint", "name": "line1", "position": [0, 1, 30] },
    { "class": "TargetPoint", "name": "line2", "position": [-30, 1, 30] },
    { "class": "TargetPoint", "name": "line3", "position": [-30, 1, 0] }
  ]
}
  1. Replace src/game.ts. The level loads before init; register its classes before validation. Gate zero is the start: racers begin expecting gate one, and returning through zero only awards a lap after all other gates.
ts
import {
  AIController,
  CheckpointVolume,
  MeshActor,
  PlayerController,
  TargetPoint,
  WheeledVehicle,
  actors,
  bindVehicleInput,
  createCircuitRace,
  defineGame,
  followRacingLine,
  possess,
  setSeat,
  spawnActor,
  validateLevel,
} from 'gameable';
import settings from '../settings.json';
import data from './level.json';

void WheeledVehicle;
void CheckpointVolume;
void MeshActor;
void TargetPoint;
const race = createCircuitRace({
  id: 'circuit',
  laps: 3,
  maxRacers: 2,
  racerRadius: 2,
  gates: ['start', 'north', 'west', 'south'],
});

export default defineGame({
  features: settings.features,
  world: settings.world,
  assets: ['cube-muted'],
  level: validateLevel(data),
  init(ctx) {
    const car = actors.named('player-car');
    const rival = actors.named('rival-car');
    if (!car || !rival) throw new Error('Missing circuit cars');
    const player = spawnActor(ctx, PlayerController, { position: car.position });
    setSeat(player, 0);
    possess(ctx, player, car);
    bindVehicleInput(player, { brake: 'Control' });
    const ai = spawnActor(ctx, AIController, { position: rival.position });
    possess(ctx, ai, rival);
    followRacingLine(ctx, ai, ['line0', 'line1', 'line2', 'line3'], {
      speed: 9,
      cornerSpeed: 5,
      lookAhead: 6,
      loop: true,
    });
    race.init(ctx);
    race.register(ctx, { racer: 1, seat: 0, entity: car.entity });
    race.register(ctx, { racer: 2, seat: null, entity: rival.entity });
  },
  systems: [race.system],
});

WheeledVehicle already composes vehicleMovement and a heading-following cameraArm chase camera. Choose arcade, grippy or drift with handling; override props rather than writing integration or steering math. The rival's followRacingLine supplies corner braking and stuck recovery. For pursuit, use chaseVehicle; for a single destination, use driveTowards.

  1. Use race.progress(1) and borrowed race.standings for authority logic. Client HUDs read circuitStandings(adapter.hudModel, 'circuit') from gameable: race.system publishes full rows and a revision that changes with public standings, including for replicas and late joiners. Compose custom HUD data in one publisher to avoid overwriting circuit data.

    LevelSettings.killHeight and KillVolume automatically recover registered vehicles at a free circuit gate slot, retry occupied slots, preserve accepted gates/laps and reset handling, velocity, input and grip effects. Manual race.respawn(ctx, 1) is only needed for an explicit recovery action. Outside races, players use checkpoints/PlayerStart; AI uses its spawn pose. For items, compose VehicleGripVolume oil and defineProjectile effect hooks with applyVehicleImpulse / setVehicleGrip from the vehicle reference.

Verify ​

Run npx tsc --noEmit -p tsconfig.json and npm run build in the copied game (in this checkout: npm run build -w templates/blank). Open the bundle in the common player; see examples/play/README.md for local hosting. Start play through the player's Play action.

W/S throttle/reverse, A/D steer, Space handbrakes and Control brakes. The camera follows car heading, and the rival loops the four route points. Drive through north, west, south, then start in their forward directions: completedLaps becomes 1. Skipping west or driving backward through start earns no lap. After three complete circuits finishPlace becomes nonzero. Drive off the platform below LevelSettings.killHeight or into a KillVolume: recovery automatically keeps accepted laps, parks the car at a free gate slot and clears its velocity. Verify circuitStandings shows the same places and laps for a late joiner.

See also ​