examples/war: working end-to-end War card game
A complete, playable port of the card game War: title screen with
0/1/2 player menu, animated deal, sliding cards, deck-count HUD, a
"WAR!" tie-break with buried cards, and a victory screen with a
fanfare. Source split across examples/war/*.ne (constants, assets,
audio, deck/queue logic, RNG, render helpers, and one state file
per game state) and pulled in via examples/war.ne.
Drives nearly every NEScript subsystem at once: custom 88-tile
sprite sheet (card frames, ranks, suits, font, BIG WAR letters);
felt background nametable; pulse-1 / pulse-2 / noise sfx; looping
march on pulse 2; an 8-bit Galois LFSR PRNG; queue-based decks
that conserve cards across rounds; a phase machine inside the
Playing state that handles draw/reveal/win/war/check; and an
autopilot that boots straight into 0-PLAYERS mode so the headless
jsnes harness captures real gameplay at frame 180.
While building this I uncovered five compiler bugs / limitations
in the v0.1 implementation; each is documented with a minimal
reproduction, root cause, current workaround, and proposed fix in
examples/war/COMPILER_BUGS.md. The most painful was the
parameter-VarId aliasing one (#1b) — two functions sharing a
parameter NAME end up sharing a single zero-page slot mapping
across the whole program. Once those compiler bugs are fixed, the
workarounds in war/*.ne should be reverted in the same PR.
https://claude.ai/code/session_0143dTgh3UeRrtfHgQwzcv5z
2026-04-15 15:22:20 +00:00
|
|
|
|
// war/constants.ne — gameplay and layout constants.
|
|
|
|
|
|
//
|
|
|
|
|
|
// Everything that feeds the game's positional layout, animation
|
|
|
|
|
|
// timing, phase machine, or card encoding lives here so one central
|
|
|
|
|
|
// edit can retune the whole game. No code, just `const` entries.
|
|
|
|
|
|
|
|
|
|
|
|
// ── Card encoding ─────────────────────────────────────────
|
|
|
|
|
|
//
|
|
|
|
|
|
// Each card in a deck is packed into one u8:
|
|
|
|
|
|
//
|
|
|
|
|
|
// high nibble = rank, 1..13 (A=1, 2..10, J=11, Q=12, K=13)
|
|
|
|
|
|
// low nibble = suit, 0..3 (0=♠, 1=♥, 2=♦, 3=♣)
|
|
|
|
|
|
//
|
|
|
|
|
|
// Rank nibble lives in bits 7..4 and extracts cleanly via >>4;
|
|
|
|
|
|
// suit nibble lives in bits 3..0 and extracts via &0x0F. No
|
|
|
|
|
|
// division, no multiplication.
|
|
|
|
|
|
const SUIT_SPADE: u8 = 0
|
|
|
|
|
|
const SUIT_HEART: u8 = 1
|
|
|
|
|
|
const SUIT_DIAMOND: u8 = 2
|
|
|
|
|
|
const SUIT_CLUB: u8 = 3
|
|
|
|
|
|
|
|
|
|
|
|
const RANK_ACE: u8 = 1
|
|
|
|
|
|
const RANK_JACK: u8 = 11
|
|
|
|
|
|
const RANK_QUEEN: u8 = 12
|
|
|
|
|
|
const RANK_KING: u8 = 13
|
|
|
|
|
|
|
|
|
|
|
|
const DECK_SIZE: u8 = 52 // standard 52-card deck
|
|
|
|
|
|
const HALF_DECK: u8 = 26 // starting count per player
|
|
|
|
|
|
|
|
|
|
|
|
// ── Tile index base pointers into the Tileset sprite ─────
|
|
|
|
|
|
//
|
|
|
|
|
|
// The Tileset sprite (declared in war/assets.ne) stacks every
|
|
|
|
|
|
// custom tile vertically in reading order, so tile index N in a
|
|
|
|
|
|
// `draw Tileset frame: N` call picks the N'th 8×8 block.
|
|
|
|
|
|
//
|
|
|
|
|
|
// Tile 0 is reserved by the linker for the builtin smiley, so
|
|
|
|
|
|
// every custom tile starts at 1. The ranges below must match the
|
|
|
|
|
|
// order the Tileset body lists them in — if you move a tile,
|
|
|
|
|
|
// move its constant too.
|
|
|
|
|
|
|
|
|
|
|
|
// Card frame (shared across every face-up card)
|
|
|
|
|
|
const TILE_FRAME_TL: u8 = 1 // top-left corner with inset
|
|
|
|
|
|
const TILE_FRAME_TR: u8 = 2 // top-right corner
|
|
|
|
|
|
const TILE_FRAME_BL: u8 = 3 // bottom-left corner
|
|
|
|
|
|
const TILE_FRAME_BR: u8 = 4 // bottom-right corner
|
|
|
|
|
|
const TILE_FRAME_BLANK_L: u8 = 5 // left-half blank cell (white body)
|
|
|
|
|
|
const TILE_FRAME_BLANK_R: u8 = 6 // right-half blank cell
|
|
|
|
|
|
|
|
|
|
|
|
// Card back — 4 tiles arranged as a 2x2 repeating diamond lattice
|
|
|
|
|
|
const TILE_BACK_TL: u8 = 7
|
|
|
|
|
|
const TILE_BACK_TR: u8 = 8
|
|
|
|
|
|
const TILE_BACK_BL: u8 = 9
|
|
|
|
|
|
const TILE_BACK_BR: u8 = 10
|
|
|
|
|
|
|
|
|
|
|
|
// Rank glyphs: one tile per rank, indexed by RANK_TILE_BASE + rank - 1
|
|
|
|
|
|
// so rank 1 (Ace) lands at RANK_TILE_BASE + 0.
|
|
|
|
|
|
const TILE_RANK_BASE: u8 = 11 // 13 tiles: A, 2..10, J, Q, K
|
|
|
|
|
|
|
|
|
|
|
|
// Small suit glyphs for the card corner, one per suit.
|
|
|
|
|
|
const TILE_SUIT_SMALL_BASE: u8 = 24 // 4 tiles: ♠ ♥ ♦ ♣
|
|
|
|
|
|
|
examples/war: redesign card art — opaque bodies, 16x16 pips, checkerboard back
The first-pass card tiles were riddled with palette-0 transparent
pixels that let the felt background bleed through every rank
glyph, small suit, and big pip. At arm's length the cards looked
like they had green specks eating them. This commit rewrites all
of the card art from scratch:
- **Opaque card bodies.** Every pixel inside a card tile is now
either white (palette 2), red (1), or black (3). No `.` values
anywhere on a face or back tile. The green felt only shows
outside the card rectangle, where it should.
- **Readable rank glyphs.** The 13 rank tiles (A, 2-9, 10, J, Q, K)
are now drawn as bold black strokes on a solid white body. The
"holes" of the letters (e.g. the triangle inside an "A") are
white, not transparent.
- **16x16 big pips.** The big centre pip is now a 4-tile (16x16)
shape split into TL/TR/BL/BR quadrants per suit. Previously it
was a 2-tile (16x8) half-height strip that looked cramped. The
TL/TR quadrants kept their existing tile indices (28-35) so the
shift is local; the new BL/BR quadrants are appended after the
BIG WAR letters at tiles 88-95 to avoid renumbering the entire
alphabet / digits / UI tile range.
- **Distinct suit shapes.** Spade is a smooth teardrop with a
short stem and base; heart is two symmetric lobes with a V
bottom; diamond is a clean rhombus; club is three circles
joined over a stem. Side-by-side they are unmistakable.
- **Clean checkerboard card back.** The old card back was a
diamond lattice that had the same transparent-bleed problem
and looked noisy anyway. Replaced with a crisp 2-pixel black-
and-white checkerboard that tiles seamlessly across the card
back's 16x24 footprint.
- **draw_card_face now emits 6 sprites in a rank/suit + 4-tile
big-pip layout.** The previous 6-sprite layout was
`[rank][ssuit] / [pipL][pipR] / [blankL][blankR]`; the new
one is `[rank][ssuit] / [pipTL][pipTR] / [pipBL][pipBR]` with
the bottom row carrying the bottom half of the big pip
instead of being wasted blank tiles.
Also:
- New constants TILE_PIP_TL_BASE / TR / BL / BR replace the old
TILE_PIP_L_BASE / TILE_PIP_R_BASE in constants.ne.
- Refreshed war.nes and the goldens. Every emulator harness
test still passes (31/31).
https://claude.ai/code/session_0143dTgh3UeRrtfHgQwzcv5z
2026-04-15 18:31:15 +00:00
|
|
|
|
// Big centre-pip, authored as a 16×16 shape split into four 8×8
|
|
|
|
|
|
// quadrants per suit. Each `_BASE + suit` picks the tile for that
|
|
|
|
|
|
// suit's quadrant. The TL/TR quadrants are contiguous at 28-35;
|
|
|
|
|
|
// the BL/BR quadrants live at 88-95 because they were added
|
|
|
|
|
|
// after the alphabet / digit / BIG WAR tiles and putting them in
|
|
|
|
|
|
// the middle would have shifted every later tile's index.
|
|
|
|
|
|
const TILE_PIP_TL_BASE: u8 = 28 // spade/heart/diamond/club TL
|
|
|
|
|
|
const TILE_PIP_TR_BASE: u8 = 32 // spade/heart/diamond/club TR
|
|
|
|
|
|
const TILE_PIP_BL_BASE: u8 = 88 // spade/heart/diamond/club BL
|
|
|
|
|
|
const TILE_PIP_BR_BASE: u8 = 92 // spade/heart/diamond/club BR
|
examples/war: working end-to-end War card game
A complete, playable port of the card game War: title screen with
0/1/2 player menu, animated deal, sliding cards, deck-count HUD, a
"WAR!" tie-break with buried cards, and a victory screen with a
fanfare. Source split across examples/war/*.ne (constants, assets,
audio, deck/queue logic, RNG, render helpers, and one state file
per game state) and pulled in via examples/war.ne.
Drives nearly every NEScript subsystem at once: custom 88-tile
sprite sheet (card frames, ranks, suits, font, BIG WAR letters);
felt background nametable; pulse-1 / pulse-2 / noise sfx; looping
march on pulse 2; an 8-bit Galois LFSR PRNG; queue-based decks
that conserve cards across rounds; a phase machine inside the
Playing state that handles draw/reveal/win/war/check; and an
autopilot that boots straight into 0-PLAYERS mode so the headless
jsnes harness captures real gameplay at frame 180.
While building this I uncovered five compiler bugs / limitations
in the v0.1 implementation; each is documented with a minimal
reproduction, root cause, current workaround, and proposed fix in
examples/war/COMPILER_BUGS.md. The most painful was the
parameter-VarId aliasing one (#1b) — two functions sharing a
parameter NAME end up sharing a single zero-page slot mapping
across the whole program. Once those compiler bugs are fixed, the
workarounds in war/*.ne should be reverted in the same PR.
https://claude.ai/code/session_0143dTgh3UeRrtfHgQwzcv5z
2026-04-15 15:22:20 +00:00
|
|
|
|
|
|
|
|
|
|
// Alphanumerics (8×8) used for all on-screen text that lives on the
|
|
|
|
|
|
// sprite layer. Letters A-Z then digits 0-9, contiguous.
|
|
|
|
|
|
const TILE_LETTER_BASE: u8 = 36 // 26 letters: A=+0, Z=+25
|
|
|
|
|
|
const TILE_DIGIT_BASE: u8 = 62 // 10 digits: 0=+0, 9=+9
|
|
|
|
|
|
|
|
|
|
|
|
// UI bits
|
|
|
|
|
|
const TILE_CURSOR: u8 = 72 // right-pointing arrow for menus
|
|
|
|
|
|
const TILE_HEART_TINY: u8 = 73 // tiny pip used as a victory marker
|
|
|
|
|
|
const TILE_DOT: u8 = 74 // single 8x8 card stack marker
|
|
|
|
|
|
const TILE_FELT_BG: u8 = 75 // subtle felt cross-hatch (bg fill)
|
|
|
|
|
|
|
|
|
|
|
|
// Big WAR title letters — each letter is a 2x2 block of tiles
|
|
|
|
|
|
// addressed by its quadrant.
|
|
|
|
|
|
const TILE_BIG_W_TL: u8 = 76
|
|
|
|
|
|
const TILE_BIG_W_TR: u8 = 77
|
|
|
|
|
|
const TILE_BIG_W_BL: u8 = 78
|
|
|
|
|
|
const TILE_BIG_W_BR: u8 = 79
|
|
|
|
|
|
const TILE_BIG_A_TL: u8 = 80
|
|
|
|
|
|
const TILE_BIG_A_TR: u8 = 81
|
|
|
|
|
|
const TILE_BIG_A_BL: u8 = 82
|
|
|
|
|
|
const TILE_BIG_A_BR: u8 = 83
|
|
|
|
|
|
const TILE_BIG_R_TL: u8 = 84
|
|
|
|
|
|
const TILE_BIG_R_TR: u8 = 85
|
|
|
|
|
|
const TILE_BIG_R_BL: u8 = 86
|
|
|
|
|
|
const TILE_BIG_R_BR: u8 = 87
|
|
|
|
|
|
|
|
|
|
|
|
// ── Screen-space layout ──────────────────────────────────
|
|
|
|
|
|
//
|
|
|
|
|
|
// Positions are in pixels. The screen is 256×240.
|
|
|
|
|
|
// Cards are 16 wide × 24 tall (2 cols × 3 rows of 8×8 tiles).
|
|
|
|
|
|
const CARD_W: u8 = 16
|
|
|
|
|
|
const CARD_H: u8 = 24
|
|
|
|
|
|
|
|
|
|
|
|
// Decks sit on the upper half of the play area; face-up cards sit
|
|
|
|
|
|
// on the lower half so the two bands never share scanlines.
|
|
|
|
|
|
//
|
|
|
|
|
|
// The X and Y deltas are deliberately exactly 64 px so the
|
|
|
|
|
|
// per-frame step animation (FLY_STEP * FRAMES_FLY = 4 * 16 = 64)
|
|
|
|
|
|
// lands a flying card exactly on its destination — no rounding,
|
|
|
|
|
|
// no overshoot, no need to clamp at the end.
|
|
|
|
|
|
//
|
|
|
|
|
|
// DECK_A_X -> PLAY_A_X = 32 -> 96 (Δ = +64)
|
|
|
|
|
|
// DECK_B_X -> PLAY_B_X = 208 -> 144 (Δ = -64)
|
|
|
|
|
|
// DECK_Y -> PLAY_Y = 64 -> 128 (Δ = +64)
|
|
|
|
|
|
const DECK_Y: u8 = 64 // top edge of deck card-back sprite
|
|
|
|
|
|
const PLAY_Y: u8 = 128 // top edge of face-up card sprite
|
|
|
|
|
|
|
|
|
|
|
|
const DECK_A_X: u8 = 32 // left deck
|
|
|
|
|
|
const DECK_B_X: u8 = 208 // right deck
|
|
|
|
|
|
const PLAY_A_X: u8 = 96 // centre-left face-up slot
|
|
|
|
|
|
const PLAY_B_X: u8 = 144 // centre-right face-up slot
|
|
|
|
|
|
|
|
|
|
|
|
// War banner is centred horizontally in the middle of the screen.
|
|
|
|
|
|
// Coordinates are the top-left of the first banner sprite.
|
|
|
|
|
|
const BANNER_X: u8 = 104
|
|
|
|
|
|
const BANNER_Y: u8 = 96
|
|
|
|
|
|
|
|
|
|
|
|
// HUD card counts sit above each deck.
|
|
|
|
|
|
const COUNT_A_X: u8 = 32
|
|
|
|
|
|
const COUNT_B_X: u8 = 208
|
|
|
|
|
|
const COUNT_Y: u8 = 56
|
|
|
|
|
|
|
|
|
|
|
|
// ── Animation timing ─────────────────────────────────────
|
|
|
|
|
|
//
|
|
|
|
|
|
// Every animation uses a power-of-two frame count so the lerp
|
|
|
|
|
|
// (delta * t / FRAMES) can be compiled into a shift-right by the
|
|
|
|
|
|
// optimizer, avoiding the software multiply/divide warning.
|
|
|
|
|
|
const FRAMES_FLY: u8 = 16 // card-draw and win-return slides
|
|
|
|
|
|
const FRAMES_BURY: u8 = 8 // faster bury animation during a war
|
|
|
|
|
|
const FRAMES_REVEAL:u8 = 32 // held pause after both cards are up
|
|
|
|
|
|
const FRAMES_BANNER:u8 = 48 // "WAR!" banner dwell
|
|
|
|
|
|
const FRAMES_DEAL_STEP: u8 = 4 // one dealt card every N frames
|
|
|
|
|
|
|
|
|
|
|
|
// Title auto-advance for the headless jsnes harness: the menu
|
|
|
|
|
|
// confirms "0 PLAYERS" automatically after this many frames of no
|
|
|
|
|
|
// input so the golden capture at frame 180 always lands in
|
|
|
|
|
|
// gameplay.
|
|
|
|
|
|
const TITLE_AUTO_FRAMES: u8 = 45
|
|
|
|
|
|
|
|
|
|
|
|
// CPU "thinking" delay before it draws a card.
|
|
|
|
|
|
const CPU_THINK_FRAMES: u8 = 20
|
|
|
|
|
|
|
|
|
|
|
|
// Victory auto-return timer.
|
|
|
|
|
|
const VICTORY_LINGER_FRAMES: u8 = 180
|
|
|
|
|
|
|
|
|
|
|
|
// ── Phase values for the Playing state's inner machine ───
|
|
|
|
|
|
//
|
|
|
|
|
|
// A plain `enum` would collide with other `Title` / `Victory` /
|
|
|
|
|
|
// etc identifiers, so we use explicit constants instead.
|
|
|
|
|
|
const P_WAIT_A: u8 = 0
|
|
|
|
|
|
const P_FLY_A: u8 = 1
|
|
|
|
|
|
const P_WAIT_B: u8 = 2
|
|
|
|
|
|
const P_FLY_B: u8 = 3
|
|
|
|
|
|
const P_REVEAL: u8 = 4
|
|
|
|
|
|
const P_RESOLVE: u8 = 5
|
|
|
|
|
|
const P_WIN_A: u8 = 6
|
|
|
|
|
|
const P_WIN_B: u8 = 7
|
|
|
|
|
|
const P_WAR_BANNER:u8 = 8
|
|
|
|
|
|
const P_WAR_BURY: u8 = 9
|
|
|
|
|
|
const P_CHECK: u8 = 10
|
|
|
|
|
|
|
|
|
|
|
|
// Game-mode bookkeeping: 0 / 1 / 2 players selected from the title.
|
|
|
|
|
|
const MODE_CPU_VS_CPU: u8 = 0
|
|
|
|
|
|
const MODE_HUMAN_VS_CPU: u8 = 1
|
|
|
|
|
|
const MODE_HUMAN_VS_HUMAN: u8 = 2
|