Space Invaders 2D

Space Invaders — Classic arcade game built with the engine (Vulkan backend). Run: uv run python packages/graphics/examples/game_spaceinvaders2d.py

Source Code

  1"""
  2Space Invaders — Classic arcade game built with the engine (Vulkan backend).
  3Run: uv run python packages/graphics/examples/game_spaceinvaders2d.py
  4"""
  5
  6
  7import random
  8
  9from simvx.core import (
 10    # Nodes
 11    CharacterBody2D,
 12    # Input
 13    Input,
 14    InputMap,
 15    Key,
 16    Node,
 17    Node2D,
 18    # Engine
 19    Property,
 20    Signal,
 21    Timer,
 22    # Math
 23    Vec2,
 24)
 25from simvx.graphics import App
 26
 27WIDTH, HEIGHT = 800, 600
 28PIXEL = 3  # scale for 8x8 sprites
 29
 30# 8x8 bit-pattern sprites (each int = one row, bit 7 = leftmost pixel)
 31ALIEN_SQUID = [0x18, 0x3C, 0x7E, 0xDB, 0xFF, 0x24, 0x5A, 0xA5]
 32ALIEN_CRAB = [0x24, 0x18, 0x3C, 0x5A, 0x7E, 0x24, 0x24, 0x42]
 33ALIEN_OCTOPUS = [0x18, 0x3C, 0x7E, 0xDB, 0xFF, 0x5A, 0x81, 0x42]
 34ALIEN_UFO = [0x3C, 0x7E, 0xFF, 0xDB, 0xFF, 0x7E, 0x24, 0x00]
 35
 36ALIEN_TYPES = [
 37    (ALIEN_SQUID, (1.0, 0.2, 0.2), 30),  # row 0: squid, red, 30pts
 38    (ALIEN_CRAB, (0.2, 1.0, 0.2), 20),  # rows 1-2: crab, green, 20pts
 39    (ALIEN_OCTOPUS, (0.2, 0.59, 1.0), 10),  # rows 3-4: octopus, blue, 10pts
 40]
 41
 42# Barrier arch pattern (11x8 string grid)
 43BARRIER_PATTERN = [
 44    "  #######  ",
 45    " ######### ",
 46    "###########",
 47    "###########",
 48    "###########",
 49    "###########",
 50    "###  #  ###",
 51    "##   #   ##",
 52]
 53BARRIER_PIXEL = 3
 54
 55
 56def draw_sprite(renderer, sprite, x, y, scale, colour):
 57    """Draw an 8x8 bit-pattern sprite using fill_rect."""
 58    renderer.set_colour(*colour)
 59    for row_i, row_bits in enumerate(sprite):
 60        for col in range(8):
 61            if row_bits & (1 << (7 - col)):
 62                renderer.fill_rect(x + col * scale, y + row_i * scale, scale, scale)
 63
 64
 65# ============================================================================
 66# Alien
 67# ============================================================================
 68
 69
 70class Alien(CharacterBody2D):
 71    died = Signal()
 72
 73    def __init__(self, alien_type=0, **kwargs):
 74        super().__init__(collision=PIXEL * 4, **kwargs)
 75        self.add_to_group("aliens")
 76        sprite_data, colour, points = ALIEN_TYPES[min(alien_type, 2)]
 77        self.sprite = sprite_data
 78        self.colour = colour
 79        self.points = points
 80
 81    def draw(self, renderer):
 82        wp = self.world_position
 83        draw_sprite(renderer, self.sprite, wp.x - PIXEL * 4, wp.y - PIXEL * 4, PIXEL, self.colour)
 84
 85
 86# ============================================================================
 87# AlienFormation — parent node that moves the entire grid
 88# ============================================================================
 89
 90
 91class AlienFormation(Node2D):
 92    speed = Property(30.0)
 93    wave_cleared = Signal()
 94    reached_bottom = Signal()
 95
 96    def __init__(self, wave=1, **kwargs):
 97        super().__init__(**kwargs)
 98        self._direction = 1
 99        self._wave = wave
100
101    def ready(self):
102        # Spawn 5x11 grid of aliens at local offsets
103        row_types = [0, 1, 1, 2, 2]
104        for row in range(5):
105            for col in range(11):
106                x = (col - 5) * 40
107                y = (row - 2) * 36
108                self.add_child(Alien(alien_type=row_types[row], name=f"Alien_{row}_{col}", position=Vec2(x, y)))
109
110    def process(self, dt):
111        aliens = self.tree.get_group("aliens") if self.tree else []
112        if not aliens:
113            self.wave_cleared()
114            return
115
116        # Speed scales with fewer aliens + wave progression
117        spd = (self.speed + (55 - len(aliens)) * 4.0) * (1.0 + (self._wave - 1) * 0.15)
118        dx = self._direction * spd * dt
119
120        # Check wall bounds via world_position of edge aliens
121        min_x = min(a.world_position.x for a in aliens)
122        max_x = max(a.world_position.x for a in aliens)
123
124        if (max_x + dx > WIDTH - 30 and self._direction > 0) or (min_x + dx < 30 and self._direction < 0):
125            self._direction *= -1
126            self.position.y += 15
127            # Check if any alien reached the bottom
128            for a in aliens:
129                if a.world_position.y > HEIGHT - 80:
130                    self.reached_bottom()
131                    return
132        else:
133            self.position.x += dx
134
135
136# ============================================================================
137# Player
138# ============================================================================
139
140
141class Player(CharacterBody2D):
142    speed = Property(300.0)
143    hit = Signal()
144
145    def __init__(self, **kwargs):
146        super().__init__(collision=12, **kwargs)
147        self.add_to_group("player")
148        self.fire_timer = self.add_child(Timer(0.4, name="FireTimer"))
149
150    def ready(self):
151        self.position = Vec2(WIDTH / 2, HEIGHT - 50)
152
153    def physics_process(self, dt):
154        if Input.is_action_pressed("move_left"):
155            self.position.x -= self.speed * dt
156        if Input.is_action_pressed("move_right"):
157            self.position.x += self.speed * dt
158        self.position.x = max(20, min(WIDTH - 20, self.position.x))
159
160        if Input.is_action_pressed("fire") and self.fire_timer.stopped:
161            self.fire_timer.start()
162            self.parent.add_child(
163                Bullet(direction=-1, name="PBullet", position=Vec2(self.position.x, self.position.y - 15))
164            )
165
166    def draw(self, renderer):
167        x, y = self.position.x, self.position.y
168        renderer.set_colour(0.0, 1.0, 0.0)
169        renderer.fill_rect(x - 13, y - 4, 26, 8)
170        renderer.fill_rect(x - 3, y - 12, 6, 8)
171        renderer.fill_rect(x - 1, y - 15, 2, 3)
172
173
174# ============================================================================
175# Bullet
176# ============================================================================
177
178
179class Bullet(CharacterBody2D):
180    def __init__(self, direction=1, **kwargs):
181        super().__init__(collision=3, **kwargs)
182        self.direction = direction
183        self.speed = 400.0
184        if direction < 0:
185            self.add_to_group("player_bullets")
186        else:
187            self.add_to_group("alien_bullets")
188
189    def physics_process(self, dt):
190        self.position.y += self.direction * self.speed * dt
191        if self.position.y < 0 or self.position.y > HEIGHT:
192            self.destroy()
193
194    def draw(self, renderer):
195        if self.direction < 0:
196            renderer.set_colour(1.0, 1.0, 1.0)
197        else:
198            renderer.set_colour(1.0, 1.0, 0.2)
199        renderer.fill_rect(self.position.x - 1, self.position.y - 4, 2, 8)
200
201
202# ============================================================================
203# Barrier
204# ============================================================================
205
206
207class Barrier(Node2D):
208    def __init__(self, **kwargs):
209        super().__init__(**kwargs)
210        self.add_to_group("barriers")
211        self.pixels = [[ch == "#" for ch in row] for row in BARRIER_PATTERN]
212
213    def hit(self, pos):
214        """Check if pos hits a barrier pixel, destroy area if so. Returns True if hit."""
215        bx, by = self.position.x, self.position.y
216        pw, ph = len(self.pixels[0]), len(self.pixels)
217        col = int((pos.x - bx) / BARRIER_PIXEL)
218        row = int((pos.y - by) / BARRIER_PIXEL)
219        if 0 <= row < ph and 0 <= col < pw and self.pixels[row][col]:
220            for dr in range(-1, 2):
221                for dc in range(-1, 2):
222                    r, c = row + dr, col + dc
223                    if 0 <= r < ph and 0 <= c < pw:
224                        self.pixels[r][c] = False
225            return True
226        return False
227
228    def draw(self, renderer):
229        renderer.set_colour(0.0, 1.0, 0.39)
230        bx, by = self.position.x, self.position.y
231        for row_i, row in enumerate(self.pixels):
232            for col_i, alive in enumerate(row):
233                if alive:
234                    renderer.fill_rect(
235                        bx + col_i * BARRIER_PIXEL, by + row_i * BARRIER_PIXEL, BARRIER_PIXEL, BARRIER_PIXEL
236                    )
237
238
239# ============================================================================
240# ScorePopup — floating "+N" text that drifts up and fades
241# ============================================================================
242
243
244class ScorePopup(Node2D):
245    def __init__(self, points=0, colour=(1.0, 1.0, 1.0), **kwargs):
246        super().__init__(**kwargs)
247        self._text = f"+{points}"
248        self._colour = colour
249        self._elapsed = 0.0
250        self._duration = 0.8
251        t = self.add_child(Timer(self._duration, name="Life"))
252        t.timeout.connect(self.destroy)
253        t.start()
254
255    def process(self, dt):
256        self._elapsed += dt
257        self.position.y -= 40 * dt
258
259    def draw(self, renderer):
260        alpha = max(0.0, 1.0 - self._elapsed / self._duration)
261        r, g, b = self._colour
262        renderer.draw_text(self._text, (self.position.x, self.position.y), scale=2, colour=(r, g, b, alpha))
263
264
265# ============================================================================
266# Explosion — expanding pixel particles
267# ============================================================================
268
269
270class Explosion(Node2D):
271    def __init__(self, colour=(1.0, 1.0, 1.0), **kwargs):
272        super().__init__(**kwargs)
273        self._colour = colour
274        self._elapsed = 0.0
275        self._duration = 0.4
276        self._particles = [
277            (random.uniform(-1, 1), random.uniform(-1, 1), random.uniform(40, 100))
278            for _ in range(8)
279        ]
280        t = self.add_child(Timer(self._duration, name="Life"))
281        t.timeout.connect(self.destroy)
282        t.start()
283
284    def process(self, dt):
285        self._elapsed += dt
286
287    def draw(self, renderer):
288        t = self._elapsed / self._duration
289        alpha = max(0.0, 1.0 - t)
290        r, g, b = self._colour
291        renderer.set_colour(r, g, b, alpha)
292        px, py = self.position.x, self.position.y
293        for dx, dy, spd in self._particles:
294            dist = spd * self._elapsed
295            renderer.fill_rect(px + dx * dist - 1.5, py + dy * dist - 1.5, 3, 3)
296
297
298# ============================================================================
299# MysteryShip — bonus target crossing the top
300# ============================================================================
301
302
303class MysteryShip(CharacterBody2D):
304    def __init__(self, **kwargs):
305        super().__init__(collision=PIXEL * 4, **kwargs)
306        self.add_to_group("mystery")
307        self.points = random.choice([50, 100, 150, 200, 300])
308        self.colour = (1.0, 0.2, 1.0)
309        self._dir = 1 if random.random() < 0.5 else -1
310        self.position = Vec2(-30 if self._dir > 0 else WIDTH + 30, 40)
311
312    def process(self, dt):
313        self.position.x += self._dir * 120 * dt
314        if (self._dir > 0 and self.position.x > WIDTH + 40) or (self._dir < 0 and self.position.x < -40):
315            self.destroy()
316
317    def draw(self, renderer):
318        draw_sprite(renderer, ALIEN_UFO, self.position.x - PIXEL * 4, self.position.y - PIXEL * 4, PIXEL, self.colour)
319
320
321# ============================================================================
322# MainMenu
323# ============================================================================
324
325
326class MainMenu(Node):
327    def __init__(self, **kwargs):
328        super().__init__(name="MainMenu", **kwargs)
329        self._blink_on = True
330        self._blink_timer = self.add_child(Timer(0.5, one_shot=False, autostart=True, name="Blink"))
331        self._blink_timer.timeout.connect(self._toggle_blink)
332
333    def _toggle_blink(self):
334        self._blink_on = not self._blink_on
335
336    def ready(self):
337        InputMap.add_action("move_left", [Key.A, Key.LEFT])
338        InputMap.add_action("move_right", [Key.D, Key.RIGHT])
339        InputMap.add_action("fire", [Key.SPACE])
340        InputMap.add_action("start", [Key.ENTER])
341
342    def process(self, dt):
343        if Input.is_action_just_pressed("start"):
344            self.tree.change_scene(Game())
345
346    def draw(self, renderer):
347        title = "SPACE INVADERS"
348        tw = renderer.text_width(title, 5)
349        renderer.draw_text(title, (WIDTH // 2 - tw // 2, 80), scale=5, colour=(1.0, 1.0, 1.0))
350
351        # Show alien types with point values
352        y = 220
353        for sprite, colour, points in ALIEN_TYPES:
354            draw_sprite(renderer, sprite, WIDTH // 2 - 80, y, 3, colour)
355            renderer.draw_text(f"= {points} PTS", (WIDTH // 2 - 45, y + 4), scale=2, colour=(1.0, 1.0, 1.0))
356            y += 50
357
358        # Mystery ship entry
359        draw_sprite(renderer, ALIEN_UFO, WIDTH // 2 - 80, y, 3, (1.0, 0.2, 1.0))
360        renderer.draw_text("= ??? PTS", (WIDTH // 2 - 45, y + 4), scale=2, colour=(1.0, 1.0, 1.0))
361
362        if self._blink_on:
363            prompt = "PRESS ENTER TO START"
364            pw = renderer.text_width(prompt, 3)
365            renderer.draw_text(prompt, (WIDTH // 2 - pw // 2, 470), scale=3, colour=(0.78, 0.78, 0.78))
366
367
368# ============================================================================
369# Game
370# ============================================================================
371
372
373class Game(Node):
374    def __init__(self, **kwargs):
375        super().__init__(name="Game", **kwargs)
376        self.score = 0
377        self.lives = 3
378        self._wave = 0
379        self._shake_time = 0.0
380        self.player = self.add_child(Player(name="Player"))
381        self.formation = None
382
383        # Mystery ship spawn timer (random 15-30s interval)
384        self._mystery_timer = self.add_child(Timer(random.uniform(15, 30), one_shot=True, autostart=True, name="MysteryTimer"))
385        self._mystery_timer.timeout.connect(self._spawn_mystery)
386
387    def ready(self):
388        self._spawn_barriers()
389        self._next_wave()
390
391    def _next_wave(self):
392        self._wave += 1
393        self.formation = self.add_child(
394            AlienFormation(wave=self._wave, name="Formation", position=Vec2(WIDTH // 2, 80 + 2 * 36))
395        )
396        self.formation.wave_cleared.connect(self._on_wave_cleared)
397        self.formation.reached_bottom.connect(self._on_game_over)
398
399        # Alien shoot timer — interval decreases with wave
400        interval = max(0.3, 1.0 - (self._wave - 1) * 0.1)
401        self._shoot_timer = self.add_child(
402            Timer(interval, one_shot=False, autostart=True, name="ShootTimer")
403        )
404        self._shoot_timer.timeout.connect(self._alien_shoot)
405
406    def _on_wave_cleared(self):
407        # Clean up shoot timer and formation
408        if self._shoot_timer:
409            self._shoot_timer.destroy()
410            self._shoot_timer = None
411        if self.formation:
412            self.formation.destroy()
413            self.formation = None
414        self._next_wave()
415
416    def _on_game_over(self):
417        self.tree.change_scene(GameOver(self.score))
418
419    def _alien_shoot(self):
420        aliens = self.tree.get_group("aliens") if self.tree else []
421        if aliens:
422            shooter = random.choice(aliens)
423            wp = shooter.world_position
424            self.add_child(Bullet(direction=1, name="ABullet", position=Vec2(wp.x, wp.y + 10)))
425
426    def _spawn_barriers(self):
427        barrier_w = len(BARRIER_PATTERN[0]) * BARRIER_PIXEL
428        total_w = 4 * barrier_w
429        gap = (WIDTH - total_w) / 5
430        for i in range(4):
431            bx = gap + i * (barrier_w + gap)
432            self.add_child(Barrier(name=f"Barrier_{i}", position=Vec2(bx, HEIGHT - 130)))
433
434    def _spawn_mystery(self):
435        self.add_child(MysteryShip(name="Mystery"))
436        # Restart timer with random interval
437        self._mystery_timer.start(random.uniform(15, 30))
438
439    def _on_alien_killed(self, alien):
440        wp = Vec2(alien.world_position)
441        self.score += alien.points
442        self.add_child(ScorePopup(points=alien.points, colour=alien.colour, position=wp))
443        self.add_child(Explosion(colour=alien.colour, position=wp))
444
445    def _on_player_hit(self):
446        self._shake_time = 0.3
447        self.lives -= 1
448        if self.lives <= 0:
449            self.tree.change_scene(GameOver(self.score))
450            return
451        self.player.position = Vec2(WIDTH / 2, HEIGHT - 50)
452
453    def process(self, dt):
454        if self._shake_time > 0:
455            self._shake_time -= dt
456
457    def physics_process(self, dt):
458        tree = self.tree
459        if not tree:
460            return
461
462        # Player bullets vs aliens + mystery
463        for bullet in tree.get_group("player_bullets"):
464            hits = bullet.get_overlapping(group="aliens")
465            if hits:
466                alien = hits[0]
467                self._on_alien_killed(alien)
468                alien.died()
469                alien.destroy()
470                bullet.destroy()
471                continue
472            # Player bullets vs mystery ship
473            hits = bullet.get_overlapping(group="mystery")
474            if hits:
475                mystery = hits[0]
476                wp = Vec2(mystery.position)
477                self.score += mystery.points
478                self.add_child(ScorePopup(points=mystery.points, colour=mystery.colour, position=wp))
479                self.add_child(Explosion(colour=mystery.colour, position=wp))
480                mystery.destroy()
481                bullet.destroy()
482                continue
483            # Player bullets vs barriers
484            for barrier in tree.get_group("barriers"):
485                if barrier.hit(bullet.position):
486                    bullet.destroy()
487                    break
488
489        # Alien bullets vs player
490        for bullet in tree.get_group("alien_bullets"):
491            hits = bullet.get_overlapping(group="player")
492            if hits:
493                bullet.destroy()
494                self.player.hit()
495                self._on_player_hit()
496                break
497            # Alien bullets vs barriers
498            for barrier in tree.get_group("barriers"):
499                if barrier.hit(bullet.position):
500                    bullet.destroy()
501                    break
502
503    def _draw_recursive(self, renderer):
504        # Push shake transform so all game objects shake together
505        shaking = self._shake_time > 0 and hasattr(renderer, 'push_transform')
506        if shaking:
507            renderer.push_transform(1, 0, 0, 1, random.uniform(-4, 4), random.uniform(-4, 4))
508        for child in self.children.safe_iter():
509            child._draw_recursive(renderer)
510        if shaking:
511            renderer.pop_transform()
512        # HUD in screen space (unshaken)
513        renderer.draw_text(f"SCORE {self.score:05d}", (10, 10), scale=2, colour=(1.0, 1.0, 1.0))
514        wave_text = f"WAVE {self._wave}"
515        ww = renderer.text_width(wave_text, 2)
516        renderer.draw_text(wave_text, (WIDTH // 2 - ww // 2, 10), scale=2, colour=(0.78, 0.78, 0.78))
517        lives_text = f"LIVES {self.lives}"
518        lw = renderer.text_width(lives_text, 2)
519        renderer.draw_text(lives_text, (WIDTH - lw - 10, 10), scale=2, colour=(1.0, 1.0, 1.0))
520
521
522# ============================================================================
523# GameOver
524# ============================================================================
525
526
527class GameOver(Node):
528    def __init__(self, score=0, **kwargs):
529        super().__init__(name="GameOver", **kwargs)
530        self.score = score
531        self._blink_on = True
532        self._blink_timer = self.add_child(Timer(0.5, one_shot=False, autostart=True, name="Blink"))
533        self._blink_timer.timeout.connect(self._toggle_blink)
534
535    def _toggle_blink(self):
536        self._blink_on = not self._blink_on
537
538    def process(self, dt):
539        if Input.is_action_just_pressed("start"):
540            self.tree.change_scene(MainMenu())
541
542    def draw(self, renderer):
543        title = "GAME OVER"
544        tw = renderer.text_width(title, 5)
545        renderer.draw_text(title, (WIDTH // 2 - tw // 2, 180), scale=5, colour=(1.0, 0.2, 0.2))
546
547        score_text = f"SCORE  {self.score:05d}"
548        sw = renderer.text_width(score_text, 3)
549        renderer.draw_text(score_text, (WIDTH // 2 - sw // 2, 300), scale=3, colour=(1.0, 1.0, 1.0))
550
551        if self._blink_on:
552            prompt = "PRESS ENTER TO CONTINUE"
553            pw = renderer.text_width(prompt, 2)
554            renderer.draw_text(prompt, (WIDTH // 2 - pw // 2, 420), scale=2, colour=(0.78, 0.78, 0.78))
555
556
557# ============================================================================
558# Main
559# ============================================================================
560
561
562if __name__ == "__main__":
563    App("Space Invaders", WIDTH, HEIGHT).run(MainMenu())