romdevtools 0.28.0 → 0.29.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 (154) hide show
  1. package/AGENTS.md +51 -41
  2. package/CHANGELOG.md +46 -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 +1 -1
  88. package/src/host/LibretroHost.js +59 -1
  89. package/src/http/tool-registry.js +11 -11
  90. package/src/mcp/tools/cheats.js +2 -1
  91. package/src/mcp/tools/frame.js +3 -2
  92. package/src/mcp/tools/index.js +3 -3
  93. package/src/mcp/tools/input.js +5 -4
  94. package/src/mcp/tools/lifecycle.js +6 -4
  95. package/src/mcp/tools/platform-docs.js +1 -1
  96. package/src/mcp/tools/preview-tile.js +6 -2
  97. package/src/mcp/tools/project.js +1098 -130
  98. package/src/mcp/tools/rom-id.js +5 -1
  99. package/src/mcp/tools/run-until.js +4 -2
  100. package/src/mcp/tools/snippets.js +6 -6
  101. package/src/mcp/tools/sprite-pipeline.js +14 -2
  102. package/src/mcp/tools/state.js +2 -1
  103. package/src/mcp/tools/tile-inspect.js +8 -1
  104. package/src/mcp/tools/toolchain.js +12 -1
  105. package/src/mcp/tools/watch-memory.js +4 -3
  106. package/src/observer/bus.js +73 -0
  107. package/src/observer/livestream.html +4 -2
  108. package/src/observer/tool-wrap.js +17 -14
  109. package/src/platforms/atari7800/MENTAL_MODEL.md +5 -5
  110. package/src/platforms/atari7800/TROUBLESHOOTING.md +5 -5
  111. package/src/platforms/c64/MENTAL_MODEL.md +11 -4
  112. package/src/platforms/c64/TROUBLESHOOTING.md +13 -0
  113. package/src/platforms/gb/MENTAL_MODEL.md +3 -3
  114. package/src/platforms/gb/TROUBLESHOOTING.md +61 -8
  115. package/src/platforms/gb/lib/c/README.md +10 -11
  116. package/src/platforms/gb/lib/c/gb_crt0.s +27 -3
  117. package/src/platforms/gb/lib/c/patch-header.js +13 -3
  118. package/src/platforms/gba/MENTAL_MODEL.md +4 -4
  119. package/src/platforms/gba/TROUBLESHOOTING.md +3 -3
  120. package/src/platforms/gba/lib/c/gba_sfx.c +40 -0
  121. package/src/platforms/gba/lib/c/gba_sfx.h +10 -0
  122. package/src/platforms/gbc/MENTAL_MODEL.md +4 -4
  123. package/src/platforms/gbc/TROUBLESHOOTING.md +4 -4
  124. package/src/platforms/gbc/UPSTREAM_SOURCES.md +1 -1
  125. package/src/platforms/gbc/lib/c/README.md +10 -11
  126. package/src/platforms/gbc/lib/c/gb_crt0.s +26 -3
  127. package/src/platforms/gbc/lib/c/patch-header.js +13 -3
  128. package/src/platforms/genesis/MENTAL_MODEL.md +3 -3
  129. package/src/platforms/genesis/TROUBLESHOOTING.md +2 -2
  130. package/src/platforms/gg/MENTAL_MODEL.md +4 -4
  131. package/src/platforms/gg/TROUBLESHOOTING.md +3 -3
  132. package/src/platforms/gg/UPSTREAM_SOURCES.md +1 -1
  133. package/src/platforms/gg/lib/c/joypad_read.c +29 -0
  134. package/src/platforms/lynx/MENTAL_MODEL.md +1 -1
  135. package/src/platforms/lynx/TROUBLESHOOTING.md +3 -3
  136. package/src/platforms/msx/MENTAL_MODEL.md +5 -5
  137. package/src/platforms/msx/TROUBLESHOOTING.md +2 -2
  138. package/src/platforms/msx/lib/c/msx_hw.h +1 -0
  139. package/src/platforms/msx/lib/c/msx_vdp.c +25 -0
  140. package/src/platforms/nes/MENTAL_MODEL.md +2 -2
  141. package/src/platforms/nes/lib/c/nes_runtime.c +149 -34
  142. package/src/platforms/nes/lib/c/nes_runtime.h +34 -1
  143. package/src/platforms/pce/MENTAL_MODEL.md +5 -5
  144. package/src/platforms/pce/TROUBLESHOOTING.md +1 -1
  145. package/src/platforms/pce/lib/c/pce_hw.h +11 -0
  146. package/src/platforms/pce/lib/c/pce_video.c +32 -0
  147. package/src/platforms/sms/MENTAL_MODEL.md +6 -6
  148. package/src/platforms/snes/MENTAL_MODEL.md +2 -2
  149. package/src/platforms/snes/TROUBLESHOOTING.md +40 -1
  150. package/src/toolchains/cc65/presets/nes/chr-ram-runtime.cfg +13 -8
  151. package/src/toolchains/cc65/presets/nes/chr-ram-runtime.crt0.s +58 -5
  152. package/src/toolchains/cc65/presets/nes/chr-rom.crt0.s +52 -3
  153. package/src/toolchains/cc65/presets/pce/rom32k.cfg +52 -0
  154. package/src/toolchains/index.js +27 -11
@@ -1,208 +1,545 @@
1
- /* ── sports.c — Genesis SGDK two-player Pong scaffold ──────────────
1
+ /* ── sports.c — Genesis versus sports game (complete example game) ───────────
2
2
  *
3
- * Two-player Pong. Both Genesis controller ports are wired
4
- * `JOY_readJoypad(JOY_1)` drives the left paddle, `JOY_readJoypad(JOY_2)`
5
- * drives the right paddle. With a single controller, the right paddle
6
- * falls back to a "chase the ball" AI so the game is still playable
7
- * solo.
3
+ * A COMPLETE, working game VOLT VOLLEY, a head-to-head court game (Pong
4
+ * lineage): title screen, 1P vs a beatable CPU and 2P simultaneous versus
5
+ * (player 2 on CONTROLLER 2), first-to-5 match flow with a result screen,
6
+ * a hardware-fixed WINDOW-plane HUD, PSG music + SFX, and a battery-backed
7
+ * record (longest win streak vs the CPU) in cartridge SRAM.
8
8
  *
9
- * Game state:
10
- * - Left + right paddles (24-px tall, 4-px wide), moveable on Y
11
- * - Ball with X + Y velocity
12
- * - Per-side score (0..9), rendered via VDP_drawText
13
- * - Ball off either side increments opponent's score + respawns
14
- * toward the loser
9
+ * THIS FILE IS MEANT TO BE FORKED AND MODIFIED into your own game — even a
10
+ * very different one. The markers tell you what's what:
11
+ * HARDWARE IDIOM (load-bearing) dodges a documented Genesis footgun;
12
+ * reshape your gameplay around it (see TROUBLESHOOTING before changing).
13
+ * GAME LOGIC (clay) court art, ball physics, CPU skill, scoring rules:
14
+ * reshape freely.
15
15
  *
16
- * Designed for the romdev playtest window with hot-plugged
17
- * controllers plug in a second pad mid-session and player 2
18
- * just starts working without a restart.
16
+ * What depends on what:
17
+ * genesis_sfx.{h,c}PSG sound wrapper (tones + noise + a background
18
+ * melody loop). For full FM music, see the xgm2_demo template
19
+ * (XGM2_loadDriver + XGM2_play + a .xgc blob incbin'd via a data.s
20
+ * sibling) — the PSG path keeps this a single-file game.
21
+ * rom_header.c (SGDK) — the Sega header at $100. Its 'RA' block at $1B0
22
+ * DECLARES the cartridge SRAM that record_load/save below depend on
23
+ * (see the SRAM idiom). The build assembles it automatically.
24
+ *
25
+ * Layering: the court (rails + net + floor) lives on plane B, painted ONCE
26
+ * at boot and never touched again. Title/result text lives on plane A, which
27
+ * is cleared during play. The HUD lives on the WINDOW plane — fixed by
28
+ * hardware, zero per-frame cost. Nothing repaints inside the frame loop.
29
+ *
30
+ * Frame budget (NTSC, 60 fps): 2 paddles + 1 ball + 2 paddle AABB tests +
31
+ * 7 SAT entries queued for vblank DMA + the occasional HUD digit — a tiny
32
+ * fraction of the 68000's frame. Plenty of headroom for fancier physics.
19
33
  */
20
34
 
21
35
  #include <genesis.h>
22
36
  #include "genesis_sfx.h"
23
37
 
24
- #define COURT_TOP 16
25
- #define COURT_BOT 208
26
- #define PADDLE_H 24
27
- #define PADDLE_W 4
28
- #define BALL_SIZE 8
29
- #define PADDLE_X1 16
30
- #define PADDLE_X2 (320 - 16 - PADDLE_W)
31
- #define COURT_W 320
32
-
33
- #define T_PADDLE (TILE_USER_INDEX + 0)
34
- #define T_BALL (TILE_USER_INDEX + 1)
35
- #define T_RAIL (TILE_USER_INDEX + 2) /* solid court rail (BG_B) */
36
- #define T_NET (TILE_USER_INDEX + 3) /* dashed centre-line segment */
37
-
38
- /* 4bpp 8×8 tile, all colour 1 solid white block. */
39
- static const u32 tile_solid[8] = {
38
+ /* The title screen renders this — examples({op:'fork'}) stamps your game's
39
+ * name here automatically. Keep it ≤16 chars of A-Z 0-9 space dash. */
40
+ #define GAME_TITLE "VOLT VOLLEY"
41
+
42
+ /* ── HARDWARE IDIOM (load-bearing — reshape gameplay around this; see TROUBLESHOOTING) ──
43
+ * CONTROLLER MAPPING — two layers, both bite:
44
+ *
45
+ * On the pad: SGDK's JOY_readJoypad(JOY_1/JOY_2) returns BUTTON_A/B/C/
46
+ * START/UP/DOWN/LEFT/RIGHT as a bitmask. The title maps A (or START) to
47
+ * 1P vs CPU and B to 2P versus; C also starts 1P (real Genesis games map
48
+ * action buttons generously — thumbs rest on C).
49
+ *
50
+ * Driving this game HEADLESSLY through an emulator (libretro/gpgx): the
51
+ * core maps Genesis A/B/C onto libretro Y/B/A. So setInput({y:true})
52
+ * presses GENESIS A (1P start here), setInput({b:true}) presses GENESIS
53
+ * B (2P start), and setInput({a:true}) presses GENESIS C — NOT Genesis A.
54
+ * Getting this wrong looks like "the game ignores input". START is start.
55
+ */
56
+ #define BTN_1P (BUTTON_A | BUTTON_C | BUTTON_START)
57
+ #define BTN_2P (BUTTON_B)
58
+
59
+ /* ── GAME LOGIC (clay — reshape freely) ──────────────────────────────────────
60
+ * Tile art. Genesis tiles are 4bpp: each u32 row = 8 pixels, one hex nibble
61
+ * per pixel = a colour index into the tile's palette line (0 = transparent).
62
+ * Sprites + font use PAL0, the P2 paddle PAL1, plane B (the court) PAL2. */
63
+ #define T_PADDLE (TILE_USER_INDEX + 0) /* sprite: 4px-wide paddle column */
64
+ #define T_BALL (TILE_USER_INDEX + 1) /* sprite: the ball */
65
+ #define T_RAIL (TILE_USER_INDEX + 2) /* plane B: top/bottom court rail */
66
+ #define T_NET (TILE_USER_INDEX + 3) /* plane B: dashed centre net */
67
+ #define T_FLOOR (TILE_USER_INDEX + 4) /* plane B: speckled court floor */
68
+ #define T_BAND (TILE_USER_INDEX + 5) /* plane B: flat band behind HUD */
69
+
70
+ static const u32 tile_paddle[8] = { /* colour 1: P1 cyan / P2 red via *
71
+ * the palette LINE in TILE_ATTR */
72
+ 0x11110000, 0x11110000, 0x11110000, 0x11110000,
73
+ 0x11110000, 0x11110000, 0x11110000, 0x11110000,
74
+ };
75
+ static const u32 tile_ball[8] = { /* volt-yellow ball + highlight */
76
+ 0x00444400, 0x04444440, 0x44455444, 0x44455444,
77
+ 0x44444444, 0x44444444, 0x04444440, 0x00444400,
78
+ };
79
+ static const u32 tile_rail[8] = {
40
80
  0x11111111, 0x11111111, 0x11111111, 0x11111111,
41
81
  0x11111111, 0x11111111, 0x11111111, 0x11111111,
42
82
  };
43
-
44
- /* Centre-net segment: a 2px-wide vertical dash down the middle of an 8×8
45
- * tile (colour 1 in the centre columns, transparent elsewhere). Stacked
46
- * down the court's centre column it reads as a dashed Pong net. */
83
+ /* Centre net: a 2px dashed bar. DIM on purpose — title/result text on plane
84
+ * A overlaps the net column, and white-on-white glyphs would be unreadable
85
+ * (plane A glyph backgrounds are transparent, so plane B shows through). */
47
86
  static const u32 tile_net[8] = {
48
- 0x00011000, 0x00011000, 0x00011000, 0x00000000,
49
- 0x00011000, 0x00011000, 0x00011000, 0x00000000,
87
+ 0x00022000, 0x00022000, 0x00022000, 0x00000000,
88
+ 0x00022000, 0x00022000, 0x00022000, 0x00000000,
89
+ };
90
+ static const u32 tile_floor[8] = { /* sparse speckles so the arena *
91
+ * reads as a court, not a void */
92
+ 0x00000000, 0x00300000, 0x00000000, 0x00000003,
93
+ 0x00000000, 0x03000000, 0x00000000, 0x00000300,
94
+ };
95
+ static const u32 tile_band[8] = {
96
+ 0x55555555, 0x55555555, 0x55555555, 0x55555555,
97
+ 0x55555555, 0x55555555, 0x55555555, 0x55555555,
50
98
  };
51
99
 
52
- /* The court lives on BG_B (cells are 8×8): top + bottom rails plus a
53
- * dashed centre net. 320px = 40 cols, COURT_TOP/BOT are pixel rows. */
54
- #define COURT_COL_L (PADDLE_X1 / 8)
55
- #define COURT_COL_R (PADDLE_X2 / 8)
56
- #define COURT_ROW_TOP (COURT_TOP / 8)
57
- #define COURT_ROW_BOT (COURT_BOT / 8 - 1)
58
- #define COURT_NET_COL (COURT_W / 16)
59
-
60
- static void draw_court(void) {
61
- s16 c, r;
62
- /* Top + bottom rails span the playfield width. */
63
- for (c = COURT_COL_L; c <= COURT_COL_R; c++) {
64
- VDP_setTileMapXY(BG_B, TILE_ATTR_FULL(PAL0, 0, 0, 0, T_RAIL), c, COURT_ROW_TOP);
65
- VDP_setTileMapXY(BG_B, TILE_ATTR_FULL(PAL0, 0, 0, 0, T_RAIL), c, COURT_ROW_BOT);
66
- }
67
- /* Dashed centre net between the rails. */
68
- for (r = COURT_ROW_TOP + 1; r < COURT_ROW_BOT; r++) {
69
- VDP_setTileMapXY(BG_B, TILE_ATTR_FULL(PAL0, 0, 0, 0, T_NET), COURT_NET_COL, r);
100
+ /* ── GAME LOGIC (clay reshape freely) ──────────────────────────────────────
101
+ * Court geometry + match rules. The court is framed by plane-B rails on cell
102
+ * rows 2 and 27; COURT_TOP/BOT keep the ball between them. Rows 0-1 sit
103
+ * under the WINDOW HUD (see the window idiom below). */
104
+ #define HUD_ROWS 2 /* window rows reserved for the HUD */
105
+ #define PADDLE_H 24 /* 3 stacked 8px sprites */
106
+ #define PADDLE_W 4
107
+ #define PADDLE_X1 16 /* P1 — left side */
108
+ #define PADDLE_X2 300 /* P2/CPU — right side (320 - 16 - 4) */
109
+ #define COURT_TOP 24 /* first pixel row below the top rail */
110
+ #define COURT_BOT 216 /* first pixel row of the bottom rail */
111
+ #define NET_COL 20 /* cell column of the centre net */
112
+ #define BALL_W 8
113
+ #define BALL_H 8
114
+ #define SCREEN_W 320 /* H40 mode */
115
+ #define WIN_SCORE 5 /* first to 5 takes the match */
116
+ #define P1_SPEED 2 /* px/frame both humans move at this */
117
+ #define CPU_SPEED 1 /* px/frame half speed: beatable */
118
+
119
+ static s16 p1y, p2y; /* paddle top Y, pixels */
120
+ static s16 bx, by; /* ball top-left, pixels */
121
+ static s16 bdx, bdy; /* ball velocity (px/frame) */
122
+ static u8 score_p1, score_p2;
123
+ static u8 serve_timer; /* freeze frames between points */
124
+ static u8 two_player; /* title pick: 0 = vs CPU, 1 = 2P versus */
125
+ static u8 streak; /* current 1P-vs-CPU win streak (RAM) */
126
+ static u16 best_streak; /* battery-backed record — see end_match */
127
+ static u8 new_record; /* result screen shows NEW RECORD */
128
+
129
+ /* Game states — the shell every example shares: title → play → game over. */
130
+ #define ST_TITLE 0
131
+ #define ST_PLAY 1
132
+ #define ST_OVER 2
133
+ static u8 state;
134
+ static u16 prev_pad;
135
+
136
+ /* ── GAME LOGIC (clay) — xorshift16 PRNG (a few 68k instructions per call).
137
+ * A versus game NEEDS this: the Genesis is fully deterministic, so without
138
+ * a noise source two fixed strategies lock into an infinite rally loop (the
139
+ * exact same 600-frame cycle, forever — a match that never ends). random8()
140
+ * is ticked once per play frame so identical game states a few seconds
141
+ * apart still diverge, and every paddle return adds a ±1 "spin". */
142
+ static u16 rng = 0xC0A7;
143
+ static u8 random8(void) {
144
+ u16 r = rng;
145
+ r ^= r << 7;
146
+ r ^= r >> 9;
147
+ r ^= r << 8;
148
+ rng = r;
149
+ return (u8)r;
150
+ }
151
+
152
+ /* ── HARDWARE IDIOM (load-bearing — reshape gameplay around this; see TROUBLESHOOTING) ──
153
+ * CARTRIDGE SRAM — the Genesis battery-save mechanism, three parts:
154
+ *
155
+ * 1. The ROM HEADER declares it: bytes $1B0.. hold 'R','A', a type word
156
+ * ($F820 = battery-backed, byte-wide on ODD addresses — the classic
157
+ * cart wiring), then start/end addresses $200000/$20FFFF. SGDK's
158
+ * rom_header.c (assembled into every build) already declares exactly
159
+ * this — no linker work needed. Emulators allocate the save RAM by
160
+ * READING THIS HEADER; no 'RA' block = writes to $200000+ go nowhere.
161
+ * 2. The MAPPER GATE: writing 1 to $A130F1 banks SRAM into $200000+,
162
+ * 0 banks the ROM back in. SGDK's SRAM_enable()/SRAM_disable() do
163
+ * this. ALWAYS disable after access — on carts >2 MB the SRAM window
164
+ * shadows ROM, and leaving it enabled corrupts later ROM fetches.
165
+ * 3. ODD-BYTE ADDRESSING: SRAM_readByte/writeByte(offset) access 68k
166
+ * address $200001 + offset*2. Headlessly, the emulator's save_ram
167
+ * region interleaves with dead even bytes: SGDK offset k lives at
168
+ * save_ram[k*2 + 1] (the even bytes read back $FF).
169
+ *
170
+ * Record layout (SGDK offsets): 0='H' 1='S' 2=lo 3=hi 4=checksum
171
+ * (lo^hi^$A5). Fresh SRAM is all $FF — the magic+checksum rejects it (and
172
+ * any corruption) so first boot shows 0, not 65535.
173
+ *
174
+ * Persistence choice: for a VERSUS sports game a raw hi-score is
175
+ * meaningless (every match ends 5-x), so we persist the longest 1P win
176
+ * streak against the CPU — the stat a returning player actually chases.
177
+ * 2P matches never touch it (humans beating each other isn't a record).
178
+ *
179
+ * Emulator note (verified against gpgx): the core sizes its save_ram
180
+ * region by scanning for the last non-$FF byte, so the region reads as
181
+ * EMPTY until the first write below lands — that's why record_init runs
182
+ * at the very top of main(). Real hardware and .srm-restoring frontends
183
+ * have no such wrinkle. */
184
+ static u16 record_load(void) {
185
+ u8 m0, m1, lo, hi, ck;
186
+ SRAM_enableRO();
187
+ m0 = SRAM_readByte(0);
188
+ m1 = SRAM_readByte(1);
189
+ lo = SRAM_readByte(2);
190
+ hi = SRAM_readByte(3);
191
+ ck = SRAM_readByte(4);
192
+ SRAM_disable();
193
+ if (m0 == 'H' && m1 == 'S' && ck == (u8)(lo ^ hi ^ 0xA5))
194
+ return ((u16)hi << 8) | lo;
195
+ return 0;
196
+ }
197
+
198
+ static void record_save(u16 v) {
199
+ u8 lo = (u8)v, hi = (u8)(v >> 8);
200
+ SRAM_enable();
201
+ SRAM_writeByte(0, 'H');
202
+ SRAM_writeByte(1, 'S');
203
+ SRAM_writeByte(2, lo);
204
+ SRAM_writeByte(3, hi);
205
+ SRAM_writeByte(4, (u8)(lo ^ hi ^ 0xA5));
206
+ SRAM_disable();
207
+ }
208
+
209
+ /* Format-on-first-boot: if the magic is absent (fresh battery), write a
210
+ * valid zero record immediately so the save file exists from frame one. */
211
+ static void record_init(void) {
212
+ best_streak = record_load();
213
+ if (best_streak == 0) record_save(0);
214
+ }
215
+
216
+ /* ── HARDWARE IDIOM (load-bearing — reshape gameplay around this; see TROUBLESHOOTING) ──
217
+ * WINDOW-PLANE HUD — the fixed status bar. The window is a third tilemap
218
+ * that REPLACES plane A wherever it's shown and IGNORES ALL SCROLLING —
219
+ * a hardware-fixed HUD with zero per-frame cost. (The NES needs a sprite-0
220
+ * raster trick for this; on Genesis it's one register.)
221
+ * VDP_setWindowOnTop(2) shows it on the top 2 cell rows; text goes in with
222
+ * VDP_drawTextBG(WINDOW, ...). Two footguns:
223
+ * - The window only lives at screen edges (top/bottom N rows or left/
224
+ * right N columns) — it cannot float mid-screen.
225
+ * - It replaces plane A ONLY: plane B and sprites still render behind/
226
+ * over it. We paint plane B's top rows with a flat dark band so HUD
227
+ * text always reads, and nothing in the game flies above y=16
228
+ * (COURT_TOP is 24 — the top rail keeps the ball clear of the HUD). */
229
+ static void hud_init(void) {
230
+ VDP_setWindowOnTop(HUD_ROWS);
231
+ }
232
+
233
+ /* ── GAME LOGIC (clay) — HUD text (window plane, redrawn only on change) ── */
234
+ static void draw_u16(VDPPlane plane, u16 v, u16 x, u16 y) {
235
+ char buf[8];
236
+ uintToStr(v, buf, 5);
237
+ VDP_drawTextBG(plane, buf, x, y);
238
+ }
239
+
240
+ static void draw_scores(void) {
241
+ char b[2] = { 0, 0 };
242
+ b[0] = '0' + score_p1;
243
+ VDP_drawTextBG(WINDOW, b, 5, 0);
244
+ b[0] = '0' + score_p2;
245
+ VDP_drawTextBG(WINDOW, b, 38, 0);
246
+ }
247
+
248
+ static void draw_hud_play(void) {
249
+ VDP_clearTextAreaBG(WINDOW, 0, 0, 40, HUD_ROWS);
250
+ VDP_drawTextBG(WINDOW, "P1", 1, 0);
251
+ VDP_drawTextBG(WINDOW, two_player ? " P2" : "CPU", 33, 0);
252
+ VDP_drawTextBG(WINDOW, "BEST", 14, 0);
253
+ draw_u16(WINDOW, best_streak, 19, 0);
254
+ draw_scores();
255
+ }
256
+
257
+ static void draw_hud_title(void) {
258
+ VDP_clearTextAreaBG(WINDOW, 0, 0, 40, HUD_ROWS);
259
+ VDP_drawTextBG(WINDOW, "BEST", 14, 0);
260
+ draw_u16(WINDOW, best_streak, 19, 0);
261
+ }
262
+
263
+ /* ── GAME LOGIC (clay) — paint the court (plane B, ONCE at boot) ──────────
264
+ * Painted once and never touched again — the frame loop does zero tilemap
265
+ * writes (rewriting tilemaps per frame is the #1 "choppy movement" bug). */
266
+ static void paint_court(void) {
267
+ u16 c, r;
268
+ /* Flat dark band behind the window HUD (rows 0-1). */
269
+ VDP_fillTileMapRect(BG_B, TILE_ATTR_FULL(PAL2, 0, 0, 0, T_BAND),
270
+ 0, 0, 64, HUD_ROWS);
271
+ for (c = 0; c < 40; c++) {
272
+ VDP_setTileMapXY(BG_B, TILE_ATTR_FULL(PAL2, 0, 0, 0, T_RAIL), c, 2);
273
+ VDP_setTileMapXY(BG_B, TILE_ATTR_FULL(PAL2, 0, 0, 0, T_RAIL), c, 27);
70
274
  }
275
+ for (r = 3; r < 27; r++)
276
+ for (c = 0; c < 40; c++)
277
+ VDP_setTileMapXY(BG_B, TILE_ATTR_FULL(PAL2, 0, 0, 0,
278
+ (c == NET_COL) ? T_NET : T_FLOOR), c, r);
71
279
  }
72
280
 
73
- static s16 p1y, p2y; /* paddle top Y, pixels */
74
- static s16 bx, by; /* ball top-left, pixels */
75
- static s16 bdx, bdy;
76
- static u16 score_p1, score_p2;
77
- static u16 serve_timer;
281
+ /* ── GAME LOGIC (clay) the title screen (text on plane A over the court) ── */
282
+ static void paint_title(void) {
283
+ VDP_clearPlane(BG_A, TRUE);
284
+ VDP_drawTextBG(BG_A, GAME_TITLE, (40 - (sizeof(GAME_TITLE) - 1)) / 2, 8);
285
+ VDP_drawTextBG(BG_A, "1P VS CPU - A", 13, 14);
286
+ VDP_drawTextBG(BG_A, "2P VERSUS - B", 13, 16);
287
+ VDP_drawTextBG(BG_A, "FIRST TO 5", 15, 19);
288
+ VDP_drawTextBG(BG_A, "UP DOWN MOVES YOUR PADDLE", 7, 22);
289
+ draw_hud_title();
290
+ }
291
+
292
+ /* ── GAME LOGIC (clay) — the result screen ── */
293
+ static void paint_over(void) {
294
+ char line[8];
295
+ VDP_clearPlane(BG_A, TRUE);
296
+ if (score_p1 >= WIN_SCORE)
297
+ VDP_drawTextBG(BG_A, "P1 WINS", 16, 8);
298
+ else
299
+ VDP_drawTextBG(BG_A, two_player ? "P2 WINS" : "CPU WINS", 16, 8);
300
+ line[0] = '0' + score_p1;
301
+ line[1] = ' '; line[2] = '-'; line[3] = ' ';
302
+ line[4] = '0' + score_p2;
303
+ line[5] = 0;
304
+ VDP_drawTextBG(BG_A, line, 17, 11);
305
+ if (new_record) VDP_drawTextBG(BG_A, "NEW RECORD", 15, 14);
306
+ VDP_drawTextBG(BG_A, "START - TITLE", 13, 21);
307
+ }
78
308
 
79
- static void serve_ball(bool to_left) {
80
- bx = COURT_W / 2 - BALL_SIZE / 2;
309
+ /* ── GAME LOGIC (clay) — serve: ball to centre, toward the chosen side.
310
+ * The serve angle takes a PRNG bit (not a fixed alternation) — one more
311
+ * place determinism is broken so idle matches can't settle into a cycle. */
312
+ static void serve_ball(u8 to_left) {
313
+ bx = SCREEN_W / 2 - BALL_W / 2;
81
314
  by = (COURT_TOP + COURT_BOT) / 2;
82
315
  bdx = to_left ? -2 : 2;
83
- bdy = ((score_p1 + score_p2) & 1) ? -1 : 1;
84
- serve_timer = 30;
316
+ bdy = (random8() & 1) ? -1 : 1;
317
+ serve_timer = 30; /* half-second breather */
85
318
  }
86
319
 
87
- static void reset_match(void) {
320
+ /* ── GAME LOGIC (clay) — start a match ── */
321
+ static void start_match(u8 players) {
322
+ two_player = players;
88
323
  p1y = (COURT_TOP + COURT_BOT) / 2 - PADDLE_H / 2;
89
324
  p2y = p1y;
90
325
  score_p1 = 0;
91
326
  score_p2 = 0;
92
- serve_ball(FALSE);
327
+ new_record = 0;
328
+ serve_ball(0);
329
+ VDP_clearPlane(BG_A, TRUE); /* drop the title text — court shows */
330
+ draw_hud_play();
331
+ sfx_tone(0, 523, 10); /* start jingle (C5) */
332
+ state = ST_PLAY;
333
+ }
334
+
335
+ /* ── GAME LOGIC (clay) — match over: result + record bookkeeping ── */
336
+ static void end_match(void) {
337
+ if (score_p1 >= WIN_SCORE && !two_player) {
338
+ ++streak;
339
+ if (streak > best_streak) {
340
+ best_streak = streak;
341
+ new_record = 1;
342
+ record_save(best_streak); /* battery SRAM — see the SRAM idiom */
343
+ }
344
+ } else if (!two_player) {
345
+ streak = 0; /* the streak dies with the loss */
346
+ }
347
+ /* End-of-match whistle: two quick descending tones. */
348
+ sfx_tone(0, 380, 8);
349
+ sfx_tone(1, 570, 12);
350
+ paint_over();
351
+ prev_pad = 0xFFFF; /* swallow buttons held at match end */
352
+ state = ST_OVER;
353
+ }
354
+
355
+ /* ── GAME LOGIC (clay) — one point scored ── */
356
+ static void score_point(u8 for_p1) {
357
+ if (for_p1) ++score_p1; else ++score_p2;
358
+ sfx_noise(10);
359
+ draw_scores();
360
+ if (score_p1 >= WIN_SCORE || score_p2 >= WIN_SCORE) end_match();
361
+ else serve_ball(for_p1); /* winner of the point receives */
93
362
  }
94
363
 
95
- static void render_scores(void) {
96
- char buf[2] = { 0, 0 };
97
- buf[0] = '0' + (score_p1 % 10);
98
- VDP_drawText(buf, 6, 2);
99
- buf[0] = '0' + (score_p2 % 10);
100
- VDP_drawText(buf, 32, 2);
364
+ /* ── GAME LOGIC (clay) — paddle hit: deflect by where the ball struck.
365
+ * Centre = flat-ish, edges = steep. Max |bdy| is 2 — the CPU moves at 1,
366
+ * so an edge hit is exactly how a human beats it. The ±1 random "spin" on
367
+ * every return keeps rallies from repeating (see the PRNG note above). */
368
+ static void deflect(s16 paddle_y) {
369
+ s16 rel = (by + BALL_H / 2) - (paddle_y + PADDLE_H / 2);
370
+ bdy = rel >> 3;
371
+ bdy += (s16)(random8() & 2) - 1; /* spin: -1 or +1 */
372
+ if (bdy > 2) bdy = 2;
373
+ if (bdy < -2) bdy = -2;
374
+ if (bdy == 0) bdy = (rel < 0) ? -1 : 1; /* never return a flat ball */
375
+ sfx_tone(0, 280, 4);
376
+ }
377
+
378
+ /* ── GAME LOGIC (clay) — stage this frame's sprites ─────────────────────────
379
+ * Fixed SAT slots: 0-2 = P1 paddle, 3-5 = P2 paddle, 6 = ball. Hidden
380
+ * sprites park at y = -16 (above the screen). NEVER hide with x = -128..0 —
381
+ * a SAT x of 0 is the VDP's sprite-masking trigger and silently blanks
382
+ * every lower-priority sprite on those scanlines. */
383
+ #define HIDE_Y (-16)
384
+ static void stage_sprites(void) {
385
+ u16 i;
386
+ u8 actors = (state != ST_TITLE); /* paddles freeze on the result */
387
+ u8 ball_on = (state == ST_PLAY); /* the match ball went off-side */
388
+ for (i = 0; i < PADDLE_H / 8; i++) {
389
+ VDP_setSprite(0 + i, PADDLE_X1, actors ? p1y + (s16)(i * 8) : (s16)HIDE_Y,
390
+ SPRITE_SIZE(1, 1), TILE_ATTR_FULL(PAL0, 1, 0, 0, T_PADDLE));
391
+ VDP_setSprite(3 + i, PADDLE_X2, actors ? p2y + (s16)(i * 8) : (s16)HIDE_Y,
392
+ SPRITE_SIZE(1, 1), TILE_ATTR_FULL(PAL1, 1, 0, 0, T_PADDLE));
393
+ }
394
+ VDP_setSprite(6, bx, ball_on ? by : (s16)HIDE_Y,
395
+ SPRITE_SIZE(1, 1), TILE_ATTR_FULL(PAL0, 1, 0, 0, T_BALL));
396
+ /* ── HARDWARE IDIOM (load-bearing) — CHAIN the sprite list before
397
+ * uploading. VDP_setSprite does NOT set the SAT link byte, and link 0
398
+ * means "end of list": skip this and the VDP draws sprite 0 only.
399
+ * VDP_linkSprites(0, 7) links slots 0..6; the queued DMA flushes the
400
+ * 7 SAT entries during vblank. ── */
401
+ VDP_linkSprites(0, 7);
402
+ VDP_updateSprites(7, DMA_QUEUE);
101
403
  }
102
404
 
103
405
  int main(bool hard) {
406
+ u16 pad, pad2, fresh;
104
407
  (void)hard;
105
408
 
106
- /* Sprite palette 0: white + cyan accents. */
107
- PAL_setColor(0 + 1, 0x0EEE);
409
+ /* SRAM first before any VDP work. The save file then exists within
410
+ * the game's first frames of life, which is what lets a frontend (or
411
+ * a headless host) see a non-empty save_ram region as early as
412
+ * possible (see the SRAM idiom note on gpgx's size scan). */
413
+ record_init();
414
+ streak = 0;
415
+
416
+ /* ── HARDWARE IDIOM (load-bearing — see TROUBLESHOOTING) ──
417
+ * Init order: tiles + palettes before the tilemaps that reference them,
418
+ * window size before window text. SGDK's boot already did the dangerous
419
+ * part (VDP regs, Z80, vblank int); this game never scrolls, so the
420
+ * default scroll mode + zero scroll values are exactly right. */
421
+ hud_init();
108
422
 
109
- VDP_loadTileData(tile_solid, T_PADDLE, 1, DMA);
110
- VDP_loadTileData(tile_solid, T_BALL, 1, DMA);
111
- VDP_loadTileData(tile_solid, T_RAIL, 1, DMA);
112
- VDP_loadTileData(tile_net, T_NET, 1, DMA);
423
+ /* Palettes: PAL0 sprites + font, PAL1 the P2 paddle, PAL2 the court.
424
+ * Colours are BGR, 3 bits per channel: 0x0BGR with E = full.
425
+ * PAL0 colour 0 is also the BACKDROP — the court floor colour. */
426
+ PAL_setColor( 0, 0x0420); /* backdrop: dark navy court */
427
+ PAL_setColor( 1, 0x0EE2); /* P1 paddle volt cyan */
428
+ PAL_setColor( 4, 0x00EE); /* ball volt yellow */
429
+ PAL_setColor( 5, 0x08FF); /* ball highlight */
430
+ PAL_setColor(15, 0x0EEE); /* font white (index 15 = SGDK font colour) */
431
+ PAL_setColor(16 + 1, 0x022E); /* P2 paddle red */
432
+ PAL_setColor(32 + 1, 0x0CC4); /* rail cyan */
433
+ PAL_setColor(32 + 2, 0x0875); /* net — DIM (text overlaps it) */
434
+ PAL_setColor(32 + 3, 0x0641); /* floor speckle */
435
+ PAL_setColor(32 + 5, 0x0201); /* HUD band near-black */
113
436
 
114
- /* Draw the static court (rails + centre net) once on BG_B. */
115
- draw_court();
437
+ VDP_loadTileData(tile_paddle, T_PADDLE, 1, DMA);
438
+ VDP_loadTileData(tile_ball, T_BALL, 1, DMA);
439
+ VDP_loadTileData(tile_rail, T_RAIL, 1, DMA);
440
+ VDP_loadTileData(tile_net, T_NET, 1, DMA);
441
+ VDP_loadTileData(tile_floor, T_FLOOR, 1, DMA);
442
+ VDP_loadTileData(tile_band, T_BAND, 1, DMA);
116
443
 
117
- VDP_drawText("PLAYER 1", 2, 1);
118
- VDP_drawText("PLAYER 2", 28, 1);
119
- VDP_drawText("UP/DOWN MOVES YOUR PADDLE", 7, 27);
444
+ paint_court(); /* plane B: painted once, never again */
445
+ sfx_init(); /* PSG: sfx channels + background melody */
120
446
 
121
- sfx_init();
122
- reset_match();
447
+ state = ST_TITLE;
448
+ prev_pad = 0xFFFF; /* swallow buttons held across power-on */
449
+ paint_title();
123
450
 
124
451
  while (TRUE) {
125
- u16 p1 = JOY_readJoypad(JOY_1);
126
- u16 p2 = JOY_readJoypad(JOY_2);
127
-
128
- /* Player 1 (left paddle) — UP/DOWN. */
129
- if ((p1 & BUTTON_UP) && p1y > COURT_TOP) p1y -= 3;
130
- if ((p1 & BUTTON_DOWN) && p1y < COURT_BOT - PADDLE_H) p1y += 3;
131
-
132
- /* Player 2 (right paddle) — human if any input this frame,
133
- * otherwise AI chases the ball. JOY_readJoypad returns 0 when
134
- * no controller is plugged into port 2. */
135
- if (p2 != 0) {
136
- if ((p2 & BUTTON_UP) && p2y > COURT_TOP) p2y -= 3;
137
- if ((p2 & BUTTON_DOWN) && p2y < COURT_BOT - PADDLE_H) p2y += 3;
138
- } else {
139
- s16 target = by - PADDLE_H / 2;
140
- if (p2y < target && p2y < COURT_BOT - PADDLE_H) p2y += 2;
141
- else if (p2y > target && p2y > COURT_TOP) p2y -= 2;
452
+ stage_sprites();
453
+
454
+ if (state == ST_TITLE) {
455
+ /* ── GAME LOGIC (clay) — title: A/START = 1P vs CPU, B = 2P ── */
456
+ pad = JOY_readJoypad(JOY_1);
457
+ fresh = pad & ~prev_pad;
458
+ prev_pad = pad;
459
+ if (fresh & BTN_1P) start_match(0);
460
+ else if (fresh & BTN_2P) start_match(1);
461
+ sfx_update();
462
+ SYS_doVBlankProcess();
463
+ continue;
142
464
  }
143
465
 
144
- if (serve_timer > 0) {
145
- serve_timer--;
146
- } else {
147
- bx += bdx;
148
- by += bdy;
149
-
150
- if (by < COURT_TOP) { by = COURT_TOP; bdy = -bdy; sfx_tone(2, 350, 2); }
151
- if (by + BALL_SIZE > COURT_BOT) { by = COURT_BOT - BALL_SIZE;bdy = -bdy; sfx_tone(2, 350, 2); }
152
-
153
- /* Paddle 1 (left) collision */
154
- if (bdx < 0
155
- && bx <= PADDLE_X1 + PADDLE_W
156
- && bx + BALL_SIZE >= PADDLE_X1
157
- && by + BALL_SIZE > p1y
158
- && by < p1y + PADDLE_H) {
159
- bdx = -bdx;
160
- bx = PADDLE_X1 + PADDLE_W;
161
- sfx_tone(1, 280, 3); /* paddle hit */
162
- }
163
- /* Paddle 2 (right) collision */
164
- if (bdx > 0
165
- && bx + BALL_SIZE >= PADDLE_X2
166
- && bx <= PADDLE_X2 + PADDLE_W
167
- && by + BALL_SIZE > p2y
168
- && by < p2y + PADDLE_H) {
169
- bdx = -bdx;
170
- bx = PADDLE_X2 - BALL_SIZE;
171
- sfx_tone(1, 280, 3);
466
+ if (state == ST_OVER) {
467
+ /* Result screen freezes the final scene; START or A → title. */
468
+ pad = JOY_readJoypad(JOY_1);
469
+ fresh = pad & ~prev_pad;
470
+ prev_pad = pad;
471
+ if (fresh & (BUTTON_START | BUTTON_A | BUTTON_C)) {
472
+ state = ST_TITLE;
473
+ prev_pad = 0xFFFF; /* swallow the held START */
474
+ paint_title();
172
475
  }
476
+ sfx_update();
477
+ SYS_doVBlankProcess();
478
+ continue;
479
+ }
173
480
 
174
- if (bx + BALL_SIZE < 0) {
175
- if (score_p2 < 9) score_p2++;
176
- sfx_noise(24); /* point lost buzz */
177
- serve_ball(FALSE);
178
- }
179
- if (bx > COURT_W) {
180
- if (score_p1 < 9) score_p1++;
181
- sfx_tone(0, 200, 16); /* point won — chime */
182
- serve_ball(TRUE);
183
- }
481
+ /* ── ST_PLAY ──────────────────────────────────────────────────── */
482
+
483
+ /* ── GAME LOGIC (clay) from here down ── */
484
+ random8(); /* tick the noise source every play frame */
485
+
486
+ /* P1 — controller 1, UP/DOWN. (prev_pad tracks through play so the
487
+ * result screen's edge-detect doesn't eat a held button.) */
488
+ pad = JOY_readJoypad(JOY_1);
489
+ prev_pad = pad;
490
+ if ((pad & BUTTON_UP) && p1y > COURT_TOP) p1y -= P1_SPEED;
491
+ if ((pad & BUTTON_DOWN) && p1y < COURT_BOT - PADDLE_H) p1y += P1_SPEED;
492
+
493
+ if (two_player) {
494
+ /* P2 — CONTROLLER 2, same speed: a fair simultaneous-versus
495
+ * match. (JOY_readJoypad(JOY_2) returns 0 with no pad in port
496
+ * 2 — the paddle just sits still; this mode is for two humans,
497
+ * the CPU lives in 1P mode.) */
498
+ pad2 = JOY_readJoypad(JOY_2);
499
+ if ((pad2 & BUTTON_UP) && p2y > COURT_TOP) p2y -= P1_SPEED;
500
+ if ((pad2 & BUTTON_DOWN) && p2y < COURT_BOT - PADDLE_H) p2y += P1_SPEED;
501
+ } else {
502
+ /* CPU — chases the ball centre at half player speed with a
503
+ * small dead zone. Beatable by design: steep edge deflections
504
+ * outrun it. */
505
+ s16 target = by + BALL_H / 2 - PADDLE_H / 2;
506
+ if (p2y + 2 < target && p2y < COURT_BOT - PADDLE_H) p2y += CPU_SPEED;
507
+ else if (p2y > target + 2 && p2y > COURT_TOP) p2y -= CPU_SPEED;
508
+ }
509
+
510
+ /* Ball update (frozen during the post-point serve pause). */
511
+ if (serve_timer > 0) {
512
+ --serve_timer;
513
+ sfx_update();
514
+ SYS_doVBlankProcess();
515
+ continue;
184
516
  }
517
+ bx += bdx;
518
+ by += bdy;
185
519
 
186
- /* Sprite SAT update — paddles are 3 stacked 1×1 sprites, ball
187
- * is one. We only use 7 sprite slots which is well under the
188
- * 80 the VDP allows. */
189
- u16 slot = 0;
190
- for (u16 i = 0; i < PADDLE_H / 8; i++) {
191
- VDP_setSprite(slot++, PADDLE_X1, p1y + i * 8, SPRITE_SIZE(1, 1),
192
- TILE_ATTR_FULL(PAL0, 1, 0, 0, T_PADDLE));
520
+ /* Rail bounce. */
521
+ if (by < COURT_TOP) { by = COURT_TOP; bdy = -bdy; sfx_tone(1, 350, 3); }
522
+ if (by + BALL_H > COURT_BOT) { by = COURT_BOT - BALL_H; bdy = -bdy; sfx_tone(1, 350, 3); }
523
+
524
+ /* Paddle collisions (direction-gated so the ball can't double-hit). */
525
+ if (bdx < 0
526
+ && bx <= PADDLE_X1 + PADDLE_W && bx + BALL_W >= PADDLE_X1
527
+ && by + BALL_H > p1y && by < p1y + PADDLE_H) {
528
+ bdx = -bdx;
529
+ bx = PADDLE_X1 + PADDLE_W;
530
+ deflect(p1y);
193
531
  }
194
- for (u16 i = 0; i < PADDLE_H / 8; i++) {
195
- VDP_setSprite(slot++, PADDLE_X2, p2y + i * 8, SPRITE_SIZE(1, 1),
196
- TILE_ATTR_FULL(PAL0, 1, 0, 0, T_PADDLE));
532
+ if (bdx > 0
533
+ && bx + BALL_W >= PADDLE_X2 && bx <= PADDLE_X2 + PADDLE_W
534
+ && by + BALL_H > p2y && by < p2y + PADDLE_H) {
535
+ bdx = -bdx;
536
+ bx = PADDLE_X2 - BALL_W;
537
+ deflect(p2y);
197
538
  }
198
- VDP_setSprite(slot++, bx, by, SPRITE_SIZE(1, 1),
199
- TILE_ATTR_FULL(PAL0, 1, 0, 0, T_BALL));
200
- /* Link slots 0..slot-1 so the VDP's SAT walk draws all of them — without
201
- * this the link bytes stay 0 (= end-of-list) and only slot 0 renders. */
202
- VDP_linkSprites(0, slot);
203
- VDP_updateSprites(slot, DMA);
204
-
205
- render_scores();
539
+
540
+ /* Off either side → point. */
541
+ if (bx < 4) score_point(0); /* past P1 right side scores */
542
+ if (bx > SCREEN_W - 4) score_point(1); /* past P2 P1 scores */
206
543
 
207
544
  sfx_update();
208
545
  SYS_doVBlankProcess();