Create a project¶
good create scaffolds a Flutter app wired up to good: a Game, a GameState,
a scene, a prefab, the asset directories, and the generated bindings.
Running flutter create ./my_game
Wrote lib/main.dart
Wrote lib/game/my_game_game.dart
Wrote lib/game/scenes/main_scene.dart
Wrote lib/game/prefabs/player.dart
Wrote assets/.gitkeep
Wrote assets/packed/.gitkeep
Patched pubspec.yaml
No assets found in the declared directories. Generating empty Textures and Audios enums.
Wrote ./lib/good.generated/textures.dart
Wrote ./lib/good.generated/audios.dart
Wrote ./lib/good.generated/good.dart
Wrote ./lib/good.generated/asset_key.dart
0 texture(s), 0 audio file(s).
Generated 4 file(s) in lib/good.generated/.
Created my_game. Next:
cd my_game
flutter pub get
flutter run
Generation runs straight away instead of telling you to run it: a fresh
project's lib/good.generated/ would otherwise be missing, so main.dart would
not compile until a second command had been run — which makes "it does not
build" a new project's first experience.
Options¶
| Option | Default | What it does |
|---|---|---|
<project_name> |
(required) | Package and directory name. Must be a valid Dart package name |
--directory=<dir> |
. |
Where to create the project |
--2d |
on | Build against goo2d |
--3d |
Build against goo3d |
|
--dry-run |
off | Report what would be created, and create nothing |
--no-flutter-create |
off | Write only the good files, into a Flutter project that already exists |
--dry-run first is a good habit — it prints every path and the exact pubspec
patch without touching the disk:
$ good create my_game --dry-run
Would run: flutter create ./my_game
Would write ./my_game/lib/main.dart
Would write ./my_game/lib/game/my_game_game.dart
...
Would add to ./my_game/pubspec.yaml:
dependencies:
goo2d: ^0.1.0
flutter:
assets:
- assets/
- assets/packed/
Two guarantees worth knowing¶
Nothing is written over. Scaffolding is a starting point, and silently
replacing a main.dart you have written in is the one unrecoverable thing this
command could do. A file that already exists is kept, and the run says so:
Kept existing lib/main.dart.
An existing directory is refused. flutter create over an existing tree
rewrites platform folders. Use --no-flutter-create to add the good files to a
project that is already there.
What gets written¶
my_game/
├── lib/
│ ├── main.dart ← Flutter app; starts the game, shows GameView
│ ├── game/
│ │ ├── my_game_game.dart ← Game + GameState: the two isolate halves
│ │ ├── scenes/main_scene.dart ← SceneStruct: what exists when it loads
│ │ └── prefabs/player.dart ← EntityStruct: one kind of entity
│ └── good.generated/ ← generated; commit it, do not edit it
│ ├── textures.dart ← one enum value per shipped image
│ ├── audios.dart ← one enum value per shipped audio file
│ ├── good.dart ← ensureGameReady(): the startup check
│ └── asset_key.dart ← encryption keys + chunk mapping
├── assets/ ← canonical assets (generated by compaction)
│ └── packed/ ← release chunks (generated by packing)
└── pubspec.yaml ← patched with the dependency and asset entries
Three of the four generated files are rewritten on every good generate.
asset_key.dart is written once and then left alone — it holds the keys
your asset packs were encrypted with, so regenerating it would orphan every pack
already built. See --rotate-keys.
good.generated/ should be committed
It is generated, but it is also the only record of your encryption keys.
Losing asset_key.dart means every previously shipped pack stops
decrypting.
The pubspec patch¶
good create edits the pubspec textually instead of through a YAML round-trip,
so the comments flutter create wrote survive:
dependencies:
goo2d: ^0.1.0
flutter:
sdk: flutter
flutter:
uses-material-design: true
# Both directories ship. `good build` fills assets/packed/ and empties
# assets/ of what it packed, so each asset is bundled exactly once.
assets:
- assets/
- assets/packed/
Both directories must be listed — that list is the only thing Flutter bundles from. If the pubspec is not a shape the patcher recognises, it declines to edit and prints the lines to add by hand instead; editing someone's pubspec blind is not something it will do.
Reading the scaffold¶
Four small files, and they are worth reading in this order.
lib/game/my_game_game.dart — the two halves¶
/// The **main isolate** half: what the game *is*.
class MyGameGame extends Game2D {
@override
GameState2D<MyGameGame> createState() => MyGameState();
}
/// The **game isolate** half: what the game *does*.
class MyGameState extends GameState2D<MyGameGame> {
@override
void onMounted() {
loadScene(MainScene());
}
}
Declarations (systems, commands, cameras, published state) go on the Game.
Simulation goes on the GameState. They are the same object graph, deep-copied
across an isolate boundary — see Architecture.
Game2D/GameState2D are the 2D layer's pair, and that narrowing is the whole
opt-in: returning a plain GameState from a Game2D is a compile error, not a
game that silently paints nothing.
lib/game/scenes/main_scene.dart — what exists¶
class MainScene extends SceneStruct {
late final Player player;
@override
void describeScene(SceneDescriptor descriptor) {
super.describeScene(descriptor);
player = descriptor.has(Player.new); // registers the prefab
}
@override
void onSceneMounted(Scene scene) {
scene.addEntity(player); // spawns one
}
}
lib/game/prefabs/player.dart — one kind of entity¶
class Player extends EntityStruct with Transform2D, WorldTransform2D, Renderable2D {
late final Sprite sprite;
@override
void describeSprites(SpriteDescriptor descriptor) {
super.describeSprites(descriptor);
sprite = descriptor.has(width: 64, height: 64, color: 0xFF4FC3F7);
}
}
It starts untextured: a flat colour is one branch in the renderer and needs no asset, so a new project draws something on its first run.
lib/main.dart — starting it¶
Game.start spawns the simulation isolate and brings the world up, so it is
asynchronous, and GameView needs a camera from a game that is already running:
/// Constructed synchronously, so there is always something to stop.
final MyGameGame _game = MyGameGame();
late final Future<void> _starting;
bool _ready = false;
@override
void initState() {
super.initState();
_starting = _start();
}
Future<void> _start() async {
await Game.start(_game);
if (!mounted) return;
setState(() => _ready = true);
}
@override
void dispose() {
// `dispose` cannot await, so the teardown hangs off the start future.
_starting.whenComplete(_game.stop);
super.dispose();
}
@override
Widget build(BuildContext context) {
if (!_ready) return const Center(child: CircularProgressIndicator());
return GameView(camera: _game.defaultCamera);
}
stop() is not optional — the game owns native memory and an isolate, and
neither is reclaimed by the widget going away.
Why the game is built synchronously, and stopped through the future
The obvious shape leaks. A nullable _game assigned after await
Game.start(...) is still null if the widget is disposed while the start is
in flight, so _game?.stop() does nothing — and the start then completes
into a dead widget with the isolate still running.
Building it in a field fixes half. The other half is that stop() returns
immediately on a run that has not finished booting, so stopping during the
start is also a silent no-op. Hanging the teardown off _starting covers
both orderings. See
Lifecycle in a widget.
Adding good to an existing project¶
--no-flutter-create writes the good files into a Flutter project that already
exists, without running flutter create over it:
It is idempotent: files that exist are kept, and a pubspec that already carries the dependency is left alone, so running it twice changes nothing.
Configuration¶
good reads its settings from a good: section of the pubspec — not a second
good.yaml beside it. A project already has one file that says what it is and
what it ships, and this also puts the asset source directory next to the
flutter: assets: list that names the output.
good:
assets:
source: assets_src/ # originals you edit and commit
output: assets/ # canonical files, generated
packed: assets/packed/ # release chunks, generated
strip-originals: false # may a build delete art it cannot rebuild
texture:
format: webp
quality: 90
audio:
format: ogg
quality: 5
Every key is optional and the values above are the defaults, so a project with
no good: section at all works — a new project should run before anyone has
configured anything. See The asset pipeline.