romdevtools 0.28.0 → 0.30.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (179) hide show
  1. package/AGENTS.md +53 -43
  2. package/CHANGELOG.md +91 -0
  3. package/README.md +3 -3
  4. package/examples/README.md +7 -7
  5. package/examples/atari2600/templates/platformer.asm +1225 -332
  6. package/examples/atari2600/templates/puzzle.asm +1056 -0
  7. package/examples/atari2600/templates/racing.asm +906 -275
  8. package/examples/atari2600/templates/shmup.asm +1031 -239
  9. package/examples/atari2600/templates/sports.asm +1135 -253
  10. package/examples/atari7800/templates/platformer.c +991 -156
  11. package/examples/atari7800/templates/puzzle.c +1091 -148
  12. package/examples/atari7800/templates/racing.c +952 -124
  13. package/examples/atari7800/templates/shmup.c +812 -134
  14. package/examples/atari7800/templates/sports.c +820 -184
  15. package/examples/c64/templates/platformer.c +879 -164
  16. package/examples/c64/templates/puzzle.c +855 -178
  17. package/examples/c64/templates/racing.c +873 -97
  18. package/examples/c64/templates/shmup.c +757 -161
  19. package/examples/c64/templates/sports.c +755 -100
  20. package/examples/gb/templates/platformer.c +841 -179
  21. package/examples/gb/templates/puzzle.c +986 -246
  22. package/examples/gb/templates/racing.c +754 -174
  23. package/examples/gb/templates/shmup.c +673 -175
  24. package/examples/gb/templates/sports.c +790 -159
  25. package/examples/gba/templates/platformer.c +626 -165
  26. package/examples/gba/templates/puzzle.c +519 -269
  27. package/examples/gba/templates/racing.c +511 -206
  28. package/examples/gba/templates/shmup.c +564 -179
  29. package/examples/gba/templates/sports.c +454 -174
  30. package/examples/gbc/templates/platformer.c +944 -180
  31. package/examples/gbc/templates/puzzle.c +363 -109
  32. package/examples/gbc/templates/racing.c +884 -180
  33. package/examples/gbc/templates/shmup.c +821 -185
  34. package/examples/gbc/templates/sports.c +870 -162
  35. package/examples/genesis/templates/platformer.c +747 -129
  36. package/examples/genesis/templates/puzzle.c +694 -261
  37. package/examples/genesis/templates/racing.c +726 -203
  38. package/examples/genesis/templates/shmup.c +535 -142
  39. package/examples/genesis/templates/sports.c +495 -158
  40. package/examples/gg/templates/platformer.c +880 -215
  41. package/examples/gg/templates/puzzle.c +875 -216
  42. package/examples/gg/templates/racing.c +915 -172
  43. package/examples/gg/templates/shmup.c +714 -191
  44. package/examples/gg/templates/sports.c +732 -129
  45. package/examples/lynx/templates/platformer.c +604 -69
  46. package/examples/lynx/templates/puzzle.c +498 -158
  47. package/examples/lynx/templates/racing.c +538 -102
  48. package/examples/lynx/templates/shmup.c +458 -131
  49. package/examples/lynx/templates/sports.c +496 -72
  50. package/examples/msx/platformer/main.c +649 -162
  51. package/examples/msx/puzzle/main.c +742 -240
  52. package/examples/msx/racing/main.c +669 -178
  53. package/examples/msx/shmup/main.c +460 -178
  54. package/examples/msx/sports/main.c +592 -126
  55. package/examples/nes/templates/platformer.c +589 -171
  56. package/examples/nes/templates/puzzle.c +563 -242
  57. package/examples/nes/templates/racing.c +502 -208
  58. package/examples/nes/templates/shmup.c +339 -145
  59. package/examples/nes/templates/sports.c +341 -183
  60. package/examples/pce/platformer/main.c +874 -205
  61. package/examples/pce/puzzle/main.c +802 -287
  62. package/examples/pce/racing/main.c +783 -208
  63. package/examples/pce/shmup/main.c +638 -212
  64. package/examples/pce/sports/main.c +586 -169
  65. package/examples/porting-across-platforms/README.md +1 -1
  66. package/examples/sms/templates/platformer.c +762 -177
  67. package/examples/sms/templates/puzzle.c +752 -212
  68. package/examples/sms/templates/racing.c +808 -145
  69. package/examples/sms/templates/shmup.c +599 -162
  70. package/examples/sms/templates/sports.c +630 -122
  71. package/examples/snes/templates/music_demo.c +7 -0
  72. package/examples/snes/templates/platformer-data.asm +123 -24
  73. package/examples/snes/templates/platformer-hdr.asm +57 -0
  74. package/examples/snes/templates/platformer.c +586 -165
  75. package/examples/snes/templates/puzzle-data.asm +116 -21
  76. package/examples/snes/templates/puzzle-hdr.asm +57 -0
  77. package/examples/snes/templates/puzzle.c +614 -235
  78. package/examples/snes/templates/racing-data.asm +390 -32
  79. package/examples/snes/templates/racing-hdr.asm +57 -0
  80. package/examples/snes/templates/racing.c +807 -196
  81. package/examples/snes/templates/shmup-data.asm +87 -29
  82. package/examples/snes/templates/shmup-hdr.asm +57 -0
  83. package/examples/snes/templates/shmup.c +459 -198
  84. package/examples/snes/templates/sports-data.asm +48 -2
  85. package/examples/snes/templates/sports-hdr.asm +57 -0
  86. package/examples/snes/templates/sports.c +414 -163
  87. package/package.json +12 -12
  88. package/src/cores/wasm/bluemsx_libretro.js +1 -1
  89. package/src/cores/wasm/bluemsx_libretro.wasm +0 -0
  90. package/src/cores/wasm/fceumm_libretro.js +1 -1
  91. package/src/cores/wasm/fceumm_libretro.wasm +0 -0
  92. package/src/cores/wasm/gambatte_libretro.js +1 -1
  93. package/src/cores/wasm/gambatte_libretro.wasm +0 -0
  94. package/src/cores/wasm/geargrafx_libretro.js +1 -1
  95. package/src/cores/wasm/geargrafx_libretro.wasm +0 -0
  96. package/src/cores/wasm/genesis_plus_gx_libretro.js +1 -1
  97. package/src/cores/wasm/genesis_plus_gx_libretro.wasm +0 -0
  98. package/src/cores/wasm/handy_libretro.js +1 -1
  99. package/src/cores/wasm/handy_libretro.wasm +0 -0
  100. package/src/cores/wasm/mgba_libretro.js +1 -1
  101. package/src/cores/wasm/mgba_libretro.wasm +0 -0
  102. package/src/cores/wasm/prosystem_libretro.js +1 -1
  103. package/src/cores/wasm/prosystem_libretro.wasm +0 -0
  104. package/src/cores/wasm/snes9x_libretro.js +1 -1
  105. package/src/cores/wasm/snes9x_libretro.wasm +0 -0
  106. package/src/cores/wasm/stella2014_libretro.js +1 -1
  107. package/src/cores/wasm/stella2014_libretro.wasm +0 -0
  108. package/src/cores/wasm/vice_x64_libretro.js +1 -1
  109. package/src/cores/wasm/vice_x64_libretro.wasm +0 -0
  110. package/src/host/LibretroHost.js +84 -8
  111. package/src/http/tool-registry.js +11 -11
  112. package/src/mcp/tools/cheats.js +2 -1
  113. package/src/mcp/tools/frame.js +3 -2
  114. package/src/mcp/tools/index.js +3 -3
  115. package/src/mcp/tools/input.js +5 -4
  116. package/src/mcp/tools/lifecycle.js +6 -4
  117. package/src/mcp/tools/memory.js +131 -24
  118. package/src/mcp/tools/platform-docs.js +1 -1
  119. package/src/mcp/tools/preview-tile.js +6 -2
  120. package/src/mcp/tools/project.js +1098 -130
  121. package/src/mcp/tools/record.js +6 -7
  122. package/src/mcp/tools/rom-id.js +5 -1
  123. package/src/mcp/tools/run-until.js +12 -4
  124. package/src/mcp/tools/snippets.js +6 -6
  125. package/src/mcp/tools/sprite-pipeline.js +14 -2
  126. package/src/mcp/tools/state.js +2 -1
  127. package/src/mcp/tools/tile-inspect.js +8 -1
  128. package/src/mcp/tools/toolchain.js +12 -1
  129. package/src/mcp/tools/watch-memory.js +53 -10
  130. package/src/observer/bus.js +73 -0
  131. package/src/observer/livestream.html +4 -2
  132. package/src/observer/tool-wrap.js +17 -14
  133. package/src/platforms/_guides/ROMHACKING_PLAYBOOK.md +32 -3
  134. package/src/platforms/atari7800/MENTAL_MODEL.md +5 -5
  135. package/src/platforms/atari7800/TROUBLESHOOTING.md +5 -5
  136. package/src/platforms/c64/MENTAL_MODEL.md +11 -4
  137. package/src/platforms/c64/TROUBLESHOOTING.md +13 -0
  138. package/src/platforms/gb/MENTAL_MODEL.md +3 -3
  139. package/src/platforms/gb/TROUBLESHOOTING.md +61 -8
  140. package/src/platforms/gb/lib/c/README.md +10 -11
  141. package/src/platforms/gb/lib/c/gb_crt0.s +27 -3
  142. package/src/platforms/gb/lib/c/patch-header.js +13 -3
  143. package/src/platforms/gba/MENTAL_MODEL.md +4 -4
  144. package/src/platforms/gba/TROUBLESHOOTING.md +3 -3
  145. package/src/platforms/gba/lib/c/gba_sfx.c +40 -0
  146. package/src/platforms/gba/lib/c/gba_sfx.h +10 -0
  147. package/src/platforms/gbc/MENTAL_MODEL.md +4 -4
  148. package/src/platforms/gbc/TROUBLESHOOTING.md +4 -4
  149. package/src/platforms/gbc/UPSTREAM_SOURCES.md +1 -1
  150. package/src/platforms/gbc/lib/c/README.md +10 -11
  151. package/src/platforms/gbc/lib/c/gb_crt0.s +26 -3
  152. package/src/platforms/gbc/lib/c/patch-header.js +13 -3
  153. package/src/platforms/genesis/MENTAL_MODEL.md +3 -3
  154. package/src/platforms/genesis/TROUBLESHOOTING.md +2 -2
  155. package/src/platforms/gg/MENTAL_MODEL.md +4 -4
  156. package/src/platforms/gg/TROUBLESHOOTING.md +3 -3
  157. package/src/platforms/gg/UPSTREAM_SOURCES.md +1 -1
  158. package/src/platforms/gg/lib/c/joypad_read.c +29 -0
  159. package/src/platforms/lynx/MENTAL_MODEL.md +1 -1
  160. package/src/platforms/lynx/TROUBLESHOOTING.md +3 -3
  161. package/src/platforms/msx/MENTAL_MODEL.md +5 -5
  162. package/src/platforms/msx/TROUBLESHOOTING.md +2 -2
  163. package/src/platforms/msx/lib/c/msx_hw.h +1 -0
  164. package/src/platforms/msx/lib/c/msx_vdp.c +25 -0
  165. package/src/platforms/nes/MENTAL_MODEL.md +2 -2
  166. package/src/platforms/nes/lib/c/nes_runtime.c +149 -34
  167. package/src/platforms/nes/lib/c/nes_runtime.h +34 -1
  168. package/src/platforms/pce/MENTAL_MODEL.md +5 -5
  169. package/src/platforms/pce/TROUBLESHOOTING.md +1 -1
  170. package/src/platforms/pce/lib/c/pce_hw.h +11 -0
  171. package/src/platforms/pce/lib/c/pce_video.c +32 -0
  172. package/src/platforms/sms/MENTAL_MODEL.md +6 -6
  173. package/src/platforms/snes/MENTAL_MODEL.md +2 -2
  174. package/src/platforms/snes/TROUBLESHOOTING.md +40 -1
  175. package/src/toolchains/cc65/presets/nes/chr-ram-runtime.cfg +13 -8
  176. package/src/toolchains/cc65/presets/nes/chr-ram-runtime.crt0.s +58 -5
  177. package/src/toolchains/cc65/presets/nes/chr-rom.crt0.s +52 -3
  178. package/src/toolchains/cc65/presets/pce/rom32k.cfg +52 -0
  179. package/src/toolchains/index.js +27 -11
@@ -1,149 +1,925 @@
1
- // ── racing.c — Commodore 64 top-down racing scaffold ─────────────────
2
- //
3
- // 3-lane endless racer. Player car at the bottom; obstacles spawn from
4
- // top and slide down. LEFT/RIGHT switches lanes. Joystick port 2.
1
+ /* ── racing.c — C64 top-down vertical road racer (complete example game) ──────
2
+ *
3
+ * VAPOR VECTOR a COMPLETE, working game: title screen, 1P endless race with
4
+ * speed control, 2P simultaneous SPLIT-LANE VERSUS (both cars on screen at
5
+ * once — player 2 on CONTROL PORT 1), a vertically-scrolling road done the
6
+ * C64 way (VIC-II fine $D011 Y-scroll + a software COARSE row shift), a fixed
7
+ * HUD held over the moving road by the C64's signature raster-IRQ split, best
8
+ * distance in-session behind the gated-persistence seam, 2-voice SID music
9
+ * with the C64's filter sweep + SFX. The player's car is a VIC-II HARDWARE
10
+ * SPRITE; the road, lane lines and scenery are CHARACTERS that scroll.
11
+ *
12
+ * THIS FILE IS MEANT TO BE FORKED AND MODIFIED into your own game — even a
13
+ * very different one. The markers tell you what's what:
14
+ * HARDWARE IDIOM (load-bearing) — dodges a documented C64 footgun; reshape
15
+ * your gameplay around it (see TROUBLESHOOTING before changing).
16
+ * GAME LOGIC (clay) — traffic patterns, speeds, tuning, art: reshape freely.
17
+ *
18
+ * What depends on what:
19
+ * c64_registers.h — VIC-II / SID / CIA symbolic addresses (header only).
20
+ * c64_sfx.{h,c} — one-shot SID sound effects on voice 2.
21
+ * The BASIC stub + crt0 come from cc65's c64 target: the .prg loads at
22
+ * $0801, a tiny BASIC line does SYS into the C runtime, and the KERNAL
23
+ * stays banked in (we lean on that for the IRQ vector — see below).
24
+ *
25
+ * Memory map this file assumes (VIC bank 0 = $0000-$3FFF):
26
+ * $0400 screen RAM (40×25 chars) $D800 color RAM (static texture)
27
+ * $0801 this program (code+data grow up from here)
28
+ * $3F00 sprite images (1 × 64 bytes) — NOT $0800, which collides with
29
+ * the .prg load address, and NOT $1000-$1FFF, where the VIC sees
30
+ * the character ROM instead of RAM (a classic invisible-sprite trap).
31
+ * Keep the program under ~14 KB so it stays below $3F00.
32
+ *
33
+ * THE SCROLL — the platformer template (TALUS TROT) scrolls HORIZONTALLY via
34
+ * $D016 + a column shift; this game scrolls VERTICALLY via $D011's YSCROLL +
35
+ * a ROW shift. Same two-layer plan: the VIC-II fine-scrolls only 0-7 px in
36
+ * hardware (YSCROLL, $D011 low 3 bits); past that you COARSE-scroll in
37
+ * software by shifting the visible char ROWS and stamping one fresh row of
38
+ * road at the top from the world. Both halves run here — see scroll_field and
39
+ * the raster split. (C64 MENTAL_MODEL.md → "Scrolling".)
40
+ */
5
41
 
6
42
  #include "c64_registers.h"
7
43
  #include "c64_sfx.h"
8
44
  #include <stdint.h>
9
45
 
46
+ /* cc65 KERNAL disk-I/O prototypes. We DON'T #include <cbm.h> — it drags in
47
+ * <c64.h>, whose VIC/SID/JOY macros collide with this project's
48
+ * c64_registers.h (cc65 errors "macro redefinition is not identical"). These
49
+ * four are the stable cc65 ABI; declaring them directly avoids the clash. */
50
+ unsigned char __fastcall__ cbm_open(unsigned char lfn, unsigned char device,
51
+ unsigned char sec_addr, const char *name);
52
+ void __fastcall__ cbm_close(unsigned char lfn);
53
+ int __fastcall__ cbm_read(unsigned char lfn, void *buffer, unsigned int size);
54
+ int __fastcall__ cbm_write(unsigned char lfn, const void *buffer, unsigned int size);
55
+
56
+ /* The title screen renders this — examples({op:'fork'}) stamps your game's
57
+ * name here automatically. Keep it ≤16 chars of A-Z 0-9 space dash. */
58
+ #define GAME_TITLE "VAPOR VECTOR"
59
+
10
60
  #define POKE(addr, val) (*(volatile uint8_t*)(addr) = (val))
11
61
  #define PEEK(addr) (*(volatile uint8_t*)(addr))
12
62
 
63
+ #define SCREEN ((volatile uint8_t*)0x0400)
64
+ #define COLORS ((volatile uint8_t*)0xD800)
65
+ #define SPRITE_POINTERS ((volatile uint8_t*)0x07F8) /* last 8 bytes of screen RAM */
66
+
67
+ /* ── Screen layout (the raster split divides bar from the scrolling road) ────
68
+ * char row 0 — score bar text: DST / BEST / CR / mode (FIXED)
69
+ * char row 1 — solid divider line (FIXED)
70
+ * char row 2 — blank spacer: the split lands mid-row HERE, where a few
71
+ * raster lines of IRQ jitter (and the YSCROLL row-smear) are
72
+ * invisible (uniform color)
73
+ * char rows 3-24 — the vertically-scrolling road
74
+ * PAL raster geometry: with YSCROLL=3 (the power-on default) text row r
75
+ * occupies raster lines 51+8r .. 58+8r. So the spacer row 2 = lines 67-74,
76
+ * and the playfield's first row 3 starts at line 75. */
77
+ #define FIELD_TOP 3
78
+ #define SPLIT_LINE 68 /* inside spacer row 2 (67-74): jitter-proof */
79
+ #define BOTTOM_LINE 251 /* first line below the 25-row text window (ends 250) */
80
+ /* $D011 values for the two halves of the frame. Keep DEN (bit4, screen on),
81
+ * RSEL (bit3, 25 rows) set and bit7 (raster compare bit 8) CLEAR (both split
82
+ * lines < 256); the low 3 bits are the fine Y-scroll 0-7. */
83
+ #define D011_KEEP 0x18 /* DEN + RSEL, bit7=0 — the constant part */
84
+ #define D011_BAR 0x1B /* fine Y = 3 (power-on) — the fixed bar */
85
+
86
+ /* ── GAME LOGIC (clay — reshape freely) — sprite art (24×21, 3 bytes/row) ──
87
+ * Two VIC-II hardware sprites: P1's car and P2's car (versus). The road,
88
+ * lane lines, shoulders and traffic are all CHARACTERS in screen RAM (the
89
+ * scroll shifts them), so they cost no sprite slots. */
90
+ #define SLOT_P1 0
91
+ #define SLOT_P2 1
92
+ #define SPR_DATA(img) (0x3F00 + (img) * 64)
93
+ #define SPR_PTR(img) (uint8_t)(SPR_DATA(img) / 64) /* $3F00/64 = $FC */
94
+ #define IMG_CAR 0
95
+
96
+ static const uint8_t car_sprite[64] = { /* a little top-down car, nose up */
97
+ 0x00,0x00,0x00, 0x03,0xC0,0x00, 0x07,0xE0,0x00, 0x07,0xE0,0x00,
98
+ 0x3F,0xFC,0x00, 0x7F,0xFE,0x00, 0x7F,0xFE,0x00, 0x66,0x66,0x00,
99
+ 0x66,0x66,0x00, 0x7F,0xFE,0x00, 0x7F,0xFE,0x00, 0x7F,0xFE,0x00,
100
+ 0x66,0x66,0x00, 0x66,0x66,0x00, 0x7F,0xFE,0x00, 0x7F,0xFE,0x00,
101
+ 0x3F,0xFC,0x00, 0x18,0x18,0x00, 0x18,0x18,0x00, 0,0,0, 0,0,0, 0,
102
+ };
103
+
104
+ /* ── HARDWARE IDIOM (load-bearing — reshape gameplay around this; see TROUBLESHOOTING) ──
105
+ * THE RASTER-IRQ SPLIT — the C64's classic "fixed status bar over a moving
106
+ * world" trick (and the gateway drug to all raster effects). The VIC-II has
107
+ * ONE $D011 fine Y-scroll for the whole frame; to scroll the road while the
108
+ * score bar stays put, you change $D011's YSCROLL MID-FRAME, at an exact
109
+ * raster line, from an interrupt. Two IRQs ping-pong per frame:
110
+ *
111
+ * line 68 (inside the blank spacer row): $D011 = road fine-Y scroll
112
+ * → everything drawn below this line scrolls
113
+ * line 251 (just past the text window): $D011 = bar Y-scroll (3)
114
+ * → next frame's bar rows render fixed; this IRQ is also the
115
+ * game's frame heartbeat (increments frame_count)
116
+ *
117
+ * The handshake, register by register:
118
+ * $D012 raster compare line (low 8 bits)
119
+ * $D011 b7 raster compare bit 8 — MUST be 0 here (both lines < 256).
120
+ * We rewrite $D011 every split with bit7 left clear; forgetting
121
+ * it is the classic "my IRQ fires on the wrong line / twice"
122
+ * bug once lines ≥ 256 get involved.
123
+ * $D01A b0 raster IRQ enable
124
+ * $D019 IRQ latch. ACK by WRITING THE BITS BACK (write-1-to-clear).
125
+ * THE LOAD-BEARING LINE: skip the ack and the IRQ re-fires the
126
+ * instant it returns, forever — the main loop starves and the
127
+ * machine looks hung.
128
+ * $0314/15 the KERNAL's IRQ indirection. The hardware vector ($FFFE)
129
+ * points into KERNAL ROM, which saves A/X/Y and jumps through
130
+ * $0314 — so with the KERNAL banked in (cc65 default) we just
131
+ * repoint $0314. Exit via jmp $EA81 (KERNAL: restore regs +
132
+ * rti), SKIPPING $EA31's jiffy-clock/keyboard scan.
133
+ * $DC0D CIA1 interrupt control. The KERNAL leaves a 60Hz CIA timer
134
+ * IRQ running (the jiffy clock); disable it ($7F = clear all
135
+ * sources) and ack it (read $DC0D) or it shares the IRQ line
136
+ * with the raster and fires our handler at random lines.
137
+ *
138
+ * Y-SCROLL SMEAR + JITTER: changing YSCROLL mid-frame makes the VIC repeat or
139
+ * drop a few pixel rows at the split line, and the IRQ itself starts 0-7
140
+ * cycles late plus the KERNAL thunk (~35 cycles) — so the $D011 write lands
141
+ * one-to-two raster lines after SPLIT_LINE. We hide BOTH by splitting inside a
142
+ * UNIFORM blank spacer row, where a smeared/shifted blank row changes nothing.
143
+ * Splits next to visible detail need cycle-exact stabilization (double-IRQ
144
+ * trick) — don't go there until you need to.
145
+ *
146
+ * The handler is ASSEMBLY-IN-C on purpose: cc65's generated C uses shared
147
+ * zero-page scratch registers, so a C-level IRQ body would corrupt whatever
148
+ * the main loop was computing. These asm lines touch only A + the flags
149
+ * (which the KERNAL thunk already saved). requires: KERNAL banked in,
150
+ * frame_count/field_d011 file-scope NON-static (asm %v needs the symbol). */
151
+ volatile uint8_t frame_count; /* bumped by the bottom IRQ — frame heartbeat */
152
+ volatile uint8_t field_d011; /* road $D011 value, precomputed by main */
153
+
154
+ void raster_irq(void) {
155
+ asm("lda $d019"); /* read VIC IRQ latch... */
156
+ asm("sta $d019"); /* ...write it back = ACK (write-1-to-clear).
157
+ * THE line you must not lose (see above). */
158
+ asm("lda $d012"); /* which raster line woke us? (self-correcting
159
+ * dispatch — no phase variable to desync) */
160
+ asm("cmp #150");
161
+ asm("bcs %g", at_bottom); /* ≥150 → we're at BOTTOM_LINE */
162
+ /* — split point (line ~68, inside the blank spacer row) — */
163
+ asm("lda %v", field_d011);
164
+ asm("sta $d011"); /* road fine-Y from here down */
165
+ asm("lda #251"); /* = BOTTOM_LINE (cc65's asm %b only takes */
166
+ asm("sta $d012"); /* signed bytes, so these are literals — the */
167
+ asm("jmp $ea81"); /* #if below keeps them honest) */
168
+ at_bottom:
169
+ asm("lda #$1B"); /* = D011_BAR */
170
+ asm("sta $d011"); /* bar Y-scroll for the top of the NEXT frame */
171
+ asm("inc %v", frame_count);/* frame heartbeat for the main loop */
172
+ asm("lda #%b", SPLIT_LINE);
173
+ asm("sta $d012"); /* next stop: the split line */
174
+ asm("jmp $ea81"); /* KERNAL: pla/tay/pla/tax/pla/rti */
175
+ }
176
+ #if BOTTOM_LINE != 251 || D011_BAR != 0x1B
177
+ #error raster_irq's asm immediates are out of sync with BOTTOM_LINE / D011_BAR
178
+ #endif
179
+
180
+ static void install_raster_irq(void) {
181
+ asm("sei"); /* no IRQs while we rewire them */
182
+ POKE(CIA1_PRA + 0x0D, 0x7F); /* $DC0D: disable ALL CIA1 IRQ sources
183
+ * (kills the KERNAL jiffy/keyboard IRQ
184
+ * — we read the sticks ourselves) */
185
+ (void)PEEK(CIA1_PRA + 0x0D); /* reading $DC0D acks anything pending */
186
+ POKE(0x0314, (uint8_t)((unsigned)raster_irq & 0xFF));
187
+ POKE(0x0315, (uint8_t)((unsigned)raster_irq >> 8));
188
+ POKE(VIC_CTRL1, D011_BAR); /* $D011: screen on, 25 rows, YSCROLL=3,
189
+ * bit7 (raster compare bit8) = 0 — both
190
+ * our lines are < 256 */
191
+ POKE(VIC_RASTER, SPLIT_LINE); /* first stop */
192
+ POKE(VIC_IRQ_ENA, 0x01); /* raster IRQ on */
193
+ POKE(VIC_IRQ, 0xFF); /* clear any stale latch bits */
194
+ asm("cli");
195
+ }
196
+
197
+ /* Wait for the bottom IRQ's heartbeat. Replaces the usual poll-$D012 loop —
198
+ * the IRQ owns the raster now, the main loop just paces itself on it. */
199
+ static void wait_frame(void) {
200
+ uint8_t f = frame_count;
201
+ while (frame_count == f) { }
202
+ }
203
+
204
+ /* ── HARDWARE IDIOM (load-bearing — see TROUBLESHOOTING) ── reading BOTH
205
+ * joystick ports. CIA1 port A ($DC00) = control port 2, port B ($DC01) =
206
+ * control port 1. Active-low: a pressed switch reads 0, so invert and mask
207
+ * to bits 0-4 (up/down/left/right/fire).
208
+ *
209
+ * THE PORT-1 GOTCHA: $DC01 is ALSO the keyboard row register — the matrix
210
+ * hangs off the same CIA lines. Writing $FF to $DC00 first deselects every
211
+ * keyboard column, so held keys can't pull $DC01 rows low and ghost into
212
+ * the port-1 stick. That's also why "port 2 is the C64 game port": P1 lives
213
+ * there by convention, and this game puts the SECOND player on port 1.
214
+ * requires: install_raster_irq already disabled the KERNAL's keyboard scan,
215
+ * so nothing else rewrites $DC00. */
216
+ static uint8_t read_stick_port2(void) { /* player 1 */
217
+ POKE(CIA1_PRA, 0xFF);
218
+ return (uint8_t)(~PEEK(CIA1_PRA) & 0x1F);
219
+ }
220
+ static uint8_t read_stick_port1(void) { /* player 2 */
221
+ POKE(CIA1_PRA, 0xFF);
222
+ return (uint8_t)(~PEEK(CIA1_PRB) & 0x1F);
223
+ }
224
+ #define JOY_UP 0x01
225
+ #define JOY_DOWN 0x02
13
226
  #define JOY_LEFT 0x04
14
227
  #define JOY_RIGHT 0x08
228
+ #define JOY_FIRE 0x10
15
229
 
16
- #define SPRITE_POINTERS ((volatile uint8_t*)0x07F8)
230
+ /* ── HARDWARE IDIOM (load-bearing) — best-distance persistence: DISK SAVE ─────────
231
+ * The C64 has no battery SRAM — the honest save medium is the FLOPPY. A game
232
+ * persists by writing a file to drive 8; VICE commits it into the live 1541
233
+ * disk image (true-drive GCR write-back), so a save survives a power cycle
234
+ * exactly as it did on real hardware. (To capture it headlessly the host does
235
+ * state({op:'exportDisk', path}); re-loading that .d64 restores the save.)
236
+ *
237
+ * REQUIRES THE GAME RUN FROM A DISK: build/package it as a .d64 and load THAT
238
+ * (loadMedia autostarts it). A bare .prg injected straight into RAM has no
239
+ * mounted disk to save to, so the save is a silent no-op — still honest (the
240
+ * value just stays in-session), it simply has nowhere to persist.
241
+ *
242
+ * We keep a 2-byte record in a SEQ file "HI" on drive 8. These are the STABLE
243
+ * SEAM: the game calls best_load at boot and best_save on a new record;
244
+ * reshape the record format freely, just keep the two function signatures. */
245
+ #define SAVE_NAME "@0:HI,S,W" /* @ = replace-if-exists; S=SEQ, W=write */
246
+ #define LOAD_NAME "0:HI,S,R"
17
247
 
18
- #define MAX_OBS 4
19
- #define LANE0_X 88
20
- #define LANE1_X 152
21
- #define LANE2_X 216
248
+ static uint16_t best_load(void) {
249
+ uint16_t v = 0;
250
+ uint8_t buf[2];
251
+ if (cbm_open(2, 8, 2, LOAD_NAME) == 0) {
252
+ if (cbm_read(2, buf, 2) == 2) v = (uint16_t)buf[0] | ((uint16_t)buf[1] << 8);
253
+ cbm_close(2);
254
+ }
255
+ return v; /* 0 if the file isn't there yet (first ever boot) */
256
+ }
257
+
258
+ static void best_save(uint16_t v) {
259
+ uint8_t buf[2];
260
+ buf[0] = (uint8_t)(v & 0xFF);
261
+ buf[1] = (uint8_t)(v >> 8);
262
+ if (cbm_open(2, 8, 2, SAVE_NAME) == 0) {
263
+ cbm_write(2, buf, 2);
264
+ cbm_close(2);
265
+ }
266
+ /* No disk mounted (ran as a bare .prg) -> cbm_open fails -> silent no-op. */
267
+ }
22
268
 
23
- static const uint8_t lane_x[3] = { LANE0_X, LANE1_X, LANE2_X };
269
+ /* ── GAME LOGIC (clay) SID music: 2 voices + THE filter sweep ─────────────
270
+ * Voice 0 = melody (pulse), voice 1 = bass (sawtooth THROUGH THE FILTER),
271
+ * voice 2 is reserved for sound effects (c64_sfx). Each voice walks a
272
+ * (freq, frames) note table once per frame; end wraps → continuous loop.
273
+ *
274
+ * THE SID FILTER — the C64's sonic signature, and the part most "music
275
+ * drivers ported from other chips" miss. One analog-modeled filter, shared
276
+ * by all voices, four registers:
277
+ * $D415 cutoff low 3 bits $D416 cutoff high 8 bits (11-bit total)
278
+ * $D417 high nibble = resonance 0-15; low 3 bits ROUTE voices into the
279
+ * filter (bit0=voice0, bit1=voice1, bit2=voice2)
280
+ * $D418 bit4=lowpass bit5=bandpass bit6=highpass — AND master volume in
281
+ * bits 0-3. Volume and filter mode share a register: any "set
282
+ * volume" helper that writes plain $0F silently turns the filter
283
+ * OFF (c64_sfx's sfx_init does exactly that, so music_init runs
284
+ * AFTER it and re-asserts the mode bits).
285
+ * FOOTGUN: bit 7 of $D418 is "3OFF" — it MUTES voice 3 entirely.
286
+ * Set it by accident and all your sound effects vanish.
287
+ * The sweep: a triangle LFO walks the cutoff up and down each frame over
288
+ * the resonant lowpass — the bass goes from muffled to snarling and back,
289
+ * the "wah" that screams Commodore. Hear it change: that IS the chip. */
290
+ #define N_A2 0x0F3Cu
291
+ #define N_C3 0x1199u
292
+ #define N_D3 0x13EEu
293
+ #define N_E3 0x1666u
294
+ #define N_F3 0x1798u
295
+ #define N_G3 0x1AE6u
296
+ #define N_A3 0x1E78u
297
+ #define N_B3 0x2253u
298
+ #define N_C4 0x2333u
299
+ #define N_D4 0x27DDu
300
+ #define N_E4 0x2CCCu
301
+ #define N_F4 0x2F30u
302
+ #define N_G4 0x35CCu
303
+ #define N_A4 0x3CF1u
304
+ #define N_B4 0x44A7u
305
+ #define N_C5 0x4666u
306
+ #define N_D5 0x4FBAu
307
+ #define N_E5 0x5998u
308
+ #define N_G5 0x6B99u
309
+ #define N_REST 0u
310
+ #define STEP 8 /* frames per melodic eighth-note (~155 BPM PAL) */
24
311
 
25
- /* Filled car-shaped sprite. */
26
- static const uint8_t car_sprite[64] = {
27
- 0,0,0, 0x00,0x7E,0x00, 0x00,0xFF,0x00, 0x01,0xFF,0x80,
28
- 0x01,0xFF,0x80, 0x01,0xFF,0x80, 0x03,0xFF,0xC0, 0x07,0xFF,0xE0,
29
- 0x07,0xE7,0xE0, 0x07,0xC3,0xE0, 0x07,0xC3,0xE0, 0x07,0xFF,0xE0,
30
- 0x07,0xFF,0xE0, 0x07,0xE7,0xE0, 0x07,0xC3,0xE0, 0x07,0xC3,0xE0,
31
- 0x07,0xC3,0xE0, 0x07,0xC3,0xE0, 0x07,0xFF,0xE0, 0x07,0xFF,0xE0,
32
- 0x03,0xFF,0xC0, 0,
312
+ typedef struct { uint16_t freq; uint8_t len; } Note;
313
+
314
+ /* The table IS the song — edit these to rescore your fork. A driving riff
315
+ * over a pumping bass; the road never stops, neither does the loop. */
316
+ static const Note melody[] = {
317
+ { N_E4, STEP }, { N_G4, STEP }, { N_A4, STEP*2 }, { N_G4, STEP }, { N_E4, STEP }, { N_A4, STEP*2 },
318
+ { N_E4, STEP }, { N_G4, STEP }, { N_C5, STEP*2 }, { N_B4, STEP }, { N_A4, STEP }, { N_G4, STEP*2 },
319
+ { N_D4, STEP }, { N_F4, STEP }, { N_A4, STEP*2 }, { N_F4, STEP }, { N_D4, STEP }, { N_A4, STEP*2 },
320
+ { N_E4, STEP }, { N_G4, STEP }, { N_B4, STEP }, { N_D5, STEP }, { N_C5, STEP*2 }, { N_REST, STEP },
321
+ { N_A4, STEP }, { N_C5, STEP }, { N_E5, STEP*2 }, { N_C5, STEP }, { N_A4, STEP*2 }, { N_G4, STEP },
322
+ { N_E4, STEP }, { N_G4, STEP }, { N_A4, STEP }, { N_B4, STEP }, { N_A4, STEP*2 }, { N_G4, STEP*2 },
323
+ };
324
+ static const Note bassline[] = {
325
+ /* Octave-pumping bass — the filter sweep chews on this. */
326
+ { N_A2, STEP*3 }, { N_A3, STEP }, { N_A2, STEP*2 }, { N_E3, STEP*2 },
327
+ { N_C3, STEP*3 }, { N_C4, STEP }, { N_C3, STEP*2 }, { N_G3, STEP*2 },
328
+ { N_D3, STEP*3 }, { N_D4, STEP }, { N_D3, STEP*2 }, { N_A3, STEP*2 },
329
+ { N_E3, STEP*3 }, { N_B3, STEP }, { N_E3, STEP*2 }, { N_G3, STEP*2 },
33
330
  };
331
+ #define MELODY_LEN (sizeof(melody) / sizeof(melody[0]))
332
+ #define BASS_LEN (sizeof(bassline) / sizeof(bassline[0]))
333
+
334
+ static uint8_t m_pos[2], m_left[2];
335
+ static uint16_t filter_cut; /* 11-bit cutoff, 0-2047 */
336
+ static uint8_t filter_up;
337
+
338
+ static void music_trigger(uint8_t v, uint16_t freq, uint8_t wave) {
339
+ if (freq == N_REST) {
340
+ POKE(SID_CTRL(v), wave); /* gate off: release tail plays */
341
+ return;
342
+ }
343
+ POKE(SID_FREQ_LO(v), (uint8_t)(freq & 0xFF));
344
+ POKE(SID_FREQ_HI(v), (uint8_t)(freq >> 8));
345
+ POKE(SID_CTRL(v), wave); /* gate OFF then ON — the 6581/8580 */
346
+ POKE(SID_CTRL(v), wave | SID_GATE); /* envelope only retriggers on the
347
+ * 0→1 gate edge */
348
+ }
349
+
350
+ static void music_init(void) {
351
+ /* Melody: pulse at 50% duty, snappy envelope. */
352
+ POKE(SID_PW_LO(0), 0x00); POKE(SID_PW_HI(0), 0x08);
353
+ POKE(SID_AD(0), 0x07); /* attack 0, decay 7 */
354
+ POKE(SID_SR(0), 0x84); /* sustain 8, release 4 */
355
+ /* Bass: sawtooth (harmonically rich — gives the filter teeth to chew). */
356
+ POKE(SID_AD(1), 0x06);
357
+ POKE(SID_SR(1), 0xA5);
358
+ /* Filter: route VOICE 1 ONLY into it (bit 1 of $D417), resonance 13/15. */
359
+ POKE(SID_RES_FILT, 0xD2);
360
+ /* Lowpass mode + master volume 15. NOTE bits shared with volume, and bit
361
+ * 7 (3OFF) stays 0 or voice-2 sound effects go silent — see block doc. */
362
+ POKE(SID_VOL_MODE, 0x1F);
363
+ filter_cut = 0x180; filter_up = 1;
364
+ m_pos[0] = m_pos[1] = 0;
365
+ m_left[0] = m_left[1] = 1; /* triggers both voices on the next update */
366
+ }
367
+
368
+ static void music_update(void) {
369
+ /* Note sequencing, one table per voice. */
370
+ if (--m_left[0] == 0) {
371
+ music_trigger(0, melody[m_pos[0]].freq, SID_PULSE);
372
+ m_left[0] = melody[m_pos[0]].len;
373
+ if (++m_pos[0] >= MELODY_LEN) m_pos[0] = 0;
374
+ }
375
+ if (--m_left[1] == 0) {
376
+ music_trigger(1, bassline[m_pos[1]].freq, SID_SAWTOOTH);
377
+ m_left[1] = bassline[m_pos[1]].len;
378
+ if (++m_pos[1] >= BASS_LEN) m_pos[1] = 0;
379
+ }
380
+ /* THE FILTER SWEEP — triangle LFO on the cutoff, ~10s round trip.
381
+ * 11-bit value split across two registers: low 3 bits in $D415,
382
+ * high 8 in $D416. */
383
+ if (filter_up) { filter_cut += 6; if (filter_cut >= 0x700) filter_up = 0; }
384
+ else { filter_cut -= 6; if (filter_cut <= 0x180) filter_up = 1; }
385
+ POKE(SID_FILTER_LO, (uint8_t)(filter_cut & 0x07));
386
+ POKE(SID_FILTER_HI, (uint8_t)(filter_cut >> 3));
387
+ }
388
+
389
+ /* ── GAME LOGIC (clay) — screen text. The C64 has NO VRAM port: screen RAM
390
+ * is plain memory, writable any time, mid-frame, no vblank dance. The only
391
+ * translation is ASCII → SCREEN CODES (not PETSCII!): A-Z land at 1-26;
392
+ * space through '?' (incl. digits) keep their ASCII values. ── */
393
+ static void draw_text(uint8_t row, uint8_t col, const char *s) {
394
+ uint16_t off = (uint16_t)row * 40 + col;
395
+ uint8_t ch;
396
+ while ((ch = (uint8_t)*s++) != 0) {
397
+ if (ch >= 'A' && ch <= 'Z') ch -= 64; /* A-Z → screen codes 1-26 */
398
+ SCREEN[off] = ch; /* 32-63 map straight through */
399
+ COLORS[off] = COLOR_WHITE;
400
+ ++off;
401
+ }
402
+ }
403
+
404
+ static void draw_u16(uint8_t row, uint8_t col, uint16_t v) {
405
+ uint8_t i, d[5];
406
+ uint16_t off = (uint16_t)row * 40 + col;
407
+ for (i = 0; i < 5; i++) { d[i] = v % 10; v /= 10; }
408
+ for (i = 0; i < 5; i++) {
409
+ SCREEN[off + i] = (uint8_t)('0' + d[4 - i]); /* digit screen code = ASCII */
410
+ COLORS[off + i] = COLOR_WHITE;
411
+ }
412
+ }
413
+
414
+ /* ── GAME LOGIC (clay) — xorshift-style PRNG (cheap, period 255) ── */
415
+ static uint8_t rng_state = 0x4D;
416
+ static uint8_t rand8(void) {
417
+ uint8_t lsb = (uint8_t)(rng_state & 1);
418
+ rng_state >>= 1;
419
+ if (lsb) rng_state ^= 0xB8;
420
+ return rng_state;
421
+ }
422
+
423
+ /* ── GAME LOGIC (clay) — THE ROAD ────────────────────────────────────────────
424
+ * The playfield is a top-down road that scrolls DOWN past cars parked near
425
+ * the bottom. Chars 3..24 are the road; the layout per char column:
426
+ * 0..ROAD_L-1 grass (left berm)
427
+ * ROAD_L solid shoulder line
428
+ * ROAD_L+1 .. ROAD_R-1 asphalt, with dashed lane lines and the double
429
+ * center divider at CENTER (the 2P territory border)
430
+ * ROAD_R solid shoulder line
431
+ * ROAD_R+1 .. 39 grass (right berm)
432
+ * Four 4-cell lanes sit between the shoulders; lane centers in lane_col[]. */
433
+ #define ROAD_L 11 /* left shoulder column */
434
+ #define ROAD_R 28 /* right shoulder column */
435
+ #define CENTER 20 /* double-line center divider (2P border) */
436
+ #define LANE_DASH1 14 /* dashed lane line between lanes 0 and 1 */
437
+ #define LANE_DASH2 25 /* dashed lane line between lanes 2 and 3 */
438
+ static const uint8_t lane_col[4] = { 12, 17, 22, 27 }; /* lane center cols */
439
+
440
+ /* Char codes for road cells. */
441
+ #define CH_GRASS 0x66 /* checker glyph = textured grass */
442
+ #define CH_SHOULDER 0xA0 /* reverse-space solid = the white edge line */
443
+ #define CH_ASPHALT 0x20 /* blank = open asphalt */
444
+ #define CH_DASH 0x5D /* vertical bar glyph = lane dashes */
445
+ #define CH_DIVIDE 0xA0 /* reverse-space solid = double center line */
446
+ #define CH_TRAFFIC 0x51 /* filled circle glyph = rival traffic car */
447
+ #define CH_BLANK 0x20
448
+
449
+ /* The STATIC color texture (paint_colors) never scrolls — the row shift moves
450
+ * only the CHARS, and they pick up each cell's resident color for free. We
451
+ * lay the road colors PER COLUMN (grass green, shoulders gray, asphalt dark),
452
+ * uniform down every row, so the coarse shift costs half the byte-moves. ── */
453
+ static uint8_t col_color[40];
454
+ static void build_col_color(void) {
455
+ uint8_t c;
456
+ for (c = 0; c < 40; c++) {
457
+ if (c < ROAD_L || c > ROAD_R) col_color[c] = COLOR_GREEN; /* grass */
458
+ else if (c == ROAD_L || c == ROAD_R) col_color[c] = COLOR_LIGHT_GRAY; /* shoulder */
459
+ else if (c == CENTER) col_color[c] = COLOR_YELLOW; /* divider */
460
+ else if (c == LANE_DASH1 || c == LANE_DASH2) col_color[c] = COLOR_LIGHT_GRAY;
461
+ else col_color[c] = COLOR_DARK_GRAY; /* asphalt */
462
+ }
463
+ }
464
+
465
+ /* Paint the STATIC color texture for the whole road window — ONCE, at boot. */
466
+ static void paint_colors(void) {
467
+ uint8_t r, c;
468
+ for (r = FIELD_TOP; r < 25; r++) {
469
+ volatile uint8_t *crow = COLORS + (uint16_t)r * 40;
470
+ for (c = 0; c < 40; c++) crow[c] = col_color[c];
471
+ }
472
+ }
473
+
474
+ static uint8_t road_phase; /* dashed-line animation phase, world row */
475
+
476
+ /* Stamp ONE road row's CHARS into screen RAM at screen row `sr`. `phase`
477
+ * walks the dashed-line pattern so the lane dashes animate as rows scroll.
478
+ * The COARSE scroll calls this once per 8 px (for the freshly exposed TOP
479
+ * edge), NOT per cell of the whole screen — a full 22-row repaint of cc65 C
480
+ * is ~50 frames (a frozen second). Keep it lean. */
481
+ static void draw_road_row(uint8_t sr, uint8_t phase) {
482
+ uint8_t c;
483
+ uint8_t *s = (uint8_t*)(0x0400) + (uint16_t)sr * 40; /* plain RAM (see scroll_field) */
484
+ uint8_t dash = (uint8_t)((phase & 3) < 2); /* 4-on/4-off dash */
485
+ for (c = 0; c < 40; c++) {
486
+ uint8_t ch;
487
+ if (c < ROAD_L || c > ROAD_R) ch = CH_GRASS;
488
+ else if (c == ROAD_L || c == ROAD_R) ch = CH_SHOULDER;
489
+ else if (c == CENTER) ch = CH_DIVIDE;
490
+ else if ((c == LANE_DASH1 || c == LANE_DASH2) && dash) ch = CH_DASH;
491
+ else ch = CH_ASPHALT;
492
+ *s++ = ch;
493
+ }
494
+ }
495
+
496
+ /* Repaint the WHOLE visible road window's CHARS. Runs ONCE per race start
497
+ * (not per frame). */
498
+ static void paint_road(void) {
499
+ uint8_t sr;
500
+ for (sr = FIELD_TOP; sr < 25; sr++) draw_road_row(sr, sr);
501
+ }
502
+
503
+ /* ── HARDWARE IDIOM (load-bearing) — the vertical COARSE scroll. The road
504
+ * moves DOWN (toward the player), so we shift the 22 visible road rows one
505
+ * char DOWN in SCREEN RAM and stamp a fresh row at the TOP. Color RAM is the
506
+ * static texture (paint_colors), so this touches ONLY screen RAM — half the
507
+ * byte-moves. Runs only on the frame the fine offset wraps (every 8 px).
508
+ * SCHEDULING IS THE TRICK: called right after wait_frame() (i.e. just after
509
+ * the line-251 IRQ). The beam won't draw road row 3 until line 75 of the NEXT
510
+ * frame (~8500 cycles away) and then takes 504 cycles/row; this loop spends
511
+ * ~600 cycles/row, so with that head start it stays ahead of the beam — no
512
+ * tearing, no double buffer. We copy bottom-up so a row isn't overwritten
513
+ * before it's read. (The grown-up alternative is page-flipping via $D018.) */
514
+ static void scroll_field(void) {
515
+ uint8_t r;
516
+ /* NON-volatile pointers on purpose: screen RAM is plain memory (not MMIO),
517
+ * so cc65 keeps the running pointer in zero page and emits a tight indexed
518
+ * copy. Marking it volatile (as the per-cell sprite writes do, for mid-frame
519
+ * correctness) would force a reload per access and roughly DOUBLE this
520
+ * loop's cost — and this loop is the scroll's whole frame budget. */
521
+ for (r = 24; r > FIELD_TOP; r--) {
522
+ uint8_t *dst = (uint8_t*)(0x0400) + (uint16_t)r * 40;
523
+ uint8_t *src = dst - 40;
524
+ uint8_t c;
525
+ for (c = 0; c < 40; c++) dst[c] = src[c];
526
+ }
527
+ ++road_phase;
528
+ draw_road_row(FIELD_TOP, road_phase); /* fresh road enters at the top */
529
+ }
530
+
531
+ /* ── GAME LOGIC (clay) — game state ── */
532
+ #define ST_TITLE 0
533
+ #define ST_PLAY 1
534
+ #define ST_OVER 2
535
+ static uint8_t state;
536
+ static uint8_t two_player;
537
+ static uint8_t winner; /* versus result: 1 = P1 wins, 2 = P2 */
538
+
539
+ /* ── Players + traffic. The cars are VIC-II HARDWARE SPRITES (P1, P2); the
540
+ * road, lane lines and rival traffic are CHARACTERS in the scrolling field. ──
541
+ * 1P: all 4 lanes, UP/FIRE accelerates, DOWN brakes (speed 1..MAX_SPEED).
542
+ * 2P versus: ONE screen = ONE road scroll, so both share a fixed speed and
543
+ * only steer — P1 (port 2) owns the left 2 lanes, P2 (port 1) the right 2,
544
+ * split at the center divider. Each starts with CRASHES_MAX crashes; first
545
+ * to use them all LOSES. */
546
+ #define MAX_TRAFFIC 4
547
+ #define CAR_ROW 20 /* both cars' fixed char row (near the bottom) */
548
+ #define CRASHES_MAX 3
549
+ #define SPAWN_PERIOD 38 /* frames between traffic spawns */
550
+ #define SPEED_2P 2 /* fixed road speed in versus */
551
+ #define MAX_SPEED 5 /* px/frame — keep < 8 so the row streamer's *
552
+ * one-row-per-8px restamp can't skip a row */
553
+
554
+ static uint8_t car_lane[2]; /* which of the 4 lanes (0..3) */
555
+ static uint8_t car_active[2];
556
+ static uint8_t crashes_left[2];
557
+ static uint8_t invuln[2]; /* post-crash blink/no-collide frames */
558
+ static uint8_t lane_min[2], lane_max[2]; /* 2P split territories */
34
559
 
35
- typedef struct { uint8_t x, y, alive; } Car;
560
+ static uint8_t traffic_alive[MAX_TRAFFIC];
561
+ static uint8_t traffic_lane[MAX_TRAFFIC];
562
+ static int16_t traffic_y[MAX_TRAFFIC]; /* world Y in px (top of road = 0) */
563
+ static uint8_t traffic_col[MAX_TRAFFIC]; /* last screen column it was drawn */
564
+ static int16_t traffic_prev_row[MAX_TRAFFIC];
36
565
 
37
- static Car player;
38
- static Car obstacles[MAX_OBS];
39
- static uint8_t spawn_timer;
40
- static uint8_t player_lane = 1;
41
- static uint8_t prev_pad;
42
- static uint8_t game_over_timer;
43
- static uint32_t rng = 1;
566
+ static uint8_t speed; /* road px/frame, 1..MAX_SPEED */
567
+ static uint16_t dist; /* 1P distance, 1 unit = 16 px */
568
+ static uint8_t dist_frac;
569
+ static uint16_t best; /* persisted best 1P distance */
570
+ static uint8_t spawn_timer;
571
+ static uint16_t scroll_px; /* total road px scrolled this run */
572
+ static uint8_t fine_prev;
573
+ static uint8_t start_pause; /* freeze frames at the green light */
574
+ static uint8_t prev0, prev1; /* edge-detect held buttons */
44
575
 
45
- static void wait_vblank(void) {
46
- while (PEEK(VIC_RASTER) < 250) { }
47
- while (PEEK(VIC_RASTER) >= 250) { }
576
+ /* Sprite Y for a char row's top: a sprite at $D001 = 51 + 8*r appears at the
577
+ * top of char row r (window row 0 sits at $D001≈50). Cars sit ON CAR_ROW. */
578
+ #define SPR_Y_FOR_ROW(r) (uint8_t)(51 + 8 * (r))
579
+ /* Lane center → sprite X (24-px visible origin; lane cols are screen chars,
580
+ * minus half the 24-px sprite to center it on the lane). */
581
+ #define LANE_X(lane) (int16_t)((int16_t)lane_col[lane] * 8 + 24 - 12)
582
+
583
+ /* ── HARDWARE IDIOM (load-bearing) — staging a sprite with the 9th X bit.
584
+ * VIC sprite X is 9 bits: low 8 in $D000+2n, bit 8 for ALL sprites packed
585
+ * into $D010. Forget $D010 and anything past X=255 wraps back to the left
586
+ * edge — the classic "my sprite teleports at two-thirds screen" bug. We
587
+ * accumulate the MSB bits while staging and commit the byte once. ── */
588
+ static uint8_t spr_msb, spr_ena;
589
+ static void stage_begin(void) { spr_msb = 0; spr_ena = 0; }
590
+ static void stage_sprite(uint8_t slot, int16_t x, uint8_t y) {
591
+ POKE(VIC_SPRITE_X(slot), (uint8_t)(x & 0xFF));
592
+ POKE(VIC_SPRITE_Y(slot), y);
593
+ if (x > 255) spr_msb |= (uint8_t)(1 << slot);
594
+ spr_ena |= (uint8_t)(1 << slot);
595
+ }
596
+ static void stage_commit(void) {
597
+ POKE(VIC_SPRITES_X8, spr_msb);
598
+ POKE(VIC_SPR_ENA, spr_ena); /* unstaged slots vanish — no stale sprites */
599
+ }
600
+
601
+ /* ── GAME LOGIC (clay) — score bar (rows 0-1) ── */
602
+ static void draw_bar_labels(void) {
603
+ uint8_t c;
604
+ for (c = 0; c < 40; c++) { /* row 1: solid divider line */
605
+ SCREEN[40 + c] = 0xA0;
606
+ COLORS[40 + c] = COLOR_DARK_GRAY;
607
+ SCREEN[80 + c] = CH_BLANK; /* row 2: the blank spacer the
608
+ * raster split hides in */
609
+ SCREEN[c] = CH_BLANK;
610
+ }
611
+ draw_text(0, 0, "DST");
612
+ draw_text(0, 11, "BEST");
613
+ draw_text(0, 23, "CR");
614
+ draw_text(0, 32, two_player ? "2P" : "1P");
615
+ }
616
+ static void draw_bar_stats(void) {
617
+ draw_u16(0, 4, dist);
618
+ draw_u16(0, 16, best);
619
+ if (two_player) {
620
+ /* versus: show each player's remaining crashes (P1-P2). */
621
+ SCREEN[26] = (uint8_t)('0' + crashes_left[0]);
622
+ SCREEN[27] = (uint8_t)('-');
623
+ SCREEN[28] = (uint8_t)('0' + crashes_left[1]);
624
+ COLORS[26] = COLOR_CYAN; COLORS[27] = COLOR_WHITE; COLORS[28] = COLOR_GREEN;
625
+ } else {
626
+ SCREEN[26] = (uint8_t)('0' + crashes_left[0]);
627
+ COLORS[26] = COLOR_WHITE;
628
+ }
48
629
  }
49
630
 
50
- static void copy_sprite(uint8_t slot, const uint8_t *data) {
631
+ /* ── GAME LOGIC (clay) title / start / game over ──────────────────────────
632
+ * Transition rule (see paint_road's note): never repaint the whole field on
633
+ * a fire press. The title draws its text ON TOP of the parked road; start
634
+ * repaints the road once (cheap enough at a state change, not per frame). */
635
+ static void draw_text_band(uint8_t row, uint8_t col, const char *s) {
636
+ uint8_t c;
637
+ volatile uint8_t *p = SCREEN + (uint16_t)row * 40;
638
+ for (c = 0; c < 40; c++) p[c] = CH_BLANK;
639
+ draw_text(row, col, s);
640
+ }
641
+
642
+ static void paint_title(void) {
643
+ draw_bar_labels();
644
+ draw_bar_stats();
645
+ draw_text_band(7, (40 - (sizeof(GAME_TITLE) - 1)) / 2, GAME_TITLE);
646
+ draw_text_band(11, 11, "PORT 2 FIRE - 1P");
647
+ draw_text_band(13, 9, "PORT 1 FIRE - 2P VERSUS");
648
+ draw_text_band(17, 15, "BEST");
649
+ draw_u16(17, 20, best);
650
+ field_d011 = D011_BAR; /* title field holds still (text lives in it) */
651
+ POKE(VIC_SPR_ENA, 0);
652
+ state = ST_TITLE;
653
+ }
654
+
655
+ static void reset_traffic(void) {
51
656
  uint8_t i;
52
- volatile uint8_t *dst = (volatile uint8_t*)(0x2000 + slot * 64); /* $2000, not $0800 (collides w/ $0801 .prg) */
53
- for (i = 0; i < 64; i++) dst[i] = data[i];
657
+ for (i = 0; i < MAX_TRAFFIC; i++) {
658
+ traffic_alive[i] = 0;
659
+ traffic_prev_row[i] = -1;
660
+ }
661
+ }
662
+
663
+ static void start_game(uint8_t players) {
664
+ two_player = players;
665
+ winner = 0;
666
+ car_lane[0] = 1; car_lane[1] = 2;
667
+ car_active[0] = 1; car_active[1] = players;
668
+ crashes_left[0] = CRASHES_MAX;
669
+ crashes_left[1] = players ? CRASHES_MAX : 0;
670
+ invuln[0] = invuln[1] = 0;
671
+ if (players) { /* split the road at the divider */
672
+ lane_min[0] = 0; lane_max[0] = 1; /* P1 owns lanes 0-1 (left) */
673
+ lane_min[1] = 2; lane_max[1] = 3; /* P2 owns lanes 2-3 (right) */
674
+ } else {
675
+ lane_min[0] = 0; lane_max[0] = 3; /* 1P: all four lanes */
676
+ }
677
+ speed = players ? SPEED_2P : 2;
678
+ dist = 0; dist_frac = 0;
679
+ spawn_timer = 0;
680
+ scroll_px = 0; fine_prev = 0;
681
+ road_phase = 0;
682
+ start_pause = 40; /* "green light" breather */
683
+ prev0 = prev1 = 0x1F; /* swallow the start FIRE held */
684
+ reset_traffic();
685
+ field_d011 = D011_BAR;
686
+ paint_road(); /* repaint the road once for this run */
687
+ draw_bar_labels();
688
+ draw_bar_stats();
689
+ sfx_tone(2, 0x40, 0x20, 6); /* start chirp */
690
+ state = ST_PLAY;
691
+ }
692
+
693
+ static void game_over(void) {
694
+ POKE(VIC_SPR_ENA, 0); /* sprites off before the message paints */
695
+ field_d011 = D011_BAR;
696
+ if (!two_player && dist > best) {
697
+ best = dist;
698
+ best_save(best); /* the persistence seam — see its doc */
699
+ }
700
+ if (two_player) {
701
+ draw_text_band(11, 16, winner == 1 ? "P1 WINS" : "P2 WINS");
702
+ } else {
703
+ draw_text_band(11, 15, "GAME OVER");
704
+ }
705
+ draw_text_band(13, 13, "FIRE - TITLE");
706
+ draw_bar_stats();
707
+ sfx_noise(24);
708
+ state = ST_OVER;
709
+ }
710
+
711
+ /* ── GAME LOGIC (clay) — a crash: lose one of this player's lives ──────────
712
+ * 1P: out of crashes → game over. 2P versus: the FIRST player to exhaust
713
+ * their crashes loses; the other wins on the spot. */
714
+ static void crash_player(uint8_t p) {
715
+ sfx_noise(16);
716
+ if (crashes_left[p]) --crashes_left[p];
717
+ invuln[p] = 90; /* mercy frames + blink */
718
+ draw_bar_stats();
719
+ if (two_player) {
720
+ if (crashes_left[p] == 0) { winner = (uint8_t)(p == 0 ? 2 : 1); game_over(); }
721
+ } else if (crashes_left[0] == 0) {
722
+ game_over();
723
+ }
54
724
  }
55
725
 
56
- static void spawn(void) {
726
+ static void spawn_traffic(void) {
57
727
  uint8_t i;
58
- rng = rng * 1103515245u + 12345u;
59
- for (i = 0; i < MAX_OBS; i++) {
60
- if (!obstacles[i].alive) {
61
- obstacles[i].x = lane_x[(rng >> 16) % 3];
62
- obstacles[i].y = 50;
63
- obstacles[i].alive = 1;
728
+ for (i = 0; i < MAX_TRAFFIC; i++) {
729
+ if (!traffic_alive[i]) {
730
+ traffic_alive[i] = 1;
731
+ traffic_lane[i] = (uint8_t)(rand8() & 3);
732
+ traffic_y[i] = -8; /* just above the top of the road */
733
+ traffic_prev_row[i] = -1;
64
734
  return;
65
735
  }
66
736
  }
67
737
  }
68
738
 
69
- static uint8_t aabb(Car *a, Car *b) {
70
- return a->x < b->x + 24 && a->x + 24 > b->x
71
- && a->y < b->y + 24 && a->y + 24 > b->y;
739
+ /* Erase a traffic car's old char (restore the road cell under it) before it
740
+ * moves otherwise it leaves a trail. Redraw the affected cell from the road
741
+ * template (lane center cells are dash-line or asphalt; never a shoulder). */
742
+ static void clear_traffic_cell(uint8_t row, uint8_t col) {
743
+ uint8_t ch;
744
+ if (row < FIELD_TOP || row > 24) return;
745
+ if (col == CENTER) ch = CH_DIVIDE;
746
+ else if (col == LANE_DASH1 || col == LANE_DASH2)
747
+ ch = ((uint8_t)(((row + road_phase) & 3) < 2)) ? CH_DASH : CH_ASPHALT;
748
+ else ch = CH_ASPHALT;
749
+ SCREEN[(uint16_t)row * 40 + col] = ch;
750
+ COLORS[(uint16_t)row * 40 + col] = col_color[col];
751
+ }
752
+
753
+ static void copy_sprite_image(uint8_t img, const uint8_t *src) {
754
+ uint8_t i;
755
+ volatile uint8_t *dst = (volatile uint8_t*)SPR_DATA(img);
756
+ for (i = 0; i < 64; i++) dst[i] = src[i];
72
757
  }
73
758
 
74
759
  void main(void) {
75
- uint8_t i, pad;
760
+ uint8_t pad0, pad1, p, i;
761
+
762
+ /* ── HARDWARE IDIOM (load-bearing) — boot order. VIC + SID config before
763
+ * the IRQ goes live; sfx_init BEFORE music_init (sfx_init writes a plain
764
+ * volume to $D418, music_init re-asserts the filter-mode bits on top). ── */
76
765
  POKE(VIC_SPR_ENA, 0);
77
- copy_sprite(0, car_sprite);
78
- SPRITE_POINTERS[0] = 0x80; /* $2000/64 */
79
- for (i = 0; i < MAX_OBS; i++) SPRITE_POINTERS[1 + i] = 0x80;
80
- POKE(VIC_SPR_COL(0), 0x07); /* yellow player */
81
- for (i = 0; i < MAX_OBS; i++) POKE(VIC_SPR_COL(1 + i), 0x02); /* red obstacles */
82
- POKE(VIC_BORDER, 0x00);
83
- POKE(VIC_BG0, 0x09); /* brown road */
84
-
85
- /* Clear screen RAM: a .prg starts over the BASIC screen, so the
86
- * KERNAL's startup text (the leftover the playtest saw at the top)
87
- * stays visible until someone wipes it. */
88
- {
89
- uint16_t k;
90
- volatile uint8_t *scr = (volatile uint8_t*)0x0400;
91
- for (k = 0; k < 1000; k++) scr[k] = 0x20;
92
- }
93
-
94
- player.x = LANE1_X; player.y = 220; player.alive = 1;
95
- for (i = 0; i < MAX_OBS; i++) obstacles[i].alive = 0;
96
- spawn_timer = 0;
97
- game_over_timer = 0;
766
+ POKE(VIC_BORDER, COLOR_BLACK);
767
+ POKE(VIC_BG0, COLOR_BLACK);
768
+ copy_sprite_image(IMG_CAR, car_sprite);
769
+ SPRITE_POINTERS[SLOT_P1] = SPR_PTR(IMG_CAR);
770
+ SPRITE_POINTERS[SLOT_P2] = SPR_PTR(IMG_CAR);
771
+ POKE(VIC_SPR_COL(SLOT_P1), COLOR_CYAN);
772
+ POKE(VIC_SPR_COL(SLOT_P2), COLOR_GREEN);
773
+ POKE(CIA1_DDRA, 0xFF); /* port A drives keyboard columns */
774
+ POKE(CIA1_DDRB, 0x00); /* port B reads rows / stick 1 */
775
+
776
+ build_col_color();
98
777
  sfx_init();
99
- POKE(VIC_SPR_ENA, 0x1F);
778
+ music_init();
779
+ best = best_load(); /* 0 until the core save round lands */
780
+
781
+ field_d011 = D011_BAR;
782
+ paint_colors(); /* STATIC color texture — once, ever */
783
+ paint_road(); /* the ONE full-field char paint (boot) */
784
+ install_raster_irq(); /* the split + heartbeat go live */
785
+ paint_title();
100
786
 
101
787
  for (;;) {
102
- pad = (uint8_t)(~PEEK(CIA1_PRA) & 0x1F);
103
- wait_vblank();
788
+ wait_frame(); /* the line-251 IRQ paces everything */
789
+
790
+ music_update();
104
791
  sfx_update();
792
+ pad0 = read_stick_port2(); /* P1 — control port 2 (convention) */
793
+ pad1 = read_stick_port1(); /* P2 — control port 1 */
794
+
795
+ if (state == ST_TITLE) {
796
+ /* Mode select doubles as a controls demo: the stick that presses FIRE
797
+ * picks the mode — port 2 starts 1P, port 1 starts 2P versus. */
798
+ if ((pad0 & JOY_FIRE) && !(prev0 & JOY_FIRE)) start_game(0);
799
+ else if ((pad1 & JOY_FIRE) && !(prev1 & JOY_FIRE)) start_game(1);
800
+ prev0 = pad0; prev1 = pad1;
801
+ continue;
802
+ }
105
803
 
106
- if (game_over_timer > 0) {
107
- game_over_timer--;
108
- if (game_over_timer == 0) {
109
- for (i = 0; i < MAX_OBS; i++) obstacles[i].alive = 0;
110
- player_lane = 1;
111
- player.x = LANE1_X;
804
+ if (state == ST_OVER) {
805
+ if (((pad0 & JOY_FIRE) && !(prev0 & JOY_FIRE)) ||
806
+ ((pad1 & JOY_FIRE) && !(prev1 & JOY_FIRE))) paint_title();
807
+ prev0 = pad0; prev1 = pad1;
808
+ continue;
809
+ }
810
+
811
+ /* ── ST_PLAY ─────────────────────────────────────────────────────────
812
+ * Set field_d011 EARLY — it must be settled long before the beam reaches
813
+ * SPLIT_LINE — and run the coarse shift right after the heartbeat. */
814
+ if (start_pause) {
815
+ --start_pause;
816
+ stage_begin();
817
+ stage_sprite(SLOT_P1, LANE_X(car_lane[0]), SPR_Y_FOR_ROW(CAR_ROW));
818
+ if (two_player) stage_sprite(SLOT_P2, LANE_X(car_lane[1]), SPR_Y_FOR_ROW(CAR_ROW));
819
+ stage_commit();
820
+ continue;
821
+ }
822
+
823
+ /* 1P speed control: UP / FIRE accelerate, DOWN brakes (edge-triggered). */
824
+ if (!two_player) {
825
+ if ((pad0 & (JOY_UP | JOY_FIRE)) && !(prev0 & (JOY_UP | JOY_FIRE)) && speed < MAX_SPEED) {
826
+ ++speed; sfx_tone(2, 0x80, 0x18, 3);
112
827
  }
113
- } else {
114
- if ((pad & JOY_LEFT) && !(prev_pad & JOY_LEFT) && player_lane > 0) {
115
- player_lane--; sfx_tone(1, 0x40, 0x18, 2);
828
+ if ((pad0 & JOY_DOWN) && !(prev0 & JOY_DOWN) && speed > 1) {
829
+ --speed; sfx_tone(2, 0x30, 0x10, 3);
830
+ }
831
+ }
832
+
833
+ /* Steering: LEFT/RIGHT change lane (edge-triggered, clamped to territory).
834
+ * P1 reads port 2, P2 reads port 1. */
835
+ for (p = 0; p < (two_player ? 2 : 1); p++) {
836
+ uint8_t pp = p ? pad1 : pad0;
837
+ uint8_t prevp = p ? prev1 : prev0;
838
+ if (!car_active[p]) continue;
839
+ if ((pp & JOY_LEFT) && !(prevp & JOY_LEFT) && car_lane[p] > lane_min[p]) {
840
+ --car_lane[p]; sfx_tone(2, 0x50, 0x14, 2);
116
841
  }
117
- if ((pad & JOY_RIGHT) && !(prev_pad & JOY_RIGHT) && player_lane < 2) {
118
- player_lane++; sfx_tone(1, 0x40, 0x18, 2);
842
+ if ((pp & JOY_RIGHT) && !(prevp & JOY_RIGHT) && car_lane[p] < lane_max[p]) {
843
+ ++car_lane[p]; sfx_tone(2, 0x50, 0x14, 2);
119
844
  }
120
- player.x = lane_x[player_lane];
121
- prev_pad = pad;
845
+ if (invuln[p]) --invuln[p];
846
+ }
847
+ prev0 = pad0; prev1 = pad1;
848
+
849
+ /* ── FINE + COARSE vertical scroll. The road moves DOWN: field_d011 low
850
+ * 3 bits = the fine Y offset (counts UP 0..7 as the road advances). When
851
+ * it wraps past a char boundary, COARSE-shift the rows down and stamp a
852
+ * fresh road row at the top. ── */
853
+ scroll_px += speed;
854
+ {
855
+ uint8_t fine = (uint8_t)(scroll_px & 7);
856
+ field_d011 = (uint8_t)(D011_KEEP | fine);
857
+ if (fine < fine_prev) scroll_field(); /* wrapped past 7→0 → coarse step */
858
+ fine_prev = fine;
859
+ }
122
860
 
123
- for (i = 0; i < MAX_OBS; i++) {
124
- if (!obstacles[i].alive) continue;
125
- obstacles[i].y += 2;
126
- if (obstacles[i].y >= 245) obstacles[i].alive = 0;
861
+ /* Distance: 16 scrolled px = 1 unit (≈ one car length). */
862
+ dist_frac += speed;
863
+ while (dist_frac >= 16) { dist_frac -= 16; ++dist; draw_bar_stats(); }
864
+
865
+ /* Traffic: rival cars drift DOWN the road a touch faster than the scroll
866
+ * so the player overtakes them. Erase the old cell, advance, redraw as a
867
+ * char in its lane-center column. */
868
+ ++spawn_timer;
869
+ if (spawn_timer >= SPAWN_PERIOD) { spawn_timer = 0; spawn_traffic(); }
870
+ for (i = 0; i < MAX_TRAFFIC; i++) {
871
+ int16_t prow;
872
+ uint8_t col, srow;
873
+ if (!traffic_alive[i]) continue;
874
+ if (traffic_prev_row[i] >= 0) /* erase previous cell */
875
+ clear_traffic_cell((uint8_t)traffic_prev_row[i], traffic_col[i]);
876
+ traffic_y[i] += speed + 1; /* a touch faster than scroll */
877
+ if (traffic_y[i] >= (int16_t)((25 - FIELD_TOP) * 8)) {
878
+ traffic_alive[i] = 0; /* slipped past the bottom */
879
+ traffic_prev_row[i] = -1;
880
+ continue;
881
+ }
882
+ prow = (int16_t)(FIELD_TOP + (traffic_y[i] >> 3));
883
+ col = lane_col[traffic_lane[i]];
884
+ srow = (uint8_t)prow;
885
+ if (srow >= FIELD_TOP && srow <= 24) {
886
+ SCREEN[(uint16_t)srow * 40 + col] = CH_TRAFFIC;
887
+ COLORS[(uint16_t)srow * 40 + col] = COLOR_LIGHT_RED;
888
+ traffic_prev_row[i] = prow;
889
+ traffic_col[i] = col;
890
+ } else {
891
+ traffic_prev_row[i] = -1;
127
892
  }
128
- spawn_timer++;
129
- if (spawn_timer >= 40) { spawn_timer = 0; spawn(); }
893
+ }
130
894
 
131
- for (i = 0; i < MAX_OBS; i++) {
132
- if (obstacles[i].alive && aabb(&player, &obstacles[i])) {
133
- game_over_timer = 60;
134
- sfx_noise(40);
895
+ /* Collisions: a player crashes if a live traffic car shares its lane AND
896
+ * is within one char row of CAR_ROW. */
897
+ for (p = 0; p < (two_player ? 2 : 1); p++) {
898
+ if (!car_active[p] || invuln[p]) continue;
899
+ for (i = 0; i < MAX_TRAFFIC; i++) {
900
+ if (!traffic_alive[i]) continue;
901
+ if (traffic_lane[i] != car_lane[p]) continue;
902
+ if (traffic_prev_row[i] >= (int16_t)(CAR_ROW - 1) &&
903
+ traffic_prev_row[i] <= (int16_t)(CAR_ROW + 1)) {
904
+ clear_traffic_cell((uint8_t)traffic_prev_row[i], traffic_col[i]);
905
+ traffic_alive[i] = 0;
906
+ traffic_prev_row[i] = -1;
907
+ crash_player(p);
135
908
  break;
136
909
  }
137
910
  }
911
+ if (state != ST_PLAY) break;
138
912
  }
913
+ if (state != ST_PLAY) continue;
139
914
 
140
- POKE(VIC_SPRITE_X(0), player.x);
141
- POKE(VIC_SPRITE_Y(0), player.y);
142
- for (i = 0; i < MAX_OBS; i++) {
143
- uint8_t ox = obstacles[i].alive ? obstacles[i].x : 0;
144
- uint8_t oy = obstacles[i].alive ? obstacles[i].y : 0;
145
- POKE(VIC_SPRITE_X(1 + i), ox);
146
- POKE(VIC_SPRITE_Y(1 + i), oy);
915
+ /* Stage the car sprites, then commit enable + X-MSB once. Invulnerable
916
+ * cars blink by skipping their slot every few frames. */
917
+ stage_begin();
918
+ for (p = 0; p < 2; p++) {
919
+ if (!car_active[p]) continue;
920
+ if (invuln[p] & 4) continue; /* blink */
921
+ stage_sprite(p ? SLOT_P2 : SLOT_P1, LANE_X(car_lane[p]), SPR_Y_FOR_ROW(CAR_ROW));
147
922
  }
923
+ stage_commit();
148
924
  }
149
925
  }