Create a Game

Goal

Build a new CLI-runnable game that works with:

  • mini-arcade run --game <game-id>

  • python manage.py from the game folder

This guide documents the current pattern used by the reference games in this repository.

Prerequisites

Naming Rules

Choose these values first:

  • game-id: CLI id and folder id (kebab-case), for example my-first-game

  • python_package: import package (snake_case), for example my_first_game

Recommended mapping:

  • folder: games/my-first-game/

  • package: games/my-first-game/src/my_first_game/

  • settings profile load call: Settings.for_game("my-first-game", required=True)

Required Layout

Use this minimum structure:

games/my-first-game/
  pyproject.toml
  manage.py
  settings/
    settings.yml
  src/my_first_game/
    __init__.py
    __main__.py
    app.py
    scenes/
      __init__.py
      commands.py
      menu.py
      pause.py
      play/
        __init__.py
        scene.py
        models.py
        draw_ops.py
        systems/
          __init__.py
          input.py
          rules.py
          render.py
    entities/
      __init__.py
      entity_id.py
      player.py
      enemy.py
    controllers/
      __init__.py
      cpu.py
  assets/
    sprites/
    fonts/
    sfx/

Step 1: pyproject.toml

Minimal template:

[build-system]
requires = ["poetry-core>=2.0.0,<3.0.0"]
build-backend = "poetry.core.masonry.api"

[project]
name = "my-first-game"
version = "0.1.0"
description = "My first mini-arcade game."
requires-python = ">=3.9,<3.12"
dependencies = [
  "mini-arcade-core~=1.1",
  "mini-arcade~=1.1",
  "mini-arcade-pygame-backend~=1.0",
  "mini-arcade-native-backend~=1.0",
]

[tool.poetry]
packages = [{ include = "my_first_game", from = "src" }]

[project.scripts]
my-first-game = "my_first_game.app:run"

[tool.mini-arcade.game]
id = "my-first-game"
entrypoint = "manage.py"
source_roots = ["src"]

[tool.mini-arcade.game] is required for CLI game discovery.

Step 2: Launchers (manage.py, __main__.py)

manage.py:

from my_first_game.app import run

if __name__ == "__main__":
    run()

src/my_first_game/__main__.py:

from my_first_game.app import run

if __name__ == "__main__":
    run()

Step 3: Settings-Driven App Bootstrap (app.py)

Use this exact startup flow:

from __future__ import annotations

from mini_arcade.modules.backend_loader import BackendLoader
from mini_arcade.modules.settings import Settings
from mini_arcade_core import run_game


def run() -> None:
    settings = Settings.for_game("my-first-game", required=True)

    backend_cfg = settings.backend_defaults(resolve_paths=True)
    backend = BackendLoader.load_backend(backend_cfg)

    engine_cfg = settings.engine_config_defaults()
    scene_cfg = settings.scene_defaults()
    gameplay_cfg = settings.gameplay_defaults()

    run_game(
        engine_config=engine_cfg,
        scene_config=scene_cfg,
        backend=backend,
        gameplay_config=gameplay_cfg,
    )


if __name__ == "__main__":
    run()

Why this matters:

  • keeps bootstrap consistent across games

  • allows backend/fps/scene defaults in YAML

  • avoids hardcoding backend classes in game code

Step 4: Game Settings (settings/settings.yml)

Use this baseline:

game:
  id: my-first-game

project:
  root: ${settings_dir}/..
  assets_root: ${project_root}/assets

scene:
  initial_scene: menu
  scene_registry:
    discover_packages:
      - my_first_game.scenes
      - mini_arcade_core.scenes

engine_config:
  fps: 60
  virtual_resolution: [960, 540]
  enable_profiler: false
  postfx:
    enabled: false
    active: []

backend:
  provider: pygame
  window:
    width: 960
    height: 540
    title: My First Game
    resizable: true
  renderer:
    background_color: [18, 18, 24]
  audio:
    enable: false

gameplay:
  difficulty:
    default: normal

Gameplay Architecture (Reference Model)

For non-trivial games, use this separation:

  • entities/: reusable entity builders and IDs

  • scenes/<mode>/models.py: world state, intent, tick context

  • scenes/<mode>/bootstrap.py: world builders and asset/bootstrap helpers

  • scenes/<mode>/pipeline.py: ordered system registration helpers

  • scenes/<mode>/spawn.py: typed spawn specs and spawn policies when needed

  • scenes/<mode>/systems/*.py: input, simulation rules, collisions, rendering

  • scenes/<mode>/draw_ops.py: reusable Drawable overlays and specialized visuals

  • scenes/<mode>/scene.py: scene registration and orchestration only

This is the same structure used in reference games:

  • Deja Bounce:

    • games/deja-bounce/src/deja_bounce/entities/

    • games/deja-bounce/src/deja_bounce/scenes/pong/models.py

    • games/deja-bounce/src/deja_bounce/scenes/pong/draw_ops.py

    • games/deja-bounce/src/deja_bounce/scenes/pong/systems/

  • Asteroids:

    • games/asteroids/src/asteroids/entities/

    • games/asteroids/src/asteroids/scenes/asteroids/models.py

    • games/asteroids/src/asteroids/scenes/asteroids/draw_ops.py

    • games/asteroids/src/asteroids/scenes/asteroids/systems/

  • Space Invaders:

    • games/space-invaders/src/space_invaders/entities/__init__.py

    • games/space-invaders/src/space_invaders/scenes/space_invaders/models.py

    • games/space-invaders/src/space_invaders/scenes/space_invaders/draw_ops.py

    • games/space-invaders/src/space_invaders/scenes/space_invaders/systems/

For discrete grid/board games, mini-arcade-core now also provides reusable helpers in mini_arcade_core.scenes.systems.builtins:

  • CadenceSystem for fixed logical movement/update ticks

  • GridCoord, GridBounds, and GridLayout for cell-space state and rendering

  • GridCellSpawnSystem plus occupancy helpers for spawning into free cells

Use those when your gameplay advances by cells rather than continuous velocity.

For falling-block or stacking puzzle games, core also provides:

  • BlockBoard for dense board state

  • FallingBlockPieceSpec / FallingBlockPiece for active piece rotations

  • BoardRowClearSystem for full-row collapse

  • BagRandomizer for deterministic bag-based piece sequencing

Use those instead of hand-rolling board arrays and row-clear loops in each game.

For brick-breaker games, core also provides:

  • ViewportBounceSystem for wall reflection

  • BounceCollisionSystem for ball-vs-paddle and ball-vs-rect bounce

  • PaddleBouncePolicy for contact-point based outgoing angles

  • BrickField and BrickFieldCollisionSystem for brick state and hits

Use those instead of re-implementing bounce math and brick damage loops in each game.

For maze arcade games, core also provides:

  • TileMap and tile_map_from_strings(...) for maze data

  • GridNavigationSystem for buffered lane movement

  • TunnelWrapSystem for tunnel exits

  • CollectibleField and CollectibleCollisionSystem for pellets and pickups

  • ModeTimerSystem for timed mode schedules

Use those instead of re-implementing tile boards, turn buffering, and mode timers in each game.

For bomb-and-arena games, core also provides:

  • ArenaTile and arena_tile_map_from_strings(...) for arena board data

  • BombPlacementSystem and BombFuseSystem for placement/fuse rules

  • ExplosionField and ExplosionLifetimeSystem for active blast cells

  • ChainReactionSystem for bomb trigger cascades

  • DestructibleTileSystem and HazardCollisionSystem for block damage and hits

Use those instead of hand-rolling blast propagation, timed bomb cleanup, and destructible-tile loops in each game.

Step 5: Scene Commands (scenes/commands.py)

from mini_arcade_core.engine.commands import (
    Command,
    CommandContext,
    PushSceneIfMissingCommand,
    RemoveSceneCommand,
)
from mini_arcade_core.engine.scenes.models import ScenePolicy


class StartGameCommand(Command):
    def execute(self, context: CommandContext):
        context.managers.scenes.change("play")


class PauseGameCommand(Command):
    def execute(self, context: CommandContext):
        PushSceneIfMissingCommand(
            "pause",
            as_overlay=True,
            policy=ScenePolicy(
                blocks_update=True,
                blocks_input=True,
                is_opaque=False,
                receives_input=True,
            ),
        ).execute(context)


class ContinueCommand(Command):
    def execute(self, context: CommandContext):
        RemoveSceneCommand("pause").execute(context)


class BackToMenuCommand(Command):
    def execute(self, context: CommandContext):
        context.managers.scenes.change("menu")

Step 6: Menu and Pause Scenes

scenes/menu.py:

from mini_arcade_core.engine.commands import QuitCommand
from mini_arcade_core.scenes.autoreg import register_scene
from mini_arcade_core.ui.menu import BaseMenuScene, MenuItem

from my_first_game.scenes.commands import StartGameCommand


@register_scene("menu")
class MenuScene(BaseMenuScene):
    @property
    def menu_title(self) -> str | None:
        return "MY FIRST GAME"

    def menu_items(self):
        return [
            MenuItem("start", "START", StartGameCommand),
            MenuItem("quit", "QUIT", QuitCommand),
        ]

scenes/pause.py:

from mini_arcade_core.scenes.autoreg import register_scene
from mini_arcade_core.ui.menu import BaseMenuScene, MenuItem

from my_first_game.scenes.commands import BackToMenuCommand, ContinueCommand


@register_scene("pause")
class PauseScene(BaseMenuScene):
    @property
    def menu_title(self) -> str | None:
        return "PAUSED"

    def menu_items(self):
        return [
            MenuItem("continue", "CONTINUE", ContinueCommand),
            MenuItem("menu", "MAIN MENU", BackToMenuCommand),
        ]

    def quit_command(self):
        return ContinueCommand()

Step 7: Gameplay Scene with Systems

scenes/play/models.py:

from dataclasses import dataclass

from mini_arcade_core.scenes.sim_scene import BaseIntent, BaseTickContext, BaseWorld


@dataclass
class PlayWorld(BaseWorld):
    viewport: tuple[float, float]
    player_x: float = 100.0
    player_speed: float = 260.0


@dataclass(frozen=True)
class PlayIntent(BaseIntent):
    move_x: float
    pause: bool = False


@dataclass
class PlayTickContext(BaseTickContext[PlayWorld, PlayIntent]):
    pass

scenes/play/systems/input.py:

from mini_arcade_core.backend.keys import Key
from mini_arcade_core.scenes.systems.builtins import (
    ActionIntentSystem,
    ActionMap,
    AxisActionBinding,
    DigitalActionBinding,
)

from my_first_game.scenes.play.models import PlayIntent, PlayTickContext

PLAY_ACTIONS = ActionMap(
    bindings={
        "move_x": AxisActionBinding(
            negative_keys=(Key.LEFT, Key.A),
            positive_keys=(Key.RIGHT, Key.D),
        ),
        "pause": DigitalActionBinding(keys=(Key.ESCAPE,)),
    }
)


def _build_intent(actions, _ctx: PlayTickContext) -> PlayIntent:
    return PlayIntent(
        move_x=actions.value("move_x"),
        pause=actions.pressed("pause"),
    )


class PlayInputSystem(ActionIntentSystem[PlayTickContext, PlayIntent]):
    def __init__(self):
        super().__init__(
            action_map=PLAY_ACTIONS,
            intent_factory=_build_intent,
            name="play_input",
        )

scenes/play/systems/rules.py:

from dataclasses import dataclass

from mini_arcade_core.scenes.systems.base_system import BaseSystem
from mini_arcade_core.scenes.systems.phases import SystemPhase

from my_first_game.scenes.commands import PauseGameCommand
from my_first_game.scenes.play.models import PlayTickContext


@dataclass
class PlayRulesSystem(BaseSystem[PlayTickContext]):
    name: str = "play_rules"
    phase: int = SystemPhase.SIMULATION
    order: int = 20

    def step(self, ctx: PlayTickContext):
        intent = ctx.intent
        if intent is None:
            return

        world = ctx.world
        world.player_x += intent.move_x * world.player_speed * ctx.dt
        world.player_x = max(20.0, min(world.viewport[0] - 20.0, world.player_x))

        if intent.pause:
            ctx.commands.push(PauseGameCommand())

scenes/play/systems/render.py:

from dataclasses import dataclass

from mini_arcade_core.engine.render.packet import RenderPacket
from mini_arcade_core.scenes.systems.base_system import BaseSystem
from mini_arcade_core.scenes.systems.phases import SystemPhase

from my_first_game.scenes.play.models import PlayTickContext


@dataclass
class PlayRenderSystem(BaseSystem[PlayTickContext]):
    name: str = "play_render"
    phase: int = SystemPhase.RENDERING
    order: int = 100

    def step(self, ctx: PlayTickContext):
        world = ctx.world
        vw, vh = world.viewport

        def draw(backend):
            backend.render.draw_rect(0, 0, int(vw), int(vh), color=(12, 14, 20))
            backend.render.draw_rect(
                int(world.player_x) - 20, int(vh) - 60, 40, 20, color=(240, 240, 240)
            )
            backend.text.draw(16, 16, "ESC pause", color=(220, 220, 220), font_size=18)

        ctx.packet = RenderPacket.from_ops([draw])

scenes/play/bootstrap.py:

from my_first_game.scenes.play.models import PlayWorld


def build_play_world(*, viewport: tuple[float, float]) -> PlayWorld:
    vw, vh = viewport
    return PlayWorld(
        entities=[],
        viewport=viewport,
        player_x=vw * 0.5,
    )

scenes/play/pipeline.py:

from my_first_game.scenes.play.systems.render import PlayRenderSystem
from my_first_game.scenes.play.systems.rules import PlayRulesSystem


def build_play_systems():
    return (
        PlayRulesSystem(),
        PlayRenderSystem(),
    )

scenes/play/scene.py:

from mini_arcade_core.scenes.autoreg import register_scene
from mini_arcade_core.scenes.bootstrap import scene_viewport
from mini_arcade_core.scenes.game_scene import GameScene, GameSceneSystemsConfig

from my_first_game.scenes.commands import PauseGameCommand
from my_first_game.scenes.play.bootstrap import build_play_world
from my_first_game.scenes.play.models import PlayTickContext, PlayWorld
from my_first_game.scenes.play.pipeline import build_play_systems


def _build_intent(actions, _ctx: PlayTickContext):
    ...


@register_scene("play")
class PlayScene(GameScene[PlayTickContext, PlayWorld]):
    tick_context_type = PlayTickContext
    systems_config = GameSceneSystemsConfig(
        controls_scene_key="play",
        intent_factory=_build_intent,
        pause_command_factory=lambda _ctx: PauseGameCommand(),
    )

    def on_enter(self):
        self.world = build_play_world(viewport=scene_viewport(self))
        self.systems.extend(build_play_systems())

Entities Deep Dive (How to model game objects)

Reference pattern from all current games:

  1. Define stable IDs (entity_id.py).

  2. Create entity builder classes/functions in entities/.

  3. Build entities with BaseEntity.from_dict(...).

  4. Store game-specific runtime flags as dynamic attributes when needed.

Typical base components in from_dict:

  • transform: position, size, optional rotation

  • shape: draw-time primitive shape metadata

  • collider: collision shape metadata

  • kinematic: velocity, acceleration, max speed

  • style: primitive color/stroke style

  • sprite: texture id for sprite-based rendering

  • anim: animation metadata (frame list + fps)

  • life: ttl/alive lifecycle metadata

Example entity builder (Asteroids-style):

from mini_arcade_core.engine.entities import BaseEntity


class PlayerShip(BaseEntity):
    @staticmethod
    def build(x: float, y: float) -> "PlayerShip":
        ship: PlayerShip = PlayerShip.from_dict(
            {
                "id": 1,
                "name": "Ship",
                "transform": {
                    "center": {"x": x, "y": y},
                    "size": {"width": 24.0, "height": 28.0},
                    "rotation_deg": -90.0,
                },
                "shape": {"kind": "triangle"},
                "collider": {"kind": "circle", "radius": 12.0},
                "kinematic": {
                    "velocity": {"vx": 0.0, "vy": 0.0},
                    "acceleration": {"ax": 0.0, "ay": 0.0},
                    "max_speed": 330.0,
                },
                "style": {"fill": (240, 240, 245, 255)},
            }
        )
        ship.fire_cd = 0.0
        ship.invuln_timer = 0.0
        return ship

Practical guidance:

  • Keep builder methods deterministic and free of side effects.

  • Add tags for gameplay-facing categories you will query often.

  • Declare entity_id_domains on World only when you need constrained runtime allocation or tracked cleanup.

  • Keep scene-global state in world, not in entity classes.

  • Use entity dynamic fields for per-entity runtime details only.

World and Models Deep Dive

Use models.py to define:

  • World (BaseWorld): all mutable scene state

  • Intent (BaseIntent): normalized input snapshot per tick

  • TickContext (BaseTickContext): typed pipeline context

Scale-up pattern from Space Invaders:

  • Put timers/cooldowns in world (ship_fire_timer, ufo_spawn_timer)

  • Put score/lives/round flags in world (score, lives, game_over)

  • Put transient VFX state in world (effects, fx_ttl)

  • Add helper selectors in world (ship(), asteroids(), bullets())

  • Prefer helper selectors backed by tags.

  • Add entity_id_domains only for content that needs bounded spawn ids such as bullet, missile, or asteroid.

Systems Deep Dive (Pipeline design)

A robust system order for gameplay scenes:

  1. Input systems (SystemPhase.INPUT)

  2. Control systems (pause/hotkeys/commands) (SystemPhase.CONTROL)

  3. Simulation systems (movement, collisions, rules) (SystemPhase.SIMULATION)

  4. Rendering systems (SystemPhase.RENDERING)

Example from real games:

  • Deja Bounce:

    • declarative gameplay shell -> intent commands -> movement bundles -> collision/rules -> render

  • Asteroids:

    • declarative gameplay shell -> ship control -> motion bundle -> collision -> render

  • Space Invaders:

    • declarative gameplay shell -> feature bundles + gameplay processors -> render

Rule of thumb:

  • Systems mutate ctx.world and enqueue commands in ctx.commands.

  • Exactly one render path must set ctx.packet each tick.

  • Keep each system focused on one responsibility.

  • Use SystemBundle when one feature is made from several reusable atomic processors.

  • Keep long ordered system lists out of scene.py; prefer a local pipeline.py builder.

GameScene shell

For most gameplay scenes, prefer GameScene plus GameSceneSystemsConfig instead of hand-wiring input/pause/render every time.

Typical responsibilities:

  • controls_scene_key + intent_factory: action bindings to typed intent

  • pause_command_factory: built-in pause intent handling

  • intent_command_bindings: one-shot command toggles

  • render_system_factory: attach the scene render system

That keeps scene code focused on world bootstrap and custom gameplay systems.

In the reference games, that usually means:

  • scene.py calls shared helpers such as scene_viewport(...) and scene_entities_config(...)

  • bootstrap.py builds the initial world

  • pipeline.py returns the ordered gameplay systems

Asset and Texture Patterns

Use these patterns from reference games:

  • Resolve asset root once (find_assets_root() helpers).

  • Put larger asset/template loaders in bootstrap.py, not directly in scene.py.

  • Cache texture lookups in scene methods (self._tex(path) pattern).

  • Keep logical projectile/animation specs in world (not global module state).

This keeps startup predictable and avoids per-frame texture loading.

Step 8: Ensure Scene Discovery Imports

src/my_first_game/scenes/__init__.py should import scene modules so decorators run:

from . import menu, pause
from .play import scene

Step 9: Run and Verify

From repo root:

mini-arcade run --game my-first-game

From game folder:

python manage.py

Expected result:

  • menu scene opens

  • ENTER starts play

  • ESC from play opens pause overlay

  • continue/menu actions work

Common Failure Modes

  • Game '<id>' not found: folder under games/ does not match --game.

  • Missing [tool.mini-arcade.game]: metadata block missing in pyproject.toml.

  • produced no RenderPacket: render system did not assign ctx.packet.

  • scene id not found: module with @register_scene(...) was not imported/discovered.

  • font/audio path issues: use ${assets_root} and backend_defaults(resolve_paths=True).

AI Agent Checklist

When generating a new game automatically, enforce this sequence:

  1. Create folder/package layout exactly as documented.

  2. Write pyproject.toml with [tool.mini-arcade.game].

  3. Add manage.py, __main__.py, and settings-driven app.py.

  4. Create settings/settings.yml with scene, engine_config, and backend.

  5. Create at least one registered gameplay scene plus one menu scene.

  6. Ensure scenes/__init__.py imports modules containing @register_scene.

  7. Run mini-arcade run --game <id> and fix import/config/runtime errors.

This gives a deterministic baseline that matches the current Mini Arcade runtime model.

Reference File Map (Use these as templates)

Deja Bounce (balanced baseline):

  • games/deja-bounce/src/deja_bounce/scenes/pong/scene.py

  • games/deja-bounce/src/deja_bounce/scenes/pong/bootstrap.py

  • games/deja-bounce/src/deja_bounce/scenes/pong/pipeline.py

  • games/deja-bounce/src/deja_bounce/scenes/pong/models.py

  • games/deja-bounce/src/deja_bounce/scenes/pong/draw_ops.py

  • games/deja-bounce/src/deja_bounce/scenes/pong/systems/

  • games/deja-bounce/src/deja_bounce/entities/

Asteroids (shape-heavy rendering and bounded spawn domains):

  • games/asteroids/src/asteroids/scenes/asteroids/scene.py

  • games/asteroids/src/asteroids/scenes/asteroids/bootstrap.py

  • games/asteroids/src/asteroids/scenes/asteroids/pipeline.py

  • games/asteroids/src/asteroids/scenes/asteroids/spawn.py

  • games/asteroids/src/asteroids/scenes/asteroids/models.py

  • games/asteroids/src/asteroids/scenes/asteroids/draw_ops.py

  • games/asteroids/src/asteroids/scenes/asteroids/systems/render.py

  • games/asteroids/src/asteroids/entities/

Space Invaders (large scene decomposition and advanced overlays):

  • games/space-invaders/src/space_invaders/scenes/space_invaders/scene.py

  • games/space-invaders/src/space_invaders/scenes/space_invaders/models.py

  • games/space-invaders/src/space_invaders/scenes/space_invaders/draw_ops.py

  • games/space-invaders/src/space_invaders/scenes/space_invaders/systems/render.py

  • games/space-invaders/src/space_invaders/entities/__init__.py