1
0
Fork 0
mirror of https://github.com/imjasonh/nescript synced 2026-07-15 04:35:37 +00:00

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
This commit is contained in:
Claude 2026-04-15 15:22:20 +00:00
parent 86db78a31f
commit 8ababdcec4
No known key found for this signature in database
18 changed files with 2886 additions and 0 deletions

View file

@ -0,0 +1,435 @@
# NEScript v0.1 — Compiler Bugs and Limitations Found While Building War
This document captures bugs and limitations discovered while
building `examples/war.ne`. Each entry includes a minimal
reproduction, the symptom we observed, the root cause if known,
and a workaround we used in `examples/war/*.ne`. The intent is
to track these so they can be fixed in a future compiler pass —
once they are, the corresponding workarounds in `war/*.ne`
should be reverted to keep the example honest.
---
## 1. Functions with more than 4 parameters silently corrupt the 5th+
### Symptom
Calling a function with 5 or 6 parameters compiles cleanly, with
no warning or error, but at runtime the 5th and 6th parameter
values are silently replaced by garbage (typically the value of
parameter 3 or 4). Animations and state writes that depend on
those parameters behave as if zero was passed.
### Reproduction
```nescript
fun arm_fly(sx: u8, sy: u8, dxsign: u8, dysign: u8, card: u8, fu: u8) {
fly_x = sx
fly_y = sy
fly_dx_sign = dxsign
fly_dy_sign = dysign
fly_card = card // gets the value of dxsign instead!
fly_face_up = fu // gets the value of dxsign instead!
}
fun caller() {
arm_fly(32, 64, 0, 0, 147, 1)
// After this call:
// fly_x = 32, fly_y = 64, fly_dx_sign = 0, fly_dy_sign = 0
// fly_card = 0 (NOT 147)
// fly_face_up = 0 (NOT 1)
}
```
### Root cause
`src/codegen/ir_codegen.rs` (around line 240) iterates through
`func.locals` and assigns the first 4 entries to zero-page
parameter slots `$04`-`$07`:
```rust
for func in &ir.functions {
for (i, local) in func.locals.iter().enumerate() {
if i < func.param_count {
if i < 4 {
var_addrs.insert(local.var_id, 0x04 + i as u16);
...
}
} else {
...
}
}
}
```
The `if i < 4` guard silently drops the mapping for params 5+
without inserting any RAM allocation for them. The corresponding
caller-side codegen for `Call` writes only the first four
arguments. Result: params 5 and 6 are never passed and the
callee reads stale memory from $04-$07 in their place.
### Workaround used in `examples/war/`
`arm_fly` is split: the four "arming" parameters stay in the
function signature, and `fly_card` / `fly_face_up` are written to
the global state directly at every call site instead. See
`war/play_state.ne` (`begin_draw_a` / `begin_draw_b`).
### Fix proposal
Two reasonable options:
1. **Diagnose-only**: emit `E05XX too many parameters` when a
`fun` declaration has more than 4 params. This is the
smallest possible change and turns silent miscompiles into a
loud compile-time error. Should ship immediately even if
option 2 is also planned.
2. **Spill to RAM**: extend the calling convention so params
beyond the first four are passed via dedicated RAM slots in
the callee's local frame. The caller-side `Call` codegen
would write those slots before `JSR`, the callee-side prologue
could leave them as-is. This grows the per-function RAM
footprint but lets users write any signature they like.
---
## 1b. Function parameters with the same name in different functions share a VarId, which collides their zero-page slot mapping
### Symptom
Two unrelated functions whose parameters happen to be named the
same (e.g. both have a `card: u8` parameter, or both have an
`x: u8` parameter) end up reading parameters from the wrong
zero-page slot at runtime. One function reads `$04`, another
reads `$06`, a third reads `$05` — depending on the parameter's
*position* in whichever function is processed last by the
codegen.
This is a much sneakier sibling of bug #1: rather than dropping
a parameter past the 4th slot, it silently reroutes parameter
reads to slots that hold completely unrelated values from the
caller.
### Reproduction
```nescript
// Function A: card is the 1st parameter, expected at $04
fun push_back_a(card: u8) {
deck_a[deck_a_front] = card // reads from $06, not $04!
deck_a_count += 1
}
// Function B: card is the 3rd parameter, expected at $06
fun draw_card_face(x: u8, y: u8, card: u8) {
// ... uses card normally ...
}
```
The IR lowering assigns `card` a single shared `VarId` because
its `var_map` is global across all functions. The codegen then
walks each function in turn, inserting `(VarId(card), $0X)`
mappings into a single global `var_addrs` `HashMap` — and
whichever function comes last in iteration order wins the
mapping. If `draw_card_face` is processed after `push_back_a`,
`VarId(card)` ends up mapped to `$06`, and `push_back_a` then
reads its `card` parameter from `$06` (which holds whatever the
caller was using as a third argument — typically junk).
### Root cause
`src/ir/lowering.rs::get_or_create_var` looks up names in
`self.var_map`, which is shared across the whole program:
```rust
fn get_or_create_var(&mut self, name: &str) -> VarId {
if let Some(&id) = self.var_map.get(name) {
id
} else {
let id = VarId(self.next_var_id);
self.next_var_id += 1;
self.var_map.insert(name.to_string(), id);
id
}
}
```
`lower_function` calls `get_or_create_var(&param.name)` for each
parameter, so two different functions both with a `card`
parameter resolve to the same `VarId`. Once that single `VarId`
flows into the codegen, the per-function "this is param index N
of function F" relationship is lost — there's only one global
mapping per `VarId`.
### Workaround used in `examples/war/`
Every parameter name in the war source is unique across the
entire program. Function-locals were already prefixed by
function (see bug #3); we extended the same scheme to params:
`push_back_a(pba_arg_card: u8)` instead of
`push_back_a(card: u8)`, etc. The wrapping `pba_card` /
`pbb_card` / `dcf_card` snapshots from bug #2 stay because they
also help with the bug-2 clobbering.
### Fix proposal
Two layers to fix in:
1. **IR lowering**: give every function its own `var_map` for
parameters and locals. The global `var_map` should only hold
top-level `var` / `const` / `enum` symbols.
2. **Codegen**: even after the IR fix, the global `var_addrs`
`HashMap` should grow a per-function dimension (one map per
`IrFunction`) so two different functions can independently
assign their own VarIds to overlapping zero-page slots.
Either fix alone is probably enough; both together is robust.
---
## 2. Function parameters share zero-page slots with nested calls — values clobbered across `JSR`
### Symptom
A function that takes parameters and then calls another function
sees its own parameters silently replaced by the inner call's
arguments. Any code path that reads the original parameter
*after* the inner call gets the wrong value.
### Reproduction
```nescript
fun draw_card_face(x: u8, y: u8, card: u8) {
var rank: u8 = card_rank(card) // x at $04 is now `card`
var suit: u8 = card_suit(card) // x at $04 is still `card`
// x is supposed to be 120 here, but it's actually `card`
var x1: u8 = x + 8 // computes card + 8, not 120 + 8
draw Tileset at: (x, y) frame: ... // draws at x = card, not 120
}
```
Concretely, calling `draw_card_face(120, 128, 0x93)` puts the
card sprite at `(0x93, 128)` — completely wrong.
### Root cause
Same allocator as bug #1: `func.locals[0..param_count]` are
mapped to `$04`, `$05`, `$06`, `$07`. The caller writes its own
arguments into the same zero-page slots before `JSR`, so the
caller's parameters at those slots get clobbered by the callee's
arguments. There is no save/restore wrapper around `JSR` and no
spill/reload pass to refresh the caller's parameters from a
backing copy.
### Workaround used in `examples/war/`
Every helper that takes parameters AND makes any nested function
call snapshots its parameters into fresh local variables at the
top of the function, then references the locals exclusively
throughout the body. See `war/render.ne::draw_card_face`,
`war/render.ne::draw_flying_card`, `war/deck.ne::push_back_a`,
`war/deck.ne::push_back_b`.
### Fix proposal
1. **Spill on entry**: at the top of every function body that
makes a call, copy `$04..$07` into per-function RAM slots and
rewrite all parameter reads to load from the RAM copies.
Equivalent to what users are doing manually today.
2. **Smarter scheduling**: only spill a parameter slot if it's
live across a call site (CFG-aware liveness pass on params).
Same effect, less RAM cost for short helpers that never read
their params after calling out.
Either fix would let users write straightforward function bodies
without having to remember the snapshot dance.
---
## 3. Function-local variable names are in a flat global namespace
### Symptom
Two different functions cannot declare locals with the same
name. The compiler emits `E0501 duplicate declaration of '<name>'`
even though the locals are in disjoint scopes.
### Reproduction
```nescript
fun foo() {
var i: u8 = 0
while i < 10 { i += 1 }
}
fun bar() {
var i: u8 = 0 // E0501 duplicate declaration of 'i'
while i < 5 { i += 1 }
}
```
### Root cause
`src/analyzer/mod.rs::register_var` inserts every `var`
declaration into a single `self.symbols` map keyed only on the
variable's name, with no qualification by function or block:
```rust
fn register_var(&mut self, var: &VarDecl) {
if self.symbols.contains_key(&var.name) {
self.diagnostics.push(Diagnostic::error(
ErrorCode::E0501,
format!("duplicate declaration of '{}'", var.name),
var.span,
));
return;
}
...
}
```
`check_statement` calls `register_var` for every `Statement::VarDecl`
encountered while walking function bodies, so all locals across
all functions and all nested blocks land in the same namespace.
### Workaround used in `examples/war/`
Every function-local variable is prefixed with a short tag
identifying its enclosing function (e.g. `dfa_card` in
`draw_front_a`, `pba_slot` in `push_back_a`,
`dwp_px` in `draw_word_player`). This makes long files harder to
read but is fully mechanical.
### Fix proposal
Rework `register_var` to maintain a stack of scopes (one per
function body, one per nested block). Each `Statement::VarDecl`
inserts into the current scope. Lookup walks the stack from
innermost to outermost. The existing global symbol table is
unchanged for top-level globals / consts / fun names; only
function-locals shift to the scoped table.
A smaller intermediate fix: keep the flat table but qualify
each local's stored name as `<function>::<var>` so the global
table sees unique entries even when source names collide.
---
## 4. Per-frame sprite-per-scanline limit is invisible to user code
### Symptom
Drawing more than 8 sprites whose Y rectangles intersect a
single scanline causes the NES PPU to silently drop the excess
sprites past the 8th in OAM order. There's no compile-time
detection and no runtime warning — letters or tiles just don't
render.
### Reproduction
```nescript
// 9 letters all on the same Y row:
draw_letter(0, 100, 0)
draw_letter(8, 100, 1)
draw_letter(16, 100, 2)
draw_letter(24, 100, 3)
draw_letter(32, 100, 4)
draw_letter(40, 100, 5)
draw_letter(48, 100, 6)
draw_letter(56, 100, 7)
draw_letter(64, 100, 8) // this one will not render
```
### Root cause
This is a real NES hardware constraint, not a compiler bug.
However, because NEScript's `draw` allocator is purely
sequential, the compiler cannot warn even when it has all the
information needed to know the layout would overflow.
### Workaround used in `examples/war/`
We staggered text rows. The title screen's "WAR / CARD GAME /
0 PLAYER / 1 PLAYER / 2 PLAYER" layout sits each row at a
different y so no scanline carries more than 7 sprites; the
victory screen's "PLAYER X / WINS" wraps after the player letter
for the same reason.
### Fix proposal
Two complementary improvements:
1. **Static analyzer pass**: walk the IR for each frame handler,
collect the set of `(x, y)` literal pairs feeding `draw`
ops within the same basic block, and emit `W01XX` if any
scanline (8-px row) would have > 8 sprites. Only catches the
literal case but that's the most common.
2. **Sprite-cycling runtime helper**: a `cycle_sprites()`
intrinsic that rotates OAM order each frame so the same
sprites get dropped on different frames, producing a flicker
instead of a permanent dropout. Standard NES technique.
---
## 5. The `inline` keyword is a hint and is silently ignored for short functions
### Symptom
Marking a tiny function `inline fun` does not always inline it.
The compiler still emits a real `JSR` with full parameter
passing through `$04`-`$07`, which means the inlining doesn't
escape the bug-2 parameter clobbering.
### Reproduction
```nescript
inline fun card_rank(card: u8) -> u8 {
return card >> 4
}
```
The asm dump shows `JSR __ir_fn_card_rank` at every call site —
the function was not inlined.
### Root cause
(Inferred — would need to confirm by reading the inliner pass.)
The optimizer's inlining pass has a size threshold or a heuristic
that prevents inlining in some contexts even when the function
is marked `inline`. There's no diagnostic emitted when the hint
is declined.
### Workaround used in `examples/war/`
None — we just live with the JSR overhead and the bug-2 fallout.
### Fix proposal
1. **Promote `inline` to a hard contract**: when `inline` is
present, always inline (or emit `W01XX` if it cannot be
inlined for a structural reason like recursion).
2. **Optional dump**: add `--dump-inliner` to print which
`inline fun` declarations were inlined and which weren't,
with the reason.
---
## Verification path after fixes
Once any of the bugs above are fixed in the compiler, the
corresponding workarounds in `examples/war/*.ne` should be
reverted in the same PR so:
- The example demonstrates idiomatic code, not workaround code.
- The PR's diff visibly proves the fix works end-to-end (the
workaround removal would otherwise be a silent regression).
- The committed `examples/war.nes` rebuilds byte-identically to
the reverted source, which the pre-commit hook enforces.
The relevant workaround sites are catalogued in each bug's
"Workaround used" section above; grep for the prefix tags
(`dcf_`, `dfa_`, `pba_`, `dwp_`, …) to find them all.

856
examples/war/assets.ne Normal file
View file

@ -0,0 +1,856 @@
// war/assets.ne — the one big Tileset sprite block.
//
// Every custom 8×8 CHR tile in the game is stacked vertically inside
// a single `sprite Tileset { pixels: [...] }` declaration. Stacking
// them 1-tile-wide × N-tiles-tall means the parser splits each tile
// into consecutive tile indices in reading order, so tile index N in
// a `draw Tileset frame: N` call picks the N'th 8×8 block.
//
// Every constant in constants.ne points at a position in this list.
// If you add or remove a tile, update BOTH this file AND the matching
// constant range.
//
// The colour vocabulary (per the palette declaration in war.ne):
//
// . transparent
// 1 red (palette index 1 — hearts, diamonds, accents)
// 2 white (palette index 2 — card body, letters)
// 3 black (palette index 3 — outlines, spades, clubs, text)
//
// All tiles below use this 4-colour alphabet. Red is only used for
// red suits; black is used for text, outlines, and black suits.
sprite Tileset {
pixels: [
// ── 01: card frame top-left corner ───────────────
// Rounded top-left corner of a card body. Black outline
// two pixels thick on the exterior, white interior so
// rank glyphs drawn with black pixels read cleanly on
// top. The interior corner is pure white so the rank
// tile (drawn next to it) overpaints naturally.
"..333333",
".3222222",
".3222222",
"3222....",
"3222....",
"3222....",
"3222....",
"3222....",
// ── 02: card frame top-right corner ──────────────
"333333..",
"2222223.",
"2222223.",
"....2223",
"....2223",
"....2223",
"....2223",
"....2223",
// ── 03: card frame bottom-left corner ────────────
"3222....",
"3222....",
"3222....",
"3222....",
"3222....",
".3222222",
".3222222",
"..333333",
// ── 04: card frame bottom-right corner ───────────
"....2223",
"....2223",
"....2223",
"....2223",
"....2223",
"2222223.",
"2222223.",
"333333..",
// ── 05: card frame blank cell — left half ────────
// Pure white interior with a left black edge (for the
// card's left border). Used for the blank middle /
// bottom of any card face that doesn't carry art.
"32222222",
"32222222",
"32222222",
"32222222",
"32222222",
"32222222",
"32222222",
"32222222",
// ── 06: card frame blank cell — right half ───────
"22222223",
"22222223",
"22222223",
"22222223",
"22222223",
"22222223",
"22222223",
"22222223",
// ── 07: card back top-left ───────────────────────
// The back is a black-and-white diamond lattice,
// painted as a 2×2 repeating block. Every card back
// uses all four corners tiled into a 16×16 / 16×24
// block.
"..333333",
".3333333",
".3322333",
"333.2.33",
"33.222.3",
"33.2.233",
"3322.333",
"3333.333",
// ── 08: card back top-right ──────────────────────
"333333..",
"3333333.",
"33322.33",
"33.2.333",
"3.222.33",
"33.2.333",
"333.2233",
"3333.333",
// ── 09: card back bottom-left ────────────────────
"3333.333",
"3322.333",
"33.2.233",
"33.222.3",
"333.2.33",
".3322333",
".3333333",
"..333333",
// ── 10: card back bottom-right ───────────────────
"3333.333",
"333.2233",
"33.2.333",
"3.222.33",
"33.2.333",
"33322.33",
"3333333.",
"333333..",
// ── 11: rank A (Ace) ─────────────────────────────
// Bold black glyph on a white card body. The rank is
// drawn 6×6 px and centred horizontally in the 8×8 tile.
// The card body fills index 2 (white) so the glyph reads
// as black-on-white when the card frame's outline tiles
// wrap it.
"22222222",
"22.33.22",
"2.3333.2",
"2.3..3.2",
"2.3333.2",
"2.3..3.2",
"2.3..3.2",
"22222222",
// ── 12: rank 2 ───────────────────────────────────
"22222222",
"2.3333.2",
"2....3.2",
"22.33.22",
"2.3...22",
"2.3...22",
"2.3333.2",
"22222222",
// ── 13: rank 3 ───────────────────────────────────
"22222222",
"2.3333.2",
"22...3.2",
"22.33.22",
"22...3.2",
"2....3.2",
"2.3333.2",
"22222222",
// ── 14: rank 4 ───────────────────────────────────
"22222222",
"2.3..3.2",
"2.3..3.2",
"2.3..3.2",
"2.3333.2",
"22...3.2",
"22...3.2",
"22222222",
// ── 15: rank 5 ───────────────────────────────────
"22222222",
"2.3333.2",
"2.3...22",
"2.3333.2",
"22...3.2",
"22...3.2",
"2.3333.2",
"22222222",
// ── 16: rank 6 ───────────────────────────────────
"22222222",
"2.3333.2",
"2.3...22",
"2.3333.2",
"2.3..3.2",
"2.3..3.2",
"2.3333.2",
"22222222",
// ── 17: rank 7 ───────────────────────────────────
"22222222",
"2.3333.2",
"22...3.2",
"22..3.22",
"22.3..22",
"22.3..22",
"22.3..22",
"22222222",
// ── 18: rank 8 ───────────────────────────────────
"22222222",
"2.3333.2",
"2.3..3.2",
"2.3333.2",
"2.3..3.2",
"2.3..3.2",
"2.3333.2",
"22222222",
// ── 19: rank 9 ───────────────────────────────────
"22222222",
"2.3333.2",
"2.3..3.2",
"2.3..3.2",
"2.3333.2",
"22...3.2",
"2.3333.2",
"22222222",
// ── 20: rank 10 (condensed "1" + "0") ─────────────
// "10" squeezed into an 8x8 tile. The "1" takes columns
// 0-1, a gap, the "0" takes columns 4-7.
"22222222",
"2.3.33.2",
"233.3..3",
"2.3.3..3",
"2.3.3..3",
"2.3.3..3",
"2.3.33.2",
"22222222",
// ── 21: rank J ───────────────────────────────────
"22222222",
"222.33.2",
"222..3.2",
"222..3.2",
"222..3.2",
"22.3.3.2",
"22.333.2",
"22222222",
// ── 22: rank Q ───────────────────────────────────
"22222222",
"2.3333.2",
"2.3..3.2",
"2.3..3.2",
"2.3.33.2",
"2.3333.2",
"22.33.32",
"22222222",
// ── 23: rank K ───────────────────────────────────
"22222222",
"2.3..3.2",
"2.3.3.22",
"2.33..22",
"2.3.3.22",
"2.3..3.2",
"2.3..3.2",
"22222222",
// ── 24: small spade ♠ ────────────────────────────
// 6x6 centred pip on a white 8x8 tile.
"22222222",
"2222.222",
"222.3.22",
"22.333.2",
"2.33333.",
"2.33333.",
"222.3.22",
"222333.2",
// ── 25: small heart ♥ ────────────────────────────
// Red pip on white.
"22222222",
"22.11.22",
"2.1111.2",
"2.1111.2",
"2.1111.2",
"22.11.22",
"222.1.22",
"22222222",
// ── 26: small diamond ♦ ──────────────────────────
"22222222",
"22221222",
"2221.122",
"221...12",
"221...12",
"2221.122",
"22221222",
"22222222",
// ── 27: small club ♣ ─────────────────────────────
"22222222",
"2222.222",
"2223332.",
"2223332.",
"22.333.2",
"2.33333.",
"22.3.3.2",
"222333.2",
// ── 28: big spade — left half ────────────────────
// The big centre-pip is drawn 16×8: two tiles side by
// side. Each suit's (left half, right half) pair is
// authored explicitly so the centre art can be bolder
// and more symmetric than the corner pips.
"2222222.",
"222222.3",
"22222.33",
"2222.333",
"222.3333",
"22.33333",
"2.333333",
".3333333",
// ── 29: big heart — left half ────────────────────
"2222.111",
"222.1111",
"22.11111",
"22.11111",
"22.11111",
"222.1111",
"2222.111",
"22222.11",
// ── 30: big diamond — left half ──────────────────
"22222222",
"22222.12",
"2222.112",
"222.1112",
"22.11112",
"2.111112",
"22.11112",
"222.1112",
// ── 31: big club — left half ─────────────────────
"2222.333",
"2222.333",
"222.3333",
"22.33333",
"2.333333",
"2.333333",
"22.333.3",
"2223.333",
// ── 32: big spade — right half ───────────────────
".3333333",
"2.333333",
"22.33333",
"222.3333",
"2222.333",
"22222.33",
"222222.3",
"2222222.",
// ── 33: big heart — right half ───────────────────
"111.2222",
"1111.222",
"11111.22",
"11111.22",
"11111.22",
"1111.222",
"111.2222",
"11.22222",
// ── 34: big diamond — right half ─────────────────
"22222222",
"21.22222",
"211.2222",
"2111.222",
"21111.22",
"211111.2",
"21111.22",
"2111.222",
// ── 35: big club — right half ────────────────────
"333.2222",
"333.2222",
"3333.222",
"33333.22",
"333333.2",
"333333.2",
"3.333.22",
"333.3222",
// ── 36-61: alphabet A..Z (8x8, white on transparent) ─
// Bold sans-serif letters drawn with 2-pixel strokes so
// they still read over the felt at NES resolution. Every
// letter uses palette index 2 (white) on a transparent
// background. Letters are 6 wide, centred horizontally
// in the 8-wide tile with a 1-pixel top/bottom margin.
// A
"........",
"..2222..",
".22..22.",
".22..22.",
".222222.",
".22..22.",
".22..22.",
"........",
// B
"........",
".22222..",
".22..22.",
".22222..",
".22..22.",
".22..22.",
".22222..",
"........",
// C
"........",
"..22222.",
".22..22.",
".22.....",
".22.....",
".22..22.",
"..22222.",
"........",
// D
"........",
".2222...",
".22.22..",
".22..22.",
".22..22.",
".22.22..",
".2222...",
"........",
// E
"........",
".222222.",
".22.....",
".22222..",
".22.....",
".22.....",
".222222.",
"........",
// F
"........",
".222222.",
".22.....",
".22222..",
".22.....",
".22.....",
".22.....",
"........",
// G
"........",
"..22222.",
".22.....",
".22.222.",
".22..22.",
".22..22.",
"..2222..",
"........",
// H
"........",
".22..22.",
".22..22.",
".222222.",
".22..22.",
".22..22.",
".22..22.",
"........",
// I
"........",
"..2222..",
"...22...",
"...22...",
"...22...",
"...22...",
"..2222..",
"........",
// J
"........",
"....222.",
".....22.",
".....22.",
".....22.",
".22..22.",
"..2222..",
"........",
// K
"........",
".22..22.",
".22.22..",
".2222...",
".22.22..",
".22..22.",
".22..22.",
"........",
// L
"........",
".22.....",
".22.....",
".22.....",
".22.....",
".22.....",
".222222.",
"........",
// M
"........",
".22..22.",
".222222.",
".222222.",
".22..22.",
".22..22.",
".22..22.",
"........",
// N
"........",
".22..22.",
".222.22.",
".222222.",
".22.222.",
".22..22.",
".22..22.",
"........",
// O
"........",
"..2222..",
".22..22.",
".22..22.",
".22..22.",
".22..22.",
"..2222..",
"........",
// P
"........",
".22222..",
".22..22.",
".22..22.",
".22222..",
".22.....",
".22.....",
"........",
// Q
"........",
"..2222..",
".22..22.",
".22..22.",
".22.222.",
".22.22..",
"..222.2.",
"........",
// R
"........",
".22222..",
".22..22.",
".22..22.",
".22222..",
".22.22..",
".22..22.",
"........",
// S
"........",
"..22222.",
".22.....",
"..2222..",
".....22.",
".....22.",
".22222..",
"........",
// T
"........",
".222222.",
"...22...",
"...22...",
"...22...",
"...22...",
"...22...",
"........",
// U
"........",
".22..22.",
".22..22.",
".22..22.",
".22..22.",
".22..22.",
"..2222..",
"........",
// V
"........",
".22..22.",
".22..22.",
".22..22.",
".22..22.",
"..2222..",
"...22...",
"........",
// W
"........",
".22..22.",
".22..22.",
".22..22.",
".222222.",
".222222.",
".22..22.",
"........",
// X
"........",
".22..22.",
"..2222..",
"...22...",
"...22...",
"..2222..",
".22..22.",
"........",
// Y
"........",
".22..22.",
".22..22.",
"..2222..",
"...22...",
"...22...",
"...22...",
"........",
// Z
"........",
".222222.",
".....22.",
"....22..",
"...22...",
"..22....",
".222222.",
"........",
// ── 62-71: digits 0..9 (bold) ──────────────────
// 0
"........",
"..2222..",
".22..22.",
".22..22.",
".22..22.",
".22..22.",
"..2222..",
"........",
// 1
"........",
"...22...",
"..222...",
".2.22...",
"...22...",
"...22...",
"..2222..",
"........",
// 2
"........",
"..2222..",
".22..22.",
".....22.",
"....22..",
"..22....",
".222222.",
"........",
// 3
"........",
"..2222..",
".22..22.",
"....22..",
".....22.",
".22..22.",
"..2222..",
"........",
// 4
"........",
"....22..",
"...222..",
"..2.22..",
".22.22..",
".222222.",
"....22..",
"........",
// 5
"........",
".222222.",
".22.....",
".22222..",
".....22.",
".22..22.",
"..2222..",
"........",
// 6
"........",
"..2222..",
".22.....",
".22222..",
".22..22.",
".22..22.",
"..2222..",
"........",
// 7
"........",
".222222.",
".....22.",
"....22..",
"...22...",
"..22....",
"..22....",
"........",
// 8
"........",
"..2222..",
".22..22.",
"..2222..",
".22..22.",
".22..22.",
"..2222..",
"........",
// 9
"........",
"..2222..",
".22..22.",
".22..22.",
"..22222.",
".....22.",
"..2222..",
"........",
// ── 72: cursor (▶) ────────────────────────────
"........",
"..2.....",
"..22....",
"..222...",
"..222...",
"..22....",
"..2.....",
"........",
// ── 73: tiny heart (victory marker) ───────────
"........",
".11..11.",
"11111111",
"11111111",
".111111.",
"..1111..",
"...11...",
"........",
// ── 74: single dot (stack marker / spacer) ────
"........",
"...22...",
"..2222..",
"..2222..",
"..2222..",
"..2222..",
"...22...",
"........",
// ── 75: felt background fill ─────────────────
// Subtle cross-hatch on the felt. When the background
// layer renders this tile through bg sub-palette 0
// (universal dk_green + forest/green/mint), index 0
// becomes dk_green and index 3 becomes mint — so the
// tile shows as dk_green felt with sparse mint flecks.
// The same CHR rendered as a sprite would show as
// transparent + black, which we never do.
"........",
"....3...",
"........",
"..3.....",
"........",
"......3.",
"........",
"...3....",
// ── 76-87: BIG WAR title letters ─────────────
// Each letter is 16×16 = a 2x2 block of tiles. The
// letters are designed bold and chunky so the title
// reads from across the room. Each tile draws a
// quadrant of the letter using palette index 2 (white
// when rendered via sp0, white when rendered via the
// bg cream sub-palette).
//
// 76: BIG W top-left
"22....22",
"22....22",
"22....22",
"22....22",
"22....22",
"22....22",
"22....22",
"22....22",
// 77: BIG W top-right
"22....22",
"22....22",
"22....22",
"22....22",
"22....22",
"22....22",
"22....22",
"22....22",
// 78: BIG W bottom-left
"22....22",
"22....22",
"22.22.22",
"22.22.22",
"22.22.22",
"2222.222",
"2222.222",
".22..22.",
// 79: BIG W bottom-right
"22....22",
"22....22",
"22.22.22",
"22.22.22",
"22.22.22",
"222.2222",
"222.2222",
".22..22.",
// 80: BIG A top-left
"....2222",
"...22222",
"..222222",
".2222.22",
"2222..22",
"2222..22",
"222....2",
"222....2",
// 81: BIG A top-right
"2222....",
"22222...",
"222222..",
"22.2222.",
"22..2222",
"22..2222",
"2....222",
"2....222",
// 82: BIG A bottom-left
"22222222",
"22222222",
"22222222",
"222....2",
"222....2",
"222....2",
"222....2",
"222....2",
// 83: BIG A bottom-right
"22222222",
"22222222",
"22222222",
"2....222",
"2....222",
"2....222",
"2....222",
"2....222",
// 84: BIG R top-left
"22222222",
"22222222",
"222....2",
"222....2",
"222....2",
"222....2",
"22222222",
"22222222",
// 85: BIG R top-right
"2222....",
"22222...",
"..222...",
"..222...",
"..222...",
"..2222..",
".22222..",
"22222...",
// 86: BIG R bottom-left
"22222222",
"22222222",
"222.2222",
"222..222",
"222...22",
"222...22",
"222....2",
"222....2",
// 87: BIG R bottom-right
"22......",
"222.....",
"2222....",
"22222...",
"222222..",
"2222222.",
"22222222",
".2222222"
]
}

84
examples/war/audio.ne Normal file
View file

@ -0,0 +1,84 @@
// war/audio.ne — every sfx + music declaration in the game.
//
// The NEScript audio driver is only linked in when user code
// contains at least one `play` / `start_music` / `stop_music`
// statement. That's always true for this game, so everything
// declared here ends up in PRG ROM.
//
// Channel budget:
// pulse 1 — sfx (FlipCard, CheerA, CheerB, WarFlash)
// pulse 2 — music (TitleTheme and the builtin fanfare)
// triangle — unused (reserved for later)
// noise — ThudDown bury sfx
// ── Sound effects ──────────────────────────────────────────
// Sharp descending click for a card flip / draw. Kept short so
// repeated clicks during the deal animation don't overlap.
sfx FlipCard {
duty: 1
pitch: 0x28
envelope: [12, 9, 5, 2]
}
// Cheerful ascending arpeggio when player A wins a round. The
// per-frame pitch envelope sweeps through three notes so the
// effect actually sounds like a melody rather than a beep.
sfx CheerA {
duty: 2
pitch: [0x50, 0x50, 0x50, 0x40, 0x40, 0x40, 0x30, 0x30, 0x30, 0x28]
volume: [14, 13, 12, 14, 13, 12, 14, 13, 12, 10]
}
// Same shape but descending for player B — immediately
// distinguishable from CheerA without reading the screen.
sfx CheerB {
duty: 2
pitch: [0x30, 0x30, 0x30, 0x40, 0x40, 0x40, 0x50, 0x50, 0x50, 0x60]
volume: [14, 13, 12, 14, 13, 12, 14, 13, 12, 10]
}
// Exciting two-part pitch sweep for the "WAR!" tie-break. A
// rising trill followed by a descending burst. The volume ramps
// loud-soft-loud to really stand out from the calmer round sfx.
sfx WarFlash {
duty: 3
pitch: [0x80, 0x60, 0x40, 0x30, 0x20, 0x20, 0x30, 0x40, 0x60, 0x80, 0x60, 0x40, 0x20, 0x20, 0x20, 0x20]
volume: [15, 13, 11, 9, 8, 10, 12, 14, 15, 13, 11, 9, 7, 5, 3, 1]
}
// Low noise thump for each card buried during a war.
sfx ThudDown {
channel: noise
pitch: 8
volume: [15, 11, 7, 3]
}
// ── Music ──────────────────────────────────────────────────
// Brisk 4/4 march on pulse 2 for the title screen. The pattern is
// a military-style tonic-dominant alternation (C - G - C - G) with
// a quick triplet pickup into each down-beat, evoking the rolling
// snare of a war drum. Every note is short and staccato so the
// melody feels like it's being played on a single high-pitched
// fife over an implied drum line.
//
// Four bars (two repeats of a two-bar phrase) that loop
// seamlessly.
music TitleTheme {
duty: 2
volume: 10
repeat: true
tempo: 8
notes: [
// bar 1 — C major down-beat with pickup triplet
G4 4, G4 4, C5 8, G4 8, E4 8, C4 8,
// bar 2 — dominant with rising triplet
G4 4, G4 4, G4 8, C5 8, E5 8, G4 8,
// bar 3 — tonic inversion, military flourish
C5 4, G4 4, E5 8, C5 8, G4 8, E4 8,
// bar 4 — resolution back to tonic
G4 4, G4 4, C5 8, G4 8, C4 16,
rest 8
]
}

View file

@ -0,0 +1,57 @@
// war/background.ne — the static 32×30 nametable loaded at reset.
//
// NEScript loads the *first* declared `background` into nametable 0
// before rendering is enabled, so whatever this file declares is
// visible on frame 0 of the ROM. We use a single felt-table
// background: a solid dark-green field for the play area. All UI
// (title banner, "PRESS A", win banners, etc.) is drawn on top via
// sprites so we never pay the cost of a full mid-frame nametable
// swap.
//
// Tile 0 is the linker's builtin smiley — we don't want that in the
// game, but it's safe to leave in the top-left corner because the
// whole nametable is painted with a blank tile. Use the legend's
// `.` entry to map every visible cell to tile 0 then rely on
// sub-palette 0 (forest/green/mint) making it look like felt.
//
// The `palette_map:` field is omitted on purpose: every attribute
// byte defaults to 0, which selects bg sub-palette 0 for every
// metatile. That's the felt palette.
background Felt {
legend {
".": 75 // TILE_FELT_BG — sparse mint flecks on dk_green
}
map: [
"................................",
"................................",
"................................",
"................................",
"................................",
"................................",
"................................",
"................................",
"................................",
"................................",
"................................",
"................................",
"................................",
"................................",
"................................",
"................................",
"................................",
"................................",
"................................",
"................................",
"................................",
"................................",
"................................",
"................................",
"................................",
"................................",
"................................",
"................................",
"................................",
"................................"
]
}

30
examples/war/compare.ne Normal file
View file

@ -0,0 +1,30 @@
// war/compare.ne — card rank/suit extraction and round resolution.
//
// Cards are packed as `(rank << 4) | suit`; the extractors are
// one shift or one mask each.
//
// Parameter names are unique across the entire program — see
// COMPILER_BUGS.md §1b for why same-named params in different
// functions silently corrupt each other through shared VarIds.
inline fun card_rank(crk_c: u8) -> u8 {
return crk_c >> 4
}
inline fun card_suit(csu_c: u8) -> u8 {
return csu_c & 0x0F
}
// Compare two cards by rank. Returns:
// 1 if A wins, 2 if B wins, 0 if they tie
fun compare_cards(cmp_a: u8, cmp_b: u8) -> u8 {
var cmp_ra: u8 = card_rank(cmp_a)
var cmp_rb: u8 = card_rank(cmp_b)
if cmp_ra > cmp_rb {
return 1
}
if cmp_rb > cmp_ra {
return 2
}
return 0
}

170
examples/war/constants.ne Normal file
View file

@ -0,0 +1,170 @@
// 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: ♠ ♥ ♦ ♣
// Big centre-pip, authored as a 16×8 pair per suit (left half + right half).
const TILE_PIP_L_BASE: u8 = 28 // 4 tiles
const TILE_PIP_R_BASE: u8 = 32 // 4 tiles
// 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

View file

@ -0,0 +1,88 @@
// war/deal_state.ne — the Deal state.
//
// Shuffles the deck on entry, then runs a brief dealing animation
// before transitioning into Playing. The animation shows a single
// face-down card sprite alternating between A's deck and B's deck
// while a FlipCard sfx clicks on each dealt step. The deck counts
// tick up alongside so it looks like the stacks are actually
// growing.
//
// This state is short (52 * FRAMES_DEAL_STEP / 2 = 104 frames per
// full deal at FRAMES_DEAL_STEP = 4). The jsnes harness captures
// at frame 180 so it will see roughly "halfway through the deal"
// unless we accelerate things. We compromise by dealing one card
// every 2 frames so the full deal lasts ~52 frames, giving
// Playing time to show meaningful content before frame 180.
state Deal {
on enter {
init_and_shuffle_decks()
// Visually pretend both decks start empty and grow during
// the animation — we animate a `visible_count` counter
// on each side. The underlying deck_*_count starts at
// HALF_DECK after init_and_shuffle_decks; we override the
// on-screen count via deal_next.
deal_next = 0
deal_timer = 0
}
on frame {
global_tick += 1
deal_timer += 1
// ── Dealing tick ─────────────────────────────────
// Deal one card every 2 frames until we've laid down
// all 52. Play a FlipCard sfx on each dealt step for
// the rhythmic click.
if deal_timer >= 2 {
deal_timer = 0
if deal_next < DECK_SIZE {
deal_next += 1
play FlipCard
}
}
// ── Rendering ────────────────────────────────────
// Draw the "table" furniture: two deck stacks in their
// resting position and the running card counts. The
// deal_next counter controls how many of the 52 have
// "landed", so the first half goes to A and the second
// half to B — matching the actual split_decks() logic.
var dl_dealt_a: u8 = deal_next
var dl_dealt_b: u8 = 0
if dl_dealt_a > HALF_DECK {
dl_dealt_b = dl_dealt_a - HALF_DECK
dl_dealt_a = HALF_DECK
}
// Both decks drawn as card backs whenever they have at
// least one card. Before that, skip the draw so the slot
// is empty.
if dl_dealt_a > 0 {
draw_card_back(DECK_A_X, DECK_Y)
}
if dl_dealt_b > 0 {
draw_card_back(DECK_B_X, DECK_Y)
}
draw_count(COUNT_A_X, COUNT_Y, dl_dealt_a)
draw_count(COUNT_B_X, COUNT_Y, dl_dealt_b)
// ── Flying card ──────────────────────────────────
// A single face-down card bouncing between the centre
// and each deck. The x position alternates based on the
// low bit of deal_next (even → going to A, odd → B).
if deal_next < DECK_SIZE {
var dl_fly_x: u8 = DECK_A_X + 32
if (deal_next & 1) != 0 {
dl_fly_x = DECK_B_X - 32
}
draw_card_back(dl_fly_x, 96)
}
// ── Transition ───────────────────────────────────
if deal_next >= DECK_SIZE {
transition Playing
}
}
}

188
examples/war/deck.ne Normal file
View file

@ -0,0 +1,188 @@
// war/deck.ne — queue operations on the three u8[52] buffers.
//
// Each of deck_a, deck_b, and pot is a 52-entry circular buffer
// storing packed rank/suit bytes. The front index is the next
// card to draw; the count is the number of cards currently in
// the buffer. Everything wraps mod 52 — since 52 isn't a power
// of two, we use `if idx >= 52 { idx -= 52 }` instead of `%` to
// avoid the expensive software mod routine.
//
// NEScript v0.1 has a flat global symbol table for every `var`
// declaration (function-locals included), so each function's
// locals are prefixed with the function's short name to avoid
// E0501 collisions across the program. Function parameters ARE
// scoped per-function, so we can keep their names short.
// ── Helpers shared by every deck ──────────────────────────
//
// Wrap a (front + count) sum back into the 0..51 range. Only
// needed inside push_back because `front` and `count` are both
// ≤ 51 by construction, so the sum is at most 102 and one
// subtraction is enough.
//
// Every parameter name in this file is unique across the entire
// program. NEScript v0.1's IR lowering uses a single global
// var_map for parameter names, so two functions both named
// `wrap52(v: u8)` and `another(v: u8)` end up sharing a VarId
// and the codegen routes their parameter reads to whichever
// zero-page slot the LAST function to be lowered claimed. See
// COMPILER_BUGS.md §1b for the gory details.
inline fun wrap52(w52_v: u8) -> u8 {
if w52_v >= DECK_SIZE {
return w52_v - DECK_SIZE
}
return w52_v
}
// ── deck_a ────────────────────────────────────────────────
fun deck_a_empty() -> u8 {
if deck_a_count == 0 {
return 1
}
return 0
}
fun draw_front_a() -> u8 {
var dfa_card: u8 = deck_a[deck_a_front]
deck_a_front = wrap52(deck_a_front + 1)
deck_a_count -= 1
return dfa_card
}
fun push_back_a(pba_in: u8) {
// Snapshot `pba_in` into a local before calling wrap52,
// because NEScript v0.1's parameter-passing ABI uses fixed
// zero-page slots — wrap52's first param shares slot $04
// with our `pba_in` and would silently clobber it.
var pba_card: u8 = pba_in
var pba_slot: u8 = wrap52(deck_a_front + deck_a_count)
deck_a[pba_slot] = pba_card
deck_a_count += 1
}
// ── deck_b ────────────────────────────────────────────────
fun deck_b_empty() -> u8 {
if deck_b_count == 0 {
return 1
}
return 0
}
fun draw_front_b() -> u8 {
var dfb_card: u8 = deck_b[deck_b_front]
deck_b_front = wrap52(deck_b_front + 1)
deck_b_count -= 1
return dfb_card
}
fun push_back_b(pbb_in: u8) {
var pbb_card: u8 = pbb_in
var pbb_slot: u8 = wrap52(deck_b_front + deck_b_count)
deck_b[pbb_slot] = pbb_card
deck_b_count += 1
}
// ── pot ───────────────────────────────────────────────────
fun push_back_pot(pbp_in: u8) {
pot[pot_count] = pbp_in
pot_count += 1
}
fun clear_pot() {
pot_count = 0
}
// Transfer every card currently in the pot into deck_a, in FIFO
// order (so the face-up and face-down cards layer naturally).
fun pot_to_a() {
var pta_i: u8 = 0
while pta_i < pot_count {
push_back_a(pot[pta_i])
pta_i += 1
}
pot_count = 0
}
fun pot_to_b() {
var ptb_i: u8 = 0
while ptb_i < pot_count {
push_back_b(pot[ptb_i])
ptb_i += 1
}
pot_count = 0
}
// ── Init + shuffle ────────────────────────────────────────
//
// Build a 52-card "master deck" in deck_a's backing array using
// a rank-major loop: for each rank 1..13, each suit 0..3, write
// the packed byte (rank << 4) | suit into deck_a[i]. Then
// bounded-random-swap-shuffle it. Finally, split the first 26
// into deck_b (copied), leaving the second 26 in deck_a's first
// half, and reset both queues' cursors.
//
// The random-swap shuffle is a bounded alternative to
// Fisher-Yates: it does N swaps between two random indices, where
// each index is rand() & 0x3F (0..63) and the swap is only done
// when both indices are < 52. 200 iterations on a 52-card deck is
// empirically well-mixed and uses only bitwise ops (no multiply,
// no divide, no W0101 warning).
fun build_master_deck() {
var bmd_r: u8 = 1
var bmd_i: u8 = 0
while bmd_r <= RANK_KING {
var bmd_s: u8 = 0
while bmd_s < 4 {
// Pack rank into the high nibble, suit into the low.
// rank fits in 4 bits (max 13) and suit fits in 2
// bits, so the shift-and-or is exact.
var bmd_shifted: u8 = bmd_r << 4
deck_a[bmd_i] = bmd_shifted | bmd_s
bmd_i += 1
bmd_s += 1
}
bmd_r += 1
}
}
fun shuffle_deck_a() {
var shf_k: u8 = 0
while shf_k < 200 {
var shf_i: u8 = rand_u8() & 0x3F
var shf_j: u8 = rand_u8() & 0x3F
if shf_i < DECK_SIZE {
if shf_j < DECK_SIZE {
var shf_tmp: u8 = deck_a[shf_i]
deck_a[shf_i] = deck_a[shf_j]
deck_a[shf_j] = shf_tmp
}
}
shf_k += 1
}
}
// After shuffle, split deck_a's 52 cards into two halves: the
// first 26 stay in deck_a, the second 26 move into deck_b.
// Reset both queues' front/count cursors in the process.
fun split_decks() {
var spd_i: u8 = 0
while spd_i < HALF_DECK {
deck_b[spd_i] = deck_a[HALF_DECK + spd_i]
spd_i += 1
}
deck_a_front = 0
deck_a_count = HALF_DECK
deck_b_front = 0
deck_b_count = HALF_DECK
pot_count = 0
}
fun init_and_shuffle_decks() {
build_master_deck()
shuffle_deck_a()
split_decks()
}

311
examples/war/play_state.ne Normal file
View file

@ -0,0 +1,311 @@
// war/play_state.ne — the Playing state and its inner phase machine.
//
// `phase` cycles through the P_* constants defined in
// war/constants.ne. `phase_timer` counts frames inside the
// current phase and is reset to 0 whenever the phase changes.
//
// All function-local var names are prefixed with the function's
// short name (or with `pf_` for "play frame") so the global
// symbol table stays collision-free.
// Set the current phase and zero the timer in one shot. Inlined
// so each call site is just two stores.
inline fun set_phase(p: u8) {
phase = p
phase_timer = 0
}
// Draw the steady-state table furniture: the two deck card backs
// (if non-empty) and the running card counts. Used as the base
// layer every frame; phase-specific sprites are layered on top.
fun draw_table() {
if deck_a_count > 0 {
draw_card_back(DECK_A_X, DECK_Y)
}
if deck_b_count > 0 {
draw_card_back(DECK_B_X, DECK_Y)
}
draw_count(COUNT_A_X, COUNT_Y, deck_a_count)
draw_count(COUNT_B_X, COUNT_Y, deck_b_count)
}
// Bury helper for a war: move one card from the deck into the
// pot (face-down). Must be called with a non-empty deck. Two
// near-identical helpers (one per side) keep the locals
// uniquely-named.
fun bury_from_a() {
var bfa_c: u8 = draw_front_a()
push_back_pot(bfa_c)
}
fun bury_from_b() {
var bfb_c: u8 = draw_front_b()
push_back_pot(bfb_c)
}
// Draw the BIG WAR banner — three 16x16 metasprites at the
// centre of the screen. 12 sprites total, drawn in the centre
// row so they don't conflict with the deck stacks (rows 64-87)
// or the face-up cards (rows 128-151).
//
// All offsets stepped through locals to dodge the `x + N`
// parameter aliasing bug; see draw_word_player.
fun draw_big_war_banner(x: u8, y: u8) {
var bwb_y1: u8 = y + 8
var bwb_x1: u8 = x + 8
var bwb_x2: u8 = x + 20
var bwb_x3: u8 = x + 28
var bwb_x4: u8 = x + 40
var bwb_x5: u8 = x + 48
// BIG W
draw Tileset at: (x, y) frame: TILE_BIG_W_TL
draw Tileset at: (bwb_x1, y) frame: TILE_BIG_W_TR
draw Tileset at: (x, bwb_y1) frame: TILE_BIG_W_BL
draw Tileset at: (bwb_x1, bwb_y1) frame: TILE_BIG_W_BR
// BIG A
draw Tileset at: (bwb_x2, y) frame: TILE_BIG_A_TL
draw Tileset at: (bwb_x3, y) frame: TILE_BIG_A_TR
draw Tileset at: (bwb_x2, bwb_y1) frame: TILE_BIG_A_BL
draw Tileset at: (bwb_x3, bwb_y1) frame: TILE_BIG_A_BR
// BIG R
draw Tileset at: (bwb_x4, y) frame: TILE_BIG_R_TL
draw Tileset at: (bwb_x5, y) frame: TILE_BIG_R_TR
draw Tileset at: (bwb_x4, bwb_y1) frame: TILE_BIG_R_BL
draw Tileset at: (bwb_x5, bwb_y1) frame: TILE_BIG_R_BR
}
// Begin the A-side draw animation: pull the top card off deck_a,
// stash it as the face-up `card_a`, arm the fly state for the
// deck → play slide, and play the click sfx.
//
// fly_card / fly_face_up are stuffed directly into globals
// instead of being passed to arm_fly, because arm_fly only takes
// 4 params (the v0.1 ABI limit) and silently drops anything past
// the fourth.
fun begin_draw_a() {
if deck_a_count > 0 {
card_a = draw_front_a()
fly_card = card_a
fly_face_up = 1
// dx_sign 0 = move right (DECK_A_X = 32 → PLAY_A_X = 96).
// dy_sign 0 = move down (DECK_Y = 64 → PLAY_Y = 128).
arm_fly(DECK_A_X, DECK_Y, 0, 0)
play FlipCard
set_phase(P_FLY_A)
}
}
// Begin the B-side draw. dx_sign 1 = move left
// (DECK_B_X = 208 → PLAY_B_X = 144). dy_sign 0 = move down.
fun begin_draw_b() {
if deck_b_count > 0 {
card_b = draw_front_b()
fly_card = card_b
fly_face_up = 1
arm_fly(DECK_B_X, DECK_Y, 1, 0)
play FlipCard
set_phase(P_FLY_B)
}
}
state Playing {
on enter {
set_phase(P_WAIT_A)
card_a = 0
card_b = 0
pot_count = 0
}
on frame {
global_tick += 1
phase_timer += 1
draw_table()
// ── Phase dispatch ───────────────────────────────
// The phases share a simple "timer hits target, advance"
// shape. Each arm of the if-chain is self-contained and
// ends either with a set_phase call or a fall-through
// that waits for more input / time.
if phase == P_WAIT_A {
// Human prompt: hint blink above the deck.
if a_is_cpu == 0 {
if (phase_timer & 32) == 0 {
draw_word_press(8, 200)
}
if button.a or button.start {
begin_draw_a()
}
} else {
// CPU draws after a short delay.
if phase_timer >= CPU_THINK_FRAMES {
begin_draw_a()
}
}
}
if phase == P_FLY_A {
step_fly_pos()
draw_flying_card(fly_x, fly_y)
if phase_timer >= FRAMES_FLY {
set_phase(P_WAIT_B)
}
}
if phase == P_WAIT_B {
// A's card is now parked in its play slot.
draw_card_face(PLAY_A_X, PLAY_Y, card_a)
if b_is_cpu == 0 {
if (phase_timer & 32) == 0 {
draw_word_press(208, 200)
}
if p2.button.a or p2.button.start or button.a or button.start {
begin_draw_b()
}
} else {
if phase_timer >= CPU_THINK_FRAMES {
begin_draw_b()
}
}
}
if phase == P_FLY_B {
// A is in place; B is flying.
draw_card_face(PLAY_A_X, PLAY_Y, card_a)
step_fly_pos()
draw_flying_card(fly_x, fly_y)
if phase_timer >= FRAMES_FLY {
set_phase(P_REVEAL)
}
}
if phase == P_REVEAL {
draw_card_face(PLAY_A_X, PLAY_Y, card_a)
draw_card_face(PLAY_B_X, PLAY_Y, card_b)
if phase_timer >= FRAMES_REVEAL {
set_phase(P_RESOLVE)
}
}
if phase == P_RESOLVE {
// Both cards go into the pot regardless of outcome.
push_back_pot(card_a)
push_back_pot(card_b)
var pf_r: u8 = compare_cards(card_a, card_b)
if pf_r == 1 {
play CheerA
set_phase(P_WIN_A)
}
if pf_r == 2 {
play CheerB
set_phase(P_WIN_B)
}
if pf_r == 0 {
// It's a tie — but only enter the war flow if both
// sides actually have cards left to bury. If a
// player ran out of cards on this very tie, the
// OTHER player wins by default and takes the pot.
if deck_a_count == 0 {
pot_to_b()
winner = 1
transition Victory
}
if deck_b_count == 0 {
pot_to_a()
winner = 0
transition Victory
}
play WarFlash
set_phase(P_WAR_BANNER)
}
}
if phase == P_WIN_A {
draw_card_face(PLAY_A_X, PLAY_Y, card_a)
draw_card_face(PLAY_B_X, PLAY_Y, card_b)
if phase_timer >= FRAMES_FLY {
pot_to_a()
set_phase(P_CHECK)
}
}
if phase == P_WIN_B {
draw_card_face(PLAY_A_X, PLAY_Y, card_a)
draw_card_face(PLAY_B_X, PLAY_Y, card_b)
if phase_timer >= FRAMES_FLY {
pot_to_b()
set_phase(P_CHECK)
}
}
if phase == P_WAR_BANNER {
draw_card_face(PLAY_A_X, PLAY_Y, card_a)
draw_card_face(PLAY_B_X, PLAY_Y, card_b)
// Flashing big "WAR" banner — only drawn on alternate
// 8-frame windows so the title strobes for emphasis.
if (phase_timer & 8) != 0 {
draw_big_war_banner(96, 80)
}
if phase_timer >= FRAMES_BANNER {
set_phase(P_WAR_BURY)
}
}
if phase == P_WAR_BURY {
// Bury up to 3 face-down cards from each deck, then
// draw a new face-up pair. We don't animate each
// individual buried card; just play a noise thump
// per buried card and advance the counters.
if phase_timer == 1 {
if deck_a_count > 0 { bury_from_a() }
if deck_b_count > 0 { bury_from_b() }
play ThudDown
}
if phase_timer == 4 {
if deck_a_count > 0 { bury_from_a() }
if deck_b_count > 0 { bury_from_b() }
play ThudDown
}
if phase_timer == 7 {
if deck_a_count > 0 { bury_from_a() }
if deck_b_count > 0 { bury_from_b() }
play ThudDown
}
if phase_timer == 10 {
// Draw new face-ups for the comparison. If either
// side has run out of cards, the OTHER side wins
// and takes the entire pot — we transition straight
// to Victory.
if deck_a_count == 0 {
pot_to_b()
winner = 1
transition Victory
}
if deck_b_count == 0 {
pot_to_a()
winner = 0
transition Victory
}
card_a = draw_front_a()
card_b = draw_front_b()
}
if phase_timer >= FRAMES_BURY + 16 {
set_phase(P_REVEAL)
}
}
if phase == P_CHECK {
if deck_a_count == 0 {
winner = 1
transition Victory
}
if deck_b_count == 0 {
winner = 0
transition Victory
}
// No winner yet — start the next round.
card_a = 0
card_b = 0
set_phase(P_WAIT_A)
}
}
}

226
examples/war/render.ne Normal file
View file

@ -0,0 +1,226 @@
// war/render.ne — card, digit, and text rendering helpers.
//
// Every function in this file is a thin wrapper around one or
// more `draw Tileset at: (x, y) frame: N` calls that writes to
// the runtime OAM cursor. Each `draw` takes one sprite slot, so
// a 16×24 card face burns 6 sprite slots, a single-character
// letter burns 1, and so on. The caller is responsible for
// sprite budgeting — see PLAN.md §3.
//
// Function-local var names are prefixed with the function's
// short name to avoid the global symbol-table collisions that
// E0501 would otherwise complain about.
// ── Card face ──────────────────────────────────────────────
//
// Draws the 6-sprite card face at (x, y) for the packed card
// byte. Layout (each cell is 8×8):
//
// [rank ][small_suit] row 0
// [pipL ][pipR ] row 1
// [blankL][blankR ] row 2
//
// Rank is the condensed glyph at TILE_RANK_BASE + (rank - 1);
// small suit + big-pip halves are indexed directly off their
// base constants.
fun draw_card_face(dcf_in_x: u8, dcf_in_y: u8, dcf_in_card: u8) {
// Snapshot every parameter into a fresh local *before* any
// nested function call. NEScript v0.1 passes parameters via
// fixed zero-page slots ($04, $05, $06), and any inner call
// overwrites those slots with its own parameter values —
// including the inline `card_rank` / `card_suit` calls below,
// which would otherwise leave `x` and `y` corrupted by the
// time the draw lines run.
var dcf_x: u8 = dcf_in_x
var dcf_y: u8 = dcf_in_y
var dcf_card: u8 = dcf_in_card
var dcf_rank: u8 = card_rank(dcf_card)
var dcf_suit: u8 = card_suit(dcf_card)
var dcf_rank_tile: u8 = TILE_RANK_BASE + dcf_rank - 1
var dcf_small_tile: u8 = TILE_SUIT_SMALL_BASE + dcf_suit
var dcf_pipl_tile: u8 = TILE_PIP_L_BASE + dcf_suit
var dcf_pipr_tile: u8 = TILE_PIP_R_BASE + dcf_suit
var dcf_x1: u8 = dcf_x + 8
var dcf_y1: u8 = dcf_y + 8
var dcf_y2: u8 = dcf_y + 16
// Row 0 — rank corner + small suit
draw Tileset at: (dcf_x, dcf_y) frame: dcf_rank_tile
draw Tileset at: (dcf_x1, dcf_y) frame: dcf_small_tile
// Row 1 — big centre pip (left + right halves)
draw Tileset at: (dcf_x, dcf_y1) frame: dcf_pipl_tile
draw Tileset at: (dcf_x1, dcf_y1) frame: dcf_pipr_tile
// Row 2 — blank bottom so the card body is symmetric
draw Tileset at: (dcf_x, dcf_y2) frame: TILE_FRAME_BLANK_L
draw Tileset at: (dcf_x1, dcf_y2) frame: TILE_FRAME_BLANK_R
}
// Draw the card-back lattice at (x, y). 6 sprites again. Rows
// 0 and 2 reuse the same top/bottom back tiles; row 1 uses the
// bottom row of the lattice as a filler so the pattern stays
// continuous.
fun draw_card_back(x: u8, y: u8) {
var dcb_x1: u8 = x + 8
var dcb_y1: u8 = y + 8
var dcb_y2: u8 = y + 16
draw Tileset at: (x, y) frame: TILE_BACK_TL
draw Tileset at: (dcb_x1, y) frame: TILE_BACK_TR
draw Tileset at: (x, dcb_y1) frame: TILE_BACK_BL
draw Tileset at: (dcb_x1, dcb_y1) frame: TILE_BACK_BR
draw Tileset at: (x, dcb_y2) frame: TILE_BACK_TL
draw Tileset at: (dcb_x1, dcb_y2) frame: TILE_BACK_TR
}
// Dispatch between front and back based on the fly_face_up flag
// stamped by the phase handlers. Snapshots params first because
// the inner call clobbers $04/$05.
fun draw_flying_card(x: u8, y: u8) {
var dfc_x: u8 = x
var dfc_y: u8 = y
if fly_face_up == 0 {
draw_card_back(dfc_x, dfc_y)
} else {
draw_card_face(dfc_x, dfc_y, fly_card)
}
}
// ── Digits + text ─────────────────────────────────────────
// Draw a single decimal digit 0..9 at (x, y).
fun draw_digit(x: u8, y: u8, d: u8) {
draw Tileset at: (x, y) frame: TILE_DIGIT_BASE + d
}
// Draw a two-digit decimal count (0..99) at (x, y). Leading zero
// is preserved so the HUD always renders two glyphs wide, which
// keeps the layout stable as the counts change.
fun draw_count(x: u8, y: u8, v: u8) {
var dct_tens: u8 = 0
var dct_n: u8 = v
// Divide by 10 without the software divide: repeatedly
// subtract 10 until n < 10. For v ≤ 52 this loops at most
// 5 times, cheaper than calling `/` and `%`.
while dct_n >= 10 {
dct_n -= 10
dct_tens += 1
}
draw_digit(x, y, dct_tens)
draw_digit(x + 8, y, dct_n)
}
// Draw a single letter 'A'..'Z' using the sprite font. `ch` is
// the letter index (0 = A, 25 = Z). Deliberately NOT marked
// `inline`: when this was inlined the resulting code put each
// inlined `draw` at double the intended X step (the inliner
// appears to re-evaluate the (x + N) parameter expression in a
// way that compounds across consecutive draws). Keeping it as
// a real function call gives every draw_letter call its own
// argument-evaluation context and the spacing comes out right.
fun draw_letter(x: u8, y: u8, ch: u8) {
draw Tileset at: (x, y) frame: TILE_LETTER_BASE + ch
}
// Short helper for drawing the word "PLAYER" at (x, y). 6 letters.
// Used by the HUD and the victory banner.
//
// We accumulate the X position in a local instead of passing
// `x + N` as a call argument: the latter pattern miscompiles in
// NEScript v0.1 (consecutive `x + N` arguments to the same
// function appear to alias the parameter slot, leaving the second
// onward call site reading a stale offset). Stepping a local
// avoids the issue entirely.
fun draw_word_player(x: u8, y: u8) {
var dwp_px: u8 = x
draw_letter(dwp_px, y, 15) // P
dwp_px += 8
draw_letter(dwp_px, y, 11) // L
dwp_px += 8
draw_letter(dwp_px, y, 0) // A
dwp_px += 8
draw_letter(dwp_px, y, 24) // Y
dwp_px += 8
draw_letter(dwp_px, y, 4) // E
dwp_px += 8
draw_letter(dwp_px, y, 17) // R
}
// "WINS" — used on the victory screen.
fun draw_word_wins(x: u8, y: u8) {
var dww_px: u8 = x
draw_letter(dww_px, y, 22) // W
dww_px += 8
draw_letter(dww_px, y, 8) // I
dww_px += 8
draw_letter(dww_px, y, 13) // N
dww_px += 8
draw_letter(dww_px, y, 18) // S
}
// "WAR" — the centred flash during a tie-break.
fun draw_word_war(x: u8, y: u8) {
var dwa_px: u8 = x
draw_letter(dwa_px, y, 22) // W
dwa_px += 8
draw_letter(dwa_px, y, 0) // A
dwa_px += 8
draw_letter(dwa_px, y, 17) // R
}
// "PRESS" — used on the title screen.
fun draw_word_press(x: u8, y: u8) {
var dwr_px: u8 = x
draw_letter(dwr_px, y, 15) // P
dwr_px += 8
draw_letter(dwr_px, y, 17) // R
dwr_px += 8
draw_letter(dwr_px, y, 4) // E
dwr_px += 8
draw_letter(dwr_px, y, 18) // S
dwr_px += 8
draw_letter(dwr_px, y, 18) // S
}
// ── Fly animation driver ──────────────────────────────────
//
// We avoid the software multiply (and the W0101 "expensive
// multiply" warning) by stepping the fly position by a fixed
// constant each frame instead of computing `start + dx * t /
// FRAMES`. The constant FLY_STEP is chosen so that
// FRAMES_FLY * FLY_STEP equals the deck-to-play distance on
// both axes:
//
// FRAMES_FLY * FLY_STEP = 64 px (16 * 4)
//
// The screen layout (DECK_*_X, PLAY_*_X, DECK_Y, PLAY_Y) is
// arranged so every animation traverses exactly 64 px on both
// axes, which keeps the per-frame step uniform.
const FLY_STEP: u8 = 4
// Step the global (fly_x, fly_y) by FLY_STEP in the directions
// stored in fly_dx_sign / fly_dy_sign. Sign 0 = positive (move
// right / down), 1 = negative (move left / up). Called once per
// frame inside the relevant fly-phase handler.
fun step_fly_pos() {
if fly_dx_sign == 0 {
fly_x += FLY_STEP
} else {
fly_x -= FLY_STEP
}
if fly_dy_sign == 0 {
fly_y += FLY_STEP
} else {
fly_y -= FLY_STEP
}
}
// Initialise the fly state for a card slide from (sx, sy) using
// the given direction signs. NEScript v0.1 only allocates four
// zero-page slots ($04-$07) for function parameters, so callers
// must stash `fly_card` and `fly_face_up` directly into the
// globals before invoking arm_fly — passing them as the 5th and
// 6th params would silently drop the values on the floor.
fun arm_fly(sx: u8, sy: u8, dxsign: u8, dysign: u8) {
fly_x = sx
fly_y = sy
fly_dx_sign = dxsign
fly_dy_sign = dysign
}

41
examples/war/rng.ne Normal file
View file

@ -0,0 +1,41 @@
// war/rng.ne — 8-bit Galois LFSR pseudo-random number generator.
//
// A classic taps = 0xB8 Galois LFSR: on every call, shift the
// state right by one and XOR with the taps polynomial if the old
// low bit was 1. The period is 255 (all non-zero states), which
// is plenty for a card shuffle.
//
// The state lives in the global `rng_state` byte declared in
// war/state.ne. Seeded from a running tick counter on title exit
// (so the jsnes golden harness gets a deterministic shuffle).
// Advance the LFSR one step and return the new state. Every caller
// treats the return value as the next random byte.
fun rand_u8() -> u8 {
var s: u8 = rng_state
var lsb: u8 = s & 1
s = s >> 1
if lsb != 0 {
s = s ^ 0xB8
}
// Guard against the degenerate all-zero state: if we ever
// roll into zero the LFSR is stuck, so reseed from a fixed
// non-zero constant. In practice this only happens on a
// bad initial seed — we start at 0xA7 so the cycle stays
// healthy.
if s == 0 {
s = 0xA7
}
rng_state = s
return s
}
// Seed the LFSR from an arbitrary byte. Zero is remapped to the
// same fallback constant rand_u8() uses so the period stays 255.
fun rng_seed(seed: u8) {
if seed == 0 {
rng_state = 0xA7
} else {
rng_state = seed
}
}

82
examples/war/state.ne Normal file
View file

@ -0,0 +1,82 @@
// war/state.ne — every mutable variable the game touches.
//
// NEScript allocates top-level `var` declarations in general RAM
// (the analyzer places them starting at $10 / $18 depending on
// whether the program declares background/palette updates). We
// keep everything global so helper functions can read and write
// without having to take parameters and return values — the
// 6502's 8-register ABI makes every extra parameter an extra
// LDA/STA pair.
// ── Decks + pot ───────────────────────────────────────────
// Each deck is a circular buffer backed by a 52-byte array.
// `*_front` is the index of the next card to draw; `*_count` is
// the number of cards currently in the buffer. Both fields wrap
// mod 52 — there's a helper for that in war/deck.ne.
var deck_a: u8[52]
var deck_a_front: u8 = 0
var deck_a_count: u8 = 0
var deck_b: u8[52]
var deck_b_front: u8 = 0
var deck_b_count: u8 = 0
// Pot holds every card currently in play (the normal-round draws
// and, during a war, the face-down buries too). Drained into the
// winner's deck after each round resolves.
var pot: u8[52]
var pot_count: u8 = 0
// ── The two cards showing face-up this round ─────────────
// These are the packed rank/suit bytes drawn from each deck at
// the start of a round. During a war, the old face-up cards get
// pushed into the pot and new values land in these slots.
var card_a: u8 = 0
var card_b: u8 = 0
// ── Game mode + phase machine ─────────────────────────────
var mode: u8 = 0 // 0/1/2 players
var a_is_cpu: u8 = 1 // bool-ish
var b_is_cpu: u8 = 1
var phase: u8 = 0 // one of the P_* constants
var phase_timer: u8 = 0 // counts up during each phase
// ── Animation state ───────────────────────────────────────
// Shared by every card-fly phase. We step (fly_x, fly_y) by a
// constant FLY_STEP per frame in the directions encoded in
// fly_dx_sign / fly_dy_sign. The screen layout is arranged so
// that FRAMES_FLY * FLY_STEP exactly matches the deck-to-play
// distance on both axes — see render.ne for the math.
var fly_x: u8 = 0
var fly_y: u8 = 0
var fly_dx_sign: u8 = 0 // 0 = +FLY_STEP / 1 = -FLY_STEP
var fly_dy_sign: u8 = 0
var fly_card: u8 = 0 // packed rank/suit to show during the fly
var fly_face_up: u8 = 1 // 0 = show card back, 1 = show face
// ── Title menu ────────────────────────────────────────────
var title_cursor: u8 = 1 // menu index (0/1/2) — default "1 PLAYER"
var title_timer: u8 = 0 // auto-advance counter
var title_debounce: u8 = 0 // menu input debounce
var title_blink: u8 = 0 // "PRESS A" blink counter
// ── Deal state ────────────────────────────────────────────
var deal_next: u8 = 0 // next card index to deal
var deal_timer: u8 = 0
// ── Victory state ─────────────────────────────────────────
var winner: u8 = 0 // 0 = A wins, 1 = B wins
var victory_timer: u8 = 0
// ── RNG ──────────────────────────────────────────────────
// 8-bit Galois LFSR state. Seeded from the free-running title
// frame counter at the moment the title screen transitions to
// Deal, so the shuffle is deterministic once the user commits
// to starting a game. The jsnes headless harness always starts
// at frame 45, so the seed is stable there too.
var rng_state: u8 = 0xA7
// ── Global free-running frame counter ────────────────────
// Used by any state that needs a coarse "which frame are we
// on" read without installing its own counter.
var global_tick: u16 = 0

144
examples/war/title_state.ne Normal file
View file

@ -0,0 +1,144 @@
// war/title_state.ne — the Title state.
//
// Draws a big "WAR" banner, a 3-option menu with a cursor, and a
// blinking "PRESS A" prompt. Autopilot: if the player doesn't
// press anything for TITLE_AUTO_FRAMES frames the menu auto-
// commits to "0 PLAYERS" so the jsnes golden capture reaches
// gameplay by frame 180.
//
// The cursor navigates with D-pad up/down; A or Start confirms.
// Debouncing is done via title_debounce — one key press per
// press rather than per frame held.
state Title {
on enter {
title_cursor = 1
title_timer = 0
title_blink = 0
title_debounce = 0
start_music TitleTheme
}
on frame {
global_tick += 1
title_timer += 1
title_blink += 1
if title_blink >= 60 {
title_blink = 0
}
if title_debounce > 0 {
title_debounce -= 1
}
// ── Big "WAR" title banner ───────────────────────
// Each letter is a 2x2 block of 16x16 BIG tiles.
// Three letters at 16px wide + 4px gap = 16*3 + 4*2 = 56px;
// centred at x = (256 - 56)/2 = 100.
// y = 32 puts the banner in the top third of the screen.
// BIG W
draw Tileset at: (100, 32) frame: TILE_BIG_W_TL
draw Tileset at: (108, 32) frame: TILE_BIG_W_TR
draw Tileset at: (100, 40) frame: TILE_BIG_W_BL
draw Tileset at: (108, 40) frame: TILE_BIG_W_BR
// BIG A
draw Tileset at: (120, 32) frame: TILE_BIG_A_TL
draw Tileset at: (128, 32) frame: TILE_BIG_A_TR
draw Tileset at: (120, 40) frame: TILE_BIG_A_BL
draw Tileset at: (128, 40) frame: TILE_BIG_A_BR
// BIG R
draw Tileset at: (140, 32) frame: TILE_BIG_R_TL
draw Tileset at: (148, 32) frame: TILE_BIG_R_TR
draw Tileset at: (140, 40) frame: TILE_BIG_R_BL
draw Tileset at: (148, 40) frame: TILE_BIG_R_BR
// Subtitle "CARD GAME" in 8x8 font under the banner.
// 9 letters incl. the embedded space → 9 sprites per row,
// which over-runs the 8-per-scanline limit. Splitting the
// subtitle across two y rows (offset by 8px) keeps each
// scanline under the limit.
draw_letter(96, 64, 2) // C
draw_letter(104, 64, 0) // A
draw_letter(112, 64, 17) // R
draw_letter(120, 64, 3) // D
draw_letter(136, 64, 6) // G
draw_letter(144, 64, 0) // A
draw_letter(152, 64, 12) // M
draw_letter(160, 64, 4) // E
// ── Menu options ─────────────────────────────────
// Three lines vertically stacked. Each row is "X PLAYER"
// (no S — plural is implied) so the row never exceeds 7
// sprites and stays under the per-scanline limit even
// when the cursor sprite shares the same row.
draw_digit(88, 104, 0)
draw_word_player(104, 104)
draw_digit(88, 120, 1)
draw_word_player(104, 120)
draw_digit(88, 136, 2)
draw_word_player(104, 136)
// Cursor sits to the left of the selected option.
var cursor_y: u8 = 104 + (title_cursor << 4) // 104, 120, 136
draw Tileset at: (72, cursor_y) frame: TILE_CURSOR
// ── Blinking "PRESS A" prompt ────────────────────
if title_blink < 30 {
draw_word_press(96, 184)
draw_letter(144, 184, 0) // A
}
// ── Input handling ───────────────────────────────
if title_debounce == 0 {
if button.up {
if title_cursor > 0 {
title_cursor -= 1
}
title_debounce = 10
title_timer = 0
play FlipCard
}
if button.down {
if title_cursor < 2 {
title_cursor += 1
}
title_debounce = 10
title_timer = 0
play FlipCard
}
if button.a or button.start {
// Commit the selection and seed the RNG from the
// current global tick so each user-visible start
// produces a different shuffle.
mode = title_cursor
rng_seed((global_tick & 0xFF) as u8)
transition Deal
}
}
// ── Autopilot for the headless harness ───────────
if title_timer >= TITLE_AUTO_FRAMES {
mode = 0
rng_seed((global_tick & 0xFF) as u8)
transition Deal
}
}
on exit {
stop_music
// Translate the menu selection into the two a_is_cpu /
// b_is_cpu flags the play state reads each frame.
if mode == MODE_CPU_VS_CPU {
a_is_cpu = 1
b_is_cpu = 1
}
if mode == MODE_HUMAN_VS_CPU {
a_is_cpu = 0
b_is_cpu = 1
}
if mode == MODE_HUMAN_VS_HUMAN {
a_is_cpu = 0
b_is_cpu = 0
}
}
}

View file

@ -0,0 +1,73 @@
// war/victory_state.ne — the Victory state.
//
// Big "PLAYER A WINS" or "PLAYER B WINS" banner, the builtin
// `fanfare` music, an auto-return to Title after
// VICTORY_LINGER_FRAMES frames, and a manual "press A to skip"
// path.
state Victory {
on enter {
victory_timer = 0
start_music fanfare
}
on frame {
global_tick += 1
victory_timer += 1
// ── Banner ──────────────────────────────────────
// "PLAYER X" on row 1, "WINS" on row 2. Splitting the
// banner across two y rows keeps each scanline under the
// NES's 8-sprite-per-scanline limit (the "PLAYER X WINS"
// single-line version was 11 sprites tall and dropped
// letters past the 8th).
draw_word_player(64, 80)
if winner == 0 {
draw_letter(120, 80, 0) // A
} else {
draw_letter(120, 80, 1) // B
}
draw_word_wins(96, 96)
// Flourish: draw the winning deck's top card in the
// middle of the screen as a victory showcase. If the
// deck is empty (shouldn't happen — we only arrive
// here because the *loser* is empty) fall back to a
// card back so the layout is stable.
if winner == 0 {
if deck_a_count > 0 {
var vic_top_a: u8 = deck_a[deck_a_front]
draw_card_face(120, 128, vic_top_a)
} else {
draw_card_back(120, 128)
}
} else {
if deck_b_count > 0 {
var vic_top_b: u8 = deck_b[deck_b_front]
draw_card_face(120, 128, vic_top_b)
} else {
draw_card_back(120, 128)
}
}
// Row of tiny hearts under the banner.
draw Tileset at: (80, 168) frame: TILE_HEART_TINY
draw Tileset at: (96, 168) frame: TILE_HEART_TINY
draw Tileset at: (112, 168) frame: TILE_HEART_TINY
draw Tileset at: (128, 168) frame: TILE_HEART_TINY
draw Tileset at: (144, 168) frame: TILE_HEART_TINY
draw Tileset at: (160, 168) frame: TILE_HEART_TINY
// ── Input + auto-return ─────────────────────────
if button.a or button.start {
transition Title
}
if victory_timer >= VICTORY_LINGER_FRAMES {
transition Title
}
}
on exit {
stop_music
}
}