1
0
Fork 0
mirror of https://github.com/imjasonh/nescript synced 2026-07-08 17:06:04 +00:00
nescript/examples/platformer.ne
Claude c5b04c2065
platformer: move HUD from sprite OAM slots to background + sprite-0 split
The status bar now paints into NT row 1 (coin + score digits on
the left, heart + lives digit on the right) using the `bg3`
sub-palette that matches `sp0` pixel-for-pixel. A single OAM
slot-0 anchor sprite sits over the coin tile; its one opaque
pixel lines up with the coin's bottom row so sprite-0 hit fires
at scanline 15, and a trailing `sprite_0_split(camera_x, 0)`
latches the playfield scroll starting at scanline 16. NT rows
0-1 stay pinned while scanlines 16+ scroll with the camera.

Score / lives updates are shadow-compared (`last_score`,
`last_lives`) so the VRAM ring sees an entry only when the
backing state actually changes — most frames append zero bytes.
OAM footprint drops from 5 sprites per frame down to 1.

Tile pipeline gains a 27th entry — a 7-transparent-row + 1-pixel
anchor — so the sprite-0 hit lands on scanline 15 instead of
scanline 8 (the latter would smear the HUD glyphs across the
split). `gen_platformer_tiles.rs` is updated in lockstep.

Ancillary changes: `bg3` retuned from `[yellow, orange,
dk_orange]` to `[red, orange, white]` (matching `sp0`);
`palette_map` row 0 flips from bg0 to bg3; legend gains `o`, `h`,
`0`, `3` so the initial map can preload the static HUD tiles and
the committed nametable already reads "coin 00 ... heart 3" on
frame 0.

`docs/future-work.md` loses the sprite-0 HUD follow-up section
(this commit lands it). Goldens + gif refreshed.
2026-04-20 17:21:20 +00:00

941 lines
34 KiB
Text
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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