Files
nyuller/GAME.md
T
ballz 64b0790519 Phase 10: Fix all 28 code review bugs
Critical fixes:
- Screen RAM relocated from  to  (VIC register collision)
- RST8 preserved in vic_setup_mcm() (raster IRQ line 311 stability)
-  random source armed with NOISE waveform (WAIT duration variety)
- Makefile  now has real file rule (run-vice works from clean)
- .PHONY lists corrected

Medium fixes:
- DRAW fault timeout >= 500 (was > 500)
- Counter minimum now 001 (init to 0, increment before render)
- WAIT duration upper bound 250 (was 249)
- Border strobe 4 frames (was 5)
- GAMEOVER shows final score, reset on TITLE entry
- ADSR decay comments corrected
- clear_color_ram() redundant loop removed
- memmap_setup() redundant MMAP_RAM removed
- Makefile: VICE/:0/c1541 checks, setsid pgrep, ensure-oscar64
- WAIT stinger uses voice 0 (protects voice 2 for RNG)

Low fixes:
- WHACKED references updated to Nyuller
- font arrays use unsigned char
- memmap_restore() documented as unused
- PROG_C64.md banking table corrected
- Makefile: clean @ prefix, help docs, OPT guard
- Audio schedule off-by-one corrected (+1 frame/note)
2026-07-18 20:04:15 +02:00

370 lines
17 KiB
Markdown
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.
# GAME.md — *Nyuller*
A two-player reaction-time duel for the Commodore 64. Two players stand
back-to-back, take ten paces, and the first one to hit fire when the
signal appears wins the round. First to **5 wins** wins the match.
The two players are **Hare** (player 1, on the left — a white rabbit
with claws and a bad attitude) and **Scoot** (player 2, on the right —
a kid on a kick scooter). The two facing-off screens, the win
screens, and the title screen all come from the artwork in
`./source_images/`.
## 1. The cast
| Player | Side | C64 control port | Artwork on the title |
|--------|------|------------------|----------------------|
| Hare (the rabbit) | Left | Joystick port 1 (`$DC01`) | Lower-left of `screen_title.png` |
| Scoot (the kid on a scooter) | Right | Joystick port 2 (`$DC00`) | Lower-right of `screen_title.png` |
Fire is the only button used. Both ports are read every frame; the
joystick value is the standard `J = PEEK(0xDC00/0xDC01)` byte with bit
4 (mask $10) as the fire button (active low — pressed when bit 4 is
clear).
## 2. Artwork
All artwork lives in `./source_images/` as 1390×1130 PNGs (8-bit RGB,
dithered down to the C64's 16-color palette by whoever made them — the
colors in the files are the colors the C64 will display):
| File | Resolution | Used for |
|------|------------|----------|
| `source_images/screen_title.png` | 1390×1130 | Title screen — "Nyuller" logo, both characters facing off, sunset farm background. |
| `source_images/screen_waiting1.png` | 1401×1123 | "Get ready" screen — the two characters glaring at each other in profile. |
| `source_images/screen_waiting_2.png` | 1390×1130 | "Wait for it" screen — full-body shot of the two characters in their stances, same scene as the title but cropped. |
| `source_images/screen_win_hare.png` | 1391×1131 | Hare won — Hare punching Scoot in the face mid-scooter. |
| `source_images/screen_win_scoot.png` | 1389×1132 | Scoot won — Scoot has ridden over Hare, who is sprawled on the ground. |
A practical note for converting to C64: the originals are 1390×1130, but
the C64 hires screen is 320×200. We'll have to either:
- Pre-crop / pre-scale the assets in a tool (recommended) to 320×200
before embedding them, or
- Pick a 320×200 region of the original (the center 320×200 crop is
usually the most interesting part) and embed that.
We'll do the cropping in an image tool (ImageMagick, GIMP, or a small
Python script) and let the C64 just blit the resulting 320×200 hires
bitmap. The Oscar64 `#embed lzo` directive is what pulls the cropped
`.bin` into the binary.
## 3. The screens (in play order)
The game is a simple state machine. The screens, in the order a player
sees them during one round:
1. **Title** (`TITLE` state) — `screen_title.png`. Static background.
A short, flashing *"PRESS FIRE ON BOTH PORTS"* banner across the
bottom half. The score is shown at the top: a row of "0"s on the
left (Hare) and right (Scoot), reading `0 0`. No music, or a
very low-key idle loop on voice 1.
2. **"Get ready"** (`READY` state) — `screen_waiting1.png`. Up for
**exactly ~1.2 seconds**. A short two-note "ping" jingle (e.g. SID
voice 1 plays A4 then A5 with a quick attack/decay envelope). The
score at the top is visible. The text "READY…" or just the screen
alone is fine.
3. **"Wait…"** (`WAIT` state) — `screen_waiting_2.png`. The suspense
state. Stays up for a **random duration between 2.0 and 5.0
seconds**, uniform. A looping suspenseful bassline plays on SID
voice 1 (a slow two-note pulse, say C2G1 alternating every ~16
jiffies), with voice 2 doing a quiet pulse-width "heartbeat"
arpeggio. No indication of how long is left — that's the point.
4. **"DRAW!"** (`DRAW` state) — white screen, large black digits
(described below), a sharp percussive stab on the SID (voice 3,
noise waveform, attack=0, decay=1, sustain=0, release=2, gated off
after 4 frames). The counter starts at `001` and increments **once
per frame** so a fast eye + finger can win in single-digit
milliseconds.
5. **Win screen** (`WIN_P1` or `WIN_P2` state) — `screen_win_hare.png`
or `screen_win_scoot.png`. Up for **exactly 2.0 seconds**. A short
3-4 note "winner" jingle on the SID (e.g. CEGC, major triad
arpeggio, fast attack, long release, voices 1 and 2 stacked). The
score at the top updates immediately (you see the new digit the
moment the win screen appears).
6. **Loop or game-over.** If the winner's score is now < 5, jump back
to step 2 (READY) for the next round. If the winner's score is 5,
go to step 7.
7. **Game over** (`GAMEOVER` state) — same as the title screen, but
with a "GAME OVER — HARE WINS!" / "GAME OVER — SCOOT WINS!"
banner instead of "PRESS FIRE", and a 56 note "match over"
fanfare. Stays up for 6 seconds (long enough to be appreciated),
then resets to step 1 (TITLE) with scores at 0/0.
## 4. The "DRAW!" screen — what to draw
The C64 hires screen is 320×200 with a 16-color palette. The DRAW
screen is:
- Background: solid white (`$D021` = white, bitmap all 0s).
- A huge number in black, centered, drawn with the standard 8×8 ROM
character set scaled up. The counter is 3 digits (001..999) so it
stays in the visible screen at any size.
- Implementation: easiest is to use the 8×8 ROM font, scaled 4× (so
each character is 32×32 pixels), centered. That gives 3 chars ×
32px = 96px wide on a 320px screen, so each digit is huge but fits.
Redrawing the screen is just "redraw the 3 screen memory cells with
the new counter value". The VIC's redraw of a 96×32 region in
character mode takes one frame's worth of work in the worst case;
the counter is updated once per frame, in sync with the raster
IRQ, so there's no flicker.
A nicer version would use a custom multicolor font (the C64 has 4
colors per 8×8 cell in multicolor character mode), but hires
character mode is simpler and the white-with-black-text look is
the classic quick-draw visual.
- Each frame, in the raster IRQ handler, the game logic does:
```c
counter++;
if (counter > 999) counter = 999; // cap it
screen[40 * 11 + 13] = '0' + (counter / 100);
screen[40 * 11 + 14] = '0' + ((counter / 10) % 10);
screen[40 * 11 + 15] = '0' + (counter % 10);
```
(and similarly for the colors at $D800+, but they're all black, so
set once at the start of the DRAW state).
- Optionally: each digit can have a small horizontal "shake" applied
per frame to give a stressed-out look. Or a small black bar across
the screen to make it look like a flash.
## 5. The score bar (top of every game screen)
- 1 character row, top of the screen. In hires + char mode, that means
using the top 8 pixels of the bitmap as character cells, and the
rest of the screen (200-8 = 192 pixels) as a hires image below.
The easiest implementation is to set the VIC's video matrix
pointer to start at a screen memory address that *visually* aligns
with the top of the screen but in practice is wherever we want.
Concretely: screen memory at $0400, but the screen "rows" we use for
the score are just the very top 8 pixels of the bitmap (we draw the
characters there by hand in the bitmap, OR we use a hybrid mode
where the top 8 rows are character cells and the rest is hires — but
C64 character rows are always 8 pixels high, so we'd have an
8-pixel score bar and a 192-pixel art area. Good enough.)
- Layout, top row, left to right:
- Cols 0-15: "HARE " (label) + 5 small icons or just "HARE: " +
the digit, centered on the left half.
- Cols 24-39: "SCOOT: " + the digit, centered on the right half.
- Total format: ` HARE * SCOOT * ` where the * is the
number of filled-in score pip (0..5). Pips are simpler than
digits: 5 small squares per side, filled or empty.
- Score pip rendering: a custom character for "filled pip" (a solid
block in the foreground color) and "empty pip" (an outline or a
blank). Each side takes 5 character cells of width.
The cleanest implementation: have two pre-loaded custom character sets
for the pip characters (filled and empty), and the rest of the score
bar is the standard ROM character set. Switch between them with
$D018. But that adds complexity — for the first pass, drawing the
pips as solid filled blocks in the bitmap is fine (the score bar is
only 8 pixels high and the pips are 6×6 blocks).
## 6. Audio (SID 6581) — the five cues
The SID has 3 voices. The game uses voice 1 for music, voice 2 for
harmony, voice 3 for sound effects. The filter is bypassed (volume
$0F, no filter routing) for the first pass — we can add filter
sweeps later for polish.
| State | Voice 1 | Voice 2 | Voice 3 | Notes |
|-------|---------|---------|---------|-------|
| TITLE | (silent, or a low idle pulse) | — | — | |
| READY | triangle A4 then A5, attack 0, decay 9, release 0, no sustain, gated off after 8 frames each | — | — | Two notes, ~0.6s total |
| WAIT | square wave C2, pulse width 50%, slow envelope (attack 0, decay 15, sustain 8, release 15) | triangle 1 octave up, same envelope, slightly delayed | — | Looping, every ~16 jiffies re-trigger |
| DRAW | — | — | noise, attack 0, decay 1, sustain 0, release 2, gated on for 4 frames | Sharp stab |
| WIN | triangle C5E5G5C6 arpeggio, fast attack, slow release, ~0.8s | triangle a third below, same notes, very quiet (volume 4) | — | Major chord jingle |
| GAMEOVER | triangle, fanfare notes (G4C5E5G5C6), long notes, ~3s | triangle, sustained fifth below | — | End-of-match feel |
(The actual note frequencies in Hz are listed in the SID 6581
datasheet — `freq = 16.777216 * note_hz / 16777216` in the 16-bit
frequency register. We'll define them in a `notes.h` lookup table in
the source.)
Voice 1 waveform is set per state; voice 2 is on only for WAIT and WIN.
The voice 3 noise in DRAW is the only sound effect that's not a
musical note.
The music is driven by a raster IRQ that updates the SID registers
on a per-note schedule. Since each state has a small, fixed set of
notes, the IRQ can be very simple — a "next note time" jiffy counter
and a pointer into a per-state note table.
## 7. Inputs
| Action | Player 1 (Hare) | Player 2 (Scoot) |
|--------|------------------|------------------|
| Fire | `PEEK(0xDC01) & 0x10 == 0` | `PEEK(0xDC00) & 0x10 == 0` |
| (other directions unused) | (ignore) | (ignore) |
`PEEK(0xDC00)` and `PEEK(0xDC01)` are the active-low joystick bytes on
CIA 1 ports A and B. Bit 4 is the fire button; the lower 4 bits are
the directions (up/down/left/right). In the title screen the game
also has to read both ports every frame to see if either player has
pressed fire.
Note: the C64's keyboard is wired in parallel with the joystick
matrix, so `PEEK(0xDC00/0xDC01)` will also return "pressed" if you
hold a key in the right column. For our purposes this is fine (most
players use joysticks), but if we want to be strict we can disable
the keyboard with `POKE 0xDC02, 0xE0` (set keyboard columns as inputs)
during gameplay and re-enable with `POKE 0xDC02, 0xFF` when returning
to BASIC.
## 8. Timing (PAL C64, 50 Hz / 312 lines)
The game runs at the PAL frame rate: 50 fields per second, 312 lines
per field. We use a single raster IRQ set to fire on a stable line
(line 311, the one right after vertical blank and before any badlines)
on every frame, so we have a 50 Hz tick to update game state, read
joysticks, update the SID, and redraw the screen.
- TITLE state: update at 25 Hz (every other frame) to flash the
prompt. Read joysticks every frame so the input is responsive.
- READY state: fixed-duration, ~1.2 sec = 60 frames. Use a frame
counter, not a real-time timer.
- WAIT state: random 100-250 frames. The random number is from the
SID's $D41B oscillator register (read-only noise that's
effectively random).
- DRAW state: indefinite. Counter increments every frame. The first
fire press wins. If for some reason neither player presses for
10 seconds (500 frames), declare it a "fault" and go to TITLE
with no point awarded.
- WIN state: fixed 2.0 sec = 100 frames.
- GAMEOVER state: fixed 6.0 sec = 300 frames.
## 9. The state machine
```
both fire
+-------------------------+
| v
TITLE ----- fire1 only ----> TITLE (wait for player 2)
| ^
| both still holding? no
v
READY (fixed 60 frames, "ping" jingle)
|
v
WAIT (random 100..250 frames, suspense music)
|
v
DRAW (counter++, play stab, wait for fire)
| \
| \-- both never fire in 500 frames --> fault back to TITLE
|
v fire1 first
WIN_P1 (100 frames, jingle, +1 to player 1)
| \
| \-- player 1 score == 5 --> GAMEOVER
v
WIN_P2 (100 frames, jingle, +1 to player 2)
| \
| \-- player 2 score == 5 --> GAMEOVER
v
(back to READY for next round)
```
States are stored in a single `byte` or `enum` variable. Each frame,
the IRQ handler runs the state machine, updates the SID, and updates
the score / counter display.
## 10. Asset preparation pipeline
Before the C64 ever sees these PNGs, we have to:
1. **Crop** each PNG to 320×200 (or pick a 320×200 region that looks
good). The center 320×200 of each is usually what we want; the
aspect ratio is close to 16:10 which is what the C64 has, so a
direct crop with no scaling works.
2. **Reduce colors** to the C64's 16-color palette. The originals
look like they were already done with a C64 palette in mind
(the orange/purple/yellow/brown/green range is classic C64), but
we should still quantize to a 16-color palette that matches the
VIC's actual 16 fixed colors. Tools: ImageMagick
(`-colors 16 +dither` or similar), GIMP (Image → Mode →
Indexed), or a small Python/PIL script.
3. **Convert to 320×200 hires bitmap format** — a single .bin file
of 8000 bytes (320×200 / 8 bits per byte, row-major, MSB first).
4. **Compress with LZO** at build time via Oscar64's
`#embed lzo "file.bin"` directive. The decompressed bitmap is
stored in a RAM region at startup.
5. **Copy to $E000-$FF40 (or wherever the hires area is)** at the
start of each state transition. Since all 4 screens are different
bitmaps, we either keep them all in RAM (4 × 8000 = 32 KB, way
too much) or we stream one in at a time from the .prg file at
each state transition.
The practical solution: embed all 4 screens with `#embed lzo`, store
them in a reserved region of the .prg file (after the program code
and before the runtime data), and at each state transition, decompress
the right one into the hires area using `oscar_expand_lzo`. This is
the same pattern Oscar64 samples use for the charsets and sprites.
## 11. Memory budget (rough)
| Item | Size | Where |
|------|------|-------|
| Compiled C code (logic, IRQ, audio) | ~4-6 KB | `$0900-$1F00` |
| Stack (C locals, IRQ save area) | 1 KB | BSS |
| Heap (no malloc) | 0 KB | n/a |
| One decompressed hires bitmap | 8000 bytes | `$6000-$7F40` (or similar 8 KB-aligned window) |
| 4 compressed bitmaps in .prg | ~10-15 KB total | After code, in ROM/loaded region |
| Character set (1 KB) + Color RAM (1 KB) | 2 KB | RAM |
| Custom font (pips, counter digits) | 256 bytes | RAM |
| SID note tables | ~200 bytes | ROM |
| **Total loaded** | ~25-30 KB | Fits comfortably in `$0900-$A000` (38 KB) or in a no-ROM build |
The game is comfortably within C64 limits. We can even use the default
$0900-$A000 region with all ROMs banked in (so the KERNAL ISR
trampoline still works), leaving the full 38 KB available for code,
data, and one decompressed bitmap at a time.
## 12. What's *not* in scope (yet)
- Sound test menu (no — the audio is fixed per state).
- Player name entry (no — pure 2-player hot-seat).
- Difficulty levels (no — the WAIT duration is fixed; we can vary it
per round if we want a "tiebreaker" round).
- AIs (no — strictly 2-player).
- Save/load high scores (no — sessions are short).
- NTSC (50 Hz vs 60 Hz) — PAL is the target. NTSC is a small port:
different raster line counts, different stable-raster line numbers.
The raster IRQ line (311) and frame rate (50 Hz) are the only
numbers that need to change.
- The score bar's *exact* pixel layout — we'll iterate on the
visual design after we have the gameplay working.
## 13. Source layout (proposed)
```
src/
├── main.c # entry point, main loop
├── game.c / game.h # state machine
├── screens.c / .h # 4 compressed bitmaps + show_screen(N) helper
├── audio.c / .h # SID note tables + play_state(N) helper
├── input.c / .h # joystick read helpers
├── score.c / .h # score bar renderer
├── draw.c / .h # the white-screen + counter rendering
├── rasterirq.c / .h # 50 Hz frame tick
├── notes.h # SID frequency table
└── build/ # (gitignored) output
```
The plan is to keep the code as flat as possible — no `region` /
`section` pragmas in the first pass, just the default Oscar64 layout,
and we reach for `#pragma region(…)` if the linker complains.
---
The art is striking, the gameplay is dead simple, and the SID has
plenty of room for the audio we want. This should be a fun first C64
project.