- Game loop runs at ~12 FPS
- Multi-channel interrupt-driven sound
- Pre-shifted sprites (and wasted fighter frames)
- Pixel-perfect collision via rendering pipeline
- Self-modifying code for blending modes
- Black-on-black buffer zone for scroll-in
- Only one tank shell and one helicopter missile at a time
- Arithmetic directly on ASCII digit strings
- Increment-then-correct for bidirectional movement
- Player 2 logic was a late addition
- Level data stored wastefully
- Island and bank rendering heavily duplicated
- Unused control selection screen
- Useless single-instruction trampoline
- Dead frame offset calculation in balloon handler
- Unused alternate explosion animation
- Unused variables and data areas
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:
- 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.
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:
- 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 print_player_2_score_digit (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.
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.