Sitelet https://flutter3d.pleion.dev/racing/tutorial/
flutter3d
Showcase Changelog 44 packages API reference

Tutorial: build a racing game

Twelve steps. The engine underneath is the one the shooter and the platformer use, unchanged. What is new is that the ground is a curve.

  • A track authored as a measured centre line with width, camber and surface bands
  • A car that understeers, oversteers and can be caught, on a tire curve rather than a friction constant
  • Laps counted through checkpoints, positions, a countdown and a finish
  • Three AI drivers that brake for the corner ahead and move over for each other
  • A chase camera that follows where the car is going, not only where it points

Set the project up #

dependencies:
  flutter: { sdk: flutter }

  flutter3d_impeller: ^0.8.0
  flutter3d:          ^0.8.0
  flutter3d_game:     ^0.8.0
  flutter3d_game_racing: ^0.8.0
  flutter3d_app:      ^0.8.0
  flutter3d_audio:    ^0.8.0
  vector_math: ^2.2.0

The versions come from pub.dev. Every line is on the same 0.7.0 set, and the lines have to agree: flutter3d_app 0.7.0 asks for flutter3d 0.7.0, so one package left on 0.6.0 stops pub get. To work against a checkout instead, for engine changes of your own, swap each line for a path: into it. Your first project covers the Flutter version that goes with the pubspec.

No flutter3d_game_shooter and no flutter3d_game_platformer. A genre is a package, and this one inherits nothing from either.

Decide what a driver may ask for #

A car has a throttle and a brake, not a forward and a back. GameAction is a string, not an enum, for this reason: a genre declares its own verbs without editing the engine.

abstract final class Drive {
  static const GameAction throttle = GameAction('throttle');
  static const GameAction brake = GameAction('brake');
  static const GameAction left = GameAction('steerLeft');
  static const GameAction right = GameAction('steerRight');
  static const GameAction handbrake = GameAction('handbrake');
}

final bindings = Bindings(<InputSource, GameAction>{});
void bind(LogicalKeyboardKey key, GameAction action) =>
    bindings.bind(InputSource.key(key.keyId), action);

bind(LogicalKeyboardKey.keyW, Drive.throttle);
bind(LogicalKeyboardKey.arrowUp, Drive.throttle);
bind(LogicalKeyboardKey.keyS, Drive.brake);
bind(LogicalKeyboardKey.arrowDown, Drive.brake);
bind(LogicalKeyboardKey.keyA, Drive.left);
bind(LogicalKeyboardKey.arrowLeft, Drive.left);
bind(LogicalKeyboardKey.keyD, Drive.right);
bind(LogicalKeyboardKey.arrowRight, Drive.right);
bind(LogicalKeyboardKey.space, Drive.handbrake);

Author a track #

A track document holds two halves: the spline this genre reads, and an ordinary level the engine has read since the first game. One script writes both. For the shipped circuits it is packages/flutter3d_editor_core/tool/levels/racing.dart (dart run tool/regenerate_levels.dart, from that package), which writes each circuit twice: ring.json with the level embedded, and ring_level.json beside it for LevelLoader. Edit the script rather than the JSON; a circuit is several hundred numbers that have to agree with each other, and a person editing them by hand introduces exactly one disagreement and does not notice.

{
  "version": 1,
  "name": "Ring",
  "track": {
    "closed": true,
    "shoulder": 4.0,
    "points": [
      {"at": [0, 0, 0],     "width": 14.0, "bank": 0.0},
      {"at": [120, 0, 40],  "width": 14.0, "bank": 3.0},
      {"at": [180, 0, 150], "width": 11.0, "bank": 7.0},
      {"at": [60, 2, 210],  "width": 12.0, "bank": 0.0}
    ],
    "surfaces": [
      {"fromS": 0.0,   "toS": 240.0, "centre": "asphalt", "shoulder": "kerb"},
      {"fromS": 240.0, "toS": 380.0, "centre": "asphalt", "shoulder": "gravel"}
    ],
    "barriers": [{"fromS": 180.0, "toS": 320.0, "right": true}],
    "checkpoints": [{"s": 250.0}, {"s": 520.0}, {"s": 780.0}],
    "grid": {"s": -14.0, "columns": 2, "rowGap": 8.0, "columnGap": 4.0}
  },
  "level": { "version": 1, "brushes": ["… the ordinary level format …"] }
}

One width and one bank per control point, every width positive, and checkpoints in ascending order. TrackSpline's constructor throws on each of these rather than producing a track that behaves oddly a lap later. bank is authored in degrees; the reader converts to the radians the maths wants.

Read it with TrackDocument, which hands back the spline, the level and the sky:

final text = await rootBundle.loadString('assets/tracks/ring.json');
final document = TrackDocument.fromJson(
  jsonDecode(text) as Map<String, Object?>,
);
final track = document.track;

Load the scenery through the engine #

final loaded = await const LevelLoader().load(
  'assets/tracks/ring_level.json',
  device: device,
  // This circuit places no entities — the scenery is brushes — so the registry
  // is empty rather than absent: the loader validates against it, and an empty
  // one is the statement that nothing is expected.
  registry: EntityRegistry(const <EntityKind>[]),
);

Then turn the curve into meshes. That is bridge.dart, the one file in the genre that knows what a mesh is. It builds the road, the verges and the walls as MeshData from the same curve the cars drive on, and uploading them into the scene is the application's job (the shipped demo does it in an addTrackTo helper in lib/src/looks.dart, one DeviceMesh.upload and one MeshNode per mesh):

final road = buildRoadMesh(track);
final leftVerge = buildVergeMesh(track, side: -1);
final rightVerge = buildVergeMesh(track, side: 1);
final barriers = buildBarrierMeshes(track, side: 1);

RoadMeshSettings controls the cut. The subdivision rule is stated as an error, not a step count: sagitta is how far the middle of a straight edge may sit from the curve it stands in for, so a hairpin gets short segments and a straight gets long ones without anybody choosing a number per corner.

buildRoadMesh(track, settings: const RoadMeshSettings(
  sagitta: 0.04,        // four centimetres, well under a kerb
  minStep: 1.5,
  maxStep: 8.0,         // not about accuracy: about per-vertex lighting and fog
  metresPerTile: 9.0,
  barrierHeight: 1.1,
));

Give the cars a ground to find #

final field = TrackField(track: track, world: loaded.collision);

GroundField is one method. It answers with a point, a normal, an arc length and a surface name, and falls back to a probe against the collision world where the curve stops describing the ground.

abstract interface class GroundField {
  bool sample(Vector3 position, double nearHint, GroundSample out);
}

nearHint is the car's last known arc length, and it is the reason this is affordable. Finding the nearest point on a closed kilometre-long spline from nothing is a global search; from a hint it is a window of a few metres. CatmullRom offers both, closestS(point, nearS:, window:) and closestSGlobal, and only the first belongs in a step.

Build the grid #

final race = RaceState(mode: RaceMode.race, track: track, racers: 4, laps: 3);

final position = Vector3.zero();
final forward = Vector3.zero();
final cars = <SphereVehicle>[];

for (var i = 0; i < 4; i++) {
  track.startSlot(i, position, forward);
  final car = SphereVehicle(
    world: loaded.collision,
    ground: field,
    position: position.clone()..y += 0.6,
    headingYaw: math.atan2(forward.x, forward.z),
  );
  // Told where it is on the lap, so the first `sample` has a hint and the
  // first progress read is not a global search from the wrong end.
  car.placeAt(car.position, car.headingYaw,
      trackDistance: track.centre.wrap(track.grid.s));
  cars.add(car);
}

Tune the car, and then the tire #

These are the numbers the game is. Change one and re-run the tests before changing a second.

const tuning = VehicleTuning(
  radius: 0.6,
  rideHeight: 0.35,
  maxSpeed: 62.0,        // about 220 km/h
  maxReverse: 12.0,
  enginePush: 14.0,
  brakeStrength: 26.0,   // brakes beat the engine, or nothing stops
  rollingDrag: 0.6,
  airDrag: 0.0006,       // per unit of speed squared; the real top-speed ceiling
  maxSteer: 0.62,        // radians at a standstill
  steerFalloff: 26.0,    // the speed at which steering has closed to half
  wheelBase: 2.6,
  gravity: 22.0,         // positive is down, and above the real figure on purpose
  groundStick: 0.45,     // how far below the car the ground still counts as under it
  suspensionRate: 9.0,
  slideAlignment: 6.0,   // how strongly a slide drags the nose round with it
  wheelInertia: 0.5,     // what makes a wheelspin a wheelspin
);

The tire is where the feel lives, and it is one object: the curve, what each surface is worth to it, and how many gravities of grip there are to divide up. Tyres holds the three, so a game can offer a player a choice between sets without passing arguments that have to agree.

final tyres = Tyres(
  name: 'road',
  model: TireModel(peakSlipAngle: 0.14, peakSlipRatio: 0.12),
  grips: const GripTable(<String, double>{
    'asphalt': 1.00,
    'kerb':    0.85,
    'gravel':  0.45,
    'grass':   0.35,
  }, fallback: 0.5),
  limit: 1.05,           // gravities of grip on a surface worth 1.0
);

Both are constructor arguments, so hand them to each car in the grid loop:

final car = SphereVehicle(
  world: loaded.collision,
  ground: field,
  position: position.clone()..y += 0.6,
  headingYaw: math.atan2(forward.x, forward.z),
  tuning: tuning,
  tyres: tyres,
);

A car given neither runs on VehicleTuning()'s defaults and Tyres.road, which is what the shipped demo does. Tyres.slicks and Tyres.rally ship too, and pitStop swaps sets at a standstill.

A curve that rises to a peak and falls away past it is the whole reason a car can be driven over the limit and caught. A constant coefficient gives a car that grips until it does not, with nothing in between, and no amount of tuning elsewhere puts that back.

TireModel.clampToCircle is the friction circle: lateral and longitudinal force share one budget. That is what makes braking into a corner cost cornering, and it is why there is no setting that gives you both.

Wire the simulation #

final simulation = RacingSimulation(
  collision: loaded.collision,
  vehicles: cars,          // a List: index nought is the player
  race: race,
  offRoadPatience: 4.0,    // long enough to run wide, short enough not to cut
  contactRestitution: 0.35,
  killPlane: -50.0,
);

The car list is a List and not a Set, and the order is part of the answer. Cars are pushed apart in pairs, so a collection that iterated differently on another machine would separate them differently and take the replay with it.

Fill in what each driver wants #

Every car is driven the same way: something fills a VehicleInput before the step. For car nought that is the keyboard.

void readDriver(RacingSimulation simulation) {
  simulation.inputs[0]
    ..throttle = input.held(Drive.throttle) ? 1.0 : 0.0
    ..brake = input.held(Drive.brake) ? 1.0 : 0.0
    ..handbrake = input.held(Drive.handbrake)
    ..steer = (input.held(Drive.right) ? 1.0 : 0.0) -
              (input.held(Drive.left) ? 1.0 : 0.0);
}

For the rest it is an AiDriver, which cannot do anything the player cannot:

final ai = AiDriver(track: track, tuning: const AiTuning(skill: 0.9));

void driveTheRest(RacingSimulation simulation, RaceState race) {
  final player = race.progress[0];
  for (var i = 1; i < cars.length; i++) {
    // How far the player is up the road from this car, wrapped. This is what
    // the rubber band reads, and zero turns it off.
    var gap = player.progressAlong(track.length) -
              race.progress[i].progressAlong(track.length);
    if (gap.abs() > track.length / 2) gap -= gap.sign * track.length;

    ai.drive(cars[i], simulation.inputs[i], others: cars, playerGap: gap);
  }
}

Run the loop #

void _onTick(Duration now) {
  final dt = _lastTick == Duration.zero
      ? 1 / 60
      : (now - _lastTick).inMicroseconds / 1e6;
  _lastTick = now;

  final steps = _step.advance(dt.clamp(0.0, 0.25));
  for (var i = 0; i < steps; i++) {
    readDriver(simulation);
    driveTheRest(simulation, race);
    simulation.step(_step.stepSeconds);
  }

  _placeCamera(dt);
  _listen(race);
  setState(() {});
}

Inputs are filled inside the step loop rather than once a frame. A frame that runs three steps and reads the keys once gives the first step three steps' worth of intent.

Place the chase camera #

final chase = ChaseCamera(world: loaded.collision, track: track);

chase.follow(cars[0], dt);
_camera
  ..setPositionFrom(chase.eye)
  ..lookAt(chase.target)
  ..projection = _lens.copyWith(fovYRadians: chase.fov);

headingBlend is what a chase camera in a racing game is about. Following the car's heading puts the camera behind a car that is sideways, so a slide is invisible. Following the velocity puts it behind a car that is stationary and pointing nowhere. It blends, and the blend fades in with speed between headingFrom and headingTo, because at walking pace the velocity means nothing.

Record a ghost #

final recorder = GhostRecorder(hz: 30.0);

// each frame
recorder.tick(race.elapsed, cars[0]);

// on a completed lap
final tape = recorder.finish(player.lapTime);
await file.writeAsString(jsonEncode(tape.toJson()));
final ghost = GhostPlayer(ghostTapeFromJson(
  jsonDecode(text) as Map<String, Object?>,
));
if (ghost.sampleAt(time, frame)) {
  ghostNode
    ..setPosition(frame.position.x, frame.position.y, frame.position.z)
    ..setRotationYawPitchRoll(frame.yaw, 0.0, 0.0);
}

Thirty samples a second, interpolated with Catmull-Rom on the way out, and rounded to a millimetre on the way to JSON. A lap of tape stays a small file and the ghost does not step.

Test it without a device #

The racing package has 140 tests and none of them draws anything. The ones worth copying:

test('a lap does not count without its checkpoints', () {
  final game = ring();
  // Nose over the line, reverse, cross it forwards again.
  driveTo(game, game.track.length - 5.0);
  driveTo(game, 5.0);
  driveTo(game, game.track.length - 5.0);
  driveTo(game, 5.0);
  expect(game.race.progress[0].lap, 0);
});

test('the tire falls away past its peak', () {
  final tires = TireModel(peakSlipAngle: 0.14, peakSlipRatio: 0.12);
  expect(tires.lateralAt(0.14), greaterThan(tires.lateralAt(0.30)));
});

test('two runs of one input agree', () {
  expect(play(recorded).save(), play(recorded).save());
});

The things that go wrong in a racing game go wrong invisibly: a car that understeers differently at a lower frame rate, a lap that counts twice because the line was crossed twice in one step, a driving line an AI cuts through a barrier, a position table that disagrees with itself on a track that crosses over itself. None of those appear in a screenshot.

Next #