- Player 2 logic was a late addition
- Island and bank rendering heavily duplicated
- Level data stored wastefully
- Unused control selection screen at $8561
- Pre-shifted sprites (and wasted fighter frames)
- Self-modifying code for blending modes
- Pixel-perfect collision via rendering pipeline
- Multi-channel interrupt-driven sound
- Arithmetic directly on ASCII digit strings
- Multiplication and division without hardware support
- Increment-then-correct for bidirectional movement
- Stack pointer tricks for computed jumps
- Useless trampoline at $76AC
- Dead calculation at $7695
- Unused explosion animation at $8AD8
- Unused variables and data areas
- RETN instead of RETI in interrupt handler
Player 2 logic was a late addition
Player 2 handling is scattered across the codebase as duplicated branches
rather than parameterized code. Nearly every routine that touches scoring,
bridge display, status line, or death handling has a
CP PLAYER_2 / JR Z fork to a near-identical copy:
- Score increment: inc_player_1_score_digit (player 1) vs inc_player_2_score_digit (player 2)
- Score printing: print_player_1_score_digit (player 1) vs 9178 (player 2)
- Bridge display: print_bridge (player 1) vs print_bridge_player_2 (player 2)
- Plane movement: handle_right and handle_left both branch for player 2 attributes
- Death handling: multiple routines with
CP PLAYER_2forks
A parameterized design using the player number as a table index would have
eliminated the duplication. The pattern strongly suggests that single-player
mode was developed first and two-player support was bolted on afterward.
Island and bank rendering heavily duplicated
Left bank and right bank rendering in render_island_line (island edges) and render_terrain_fragment
(terrain fragments) follow identical logic but are implemented as separate
code paths with only operand differences. The duplication is remarkably
regular — same structure, same control flow, different constants.
This pattern could indicate systematic copy-paste development, or
alternatively, output from a high-level language compiler or macro assembler
that expanded separate invocations without optimizing the identical code.
Level data stored wastefully
The game dedicates nearly 24 KB to level definitions — almost half the total
program size:
| Data | Size | Location |
|---|---|---|
| Terrain shapes | 48 levels x 256 bytes = 12,288 bytes | level_terrains |
| Object spawns | 48 levels x 256 bytes = 12,288 bytes | level_objects |
The terrain data is reasonably efficient (64 fragments x 4 bytes = 256 bytes
per level). However, the object spawn data uses 128 slots x 2 bytes per
level, where most slots are empty (X position = $00). A more compact encoding
— such as run-length compression for empty slots or delta-encoding for
terrain offsets — could save significant memory.
The straightforward storage suggests the level data was likely generated by
an external tool that prioritized simplicity over compactness.
Unused control selection screen at $8561
Text data at msg_control_menu contains a formatted control selection menu that is
never displayed by the game code. It appears to be a remnant of an earlier
UI design, replaced by the current menu system implemented at setup.
Pre-shifted sprites (and wasted fighter frames)
All sprites exist in 4 copies, each shifted by 2 pixels. The rendering
routine at render_sprite picks the correct pre-shifted frame based on the object's
X position bits 1-2. This trades memory for speed: a single table lookup
replaces what would otherwise be 0-6 bit-shift operations per scanline during
rendering.
However, fighters move at 4 pixels per frame — exactly one character cell
width. This means that at most 1-2 of the 4 shifted frames are ever actually
displayed for fighters. The remaining 2-3 frames (48-72 bytes per direction)
are wasted memory.
Self-modifying code for blending modes
The sprite renderer uses self-modifying code to switch between blending modes
at runtime. The opcodes OPCODE_XOR_B and OPCODE_OR_B are patched into the
instruction addresses sprite_draw_op and sprite_erase_op:
- blending_mode_xor_nop patches in XOR mode (for fighters and tanks)
- blending_mode_or_or restores OR mode (default blending)
Self-modifying code is also used for the fuel gauge at calculate_fuel_gauge_offset, where a
computed sprite data offset is patched into an RLC instruction's operand.
Pixel-perfect collision via rendering pipeline
The two-phase render pipeline at render_object serves double duty — it both renders
sprites and detects collisions in a single pass:
- Phase 1: XOR erase the old sprite (removes the previous frame)
- Phase 2: OR draw the new sprite, checking for non-zero pixels at each position
If OR-ing a sprite pixel onto non-zero screen data, a collision is flagged
and dispatched via collision_dispatcher. This eliminates the need for a separate collision
map, but it also means that any non-zero screen content — including attribute
artifacts, explosion debris, or even POKEd data — acts as a collision
surface. See also Collision detection works on
raw screen pixels.
Multi-channel interrupt-driven sound
Sound routines are called from the ~50 Hz interrupt handler at int_handler, one
frame of audio output per interrupt. Multiple sounds can play simultaneously
via a 6-bit bitmask at state_controls (fire, speed modes, low fuel, bonus life,
explosion).
The explosion sound's distinctively noisy character comes from an
uninitialized register: the DE register is not set by the caller, so it
retains whatever value the interrupted main loop code happened to leave. This
makes the pitch vary semi-randomly between frames, producing the chaotic
explosion effect.
Arithmetic directly on ASCII digit strings
Instead of binary addition followed by BCD-to-ASCII conversion, the game
manipulates score characters directly. The algorithm at inc_player_1_score_digit works like
schoolbook long addition:
- Load an ASCII digit character from the score buffer
- Increment it (INC adds 1 to the character code)
- Compare with '9'+1 (CHAR_0 + 10) to detect overflow
- If overflow: store '0' and carry to the next higher digit
- If no overflow: store the updated digit and print it
This avoids binary math entirely — there is no binary score representation
anywhere in the game. The tradeoff: single-digit updates are fast, but adding
multi-digit values requires repeated single-digit increments with carry
propagation.
Multiplication and division without hardware support
The Z80 has no multiply or divide instructions. River Raid implements these
operations using shift-and-add chains for multiplication and repeated
subtraction for division.
Multiplication by powers of 2 uses left-shift chains:
| Operation | Implementation | Example |
|---|---|---|
| x2 | ADD A,A (or SLA A) |
|
| x4 | ADD A,A twice |
|
| x8 | ADD A,A three times (or SLA) |
8A66 |
| x3 | x2 + original | |
| x5 | x4 + original | |
| x7 | x8 - original |
Division by powers of 2 uses right-shift chains (
SRL). For
example, dividing by 8 requires three SRL A instructions, as
seen at 7373.
For non-power-of-2 division, the code uses repeated subtraction. For example,
at $7387 the operation
C = C - E*4 is performed as four
consecutive SUB E instructions rather than using a
shift-and-subtract algorithm.
Increment-then-correct for bidirectional movement
When a tank on a river bank moves (invert_tank_offset_delta, operate_tank_on_bank), the code always
increments the coordinate first, then checks which bank the tank is on. If
it is on the right bank, it subtracts twice the increment to effectively
decrement:
This replaces a conditional branch (to choose increment vs decrement) with
unconditional arithmetic followed by a correction. It saves a few bytes but
looks counterintuitive when reading the code.
Stack pointer tricks for computed jumps
The menu system at setup manipulates the stack pointer directly: it sets SP
to point at setup_sp, a table of return addresses. Each subsequent RET
instruction pops an address from this table and jumps to it, creating a chain
of computed GOTOs. The original stack pointer is saved at saved_stack_pointer and
restored after the menu sequence completes.
Useless trampoline at $76AC
The routine at jp_operate_viewport_slots (
jp_operate_viewport_slots) is a single
unconditional JP operate_viewport_slots instruction. It adds no logic —
callers could jump to operate_viewport_slots directly. This is likely a remnant of
refactoring where an intermediate routine was removed but its entry point was
preserved.
Dead calculation at $7695
At $7695, 8 bytes calculate a frame offset from the frame counter. However,
the result is immediately overwritten by balloon-specific parameters in the
instructions that follow. This dead code is likely left over from a time when
this code path handled multiple object types before being specialized for
balloons only.
Unused explosion animation at $8AD8
A 6-frame animation at sprite_unused_explosion shows a diamond shape expanding from a single
pixel to a full diamond (48 bytes total). This appears to be an
early or alternate explosion effect that was cut from the final game. The
sprites are already rendered as UDGs in the disassembly listing.
Unused variables and data areas
Several variables and data areas in the game binary are never accessed by any
code:
| Address | Description |
|---|---|
| state_unused_5F6F | State variable: cleared during init but never read |
| data_unused_64B4 | Data area (purpose unknown) |
| data_unused_6C2B | Data area (purpose unknown) |
| data_unused_7727 | Data area (purpose unknown) |
| data_unused_8391 | 32-byte alternate road attributes using $3F/$C0 values (flash + invisible); possibly a debug or early version of the road rendering |
| data_unused_8B18 | Data area in sprite renderer |
| data_unused_8B1B | Data area in sprite renderer |
| data_unused_8C4A | Data area in sprite renderer |
RETN instead of RETI in interrupt handler
The interrupt handler at int_handler uses RETN (return from non-maskable
interrupt, opcode $ED $45) instead of RETI (return from maskable interrupt,
opcode $ED $4D). On a standard ZX Spectrum without daisy-chained Z80
peripherals, these are functionally equivalent — both copy IFF2 to IFF1 and
pop the return address. The distinction only matters for Z80 peripheral chips
(PIO, SIO, CTC) that monitor the data bus for the specific RETI opcode to
manage their interrupt priority chains.