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.jsonsrc/game.ts
Steps
- 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] }
]
}- Replace
src/game.ts. The level loads beforeinit; 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.
Use
race.progress(1)and borrowedrace.standingsfor authority logic. Client HUDs readcircuitStandings(adapter.hudModel, 'circuit')fromgameable:race.systempublishes fullrowsand arevisionthat changes with public standings, including for replicas and late joiners. Compose custom HUD data in one publisher to avoid overwriting circuit data.LevelSettings.killHeightandKillVolumeautomatically recover registered vehicles at a free circuit gate slot, retry occupied slots, preserve accepted gates/laps and reset handling, velocity, input and grip effects. Manualrace.respawn(ctx, 1)is only needed for an explicit recovery action. Outside races, players use checkpoints/PlayerStart; AI uses its spawn pose. For items, composeVehicleGripVolumeoil anddefineProjectileeffect hooks withapplyVehicleImpulse/setVehicleGripfrom 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.