Asteroids 2D¶
The classic arcade shooter in one small, idiomatic file.
â–¶ Run in browserTags: game collision wrap shooting
Thrust, turn and shoot through waves of splitting asteroids on a wrap-around playfield, with a title screen, a HUD, lives and a game-over screen: a whole arcade game loop with no physics engine in sight.
Every actor is a plain Node2D that integrates its own velocity and wraps at
the screen edges, and collisions are circle-vs-circle radius tests over
scene-tree groups. Along the way the demo shows:
Immediate-mode 2D drawing: polygons, line loops, circles and text from
on_drawScene flow with
tree.change_scenebetween menu, game and game overSignal(fired,died) decoupling the ship from the game rulesScene-tree groups driving the bullet and asteroid collision queries
Timerchildren for the fire-rate cooldown and the bullet lifetimePropertydescriptors exposing tunable handling and difficulty valuesDeclarative
input_actions, re-registered for every scene the tree swaps in
Controls: W or Up thrusts, A/D or Left/Right turn, Space fires, Enter starts. With a mouse or a touchscreen, hold anywhere to steer toward the pointer while thrusting and firing, and tap to start.
Source¶
1#!/usr/bin/env python3
2"""Asteroids 2D: The classic arcade shooter in one small, idiomatic file.
3
4Thrust, turn and shoot through waves of splitting asteroids on a wrap-around
5playfield, with a title screen, a HUD, lives and a game-over screen: a whole
6arcade game loop with no physics engine in sight.
7
8Every actor is a plain ``Node2D`` that integrates its own velocity and wraps at
9the screen edges, and collisions are circle-vs-circle radius tests over
10scene-tree groups. Along the way the demo shows:
11
12- Immediate-mode 2D drawing: polygons, line loops, circles and text from ``on_draw``
13- Scene flow with ``tree.change_scene`` between menu, game and game over
14- ``Signal`` (``fired``, ``died``) decoupling the ship from the game rules
15- Scene-tree groups driving the bullet and asteroid collision queries
16- ``Timer`` children for the fire-rate cooldown and the bullet lifetime
17- ``Property`` descriptors exposing tunable handling and difficulty values
18- Declarative ``input_actions``, re-registered for every scene the tree swaps in
19
20Controls: W or Up thrusts, A/D or Left/Right turn, Space fires, Enter starts.
21With a mouse or a touchscreen, hold anywhere to steer toward the pointer while
22thrusting and firing, and tap to start.
23
24# /// simvx
25# tags = ["game", "collision", "wrap", "shooting"]
26# web = { root = "MainMenu" }
27# ///
28"""
29
30import math
31import random
32
33from simvx.core import (
34 # Input
35 Input,
36 Key,
37 MouseButton,
38 Node,
39 Node2D,
40 # Engine
41 Property,
42 Signal,
43 Timer,
44 # Math
45 Vec2,
46)
47from simvx.graphics import App
48
49WIDTH, HEIGHT = 800, 600
50
51# Declared on every root scene below: the tree bulk-registers a root's
52# ``input_actions`` on mount and again after each ``change_scene``, so no scene
53# depends on another having run first.
54INPUT_ACTIONS = {
55 "thrust": [Key.W, Key.UP],
56 "turn_left": [Key.A, Key.LEFT],
57 "turn_right": [Key.D, Key.RIGHT],
58 "fire": [Key.SPACE],
59 "start": [Key.ENTER, MouseButton.LEFT],
60}
61
62# Ship shapes: local-space points drawn by Node2D.draw_polygon
63SHIP_SHAPE = [Vec2(0, -12), Vec2(-8, 10), Vec2(8, 10)]
64THRUST_SHAPE = [Vec2(-5, 10), Vec2(0, 18), Vec2(5, 10)]
65
66
67class Body2D(Node2D):
68 """A manually integrated 2D actor with a collision radius + group overlap query.
69
70 Asteroids is a wrap-around arcade game with NO physics simulation: every actor
71 integrates ``position += velocity * dt`` itself and collisions are plain
72 circle-vs-circle radius tests. This is deliberately NOT a ``CharacterBody2D``
73 (these entities need no collider at all); it is a light
74 ``Node2D`` that carries a radius and a group-scoped overlap poll.
75 """
76
77 #: Scene-tree group this actor joins on creation (``None`` = ungrouped).
78 collision_group: str | None = None
79
80 def __init__(self, radius: float = 8.0, **kwargs):
81 super().__init__(**kwargs)
82 self.radius = float(radius)
83 self.velocity = Vec2()
84 if self.collision_group:
85 self.add_to_group(self.collision_group)
86
87 def kill(self):
88 """Leave the collision group now, then queue destruction.
89
90 ``destroy()`` only schedules removal for the end of the frame, and a
91 node awaiting deletion is still returned by ``group``. Leaving the
92 group first makes the kill visible immediately, so one bullet cannot
93 score a second hit and the ship cannot die to an already-shot asteroid
94 later in the same frame.
95 """
96 if self.collision_group:
97 self.remove_from_group(self.collision_group)
98 self.destroy()
99
100 def get_overlapping(self, group: str) -> list[Body2D]:
101 """Other ``Body2D`` nodes in ``group`` whose radius overlaps ours."""
102 if not self.tree:
103 return []
104 hits: list[Body2D] = []
105 for b in self.tree.group(group):
106 if b is self or not isinstance(b, Body2D):
107 continue
108 d = b.world_position - self.world_position
109 rr = self.radius + b.radius
110 if float(d.x) ** 2 + float(d.y) ** 2 <= rr * rr:
111 hits.append(b)
112 return hits
113
114
115def random_asteroid_shape(radius: float, verts=10) -> list[Vec2]:
116 return [
117 Vec2(math.cos(a) * radius * random.uniform(0.7, 1.3), math.sin(a) * radius * random.uniform(0.7, 1.3))
118 for a in (i / verts * math.tau for i in range(verts))
119 ]
120
121
122# ============================================================================
123# Ship
124# ============================================================================
125
126
127class Ship(Body2D):
128 # ``coerce`` converts once at assignment, so the turn rate is written in
129 # degrees but stored (and used) in radians like every other engine angle.
130 turn_speed = Property(
131 200.0,
132 coerce=math.radians,
133 range=(math.radians(50), math.radians(400)),
134 hint="Turn rate in degrees per second",
135 )
136 thrust_power = Property(300.0, range=(50, 800))
137 max_speed = Property(400.0, range=(100, 1000))
138 drag = Property(0.98, range=(0.9, 1.0))
139
140 def __init__(self, **kwargs):
141 super().__init__(radius=10, **kwargs)
142 self.fired = Signal()
143 self.died = Signal()
144 self._thrusting = False
145 self._invincible = 0.0
146 self._visible = True
147
148 self.fire_timer = self.add_child(Timer(0.15, name="FireTimer"))
149
150 def on_ready(self):
151 self.position = Vec2(WIDTH / 2, HEIGHT / 2)
152
153 def on_fixed_update(self, dt: float):
154 # Turning
155 if Input.is_action_pressed("turn_left"):
156 self.rotation -= self.turn_speed * dt
157 if Input.is_action_pressed("turn_right"):
158 self.rotation += self.turn_speed * dt
159
160 # Pointer controls: hold/drag to steer toward the pointer, thrusting and
161 # firing while held (web touch arrives as MouseButton.LEFT).
162 pointer_held = Input.is_mouse_button_pressed(MouseButton.LEFT)
163 if pointer_held:
164 d = Input.mouse_position - self.world_position
165 if d.length() > self.radius * 2:
166 target = math.atan2(float(d.x), -float(d.y))
167 diff = (target - self.rotation + math.pi) % math.tau - math.pi
168 step = self.turn_speed * dt
169 self.rotation += max(-step, min(step, diff))
170
171 # Thrust
172 self._thrusting = Input.is_action_pressed("thrust") or pointer_held
173 if self._thrusting:
174 self.velocity += self.forward * (self.thrust_power * dt)
175 speed = self.velocity.length()
176 if speed > self.max_speed:
177 self.velocity = self.velocity.normalized() * self.max_speed
178
179 self.velocity *= self.drag
180 self.position += self.velocity * dt
181 self.wrap_screen()
182
183 # Shooting (timer prevents rapid-fire)
184 if (Input.is_action_pressed("fire") or pointer_held) and self.fire_timer.stopped:
185 self.fire_timer.start()
186 self.fired.emit()
187
188 # Invincibility blink
189 if self._invincible > 0:
190 self._invincible -= dt
191 self._visible = int(self._invincible * 10) % 2 == 0
192 else:
193 self._visible = True
194
195 def on_draw(self, renderer):
196 if not self._visible:
197 return
198 self.draw_polygon(renderer, SHIP_SHAPE)
199 if self._thrusting:
200 self.draw_polygon(renderer, THRUST_SHAPE)
201
202 def respawn(self):
203 self.position = Vec2(WIDTH / 2, HEIGHT / 2)
204 self.velocity = Vec2()
205 self.rotation = 0.0
206 self._invincible = 2.0
207
208 @property
209 def is_invincible(self):
210 return self._invincible > 0
211
212
213# ============================================================================
214# Bullet
215# ============================================================================
216
217
218class Bullet(Body2D):
219 collision_group = "bullets"
220
221 speed = Property(500.0)
222
223 def __init__(self, direction: Vec2 | None = None, **kwargs):
224 super().__init__(radius=2, **kwargs)
225 if direction is not None:
226 self.velocity = direction * self.speed
227
228 # Auto-expire via timer
229 t = self.add_child(Timer(1.5, name="Lifetime"))
230 t.timeout.connect(self.kill)
231 t.start()
232
233 def on_fixed_update(self, dt: float):
234 self.position += self.velocity * dt
235 self.wrap_screen()
236
237 def on_draw(self, renderer):
238 renderer.draw_circle(self.world_position, self.radius, segments=6)
239
240
241# ============================================================================
242# Asteroid
243# ============================================================================
244
245SIZES = {"large": 40, "medium": 20, "small": 10}
246SCORES = {"large": 20, "medium": 50, "small": 100}
247
248
249class Asteroid(Body2D):
250 collision_group = "asteroids"
251
252 size_class = Property("large", enum=["large", "medium", "small"])
253
254 def __init__(self, size_class="large", **kwargs):
255 radius = SIZES[size_class]
256 super().__init__(radius=radius, **kwargs)
257 self.size_class = size_class
258 self._shape = random_asteroid_shape(radius)
259 self._spin = math.radians(random.uniform(-90, 90))
260 # Random velocity
261 angle = random.uniform(0, math.tau)
262 speed = random.uniform(40, 120)
263 self.velocity = Vec2(math.cos(angle), math.sin(angle)) * speed
264
265 def on_fixed_update(self, dt: float):
266 self.position += self.velocity * dt
267 self.wrap_screen(margin=SIZES[self.size_class])
268 self.rotation += self._spin * dt
269
270 def on_draw(self, renderer):
271 self.draw_polygon(renderer, self._shape)
272
273 def split(self) -> list[Asteroid]:
274 next_size = {"large": "medium", "medium": "small"}.get(self.size_class)
275 if not next_size:
276 return []
277 return [Asteroid(name="Asteroid", size_class=next_size, position=Vec2(self.position)) for _ in range(2)]
278
279
280# ============================================================================
281# MainMenu
282# ============================================================================
283
284
285class MainMenu(Node):
286 # on_draw blinks "PRESS ENTER" from the per-frame _blink timer (no Property),
287 # so it must re-run every frame under retained 2D.
288 dynamic = True
289
290 input_actions = INPUT_ACTIONS
291
292 def __init__(self, **kwargs):
293 super().__init__(name="MainMenu", **kwargs)
294 self._blink = 0.0
295
296 def on_update(self, dt):
297 self._blink += dt
298 if Input.is_action_just_pressed("start"):
299 self.tree.change_scene(AsteroidsGame())
300
301 def on_draw(self, renderer):
302 cx = WIDTH // 2
303 # alignment="centre" anchors each line's centre on x, so no width maths.
304 renderer.draw_text("ASTEROIDS", (cx, 120), scale=6, alignment="centre", colour=(1.0, 1.0, 1.0))
305
306 # Draw decorative ship
307 pts = Node2D(position=Vec2(cx, 300)).transform_points([p * 2.5 for p in SHIP_SHAPE])
308 renderer.draw_lines(pts, closed=True)
309
310 # Controls
311 renderer.draw_text("W/UP THRUST", (cx - 100, 370), scale=2, colour=(0.71, 0.71, 0.71))
312 renderer.draw_text("A/D TURN", (cx - 100, 395), scale=2, colour=(0.71, 0.71, 0.71))
313 renderer.draw_text("SPACE FIRE", (cx - 100, 420), scale=2, colour=(0.71, 0.71, 0.71))
314 renderer.draw_text("TOUCH HOLD TO FLY + FIRE", (cx - 100, 445), scale=2, colour=(0.71, 0.71, 0.71))
315
316 if int(self._blink * 2) % 2 == 0:
317 prompt = "PRESS ENTER OR TAP TO START"
318 renderer.draw_text(prompt, (cx, 490), scale=3, alignment="centre", colour=(0.78, 0.78, 0.78))
319
320
321# ============================================================================
322# Game Scene
323# ============================================================================
324
325
326class AsteroidsGame(Node2D):
327 # on_draw reads the plain _score/_lives counters (no Property), so the HUD
328 # must re-run every frame under retained 2D.
329 dynamic = True
330
331 input_actions = INPUT_ACTIONS
332
333 start_asteroids = Property(4, range=(1, 12))
334 lives = Property(3, range=(1, 10))
335
336 def __init__(self, **kwargs):
337 super().__init__(name="AsteroidsGame", **kwargs)
338 self.ship = self.add_child(Ship(name="Ship"))
339 self._score = 0
340 self._lives = self.lives
341 self._wave = 0
342
343 def on_ready(self):
344 @self.ship.fired.connect
345 def on_fire():
346 fwd = self.ship.forward
347 self.add_child(
348 Bullet(
349 name="Bullet",
350 position=Vec2(self.ship.position) + fwd * 15,
351 direction=fwd,
352 )
353 )
354
355 @self.ship.died.connect
356 def on_died():
357 self._lives -= 1
358 if self._lives <= 0:
359 self.tree.change_scene(GameOver(self._score))
360 return
361 self.ship.respawn()
362
363 self._spawn_wave()
364
365 def _spawn_wave(self):
366 self._wave += 1
367 for i in range(self.start_asteroids + self._wave - 1):
368 pos = Vec2(
369 random.choice([random.uniform(0, 100), random.uniform(WIDTH - 100, WIDTH)]),
370 random.choice([random.uniform(0, 100), random.uniform(HEIGHT - 100, HEIGHT)]),
371 )
372 self.add_child(Asteroid(name=f"Asteroid{i}", position=pos))
373
374 def on_fixed_update(self, dt: float):
375 if not self.tree:
376 return
377 # Bullet-asteroid collisions via groups. kill() drops the bullet and the
378 # asteroid out of their groups at once, so neither can be hit twice.
379 for bullet in self.tree.group("bullets"):
380 for asteroid in bullet.get_overlapping(group="asteroids"):
381 self._score += SCORES[asteroid.size_class]
382 for piece in asteroid.split():
383 self.add_child(piece)
384 asteroid.kill()
385 bullet.kill()
386 break
387
388 # Ship-asteroid collisions
389 if not self.ship.is_invincible:
390 if self.ship.get_overlapping(group="asteroids"):
391 self.ship.died.emit()
392
393 # Next wave? (the last death may have swapped in GameOver, detaching us)
394 if self.tree and not self.tree.group("asteroids"):
395 self._spawn_wave()
396
397 def on_draw(self, renderer):
398 # HUD: score
399 renderer.draw_text(f"SCORE {self._score:05d}", (10, 10), scale=2, colour=(1.0, 1.0, 1.0))
400 # HUD: draw remaining lives as small ships
401 for i in range(self._lives):
402 pts = Node2D(position=Vec2(WIDTH - 80 + i * 25, 18)).transform_points(SHIP_SHAPE)
403 renderer.draw_lines(pts, closed=True)
404
405
406# ============================================================================
407# GameOver
408# ============================================================================
409
410
411class GameOver(Node):
412 # on_draw blinks the continue prompt from the per-frame _blink timer
413 # (no Property), so it must re-run every frame under retained 2D.
414 dynamic = True
415
416 input_actions = INPUT_ACTIONS
417
418 def __init__(self, score=0, **kwargs):
419 super().__init__(name="GameOver", **kwargs)
420 self.score = score
421 self._blink = 0.0
422
423 def on_update(self, dt):
424 self._blink += dt
425 if Input.is_action_just_pressed("start"):
426 self.tree.change_scene(MainMenu())
427
428 def on_draw(self, renderer):
429 cx = WIDTH // 2
430 renderer.draw_text("GAME OVER", (cx, 180), scale=5, alignment="centre", colour=(1.0, 0.2, 0.2))
431
432 score_text = f"SCORE {self.score:05d}"
433 renderer.draw_text(score_text, (cx, 300), scale=3, alignment="centre", colour=(1.0, 1.0, 1.0))
434
435 if int(self._blink * 2) % 2 == 0:
436 prompt = "PRESS ENTER OR TAP TO CONTINUE"
437 renderer.draw_text(prompt, (cx, 400), scale=2, alignment="centre", colour=(0.78, 0.78, 0.78))
438
439
440# ============================================================================
441# Main
442# ============================================================================
443
444
445if __name__ == "__main__":
446 App("Asteroids", WIDTH, HEIGHT).run(MainMenu())