17 KiB
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:
-
Title (
TITLEstate) —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), reading0 0. No music, or a very low-key idle loop on voice 1. -
"Get ready" (
READYstate) —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. -
"Wait…" (
WAITstate) —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. -
"DRAW!" (
DRAWstate) — 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 at001and increments once per frame so a fast eye + finger can win in single-digit milliseconds. -
Win screen (
WIN_P1orWIN_P2state) —screen_win_hare.pngorscreen_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). -
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.
-
Game over (
GAMEOVERstate) — 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:
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:
- 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.
- 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 +ditheror similar), GIMP (Image → Mode → Indexed), or a small Python/PIL script. - Convert to 320×200 hires bitmap format — a single .bin file of 8000 bytes (320×200 / 8 bits per byte, row-major, MSB first).
- 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. - 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.