Asteroids 2D¶

The classic arcade shooter in one small, idiomatic file.

â–¶ Run in browser

Tags: 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_draw

  • Scene flow with tree.change_scene between menu, game and game over

  • Signal (fired, died) decoupling the ship from the game rules

  • Scene-tree groups driving the bullet and asteroid collision queries

  • Timer children for the fire-rate cooldown and the bullet lifetime

  • Property descriptors exposing tunable handling and difficulty values

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