// Platformer — an end-to-end side-scrolling demo game that // exercises nearly every subsystem of the NEScript compiler in // one program: custom CHR tiles, a full 32×30 background nametable // with per-region attribute palettes, a multi-tile metasprite // player with gravity/jump physics, wrap-around horizontal // scrolling, moving enemies with stomp-or-die contact logic, a // sprite-based status bar at the top of the viewport (coin icon + // two-digit score on the left, heart icon + lives counter on the // right) that rides along through the death screen, cross-state // life tracking that cycles GameOver → Playing while hearts last // and bounces back to Title when the last heart is spent, // collectible coins, user-declared SFX envelopes, a user-declared // music track, and a title → playing → game-over state machine // driven by both controller input and an autopilot so the jsnes // golden harness (which runs headless with no simulated buttons) // still captures the full gameplay loop inside the first ~6 // seconds of demo. // // Controls (when played by a human): // D-pad left/right — walk // A — jump // Start — on title screen: begin; after game over: retry // // Build: cargo run --release -- build examples/platformer.ne // Output: examples/platformer.nes // // All CHR tile art, nametable bytes, and attribute bytes in this // file are generated by `cargo run --bin gen_platformer_tiles`. // Regenerate them from `scripts/gen_platformer_tiles.rs` if you // want to tweak the level. game "Platformer" { mapper: NROM // Horizontal arrangement means nametable 1 mirrors nametable 0 // horizontally, so our single loaded nametable tiles endlessly // as the camera scrolls right. mirroring: horizontal } // ── Palette ────────────────────────────────────────────────── // Authored in grouped form: one shared `universal:` colour fills // every sub-palette's index-0 slot automatically. That single // declaration is enough to dodge the notorious `$3F10/$3F14/$3F18/ // $3F1C` PPU mirror trap — when a program writes 8 distinct // "index 0" bytes sequentially, the last write clobbers the // shared background colour and the screen turns the wrong colour // (or all-black). Using one shared universal everywhere is what // every shipped NES game does; the parser handles it for us. // // `0x22` is the lavender-blue NES master palette slot Super Mario // Bros uses for its iconic sky. The colour names map to the // curated set documented in `docs/language-guide.md`. palette Main { universal: 0x22 // sky blue (NES $22) // Background sub-palettes bg0: [white, gray, black] // sky / clouds bg1: [red, orange, dk_red] // bricks, Q-blocks bg2: [bright_green, dk_orange, brown] // grass, hills, bushes, dirt bg3: [red, orange, white] // HUD chrome (matches sp0) // Sprite sub-palette 0 — every `draw` uses this one slot // because the OAM attribute byte is always 0 in v0.1. // 1 = red cap, 2 = orange skin, 3 = white highlight. sp0: [red, orange, white] sp1: [brown, dk_orange, orange] // reserved sp2: [neon_green, bright_green, white] // reserved sp3: [yellow, olive, cream] // reserved } // ── Tileset ────────────────────────────────────────────────── // One big sprite declaration holds every custom CHR tile used by // both sprites and the background nametable. `draw Tileset ... // frame: N` overrides the OAM tile-index byte with `N`, so the // same declaration drives the player, enemies, coins, and every // background tile after tile 0. Tile 0 is reserved by the linker // for the built-in smiley — the background leaves it unreferenced. // // All 15 tiles are authored as 8×8 ASCII pixel art and stacked // vertically (1 tile wide × 15 tiles tall) so the parser splits // them into consecutive CHR tile indices in reading order. The // art uses the `.abc` vocabulary (`. = 0`, `a = 1`, `b = 2`, // `c = 3`); regenerate with `cargo run --bin gen_platformer_tiles`. sprite Tileset { pixels: [ // tile 1: Player head L "..aaaaaa", ".aaaaaaa", ".aaabbbb", ".aabbbbc", "aabbcccc", "aabbccbc", "aabbbbbb", "aabbbbbb", // tile 2: Player head R "aaaaaa..", "aaaaaaa.", "bbbbaaa.", "cbbbbbaa", "cccccbbb", "bcbbccbb", "bbbbbbbb", "bbbbbbbb", // tile 3: Player body L ".aaaaaaa", "aaacaaaa", "aaaccaaa", "aaaaaaaa", ".bbbbbbb", ".bbbbbbb", ".bb..bbb", "aaa..bbb", // tile 4: Player body R "aaaaaaa.", "aaaacaaa", "aaaccaaa", "aaaaaaaa", "bbbbbbb.", "bbbbbbb.", "bbb..bb.", "bbb..aaa", // tile 5: Enemy "..aaaa..", ".aaaaaa.", ".aacbaa.", "aacccaaa", "abbccbba", ".aaaaaa.", "a.aaaa.a", "a..aa..a", // tile 6: Coin "...bb...", "..bbbb..", ".bbccbb.", ".bcbbcb.", ".bcbbcb.", ".bbccbb.", "..bbbb..", "...bb...", // tile 7: Grass top "aaaaaaaa", "a.aaa.aa", "aaaaaaaa", "accacaca", "ccccaccc", "cccccccc", "cbccccbc", "cccccccc", // tile 8: Dirt "cccccccc", "cbccccbc", "cccccccc", "ccccbccc", "cccccccc", "cbccccbc", "ccccbccc", "cccccccc", // tile 9: Brick "cccccccc", "caaaaaca", "caaaaaca", "caaaaaca", "cccccccc", "aaacaaaa", "aaacaaaa", "aaacaaaa", // tile 10: Cloud L "........", "...aaa..", "..aaaaa.", ".aaaaaaa", ".aaaaaaa", ".bbaaaaa", "..bbbbba", ".....bbb", // tile 11: Cloud R "........", ".aaa....", "aaaaa...", "aaaaaaa.", "aaaaaaa.", "aaaaabb.", "abbbbb..", "bbb.....", // tile 12: Hill "........", "....aa..", "...aaaa.", "..aaaaaa", "..abaaaa", ".abbabaa", ".aaababa", "aabbabba", // tile 13: Bush "........", "...aa...", "..aaaa..", ".aaaaac.", "aaaaaaca", "aaaaacca", "aaaaacca", "acacacac", // tile 14: Q Block "cccccccc", "caaaaaac", "cabbbbac", "cabccbac", "cabcbbac", "cabbbbac", "caaaaaac", "cccccccc", // tile 15: Sky (blank) "........", "........", "........", "........", "........", "........", "........", "........", // tile 16: Digit 0 "........", "..cccc..", "..c..c..", "..c..c..", "..c..c..", "..c..c..", "..cccc..", "........", // tile 17: Digit 1 "........", "...cc...", "..ccc...", "...cc...", "...cc...", "...cc...", "..cccc..", "........", // tile 18: Digit 2 "........", "..cccc..", ".....c..", "....cc..", "...cc...", "..cc....", "..cccc..", "........", // tile 19: Digit 3 "........", "..cccc..", ".....c..", "...ccc..", ".....c..", ".....c..", "..cccc..", "........", // tile 20: Digit 4 "........", "..c..c..", "..c..c..", "..cccc..", ".....c..", ".....c..", ".....c..", "........", // tile 21: Digit 5 "........", "..cccc..", "..c.....", "..cccc..", ".....c..", "..c..c..", "..cccc..", "........", // tile 22: Digit 6 "........", "..cccc..", "..c.....", "..cccc..", "..c..c..", "..c..c..", "..cccc..", "........", // tile 23: Digit 7 "........", "..cccc..", ".....c..", "....c...", "...c....", "..c.....", "..c.....", "........", // tile 24: Digit 8 "........", "..cccc..", "..c..c..", "..cccc..", "..c..c..", "..c..c..", "..cccc..", "........", // tile 25: Digit 9 "........", "..cccc..", "..c..c..", "..cccc..", ".....c..", ".....c..", "..cccc..", "........", // tile 26: Heart "........", ".aa..aa.", "aaaaaaaa", "aaaaaaaa", ".aaaaaa.", "..aaaa..", "...aa...", "........", // tile 27: Sprite 0 anchor (single opaque pixel at row 7, // col 3 — the sprite-0 hit fires on the last // HUD scanline so the scroll split lands // cleanly on the playfield below) "........", "........", "........", "........", "........", "........", "........", "...c...." ] } // Tile index constants — must match `scripts/gen_platformer_tiles.rs`. const TILE_PLAYER_HL: u8 = 1 const TILE_PLAYER_HR: u8 = 2 const TILE_PLAYER_BL: u8 = 3 const TILE_PLAYER_BR: u8 = 4 const TILE_ENEMY: u8 = 5 const TILE_COIN: u8 = 6 // HUD glyphs. The digit tiles are contiguous so // `TILE_DIGIT_0 + n` picks digit `n`. Digit + heart tiles are // shared between sprite rendering (v0.1 sprites) and background // rendering (the HUD row's `nt_set`s) via the matching `bg3` // sub-palette. const TILE_DIGIT_0: u8 = 16 const TILE_HEART: u8 = 26 // OAM slot-0 anchor — a 7-scanline-tall transparent sprite with // one opaque pixel at row 7. Aligns with the coin tile's bottom // row so sprite-0 hit fires at scanline 15, keeping the scroll // split exactly at the HUD-row boundary. const TILE_SPRITE0_ANCHOR: u8 = 27 // ── Background ────────────────────────────────────────────── // The 32×30 nametable is authored as ASCII art with a `legend` // block naming each tile by a single character. Horizontal // scrolling pans this single nametable via `scroll()` so it only // needs to look interesting at any X offset. // // `palette_map:` is the friendlier alternative to a 64-byte // hand-packed attribute table: 16×15 metatile entries, one // digit per 16×16 metatile, and the parser packs the 2-bit // `(br<<6)|(bl<<4)|(tr<<2)|tl` quadrants automatically. The // three palette regions are sky (`0`), brick row (`1`), and // ground (`2`). background Level { legend { ".": 15 // sky (blank) "<": 10 // cloud left half ">": 11 // cloud right half "#": 9 // brick "Q": 14 // question block "^": 12 // hill silhouette "*": 13 // bush "=": 7 // grass top "%": 8 // dirt // HUD chrome glyphs — row 0 only. Mapped to the sprite // CHR tiles we already declared so sprites and background // render identical glyphs under the matching bg3 palette. "o": 6 // coin icon (HUD score marker) "h": 26 // heart icon (HUD lives marker) "0": 16 // digit 0 (initial score tens/ones + fallback) "3": 19 // digit 3 (initial lives) } // Row 1 carries the status bar: coin + two score digits on the // left, heart + single lives digit on the right. Row 0 stays // sky because the jsnes golden harness (and many emulators) // mask out the top 8 scanlines as overscan — anything painted // there would never show in the committed PNG. Sprite 0 // (painted over column 2's coin tile every frame) overlaps the // `o` glyph's opaque pixels so the sprite-0 hit flag fires on // a scanline inside the HUD row; `sprite_0_split(camera_x, 0)` // in `on frame` picks up that hit and swaps the horizontal // scroll from 0 to `camera_x` for the rest of the frame, // pinning the HUD while the playfield scrolls underneath. The // digits and heart glyph sit at fixed columns so shadow-compare // `nt_set` writes can tick the score / lives readouts without // repainting the whole row. map: [ "................................", "..o.00..................h.3.....", "................................", "................................", "................................", "....<>..............<>..........", "............<>..................", "................................", "................................", "................................", "................................", "................................", "................................", "................................", "................................", "......##Q#........###...........", "................................", "................................", "................................", "................................", "..^^^.................^^^^......", "..........***..............**...", "================================", "%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%#%", "%%%%%#%%%%%%%%%%%%#%%%%%%%%%%%%%", "%%%%%%%%%%%#%%%%%%%%%%%%#%%%%%%%", "%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%", "%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%", "%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%", "%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%" ] palette_map: [ "3333333333333333", // metatile row 0 (NT rows 0-1) // → bg3 (HUD chrome: red / orange / white) "0000000000000000", // metatile rows 1-5 → sky sub-palette "0000000000000000", "0000000000000000", "0000000000000000", "0000000000000000", "1111111111111111", // metatile rows 6-7 → brick sub-palette "1111111111111111", "0000000000000000", // metatile rows 8-9 → sky again "0000000000000000", "2222222222222222", // metatile rows 10-15 → ground sub-palette "2222222222222222", // (rows 15 sits off-screen but the PPU "2222222222222222", // still reads its attribute byte, so "2222222222222222", // we emit it explicitly to match the "2222222222222222", // hand-packed attribute table the "2222222222222222" // old form was using.) ] } // ── Audio ──────────────────────────────────────────────────── // // SFX: scalar `pitch:` matches the v1 driver's latch-once // behaviour — pulse 1's period is sampled exactly once when the // effect triggers and never updated, so a single byte is the // natural form for the current pipeline. `envelope:` is the // friendlier alias for `volume:`. // Custom boingy jump sound — fixed-pitch arc on pulse 1. sfx Boing { duty: 2 pitch: 0x30 envelope: [12, 11, 10, 9, 7, 5, 2] } // Custom coin ding — short rising blip. sfx Ding { duty: 2 pitch: 0x20 envelope: [14, 12, 8, 3] } // Custom looping underworld theme — playful four-bar loop on // pulse 2 that plays while the player is alive. Note names // (C4 / E4 / etc.) plus a default `tempo:` so each note only // has to spell its frame count when it deviates from the beat. music Theme { duty: 2 volume: 9 repeat: true tempo: 10 notes: [ C4, E4, G4, C5, G4, E4, D4 15, rest 5, B3, E4, G4, C5 20, rest 10 ] } // ── Gameplay constants ────────────────────────────────────── const PLAYER_SCREEN_X: u8 = 80 // fixed camera-relative X const GROUND_Y: u8 = 160 // feet land here (y of head) const JUMP_RISE: u8 = 12 // upward frames after take-off const JUMP_PX: u8 = 3 // px moved each rise frame const GRAVITY_CAP: u8 = 4 // terminal fall speed const ENEMY_Y: u8 = 168 // enemies walk on the grass const COIN_Y: u8 = 144 // coins float a bit above // World-space X positions of the moving entities. Wrap-around // arithmetic (u8) makes the level feel like an endless loop. const ENEMY1_WORLD_X: u8 = 100 const ENEMY2_WORLD_X: u8 = 200 const COIN1_WORLD_X: u8 = 50 const COIN2_WORLD_X: u8 = 150 // How many auto-jumps the autopilot will pre-queue per life. Two // is exactly enough to stomp enemy 1 and enemy 2 once each; after // that the autopilot stops assisting so the headless harness gets // to see the player walk into the next enemy, die, and retry. const AUTOPILOT_JUMPS: u8 = 2 // ── Game state ────────────────────────────────────────────── // `frame_tick` is shared: Title reads it to auto-advance, Playing // reads it for animation phasing. `stomp_count` bridges // Playing → GameOver so the death screen can tally coins. The // rest — player physics, camera, liveness, autopilot budget — // are only meaningful while Playing is running, so they live on // Playing's state block and overlay with the Title / GameOver // locals (`blink`, `linger`) at the same bytes. // Cross-state scratch var frame_tick: u8 = 0 // free-running frame counter var stomp_count: u8 = 0 // successful enemy stomps this life var lives: u8 = 3 // lives remaining; HUD heart readout // HUD shadow-compare cache. The initial nametable already paints // "coin 00" / "heart 3" into row 0 with the bg3 palette, so the // shadow values here start matching the on-screen glyphs — every // frame where `stomp_count == last_score` skips the score repaint // entirely, and likewise for lives. 255 would also force a // guaranteed-initial-paint fallback on the first frame if the map // ever drifts from these defaults; we use the real starting values // so the harness's frame-180 golden doesn't capture a transient // paint flicker. var last_score: u8 = 0 var last_lives: u8 = 3 // ── HUD helpers ───────────────────────────────────────────── // Background-row HUD, pinned to the top of the viewport with a // sprite-0 hit split instead of burning five OAM slots every // frame. Two moving pieces: // // 1. The status glyphs — coin, two score digits, heart, lives // digit — live in NT row 0 and are repainted via `nt_set` // only when the backing state changes (`stomp_count`, // `lives`). The initial map already contains the starting // values so the first frame is already correct. // // 2. A single OAM sprite (slot 0) is drawn over the coin tile // every frame. The PPU sets the sprite-0 hit flag when the // sprite's opaque pixels overlap the coin tile's opaque // pixels — guaranteed to fire on some scanline inside the // HUD row — and a `sprite_0_split(camera_x, 0)` call picks // up that hit to switch the horizontal scroll from 0 to // `camera_x` for the rest of the frame. Row 0 stays pinned, // everything below it scrolls with the camera. fun draw_hud() { // Sprite 0 — must be the FIRST draw of the frame so it lands // in OAM slot 0. The anchor tile has one opaque pixel at row // 7, col 3 (a single dot), which when positioned at (16, 7) // in OAM renders at screen (19, 15) and overlaps column 3 of // the coin tile's bottom row in NT row 1. That last-scanline // overlap makes the sprite-0 hit flag set at scanline 15, so // the `sprite_0_split(camera_x, 0)` call below can write // `$2005` before the next HBLANK and the PPU latches the new // horizontal scroll starting at scanline 16 — exactly the // boundary between the HUD row and the playfield. The rest // of the anchor tile is fully transparent, so the sprite is // functionally invisible on top of the coin. draw Tileset at: (16, 7) frame: TILE_SPRITE0_ANCHOR // Score tens / ones — shadow-compare so the VRAM ring only // gets an entry when `stomp_count` actually moved. The // comparison runs once per frame and most frames take the // no-op path (score only ticks on a stomp). if stomp_count != last_score { last_score = stomp_count nt_set(4, 1, TILE_DIGIT_0 + (stomp_count / 10)) nt_set(5, 1, TILE_DIGIT_0 + (stomp_count % 10)) } // Lives digit — same shadow-compare. Only fires on a life // transition (death in `GameOver.on_enter`, refill in // `Title.on_enter`). if lives != last_lives { last_lives = lives nt_set(26, 1, TILE_DIGIT_0 + lives) } } // ── Helper functions ──────────────────────────────────────── // Try to start a jump. Only valid when the player is on the // ground (on_ground == 1); otherwise this is a no-op. fun try_jump() { if on_ground == 1 { rise_count = JUMP_RISE on_ground = 0 fall_vy = 0 play Boing } } // Advance player physics one frame. Handles the jump-rise phase // (upward movement while rise_count > 0) and the gravity fall // phase (increment fall_vy each frame up to GRAVITY_CAP, clamp to // GROUND_Y on landing). fun step_physics() { if rise_count > 0 { // Clamp underflow so a too-high jump doesn't wrap into the // bottom of the screen on an 8-bit subtraction. if player_y >= JUMP_PX { player_y -= JUMP_PX } else { player_y = 0 } rise_count -= 1 } else { player_y += fall_vy if fall_vy < GRAVITY_CAP { fall_vy += 1 } if player_y >= GROUND_Y { player_y = GROUND_Y on_ground = 1 fall_vy = 0 } } } // Draw the 2×2 hero metasprite at (PLAYER_SCREEN_X, player_y). // Each `draw` statement writes a fresh OAM slot via the runtime // cursor, so four calls produce four sprites occupying a 16×16 // block. fun draw_player() { draw Tileset at: (PLAYER_SCREEN_X, player_y ) frame: TILE_PLAYER_HL draw Tileset at: (PLAYER_SCREEN_X + 8, player_y ) frame: TILE_PLAYER_HR draw Tileset at: (PLAYER_SCREEN_X, player_y + 8) frame: TILE_PLAYER_BL draw Tileset at: (PLAYER_SCREEN_X + 8, player_y + 8) frame: TILE_PLAYER_BR } // Resolve contact with an enemy whose screen X is `e_sx`. Mutates // the player's physics / alive flag directly and returns 1 on a // successful stomp, 0 on no contact or a fatal hit. The caller // checks `alive` after invocation to decide whether to keep // running the frame. // // The player's 16×16 hitbox is at ([80, 96), [player_y, player_y+16)). // Each enemy's 8×8 hitbox is at ([e_sx, e_sx+8), [168, 176)). Those // overlap horizontally when e_sx ∈ (72, 96) and overlap vertically // when player_y ∈ (152, 176). // // Outcomes: // - No overlap → no-op // - Overlap while rising (rise_count > 0) → graceful // pass-through. rise_count > 0 only happens right after a // successful stomp bounce (try_jump() clears rise_count by // the end of the same frame) or on the stomp's own upward // leg, which we don't want to retrigger. // - Overlap while falling, feet near enemy head → STOMP: bounce // up, increment `stomp_count`, play Boing // - Any other overlap (walking, on ground) → DEATH: set // `alive = 0` and play the builtin `hit` sfx fun resolve_enemy_hit(e_sx: u8) -> u8 { if e_sx > 72 { if e_sx < 96 { if player_y > 152 { if player_y < 176 { // Real contact. Stomp if falling; otherwise, if // we're not in the post-bounce grace window, // die. if fall_vy > 0 { rise_count = 6 fall_vy = 0 stomp_count += 1 play Boing return 1 } if rise_count == 0 { alive = 0 play hit } } } } } return 0 } // ── States ────────────────────────────────────────────────── state Title { var blink: u8 = 0 on enter { blink = 0 frame_tick = 0 // Fresh run — restore the full life bar so the HUD reads 3 // when Playing kicks in and the GameOver loop doesn't trap // the game in a zero-lives state forever. lives = 3 stomp_count = 0 start_music Theme } on frame { frame_tick += 1 blink += 1 if blink >= 60 { blink = 0 } // Blink a "press start" marker at roughly 0.5 Hz. We use // the coin tile for visual continuity with the game world. if blink < 30 { draw Tileset at: (120, 120) frame: TILE_COIN } // Auto-advance so the jsnes golden harness (which never // presses start) still reaches Playing and captures gameplay // at frame 180. A human player can also press Start to skip // the wait early. if frame_tick >= 20 { transition Playing } if button.start { transition Playing } } } state Playing { // Physics, camera, liveness, and autopilot budget — all of // this is Playing-only. Declaring them inside the state block // lets the analyzer overlay them with Title.blink and // GameOver.linger; each variable's initializer re-runs on // entry, so the retry loop starts each life on the ground // with a fresh autopilot budget without any manual reset. var player_y: u8 = GROUND_Y var on_ground: u8 = 1 var rise_count: u8 = 0 var fall_vy: u8 = 0 var camera_x: u8 = 0 var anim_tick: u8 = 0 var alive: u8 = 1 var auto_jumps: u8 = 0 on enter { frame_tick = 0 stomp_count = 0 start_music Theme } on frame { frame_tick += 1 anim_tick += 1 // ── Input ───────────────────────────────────────────── // Human controls — these work even while the autopilot // is running so you can stomp the demo at any time. if button.a { try_jump() } if button.right { camera_x += 1 // walk forward by scrolling right } if button.left { // Walk back (wraps the camera — the level is cyclic). camera_x -= 1 } // Always advance camera one px per frame (baseline scroll). camera_x += 1 // ── Compute screen positions of world entities ────── var e1_sx: u8 = ENEMY1_WORLD_X - camera_x var e2_sx: u8 = ENEMY2_WORLD_X - camera_x var c1_sx: u8 = COIN1_WORLD_X - camera_x var c2_sx: u8 = COIN2_WORLD_X - camera_x // ── Proximity autopilot ───────────────────────────── // Pre-jump when an enemy is 19 px ahead. The fall phase of // a JUMP_RISE=12, GRAVITY_CAP=4 jump lands the player's // feet at enemy head height ~20 frames later, by which // point the autopilot camera has scrolled the enemy under // the player. Limited to AUTOPILOT_JUMPS hops per life so // the third enemy encounter becomes a scripted death, // giving the golden harness a visible game-over sequence. if auto_jumps < AUTOPILOT_JUMPS { if on_ground == 1 { if e1_sx == 99 { try_jump() auto_jumps += 1 } if e2_sx == 99 { try_jump() auto_jumps += 1 } } } // ── Physics ────────────────────────────────────────── step_physics() // ── Coin pickups ───────────────────────────────────── // Coin collect: player occupies [80..96). A coin is "hit" // when its screen X is in [72..96) (8-px tolerance on each // side of the player's left edge). if c1_sx >= 72 and c1_sx < 96 { if player_y <= COIN_Y + 16 { play Ding } } if c2_sx >= 72 and c2_sx < 96 { if player_y <= COIN_Y + 16 { play Ding } } // ── Enemy collisions ──────────────────────────────── // Both enemies get the full stomp-or-die check; the helper // mutates player state directly. If `alive` flips to 0 // we fall through the rest of the frame (drawing is // guarded below) and transition to GameOver at the end. resolve_enemy_hit(e1_sx) resolve_enemy_hit(e2_sx) // ── Drawing ────────────────────────────────────────── // `draw_hud()` runs FIRST so its coin sprite lands in OAM // slot 0 — that's what makes the PPU's sprite-0 hit flag // fire on the coin-tile overlap inside the HUD row, which // we pick up with `sprite_0_split` at the end of the // frame. Every other `draw` has to come after. draw_hud() // Enemies and coins are always drawn so the scene stays // coherent even on the fatal-contact frame. var ey: u8 = ENEMY_Y if (anim_tick & 16) != 0 { ey = ENEMY_Y - 2 } draw Tileset at: (e1_sx, ey) frame: TILE_ENEMY draw Tileset at: (e2_sx, ENEMY_Y) frame: TILE_ENEMY var cy: u8 = COIN_Y if (anim_tick & 8) != 0 { cy = COIN_Y - 1 } draw Tileset at: (c1_sx, cy) frame: TILE_COIN draw Tileset at: (c2_sx, COIN_Y) frame: TILE_COIN // Player is only drawn while alive — on the dying frame // we show the scene without the hero on top of the enemy // that killed them, which reads as "player got got". if alive == 1 { draw_player() } // ── Sprite-0 scroll split ──────────────────────────── // The VRAM-drain in NMI resets `$2005` to `(0, 0)` for // the top of each frame; `sprite_0_split` busy-waits for // the sprite-0 hit flag (fired mid-HUD-row where OAM // slot 0's coin sprite overlaps the NT-row-1 coin tile) // and then latches `(camera_x, 0)` for the remainder of // the frame. Scanlines above the hit render the HUD // pinned; scanlines below render the playfield scrolled. sprite_0_split(camera_x, 0) // Fatal contact this frame → kick the state machine over // to GameOver on the next frame. if alive == 0 { transition GameOver } } } state GameOver { var linger: u8 = 0 on enter { linger = 0 // Burn a life on entry so the HUD's heart counter ticks // down visibly each death. Guard against underflow in case // something transitions here with lives already at zero. if lives > 0 { lives -= 1 } stop_music } on frame { linger += 1 // Sync the HUD row first (sprite 0 + shadow-compare // `nt_set` for the digits) so the tens/ones glyphs tick // down when `lives` decrements in `on_enter`. No // `sprite_0_split` call here — GameOver doesn't scroll, // so NMI's scroll-reset keeps the whole frame at // `(0, 0)` and the HUD row renders normally. The sprite- // 0 hit flag fires harmlessly and stays latched until the // next Playing frame clears it. draw_hud() // "GAME OVER" marker: a row of enemy tiles across the // middle of the screen. The death-screen backdrop is // whatever Playing last scrolled to — we don't clear it. draw Tileset at: (96, 112) frame: TILE_ENEMY draw Tileset at: (112, 112) frame: TILE_ENEMY draw Tileset at: (128, 112) frame: TILE_ENEMY draw Tileset at: (144, 112) frame: TILE_ENEMY // Auto-retry after ~1 s so the headless harness sees the // full loop. When the last life is spent, bounce back to // the Title screen instead (Title's `on enter` refills the // heart bar for the next run). A human can hit Start to // skip the wait. if linger >= 60 { if lives == 0 { transition Title } else { transition Playing } } if button.start { if lives == 0 { transition Title } else { transition Playing } } } } start Title