Asteroids Destroyer Dev Log – 21 April 2026

Asteroids Destroyer โ€” Development Journal

Dev Log โ€” 21 April 2026

๐Ÿ“… Session 5
๐ŸŽฎ Godot 4.6
โš™๏ธ Physics
๐Ÿ† Ranks
๐Ÿ› UI Fixes
๐Ÿ“‹
Session Overview
Six issues tackled: asteroids, power-ups, level completion, endless mode, ranks, main menu

I had multiple outstanding bugs from the previous session plus some new issues I’d spotted. Got through all of them:

  1. Power-ups not spawning; the export property was never assigned in the editor
  2. Level not completing, caused by WAVE_CLEAR_DELAY = 0.0 caused the timer check to never fire
  3. Asteroids drifting off-screen leaving the player with nothing to do
  4. Endless mode would show the Level Complete screen instead of looping
  5. Player rank badge showing the wrong rank on start
  6. Settings button hover box on the Main Menu was far wider than its icon
๐Ÿ’Š
Power-ups Not Spawning
scripts/game/GameScene.gd

Root causepowerup_scene was declared as @export var, which means it must be manually assigned in the Godot Editor inspector. If the editor step was skipped, the variable stays null and _try_spawn_powerup exits immediately on its null-check.

Fix

Removed the @export and replaced with a direct preload() call. The scene is always the same file so there’s no reason to make it configurable via the editor:

# Before @export var powerup_scene: PackedScene # After var powerup_scene: PackedScene = preload(“res://scenes/entities/PowerUp.tscn”)

This change also removes the risk of a future scene reload forgetting to re-assign the export.

โœ…
Level Not Completing
scripts/game/GameScene.gd

Root causeWAVE_CLEAR_DELAY = 0.0 (set to keep the screen always busy). In _process(), the completion check was if wave_clear_timer > 0.0. Setting the timer to exactly 0.0 makes this check permanently false, so _next_wave_or_complete() is never called.

Fix

Both places that set the timer now branch on the zero-delay case and call the function directly via call_deferred() instead of going through the timer:

if asteroids_alive <= 0 and _spawn_queue.is_empty() and is_wave_active: is_wave_active = false if WAVE_CLEAR_DELAY <= 0.0: _next_wave_or_complete.call_deferred() else: wave_clear_timer = WAVE_CLEAR_DELAY

The deferred call ensures we don’t accidentally call into wave logic mid-physics-frame. This fix was applied to both _on_asteroid_destroyed and the off-screen cleanup function.

๐Ÿชจ
Asteroid Boundary Bounce System
scripts/entities/Asteroid.gd ยท scripts/game/GameScene.gd

Previously asteroids drifted freely and were deleted by a periodic off-screen cleanup job after travelling 500px past the screen edge. This left gaps where no asteroids were visible. I also wanted all new wave spawns to come from outside the playable area and drift inward.

Invisible Bounce Wall

Asteroid.gd now overrides _integrate_forces, the correct hook for modifying a RigidBody2D’s state without fighting the physics engine. An invisible boundary sits 100px outside each screen edge. When an asteroid crosses it, its velocity component on that axis is reflected (sign-flipped) and its position is clamped back to the wall:

const BOUNCE_MARGIN: float = 100.0 func _integrate_forces(state: PhysicsDirectBodyState2D) -> void: var pos: Vector2 = state.transform.origin var vel: Vector2 = state.linear_velocity var hit: bool = false if pos.x < -BOUNCE_MARGIN: pos.x = -BOUNCE_MARGIN; vel.x = absf(vel.x); hit = true elif pos.x > _screen_size.x + BOUNCE_MARGIN: pos.x = _screen_size.x + BOUNCE_MARGIN; vel.x = -absf(vel.x); hit = true if pos.y < -BOUNCE_MARGIN: pos.y = -BOUNCE_MARGIN; vel.y = absf(vel.y); hit = true elif pos.y > _screen_size.y + BOUNCE_MARGIN: pos.y = _screen_size.y + BOUNCE_MARGIN; vel.y = -absf(vel.y); hit = true if hit: var t: Transform2D = state.transform t.origin = pos state.transform = t state.linear_velocity = vel

Because the bounce wall is outside the visible screen (100px past the edge), asteroids briefly become invisible before they return, giving the effect of a hard invisible barrier just outside view.

Cleanup Safety Net Updated

The periodic off-screen cleanup in GameScene was kept as an extreme safety valve (raised from 500px to 1200px) in case something glitches through the boundary. The cleanup interval was also relaxed from 3s to 5s since it should almost never trigger.

Spawn Rules: Already Met

New wave spawns already follow the required rules: ~25% spawn at on-screen quadrant positions at the moment the wave begins (the “level load burst”), while the remaining 75% enter from the 8 fixed edge points and drift toward the center. Split asteroids from destroyed parents spawn at the parent’s last position (on-screen), which is expected behaviour. No changes were needed to the spawn system itself.

โ™พ๏ธ
Endless Mode: Level Complete Instead of Looping
scripts/game/GameScene.gd

Root cause_next_wave_or_complete() unconditionally called _level_complete() after all 3 waves. In endless mode (gm.current_level == 0) this loaded the Level Complete screen and incremented the level, which is completely wrong behaviour.

Fix

Added an endless-mode branch that resets current_wave to 0 and restarts the wave cycle, looping forever:

func _next_wave_or_complete() -> void: if current_wave >= WAVES_PER_LEVEL: if _gm.current_level == 0: # endless mode โ€” loop waves forever current_wave = 0 _start_wave() else: _level_complete() else: _start_wave()

Wave difficulty still scales with _gm.current_level via _get_wave_composition() and _spawn_interval(). In endless mode current_level = 0, so the game uses the easiest settings permanently. That is intentional for a relaxed endless experience. If escalating difficulty is wanted in endless, that can be driven off a separate session counter later.

๐ŸŽ–๏ธ
Player Rank Badge Showing Wrong Rank
scripts/autoload/GameManager.gd

Root causeGameManager.load_game() restored current_rank from the save file verbatim without verifying it against total_score. If a save file was written by an older build before rank-up logic existed, or if the save was otherwise inconsistent, current_rank could be 0 even when total_score had exceeded multiple rank thresholds. The HUD then displays the rank-0 badge regardless of actual progress.

Rank Recalculation on Load

After all save values are loaded, _recalculate_rank() walks the threshold array and sets current_rank to the highest index that total_score satisfies. This always results in a rank consistent with the actual score:

func _recalculate_rank() -> void: var r: int = 0 for i in RANK_THRESHOLDS.size(): if total_score >= RANK_THRESHOLDS[i]: r = i else: break current_rank = r

Called at the end of load_game(). Nukes are not retroactively re-awarded since nukes_held is already loaded from the save.

โš™๏ธ
Main Menu: Settings Hover Box Too Large
scenes/menus/MainMenu.tscn

The Settings button (bottom-right) had a hit area of 344ร—248px while the Ships button (bottom-left, which I’d confirmed was correct) is 202ร—256px. The oversized Settings button made the hover glow extend far beyond the icon.

Fix

Resized BtnSettings to 202ร—256px, flush to the right screen edge, matching the proportions of BtnShop:

Property Before After
offset_left 1632 1718
offset_right 1976 1920
offset_bottom 1071 1079

Width is now 202px (matching BtnShop). The button is right-edge-flush at x=1920, the same way BtnShop is left-edge-flush at x=3.

๐Ÿ›ก๏ธ
Shield Sprite Scale Reduced (carry-over from 19 Apr)
scripts/entities/Player.gd

Shield overlay was set to Vector2(1.6, 1.6) which made it far larger than the ship. Reduced to Vector2(0.9, 0.9). The shield textures are already larger than the ship sprite, so 0.9ร— sits just outside the ship boundary without looking oversized. Fine-tune in 0.1 increments if needed.

๐ŸŒŠ
Wave Spawn System Overhaul
scripts/entities/Asteroid.gd ยท scripts/game/GameScene.gd

Three separate issues were causing asteroids to be invisible for extended periods, leaving the player with nothing to shoot:

1. Bounce Wall Too Far Out (Asteroid.gd)

IssueBOUNCE_MARGIN was 100px outside the screen edge. An asteroid could travel 100px off-screen, completely invisible, before bouncing back. Near the end of a wave when only a few asteroids remained, the player could be left staring at an empty screen for several seconds.

Fix Reduced to 20px. Asteroids barely clip the edge before returning, staying visible at all times.

const BOUNCE_MARGIN: float = 20.0 # was 100.0

2. Spawn Points Too Far From Screen (GameScene.gd)

IssueSPAWN_MARGIN was 200px outside the screen. A slow large asteroid (50 px/s minimum speed) took over 4 seconds just to enter the visible area from its spawn point, and the player saw nothing during this time.

Fix Reduced to 80px. Worst-case entry time is now under 2 seconds; fast asteroids appear almost immediately.

const SPAWN_MARGIN: float = 80.0 # was 200.0

3. On-Screen Burst at Wave Start Removed (GameScene.gd)

Issue_start_wave() spawned ~25% of the wave’s asteroids directly inside the playable area. This broke the design rule that all new asteroids should enter from outside the screen.

Fix Removed the on-screen burst entirely. Every asteroid in every wave now queues from one of the 8 edge spawn points and drifts toward the center. The _quadrant_positions() helper was deleted as it is no longer used.

4. First Spawn Delay Removed (GameScene.gd)

Issue After the queue was built, _spawn_timer was set to _spawn_interval() (1.2s at level 1), meaning the very first asteroid waited over a second before spawning, leaving an empty screen at wave start.

Fix Timer is now set to 0.0 after queue build so the first asteroid spawns on the very next frame.

_spawn_timer = 0.0 # was _spawn_interval() โ€” first asteroid now spawns immediately

Power-up spawn behaviour: no change needed

Power-ups already spawn at the asteroid’s destroyed position and drift from there, the “drops on destroy” approach. This is correct behaviour and was left unchanged.

โš ๏ธ
Wave Incoming Warning
scripts/game/GameScene.gd

Between waves there was no visual feedback. The screen went quiet and the player had no warning the next wave was coming. Added a red flashing WAVE X label that appears for ~1.8 seconds before each new wave starts.

Implementation

_next_wave_or_complete() now routes through _show_wave_warning() instead of calling _start_wave() directly. The warning creates a temporary CanvasLayer overlay (layer 8, above the game), adds a centred 96px red label, flashes it 4 times via Tween, then frees the overlay and calls _start_wave() via a tween callback:

var tw := create_tween() for _i in 4: tw.tween_property(lbl, “modulate:a”, 0.08, 0.20).set_ease(Tween.EASE_IN) tw.tween_property(lbl, “modulate:a”, 1.00, 0.22).set_ease(Tween.EASE_OUT) tw.tween_callback(cl.queue_free) tw.tween_callback(_start_wave)

Works for both levelled and endless modes. In endless, the wave counter resets to 0 before the warning so it correctly shows “WAVE 1” at the start of each new cycle.

๐Ÿ†
Level Complete Screen: Full Redesign
scenes/menus/LevelComplete.tscn ยท scripts/menus/LevelComplete.gd

The previous Level Complete screen used a plain panel with text labels and no visual identity. Rebuilt to match the original Buildbox screenshots: space background, Score/Best on either side, centred content with a cyan tech arc frame, selected ship inside the frame, animated stars, rank promotion message, and styled Continue/Retry buttons.

Layout (1920ร—1080)

Element Position Notes
Background Full screen Same space BG as game scene
Score label Left, x 40โ€“320, y 400โ€“545 Cyan header + white value
Best label Right, x 1600โ€“1880, y 400โ€“545 Same style
“Level Complete” Centre, y 108โ€“162 34px, pale blue
“Congratulations” Centre, y 162โ€“258 64px, white
Stars ร—3 Centre, y 258โ€“370 Animated, 92px
Arc frame Centre, y 344โ€“672 ship_select_frame.png
Ship display Inside frame, y 372โ€“618 Level Complete ship variant
Thrusters Below ship, y 606โ€“672 Thrusters.png
Rank message Centre, y 678โ€“772 “You Have Been Promoted / [Rank]”
Continue button Centre, y 788โ€“876 Cyan border, pill shape
Retry button Centre, y 892โ€“958 Smaller, same style

Star Animation

Stars are hidden (scale 0, alpha 0) at build time and animated in sequence via create_tween(). Each star is staggered by 0.42 s:

  • Earned star: fades in, overshoots to scale 1.45 (TRANS_BACK EASE_OUT), settles to 1.0, in gold (#FFB800)
  • Unearned star: fades in, scales to 0.75, in dim grey

Scene Architecture

All visual nodes are explicitly defined in LevelComplete.tscn and are fully visible in the Godot editor. LevelComplete.gd uses @onready references to reach those nodes, and contains no _build_ui() and creates no nodes at runtime. Content (score, ship texture, rank text) is populated in _populate(), called from setup(stars, score, completed_level) after the scene is added to the tree.

Assets Used

  • Game Assests/UI/Level Complate/ship_select_frame.png: cyan tech arc
  • Game Assests/UI/Level Complate/Ships/[ship].png: flat ship art per variant
  • Game Assests/UI/Level Complate/Ships/Thrusters.png: flame sprite
๐Ÿ”
Level Complete: Blank Screen (Two Root Causes)
scenes/menus/LevelComplete.tscn ยท scripts/menus/LevelComplete.gd

After the Level Complete redesign the screen appeared completely blank in-game. Two separate root causes were found and fixed.

Cause 1: GDScript parse error with const :=

Root cause In GDScript 4, const declarations must use =. Using := (the type-inferring assignment for var) on a const is a parse error. All six asset path constants had been written with :=:

# WRONG โ€” silent parse error, entire script fails to load const _FONT_PATH := “res://Assests/Fonts/neuropol/neuropol.ttf” # CORRECT const _FONT_PATH = “res://Assests/Fonts/neuropol/neuropol.ttf”

The parse failure meant the script never loaded at all, leaving the CanvasLayer empty. Fixed by changing all six constants to use =.

Cause 2: Programmatic _build_ui() invisible in editor

Root cause The script built every node at runtime inside _build_ui(). The Godot editor has no way to preview runtime-created nodes, so the scene tree showed only the bare CanvasLayer root. This made it impossible to visually inspect or adjust the layout without running the game, and any future design changes required editing code rather than the editor.

Fix Rewrote LevelComplete.tscn with every node explicitly declared in the scene file. Rewrote LevelComplete.gd to use @onready references to those nodes. The script now only handles:

  • Connecting button signals in _ready()
  • Populating dynamic content (score, ship texture, rank text) in _populate()
  • Running the star animation in _animate_stars()
  • Navigation via _next_level() / _retry()

The screen is now fully visible and editable in the Godot editor scene view.

๐Ÿ”ข
Level Select: Duplicate Level Number Removed
scripts/menus/LevelSelect.gd

Root cause The level icon images (Level %d.png) already have the white level number baked into the artwork. The _make_tile() function was also adding a programmatic Label on top of the icon, coloured turquoise (Color(0.0, 0.95, 1.0)), resulting in two numbers visible on every unlocked tile: the white one from the texture and a cyan one from the label.

Fix Removed the num_lbl Label block entirely. The white number from the icon asset is the correct display.

๐ŸŽ–๏ธ
Rank Badge Now Displayed on Level Complete Screen
scenes/menus/LevelComplete.tscn ยท scripts/menus/LevelComplete.gd

Issue The Level Complete screen showed the rank name as text but the rank badge image was never displayed. The old script had a _RANK_PATH_FMT constant that was dropped during the @onready rewrite.

Fix

Added a RankBadge TextureRect node to LevelComplete.tscn at the left-hand side of the screen (x=50โ€“340, y=560โ€“850, 290ร—290 px). This occupies the empty space below the score readout without disturbing the centred ship/stars/buttons layout.

var rank_num: int = clampi(_gm.current_rank + 1, 1, 19) var badge_path: String = “res://Sci Fi Ranks/Sliver Ranks/Ranks/rank_%02d.png” % rank_num var badge_tex := load(badge_path) as Texture2D if badge_tex: _rank_badge.texture = badge_tex

Badge files are rank_01.png โ†’ rank_19.png in the Silver Ranks folder. Index = current_rank + 1, clamped to [1, 19].

Sub-label Updated

The SubLabel text now shows rank name and coins earned on one line, and correctly detects whether a promotion happened this session:

var promoted: bool = _gm.current_rank > _gm.session_start_rank if promoted: _sub_label.text = “You Have Been Promoted!\n%s ยท +%d coins” % [rank_name, _gm.last_coins_earned] else: _sub_label.text = “Rank: %s ยท +%d coins” % [rank_name, _gm.last_coins_earned]
๐Ÿš€
Ship Auto-Progression System
scripts/autoload/GameManager.gd ยท scripts/entities/Player.gd

Ships now auto-upgrade within their series (X1โ†’X2โ†’X3) as the player’s rank increases. Advancing within a series is free; coins are only needed to buy the first ship in the next series.

Rank โ†’ Variant Mapping

Rank range Ship variant Thrusters Rockets per shot
0โ€“5 (Lieutenant / Officer) X1 Centre only 1
6โ€“11 (Squadron / Wing) X2 Side wings 2
12โ€“18 (Group Captain+) X3 Centre + wings 3

Auto-Upgrade Logic (GameManager.gd)

get_ship_variant() computes the correct variant from the current rank. _auto_upgrade_ship() is called from _check_rank_up(). It calculates the new ship index within the same series and updates selected_ship (and unlocks it for free) if the variant increased:

func _auto_upgrade_ship() -> void: var series: int = selected_ship / 3 var new_variant: int = get_ship_variant() var new_ship: int = series * 3 + new_variant if new_ship > selected_ship: selected_ship = new_ship if not unlocked_ships[selected_ship]: unlocked_ships[selected_ship] = true

Upgrade only moves forward: if the player buys and equips a higher-tier series, that series upgrades independently.

Multi-Rocket Firing (Player.gd)

_handle_fire() now reads gm.get_ship_variant() each shot. X2 fires from two wing positions; X3 fires from all three:

match variant: 0: # X1 โ€” single centre gun bullet_fired.emit(gun_barrel.global_position, rotation) 1: # X2 โ€” two wing guns var left_pos := global_position + Vector2(-18.0, -25.0).rotated(rotation) var right_pos := global_position + Vector2( 18.0, -25.0).rotated(rotation) bullet_fired.emit(left_pos, rotation) bullet_fired.emit(right_pos, rotation) 2: # X3 โ€” centre + two wings bullet_fired.emit(gun_barrel.global_position, rotation) bullet_fired.emit(left_pos, rotation) bullet_fired.emit(right_pos, rotation)

All bullets travel parallel (same rotation, different spawn origin) so X3 delivers a spread that still hits a focused target at long range.

๐Ÿ’ฐ
Coin Rewards on Level Complete
scripts/autoload/GameManager.gd ยท scripts/game/GameScene.gd ยท scripts/menus/LevelComplete.gd

Players now earn coins each time they complete a level. Coins accumulate toward purchasing the next ship series from the shop.

Formula

Stars earned Coins awarded
1 star 15 coins
2 stars 20 coins
3 stars 25 coins

By the time the player reaches rank 12 (X3 ship, ~93 level completions), they should have accumulated well over 600 coins, enough to purchase the DKO series X1 (600 coins). This satisfies the design goal of reaching max ship variant and having enough coins to buy the next series.

Implementation

award_level_coins(stars) was added to GameManager and called in GameScene._level_complete() after stars are calculated. It stores the amount in last_coins_earned (session-only, not persisted) so the Level Complete screen can display it.

๐Ÿ“Œ
Open Items
Pending for future sessions
  • Mobile joystick input not wired to player movement
  • Audio bus muting on session start (respect saved music_enabled / sfx_enabled)
  • HUD nuke count display (update_nukes() stub still returns pass)
  • Nuke activation mechanic for rank-earned nukes (stored in nukes_held, no key binding yet)
  • Coin shop / IAP integration
  • Endless mode difficulty scaling (currently stays at level-0 speed forever)
  • Level Complete screen: visual polish pass still needed