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 ./my_game_bundle/pubspec.yaml
Wrote ./my_game_bundle/lib/textures.dart
Wrote ./my_game_bundle/lib/audios.dart
Wrote ./my_game_bundle/lib/good.dart
Wrote ./my_game_bundle/lib/asset_key.dart
Resolving my_game_bundle - flutter pub get
0 texture(s), 0 audio file(s).
Generated 5 file(s) in my_game_bundle/.
my_game_bundle/ is good's, and lib/ is yours. The bindings import as
`package:my_game_bundle/textures.dart`.
Created my_game. Next:
cd my_game
flutter run
Generation runs straight away instead of telling you to run it: the pubspec now
carries a path dependency on my_game_bundle, and a project whose bundle
package has never been written does not resolve at all — which makes "it does
not build" a new project's first experience.
flutter pub get is not in the next steps either. Generating runs it, because a
path dependency that was never resolved is the one mistake Flutter answers by
building green and shipping nothing.
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/
dependencies:
my_game_bundle:
path: my_game_bundle
good:
bundle: my_game_bundle
Would generate my_game_bundle/ beside it.
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.
A taken bundle name stops the command before it writes. See Adding good to a project that already exists.
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
├── my_game_bundle/ ← generated; commit it, do not edit it
│ ├── .good_bundle ← says the directory is good's to rewrite
│ ├── pubspec.yaml ← every line generated
│ └── lib/
│ ├── 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 dependencies and asset entries
Everything good generates is in the package beside the project, and nothing good
generates is under lib/. Split by who wrote it rather than by what kind of
file it is, so "may this be overwritten" has one answer instead of two. The
.good_bundle marker is what makes the answer provable: without it, every good
command refuses to write to that directory or delete anything in it, which is
what stops a routine regeneration from emptying a package you wrote by hand.
The marker is the first file written into the directory — before the generated
pubspec, before lib/. A run that stops partway, on a closed terminal or a full
disk, leaves a package good can still prove it created, and the next good
generate finishes it. Written last, the same interruption leaves an unmarked
directory that every later command refuses, and the way out of that is deleting
a package by hand.
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.
my_game_bundle/ 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.
Adding good to a project that already exists¶
good create <name> --no-flutter-create writes the good files into a Flutter
project that is already laid out. The bundle package is being introduced into a
tree good did not create, so its name may already be taken. Three ways, all
checked before the first file is written:
- a directory of that name carrying no
.good_bundle; - a
dependencies:entry of that name pointing somewhere else; - a marked directory whose name is not the one
good: bundle:records.
Any of them stops the command with exit code 65 and names the path. No scaffold file is written and the pubspec is not patched, so the project is as it was.
good does not pick a different name for you. The recorded name is worth having
because it is stable and knowable, and one chosen on your behalf during a
command you ran for something else is neither. Set good: bundle: in the
pubspec to the name you want and run the command again.
The pubspec patch¶
good create edits the pubspec textually instead of through a YAML round-trip,
so the comments flutter create wrote survive:
dependencies:
my_game_bundle:
path: my_game_bundle
goo2d: ^0.1.0
flutter:
sdk: flutter
flutter:
uses-material-design: true
# Both directories ship. `good build` fills assets/packed/ and
# leaves the loose copies in assets/, so a packed asset is bundled
# twice. Set `strip-originals: true` under `good: assets:` to delete
# them - Image.asset stops resolving those paths once they are gone.
# Keep your originals in assets_src/; assets/ is generated.
assets:
- assets/
- assets/packed/
good:
bundle: my_game_bundle
Both asset directories must be listed — that list is the only thing Flutter bundles from.
good: bundle: is the answer to "which package is the bundle", and it is
written rather than worked out each run. Rename the project to cool_game and
that line still names my_game_bundle, which is the directory that exists, the
one dependencies: points at, and the one your imports spell. Recomputing the
name would build cool_game_bundle beside it: two packages, both resolving,
one of them dead, and nothing in the build saying which answered.
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 {
final sprite = Sprite.of(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:
/// The in-flight start. `Game.start` is what builds the game, so this is the
/// only thing there is to hold until it completes.
late final Future<MyGameGame> _starting;
MyGameGame? _game;
@override
void initState() {
super.initState();
_starting = _start();
}
Future<MyGameGame> _start() async {
final game = await Game.start(MyGameGame.new);
if (mounted) setState(() => _game = game);
return game;
}
@override
void dispose() {
// `dispose` cannot await, so the teardown hangs off the start future.
_starting.then((game) => game.stop());
super.dispose();
}
@override
Widget build(BuildContext context) {
final game = _game;
if (game == null) 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 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.
There is nothing to hold earlier, either: Game.start(MyGameGame.new) takes
a constructor and builds the game itself, which is what lets a
Channel.int32() on one of its fields declare at all. And stop() returns
immediately on a run that has not finished booting, so stopping during the
start is a silent no-op too. 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.