flutter3d
Showcase Changelog 38 packages API reference

Running without a screen

since 0.7.0 Simulation, audio and XR

An agent playing a game through an MCP server, or a farm of playtests running in a container, need a game with no window and no GPU: a step, a way to save, and answers in words and in data instead of a picture. HeadlessRun and HeadlessGame are that vocabulary, shared across genres so a tool built against one does not have to be rewritten for the next.

Step 1: Implement the interface #

A real game answers step, save, outcome, position, eye, aim, a one-sentence summary and a reading as data. This page's walker is a toy that stands in for one: it moves towards a target and reports won once it gets there.

/// A toy game: a walker that moves towards x=10 and stops.
final class _WalkerRun implements HeadlessRun {
  final Vector3 _position = Vector3.zero();

  @override
  void step(double dt) {
    if (outcome != RunOutcome.playing) return;
    _position.x += 2.0 * dt;
    if (_position.x >= 10.0) {
      _position.x = 10.0;
      outcome = RunOutcome.won;
    }
  }

  @override
  Snapshot save() => Snapshot(<String, Object?>{'x': _position.x});

  @override
  RunOutcome outcome = RunOutcome.playing;

  @override
  Vector3 get position => _position;

  @override
  void eye(Vector3 out) => out.setValues(_position.x, 1.7, _position.z);

  @override
  void aim(Vector3 out) => out.setValues(1.0, 0.0, 0.0);

  @override
  String get summary =>
      'walking at x=${_position.x.toStringAsFixed(2)}, ${outcome.name}';

  @override
  Map<String, Object?> get reading => <String, Object?>{
    'x': _position.x,
    'outcome': outcome.name,
  };
}

Step 2: Drive it with no renderer at all #

Nothing here builds a scene to look at. The loop just calls step until the outcome says the run is over.

final run = _WalkerRun();
var steps = 0;
while (run.outcome == RunOutcome.playing && steps < 1000) {
  run.step(1 / 60);
  steps++;
}

Step 3: Read the result as words and as data #

The same run answers a sentence for a log and a map for a program, plus whatever save would write to a file.

return '${run.summary} after $steps steps\n'
    'reading: ${run.reading}\n'
    'save: ${run.save().toJson()}';

This is the shape bin/'s own command line tool drives: it opens a real game through HeadlessGame.start, and everything after that is exactly this loop.

Step 4: The same run, looked at #

The blind loop above needs no window, but the run it steps is an ordinary object, and here it is stepped once a frame instead of a thousand times at once: the walker goes to the post at x = 10, the bar is how far it has got, and the post turns green when the run reports it has won. Nothing about the run changed to be watched; what it says about itself, its summary and reading, is the same sentence a tool reading it blind would get.

/// A toy game: a walker that moves towards x=10 and stops.
final class _WalkerRun implements HeadlessRun {
  final Vector3 _position = Vector3.zero();

  @override
  void step(double dt) {
    if (outcome != RunOutcome.playing) return;
    _position.x += 2.0 * dt;
    if (_position.x >= 10.0) {
      _position.x = 10.0;
      outcome = RunOutcome.won;
    }
  }

  @override
  Snapshot save() => Snapshot(<String, Object?>{'x': _position.x});

  @override
  RunOutcome outcome = RunOutcome.playing;

  @override
  Vector3 get position => _position;

  @override
  void eye(Vector3 out) => out.setValues(_position.x, 1.7, _position.z);

  @override
  void aim(Vector3 out) => out.setValues(1.0, 0.0, 0.0);

  @override
  String get summary =>
      'walking at x=${_position.x.toStringAsFixed(2)}, ${outcome.name}';

  @override
  Map<String, Object?> get reading => <String, Object?>{
    'x': _position.x,
    'outcome': outcome.name,
  };
}