// 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: [yellow, orange, dk_orange] // reserved // 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 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 (sprite-only — not referenced by the nametable). The // digit tiles are contiguous so `TILE_DIGIT_0 + n` picks digit `n`. const TILE_DIGIT_0: u8 = 16 const TILE_HEART: u8 = 26 // ── 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 } map: [ "................................", "................................", "................................", "................................", "................................", "....<>..............<>..........", "............<>..................", "................................", "................................", "................................", "................................", "................................", "................................", "................................", "................................", "......##Q#........###...........", "................................", "................................", "................................", "................................", "..^^^.................^^^^......", "..........***..............**...", "================================", "%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%#%", "%%%%%#%%%%%%%%%%%%#%%%%%%%%%%%%%", "%%%%%%%%%%%#%%%%%%%%%%%%#%%%%%%%", "%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%", "%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%", "%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%", "%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%" ] palette_map: [ "0000000000000000", // metatile rows 0-5 → sky sub-palette "0000000000000000", "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 helpers ───────────────────────────────────────────── // Paint the four-sprite status bar at the very top of the screen: // a coin icon followed by a two-digit stomp tally on the left, and // a heart icon followed by a single-digit lives counter on the // right. Everything is drawn as OAM sprites (never the nametable) // so the HUD stays pinned while the background scrolls under it. // y = 16 is above the brick row, so the white digits read cleanly // against the universal-sky backdrop. // Split a 0..99 count into its tens and ones decimal digits via // repeated subtraction — the runtime's software divide has a known // issue that scribbles over state-local memory (tracked in // docs/future-work.md §G), and the HUD is hot enough that the // miscompile sends the state machine spinning. Manual repeated // subtraction is two dozen cycles for the worst case of a two-digit // score, which is cheap for a once-per-frame call. fun tens_digit(n: u8) -> u8 { var t: u8 = 0 var r: u8 = n while r >= 10 { r -= 10 t += 1 } return t } fun ones_digit(n: u8) -> u8 { var r: u8 = n while r >= 10 { r -= 10 } return r } fun draw_hud() { // Score side (left): coin + tens + ones. draw Tileset at: (16, 16) frame: TILE_COIN draw Tileset at: (28, 16) frame: TILE_DIGIT_0 + tens_digit(stomp_count) draw Tileset at: (36, 16) frame: TILE_DIGIT_0 + ones_digit(stomp_count) // Lives side (right): heart + digit. draw Tileset at: (208, 16) frame: TILE_HEART draw Tileset at: (224, 16) frame: 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 ────────────────────────────────────────── // 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 // Status bar (score tally on the left, lives on the right). // Drawn as sprites so it stays pinned while the nametable // scrolls under it. draw_hud() // 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() } // ── PPU scroll latch ───────────────────────────────── // Write the scroll at the very end of the frame so it's // the last $2005 pair before the implicit wait_frame / // NMI handshake, minimizing mid-frame artifacts. scroll(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 // "GAME OVER" marker: a row of enemy tiles across the // middle of the screen, plus a live readout of how many // stomps the player racked up before dying (drawn as // coins right underneath). 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 // Carry the status bar through the death screen so the // final score and the remaining heart total are visible at // the exact same position as in Playing — no awkward jump. draw Tileset at: (16, 16) frame: TILE_COIN draw Tileset at: (28, 16) frame: TILE_DIGIT_0 + tens_digit(stomp_count) draw Tileset at: (36, 16) frame: TILE_DIGIT_0 + ones_digit(stomp_count) draw Tileset at: (208, 16) frame: TILE_HEART draw Tileset at: (224, 16) frame: TILE_DIGIT_0 + lives // 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