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)
370 lines
17 KiB
Markdown
370 lines
17 KiB
Markdown
# 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 C2–G1 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. C–E–G–C, 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 5–6 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 C5–E5–G5–C6 arpeggio, fast attack, slow release, ~0.8s | triangle a third below, same notes, very quiet (volume 4) | — | Major chord jingle |
|
||
| GAMEOVER | triangle, fanfare notes (G4–C5–E5–G5–C6), 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.
|