Testing a game
A game built on flutter3d has two kinds of code under test, and they want opposite treatment. The simulation, meaning the loop, the world, the input, the bus, the brains, the rewind buffer and navigation, is deterministic and runs headless, so a test runs the real thing on a tape. The edges are where the game waits on something outside the process, such as a server, a disk, a sensor, a controller or a share sheet. A test can't have those, so it uses a stand-in, and the engine ships one for each edge. You won't be able to write your own with implements, because the types refuse it; the last section explains why.
Drive the simulation, don't mock it #
EngineLoop, EcsWorld, InputState, EventBus, RewindBuffer, NavGrid and NavMesh are final. A brain is a class you extend, and the world it thinks in is the real one. A mock of the loop agrees with whatever the test says the loop does, which means it agrees with every bug the loop has. The real loop costs nothing to build and it is what your game runs on, so replacing it buys nothing.
What a test controls instead is the input, and it controls it completely. Each step reads an InputState, and an InputTape is a recording of one per step. Record a tape once, or write one by hand, and the run it produces is the same on every machine, every time:
import 'package:flutter3d_sim/flutter3d_sim.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
test('a step reads only what the tape gave it', () {
final input = InputState();
final loop = EngineLoop(input: input)
..playback = InputTapePlayback.tape(
InputTape(
seed: 0,
frames: <InputFrame>[const InputFrame(), const InputFrame()],
),
);
loop.runSteps(2);
expect(loop.published.step, 2);
});
}
Three more pieces cover most of the rest.
ManualTimeis the loop's clock when the test, not the wall, says how much time passed. Hand it toloop.timeand calladvance(seconds); a test of a cooldown or a timeout steps exactly as far as it means to.- The headless game is the genre's own run with no renderer:
HeadlessGame.startgives you aHeadlessRunyoustepandsave. Two runs of the same seed and the same tape have to save the same snapshot, and a third run with one extra press must not. Without the third, a test can't tell determinism from a run where nothing happened. testReplayinflutter3d_testingplays a committed.f3drunthrough the loop's own snapshots, checks every digest the tape recorded, and compares frames at the steps you name. See Testing.
If a test of the simulation seems to need a mock, the test usually wants a smaller level, a shorter tape or a seed it chose, and none of those require replacing the loop.
Fake the edges, with the fakes the engine ships #
An edge is a seam the engine leaves open to the outside world, and every one of them is an abstract base class. Each comes with a fake that keeps its contract: it answers the way the real implementation answers, refuses what the real one refuses, and records what it was asked so a test can read it back. Each fake has a test of its own that holds it to that contract, next to the real implementation wherever the real one can run without a network or a device.
| The seam | The fake | Where |
|---|---|---|
SimulationHandle, what a view holds of a simulation |
ScriptedSimulation |
flutter3d_testing |
TelemetrySink, where agreed runs go |
RecordingTelemetrySink |
flutter3d_testing |
EduDataSource, a live sensor bound to a level |
ScriptedDataSource |
flutter3d_testing |
RunTransport, how RunService reaches a share server |
RecordingRunTransport |
flutter3d_testing |
AssetSource, where a model's bytes come from |
MemoryAssetSource |
flutter3d_testing |
HubServer, the model hub |
FakeHubServer |
flutter3d_testing |
Storage, BinaryStorage |
MemoryStorage, MemoryBinaryStorage |
flutter3d_app |
PhotoShelf, where photo mode puts a picture |
MemoryPhotoShelf |
flutter3d_app |
XrSession, XrSwapchain |
FakeXrSession, FakeXrSwapchain |
flutter3d_app |
CloudSaveStore |
MemoryCloudSaves |
flutter3d_game (testing.dart) |
MouseCapture |
ScriptedMouseCapture |
flutter3d_game (testing.dart) |
GamepadPlatform |
ScriptedGamepad |
pad_input (testing.dart) |
PointerLockPlatform |
ScriptedPointerLock |
flutter3d_pointer_lock (testing.dart) |
HeadTracker |
ScriptedHeadTracker |
flutter3d_stereo (testing.dart) |
AudioBackend |
SilentBackend |
flutter3d_audio_core |
GraphicsDevice |
FakeBackend, or cpuTestDevice to draw for real |
flutter3d_hardware, flutter3d_cpu |
PeerWire |
LoopbackWire, LoopbackPair |
flutter3d_net |
PluginSource |
MemoryPluginSource |
flutter3d_plugin_runtime |
LoopRegistry, EventRegistry, ComponentRegistry, what a plugin installs into |
PluginTestHost, with TestLoop, TestEvents, TestComponents |
flutter3d_plugin_api (testing.dart), exported by flutter3d_testing |
PlayedGame, the editor's Play panel |
ScriptedPlayedGame |
flutter3d_editor_play |
The fakes for the seams a game's simulation meets live in flutter3d_testing, which a game already has as a dev dependency. The rest live beside their seam, because the seam's package sits above the test kit or is a plugin of its own.
This tests the consent check of telemetry with no server. TelemetryUploader is the real class, and only the place the run goes is fake:
import 'package:flutter3d_sim/flutter3d_sim.dart';
import 'package:flutter3d_testing/flutter3d_testing.dart';
import 'package:flutter_test/flutter_test.dart';
// `recordedRun` is a `Demo` the test recorded, or read from a committed file.
void consentTests(Demo recordedRun) {
test('no run leaves before the player agrees', () async {
final sink = RecordingTelemetrySink();
var consent = const TelemetryConsent.notAsked();
final uploader = TelemetryUploader(
game: 'crypt',
policy: 'v1',
consent: () => consent,
sink: sink,
);
await uploader.send(recordedRun);
expect(sink.delivered, isEmpty);
consent = TelemetryConsent.granted(policy: 'v1', at: DateTime.utc(2026));
await uploader.send(recordedRun);
expect(sink.delivered.single.game, 'crypt');
});
}
A HUD can be tested against exactly the states the test chooses, without stepping a world to reach them:
import 'package:flutter3d_plugin_api/flutter3d_plugin_api.dart';
import 'package:flutter3d_sim/flutter3d_sim.dart';
import 'package:flutter3d_testing/flutter3d_testing.dart';
void hudTest() {
final sim = ScriptedSimulation(
answers: <String, SimulationAnswer>{
'crypt.keys': (SimulationQuestion question) => 2,
},
);
// Build the HUD over `sim`, then:
sim.publish(PublishedState(step: 120, seconds: 2));
}
The answers a ScriptedSimulation is handed take the same SimulationAnswer shape the simulation's own registry takes, so a test can hand it the very function a plugin registers.
A seam that turns out to need something its fake doesn't do is a reason to extend the fake's package, not to write a second one in your tests. Your copy won't have a test holding it to the contract, and the next release that changes the contract won't tell it.
Why there is no implements-mock #
A mocking library makes a stand-in by writing class MockLoop extends Mock implements EngineLoop. Against flutter3d that line does not compile, because of the class modifiers.
- A
finalclass can be neither extended nor implemented outside its library. That covers the whole simulation core. The engine can change howEngineLoopworks inside without breaking anybody, because nobody outside can have written a secondEngineLoop. - An
abstract base classcan be extended outside its library but not implemented. That's every edge. A seam that isbasecan gain a member with a default body in a 1.x release, and every subclass keeps compiling, since it inherits the default. A seam open toimplementscould never gain anything: one new member would break every implementation somebody wrote. The same modifier is what turns a mock away. - A platform interface checks it at run time too.
GamepadPlatform.instance = ...andPointerLockPlatform.instance = ...verify the platform token, so a fake built withimplementsthrows when it's installed.ScriptedGamepadandScriptedPointerLockextend the interface and install cleanly.
What to use instead:
| You want to test | Use |
|---|---|
| Game rules, physics, AI, navigation, rewinding | The real loop, on a tape, with ManualTime where time matters |
| A level, start to finish | The headless game, or testReplay with a committed run |
| What leaves the process (network, disk, sensor, controller, GPU) | The fake in the table above |
| An edge whose fake doesn't do what you need | A class of your own that extends the seam, the way the shipped fake does |
| A plugin's registrations | PluginTestHost: the plugin registries are the engine's to implement, and a call that grows a named parameter in a minor breaks a subclass of your own, but not the shipped fakes |
Keeping it true #
A structure rule, an edge of the engine ships its fake, reads every published package's API snapshot for an abstract base seam with a member that answers a Future or a Stream, or one built on PlatformInterface, and requires it to name its fake: a concrete class that extends the seam, in flutter3d_testing or beside the seam. A seam whose waiting is the engine's own work rather than the world's, like a decoder that answers later only because it decodes on another isolate, says in the same table why a test needs no fake. A new edge without either fails the scan. See the rules that hold the architecture.