Your first game¶
Starting from the project good create scaffolded, this
page adds the four things every game needs: a system, input,
an asset, and a camera. Every snippet here compiles against the current
engine.
At the end you have a textured sprite you can drive with W A S D.
1. Run the scaffold¶
A blue 64×64 square in the middle of the window. That is Player, spawned by
MainScene.onSceneMounted, drawn by the renderer Game2D declared for you.
It is in the middle because no camera exists yet: with no active camera the projection stays at the origin with a zoom of 1, and the whole world draws. A game that has not placed a camera shows something, not a black screen.
2. Move it — a system¶
A GameSystem is where per-tick work lives. FixedTickable gets it
onFixedUpdate, called once per fixed step (60 Hz by default) with exactly
fixedTimeStep of simulated time — never a variable frame delta. That is the
mixin for anything that writes component data, which this system does;
Tickable is the other one, for systems that read what the simulation finished
and publish it. Choosing between them
covers the cases where it is less obvious.
Create lib/game/systems/player_system.dart:
import 'package:goo2d/goo2d.dart';
import '../prefabs/player.dart';
class PlayerSystem extends GameSystem with FixedTickable {
late final Query players;
@override
void describeQuery(QueryDescriptor descriptor) {
super.describeQuery(descriptor);
players = descriptor.query().withAll(Transform2D, Player).build(); // (1)!
}
@override
void onFixedUpdate() {
final dt = game.fixedTimeStep.inMicroseconds / 1000000.0;
for (final group in players.groups()) { // (2)!
final transform = group.get<Transform2D>();
for (final entity in group) {
transform.transformOffsetX[entity] += 60 * dt;
}
}
}
}
- The query is compiled once, at declare time, into an archetype match. Nothing searches at tick time.
groups()yields one group per matching archetype;group.get<T>()resolves that archetype's component once for the whole group, and the inner loop indexes it byEntity. Resolving inside the inner loop would repeat that lookup per entity.
Declare it on the state, not the game — a system exists only on the isolate that ticks it:
class MyGameState extends GameState2D<MyGameGame> {
@override
void describeSystems(SystemDescriptor descriptor) {
super.describeSystems(descriptor);
descriptor.has(PlayerSystem());
}
@override
void onMounted() => loadScene(MainScene());
}
Run it: the square slides right forever.
No closures in onFixedUpdate
players.groups() is walked with indexed for loops.
.map, .where, .any and .forEach each allocate a closure — and
usually an Iterable — every call, and this method runs 60 times a second.
See Hot-path rules.
3. Steer it — input¶
Input actions are declared, like everything else, and hand back a typed handle. A system can declare its own:
class PlayerSystem extends GameSystem with FixedTickable {
late final Query players;
late final Input<Vector2> movement;
// ...
@override
void describeInputs(InputDescriptor descriptor) {
super.describeInputs(descriptor);
movement = descriptor.has<Vector2>(
const Vec2Binding(
up: InputKey.w,
down: InputKey.s,
left: InputKey.a,
right: InputKey.d,
),
);
}
}
Vec2Binding composes four held/not-held keys into a Vector2. Keys are named
by physical position, so InputKey.w is the key where a QWERTY W sits
regardless of the player's layout — WASD stays under the same fingers on AZERTY.
Now use it:
@override
void onFixedUpdate() {
final dt = game.fixedTimeStep.inMicroseconds / 1000000.0;
final direction = movement.value; // (1)!
if (direction.x == 0 && direction.y == 0) return;
for (final group in players.groups()) {
final transform = group.get<Transform2D>();
final player = group.get<Player>();
for (final entity in group) {
final step = player.speed[entity] * dt; // (2)!
transform
..transformOffsetX[entity] += direction.x * step
..transformOffsetY[entity] += direction.y * step;
}
}
}
- Read it, do not keep it. For a
Vector2this returns the one instance the action owns and mutates in place each tick — storing it in a field stores something that changes under you. speedis a component field added in the next step. Positiveyis down — world space projects into Flutter's canvas.
Per-entity data¶
speed is not a Dart field on Player; it is a column in the row layout, and
declaring it is one line:
class Player extends EntityStruct with Transform2D, WorldTransform2D, Renderable2D {
final speed = Field.float64(220); // (1)!
}
220is the default every new row starts at, not a value stored on the prefab. Write per entity withspeed[entity] = 400.
This is the single most important thing to internalise about the engine: a
Field.float64() is a column, and [entity] is the row index.
See Entities and components.
Run it: WASD drives the square.
4. Give it a texture — assets¶
Drop an image into the source directory, which holds the originals you edit and commit:
Convert it to the canonical format:
$ good assets compact
sprites/player.png -> sprites/player.webp
1 written, 0 up to date, 0 failed.
These directories now hold assets but are not listed under `flutter: assets:` in pubspec.yaml,
so Flutter will not bundle them and `good generate` will not see them:
- assets/sprites/
Do what it says — Flutter bundles only what the pubspec lists, and directory entries do not recurse into subdirectories:
Then generate the bindings:
enum Textures with LocalEnumAssetKey<Texture> {
spritesPlayer('assets/sprites/player.webp');
// ...
}
The path became an identifier: assets/sprites/player.webp → spritesPlayer.
Rename a file and the enum value's name changes with it, so a stale reference
is a compile error, not a missing texture at runtime.
Declare the asset on the prefab and hand it to the sprite:
import 'package:goo2d/goo2d.dart';
import '../../good.generated/textures.dart';
class Player extends EntityStruct
with Transform2D, WorldTransform2D, Renderable2D {
late final TextureAsset texture;
late final Sprite sprite;
final speed = Field.float64(220);
@override
void describeAssets(AssetDescriptor descriptor) {
super.describeAssets(descriptor);
texture = descriptor.has(Textures.spritesPlayer);
}
@override
void describeSprites(SpriteDescriptor descriptor) {
super.describeSprites(descriptor);
sprite = descriptor.has(width: 64, height: 64, texture: texture);
}
}
Declare the asset wherever you use it
A prefab that reads a texture must declare it, even if its scene declares
the same one. has is idempotent per identity and prefabs share their
scene's descriptor, so both fields end up the identical handle — one
address, one decode.
Run it: your image, moving with WASD.
5. Add a camera¶
A camera is an entity like any other — a transform plus the Camera component.
That is what makes "the camera follows the player" a parenting problem, not a
special case.
class Eye() extends EntityStruct with Transform2D, WorldTransform2D, Camera;
Spawn it and point it at the view the game declared:
class MainScene extends SceneStruct {
late final Player player;
late final Eye eye;
@override
void describeScene(SceneDescriptor descriptor) {
super.describeScene(descriptor);
player = descriptor.has(Player.new);
eye = descriptor.has(Eye.new);
}
@override
void onSceneMounted(Scene scene) {
final camera = scene.addEntity(eye);
eye.view[camera] = (game as Game2D).defaultCamera; // (1)!
scene.addEntity(player);
}
}
defaultCamerais the viewGame2Ddeclares for you, and the oneGameView(camera: game.defaultCamera)inmain.dartshows. A game can declare more views and show several at once.
Nothing looks different yet — the camera sits at the origin, which is where the
projection already was. Move the Eye's transform and the view moves; set
eye.zoom[camera] = 2 and everything draws twice as large.
To make it follow, write its position from a system, or make it a Child of
the player and let WorldTransformSystem compose it. See
Transforms and hierarchy.
What you have¶
my_game/lib/game/
├── my_game_game.dart Game + GameState, systems declared
├── prefabs/player.dart Player (transform, sprite, speed) and Eye (camera)
├── scenes/main_scene.dart what exists when the scene loads
└── systems/player_system.dart the movement loop and the input action
Four declarations and one loop. That shape holds as the game grows: prefabs
say what things are, scenes say what exists, systems say what happens,
and the Game says what crosses to Flutter.
Three names above are 2D: Transform2D, Renderable2D/Sprite and Game2D.
The rest — systems, queries, input, assets, scenes — came from the kernel, and
a goo3d game writes them unchanged.
Where to go next¶
-
Architecture — the two isolates, and why your
Gameexists twice. -
Systems and queries — ordering, enabling,
TickableversusFixedTickable. -
Physics — add
RigidBody2Dand let Box2D drive the transforms. -
Exporting a game — pack, encrypt and ship it.