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:
- The template (
templates/asteroids.html) — ~600 lines of inline JavaScript that handles physics, rendering, collision, scoring, and high score submission. - The game route (
src/routers/asteroids.py) — serves the template with a signed score token. - 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():
- Broad phase — fast circle-circle distance check to reject distant pairs.
- Narrow phase — two checks:
- Edge intersection: every edge of polygon A is tested against every edge of B via 2D cross-product segment intersection.
- 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
- 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. - The player enters their name (or defaults to "AAA").
- The client submits name, score, and round via
POST /api/highscores. - The server records the score and returns the player's rank.
- 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 |