Add GAME.md: design spec for the quick-draw game
This commit is contained in:
@@ -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.
|
||||
Reference in New Issue
Block a user