Space Invaders 2D¶

the arcade classic rebuilt on nodes, signals and synthesized sound.

â–¶ Run in browser

Tags: game collision waves shooting

Five rows of aliens march across the screen, drop closer every time the formation touches an edge, and shoot back from the bottom of each column. Four barriers erode block by block as fire lands on them, and a mystery ship crosses the top on the arcade’s 15-shot scoring cycle. Clear the fleet and the next wave arrives faster and fires more often, so the run ends when your three lives do, or when the aliens reach the ground.

Every pixel and every sound is generated in code: the demo ships no asset files.

  • 8x8 bit-pattern sprites drawn as filled rects, two frames per alien type driving the marching animation

  • Shot, explosion, march-step and UFO-siren clips synthesized with NumPy at import time and wrapped as AudioClip.from_pcm buffers

  • One-shot AudioPlayer nodes that reap themselves via queue_free_on_end, mixed through the SFX bus the options popup drives live

  • Scene-tree groups (aliens, bullets, barriers, the player, the mystery ship) backing the circle-overlap collision queries, with no physics world involved

  • Per-instance signals (died, wave_cleared, reached_bottom, hit, fired) keeping the rules out of the actors

  • Timer nodes for the fire rate, the alien volley, respawn invulnerability and the wave banner

  • tree.change_scene between menu, game and game-over, with declarative input_actions re-registered on every swap

  • Anchored Label HUD plus a modal show_overlay popup with Slider volume controls

  • Camera2D.shake on a player death, over a parallax starfield

Controls: arrows or A/D move, SPACE fires, ENTER starts. With a mouse or a touchscreen, hold anywhere to slide the cannon toward the pointer and fire while held; the AUDIO OPTIONS button (or O) opens the mixer.

Source¶

   1#!/usr/bin/env python3
   2"""Space Invaders 2D: the arcade classic rebuilt on nodes, signals and synthesized sound.
   3
   4# /// simvx
   5# tags = ["game", "collision", "waves", "shooting"]
   6# web = { root = "MainMenu" }
   7# ///
   8
   9Five rows of aliens march across the screen, drop closer every time the
  10formation touches an edge, and shoot back from the bottom of each column. Four
  11barriers erode block by block as fire lands on them, and a mystery ship crosses
  12the top on the arcade's 15-shot scoring cycle. Clear the fleet and the next wave
  13arrives faster and fires more often, so the run ends when your three lives do,
  14or when the aliens reach the ground.
  15
  16Every pixel and every sound is generated in code: the demo ships no asset files.
  17
  18- 8x8 bit-pattern sprites drawn as filled rects, two frames per alien type
  19  driving the marching animation
  20- Shot, explosion, march-step and UFO-siren clips synthesized with NumPy at
  21  import time and wrapped as ``AudioClip.from_pcm`` buffers
  22- One-shot ``AudioPlayer`` nodes that reap themselves via ``queue_free_on_end``,
  23  mixed through the ``SFX`` bus the options popup drives live
  24- Scene-tree groups (aliens, bullets, barriers, the player, the mystery ship)
  25  backing the circle-overlap collision queries, with no physics world involved
  26- Per-instance signals (``died``, ``wave_cleared``, ``reached_bottom``, ``hit``,
  27  ``fired``) keeping the rules out of the actors
  28- ``Timer`` nodes for the fire rate, the alien volley, respawn invulnerability
  29  and the wave banner
  30- ``tree.change_scene`` between menu, game and game-over, with declarative
  31  ``input_actions`` re-registered on every swap
  32- Anchored ``Label`` HUD plus a modal ``show_overlay`` popup with ``Slider``
  33  volume controls
  34- ``Camera2D.shake`` on a player death, over a parallax starfield
  35
  36Controls: arrows or A/D move, SPACE fires, ENTER starts. With a mouse or a
  37touchscreen, hold anywhere to slide the cannon toward the pointer and fire while
  38held; the AUDIO OPTIONS button (or O) opens the mixer.
  39"""
  40
  41import random
  42
  43import numpy as np
  44
  45from simvx.core import (
  46    AudioClip,
  47    AudioPlayer,
  48    Camera2D,
  49    Input,
  50    Key,
  51    MouseButton,
  52    Node,
  53    Node2D,
  54    Property,
  55    Signal,
  56    Timer,
  57    Vec2,
  58)
  59from simvx.core.audio_bus import AudioBusLayout
  60from simvx.core.ui import AnchorPreset, Button, Control, Label, Panel, Slider
  61from simvx.graphics import App
  62
  63WIDTH, HEIGHT = 800, 600
  64
  65# Declared on every root scene below: the tree bulk-registers a root's
  66# ``input_actions`` on mount and again after each ``change_scene``, so no scene
  67# depends on another having run first.
  68INPUT_ACTIONS = {
  69    "move_left": [Key.A, Key.LEFT],
  70    "move_right": [Key.D, Key.RIGHT],
  71    "fire": [Key.SPACE],
  72    "start": [Key.ENTER, MouseButton.LEFT],
  73    "options": [Key.O],
  74}
  75
  76
  77class Body2D(Node2D):
  78    """A manually integrated 2D actor with a collision radius + group overlap query.
  79
  80    Space Invaders is an arcade game with NO physics simulation: aliens, bullets
  81    and the player move by directly setting ``position`` and collisions are plain
  82    circle-vs-circle radius tests. So this is deliberately not a physics body
  83    (these entities need no collider at all): it is a light ``Node2D`` carrying a
  84    radius and a group-scoped overlap poll.
  85    """
  86
  87    def __init__(self, radius: float = 8.0, **kwargs):
  88        super().__init__(**kwargs)
  89        self.radius = float(radius)
  90
  91    def get_overlapping(self, group: str) -> list[Body2D]:
  92        """Other ``Body2D`` nodes in ``group`` whose radius overlaps ours."""
  93        if not self.tree:
  94            return []
  95        hits: list[Body2D] = []
  96        for b in self.tree.group(group):
  97            if b is self or not isinstance(b, Body2D):
  98                continue
  99            d = b.world_position - self.world_position
 100            rr = self.radius + b.radius
 101            if float(d.x) ** 2 + float(d.y) ** 2 <= rr * rr:
 102                hits.append(b)
 103        return hits
 104
 105
 106PIXEL = 3  # scale for 8x8 sprites
 107SAMPLE_RATE = 48000  # matches the engine mixer, so the clips need no resampling
 108
 109# 8x8 bit-pattern sprites (each int = one row, bit 7 = leftmost pixel).
 110# Two frames per type drives the iconic "step" animation.
 111ALIEN_SQUID_A = [0x18, 0x3C, 0x7E, 0xDB, 0xFF, 0x24, 0x5A, 0xA5]
 112ALIEN_SQUID_B = [0x18, 0x3C, 0x7E, 0xDB, 0xFF, 0x24, 0xA5, 0x5A]
 113ALIEN_CRAB_A = [0x24, 0x18, 0x3C, 0x5A, 0x7E, 0x24, 0x24, 0x42]
 114ALIEN_CRAB_B = [0x24, 0xA5, 0x3C, 0x5A, 0x7E, 0x24, 0x42, 0x24]
 115ALIEN_OCTOPUS_A = [0x18, 0x3C, 0x7E, 0xDB, 0xFF, 0x5A, 0x81, 0x42]
 116ALIEN_OCTOPUS_B = [0x18, 0x3C, 0x7E, 0xDB, 0xFF, 0x5A, 0x42, 0x81]
 117ALIEN_UFO = [0x3C, 0x7E, 0xFF, 0xDB, 0xFF, 0x7E, 0x24, 0x00]
 118
 119ALIEN_TYPES = [
 120    ((ALIEN_SQUID_A, ALIEN_SQUID_B), (1.0, 0.2, 0.2), 30),  # squid, red, 30 pts
 121    ((ALIEN_CRAB_A, ALIEN_CRAB_B), (0.2, 1.0, 0.2), 20),  # crab, green, 20 pts
 122    ((ALIEN_OCTOPUS_A, ALIEN_OCTOPUS_B), (0.2, 0.59, 1.0), 10),  # octopus, blue, 10 pts
 123]
 124
 125# Formation geometry: one squid row, two crab rows, two octopus rows.
 126ALIEN_ROW_TYPES = [0, 1, 1, 2, 2]
 127ALIEN_ROWS = len(ALIEN_ROW_TYPES)
 128ALIEN_COLS = 11
 129ALIEN_SPACING_X = 40
 130ALIEN_SPACING_Y = 36
 131
 132# Canonical Invaders mystery-ship 15-cycle (player shot count → points).
 133MYSTERY_POINTS_CYCLE = [100, 50, 50, 100, 150, 100, 100, 50, 300, 100, 100, 100, 50, 150, 100]
 134
 135BARRIER_PATTERN = [
 136    "  #######  ",
 137    " ######### ",
 138    "###########",
 139    "###########",
 140    "###########",
 141    "###########",
 142    "###  #  ###",
 143    "##   #   ##",
 144]
 145BARRIER_PIXEL = 3
 146
 147
 148# ---------------------------------------------------------------------------
 149# Procedural audio: generated once at import time. The same numpy buffers feed
 150# the desktop (miniaudio) and web (Web Audio) backends unchanged.
 151# ---------------------------------------------------------------------------
 152
 153
 154def _envelope(n_frames: int, attack: float = 0.005, release: float = 0.05) -> np.ndarray:
 155    env = np.ones(n_frames, dtype=np.float32)
 156    a = min(int(SAMPLE_RATE * attack), n_frames // 4)
 157    r = min(int(SAMPLE_RATE * release), n_frames // 2)
 158    if a:
 159        env[:a] = np.linspace(0, 1, a, dtype=np.float32)
 160    if r:
 161        env[-r:] = np.linspace(1, 0, r, dtype=np.float32)
 162    return env
 163
 164
 165def _clip(name: str, mono: np.ndarray) -> AudioClip:
 166    """Wrap a mono float32 buffer as a centred stereo ``AudioClip``.
 167
 168    ``AudioClip.from_pcm`` is the sanctioned way to hand the engine a
 169    pre-rendered buffer: it records the rate and channel count alongside the
 170    samples, so nothing downstream has to guess how to play them.
 171    """
 172    mono = np.clip(mono, -1.0, 1.0).astype(np.float32)
 173    interleaved = np.empty(mono.size * 2, dtype=np.float32)
 174    interleaved[0::2] = mono
 175    interleaved[1::2] = mono
 176    return AudioClip.from_pcm(interleaved, sample_rate=SAMPLE_RATE, channels=2, name=name)
 177
 178
 179def _make_shoot() -> AudioClip:
 180    duration = 0.10
 181    n = int(SAMPLE_RATE * duration)
 182    sweep = np.linspace(880.0, 220.0, n, dtype=np.float32)
 183    phase = 2 * np.pi * np.cumsum(sweep) / SAMPLE_RATE
 184    sig = 0.35 * np.sign(np.sin(phase)) * _envelope(n, 0.002, 0.05)
 185    return _clip("sfx:shoot", sig)
 186
 187
 188def _make_explosion() -> AudioClip:
 189    duration = 0.35
 190    n = int(SAMPLE_RATE * duration)
 191    noise = np.random.uniform(-1, 1, n).astype(np.float32)
 192    sweep = np.linspace(1.0, 0.2, n, dtype=np.float32)
 193    sig = 0.45 * noise * sweep * _envelope(n, 0.001, 0.15)
 194    return _clip("sfx:explosion", sig)
 195
 196
 197def _make_step(freq: float) -> AudioClip:
 198    duration = 0.08
 199    n = int(SAMPLE_RATE * duration)
 200    t = np.linspace(0, duration, n, dtype=np.float32)
 201    sig = 0.4 * np.sign(np.sin(2 * np.pi * freq * t)) * _envelope(n, 0.002, 0.02)
 202    return _clip(f"sfx:step:{freq:.0f}", sig)
 203
 204
 205def _make_ufo_loop() -> AudioClip:
 206    duration = 0.40
 207    n = int(SAMPLE_RATE * duration)
 208    t = np.linspace(0, duration, n, dtype=np.float32)
 209    # Wobble between 600 and 1100 Hz: classic UFO siren.
 210    freq = 850.0 + 250.0 * np.sin(2 * np.pi * 6.0 * t)
 211    phase = 2 * np.pi * np.cumsum(freq) / SAMPLE_RATE
 212    sig = 0.25 * np.sin(phase).astype(np.float32)
 213    return _clip("sfx:ufo_loop", sig)
 214
 215
 216def _make_ufo_hit() -> AudioClip:
 217    duration = 0.45
 218    n = int(SAMPLE_RATE * duration)
 219    t = np.linspace(0, duration, n, dtype=np.float32)
 220    # Three descending tones blended with noise for a "chord crash".
 221    tone = sum(np.sin(2 * np.pi * f * t) for f in (660.0, 440.0, 220.0)) / 3.0
 222    noise = np.random.uniform(-0.4, 0.4, n).astype(np.float32)
 223    sig = 0.4 * (tone + 0.5 * noise) * _envelope(n, 0.001, 0.2)
 224    return _clip("sfx:ufo_hit", sig)
 225
 226
 227SFX_SHOOT = _make_shoot()
 228SFX_EXPLOSION = _make_explosion()
 229SFX_STEPS = [_make_step(f) for f in (110.0, 92.5, 82.4, 73.4)]
 230SFX_UFO_LOOP = _make_ufo_loop()
 231SFX_UFO_HIT = _make_ufo_hit()
 232
 233
 234def play_sfx(parent: Node, stream: AudioClip, *, bus: str = "SFX", volume_db: float = 0.0) -> AudioPlayer:
 235    """Spawn a one-shot SFX player that removes itself when the clip ends."""
 236    return parent.add_child(
 237        AudioPlayer(stream=stream, bus=bus, volume_db=volume_db, autoplay=True, queue_free_on_end=True, name="SFX")
 238    )
 239
 240
 241# ---------------------------------------------------------------------------
 242# Helpers
 243# ---------------------------------------------------------------------------
 244
 245
 246def draw_sprite(renderer, sprite, x, y, scale, colour):
 247    """Draw an 8x8 bit-pattern sprite using filled rects."""
 248    for row_i, row_bits in enumerate(sprite):
 249        for col in range(8):
 250            if row_bits & (1 << (7 - col)):
 251                renderer.draw_rect((x + col * scale, y + row_i * scale), (scale, scale), colour=colour, filled=True)
 252
 253
 254# ---------------------------------------------------------------------------
 255# Starfield: parallax background drawn beneath everything
 256# ---------------------------------------------------------------------------
 257
 258
 259class Starfield(Node2D):
 260    """Slow-scrolling parallax stars rendered in screen space."""
 261
 262    # Stars drift down every frame (on_update mutates _stars), so on_draw must
 263    # re-run each frame under retained 2D.
 264    dynamic = True
 265
 266    def __init__(self, count: int = 70, **kwargs):
 267        super().__init__(**kwargs)
 268        rng = random.Random(0xC0DE)  # deterministic so the field looks the same each run
 269        self._stars = [
 270            (rng.uniform(0, WIDTH), rng.uniform(0, HEIGHT), rng.uniform(0.25, 1.0), rng.uniform(0.2, 1.0))
 271            for _ in range(count)
 272        ]
 273
 274    def on_update(self, dt: float):
 275        new_stars = []
 276        for x, y, brightness, parallax in self._stars:
 277            y += parallax * 18.0 * dt
 278            if y > HEIGHT:
 279                y -= HEIGHT
 280                x = random.uniform(0, WIDTH)
 281            new_stars.append((x, y, brightness, parallax))
 282        self._stars = new_stars
 283
 284    def on_draw(self, renderer):
 285        for x, y, b, _p in self._stars:
 286            renderer.draw_rect((x, y), (1.5, 1.5), colour=(b, b, b, 1.0), filled=True)
 287
 288
 289# ---------------------------------------------------------------------------
 290# Alien
 291# ---------------------------------------------------------------------------
 292
 293
 294class Alien(Body2D):
 295    died = Signal()  # emits self when destroyed by a player bullet
 296
 297    def __init__(self, alien_type: int = 0, **kwargs):
 298        super().__init__(radius=PIXEL * 4, **kwargs)
 299        self.add_to_group("aliens")
 300        frames, colour, points = ALIEN_TYPES[min(alien_type, 2)]
 301        self.frames = frames
 302        self.colour = colour
 303        self.points = points
 304        self.frame = 0  # animation frame, pushed by the formation via set_frame()
 305
 306    def set_frame(self, frame: int):
 307        """Swap the animation frame and mark the sprite for redraw."""
 308        if frame != self.frame:
 309            self.frame = frame
 310            self.queue_redraw()
 311
 312    def on_draw(self, renderer):
 313        wp = self.world_position
 314        draw_sprite(renderer, self.frames[self.frame], wp.x - PIXEL * 4, wp.y - PIXEL * 4, PIXEL, self.colour)
 315
 316
 317# ---------------------------------------------------------------------------
 318# AlienFormation
 319# ---------------------------------------------------------------------------
 320
 321
 322class AlienFormation(Node2D):
 323    speed = Property(28.0)
 324    wave_cleared = Signal()
 325    reached_bottom = Signal()
 326
 327    def __init__(self, wave: int = 1, **kwargs):
 328        super().__init__(**kwargs)
 329        self._direction = 1
 330        self._wave = wave
 331        self._frame = 0
 332        self._step_index = 0
 333        self._move_accum = 0.0  # pixels travelled since last animation step
 334        self._step_cooldown = 0.0  # seconds remaining before next step is allowed
 335
 336    def on_ready(self):
 337        for row in range(ALIEN_ROWS):
 338            for col in range(ALIEN_COLS):
 339                x = (col - ALIEN_COLS // 2) * ALIEN_SPACING_X
 340                y = (row - ALIEN_ROWS // 2) * ALIEN_SPACING_Y
 341                self.add_child(Alien(alien_type=ALIEN_ROW_TYPES[row], name=f"Alien_{row}_{col}", position=Vec2(x, y)))
 342
 343    def on_update(self, dt: float):
 344        aliens = self.tree.group("aliens") if self.tree else []
 345        if not aliens:
 346            self.wave_cleared()
 347            return
 348
 349        # Speed scales with fewer aliens + gentle wave progression.
 350        full_fleet = ALIEN_ROWS * ALIEN_COLS
 351        spd = (self.speed + (full_fleet - len(aliens)) * 4.0) * (1.0 + (self._wave - 1) * 0.10)
 352        dx = self._direction * spd * dt
 353
 354        min_x = min(a.world_position.x for a in aliens)
 355        max_x = max(a.world_position.x for a in aliens)
 356
 357        if (max_x + dx > WIDTH - 30 and self._direction > 0) or (min_x + dx < 30 and self._direction < 0):
 358            self._direction *= -1
 359            # Descend 22 px per edge bounce: enough to feel the fleet closing in,
 360            # small enough that a wave is still survivable.
 361            self.position.y += 22
 362            for a in aliens:
 363                if a.world_position.y > HEIGHT - 80:
 364                    self.reached_bottom()
 365                    return
 366        else:
 367            self.position.x += dx
 368            self._move_accum += abs(dx)
 369
 370        # Pixel cadence drives the visual frame swap; a separate time cooldown
 371        # keeps the step audio from devolving into a buzz at end-of-wave speeds
 372        # (cap at ~8 Hz). Visual animation stays untied to the cooldown.
 373        self._step_cooldown = max(0.0, self._step_cooldown - dt)
 374        if self._move_accum >= 14.0:
 375            self._move_accum -= 14.0
 376            self._frame ^= 1
 377            for alien in aliens:
 378                alien.set_frame(self._frame)
 379            if self._step_cooldown <= 0.0:
 380                play_sfx(self, SFX_STEPS[self._step_index % 4], bus="SFX")
 381                self._step_index += 1
 382                self._step_cooldown = 0.12
 383
 384
 385# ---------------------------------------------------------------------------
 386# Player
 387# ---------------------------------------------------------------------------
 388
 389
 390class Player(Body2D):
 391    speed = Property(300.0)
 392    # Set while the cannon is respawning: the game skips hit tests and the
 393    # cannon blinks. The on_change hook restarts the blink from full brightness.
 394    invulnerable = Property(False, on_change="_on_invulnerable_changed")
 395    hit = Signal()
 396    fired = Signal()
 397
 398    def __init__(self, **kwargs):
 399        super().__init__(radius=12, **kwargs)
 400        self.add_to_group("player")
 401        self.fire_timer = self.add_child(Timer(0.4, name="FireTimer"))
 402        self._invuln_timer = self.add_child(Timer(1.5, one_shot=True, name="InvulnTimer"))
 403        self._invuln_timer.timeout.connect(self._end_invulnerability)
 404        self._blink_phase = 0.0
 405
 406    def on_ready(self):
 407        self.position = Vec2(WIDTH / 2, HEIGHT - 50)
 408
 409    def begin_invulnerability(self, seconds: float):
 410        """Blink and ignore alien fire for ``seconds`` after a respawn."""
 411        self.invulnerable = True
 412        self._invuln_timer.start(seconds)
 413
 414    def _end_invulnerability(self):
 415        self.invulnerable = False
 416
 417    def _on_invulnerable_changed(self):
 418        self._blink_phase = 0.0
 419        self.queue_redraw()
 420
 421    def on_fixed_update(self, dt: float):
 422        if self.invulnerable:
 423            self._blink_phase += dt
 424            self.queue_redraw()  # the blink is time-driven, so dirty the sprite by hand
 425
 426        # Pointer controls: hold/drag to slide the cannon toward the pointer and
 427        # fire while held (web touch arrives as MouseButton.LEFT).
 428        pointer_held = Input.is_mouse_button_pressed(MouseButton.LEFT)
 429        if Input.is_action_pressed("move_left"):
 430            self.position.x -= self.speed * dt
 431        if Input.is_action_pressed("move_right"):
 432            self.position.x += self.speed * dt
 433        if pointer_held:
 434            step = self.speed * dt
 435            self.position.x += max(-step, min(step, float(Input.mouse_position.x) - self.position.x))
 436        self.position.x = max(20, min(WIDTH - 20, self.position.x))
 437
 438        if (Input.is_action_pressed("fire") or pointer_held) and self.fire_timer.stopped:
 439            self.fire_timer.start()
 440            self.parent.add_child(
 441                Bullet(direction=-1, name="PBullet", position=Vec2(self.position.x, self.position.y - 15))
 442            )
 443            play_sfx(self, SFX_SHOOT, bus="SFX")
 444            self.fired()
 445
 446    def on_draw(self, renderer):
 447        if self.invulnerable and int(self._blink_phase * 8) % 2 == 0:
 448            return  # blink while respawning
 449        x, y = self.position.x, self.position.y
 450        green = (0.0, 1.0, 0.0, 1.0)
 451        renderer.draw_rect((x - 13, y - 4), (26, 8), colour=green, filled=True)
 452        renderer.draw_rect((x - 3, y - 12), (6, 8), colour=green, filled=True)
 453        renderer.draw_rect((x - 1, y - 15), (2, 3), colour=green, filled=True)
 454
 455
 456# ---------------------------------------------------------------------------
 457# Bullet
 458# ---------------------------------------------------------------------------
 459
 460
 461class Bullet(Body2D):
 462    def __init__(self, direction: int = 1, **kwargs):
 463        super().__init__(radius=3, **kwargs)
 464        self.direction = direction
 465        self.speed = 400.0
 466        self.add_to_group("player_bullets" if direction < 0 else "alien_bullets")
 467
 468    def on_fixed_update(self, dt: float):
 469        self.position.y += self.direction * self.speed * dt
 470        if self.position.y < 0 or self.position.y > HEIGHT:
 471            self.destroy()
 472
 473    def on_draw(self, renderer):
 474        colour = (1.0, 1.0, 1.0, 1.0) if self.direction < 0 else (1.0, 1.0, 0.2, 1.0)
 475        renderer.draw_rect((self.position.x - 1, self.position.y - 4), (2, 8), colour=colour, filled=True)
 476
 477
 478# ---------------------------------------------------------------------------
 479# Barrier
 480# ---------------------------------------------------------------------------
 481
 482
 483class Barrier(Node2D):
 484    def __init__(self, **kwargs):
 485        super().__init__(**kwargs)
 486        self.add_to_group("barriers")
 487        self.pixels = [[ch == "#" for ch in row] for row in BARRIER_PATTERN]
 488
 489    def hit(self, pos) -> bool:
 490        bx, by = self.position.x, self.position.y
 491        pw, ph = len(self.pixels[0]), len(self.pixels)
 492        col = int((pos.x - bx) / BARRIER_PIXEL)
 493        row = int((pos.y - by) / BARRIER_PIXEL)
 494        if 0 <= row < ph and 0 <= col < pw and self.pixels[row][col]:
 495            for dr in range(-1, 2):
 496                for dc in range(-1, 2):
 497                    r, c = row + dr, col + dc
 498                    if 0 <= r < ph and 0 <= c < pw:
 499                        self.pixels[r][c] = False
 500            return True
 501        return False
 502
 503    def on_draw(self, renderer):
 504        barrier_colour = (0.0, 1.0, 0.39, 1.0)
 505        bx, by = self.position.x, self.position.y
 506        for row_i, row in enumerate(self.pixels):
 507            for col_i, alive in enumerate(row):
 508                if alive:
 509                    renderer.draw_rect(
 510                        (bx + col_i * BARRIER_PIXEL, by + row_i * BARRIER_PIXEL),
 511                        (BARRIER_PIXEL, BARRIER_PIXEL),
 512                        colour=barrier_colour,
 513                        filled=True,
 514                    )
 515
 516
 517# ---------------------------------------------------------------------------
 518# Floating effects
 519# ---------------------------------------------------------------------------
 520
 521
 522class ScorePopup(Node2D):
 523    def __init__(self, points: int = 0, colour=(1.0, 1.0, 1.0), **kwargs):
 524        super().__init__(**kwargs)
 525        self._text = f"+{points}"
 526        self._colour = colour
 527        self._elapsed = 0.0
 528        self._duration = 0.8
 529        t = self.add_child(Timer(self._duration, name="Life"))
 530        t.timeout.connect(self.destroy)
 531        t.start()
 532
 533    def on_update(self, dt: float):
 534        self._elapsed += dt
 535        self.position.y -= 40 * dt
 536
 537    def on_draw(self, renderer):
 538        alpha = max(0.0, 1.0 - self._elapsed / self._duration)
 539        r, g, b = self._colour
 540        renderer.draw_text(self._text, (self.position.x, self.position.y), scale=2, colour=(r, g, b, alpha))
 541
 542
 543class Explosion(Node2D):
 544    # The particles expand and fade from a plain elapsed-time attribute while the
 545    # node stays put, so nothing dirties the draw: opt into per-frame redraws.
 546    dynamic = True
 547
 548    def __init__(self, colour=(1.0, 1.0, 1.0), **kwargs):
 549        super().__init__(**kwargs)
 550        self._colour = colour
 551        self._elapsed = 0.0
 552        self._duration = 0.4
 553        self._particles = [(random.uniform(-1, 1), random.uniform(-1, 1), random.uniform(40, 100)) for _ in range(8)]
 554        t = self.add_child(Timer(self._duration, name="Life"))
 555        t.timeout.connect(self.destroy)
 556        t.start()
 557
 558    def on_update(self, dt: float):
 559        self._elapsed += dt
 560
 561    def on_draw(self, renderer):
 562        alpha = max(0.0, 1.0 - self._elapsed / self._duration)
 563        r, g, b = self._colour
 564        colour = (r, g, b, alpha)
 565        px, py = self.position.x, self.position.y
 566        for dx, dy, spd in self._particles:
 567            dist = spd * self._elapsed
 568            renderer.draw_rect((px + dx * dist - 1.5, py + dy * dist - 1.5), (3, 3), colour=colour, filled=True)
 569
 570
 571# ---------------------------------------------------------------------------
 572# MysteryShip
 573# ---------------------------------------------------------------------------
 574
 575
 576class MysteryShip(Body2D):
 577    def __init__(self, points: int = 100, **kwargs):
 578        super().__init__(radius=PIXEL * 4, **kwargs)
 579        self.add_to_group("mystery")
 580        self.points = points
 581        self.colour = (1.0, 0.2, 1.0)
 582        self._dir = 1 if random.random() < 0.5 else -1
 583        self.position = Vec2(-30 if self._dir > 0 else WIDTH + 30, 40)
 584        # Looping siren: quieter than other SFX (closer to background music) so it
 585        # doesn't dominate the mix while the ship transits the screen. As a child
 586        # of the ship it stops itself when the ship leaves the tree, whether that
 587        # is a bullet hit or the exit below.
 588        self.add_child(
 589            AudioPlayer(stream=SFX_UFO_LOOP, bus="SFX", loop=True, autoplay=True, volume_db=-6.0, name="Siren")
 590        )
 591
 592    def on_update(self, dt: float):
 593        self.position.x += self._dir * 120 * dt
 594        if (self._dir > 0 and self.position.x > WIDTH + 40) or (self._dir < 0 and self.position.x < -40):
 595            self.destroy()
 596
 597    def on_draw(self, renderer):
 598        draw_sprite(renderer, ALIEN_UFO, self.position.x - PIXEL * 4, self.position.y - PIXEL * 4, PIXEL, self.colour)
 599
 600
 601# ---------------------------------------------------------------------------
 602# Wave banner: shown briefly between waves
 603# ---------------------------------------------------------------------------
 604
 605
 606class WaveBanner(Control):
 607    """Full-width "WAVE n" banner shown for a moment between waves.
 608
 609    ``AnchorPreset.CENTER`` collapses both anchor pairs onto the centre point, so
 610    the box comes from the margins: set all four, not ``size``.
 611    """
 612
 613    def __init__(self, wave: int, on_done, **kwargs):
 614        super().__init__(**kwargs)
 615        self.set_anchor_preset(AnchorPreset.CENTER)
 616        self.margin_left = -WIDTH / 2
 617        self.margin_right = WIDTH / 2
 618        self.margin_top = -40
 619        self.margin_bottom = 40
 620        label = self.add_child(Label(f"WAVE {wave}", name="WaveLabel"))
 621        label.font_size = 56
 622        label.alignment = "center"
 623        label.set_anchor_preset(AnchorPreset.FULL_RECT)
 624        label.text_colour = (1.0, 1.0, 1.0, 1.0)
 625        self._timer = self.add_child(Timer(1.5, one_shot=True, autostart=True, name="Life"))
 626
 627        def _finish():
 628            on_done()
 629            self.destroy()
 630
 631        self._timer.timeout.connect(_finish)
 632
 633
 634# ---------------------------------------------------------------------------
 635# Audio settings popup
 636# ---------------------------------------------------------------------------
 637
 638
 639def _slider_to_db(v: float) -> float:
 640    """Map slider 0..100 to bus volume in dB. 0 → -40 (near-silent), 100 → 0."""
 641    return -40.0 + (v / 100.0) * 40.0
 642
 643
 644def _db_to_slider(db: float) -> float:
 645    return max(0.0, min(100.0, (db + 40.0) * 100.0 / 40.0))
 646
 647
 648class AudioSettingsPopup(Control):
 649    """Modal popup with Music/SFX volume sliders.
 650
 651    Shown through the overlay layer: ``show_overlay("blocking", inert=True)``
 652    supplies the dim scrim, freezes the scene underneath and keeps input from
 653    reaching it, so the popup only has to lay out its own widgets.
 654    """
 655
 656    closed = Signal()
 657
 658    def __init__(self, **kwargs):
 659        super().__init__(**kwargs)
 660        self.set_anchor_preset(AnchorPreset.FULL_RECT)
 661
 662        # Centred dialog body. A collapsed anchor pair (CENTER here, and the
 663        # TOP_WIDE / BOTTOM_WIDE children below on their vertical axis) takes its
 664        # box from the two margins on that axis, so both are set.
 665        body = self.add_child(Panel(name="Body"))
 666        body.set_anchor_preset(AnchorPreset.CENTER)
 667        body.margin_left = -180
 668        body.margin_right = 180
 669        body.margin_top = -120
 670        body.margin_bottom = 120
 671        body.bg_colour = (0.08, 0.08, 0.12, 0.95)
 672
 673        title = body.add_child(Label("AUDIO", name="Title"))
 674        title.font_size = 32
 675        title.alignment = "center"
 676        title.set_anchor_preset(AnchorPreset.TOP_WIDE)
 677        title.margin_top = 12
 678        title.margin_bottom = 52
 679
 680        layout = AudioBusLayout.get_default()
 681        music_db = layout.get_bus("Music").volume_db
 682        sfx_db = layout.get_bus("SFX").volume_db
 683
 684        self._music_slider = self._row(body, "MUSIC", _db_to_slider(music_db), 70, lambda v: self._set_bus("Music", v))
 685        self._sfx_slider = self._row(body, "SFX", _db_to_slider(sfx_db), 130, lambda v: self._set_bus("SFX", v))
 686
 687        back = body.add_child(Button("BACK", name="Back"))
 688        back.set_anchor_preset(AnchorPreset.BOTTOM_WIDE)
 689        back.margin_left = 120
 690        back.margin_right = 120
 691        back.margin_top = -52
 692        back.margin_bottom = -16
 693        back.pressed.connect(self._close)
 694
 695    def _row(self, parent: Control, label: str, value: float, top_y: float, on_change) -> Slider:
 696        lab = parent.add_child(Label(label))
 697        lab.font_size = 18
 698        lab.set_anchor_preset(AnchorPreset.TOP_LEFT)
 699        lab.margin_left = 24
 700        lab.margin_top = top_y
 701        lab.size = Vec2(80, 28)
 702
 703        slider = parent.add_child(Slider(0, 100, value=value, name=f"Slider{label}"))
 704        slider.set_anchor_preset(AnchorPreset.TOP_LEFT)
 705        slider.margin_left = 120
 706        slider.margin_top = top_y + 4
 707        slider.size = Vec2(220, 20)
 708        slider.value_changed.connect(on_change)
 709        return slider
 710
 711    @staticmethod
 712    def _set_bus(name: str, slider_value: float):
 713        bus = AudioBusLayout.get_default().get_bus(name)
 714        bus.volume_db = _slider_to_db(slider_value)
 715
 716    def _close(self):
 717        self.closed()
 718        if self.is_overlay_open:
 719            self.close_overlay()
 720        self.destroy()
 721
 722
 723# ---------------------------------------------------------------------------
 724# MainMenu
 725# ---------------------------------------------------------------------------
 726
 727
 728class MainMenu(Node):
 729    input_actions = INPUT_ACTIONS
 730
 731    def __init__(self, **kwargs):
 732        super().__init__(name="MainMenu", **kwargs)
 733        self._popup_open = False
 734        self._blink_on = True
 735        self._blink_timer = self.add_child(Timer(0.5, one_shot=False, autostart=True, name="Blink"))
 736        self._blink_timer.timeout.connect(self._toggle_blink)
 737
 738        # Real button so the mixer is reachable with a mouse or a finger, not
 739        # only with the O key.
 740        self._options_button = self.add_child(Button("AUDIO OPTIONS (O)", on_press=self._open_popup, name="Options"))
 741        self._options_button.set_anchor_preset(AnchorPreset.CENTER_BOTTOM)
 742        self._options_button.font_size = 18
 743        self._options_button.margin_left = -120
 744        self._options_button.margin_right = 120
 745        self._options_button.margin_top = -72
 746        self._options_button.margin_bottom = -30
 747
 748    def _toggle_blink(self):
 749        self._blink_on = not self._blink_on
 750        self.queue_redraw()  # blink flips on a timer event, not per-frame -> dirty here
 751
 752    def on_ready(self):
 753        self.add_child(Starfield(name="Starfield"))
 754
 755    def _pointer_over_options(self) -> bool:
 756        """True while the pointer sits on the options button.
 757
 758        ``start`` is bound to the left button so a tap anywhere begins a game;
 759        this keeps a tap on the button from doing both at once.
 760        """
 761        x, y, w, h = self._options_button.get_global_rect()
 762        p = Input.mouse_position
 763        return x <= p.x <= x + w and y <= p.y <= y + h
 764
 765    def on_update(self, dt: float):
 766        if self._popup_open:
 767            return
 768        if Input.is_action_just_pressed("start") and not self._pointer_over_options():
 769            self.tree.change_scene(Game())
 770        elif Input.is_action_just_pressed("options"):
 771            self._open_popup()
 772
 773    def _open_popup(self):
 774        if self._popup_open:
 775            return
 776        popup = self.add_child(AudioSettingsPopup(name="AudioSettings"))
 777        popup.show_overlay("blocking", inert=True, dismiss=False)
 778        popup.closed.connect(self._on_popup_closed)
 779        self._popup_open = True
 780        self._options_button.visible = False
 781        self.queue_redraw()  # menu copy must clear so it can't bleed through the scrim
 782
 783    def _on_popup_closed(self):
 784        self._popup_open = False
 785        self._options_button.visible = True
 786        self.queue_redraw()  # repaint the menu now the popup is gone
 787
 788    def on_draw(self, renderer):
 789        if self._popup_open:
 790            return  # popup owns the screen; menu copy would bleed through the backdrop
 791
 792        title = "SPACE INVADERS"
 793        tw = renderer.text_width(title, 5)
 794        renderer.draw_text(title, (WIDTH // 2 - tw // 2, 80), scale=5, colour=(1.0, 1.0, 1.0))
 795
 796        y = 200
 797        for frames, colour, points in ALIEN_TYPES:
 798            draw_sprite(renderer, frames[0], WIDTH // 2 - 80, y, 3, colour)
 799            renderer.draw_text(f"= {points} PTS", (WIDTH // 2 - 45, y + 4), scale=2, colour=(1.0, 1.0, 1.0))
 800            y += 46
 801        draw_sprite(renderer, ALIEN_UFO, WIDTH // 2 - 80, y, 3, (1.0, 0.2, 1.0))
 802        renderer.draw_text("= ??? PTS", (WIDTH // 2 - 45, y + 4), scale=2, colour=(1.0, 1.0, 1.0))
 803
 804        if self._blink_on:
 805            prompt = "ENTER OR CLICK TO START"
 806            pw = renderer.text_width(prompt, 3)
 807            renderer.draw_text(prompt, (WIDTH // 2 - pw // 2, 396), scale=3, colour=(0.78, 0.78, 0.78))
 808        for i, line in enumerate(("ARROWS / A-D  MOVE      SPACE  FIRE", "OR HOLD THE POINTER TO MOVE AND FIRE")):
 809            lw = renderer.text_width(line, 2)
 810            renderer.draw_text(line, (WIDTH // 2 - lw // 2, 456 + i * 34), scale=2, colour=(0.6, 0.6, 0.6))
 811
 812
 813# ---------------------------------------------------------------------------
 814# Game
 815# ---------------------------------------------------------------------------
 816
 817
 818class Game(Node):
 819    input_actions = INPUT_ACTIONS
 820
 821    def __init__(self, **kwargs):
 822        super().__init__(name="Game", **kwargs)
 823        self.score = 0
 824        self.lives = 3
 825        self._wave = 0
 826        self._shots_fired = 0
 827        self._between_waves = False
 828
 829        self.add_child(Starfield(name="Starfield"))
 830
 831        self.camera = self.add_child(Camera2D(name="Camera", position=Vec2(WIDTH / 2, HEIGHT / 2)))
 832        self.player = self.add_child(Player(name="Player"))
 833        self.player.fired.connect(self._on_player_fired)
 834        self.player.hit.connect(self._on_player_hit)
 835        self.formation: AlienFormation | None = None
 836        self._shoot_timer: Timer | None = None
 837
 838        # HUD: Label controls with anchors so layout follows the window.
 839        self._score_label = self._make_hud_label("SCORE 00000", AnchorPreset.TOP_LEFT, left=10, width=170)
 840        self._wave_label = self._make_hud_label(
 841            "WAVE 1", AnchorPreset.CENTER_TOP, left=-70, width=140, align="center", colour=(0.78, 0.78, 0.78, 1.0)
 842        )
 843        self._lives_label = self._make_hud_label("LIVES 3", AnchorPreset.TOP_RIGHT, left=-150, width=140, align="right")
 844
 845        self._mystery_timer = self.add_child(
 846            Timer(random.uniform(15, 30), one_shot=True, autostart=True, name="MysteryTimer")
 847        )
 848        self._mystery_timer.timeout.connect(self._spawn_mystery)
 849
 850    def _make_hud_label(
 851        self,
 852        text: str,
 853        preset: AnchorPreset,
 854        *,
 855        left: float,
 856        width: float,
 857        align: str = "left",
 858        colour=(1.0, 1.0, 1.0, 1.0),
 859    ) -> Label:
 860        """One anchored HUD label, ``left`` pixels from its anchor.
 861
 862        A preset whose anchors collapse to a point (CENTER_TOP, TOP_RIGHT) takes
 863        its box from the margin pair while TOP_LEFT takes it from ``size``, so
 864        both are set and kept in agreement.
 865        """
 866        height = 28.0
 867        lbl = self.add_child(Label(text, name=f"HUD_{text.split()[0]}"))
 868        lbl.set_anchor_preset(preset)
 869        lbl.font_size = 22
 870        lbl.alignment = align
 871        lbl.text_colour = colour
 872        lbl.size = Vec2(width, height)
 873        lbl.margin_left = left
 874        lbl.margin_right = left + width
 875        lbl.margin_top = 10
 876        lbl.margin_bottom = 10 + height
 877        return lbl
 878
 879    def on_ready(self):
 880        self._spawn_barriers()
 881        self._next_wave()
 882
 883    def _next_wave(self):
 884        self._wave += 1
 885        self._between_waves = False
 886        self.formation = self.add_child(
 887            AlienFormation(
 888                wave=self._wave, name="Formation", position=Vec2(WIDTH // 2, 80 + (ALIEN_ROWS // 2) * ALIEN_SPACING_Y)
 889            )
 890        )
 891        self.formation.wave_cleared.connect(self._on_wave_cleared)
 892        self.formation.reached_bottom.connect(self._on_game_over)
 893        # Signals are per-instance and disconnect themselves when either end is
 894        # destroyed, so the wave can be wired up and then forgotten.
 895        for alien in self.formation.children:
 896            if isinstance(alien, Alien):
 897                alien.died.connect(self._on_alien_killed)
 898
 899        # Shoot interval slows the floor a bit so wave 5+ stays beatable.
 900        interval = max(0.45, 1.10 - (self._wave - 1) * 0.10)
 901        self._shoot_timer = self.add_child(Timer(interval, one_shot=False, autostart=True, name="ShootTimer"))
 902        self._shoot_timer.timeout.connect(self._alien_shoot)
 903
 904    def _on_wave_cleared(self):
 905        if self._between_waves:
 906            return
 907        self._between_waves = True
 908        if self._shoot_timer:
 909            self._shoot_timer.destroy()
 910            self._shoot_timer = None
 911        if self.formation:
 912            self.formation.destroy()
 913            self.formation = None
 914        self.add_child(WaveBanner(self._wave + 1, self._next_wave, name="WaveBanner"))
 915
 916    def _on_game_over(self):
 917        self.tree.change_scene(GameOver(self.score))
 918
 919    def _alien_shoot(self):
 920        aliens = self.tree.group("aliens") if self.tree else []
 921        if not aliens:
 922            return
 923        # Pick the bottom-most alien per column so back rows can't friendly-fire.
 924        columns: dict[int, Alien] = {}
 925        for a in aliens:
 926            col = round(a.world_position.x / ALIEN_SPACING_X)
 927            existing = columns.get(col)
 928            if existing is None or a.world_position.y > existing.world_position.y:
 929                columns[col] = a
 930        shooter = random.choice(list(columns.values()))
 931        wp = shooter.world_position
 932        self.add_child(Bullet(direction=1, name="ABullet", position=Vec2(wp.x, wp.y + 10)))
 933
 934    def _spawn_barriers(self):
 935        barrier_w = len(BARRIER_PATTERN[0]) * BARRIER_PIXEL
 936        total_w = 4 * barrier_w
 937        gap = (WIDTH - total_w) / 5
 938        for i in range(4):
 939            bx = gap + i * (barrier_w + gap)
 940            self.add_child(Barrier(name=f"Barrier_{i}", position=Vec2(bx, HEIGHT - 130)))
 941
 942    def _spawn_mystery(self):
 943        points = MYSTERY_POINTS_CYCLE[self._shots_fired % len(MYSTERY_POINTS_CYCLE)]
 944        self.add_child(MysteryShip(points=points, name="Mystery"))
 945        self._mystery_timer.start(random.uniform(15, 30))
 946
 947    def _on_player_fired(self):
 948        self._shots_fired += 1
 949
 950    def _on_alien_killed(self, alien: Alien):
 951        wp = Vec2(alien.world_position)
 952        self.score += alien.points
 953        self.add_child(ScorePopup(points=alien.points, colour=alien.colour, position=wp))
 954        self.add_child(Explosion(colour=alien.colour, position=wp))
 955        play_sfx(self, SFX_EXPLOSION, bus="SFX", volume_db=-3.0)
 956
 957    def _on_player_hit(self):
 958        self.camera.shake(intensity=4.0, duration=0.3)
 959        wp = Vec2(self.player.position)
 960        self.add_child(Explosion(colour=(0.0, 1.0, 0.4), position=wp))
 961        play_sfx(self, SFX_EXPLOSION, bus="SFX", volume_db=-3.0)
 962        self.lives -= 1
 963        if self.lives <= 0:
 964            self.tree.change_scene(GameOver(self.score))
 965            return
 966        self.player.position = Vec2(WIDTH / 2, HEIGHT - 50)
 967        self.player.begin_invulnerability(1.5)
 968
 969    def on_update(self, dt: float):
 970        self._score_label.text = f"SCORE {self.score:05d}"
 971        self._wave_label.text = f"WAVE {self._wave}"
 972        self._lives_label.text = f"LIVES {self.lives}"
 973
 974    def on_fixed_update(self, dt: float):
 975        tree = self.tree
 976        if not tree:
 977            return
 978
 979        for bullet in tree.group("player_bullets"):
 980            # Aliens
 981            hits = bullet.get_overlapping(group="aliens")
 982            if hits:
 983                alien = hits[0]
 984                alien.died(alien)
 985                alien.destroy()
 986                bullet.destroy()
 987                continue
 988            # Mystery ship
 989            hits = bullet.get_overlapping(group="mystery")
 990            if hits:
 991                mystery = hits[0]
 992                wp = Vec2(mystery.position)
 993                self.score += mystery.points
 994                self.add_child(ScorePopup(points=mystery.points, colour=mystery.colour, position=wp))
 995                self.add_child(Explosion(colour=mystery.colour, position=wp))
 996                play_sfx(self, SFX_UFO_HIT, bus="SFX", volume_db=-3.0)
 997                mystery.destroy()
 998                bullet.destroy()
 999                continue
1000            # Mid-air interception with alien bullets: both vanish in a spark.
1001            hits = bullet.get_overlapping(group="alien_bullets")
1002            if hits:
1003                other = hits[0]
1004                self.add_child(Explosion(colour=(1.0, 1.0, 1.0), position=Vec2(bullet.position)))
1005                bullet.destroy()
1006                other.destroy()
1007                continue
1008            # Barriers
1009            for barrier in tree.group("barriers"):
1010                if barrier.hit(bullet.position):
1011                    bullet.destroy()
1012                    break
1013
1014        for bullet in tree.group("alien_bullets"):
1015            if not self.player.invulnerable:
1016                hits = bullet.get_overlapping(group="player")
1017                if hits:
1018                    bullet.destroy()
1019                    self.player.hit()  # connected to _on_player_hit in __init__
1020                    continue
1021            for barrier in tree.group("barriers"):
1022                if barrier.hit(bullet.position):
1023                    bullet.destroy()
1024                    break
1025
1026
1027# ---------------------------------------------------------------------------
1028# GameOver
1029# ---------------------------------------------------------------------------
1030
1031
1032class GameOver(Node):
1033    input_actions = INPUT_ACTIONS
1034
1035    def __init__(self, score: int = 0, **kwargs):
1036        super().__init__(name="GameOver", **kwargs)
1037        self.score = score
1038        self._blink_on = True
1039        self._blink_timer = self.add_child(Timer(0.5, one_shot=False, autostart=True, name="Blink"))
1040        self._blink_timer.timeout.connect(self._toggle_blink)
1041
1042    def _toggle_blink(self):
1043        self._blink_on = not self._blink_on
1044        self.queue_redraw()  # blink flips on a timer event, not per-frame -> dirty here
1045
1046    def on_ready(self):
1047        self.add_child(Starfield(name="Starfield"))
1048
1049    def on_update(self, dt: float):
1050        if Input.is_action_just_pressed("start"):
1051            self.tree.change_scene(MainMenu())
1052
1053    def on_draw(self, renderer):
1054        title = "GAME OVER"
1055        tw = renderer.text_width(title, 5)
1056        renderer.draw_text(title, (WIDTH // 2 - tw // 2, 180), scale=5, colour=(1.0, 0.2, 0.2))
1057
1058        score_text = f"SCORE  {self.score:05d}"
1059        sw = renderer.text_width(score_text, 3)
1060        renderer.draw_text(score_text, (WIDTH // 2 - sw // 2, 300), scale=3, colour=(1.0, 1.0, 1.0))
1061
1062        if self._blink_on:
1063            prompt = "ENTER OR CLICK TO CONTINUE"
1064            pw = renderer.text_width(prompt, 2)
1065            renderer.draw_text(prompt, (WIDTH // 2 - pw // 2, 420), scale=2, colour=(0.78, 0.78, 0.78))
1066
1067
1068# ---------------------------------------------------------------------------
1069# Main
1070# ---------------------------------------------------------------------------
1071
1072
1073if __name__ == "__main__":
1074    App("Space Invaders", WIDTH, HEIGHT, target_fps=30).run(MainMenu())