Fork me on GitHub
Game loop runs at ~12 FPS
The game loop runs at approximately 12 FPS despite the ZX Spectrum's 50 Hz interrupt rate. This was measured programmatically using a custom FPS counter that tracks state_tick increments per 50 interrupts. The gap is caused by the scroll routine taking multiple frame periods to complete.
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 sound_flags (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.
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.
Pixel-perfect collision via rendering pipeline
The sprite renderer doubles as the collision detector. The two-phase pipeline at render_object XOR-erases the old sprite, then OR-draws the new one; if OR-ing onto non-zero screen data, a collision is flagged and dispatched via collision_dispatcher. There is no separate collision map — the framebuffer itself is the collision surface.
This means any non-zero screen content — including attribute artifacts, explosion debris, or data written via POKE during a pause — acts as a collision surface. See also Collision detection works on raw screen pixels.
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.
Black-on-black buffer zone for scroll-in
Attribute row 0 (screen_attributes) is kept permanently black, making it an invisible 8-pixel buffer. Objects spawning at the top scroll into view from behind it. This is sufficient for 1-tile sprites (ship, helicopter) but not for balloons (16 px) or fuel stations (25 px) — their lower portions appear immediately.
The overview text crawl uses a similar 1-column buffer at column 31 of char row 23: characters are printed there with black ink + paper and shifted left into view by scroll_text_crawl.
Only one tank shell and one helicopter missile at a time
Each projectile type has a single global state variable: tank_shell_state (tank_shell_state) for tank shells and helicopter_missile_coordinates_ptr (helicopter missile coordinates) for helicopter missiles. When any tank tries to fire, it checks TANK_SHELL_BIT_FLYING in tank_shell_state and skips firing if the bit is set. When any advanced helicopter tries to fire, it checks the Y coordinate in helicopter_missile_coordinates_ptr and returns if non-zero. All tanks share the one shell slot; all advanced helicopters share the one missile slot.
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.
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.
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.
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 × 256 bytes = 12,288 bytes level_terrains
Object spawns 48 levels × 256 bytes = 12,288 bytes level_objects
The terrain data is reasonably efficient (64 fragments × 4 bytes = 256 bytes per level). However, the object spawn data uses 128 slots × 2 bytes per level, where most slots are empty (X position = 0). 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.
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.
Unused control selection screen
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.
Useless single-instruction trampoline
The routine at 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 frame offset calculation in balloon handler
At dead_frame_offset, 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 alternate explosion animation
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 Alternate road attributes (possibly debug or early version)
data_unused_8B18 Data area in sprite renderer
data_unused_8B1B Data area in sprite renderer
data_unused_8C4A Data area in sprite renderer
Several of these areas begin with the bytes $C3,$90,$EA — the Z80 encoding of JP $EA90. Since $EA90 falls within the object spawn tables and has no special meaning in the shipped game, this is likely a fill pattern from the development toolchain: a debug trap that would redirect stray execution to a fixed address (presumably a monitor or debugger) on the development system.