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,238 +1,656 @@
1
- /* ── platformer.c — NES single-screen platformer scaffold ────────────
1
+ /* ── platformer.c — NES side-scrolling platformer (complete example game) ────
2
2
  *
3
- * A complete, runnable platformer baseline. Includes:
4
- * - Player (8x8 sprite) d-pad LEFT/RIGHT moves, A jumps
5
- * - Gravity + jump physics with fixed-point Y (Q4.4: 4 frac bits)
6
- * - Static platform list (5 horizontal rects) collide-from-above only
7
- * - Walk + jump animation via tile-id swapping
8
- * - Right-edge "you win" silence; fall off bottom → respawn
3
+ * LEDGE LEAPER — a COMPLETE, working game: title screen, 1P mode and 2P
4
+ * ALTERNATING-TURNS mode (arcade-classic: players swap on death; each player
5
+ * has their own score and own 3 lives; player 2 plays on CONTROLLER 2),
6
+ * coins + distance scoring, persistent hi-score (battery SRAM), music +
7
+ * SFX, and the NES's signature sprite-0-hit split: a fixed HUD strip over
8
+ * a horizontally scrolling level.
9
9
  *
10
- * Why fixed-point Y: gravity adds < 1 pixel/frame at peak jump, so we
11
- * need sub-pixel precision. X stays integer because horizontal motion
12
- * is integer-speed (no acceleration). Standard NES platformer trick.
10
+ * THIS FILE IS MEANT TO BE FORKED AND MODIFIED into your own game — even a
11
+ * very different one. The markers tell you what's what:
12
+ * HARDWARE IDIOM (load-bearing) dodges a documented NES footgun; reshape
13
+ * your gameplay around it (see TROUBLESHOOTING before changing).
14
+ * GAME LOGIC (clay) — level layout, physics tuning, scoring, art: reshape
15
+ * freely.
13
16
  *
14
- * Collision is rect-vs-rect "land on top" only — no side-collision,
15
- * no head-bump-stop. Add those when your scaffold proves out. This
16
- * keeps the baseline understandable + extendable.
17
+ * What depends on what:
18
+ * nes_runtime.{h,c} rendering/input/sound/text/hi-score library.
19
+ * chr-ram-runtime.crt0.s boot + NMI + iNES header (BATTERY bit feeds
20
+ * hiscore_load/save). Load-bearing; edit with TROUBLESHOOTING open.
17
21
  *
18
- * SINGLE-SCREEN as shipped (platforms are drawn as SPRITES, not BG).
19
- * To make it a SIDE-SCROLLER you must draw platforms into the BACKGROUND
20
- * nametables (so hardware scroll moves them), use ppu_scroll(camX, 0)
21
- * which flips the PPUCTRL nametable-select bit past 256 px for you — and
22
- * for a world wider than 512 px stream a fresh nametable column + its
23
- * attribute byte into the off-screen nametable each 8-px camera step.
24
- * The chr-ram-runtime crt0 uses VERTICAL mirroring, giving NT0 (left) +
25
- * NT1 (right) side by side = a free 512-px world. See the NES
26
- * MENTAL_MODEL.md "Backgrounds" + "NMI" sections.
22
+ * The level: a 256-px-wide COLUMN MAP (ground height + one-way platforms +
23
+ * pits) painted IDENTICALLY into both nametables, so the 8-bit X scroll
24
+ * wraps seamlessly an endless looping run of pits, platforms, coins and
25
+ * spikes. Coins/spikes are sprites that drift with the scroll (world-
26
+ * anchored while on screen, respawning at the right edge).
27
+ *
28
+ * Frame budget (NTSC, 60fps): player physics + a two-column tile probe +
29
+ * (3 coins + 2 spikes) of AABB + the sprite-0 spin (a few scanlines) fits
30
+ * comfortably in one frame; a HUD redraw is ≤12 queued VRAM writes (the
31
+ * queue drains 16 per vblank).
27
32
  */
28
33
 
29
34
  #include "nes_runtime.h"
30
35
 
31
- /* ── Tiles: player idle/jump + platform block (SPRITES, table $0000) ── */
36
+ /* The title screen renders this examples({op:'fork'}) stamps your game's
37
+ * name here automatically. Keep it ≤16 chars of A-Z 0-9 space dash. */
38
+ #define GAME_TITLE "LEDGE LEAPER"
39
+
40
+ /* ── GAME LOGIC (clay — reshape freely) ──────────────────────────────────────
41
+ * Tile art. Each 8x8 tile = 16 bytes: 8 plane-0 rows then 8 plane-1 rows
42
+ * (2bpp — plane0-only pixels use colour 1, both planes = colour 3). */
32
43
  static const uint8_t tile_blank[16] = { 0 };
33
44
  static const uint8_t tile_player_idle[16] = {
34
- 0x3C, 0x7E, 0xFF, 0xFF, 0xFF, 0xFF, 0x7E, 0x3C, /* plane 0 round blob */
35
- 0, 0, 0, 0, 0, 0, 0, 0,
45
+ 0x3C, 0x7E, 0xFF, 0xFF, 0xFF, 0x7E, 0x66, 0x66, /* round body + legs */
46
+ 0x00, 0x24, 0x24, 0x00, 0x00, 0x00, 0x00, 0x00, /* eyes (colour 3) */
36
47
  };
37
48
  static const uint8_t tile_player_jump[16] = {
38
- 0x18, 0x7E, 0xFF, 0xFF, 0xE7, 0xC3, 0x81, 0x00, /* plane 0 — arms up */
49
+ 0x18, 0x7E, 0xFF, 0xFF, 0xE7, 0xC3, 0x81, 0x00, /* arms up */
50
+ 0x00, 0x24, 0x24, 0x00, 0x00, 0x00, 0x00, 0x00,
51
+ };
52
+ static const uint8_t tile_coin[16] = {
53
+ 0x3C, 0x7E, 0xFF, 0xFF, 0xFF, 0xFF, 0x7E, 0x3C, /* coin disc */
54
+ 0x00, 0x3C, 0x66, 0x5A, 0x5A, 0x66, 0x3C, 0x00, /* embossed ring */
55
+ };
56
+ static const uint8_t tile_spike[16] = {
57
+ 0x00, 0x18, 0x18, 0x3C, 0x3C, 0x7E, 0x7E, 0xFF, /* ground spike */
39
58
  0, 0, 0, 0, 0, 0, 0, 0,
40
59
  };
41
- static const uint8_t tile_platform[16] = {
42
- 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, /* plane 0 — solid */
60
+ /* Sprite 0's marker block — fully OPAQUE (the sprite-0 hit fires on
61
+ * opaque-sprite-over-opaque-BG, colour is irrelevant). Its palette below
62
+ * makes it the same brown as the HUD bar, so it's invisible in the bar. */
63
+ static const uint8_t tile_mark[16] = {
64
+ 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
43
65
  0, 0, 0, 0, 0, 0, 0, 0,
44
66
  };
45
67
 
46
- /* ── BG scenery tiles (BACKGROUND pattern table $1000) ────────────────
47
- * Painted into the nametable so the world reads as a real outdoor scene on
48
- * boot (sky + clouds + dirt) instead of sprites floating on flat black. The
49
- * BG backdrop colour (palette[0]) is sky blue, so the colours used here are
50
- * cloud white (idx1), dirt brown (idx2) and grass green (idx3).
51
- *
52
- * BG_CLOUD — a puffy cloud (idx1) dotted across the upper sky.
53
- * BG_DIRT — solid dirt fill (idx2) for the ground band.
54
- * BG_GRASS — a grass-topped dirt tile (idx3 cap over idx2) for the
55
- * surface row of the ground. */
56
- #define BG_CLOUD 1 /* BG tile index 1 → uploaded to $1010 */
57
- #define BG_DIRT 2 /* BG tile index 2 → uploaded to $1020 */
58
- #define BG_GRASS 3 /* BG tile index 3 → uploaded to $1030 */
68
+ /* BG tiles (BACKGROUND pattern table $1000 — separate from the sprite
69
+ * table at $0000; the runtime's PPUCTRL setup makes that split). */
59
70
  static const uint8_t bg_tile_cloud[16] = {
60
- 0x00, 0x18, 0x3C, 0x7E, 0x7E, 0x00, 0x00, 0x00, /* plane 0 → cloud (idx1) */
71
+ 0x00, 0x18, 0x3C, 0x7E, 0x7E, 0x00, 0x00, 0x00, /* puffy cloud (idx1) */
61
72
  0, 0, 0, 0, 0, 0, 0, 0,
62
73
  };
63
74
  static const uint8_t bg_tile_dirt[16] = {
64
- 0, 0, 0, 0, 0, 0, 0, 0, /* plane 0 clear */
65
- 0xFF, 0xFF, 0xEF, 0xFF, 0xFF, 0xFE, 0xFF, 0xFF, /* plane 1 → dirt (idx2) */
75
+ 0, 0, 0, 0, 0, 0, 0, 0,
76
+ 0xFF, 0xFF, 0xEF, 0xFF, 0xFF, 0xFE, 0xFF, 0xFF, /* dirt fill (idx2) */
66
77
  };
67
78
  static const uint8_t bg_tile_grass[16] = {
68
- 0xFF, 0xFF, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, /* plane0: top 2 rows on */
69
- 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, /* plane1: all rows on */
70
- }; /* rows 0-1 → both planes = idx3 (grass cap); rows 2-7 → plane1 = idx2 (dirt) */
79
+ 0xFF, 0xFF, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, /* top 2 rows idx3 */
80
+ 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, /* rest idx2 (dirt) */
81
+ };
82
+ /* A solid tile for the HUD bar — sprite 0 must overlap an OPAQUE BG pixel
83
+ * for the sprite-0 hit to fire (see the split idiom below). */
84
+ static const uint8_t bg_tile_hudbar[16] = {
85
+ 0, 0, 0, 0, 0, 0, 0, 0,
86
+ 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, /* solid idx2 brown */
87
+ };
88
+ #define BG_CLOUD 1
89
+ #define BG_DIRT 2
90
+ #define BG_GRASS 3 /* also used for floating platforms (grass slabs) */
91
+ #define BG_HUDBAR 4
71
92
 
72
93
  static const uint8_t palette[32] = {
73
- /* BG0: sky-blue backdrop, cloud white (idx1), dirt brown (idx2),
74
- * grass green (idx3) */
94
+ /* BG: ALL FOUR sub-palettes identical (sky, cloud white, dirt brown,
95
+ * grass green). That makes stale attribute-table bits harmless — power-on
96
+ * CIRAM is garbage, and identical sub-palettes mean any attribute value
97
+ * picks the same colours. We clear the attribute tables anyway (belt and
98
+ * braces, see paint_field). */
75
99
  0x21, 0x30, 0x17, 0x2A,
76
100
  0x21, 0x30, 0x17, 0x2A,
77
101
  0x21, 0x30, 0x17, 0x2A,
78
102
  0x21, 0x30, 0x17, 0x2A,
79
- /* The universal backdrop ($3F00) is MIRRORED at $3F10 — the sprite
80
- * palette-0 colour-0 slot. palette_load writes all 32 bytes in order, so
81
- * byte 16 (this slot) is the LAST write to that mirror and therefore wins.
82
- * Keep it equal to the BG backdrop (sky blue) or the sky renders as
83
- * whatever colour-0 you put here, not the BG[0] above. (Sprite colour 0 is
84
- * transparent regardless, so this never affects how sprites draw.) */
85
- 0x21, 0x16, 0x30, 0x27, /* sp0: playerRED + white/orange trim. (Was light
86
- * blues nearly invisible against the sky-blue
87
- * backdrop. Colour 0 stays the backdrop mirror.) */
88
- 0x0F, 0x18, 0x28, 0x38, /* sp1: platforms — green */
89
- 0x0F, 0x16, 0x06, 0x36,
90
- 0x0F, 0x2A, 0x1A, 0x0A,
103
+ /* The universal backdrop ($3F00) is MIRRORED at $3F10 — sprite palette 0
104
+ * colour 0. palette_load writes all 32 bytes in order, so this byte is
105
+ * the LAST write to the mirror and wins: keep it equal to the BG backdrop
106
+ * (sky blue) or the whole sky changes colour. (Sprite colour 0 is
107
+ * transparent regardless this never affects how sprites draw.) */
108
+ 0x21, 0x16, 0x30, 0x27, /* sp0: player red body, white/orange trim */
109
+ 0x0F, 0x17, 0x17, 0x17, /* sp1: sprite-0 marker HUD-bar brown camo */
110
+ 0x0F, 0x16, 0x06, 0x30, /* sp2: spikes danger red */
111
+ 0x0F, 0x28, 0x27, 0x30, /* sp3: coins gold */
91
112
  };
92
113
 
93
- /* ── Platforms (static rectangles) ───────────────────────────────── */
94
- typedef struct { uint8_t x, y, w; } Platform; /* 8 px tall, w in tiles */
95
- #define NUM_PLATFORMS 5
96
- static const Platform platforms[NUM_PLATFORMS] = {
97
- { 16, 200, 20 }, /* ground band */
98
- { 40, 168, 4 },
99
- { 104, 144, 4 },
100
- { 168, 168, 4 },
101
- { 200, 120, 4 },
114
+ /* ── GAME LOGIC (clay — reshape freely) ──────────────────────────────────────
115
+ * The level a 32-column map; world x = (screen x + scroll) mod 256.
116
+ * ground_row[c] — nametable row of the ground's grass top, 0xFF = pit.
117
+ * plat_row[c] row of a one-way floating platform, 0 = none.
118
+ * Rows are nametable rows (y = row*8). Playfield rows are 3..29. */
119
+ #define NO_GROUND 0xFF
120
+ static const uint8_t ground_row[32] = {
121
+ 26, 26, 26, 26, 26, 26, 26, 26, /* start runway */
122
+ 26, NO_GROUND, NO_GROUND, 26, 26, 26, 26, 26, /* pit 1 (16 px) */
123
+ 26, 26, 26, 26, NO_GROUND, NO_GROUND, NO_GROUND, /* pit 2 (24 px) */
124
+ 26, 26, 26, 26, 26, 26, 26, 26, 26,
125
+ };
126
+ static const uint8_t plat_row[32] = {
127
+ 0, 0, 0, 0, 21, 21, 21, 0, /* slab before pit 1 */
128
+ 0, 0, 0, 0, 0, 0, 20, 20, /* slab mid-level */
129
+ 20, 0, 0, 0, 0, 0, 0, 0,
130
+ 0, 21, 21, 21, 0, 0, 0, 0, /* slab near the loop */
102
131
  };
103
132
 
104
- #define TILE_PLAYER_IDLE 1
105
- #define TILE_PLAYER_JUMP 2
106
- #define TILE_PLATFORM 3
107
- #define PLAYER_PAL 0
108
- #define PLATFORM_PAL 1
109
-
110
- /* ── Player state ────────────────────────────────────────────────── */
111
- static uint8_t px = 24;
112
- static uint16_t py_q44 = (200 - 8) << 4; /* Q4.4 — start above leftmost platform */
113
- static int8_t vy_q44 = 0;
114
- static uint8_t on_ground = 0;
115
-
116
- #define GRAVITY_Q44 1 /* +1/16 px per frame per frame */
117
- #define JUMP_VEL_Q44 (-40) /* initial vy peak ~5 tile jump */
118
- #define MOVE_SPEED 2 /* px/frame1 read as 'moves slowly' in playtesting */
119
-
120
- /* AABB: player rect vs platform top edge (treat platform as 8 px tall). */
121
- static uint8_t landed_on(uint8_t pl_idx, uint8_t player_y) {
122
- const Platform *p = &platforms[pl_idx];
123
- uint8_t feet_y = player_y + 7;
124
- /* Window starts ONE PIXEL above the top edge: the standing snap puts the
125
- * feet at p->y - 1, and gravity's sub-pixel trickle doesn't move the
126
- * integer Y every frame with the old `feet_y < p->y` cutoff the player
127
- * "stood" with on_ground=0 most frames, so A-press jumps only registered
128
- * on lucky frames and the idle/jump sprite flickered every frame. */
129
- if (feet_y + 1 < p->y || feet_y > p->y + 4) return 0; /* not at top edge */
130
- if (px + 7 < p->x) return 0;
131
- if (px > p->x + (p->w << 3) - 1) return 0;
132
- return 1;
133
+ #define TILE_PLAYER_IDLE 1
134
+ #define TILE_PLAYER_JUMP 2
135
+ #define TILE_COIN 3
136
+ #define TILE_SPIKE 4
137
+ #define TILE_MARK 5
138
+ #define PLAYER_PAL 0
139
+ #define MARK_PAL 1
140
+ #define SPIKE_PAL 2
141
+ #define COIN_PAL 3
142
+
143
+ /* HUD layout (mind the OVERSCAN: most NTSC displays/cores crop the top 8
144
+ * scanlines, so nametable row 0 is invisible — never put text there):
145
+ * row 0 blank (cropped by overscan)
146
+ * row 1 HUD text (P# / lives / SC / HI)
147
+ * row 2solid bar: the visual divider AND sprite 0's opaque anchor
148
+ * row 3+ — the scrolling playfield
149
+ * The HUD strip always renders with scroll (0,0) from nametable 0, so HUD
150
+ * text lives ONLY in nametable 0 — it can never scroll into view twice. */
151
+ #define HUD_ROWS 3
152
+ #define START_LIVES 3
153
+
154
+ /* ── GAME LOGIC (clay) physics + tuning ── */
155
+ #define GRAVITY_Q44 1 /* +1/16 px per frame per frame */
156
+ #define JUMP_VEL_Q44 (-40) /* launch vy (Q4.4) ~50 px / ~6 tile apex */
157
+ #define MAX_VY_Q44 80 /* terminal velocity, 5 px/frame MUST stay *
158
+ * under 6: the landing probe's 6-px window *
159
+ * can't catch a faster fall (tunnelling) */
160
+ #define MOVE_SPEED 2 /* px/frame walk + scroll speed */
161
+ #define SCROLL_WALL 112 /* px: past this the world scrolls, not you */
162
+ #define GROUND_TOP 208 /* ground_row 26 * 8 */
163
+ #define SPIKE_Y 200 /* spikes stand on the ground */
164
+ #define NUM_COINS 3
165
+ #define NUM_SPIKES 2
166
+
167
+ static uint8_t px; /* player screen x */
168
+ static uint16_t py_q44; /* player y, Q4.4 fixed point — gravity
169
+ * adds <1 px/frame near the jump apex,
170
+ * so we need sub-pixel precision */
171
+ static int8_t vy_q44;
172
+ static uint8_t on_ground;
173
+ static uint8_t scroll_x; /* level scroll — uint8 wraps at 256 = *
174
+ * exactly one level loop (seamless) */
175
+ static uint8_t dist_sub; /* sub-counter: 64 px scrolled = +1 pt */
176
+ static uint8_t coin_x[NUM_COINS], coin_y[NUM_COINS];
177
+ static uint8_t spike_x[NUM_SPIKES], spike_active[NUM_SPIKES];
178
+
179
+ /* Players: index 0 = P1 (controller 1), 1 = P2 (controller 2 — alternating
180
+ * turns, arcade-classic style). Each has own score + own lives; the HUD shows the
181
+ * CURRENT player's numbers. */
182
+ static uint8_t two_player;
183
+ static uint8_t cur_player;
184
+ static uint8_t p_lives[2];
185
+ static uint16_t p_score[2];
186
+ static uint16_t hiscore;
187
+ static uint8_t turn_pause; /* freeze frames after a turn change */
188
+ static uint16_t rng = 0xC0DE;
189
+
190
+ /* Game states — the shell every example shares: title → play → game over. */
191
+ #define ST_TITLE 0
192
+ #define ST_PLAY 1
193
+ #define ST_OVER 2
194
+ static uint8_t state;
195
+ static uint8_t prev_pad;
196
+
197
+ /* ── GAME LOGIC (clay) — xorshift16 PRNG (~tens of cycles per call) ── */
198
+ static uint8_t random8(void) {
199
+ uint16_t r = rng;
200
+ r ^= r << 7;
201
+ r ^= r >> 9;
202
+ r ^= r << 8;
203
+ rng = r;
204
+ return (uint8_t)r;
133
205
  }
134
206
 
135
- void main(void) {
136
- uint8_t pad, prev_pad = 0;
137
- uint8_t player_y, i;
207
+ static uint8_t dist8(uint8_t a, uint8_t b) {
208
+ return (a > b) ? (a - b) : (b - a);
209
+ }
138
210
 
139
- ppu_off();
211
+ /* ── HARDWARE IDIOM (load-bearing — reshape gameplay around this; see TROUBLESHOOTING) ──
212
+ * Sprite-0-hit split scroll — THE classic NES technique (the fixed
213
+ * status bar over a scrolling field in countless NES classics). The PPU has ONE scroll for the whole
214
+ * frame; to keep the HUD fixed while the playfield scrolls, you change the
215
+ * scroll MID-FRAME, and sprite 0 is your timing signal:
216
+ *
217
+ * 1. Sprite 0 (the FIRST sprite staged each frame) sits inside the HUD,
218
+ * overlapping an OPAQUE background pixel (our solid HUD bar tile).
219
+ * 2. The NMI commits scroll (0,0) at vblank — the HUD renders unscrolled.
220
+ * 3. After ppu_wait_nmi(), poll PPUSTATUS bit 6 in TWO phases: first wait
221
+ * for it to CLEAR (the stale flag from the previous frame survives all
222
+ * of vblank and only clears at the pre-render line), then wait for it
223
+ * to SET — the exact pixel where sprite 0's opaque pixel overlaps
224
+ * opaque background.
225
+ * 4. THEN write the playfield scroll to PPUSCROLL — everything below the
226
+ * HUD renders with the new scroll.
227
+ *
228
+ * Requires: sprite 0 staged FIRST (oam_spr call order = OAM order), an
229
+ * opaque BG pixel under it, ppu_scroll(0,0) left as the frame scroll, and
230
+ * this poll running EVERY frame (miss a frame and the field jumps).
231
+ * Mid-frame X-scroll needs only the two PPUSCROLL writes below. (Mid-frame
232
+ * Y needs the 4-write $2006/$2005 dance — see TROUBLESHOOTING before
233
+ * attempting; X covers the HUD-over-scrolling-field pattern.)
234
+ * The two-phase spin burns from vblank start to the hit scanline — about
235
+ * 35 scanlines of CPU every frame. Budget for it: your game logic gets the
236
+ * rest of the visible frame, which is plenty for a game this size. */
237
+ #define PPUSTATUS_REG (*(volatile uint8_t *)0x2002)
238
+ #define PPUSCROLL_REG (*(volatile uint8_t *)0x2005)
239
+ static void split_after_hud(void) {
240
+ uint8_t timeout = 240;
241
+ /* FOOTGUN: the hit flag from the frame JUST RENDERED stays set all the
242
+ * way through vblank — it only clears at the next pre-render line. We're
243
+ * called right after ppu_wait_nmi() (i.e. inside vblank), so polling for
244
+ * "set" alone exits INSTANTLY on the stale flag and the PPUSCROLL write
245
+ * lands during vblank — scrolling the WHOLE next frame, HUD included
246
+ * (the shear is subtle: it looks like the HUD "drifting"). The classic
247
+ * fix is the two-phase poll: wait for the stale flag to CLEAR (the
248
+ * pre-render line), then wait for THIS frame's hit to SET. */
249
+ while (PPUSTATUS_REG & 0x40) {
250
+ if (--timeout == 0) return; /* flag stuck: bail, keep scroll (0,0) */
251
+ }
252
+ timeout = 240;
253
+ while (!(PPUSTATUS_REG & 0x40)) {
254
+ if (--timeout == 0) return; /* rendering off / sprite-0 missing: bail */
255
+ }
256
+ PPUSCROLL_REG = scroll_x; /* playfield X scroll (below the HUD) */
257
+ PPUSCROLL_REG = 0;
258
+ }
259
+
260
+ /* Stage sprite 0 = an 8x8 opaque block over the HUD BAR row (OAM y is
261
+ * scanline-1, so y=16 renders scanlines 17-24 = nametable row 2 = the bar —
262
+ * opaque-on-opaque, so the hit fires INSIDE the bar and the scroll change
263
+ * lands below it, never shearing the text row). Must be the FIRST oam_spr
264
+ * call of the frame (OAM order = call order; the split needs index 0). */
265
+ static void stage_sprite0(void) {
266
+ oam_spr(4, (HUD_ROWS - 1) * 8, TILE_MARK, MARK_PAL);
267
+ }
140
268
 
141
- chr_ram_upload(0x0000, tile_blank, 16);
142
- chr_ram_upload(0x0010, tile_player_idle, 16);
143
- chr_ram_upload(0x0020, tile_player_jump, 16);
144
- chr_ram_upload(0x0030, tile_platform, 16);
269
+ /* ── GAME LOGIC (clay) — HUD text (queued writes; NMI commits next vblank) ── */
270
+ static void draw_hud(void) {
271
+ tile_set(0, 1, 1, (uint8_t)(0x41 + cur_player)); /* '1' or '2' */
272
+ tile_set(0, 3, 1, 0x40 + p_lives[cur_player]); /* lives as a digit */
273
+ text_draw_u16(0, 9, 1, p_score[cur_player]);
274
+ text_draw_u16(0, 19, 1, hiscore);
275
+ }
145
276
 
146
- /* BG scenery tiles go in pattern table 1 ($1000) — where the default
147
- * PPUCTRL points the background fetch. */
148
- chr_ram_upload(0x1010, bg_tile_cloud, 16);
149
- chr_ram_upload(0x1020, bg_tile_dirt, 16);
150
- chr_ram_upload(0x1030, bg_tile_grass, 16);
277
+ static void draw_hud_labels(void) {
278
+ text_draw(0, 0, 1, "P");
279
+ text_draw(0, 6, 1, "SC");
280
+ text_draw(0, 16, 1, "HI");
281
+ }
151
282
 
152
- palette_load(palette);
283
+ /* PPU-off digit painter (the queued text_draw_u16 needs rendering ON). */
284
+ static void digits_unsafe(uint16_t ppu_addr, uint16_t v) {
285
+ uint8_t d[5], i;
286
+ for (i = 0; i < 5; i++) { d[i] = v % 10; v /= 10; }
287
+ for (i = 0; i < 5; i++) vram_unsafe_set(ppu_addr + i, (uint8_t)(0x40 + d[4 - i]));
288
+ }
289
+
290
+ /* ── GAME LOGIC (clay) — the title screen ──────────────────────────────────
291
+ * Painted with the PPU OFF (text_draw_unsafe = raw VRAM writes; the queued
292
+ * variant would deadlock with rendering disabled — see TROUBLESHOOTING). */
293
+ static void paint_title(void) {
294
+ uint16_t a = 0x2000;
295
+ uint8_t r, c, t;
296
+ ppu_off();
297
+ for (r = 0; r < 30; r++) {
298
+ for (c = 0; c < 32; c++) {
299
+ t = 0; /* sky backdrop */
300
+ if (r == 26) t = BG_GRASS;
301
+ else if (r > 26) t = BG_DIRT;
302
+ vram_unsafe_set(a, t);
303
+ ++a;
304
+ }
305
+ }
306
+ text_draw_unsafe(0x2000 + 8 * 32 + ((32 - sizeof(GAME_TITLE) + 1) / 2), GAME_TITLE);
307
+ text_draw_unsafe(0x2000 + 13 * 32 + 10, "1P START - A");
308
+ text_draw_unsafe(0x2000 + 15 * 32 + 10, "2P TURNS - B");
309
+ text_draw_unsafe(0x2000 + 20 * 32 + 10, "HI");
310
+ digits_unsafe(0x2000 + 20 * 32 + 13, hiscore);
311
+ ppu_scroll(0, 0);
312
+ oam_clear();
313
+ ppu_on_all();
314
+ }
153
315
 
154
- /* Paint the outdoor scene into the nametable while rendering is off
155
- * (vram_unsafe_set = raw write; tile_set's NMI queue would deadlock before
156
- * ppu_on). Sky-blue backdrop shows through the empty upper rows; we dot
157
- * clouds across it, cap the ground with a grass row, and fill the bottom
158
- * band with solid dirt. The sprite platforms still draw on top of this. */
159
- {
160
- uint16_t r, c;
161
- /* clouds scattered through the upper sky (rows 2..9) */
162
- for (r = 2; r < 10; r++)
163
- for (c = (uint16_t)((r * 3) % 6); c < 32; c += 6)
164
- vram_unsafe_set((uint16_t)(0x2000 + r * 32 + c), BG_CLOUD);
165
- /* grass cap row + solid dirt to the bottom of the screen */
166
- for (c = 0; c < 32; c++)
167
- vram_unsafe_set((uint16_t)(0x2000 + 24 * 32 + c), BG_GRASS);
168
- for (r = 25; r < 30; r++)
169
- for (c = 0; c < 32; c++)
170
- vram_unsafe_set((uint16_t)(0x2000 + r * 32 + c), BG_DIRT);
316
+ /* ── GAME LOGIC (clay) paint the level from the column map ───────────────
317
+ * Painted into BOTH nametables (vertical mirroring puts NT0 + NT1 side by
318
+ * side = a 512-px canvas). Identical copies + a 256-px-periodic level make
319
+ * the uint8 scroll wrap PERFECTLY seamless: the visible window always shows
320
+ * the same content at world x mod 256, so the run loops forever. */
321
+ static void paint_field(void) {
322
+ uint16_t base, a;
323
+ uint8_t nt, r, c, t, g;
324
+ ppu_off();
325
+ for (nt = 0; nt < 2; nt++) {
326
+ base = nt ? 0x2400 : 0x2000;
327
+ a = base;
328
+ for (c = 0; c < 32; c++) { vram_unsafe_set(a, 0); ++a; } /* row 0: overscan */
329
+ for (c = 0; c < 32; c++) { vram_unsafe_set(a, 0); ++a; } /* row 1: HUD text */
330
+ for (c = 0; c < 32; c++) { vram_unsafe_set(a, BG_HUDBAR); ++a; } /* row 2: bar */
331
+ for (r = HUD_ROWS; r < 30; r++) {
332
+ for (c = 0; c < 32; c++) {
333
+ g = ground_row[c];
334
+ t = 0;
335
+ if (r == plat_row[c]) t = BG_GRASS; /* floating slab */
336
+ else if (g != NO_GROUND) {
337
+ if (r == g) t = BG_GRASS; /* ground surface */
338
+ else if (r > g) t = BG_DIRT; /* ground body */
339
+ }
340
+ if (t == 0 && r >= 4 && r <= 9) {
341
+ if (((r * 7 + c * 5) & 15) == 0) t = BG_CLOUD;
342
+ }
343
+ vram_unsafe_set(a, t);
344
+ ++a;
345
+ }
346
+ }
347
+ /* Attribute table → palette 0 everywhere. CIRAM powers on as garbage;
348
+ * with our identical BG sub-palettes it wouldn't show, but clear it so
349
+ * forks that diverge the palettes don't inherit a latent bug. */
350
+ a = base + 0x3C0;
351
+ for (c = 0; c < 64; c++) { vram_unsafe_set(a, 0); ++a; }
171
352
  }
353
+ ppu_scroll(0, 0);
354
+ oam_clear();
355
+ ppu_on_all();
356
+ /* Labels go through the queued path once rendering is on. */
357
+ draw_hud_labels();
358
+ }
172
359
 
360
+ /* ── GAME LOGIC (clay) — the game-over results screen ── */
361
+ static void paint_over(void) {
362
+ uint16_t a = 0x2000;
363
+ uint16_t i;
364
+ ppu_off();
365
+ for (i = 0; i < 960; i++) { vram_unsafe_set(a, 0); ++a; }
366
+ text_draw_unsafe(0x2000 + 8 * 32 + 11, "GAME OVER");
367
+ text_draw_unsafe(0x2000 + 12 * 32 + 9, "P1");
368
+ digits_unsafe(0x2000 + 12 * 32 + 13, p_score[0]);
369
+ if (two_player) {
370
+ text_draw_unsafe(0x2000 + 14 * 32 + 9, "P2");
371
+ digits_unsafe(0x2000 + 14 * 32 + 13, p_score[1]);
372
+ }
373
+ text_draw_unsafe(0x2000 + 17 * 32 + 9, "HI");
374
+ digits_unsafe(0x2000 + 17 * 32 + 13, hiscore);
375
+ text_draw_unsafe(0x2000 + 21 * 32 + 9, "START - TITLE");
376
+ ppu_scroll(0, 0);
173
377
  oam_clear();
174
378
  ppu_on_all();
379
+ }
380
+
381
+ /* ── GAME LOGIC (clay) — coins + spikes (sprite objects in the world) ── */
382
+ static const uint8_t coin_heights[4] = { 184, 160, 128, 152 };
383
+ static void respawn_coin(uint8_t i) {
384
+ coin_x[i] = (uint8_t)(232 + (random8() & 15)); /* enter at the right */
385
+ coin_y[i] = coin_heights[random8() & 3];
386
+ }
387
+
388
+ static void try_spawn_spike(uint8_t i) {
389
+ /* Anchor only over ground: an inactive spike rolls a low per-frame
390
+ * chance, and only spawns if the level column entering at the right
391
+ * edge has ground under it (never floats over a pit). */
392
+ uint8_t c = (uint8_t)(248 + scroll_x) >> 3;
393
+ if (ground_row[c] == NO_GROUND) return;
394
+ if (random8() > 4) return;
395
+ spike_x[i] = 248;
396
+ spike_active[i] = 1;
397
+ }
398
+
399
+ /* ── GAME LOGIC (clay) — start a turn / a run ── */
400
+ static void begin_turn(void) {
401
+ px = 24;
402
+ py_q44 = (uint16_t)(GROUND_TOP - 8) << 4;
403
+ vy_q44 = 0;
404
+ on_ground = 1;
405
+ scroll_x = 0;
406
+ dist_sub = 0;
407
+ coin_x[0] = 88; coin_y[0] = 184;
408
+ coin_x[1] = 152; coin_y[1] = 160;
409
+ coin_x[2] = 216; coin_y[2] = 128;
410
+ spike_x[0] = 136; spike_active[0] = 1; /* both anchored on ground at */
411
+ spike_x[1] = 224; spike_active[1] = 1; /* scroll 0 — see ground_row */
412
+ turn_pause = 48; /* "P1/P2 ready" breather */
413
+ prev_pad = 0xFF; /* swallow held buttons across *
414
+ * the turn change */
415
+ ppu_scroll(0, 0);
416
+ draw_hud();
417
+ }
418
+
419
+ static void start_game(uint8_t players) {
420
+ two_player = players;
421
+ cur_player = 0;
422
+ p_score[0] = p_score[1] = 0;
423
+ p_lives[0] = START_LIVES;
424
+ p_lives[1] = players ? START_LIVES : 0;
425
+ paint_field();
426
+ begin_turn();
427
+ sound_play_tone(0, 0x0FD, 8, 8); /* start jingle (A4) */
428
+ state = ST_PLAY;
429
+ }
430
+
431
+ static void game_over(void) {
432
+ uint16_t best = p_score[0];
433
+ if (two_player && p_score[1] > best) best = p_score[1];
434
+ if (best > hiscore) {
435
+ hiscore = best;
436
+ /* ── HARDWARE IDIOM (load-bearing) — persists via battery PRG-RAM at
437
+ * $6000; works because the crt0's iNES header sets the BATTERY bit.
438
+ * See nes_runtime.c for the magic+checksum layout. ── */
439
+ hiscore_save(hiscore);
440
+ }
441
+ state = ST_OVER;
442
+ paint_over();
443
+ }
444
+
445
+ /* ── GAME LOGIC (clay) — death + alternating-turn handoff ── */
446
+ static void kill_player(void) {
447
+ uint8_t other;
448
+ sound_play_noise(12, 12, 14);
449
+ if (p_lives[cur_player] > 0) --p_lives[cur_player];
450
+ if (two_player) {
451
+ other = cur_player ^ 1;
452
+ if (p_lives[other] > 0) cur_player = other; /* swap turns */
453
+ else if (p_lives[cur_player] == 0) { game_over(); return; }
454
+ } else if (p_lives[0] == 0) {
455
+ game_over();
456
+ return;
457
+ }
458
+ begin_turn();
459
+ }
460
+
461
+ /* ── GAME LOGIC (clay) — landing probe against the column map ──────────────
462
+ * One-way platforms, classic NES style: only catch the player while FALLING
463
+ * through a narrow window at the surface. The window is 6 px tall —
464
+ * top-1 (the standing snap parks feet at top, and gravity's sub-pixel
465
+ * trickle doesn't move the integer Y every frame; without the -1 slack the
466
+ * player "stands" with on_ground=0 most frames, so jumps only register on
467
+ * lucky frames and the idle/jump sprite flickers) through top+4 (so a
468
+ * 5 px/frame terminal-velocity fall can't step over it). */
469
+ static uint8_t land_top(uint8_t c, uint8_t feet) {
470
+ uint8_t r, top;
471
+ r = plat_row[c];
472
+ if (r) {
473
+ top = r << 3;
474
+ if ((uint8_t)(feet + 1) >= top && feet <= (uint8_t)(top + 4)) return top;
475
+ }
476
+ r = ground_row[c];
477
+ if (r != NO_GROUND) {
478
+ top = r << 3;
479
+ if ((uint8_t)(feet + 1) >= top && feet <= (uint8_t)(top + 4)) return top;
480
+ }
481
+ return 0;
482
+ }
483
+
484
+ void main(void) {
485
+ uint8_t i, pad, delta, y8, feet, c0, c1, top, killed;
486
+ uint8_t player_y;
487
+
488
+ /* ── HARDWARE IDIOM (load-bearing — see TROUBLESHOOTING) ──
489
+ * Init order: PPU off → CHR upload → palette → nametable (raw writes) →
490
+ * OAM clear → rendering on. CHR/palette/nametable writes REQUIRE the PPU
491
+ * off (raw $2007 traffic during rendering corrupts the address latch
492
+ * mid-frame). The runtime's ppu_off/ppu_on_all pair owns the PPUCTRL/
493
+ * PPUMASK bits — don't poke those registers directly alongside it. */
494
+ ppu_off();
495
+ chr_ram_upload(0x0000, tile_blank, 16);
496
+ chr_ram_upload(0x0010, tile_player_idle, 16);
497
+ chr_ram_upload(0x0020, tile_player_jump, 16);
498
+ chr_ram_upload(0x0030, tile_coin, 16);
499
+ chr_ram_upload(0x0040, tile_spike, 16);
500
+ chr_ram_upload(0x0050, tile_mark, 16);
501
+ chr_ram_upload(0x1010, bg_tile_cloud, 16);
502
+ chr_ram_upload(0x1020, bg_tile_dirt, 16);
503
+ chr_ram_upload(0x1030, bg_tile_grass, 16);
504
+ chr_ram_upload(0x1040, bg_tile_hudbar, 16);
505
+ font_upload();
506
+ palette_load(palette);
175
507
  sound_init();
176
508
 
509
+ hiscore = hiscore_load(); /* battery SRAM — 0 on first boot */
510
+ state = ST_TITLE;
511
+ paint_title();
512
+
177
513
  for (;;) {
178
- player_y = (uint8_t)(py_q44 >> 4);
514
+ if (state == ST_TITLE) {
515
+ /* ── GAME LOGIC (clay) — title: A = 1P, B = 2P alternating turns ── */
516
+ oam_clear();
517
+ ppu_wait_nmi();
518
+ sound_music_tick();
519
+ pad = pad_poll(0);
520
+ if ((pad & PAD_A) && !(prev_pad & PAD_A)) start_game(0);
521
+ else if ((pad & PAD_B) && !(prev_pad & PAD_B)) start_game(1);
522
+ else if ((pad & PAD_START) && !(prev_pad & PAD_START)) start_game(0);
523
+ prev_pad = pad;
524
+ continue;
525
+ }
179
526
 
180
- /* ── Stage sprites BEFORE ppu_wait_nmi() ────────────────────
181
- * The NMI handler DMAs shadow OAM at vblank-start, so oam_clear +
182
- * oam_spr MUST run before the wait below — flip the order and the
183
- * visible frame lags / shows stale sprites. */
184
- oam_clear();
185
- /* player tile depends on whether airborne */
186
- oam_spr(px, player_y,
187
- on_ground ? TILE_PLAYER_IDLE : TILE_PLAYER_JUMP,
188
- PLAYER_PAL);
189
- /* draw each platform tile (one OAM entry per 8 px column) */
190
- for (i = 0; i < NUM_PLATFORMS; i++) {
191
- uint8_t k;
192
- for (k = 0; k < platforms[i].w; k++) {
193
- oam_spr(platforms[i].x + (k << 3), platforms[i].y, TILE_PLATFORM, PLATFORM_PAL);
527
+ if (state == ST_OVER) {
528
+ /* Results screen (scroll 0, no split needed); START or A → title. */
529
+ oam_clear();
530
+ ppu_wait_nmi();
531
+ sound_music_tick();
532
+ pad = pad_poll(0);
533
+ if ((pad & (PAD_START | PAD_A)) && !(prev_pad & (PAD_START | PAD_A))) {
534
+ state = ST_TITLE;
535
+ paint_title();
194
536
  }
537
+ prev_pad = pad;
538
+ continue;
195
539
  }
196
540
 
197
- ppu_wait_nmi();
541
+ /* ── ST_PLAY ─────────────────────────────────────────────────────── */
542
+
543
+ /* ── HARDWARE IDIOM (load-bearing — see TROUBLESHOOTING) ──
544
+ * Stage ALL sprites BEFORE ppu_wait_nmi(). The NMI DMAs shadow OAM →
545
+ * real OAM at the START of vblank, copying whatever shadow OAM holds AT
546
+ * THAT MOMENT. Stage-then-wait; flipping it shows stale/empty sprites.
547
+ * Sprite 0 (the split marker) must be staged FIRST — OAM order is
548
+ * oam_spr call order, and the split idiom needs it at index 0. */
549
+ player_y = (uint8_t)(py_q44 >> 4);
550
+ oam_clear();
551
+ stage_sprite0();
552
+ /* Blink the player during the turn-change breather. */
553
+ if (turn_pause == 0 || (turn_pause & 4))
554
+ oam_spr(px, player_y,
555
+ on_ground ? TILE_PLAYER_IDLE : TILE_PLAYER_JUMP, PLAYER_PAL);
556
+ for (i = 0; i < NUM_COINS; i++)
557
+ oam_spr(coin_x[i], coin_y[i], TILE_COIN, COIN_PAL);
558
+ for (i = 0; i < NUM_SPIKES; i++)
559
+ if (spike_active[i]) oam_spr(spike_x[i], SPIKE_Y, TILE_SPIKE, SPIKE_PAL);
198
560
 
199
- /* ── Input ──────────────────────────────────────────────── */
561
+ ppu_wait_nmi();
562
+ split_after_hud(); /* the sprite-0 split — every frame */
200
563
  sound_music_tick();
201
- pad = pad_poll(0);
202
- if ((pad & PAD_LEFT) && px > 8) px -= MOVE_SPEED;
203
- if ((pad & PAD_RIGHT) && px < 240) px += MOVE_SPEED;
204
- /* Jump on rising-edge of A while on ground. */
564
+
565
+ if (turn_pause) { /* freeze gameplay, keep the frame honest */
566
+ --turn_pause;
567
+ continue;
568
+ }
569
+
570
+ /* ── GAME LOGIC (clay) from here down ──────────────────────────────
571
+ * Input — the CURRENT player's controller (alternating turns: P2 is
572
+ * on controller 2). Past SCROLL_WALL the world scrolls instead of the
573
+ * player (the camera never scrolls back — the classic one-way camera). */
574
+ pad = pad_poll(cur_player);
575
+ delta = 0;
576
+ if (pad & PAD_RIGHT) {
577
+ if (px < SCROLL_WALL) px += MOVE_SPEED;
578
+ else { scroll_x += MOVE_SPEED; delta = MOVE_SPEED; }
579
+ }
580
+ if ((pad & PAD_LEFT) && px > 8) px -= MOVE_SPEED;
205
581
  if ((pad & PAD_A) && !(prev_pad & PAD_A) && on_ground) {
206
582
  vy_q44 = JUMP_VEL_Q44;
207
583
  on_ground = 0;
208
- sound_play_tone(0, 0x150, 6, 6);
584
+ sound_play_tone(0, 0x150, 6, 6); /* jump whoop */
209
585
  }
210
586
  prev_pad = pad;
211
587
 
212
- /* ── Physics ────────────────────────────────────────────── */
213
- /* gravity */
214
- if (vy_q44 < 80) vy_q44 += GRAVITY_Q44; /* terminal velocity */
588
+ /* World objects drift left as the level scrolls (world-anchored). */
589
+ if (delta) {
590
+ dist_sub += delta;
591
+ if (dist_sub >= 64) { /* distance pay */
592
+ dist_sub -= 64;
593
+ ++p_score[cur_player];
594
+ draw_hud();
595
+ }
596
+ for (i = 0; i < NUM_COINS; i++) {
597
+ if (coin_x[i] < 16 + delta) respawn_coin(i);
598
+ else coin_x[i] -= delta;
599
+ }
600
+ for (i = 0; i < NUM_SPIKES; i++) {
601
+ if (!spike_active[i]) continue;
602
+ if (spike_x[i] < 16 + delta) spike_active[i] = 0;
603
+ else spike_x[i] -= delta;
604
+ }
605
+ }
606
+ for (i = 0; i < NUM_SPIKES; i++)
607
+ if (!spike_active[i]) try_spawn_spike(i);
608
+
609
+ /* Physics: gravity + sub-pixel Y. */
610
+ if (vy_q44 < MAX_VY_Q44) vy_q44 += GRAVITY_Q44;
215
611
  py_q44 += vy_q44;
612
+ y8 = (uint8_t)(py_q44 >> 4);
216
613
 
217
- /* ── Ground / platform collision (descending only) ──────── */
218
- on_ground = 0;
219
- if (vy_q44 >= 0) { /* falling */
220
- for (i = 0; i < NUM_PLATFORMS; i++) {
221
- if (landed_on(i, (uint8_t)(py_q44 >> 4))) {
222
- py_q44 = (platforms[i].y - 8) << 4;
223
- vy_q44 = 0;
224
- on_ground = 1;
225
- break;
226
- }
614
+ /* Fell into a pit (below the screen) lose the turn. */
615
+ if (y8 >= 232) {
616
+ kill_player();
617
+ continue;
618
+ }
619
+
620
+ /* Landing — probe the two level columns under the player's feet. */
621
+ if (vy_q44 >= 0) {
622
+ feet = y8 + 8;
623
+ c0 = (uint8_t)(px + scroll_x) >> 3;
624
+ c1 = (uint8_t)(px + scroll_x + 7) >> 3;
625
+ top = land_top(c0, feet);
626
+ if (top == 0) top = land_top(c1, feet);
627
+ if (top) {
628
+ py_q44 = (uint16_t)(top - 8) << 4;
629
+ vy_q44 = 0;
630
+ if (!on_ground) sound_play_tone(1, 0x2A0, 3, 2); /* landing thud */
631
+ on_ground = 1;
632
+ } else {
633
+ on_ground = 0; /* walked off */
227
634
  }
228
635
  }
229
636
 
230
- /* ── Off-bottom respawn at top-left ───────────────────── */
231
- if ((py_q44 >> 4) >= 232) {
232
- px = 24;
233
- py_q44 = 100 << 4;
234
- vy_q44 = 0;
235
- sound_play_noise(8, 8, 8); /* respawn buzz */
637
+ /* Coins (collect) + spikes (death). */
638
+ for (i = 0; i < NUM_COINS; i++) {
639
+ if (dist8(coin_x[i], px) < 8 && dist8(coin_y[i], y8) < 8) {
640
+ p_score[cur_player] += 10;
641
+ sound_play_tone(0, 0x0D6, 8, 5); /* coin ping */
642
+ draw_hud();
643
+ respawn_coin(i);
644
+ }
645
+ }
646
+ killed = 0;
647
+ for (i = 0; i < NUM_SPIKES; i++) {
648
+ if (!spike_active[i]) continue;
649
+ if (dist8(spike_x[i], px) < 7 && dist8(SPIKE_Y, y8) < 7) {
650
+ killed = 1;
651
+ break;
652
+ }
236
653
  }
654
+ if (killed) kill_player();
237
655
  }
238
656
  }