From 9242dca9e73d12652e8919a93700227f7eff8277 Mon Sep 17 00:00:00 2001 From: ballz Date: Fri, 17 Jul 2026 00:42:20 +0200 Subject: [PATCH] Add GAME.md: design spec for the quick-draw game --- GAME.md | 370 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 370 insertions(+) create mode 100644 GAME.md diff --git a/GAME.md b/GAME.md new file mode 100644 index 0000000..dbccc67 --- /dev/null +++ b/GAME.md @@ -0,0 +1,370 @@ +# GAME.md — *Whack Hare!* + +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 — "WHACKED" 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.sh # (already exists) +└── 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.