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:
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:
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.