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. P to pause, Esc for settings. Key bindings and colours are customisable via the settings panel. Visit /asteroids.

Architecture

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

  1. The template (templates/asteroids.html) — loads the external game script, defines the page layout (HTML + CSS, ~200 lines), and includes configurable game metadata.
  2. The game script (static/js/asteroids.js) — ~1360 lines of client-side JavaScript handling physics, rendering, collision, scoring, settings persistence, audio synthesis, and high score submission. Served as a static asset via FastAPI's StaticFiles mount; also exports its pure functions for Node.js testing.
  3. The game route (src/routers/asteroids.py) — serves the template.
  4. The high score API (/api/highscores, POST /api/highscores) — accepts score submissions from localhost only.

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, with all tunable parameters defined in a CONFIG object:

function generatePolygon(vertices, size, maxArea) {
    var minArea = size * size * CONFIG.polygon.minAreaRatio;
    var best = null, bestArea = 0;
    var fallback = null, fallbackArea = 0;
    for (var attempt = 0; attempt < CONFIG.polygon.generateAttempts; attempt++) {
        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(CONFIG.polygon.minRadiusRatio, CONFIG.polygon.maxRadiusRatio);
        for (var i = 0; i < angles.length; i++) {
            radius = Math.max(CONFIG.polygon.minRadiusRatio, Math.min(CONFIG.polygon.maxRadiusRatio, radius + rand(-CONFIG.polygon.radiusStep, CONFIG.polygon.radiusStep)));
            points.push({ x: radius * r * Math.cos(angles[i]), y: radius * r * Math.sin(angles[i]) });
        }
        var a = polygonArea(points);
        if (a >= minArea && (!maxArea || a <= maxArea)) return points;
        if (a > bestArea && (!maxArea || a <= maxArea)) { best = points; bestArea = a; }
        if (a > fallbackArea) { fallback = points; fallbackArea = a; }
    }
    return best || fallback;
}

The generator makes up to 15 attempts per polygon (CONFIG.polygon.generateAttempts), rejecting any with area below size² × 0.03 (the minAreaRatio) to prevent degenerate line-like shapes. The radius oscillates between 0.3× and 1.0× with a random step of 0.15 per vertex (radiusStep). Child asteroids from splits are area-capped so they never exceed the parent's visual size. The shoelace formula (polygonArea) 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. Each child asteroid's polygon area is capped at 75% of the parent's area, preventing sparse parents from producing denser-looking children.

Collision Detection

Two approaches, both pixel-perfect:

Polygon-polygon (ship vs asteroid)

Uses a two-phase approach within polygonCollision():

  1. Broad phase — if both polygons have a cached boundRadius (computed once on creation), a fast circle-circle distance check rejects distant pairs in O(1) without allocating world-space vertex arrays.
  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 — pointInWrappedPolygon() (bullet vs asteroid) and wrappedPolygonCollision() (ship vs asteroid) — project both objects to screen-space coordinates before testing. wrappedPolygonCollision checks polygon B at all 9 wrap positions (shifted by ±W, ±H) relative to polygon A's primary position. This ensures collisions work correctly at wrap boundaries — for example, an asteroid visible at the left edge can collide with a ship at the right edge. An earlier version shifted both polygons by the same offset, which silently prevented cross-boundary collisions.

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

This is implemented as wrapCoordinate(val, max) — a simple two-modulo operation that always produces a value in [0, max). Unlike the earlier wrapFar helper, there is no early-return guard; performance profiling showed the modulo is fast enough even for large coordinate values.

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.

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.

Accuracy Bonus

Each consecutive asteroid hit builds an accuracy streak, displayed next to the score in the HUD. Streak increases the points awarded per hit:

  • Streak mechanic: each hit increments the streak (capped at 20). A bullet expiring without hitting an asteroid resets the streak to 0. Ship death also resets the streak.
  • Bonus formula: bonusPoints = round(basePoints × streak × bonusPerStreak), where bonusPerStreak = 0.1 (10%)
  • Example: streak 1 → +10% bonus, streak 5 → +50% bonus, streak 10 → +100% bonus

The streak resets to 0 immediately on ship death and is tracked per game session. There is no carry-over between games.

Particles

When an asteroid is destroyed or the ship is destroyed, a burst of particles spawns at the impact position. Particles inherit 50% of the destroyed object's velocity, scatter with random speed and direction, and fade out over 0.3–0.8 seconds. Particles are drawn on top of all other game elements, including the game over overlay. Particle colours follow the same theme/custom colour settings as the corresponding game element.

Page Layout

The game uses a three-column flex layout that collapses vertically on small screens:

<!-- Outer: Bootstrap row centres the game block on the page -->
<div class="row justify-content-center">
  <div class="game-layout">                         <!-- width: 100%; max-width: 1400px -->
    <div class="game-layout-inner">                 <!-- display: flex; gap: 1rem; flex-wrap: wrap; justify-content: center -->
      <div class="controls-sidebar">                <!-- width: 160px; flex-shrink: 0 -->
        Controls panel, always visible on desktop.
      </div>
      <div class="game-canvas-col">                 <!-- flex: 1 1 0; min-width: 0; display: flex; flex-direction: column; align-items: center -->
        <div class="canvas-wrap">                   <!-- width: calc(min(100%, (100vh - 18rem) * 1024/768)); aspect-ratio: 1024/768 -->
          <canvas id="game-canvas"></canvas>        <!-- width: 100%; height: 100%; cursor: none -->
        </div>
        <div class="d-flex justify-content-between mt-2">  <!-- same width as .canvas-wrap -->
          Score / action links.
        </div>
      </div>
      <div class="highscore-sidebar">               <!-- width: 200px; flex-shrink: 0 -->
        High score list, always visible on desktop.
      </div>
    </div>
  </div>
</div>

Behaviour

  • Wide viewports (≥992px): Three columns sit side by side. The canvas-col grows to fill available space (flex: 1 1 0) between the two fixed sidebars. The canvas-wrap expands to fill the column width.
  • Short viewports (limited height): The calc(min(100%, (100vh - 18rem) * 1024/768)) on .canvas-wrap constrains the width so the canvas never exceeds 100vh - 18rem in height (18rem ≈ header + footer chrome). The canvas-wrap centres within the column, preserving the 4:3 aspect ratio.
  • Narrow viewports (<992px): The flex-direction: column media query stacks all three items vertically at full width.

This layout pattern can be reused for any page needing a centre canvas with fixed-width sidebars.

Code Architecture

All game state, rendering, and logic live inside an IIFE (Immediately Invoked Function Expression) in static/js/asteroids.js, which keeps internals private while exporting 12 pure functions via module.exports when loaded in Node.js. This enables unit testing of geometry and utility functions without a browser.

CONFIG object

All tunable game constants are defined in a single CONFIG object at module load time:

CONFIG.ship          — radius, turnSpeed, thrustAccel, invincibleDuration, invincibleBlinkRate
CONFIG.bullet        — speed, life, radius, fireCooldown
CONFIG.asteroid      — spawnMargin, spawnAttempts, safeRadius, splitOffset, childAreaFraction, driftSpeed, rotSpeed
CONFIG.particle      — speed range, life range, size range, explosionCount, deathCount
CONFIG.thrust        — particlesPerFrame, life/speed/size ranges, spray, flameColors
CONFIG.shake         — hitIntensity, deathIntensity, decay
CONFIG.stars         — count
CONFIG.floatingText  — speed, duration
CONFIG.accuracy      — bonusPerStreak, maxStreak
CONFIG.round         — initialAsteroidCount, asteroidsPerRound, multiplierBase/PerRound, flashDuration
CONFIG.polygon       — generateAttempts, minAreaRatio, min/maxRadiusRatio, radiusStep, edgeIntersectEpsilon

Prior to this refactor, these values were scattered as inline literals throughout the code. Grouping them makes tuning easier and improves readability.

Variable naming

Game-loop variables were expanded from single-letter abbreviations to descriptive names:

Before After
s ship
a / ast asteroid
b / bull bullet
st star
ft floatText
p particle
wa / wb (removed — broad phase uses cached boundRadius)
sc themeColors

Event-loop robustness

  • Visibility API: the game auto-pauses when the browser tab is hidden and resumes when it becomes visible again (unless a settings or name modal is open).
  • AudioContext on gesture: the Web Audio context is resumed on the first click or keydown event, satisfying browser autoplay policies. The event listeners self-remove after the first invocation.
  • Fetch error handling: all three fetch() calls (load highscores, check highscore, submit score) have .catch() handlers that provide user-visible fallback messages or gracefully dismiss modals.
  • rAF ID stored: the requestAnimationFrame ID is stored in rAFId, enabling future cancellation.

Testing

The extracted static/js/asteroids.js exports 13 pure functions:

  • polygonArea, pointInPolygon, cross2, segmentsIntersect
  • wrapCoordinate, keyCodeToLabel, escHtml
  • transformPoint, getWorldPoints
  • polygonCollision, rand, randInt
  • accuracyBonus

These are tested by 53 assertions in tests/js/asteroids.test.js using Node.js's built-in test runner (node --test). The test file provides minimal DOM mocks (canvas, document, localStorage, AudioContext) to allow the IIFE to initialise without a browser. All 53 tests run as part of the project's test.sh script alongside the 127 Python/pytest tests.

Rendering

Theme awareness

The game reads CSS custom properties from the active Bootswatch theme for element colours: - Ship--bs-primary - Bullets--bs-danger - Asteroids--bs-body-color (with dark/light fallback) - Stars — semi-transparent dark/light - Background--bs-body-bg

Each element can be overridden via the settings panel. Users can create and save named colour themes in their browser's localStorage.

Visual effects

Each effect can be independently toggled via the sidebar settings:

  • Glow (glowFX): All polygons (ship, bullets, asteroids) render with an 8px shadowBlur in their own colour, creating a soft neon glow.
  • Screen shake (shake): Asteroid hits trigger a 3px screen shake; ship death triggers 10px. The shake decays exponentially.
  • Score popups (hits): When an asteroid is destroyed, a floating +N text rises from the impact point and fades out over 1 second, coloured using the theme's body text colour (--bs-body-color) with a matching glow.
  • Thruster flame (thrustfx): While thrusting, four warm-coloured particles (red-orange to yellow) emit from the ship's rear with a wider spread and shorter lifetime than destruction particles.
  • Explosion particles (particles): Asteroid hits and ship death each release a burst of coloured particles that scatter, fade, and inherit the destroyed object's velocity.
  • Starfield (stars): 200 stars with varied sizes, twinkle (sine-based opacity oscillation), depth layers (larger stars glow), and slow downward parallax drift that wraps around.
  • Background vignette: A radial gradient overlay adds subtle depth — brighter at the centre, darker at the edges. Not user-toggleable.

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 "Unknown").
  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

Score submissions require a single-use HMAC-signed token obtained from GET /api/highscores/check. The flow:

  1. Death → check: The client calls GET /api/highscores/check?score=N and receives {"is_highscore": bool, "token": "..."}.
  2. Submit: The client includes the token in the POST /api/highscores body. The server verifies it against the submitted score using HMAC-SHA256 with a server-side secret. Tokens are valid for 10 seconds and can only be used once. The name-entry modal shows a live countdown and auto-dismisses when the token expires.

This prevents direct API calls (even via Swagger) and replay attacks. An attacker would need to play the game to obtain a valid token, and could only submit a score matching the one they checked.

Audio

All sound effects are synthesized at runtime using the Web Audio API — no audio files are loaded:

  • Fire: Square-wave oscillator, frequency sweeps from 880Hz → 440Hz over 0.1s, low gain.
  • Explosion: 0.25s white noise buffer with exponential gain decay.
  • Thrust: Sawtooth oscillator at ~60–80Hz that plays continuously while the thrust key is held, with a 0.1s fade-out on release.

The AudioContext is created on the first sound trigger (browser policy), so there is no upfront cost. All audio can be disabled via the Sound effects toggle in settings.

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
Enter (game over) Restart
Escape Open / close settings
P Pause / resume
Click eye icon Toggle focus mode (dims sidebars, keeps canvas)

All key bindings are customisable via the settings panel. P and Escape cannot be rebound. A controls sidebar on the left of the canvas shows the current key bindings at all times (with line-height: 1.9 for comfortable reading); it is dimmed in focus mode. On narrow screens the sidebar is hidden and bindings are shown inline above the canvas.

Settings

Click the cog icon or press Escape to open the settings panel. Settings are persisted in browser localStorage.

Key Mappings

Each action (thrust, rotate left, rotate right, fire) can be rebound to any keyboard key. Click a bind button, then press the desired key. Binding a key that is already in use will reassign it (the old action reverts to its default). P and Escape are reserved and cannot be bound.

Game Options

Seven toggles are available in the left sidebar, each persisted in browser localStorage:

Toggle Setting Effect when off
Sound sound Mutes all synthesized audio
Shake shake Suppresses camera shake on hits and death
Stars stars Hides the entire starfield
Glow glowFX Removes the neon shadowBlur from all polygons and floating text
Hit FX hits Suppresses floating +N score popups and the hit sound
Thrust FX thrustfx Suppresses the warm-coloured thrust flame particles
Particles particles Suppresses explosion particle bursts from asteroid hits and ship death

A "thrust" particle is the short-lived flame emitted from the ship's rear while the thrust key is held. The Hit FX and Particles toggles have distinct purposes: Hit FX controls score-feedback text and sound, while Particles controls the visual debris burst. All toggles default to on and can be changed mid-game without restarting.

Colour Themes

The Default (bootswatch) mode inherits colours from the active Bootswatch theme and is not editable. Create a named theme via the + button to save custom colours — colour pickers become editable and changes are saved in real time. Themes can be switched, renamed, or deleted. Background colour is included alongside ship, bullets, asteroids, and stars.

Files

File Role
templates/asteroids.html Game template (layout, styles, script include)
static/js/asteroids.js Game engine (all client-side logic)
tests/js/asteroids.test.js 53 unit tests for exported pure functions
src/routers/asteroids.py GET /asteroids route
src/routers/highscores.py GET/POST /api/highscores, GET /api/highscores/check
src/store.py Thread-safe in-memory high score store