Skip to content

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.

good create my_game
cd my_game
flutter run
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:

pubspec.yaml
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:

good create my_existing_app --no-flutter-create

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.

pubspec.yaml
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.


Next

Your first game →