flutter3d
Showcase Changelog 62 packages API reference

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.

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.

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.