// 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 // coin HUD that tracks live score, 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 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 // ── 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 // ── 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 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 // Live HUD: draw one coin per stomp in the top-left. The // background palette-1 region covers this strip so the // sprite coins read correctly against the brick band. if stomp_count >= 1 { draw Tileset at: (16, 24) frame: TILE_COIN } if stomp_count >= 2 { draw Tileset at: (28, 24) frame: TILE_COIN } if stomp_count >= 3 { draw Tileset at: (40, 24) frame: TILE_COIN } if stomp_count >= 4 { draw Tileset at: (52, 24) 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() } // ── 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 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 if stomp_count >= 1 { draw Tileset at: (96, 128) frame: TILE_COIN } if stomp_count >= 2 { draw Tileset at: (112, 128) frame: TILE_COIN } if stomp_count >= 3 { draw Tileset at: (128, 128) frame: TILE_COIN } if stomp_count >= 4 { draw Tileset at: (144, 128) frame: TILE_COIN } // Auto-retry after ~1 s so the headless harness sees the // full loop. A human can hit Start to retry immediately. if linger >= 60 { transition Playing } if button.start { transition Playing } } } start Title