Running without a screen
Open the live demo · Read the source · View on GitHub
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,
};
}