aboutsummaryrefslogtreecommitdiff
path: root/docs/superpowers/plans/2026-04-05-milestone-8-game-object-lifecycle.md
blob: 0d123080d857536b77c43c4fdf9425541f222612 (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
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
# Milestone 8 — Game Object and Lifecycle API

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Introduce `make-game` + `game-run!` as the public entry point for downstroke, backed by a minimal asset registry, and port macroknight to use them.

**Architecture:** Two new modules (`assets.scm` — key/value registry; `engine.scm` — game struct, lifecycle, frame loop), plus `render-scene!` added to `renderer.scm`. `game-run!` owns SDL2 init, window/renderer creation, and the frame loop; lifecycle hooks (`preload:`, `create:`, `update:`, `render:`) are user-supplied lambdas.

**Tech Stack:** Chicken Scheme, SDL2 (sdl2 egg), sdl2-ttf, sdl2-image, SRFI-64 (tests), defstruct egg

**Spec:** `docs/superpowers/specs/2026-04-05-milestone-8-game-object-lifecycle-design.md`

---

## File Map

| Action | File | Purpose |
|---|---|---|
| Create | `assets.scm` | Key→value asset registry |
| Create | `tests/assets-test.scm` | SRFI-64 unit tests for assets.scm |
| Modify | `renderer.scm` | Add `render-scene!` |
| Modify | `tests/renderer-test.scm` | Add tests for `render-scene!` |
| Create | `engine.scm` | `make-game`, `game-run!`, accessors |
| Create | `tests/engine-test.scm` | Unit tests for make-game, accessors (SDL2 mocked) |
| Modify | `Makefile` | Add assets + engine to build, add test targets |
| Modify | `/home/gene/src/macroknight/game.scm` | Port to `make-game` + `game-run!` |

---

## Task 1: `assets.scm` — minimal key/value registry

**Files:**
- Create: `assets.scm`
- Create: `tests/assets-test.scm`

### What this module does

A thin wrapper around a hash table. No asset-type logic — that's Milestone 6. Three public functions:

```scheme
(make-asset-registry)          ;; → hash-table
(asset-set! registry key val)  ;; → unspecified (mutates)
(asset-ref registry key)       ;; → value or #f if missing
```

- [ ] **Step 1: Write the failing tests**

Create `tests/assets-test.scm`:

```scheme
(import scheme (chicken base) srfi-64)

(include "assets.scm")
(import downstroke/assets)

(test-begin "assets")

(test-group "make-asset-registry"
  (test-assert "returns a value"
    (make-asset-registry)))

(test-group "asset-set! and asset-ref"
  (let ((reg (make-asset-registry)))
    (test-equal "missing key returns #f"
      #f
      (asset-ref reg 'missing))

    (asset-set! reg 'my-tilemap "data")
    (test-equal "stored value is retrievable"
      "data"
      (asset-ref reg 'my-tilemap))

    (asset-set! reg 'my-tilemap "updated")
    (test-equal "overwrite replaces value"
      "updated"
      (asset-ref reg 'my-tilemap))

    (asset-set! reg 'other 42)
    (test-equal "multiple keys coexist"
      "updated"
      (asset-ref reg 'my-tilemap))
    (test-equal "second key retrievable"
      42
      (asset-ref reg 'other))))

(test-end "assets")
```

- [ ] **Step 2: Run test to confirm it fails**

```bash
cd /home/gene/src/downstroke
csi -s tests/assets-test.scm
```

Expected: error — `assets.scm` not found / `downstroke/assets` not defined.

- [ ] **Step 3: Implement `assets.scm`**

Create `assets.scm`:

```scheme
(module downstroke/assets *

(import scheme
        (chicken base)
        (srfi 69))

(define (make-asset-registry)
  (make-hash-table))

(define (asset-set! registry key value)
  (hash-table-set! registry key value))

(define (asset-ref registry key)
  (hash-table-ref/default registry key #f))

) ;; end module
```

- [ ] **Step 4: Run test to confirm it passes**

```bash
cd /home/gene/src/downstroke
csi -s tests/assets-test.scm
```

Expected: all tests pass, no failures reported.

- [ ] **Step 5: Commit**

```bash
cd /home/gene/src/downstroke
git add assets.scm tests/assets-test.scm
git commit -m "feat: add assets.scm — minimal key/value asset registry"
```

---

## Task 2: Add `render-scene!` to `renderer.scm`

**Files:**
- Modify: `renderer.scm` (add one function after `draw-entities`)
- Modify: `tests/renderer-test.scm` (add one test group)

### What this adds

`render-scene!` draws a complete scene (all tilemap layers + all entities) given a renderer and a scene struct. It delegates to the already-tested `draw-tilemap` and `draw-entities`.

Existing signatures in `renderer.scm`:
- `(draw-tilemap renderer camera tileset-texture tilemap)` — 4 args
- `(draw-entities renderer camera tileset tileset-texture entities)` — 5 args

The `tileset` is extracted from `tilemap` via the `tilemap-tileset` accessor (from `tilemap.scm`).

- [ ] **Step 1: Write the failing test**

Add to `tests/renderer-test.scm`, before `(test-end "renderer")`:

```scheme
(test-group "render-scene!"
  ;; render-scene! calls draw-tilemap and draw-entities — both are mocked to return #f.
  ;; We verify it doesn't crash on a valid scene and returns unspecified.
  (let* ((cam      (make-camera x: 0 y: 0))
         (tileset  (make-tileset tilewidth: 16 tileheight: 16
                                 spacing: 0 tilecount: 100 columns: 10
                                 image-source: "" image: #f))
         (layer    (make-layer name: "ground" width: 2 height: 2
                               map: '((1 2) (3 4))))
         (tilemap  (make-tilemap width: 2 height: 2
                                 tilewidth: 16 tileheight: 16
                                 tileset-source: ""
                                 tileset: tileset
                                 layers: (list layer)
                                 objects: '()))
         (scene    (make-scene entities: '()
                               tilemap: tilemap
                               camera: cam
                               tileset-texture: #f)))
    (test-assert "does not crash on valid scene"
      (begin (render-scene! #f scene) #t))))
```

- [ ] **Step 2: Run test to confirm it fails**

```bash
cd /home/gene/src/downstroke
csi -s tests/renderer-test.scm
```

Expected: error — `render-scene!` not defined.

- [ ] **Step 3: Add `render-scene!` to `renderer.scm`**

Add after the `draw-entities` definition (before the closing `)`):

```scheme
  ;; --- Scene drawing ---

  (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)))
```

- [ ] **Step 4: Run tests to confirm they pass**

```bash
cd /home/gene/src/downstroke
csi -s tests/renderer-test.scm
```

Expected: all tests pass.

- [ ] **Step 5: Commit**

```bash
cd /home/gene/src/downstroke
git add renderer.scm tests/renderer-test.scm
git commit -m "feat: add render-scene! to renderer — draw full scene in one call"
```

---

## Task 3: `engine.scm` — game struct, constructor, accessors

**Files:**
- Create: `engine.scm`
- Create: `tests/engine-test.scm`

This task covers only the data structure and constructor — no `game-run!` yet. TDD applies to the parts we can unit-test (struct creation, accessors).

`game-run!` requires a live SDL2 window and cannot be unit-tested; it is tested in Task 6 via macroknight integration.

### Mock strategy for tests

`engine.scm` imports `renderer.scm`, `input.scm`, `world.scm`, `assets.scm`, and the SDL2 sub-libraries. The test file follows the same mock-module pattern as `tests/renderer-test.scm`: define mock modules that satisfy the imports, then `(include "engine.scm")`.

- [ ] **Step 1: Write the failing tests**

Create `tests/engine-test.scm`:

```scheme
(import scheme (chicken base) (chicken keyword) srfi-64 defstruct)

;; --- Mocks ---

(module sdl2 *
  (import scheme (chicken base))
  (define (set-main-ready!) #f)
  (define (init! . args) #f)
  (define (quit! . args) #f)
  (define (get-ticks) 0)
  (define (delay! ms) #f)
  (define (pump-events!) #f)
  (define (has-events?) #f)
  (define (make-event) #f)
  (define (poll-event! e) #f)
  (define (num-joysticks) 0)
  (define (is-game-controller? i) #f)
  (define (game-controller-open! i) #f)
  (define (create-window! . args) 'mock-window)
  (define (create-renderer! . args) 'mock-renderer)
  (define (destroy-window! . args) #f)
  (define (render-clear! . args) #f)
  (define (render-present! . args) #f))
(import (prefix sdl2 "sdl2:"))

(module sdl2-ttf *
  (import scheme (chicken base))
  (define (init!) #f))
(import (prefix sdl2-ttf "ttf:"))

(module sdl2-image *
  (import scheme (chicken base))
  (define (init! . args) #f))
(import (prefix sdl2-image "img:"))

;; --- Real deps (include order follows dependency order) ---
(import simple-logger)   ;; required by input.scm
(include "entity.scm")   (import downstroke/entity)
(include "tilemap.scm")  (import downstroke/tilemap)
(include "world.scm")    (import downstroke/world)
(include "input.scm")    (import downstroke/input)
(include "assets.scm")   (import downstroke/assets)

;; Mock renderer (render-scene! can't run without real SDL2)
(module downstroke/renderer *
  (import scheme (chicken base))
  (define (render-scene! . args) #f))
(import downstroke/renderer)

(include "engine.scm")
(import downstroke/engine)

;; --- Tests ---

(test-begin "engine")

(test-group "make-game defaults"
  (let ((g (make-game)))
    (test-equal "default title"
      "Downstroke Game"
      (game-title g))
    (test-equal "default width"
      640
      (game-width g))
    (test-equal "default height"
      480
      (game-height g))
    (test-equal "default frame-delay"
      16
      (game-frame-delay g))
    (test-equal "scene starts as #f"
      #f
      (game-scene g))
    (test-equal "window starts as #f"
      #f
      (game-window g))
    (test-equal "renderer starts as #f"
      #f
      (game-renderer g))
    (test-assert "assets registry is created"
      (game-assets g))
    (test-assert "input state is created"
      (game-input g))))

(test-group "make-game with keyword args"
  (let ((g (make-game title: "My Game" width: 320 height: 240 frame-delay: 33)))
    (test-equal "custom title" "My Game" (game-title g))
    (test-equal "custom width" 320 (game-width g))
    (test-equal "custom height" 240 (game-height g))
    (test-equal "custom frame-delay" 33 (game-frame-delay g))))

(test-group "game-asset and game-asset-set!"
  (let ((g (make-game)))
    (test-equal "missing key returns #f"
      #f
      (game-asset g 'no-such-asset))
    (game-asset-set! g 'my-font 'font-object)
    (test-equal "stored asset is retrievable"
      'font-object
      (game-asset g 'my-font))
    (game-asset-set! g 'my-font 'updated-font)
    (test-equal "overwrite replaces asset"
      'updated-font
      (game-asset g 'my-font))))

(test-group "make-game hooks default to #f"
  (let ((g (make-game)))
    (test-equal "preload-hook is #f" #f (game-preload-hook g))
    (test-equal "create-hook is #f"  #f (game-create-hook g))
    (test-equal "update-hook is #f"  #f (game-update-hook g))
    (test-equal "render-hook is #f"  #f (game-render-hook g))))

(test-group "make-game accepts hook lambdas"
  (let* ((called #f)
         (g (make-game update: (lambda (game dt) (set! called #t)))))
    (test-assert "update hook is stored"
      (procedure? (game-update-hook g)))))

(test-end "engine")
```

- [ ] **Step 2: Run test to confirm it fails**

```bash
cd /home/gene/src/downstroke
csi -s tests/engine-test.scm
```

Expected: error — `engine.scm` not found.

- [ ] **Step 3: Implement `engine.scm` struct + constructor + accessors (no `game-run!`)**

Create `engine.scm`. `game-run!` is a stub for now — it will be filled in Task 4.

```scheme
(module downstroke/engine *

(import scheme
        (chicken base)
        (chicken keyword)
        (prefix sdl2 "sdl2:")
        (prefix sdl2-ttf "ttf:")
        (prefix sdl2-image "img:")
        defstruct
        downstroke/world
        downstroke/input
        downstroke/assets
        downstroke/renderer)

;; ── Game struct ────────────────────────────────────────────────────────────
;; constructor: make-game* (raw) so we can define make-game as keyword wrapper

(defstruct (game constructor: make-game*)
  title width height
  window renderer
  input          ;; input-state record
  input-config   ;; input-config record
  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; #f until create: runs

;; ── Public constructor ─────────────────────────────────────────────────────

(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)
    input-config: input-config
    assets:       (make-asset-registry)
    frame-delay:  frame-delay
    preload-hook: preload
    create-hook:  create
    update-hook:  update
    render-hook:  render))

;; ── Convenience accessors ──────────────────────────────────────────────────

;; game-camera: derived from the current scene (only valid after create: runs)
(define (game-camera game)
  (scene-camera (game-scene game)))

;; game-asset: retrieve an asset by key
(define (game-asset game key)
  (asset-ref (game-assets game) key))

;; game-asset-set!: store an asset by key
(define (game-asset-set! game key value)
  (asset-set! (game-assets game) key value))

;; ── game-run! ──────────────────────────────────────────────────────────────
;; Stub — implemented in Task 4

(define (game-run! game)
  (error "game-run! not yet implemented"))

) ;; end module
```

- [ ] **Step 4: Run tests to confirm they pass**

```bash
cd /home/gene/src/downstroke
csi -s tests/engine-test.scm
```

Expected: all tests pass.

- [ ] **Step 5: Commit**

```bash
cd /home/gene/src/downstroke
git add engine.scm tests/engine-test.scm
git commit -m "feat: add engine.scm — game struct, make-game constructor, accessors"
```

---

## Task 4: Implement `game-run!`

**Files:**
- Modify: `engine.scm` (replace stub with real implementation)

`game-run!` is tested via macroknight integration in Task 6, not via unit tests (it requires a real SDL2 display).

- [ ] **Step 1: Replace the `game-run!` stub in `engine.scm`**

Replace the stub `(define (game-run! game) (error ...))` with:

```scheme
(define (game-run! game)
  ;; 1. SDL2 init (audio excluded — mixer.scm not yet extracted;
  ;;    user calls init-audio! in their preload: hook)
  (sdl2:set-main-ready!)
  (sdl2:init! '(video joystick game-controller))
  (ttf:init!)
  (img:init! '(png))

  ;; Open any already-connected game controllers
  (let init-controllers ((i 0))
    (when (< i (sdl2:num-joysticks))
      (when (sdl2:is-game-controller? i)
        (sdl2:game-controller-open! i))
      (init-controllers (+ i 1))))

  ;; 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 — user loads assets here
  (when (game-preload-hook game)
    ((game-preload-hook game) game))

  ;; 4. create: hook — user builds initial scene here
  (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)))
      ;; Collect all pending SDL2 events
      (sdl2:pump-events!)
      (let* ((events (let collect ((lst '()))
                       (if (not (sdl2:has-events?))
                           (reverse lst)
                           (let ((e (sdl2:make-event)))
                             (sdl2:poll-event! e)
                             (collect (cons e lst))))))
             (input  (input-state-update (game-input game) events
                                         (game-input-config game))))
        (game-input-set! game input)
        (unless (input-held? input 'quit)
          ;; update: hook — user game logic
          (when (game-update-hook game)
            ((game-update-hook game) game dt))
          ;; render: engine draws world, then user overlay
          (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!))
```

Note: `sdl2:pump-events!` processes the OS event queue; `sdl2:has-events?`/`sdl2:make-event`/`sdl2:poll-event!` drain it into a list. This matches the pattern used in macroknight's existing game loop (lines 661–667).

**Spec discrepancy:** The spec document shows `(sdl2:collect-events!)` in its pseudocode — that function does not exist in the sdl2 egg. The plan's pump+collect loop above is the correct implementation. Ignore the spec's `sdl2:collect-events!` reference.

- [ ] **Step 2: Verify engine-test.scm still passes**

```bash
cd /home/gene/src/downstroke
csi -s tests/engine-test.scm
```

Expected: all tests pass (game-run! itself is not called in unit tests).

- [ ] **Step 3: Commit**

```bash
cd /home/gene/src/downstroke
git add engine.scm tests/engine-test.scm
git commit -m "feat: implement game-run! — SDL2 init, lifecycle hooks, frame loop"
```

---

## Task 5: Update the Makefile

**Files:**
- Modify: `Makefile`

- [ ] **Step 1: Add `assets` and `engine` to `MODULE_NAMES`**

In `Makefile`, change:
```makefile
MODULE_NAMES := entity tilemap world input physics renderer
```
to:
```makefile
MODULE_NAMES := entity tilemap world input physics renderer assets engine
```

- [ ] **Step 2: Add dependency declarations**

Add after `bin/renderer.o: bin/entity.o bin/tilemap.o bin/world.o`:

```makefile
bin/assets.o:
bin/engine.o: bin/renderer.o bin/world.o bin/input.o bin/assets.o
```

`assets.scm` has no inter-module dependencies. `engine.scm` depends on renderer, world, input, and assets.

- [ ] **Step 3: Add test targets**

In the `test:` rule, add:
```makefile
	@csi -s tests/assets-test.scm
	@csi -s tests/engine-test.scm
```

- [ ] **Step 4: Verify the build**

```bash
cd /home/gene/src/downstroke
make clean && make
```

Expected: all `.o` files created in `bin/`, including `bin/assets.o` and `bin/engine.o`. No errors.

- [ ] **Step 5: Run all tests**

```bash
cd /home/gene/src/downstroke
make test
```

Expected: all test suites pass.

- [ ] **Step 6: Commit**

```bash
cd /home/gene/src/downstroke
git add Makefile
git commit -m "build: add assets and engine modules to Makefile"
```

---

## Task 6: Port macroknight to `make-game` + `game-run!`

**Files:**
- Modify: `/home/gene/src/macroknight/game.scm`

This is the integration test for the whole milestone. macroknight's current `game.scm` is ~678 lines with SDL2 init, the frame loop, and all game state mixed together. After this task it should be ≤50 lines: only `make-game`, the three lifecycle hooks, and `game-run!`.

### What moves vs. what stays

**Moves to engine (delete from game.scm):**
- Lines 87–137: SDL2 init block — from `(sdl2:set-main-ready!)` through `(define *title-font* ...)` inclusive. This covers: `sdl2:set-main-ready!`, `sdl2:init!`, `init-game-controllers!` call, `ttf:init!`, `img:init!`, audio init calls, `on-exit` handlers, exception handler wrapper, `sdl2:set-hint!`, `sdl2:create-window!`, `sdl2:create-renderer!`, `ttf:open-font` calls.
- Lines 659–673: The main `let/cc` game loop

**Stays in macroknight (wrapped in lifecycle hooks):**
- `init-audio!` + `load-sounds!` + `load-music!` → `preload:` hook
- `ttf:open-font` calls (fonts) → `preload:` hook
- `make-initial-game-state` → `create:` hook
- `update-game-state` dispatch → `update:` hook
- `render-frame` mode dispatch (minus clear/present) → `render:` hook

**macroknight-specific state** (`game-state` struct: mode, menu-cursor, scene, input, etc.) stays as the module-level `*gs*` variable — set in `create:`, mutated in `update:`.

**`render-frame` split:** The existing `render-frame` calls `sdl2:render-clear!` and `sdl2:render-present!` — those are now owned by the engine. Modify `render-frame` to remove those two calls, keeping only the draw-color set and the mode dispatch. The modified `render-frame` becomes the body of the `render:` hook. Since the background color is black (`0 0 0`) and the engine's clear already clears to black, there is no visual difference.

### Implementation steps

- [ ] **Step 1: Add `downstroke/engine` to macroknight's imports**

In `/home/gene/src/macroknight/game.scm`, add to the import list:
```scheme
downstroke/engine
downstroke/assets
```

- [ ] **Step 2: Restructure as `make-game` call**

Replace the top-level SDL2 init block and the game loop at the bottom of `game.scm` with a `make-game` call. The three lifecycle hooks capture the existing functions from the rest of the file.

**First, modify `render-frame`** to remove the SDL2 clear/present calls (the engine now owns those). Change lines 378–385 of `game.scm` from:

```scheme
(define (render-frame gs)
  (set! (sdl2:render-draw-color *renderer*) +background-color+)
  (sdl2:render-clear! *renderer*)
  (case (game-state-mode gs)
    ((main-menu)    (draw-main-menu    gs))
    ((stage-select) (draw-stage-select gs))
    ((playing)      (draw-playing      gs)))
  (sdl2:render-present! *renderer*))
```

to:

```scheme
(define (render-frame gs)
  (set! (sdl2:render-draw-color *renderer*) +background-color+)
  (case (game-state-mode gs)
    ((main-menu)    (draw-main-menu    gs))
    ((stage-select) (draw-stage-select gs))
    ((playing)      (draw-playing      gs))))
```

**Then add module-level state and the engine entry point** at the bottom of `game.scm` (replacing the old `let/cc` loop):

```scheme
;; ── Module-level game state ────────────────────────────────────────────────

(define *gs* #f)  ;; macroknight game state, set in create:

;; ── Engine entry point ─────────────────────────────────────────────────────

(define *the-game*
  (make-game
    title:       "MacroKnight"
    width:       +screen-width+
    height:      +screen-height+
    frame-delay: 10

    preload: (lambda (game)
               ;; Audio (mixer not yet extracted — call directly)
               (init-audio!)
               (load-sounds! '((jump . "assets/jump.wav")))
               (load-music!  "assets/theme.ogg")
               (play-music!  0.6)
               ;; Fonts
               (set! *font*       (ttf:open-font "assets/DejaVuSans.ttf" 12))
               (set! *title-font* (ttf:open-font "assets/DejaVuSans.ttf" 32))
               ;; Make the SDL2 renderer available to macroknight functions
               (set! *renderer* (game-renderer game)))

    create: (lambda (game)
              (set! *gs* (make-initial-game-state)))

    update: (lambda (game dt)
              ;; update-game-state uses a continuation for the "quit" menu item;
              ;; pass a no-op since the engine handles Escape→quit automatically.
              (update-game-state *gs* (lambda () #f))
              (maybe-advance-level *gs*))

    render: (lambda (game)
              ;; engine has already called render-clear! and render-scene!
              ;; (game-scene is #f so render-scene! is a no-op for now);
              ;; render-frame draws the mode-specific content.
              (render-frame *gs*))))

(game-run! *the-game*)
```

**Notes:**
- `*renderer*`, `*font*`, `*title-font*` remain module-level variables (set in `preload:`). Keep their `(define ...)` declarations at the top of `game.scm` as `(define *font* #f)` etc. (uninitialized — they'll be set in `preload:`).
- The engine's input handles Escape → quit via `input-held? input 'quit`; the old `let/cc exit-main-loop!` mechanism and `exit-main-loop!` call in `update-game-state` are no longer needed. **However, do not change `update-game-state` in this milestone** — the `(lambda () #f)` passed as `exit!` means the "Quit" menu item is a no-op for now. Fix it in a later milestone.
- `(game-scene game)` is never set from macroknight, so the engine's `render-scene!` is always a no-op. All rendering happens in the `render:` hook via `render-frame`. This is intentional for this port milestone.

- [ ] **Step 3: Remove the old SDL2 init block from `game.scm`**

Delete lines 87–137 — everything from `(sdl2:set-main-ready!)` through `(define *title-font* (ttf:open-font ...))` inclusive.

This also removes the `on-exit` cleanup handlers (lines 107–108) and the custom exception handler (lines 113–117). **This is intentional for this milestone.** The engine's `game-run!` calls `sdl2:destroy-window!` and `sdl2:quit!` at cleanup, but does not install an exception handler. If macroknight crashes mid-run, SDL2 will not be shut down cleanly. This will be addressed in a later milestone (either by adding error handling to `game-run!` or by macroknight wrapping `game-run!` in a guard).

After deletion, add these uninitialized stubs near the top of `game.scm` (after the imports) so the rest of the file can still reference the variables. Note: `*window*` only appears inside the deleted range, so its stub is precautionary only.

```scheme
(define *window*     #f)  ;; owned by engine; not used after this port
(define *renderer*   #f)  ;; set in preload:
(define *font*       #f)  ;; set in preload:
(define *title-font* #f)  ;; set in preload:
(define *text-color* (sdl2:make-color 255 255 255))
```

- [ ] **Step 4: Remove the old main loop from `game.scm`**

Delete lines 659–678 (the `let/cc` game loop through the end of the file including the `"Bye!\n"` print). The engine's `game-run!` replaces it. The new `(define *the-game* ...)` and `(game-run! *the-game*)` from Step 2 are the new end of the file.

- [ ] **Step 5: Build macroknight to verify it compiles**

```bash
cd /home/gene/src/macroknight
make clean && make
```

Expected: successful compilation. Fix any symbol-not-found or import errors before continuing.

- [ ] **Step 6: Run macroknight to verify it plays**

```bash
cd /home/gene/src/macroknight
./bin/game
```

Expected: game launches, title screen appears, gameplay works correctly (title → stage select → play a level). Press Escape to quit.

**Known regression (intentional):** Selecting "Quit" from the main menu is a no-op in this milestone. The old `exit-main-loop!` continuation no longer exists; the `update:` hook passes `(lambda () #f)` in its place. This will be fixed in a later milestone.

- [ ] **Step 7: Verify game.scm is ≤50 lines**

```bash
wc -l /home/gene/src/macroknight/game.scm
```

If still over 50 lines, extract helper functions that belong in other modules. Game-specific logic (menu rendering, level loading) can stay; SDL2 boilerplate must be gone.

- [ ] **Step 8: Commit**

```bash
cd /home/gene/src/macroknight
git add game.scm
git commit -m "feat: port macroknight to use make-game + game-run! (Milestone 8)"
```

Then commit the downstroke side:

```bash
cd /home/gene/src/downstroke
git add -A
git commit -m "feat: Milestone 8 complete — make-game + game-run! engine entry point"
```

---

## Acceptance Criteria

- [ ] `make test` in downstroke passes all suites including assets and engine
- [ ] `make` in downstroke builds all modules including `bin/assets.o` and `bin/engine.o`
- [ ] `make && ./bin/game` in macroknight launches and plays correctly
- [ ] macroknight `game.scm` is ≤50 lines with no SDL2 init or frame-loop boilerplate
- [ ] `make-game` + `game-run!` are the sole entry point — no top-level SDL2 calls outside them