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())