Hex Battle — Full Thrust-Inspired Space Combat

Top-down hex grid space battle game using ships built with the Ship Creator. Turn-based vector movement, facings, firing arcs, and threshold damage, inspired by the Full Thrust tabletop miniatures rules (1990s, Ground Zero Games).


Overview

Two interconnected pages:

Page Purpose Key File
Ship Stats Editor Extends Ship Creator to attach combat stats to saved ships static/js/ship-creator.js (modified)
Hex Battle The battle game itself — hex grid, ships, movement, combat static/js/hex-battle.js (new)

Page 1 — Ship Stats Editor

Extends the Ship Creator sidebar with a third tab: "Stats" (alongside existing Editor and Shapes tabs).

When a saved ship is selected in the Shapes tab, switching to the Stats tab shows a form to assign combat parameters:

Field Type Range Default Description
Hull Points number 1–50 10 Total damage capacity; threshold checks at 75%, 50%, 25%
Thrust Rating number 1–12 6 Max thrust points per turn (distributed across vectors)
Screen Rating number 0–4 1 Damage reduction vs beam weapons
Weapons list Each weapon has: name, type, mount position, arc

Weapon Types

Weapon Dice Range (hexes) Arc Special
Class-1 Beam 1 12 Fore
Class-2 Beam 2 12 Fore
Class-3 Beam 3 12 Fore May split dice between two targets
Pulse Torpedo 2 18 Any Ignores screens; half damage at >9 hexes
Missile 1 24 Fore Single-shot; 1 turn to reload

Weapon Mount Arc

Each weapon specifies one of four arcs (relative to ship facing):

      Fore (-60° to +60°)
         /\
   Port |  | Starboard
   (-120 to -60) | (60 to 120)
         \/
        Aft (120 to -120)

Storage Format

Saved ships in localStorage gain a stats field alongside regions:

{
  "my_cruiser": {
    "regions": [[{"x": 18, "y": 0}, ...], ...],
    "stats": {
      "hull": 12,
      "hullMax": 12,
      "thrust": 6,
      "screens": 2,
      "weapons": [
        {"name": "Fore Beam", "type": "class2", "arc": "fore", "mountX": 15, "mountY": 0},
        {"name": "Port Battery", "type": "class1", "arc": "port", "mountX": -8, "mountY": 10}
      ]
    }
  }
}

File changes

File Change
static/js/ship-creator.js Add Stats tab; save/load includes stats metadata
templates/playground.html Add Stats tab panel with form fields in sidebar

Page 2 — Hex Battle Game

File Plan

File Lines Status
static/js/hex-battle.js ~800 New — all game logic
templates/hex-battle.html ~150 New — game page template
src/routers/hex_battle.py ~10 New — route handler
src/main.py +2 Modified — register router

Template Layout

Follows the three-column flexbox pattern from asteroids.html and invaders.html:

┌──────────────────────────────────────────────────────────────────┐
│  breadcrumb                                                      │
├──────────────┬──────────────────────────────────┬────────────────┤
│  Left panel  │         Canvas (flex-grow)        │  Right panel   │
│  (160px)     │  1024×768 logical, HiDPI,        │  (200px)       │
│              │  zoom/pan/full-screen             │                │
│  Ship roster │                                  │  Event log     │
│  Turn info   │                                  │  Damage        │
│  Phase btn   │                                  │  notifications │
│  Controls    │                                  │                │
├──────────────┴──────────────────────────────────┴────────────────┤
│  Status bar: selected ship info, velocity vector, facing          │
└──────────────────────────────────────────────────────────────────┘

Canvas Setup

Identical to existing games:

var W = 1024, H = 768;
var dpr = window.devicePixelRatio || 1;
canvas.width = W * dpr;
canvas.height = H * dpr;
ctx.scale(dpr, dpr);

Full-screen via canvas.requestFullscreen(). On full-screen enter/exit, canvas buffer is resized to fill the screen while maintaining the logical coordinate system and HiDPI scaling.

Zoom/Pan applied as canvas transform:

ctx.save();
ctx.translate(battle.panX, battle.panY);
ctx.scale(battle.zoom, battle.zoom);
// draw world-space content (hexes, ships, range rings)
ctx.restore();
Input Behaviour
Mouse wheel Zoom in/out (0.25×–3×), centred on cursor
Middle-click drag Pan
F key / button Toggle full-screen
Z key Zoom to fit all ships
Esc Deselect

Hex Grid System

Geometry

Flat-top hexagons, outer radius 64px.

   /\
  |  |   radius = 64
   \/

Hex-to-pixel:

x = size * (3/2 * col)
y = size * (√3/2 * col + √3 * row)

Pixel-to-hex (for click detection):

q = (2/3 * x) / size
r = (-1/3 * x + √3/3 * y) / size
→ cube_round(q, r, -q - r)

Rendering

Each hex rendered as a 6-vertex polygon path (reuses existing drawPolygon pattern). Hexes outside the viewport (after zoom/pan) are culled.

Hex colours by terrain type:

Type Fill Label
Open space --bs-body-bg at 0.05 alpha
Debris field Orange tint D
Nebula Purple tint N
Planet Green circle P

Grid State

var battle = {
    hexGrid: null,         // sparse map: "col,row" → { terrain, occupiedBy }
    ships: [],             // all ships on the board
    turn: 0,
    phase: 'deploy',       // deploy → plot → move → fire → damage → end
    zoom: 1,
    panX: 0,
    panY: 0,
    selectedShip: null,
    targetShip: null,
    // ...
};

Ship Rendering

Each ship on the board has:

Field Type Description
hexQ, hexR int Current hex position
facing int 0–5 Direction (0 = right, 1 = upper-right, etc., clockwise)
velocity {q, r} Hex-coordinate velocity per turn
polygon [{x,y},...] Ship shape (from Ship Creator)
stats object Hull, thrust, screens, weapons (from stats editor)
damage int Current cumulative damage
thrustUsed int Thrust expended this turn
owner 0 or 1 Player 0 / Player 1 (hot-seat or AI)

Drawing a Ship on a Hex

var centre = hexToPixel(ship.hexQ, ship.hexR);
var angle = ship.facing * (Math.PI / 3);  // 0–5 → 0°–300° in 60° steps
drawPolygon(ship.polygon, centre.x, centre.y, angle, ship.colour);

Facing indicator: a small filled triangle drawn 8px ahead of the ship polygon's forward edge.

Colour by owner: Player 0 = --bs-primary, Player 1 = --bs-danger.

Selected ship highlight: ship polygon drawn with glow effect (shadowBlur) matching existing asteroids.js pattern.


Vector Movement System

Turn Sequence

Each turn progresses through five phases:

deploy → plot → move → fire → damage → end

The current phase determines what mouse/keyboard interactions are active.

Phase 1 — Deploy (turn 0 only)

Players place ships on their edge of the board. Click to set hex, then choose facing (0–5 via scroll or number keys).

Phase 2 — Plot

For each ship, the player allocates:

  1. Facing change — 0, 1, or 2 hex-face rotations (clockwise or anticlockwise)
  2. Thrust allocation — distribute thrust_rating points across:
  3. Ahead — accelerate in current facing direction
  4. Port/Starboard — lateral thrust (rotate velocity vector)
  5. Brake — decelerate
  6. Weapon targets — assign each weapon to a target ship (or hold)

UI: select a ship → show thrust allocation UI (sliders or +/- buttons for each vector) → confirm.

Phase 3 — Move

Resolve simultaneously for all ships:

1. Apply facing change (rotate ship polygon)
2. Apply thrust (delta to velocity in ship's local frame)
3. Convert velocity to hex offset and move ship
4. Check hex collisions (ships cannot occupy same hex)

Velocity model:

velocity = { q: number, r: number }

New velocity = old velocity + thrust_vector
  where thrust_vector is the thrust allocation converted
  from the ship's facing-relative frame to hex coordinates

Facing the ship changes which hex direction is "ahead",
  so thrust allocation is always in the ship's local frame.

This produces the signature Full Thrust "coast and pivot" feel — a ship moving rapidly doesn't stop quickly because brake thrust only counters one component of the existing velocity.

Phase 4 — Fire

Resolve weapons sequentially (order determined by initiative roll, 1d6 per ship):

For each weapon with a target:
  1. Check target is in weapon's arc
  2. Compute hex distance (range check)
  3. Roll to-hit (2d6, target 4+ at short range, 6+ at medium, 8+ at long)
  4. If hit, roll damage dice (weapon-dependent)
  5. Apply screens (reduce damage by screen rating)
  6. Deduct remaining damage from hull
  7. If hull crosses a threshold (75%, 50%, 25%), roll on damage table

Range bands (for to-hit):

Range Hexes To-Hit (2d6)
Short 1–4 4+
Medium 5–8 6+
Long 9–12 8+

Arc check — given the target hex position, compute the bearing angle from the firing ship's hex. Compare to ship facing:

fore:       bearing within ±60° of facing
port:       bearing between 60° and 120° left of facing
starboard:  bearing between 60° and 120° right of facing
aft:        bearing within 120°–180° of facing

Phase 5 — Damage

Roll threshold checks for any ship that crossed a hull threshold this turn:

var thresholdRoll = randInt(1, 6);
// +1 if damage from explosion (nearby ship destroyed)
// -1 if ship has screens down
switch (thresholdRoll) {
    case 1: // Crew hit — cannot fire next turn
    case 2: // Random weapon destroyed
    case 3: // Engine hit — thrust halved
    case 4: // Fire control — cannot change facing next turn
    case 5: // Screens down
    case 6: // No effect
}

Phase 6 — End

  • Reload missile launchers
  • Clear thrust allocation
  • Check victory conditions (all enemy ships destroyed, or scenario-specific)
  • Advance turn counter
  • Back to Plot phase

Weapon Visual Feedback

Event Visual
Beam fire White line from weapon mount to target hex, 200ms
Torpedo Blue particle trail (reuse particle system from asteroids.js)
Missile Red particle trail
Hit marker Brief flash (white bloom at impact point)
Ship destroyed Explosion particle burst + fading debris polygons
Threshold damage Brief red glow on ship

AI Opponent (Solo Mode)

Simple scripted AI for hot-seat solo play:

Priority order:
  1. If a target is in range and fore arc → fire all weapons
  2. If velocity is high and approaching board edge → brake
  3. If a target is in range but not in arc → pivot towards target
  4. If no target → move towards nearest enemy
  5. If hull < 50% → move away from nearest enemy

Thrust allocation: always use 50% for ahead, 25% for facing change, 25% for lateral correction.


Event Log

Right sidebar shows a scrollable log of all combat events:

Turn 3 — Fire Phase
  MyCruiser fires Class-2 Beam at EnemyFighter → hits (roll 7), 2 damage
  EnemyFighter hull: 6/10 (no threshold)
  EnemySniper fires Class-3 Beam at MyCruiser → hits (roll 9), 3 damage
  MyCruiser screens reduce to 1 damage
  MyCruiser hull: 14/15

Turn 3 — Damage Phase
  EnemyFighter at 50% hull — threshold roll: 4 (Fire control hit)

High Scores

Same API pattern as Asteroids/Invaders:

  • On game end (all ships of one side destroyed), show score modal
  • Score = (enemy hull points destroyed × 100) + (turns survived × 10) + (ships remaining × 500)
  • Submit via POST /api/highscores with { game: "hex_battle", score, name, ... }
  • Show top 10 in the sidebar

File-by-File Implementation Order

Phase 1 — Foundation

Step Files What
1 src/routers/hex_battle.py Route: GET /hex-battle
2 src/main.py include_router(hex_battle.router)
3 templates/hex-battle.html Three-column template, canvas, sidebar containers, full-screen button

Phase 2 — Hex Grid + Ship Viewer

Step Files What
4 static/js/hex-battle.js Canvas setup (HiDPI, rAF loop)
5 Same file Hex geometry functions (hex-to-pixel, pixel-to-hex, hex neighbours)
6 Same file Hex grid rendering with zoom/pan
7 Same file Load a saved ship from localStorage, draw it on a hex at a facing
8 Same file Full-screen support

Phase 3 — Movement

Step Files What
9 Same file Ship state model (hex, facing, velocity, stats)
10 Same file Thrust allocation UI (plot phase)
11 Same file Vector movement resolution (move phase)
12 Same file Hex collision detection

Phase 4 — Combat

Step Files What
13 Same file Firing arc calculation
14 Same file Weapon stat loading from saved ship
15 Same file To-hit and damage resolution
16 Same file Screen damage reduction
17 Same file Hull threshold checks + damage table

Phase 5 — Ship Stats Editor

Step Files What
18 static/js/ship-creator.js Stats tab UI (hull, thrust, screens, weapon list)
19 templates/playground.html Stats tab panel

Phase 6 — Polish

Step Files What
20 static/js/hex-battle.js Event log
21 Same file Turn flow state machine
22 Same file Basic AI opponent
23 Same file High score integration
24 Same file Particle effects (weapon fire, explosions)

Code Conventions

All new code follows the established patterns from asteroids.js and invaders.js:

  • IIFE wrapper — no global scope pollution
  • rAF game loopupdate(dt) / draw() separation, dt capped at 0.05s
  • draw() never mutates state — rendering is a pure function of game state
  • 1024×768 logical, HiDPI-scaled — identical canvas setup
  • Three-column flexbox layout — same template structure
  • localStorage persistence — same STORAGE_KEY pattern
  • {x, y} polygon format — compatible with Ship Creator export
  • drawPolygon — same utility signature as asteroids.js
  • Key bindings via keys[e.code] — same input model
  • Particle/floating-text systems — same struct and lifecycle as existing games