aboutsummaryrefslogtreecommitdiff
path: root/docs/superpowers/specs/2026-04-05-milestone-8-game-object-lifecycle-design.md
blob: d6e71be65c2f84d879bf25d298e342732c1225d7 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
# Milestone 8 — The Game Object and Lifecycle API

**Date:** 2026-04-05
**Status:** Approved
**Scope:** `engine.scm`, `assets.scm`, macroknight port
**Milestone numbering:** Follows `downstroke.org` / `TODO-engine.org` (Milestone 8). CLAUDE.md refers to the same work as "Milestone 7".

---

## Goal

Introduce `make-game` and `game-run!` as the public entry point for downstroke. A minimal game becomes ~20 lines of Scheme. This is the "Phaser moment" — the API stabilises around a game struct with lifecycle hooks.

---

## Architecture

Two new files:

- **`engine.scm`** — `make-game` struct, `game-run!`, the frame loop, and all public accessors.
- **`assets.scm`** — minimal key→value asset registry. No asset-type-specific logic; that is Milestone 6's domain.

`engine.scm` imports: `renderer.scm`, `input.scm`, `world.scm`, `assets.scm`, the sdl2 egg (prefix `sdl2:`), `sdl2-ttf` (prefix `ttf:`), and `sdl2-image` (prefix `img:`).

**Audio / mixer is not initialised by the engine in this milestone.** `mixer.scm` and `sound.scm` remain in macroknight and are not yet extracted. macroknight's `preload:` hook calls `init-audio!` directly from `sound.scm` until those modules are extracted.

`physics.scm` is **not** imported by the engine. Physics is a library; the user requires it if needed and calls physics functions from their `update:` hook.

### New function in `renderer.scm`

`render-scene!` does not yet exist. It must be added to `renderer.scm` as part of this milestone. The module uses `export *` so no explicit export update is needed.

Existing signatures in `renderer.scm` that `render-scene!` calls:
- `(draw-tilemap renderer camera tileset-texture tilemap)` — 4 args
- `(draw-entities renderer camera tileset tileset-texture entities)` — 5 args
- `tilemap-tileset` is a field accessor on the `tilemap` struct (defined in `tilemap.scm`)

```scheme
(define (render-scene! renderer scene)
  (let ((camera          (scene-camera scene))
        (tilemap         (scene-tilemap scene))
        (tileset-texture (scene-tileset-texture scene))
        (tileset         (tilemap-tileset (scene-tilemap scene)))
        (entities        (scene-entities scene)))
    (draw-tilemap renderer camera tileset-texture tilemap)
    (draw-entities renderer camera tileset tileset-texture entities)))
```

### Build order (Makefile)

Only modules that currently exist in downstroke are listed. Modules still in macroknight (`animation`, `ai`, `prefabs`, `mixer`, `sound`) are not prerequisites for this milestone.

```
entity → tilemap → world → input → renderer → assets → engine
```

`physics` is not a link dependency of `engine` and is omitted from this chain. It is a separate build target.

---

## Data Structures

### `game` struct (`engine.scm`)

`defstruct` is called with `constructor: make-game*` so the generated raw constructor does not collide with the public keyword constructor `make-game`.

```scheme
(defstruct (game constructor: make-game*)
  title width height
  window renderer
  input                ;; input-state record (accessor: game-input)
  input-config         ;; input-config record (accessor: game-input-config)
  assets               ;; asset registry hash table (from assets.scm)
  frame-delay
  preload-hook         ;; (lambda (game) ...)
  create-hook          ;; (lambda (game) ...)
  update-hook          ;; (lambda (game dt) ...)
  render-hook          ;; (lambda (game) ...) — post-render overlay
  scene)               ;; current scene struct (accessor: game-scene)
```

**Note:** There is no separate `camera` field. The camera lives inside the scene (`scene-camera`). `game-camera` is a convenience accessor (see Accessors section).

### `make-game` keyword constructor

```scheme
(define (make-game #!key
    (title "Downstroke Game")
    (width 640) (height 480)
    (frame-delay 16)
    (input-config *default-input-config*)
    (preload #f) (create #f) (update #f) (render #f))
  (make-game*
    title: title width: width height: height
    window: #f renderer: #f scene: #f
    input: (create-input-state input-config)  ;; create-input-state takes one arg: config
    input-config: input-config
    assets: (make-asset-registry)
    frame-delay: frame-delay
    preload-hook: preload
    create-hook: create
    update-hook: update
    render-hook: render))
```

### Asset registry (`assets.scm`)

```scheme
(define (make-asset-registry)    (make-hash-table))
(define (asset-set! reg key val) (hash-table-set! reg key val))
(define (asset-ref reg key)      (hash-table-ref reg key (lambda () #f)))
```

---

## Lifecycle: `game-run!`

```scheme
(define (game-run! game)
  ;; 1. SDL2 init (no audio — mixer not yet extracted, user calls init-audio! in preload:)
  (sdl2:init! '(video joystick game-controller))
  (ttf:init!)
  (img:init! '(png))

  ;; 2. Create window + renderer
  (game-window-set! game
    (sdl2:create-window! (game-title game) 'centered 'centered
                         (game-width game) (game-height game) '()))
  (game-renderer-set! game
    (sdl2:create-renderer! (game-window game) -1 '(accelerated vsync)))

  ;; 3. preload hook
  (when (game-preload-hook game) ((game-preload-hook game) game))

  ;; 4. create hook
  (when (game-create-hook game) ((game-create-hook game) game))

  ;; 5. Frame loop
  (let loop ((last-ticks (sdl2:get-ticks)))
    (let* ((now    (sdl2:get-ticks))
           (dt     (- now last-ticks))
           (events (sdl2:collect-events!))
           (input  (input-state-update (game-input game) events (game-input-config game))))
      (game-input-set! game input)
      (unless (input-held? input 'quit)
        (when (game-update-hook game) ((game-update-hook game) game dt))
        (sdl2:render-clear! (game-renderer game))
        (when (game-scene game)
          (render-scene! (game-renderer game) (game-scene game)))
        (when (game-render-hook game) ((game-render-hook game) game))
        (sdl2:render-present! (game-renderer game))
        (sdl2:delay! (game-frame-delay game))
        (loop now))))

  ;; 6. Cleanup
  (sdl2:destroy-window! (game-window game))
  (sdl2:quit!))
```

**Notes:**
- `sdl2:collect-events!` pumps the SDL2 event queue and returns a list of events (from the sdl2 egg).
- `input-state-update` is the existing function in `input.scm`: `(input-state-update state events config)`. It is purely functional — it returns a new `input-state` record and does not mutate the existing one.
- `create-input-state` in `input.scm` takes one argument: `(create-input-state config)` — it initialises all actions to `#f`.
- Quit is detected via `(input-held? input 'quit)` — the `'quit` action is in `*default-input-config*` mapped to `escape` key: `(escape . quit)` in `keyboard-map`.
- `sdl2:render-clear!` and `sdl2:render-present!` are called directly from the sdl2 egg (no wrapper needed).
- The loop exits when `input-held?` returns true for `'quit`. No user-settable `game-running?` flag in this milestone.

---

## `render:` Hook Semantics

Following Phaser 2.x: the engine draws the full scene first via `render-scene!` (tilemap layers + entities), then calls the user's `render:` hook. The hook is for post-render overlays only — debug info, HUD elements, custom visuals. It does not replace or suppress the engine's render pass.

---

## Physics

Physics (`physics.scm`) is a library, not a pipeline. `game-run!` does not call any physics function automatically. Users who want physics `(require-extension downstroke/physics)` and call functions from their `update:` hook.

---

## Public Accessors

All `game-*` readers and `game-*-set!` mutators are generated by `defstruct` and exported. Additional convenience accessors:

| Accessor | Description |
|---|---|
| `game-renderer` | SDL2 renderer — available from `preload:` onward |
| `game-window` | SDL2 window |
| `game-scene` / `game-scene-set!` | Current scene struct; user sets this in `create:` |
| `game-camera` | Convenience: `(scene-camera (game-scene game))` — derived, not a struct field. Only valid after `create:` sets a scene; calling before that is an error. |
| `game-assets` | Full asset registry hash table |
| `game-input` | Current `input-state` record |
| `game-input-config` | Current `input-config` record |
| `(game-asset game key)` | Convenience: `(asset-ref (game-assets game) key)` |
| `(game-asset-set! game key val)` | Convenience: `(asset-set! (game-assets game) key val)` |

`game-camera` is not a struct field — it is a derived accessor:
```scheme
(define (game-camera game)
  (scene-camera (game-scene game)))
```

---

## Scene-Level Preloading

Out of scope for this milestone. A `preload:` slot will be reserved in the scene struct when `scene-loader.scm` is implemented (Milestone 7), and the two-level asset registry (global + per-scene) will be designed then.

---

## macroknight Port

macroknight's `game.scm` must be ported to use `make-game` + `game-run!` as part of this milestone. It is the primary integration test.

**What moves to the engine:** SDL2 init block (lines 87–137), the main loop (line 659+), and all frame-level rendering.

**What stays in macroknight `game.scm`:** Only the three lifecycle hooks and `game-run!`:
- `preload:` — load fonts, sounds, tilemaps (currently scattered across lines 87–137)
- `create:` — build initial scene (currently `make-initial-game-state`, line 196)
- `update:` — game logic, input dispatch, physics calls (currently the per-frame update functions)

**Acceptance criterion:** macroknight compiles (`make` succeeds), the game runs and plays correctly (title screen → stage select → gameplay), and `game.scm` is ≤50 lines with no SDL2 init or frame-loop boilerplate.

---

## Out of Scope

- Scene-level preloading (Milestone 7)
- Asset-type-specific load functions (`game-load-tilemap!`, `game-load-font!`, etc.) — Milestone 6
- `game-running?` quit flag
- Scene state machine (Milestone 9)
- AI tag lookup (Milestone 10)
- Extraction of `animation`, `ai`, `prefabs`, `mixer`, `sound` modules into downstroke