Asteroids++

Classic Asteroids reimplemented with random polygon asteroids, pixel-perfect collision, wrap-around physics, infinite rounds, and a high score system. All rendering is done client-side via the Canvas 2D API — no server-side images.

Play

Arrow keys to move, space to fire. Visit /asteroids.

Architecture

The entire game runs in the browser. The server provides three things:

  1. The template (templates/asteroids.html) — ~600 lines of inline JavaScript that handles physics, rendering, collision, scoring, and high score submission.
  2. The game route (src/routers/asteroids.py) — serves the template with a signed score token.
  3. The high score API (/api/highscores, POST /api/highscores) — accepts signed score submissions.

Everything else — polygon generation, game loop, collision detection, rendering — is client-side JavaScript.

Polygon Generation

Asteroid shapes use the same algorithm as the polygon generator ported to JavaScript:

function generatePolygon(vertices, size) {
    var angles = [];
    for (var i = 0; i < vertices; i++) angles.push(Math.random() * 2 * Math.PI);
    angles.sort();
    var r = size / 2;
    var points = [];
    var radius = rand(0.5, 1.0);
    for (var i = 0; i < angles.length; i++) {
        radius = Math.max(0.3, Math.min(1.0, radius + rand(-0.15, 0.15)));
        points.push({ x: radius * r * Math.cos(angles[i]), y: radius * r * Math.sin(angles[i]) });
    }
    return points;
}

Up to 15 attempts per polygon, rejecting any with area < 3% of its bounding square to prevent degenerate line-like shapes. The shoelace formula computes the area.

Asteroid sizes

Three tiers, each with its own vertex range and point value:

Size Radius Vertices Base score
Large 90 px 8–14 20
Medium 50 px 6–10 50
Small 25 px 5–8 100

When shot, large asteroids split into two medium, medium into two small, and small are destroyed.

Collision Detection

Two approaches, both pixel-perfect:

Polygon-polygon (ship vs asteroid)

Uses a two-phase approach within polygonCollision():

  1. Broad phase — fast circle-circle distance check to reject distant pairs.
  2. Narrow phase — two checks:
  3. Edge intersection: every edge of polygon A is tested against every edge of B via 2D cross-product segment intersection.
  4. Point-in-polygon: every vertex of A is tested against B (and vice versa) using the ray-casting algorithm (even-odd rule).

Point-in-polygon (bullet vs asteroid)

Bullets are checked via pointInPolygon() — the bullet's center point is tested against the asteroid's transformed polygon using ray casting.

Wrap-aware collision

Both collision functions are wrapped with pointInWrappedPolygon() and wrappedPolygonCollision() which test the target against all 9 screen-offset copies (shifted by ±W, ±H) of the source polygon. This ensures collisions work correctly when an asteroid is split across the screen boundary.

Wrap-around Physics

Objects move freely in an unbounded coordinate space. Rendering and collision use modulo projection to map any real coordinate to the visible screen, so there is never a teleport:

sx = ((cx % W) + W) % W   // screen-space x in [0, W)
sy = ((cy % H) + H) % H

Rendering: drawWrappedPolygon() draws the polygon at the screen-space position, plus up to 8 neighbouring copies (shifted by ±W, ±H) so that objects straddling the screen edge appear on both sides simultaneously. As cx increases past a multiple of W, sx wraps smoothly from 1023 → 0 → 1 — the rendered copy glides across the edge pixel by pixel, never jumping.

Collision: The same modulo projection is applied in pointInWrappedPolygon() and wrappedPolygonCollision() so that collision checks align with what is actually visible.

Position: A wrapFar() function only kicks in at ±100 screen widths to prevent floating-point precision loss. Under normal play, positions are never wrapped.

Infinite Rounds

When all asteroids are cleared, a new round begins:

Round Asteroids Score multiplier
1 5 ×1.0
2 7 ×1.5
3 9 ×2.0
4 11 ×2.5
5 13 ×3.0
N 3 + 2N 1 + (N-1) × 0.5

The ship respawns centre-screen with 1.5 seconds of invincibility (indicated by flashing). The round number, multiplier, and current score flash briefly on screen each round.

Rendering

Theme awareness

The game reads data-bs-theme from the document element to pick outline colours: - Dark mode (data-bs-theme="dark"): white outlines with semi-transparent white fill (8% alpha). - Light mode: black outlines with semi-transparent black fill (8% alpha).

This matches the style used by the polygon generator.

HiDPI support

Canvas buffer is scaled by window.devicePixelRatio while keeping CSS display size at 1024×768 logical pixels, ensuring crisp rendering on Retina displays.

High Scores

Submission flow

  1. When the player dies, the client calls GET /api/highscores/check?score=N. If the score is a new high score, a name-entry modal appears.
  2. The player enters their name (or defaults to "AAA").
  3. The client submits name, score, and round via POST /api/highscores.
  4. The server records the score and returns the player's rank.
  5. The high score sidebar refreshes immediately.

Anti-tampering

The POST /api/highscores endpoint checks that the request comes from a local address:

_LOCAL = ("127.0.0.1", "::1", "::ffff:127.0.0.1")

def _local_only(request: Request):
    if request.client and request.client.host not in _LOCAL:
        raise HTTPException(status_code=403, detail="External submissions not accepted")

In production the FastAPI process listens only on 127.0.0.1 behind an nginx reverse proxy, so all legitimate requests arrive from localhost. Direct connections to the uvicorn port from an external IP are rejected with 403 External submissions not accepted.

In-memory store

src/store.py keeps the top 10 scores sorted by value, protected by a threading lock. The cache is process-local — restarting the server resets scores.

Controls

Key Action
Arrow Up Thrust
Arrow Left / Right Rotate
Space Fire
Space (game over) Restart

Files

File Role
templates/asteroids.html Game template (all game logic inline)
src/routers/asteroids.py GET /asteroids route, token issuance
src/routers/highscores.py GET/POST /api/highscores, GET /api/highscores/check
src/store.py Thread-safe in-memory high score store