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:
- Facing change — 0, 1, or 2 hex-face rotations (clockwise or anticlockwise)
- Thrust allocation — distribute
thrust_ratingpoints across: - Ahead — accelerate in current facing direction
- Port/Starboard — lateral thrust (rotate velocity vector)
- Brake — decelerate
- 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/highscoreswith{ 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 loop —
update(dt)/draw()separation,dtcapped 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
localStoragepersistence — sameSTORAGE_KEYpattern{x, y}polygon format — compatible with Ship Creator exportdrawPolygon— 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