Skip to content

Commit 015e762

Browse files
committed
Implement combat system with damage tracking and phase cycling
- Add gun combat engine (combat.ts): odds-based damage table, range/velocity modifiers, counterattack system, cumulative disabled turns, elimination - Add asteroid hazard rolls during movement (Other Damage table) - Implement proper phase cycling: astrogation -> combat -> resupply -> next player - Disabled ships drift without maneuvering, damage recovers 1 turn per game turn - Wire combat/skipCombat messages through server DO - Add combat phase UI with skip combat button - Add 42 combat unit tests (112 total) - Add README.md, rename spec.md to SPEC.md
1 parent 9d809bc commit 015e762

File tree

11 files changed

+1018
-65
lines changed

11 files changed

+1018
-65
lines changed

README.md

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
# Delta-V
2+
3+
An online multiplayer implementation of [Triplanetary](https://en.wikipedia.org/wiki/Triplanetary_(board_game)) -- space combat with vector movement and gravity across the inner Solar System.
4+
5+
## What is it?
6+
7+
Two players command ships racing between planets. Ships move using realistic vector physics on a hex grid: velocity persists between turns, fuel is burned to accelerate, and planetary gravity deflects your course. Combat uses odds-based dice resolution with range and velocity modifiers.
8+
9+
The game renders as a smooth, continuous-space experience (no visible hex grid) while using axial hex coordinates internally for all game logic.
10+
11+
## Quick Start
12+
13+
```bash
14+
npm install
15+
npm run dev # Start local dev server (wrangler)
16+
```
17+
18+
Open two browser tabs to `http://localhost:8787`. Create a game in one tab, join with the code in the other.
19+
20+
## Commands
21+
22+
| Command | Description |
23+
|---------|-------------|
24+
| `npm run dev` | Start local development server |
25+
| `npm run build` | Build client bundle |
26+
| `npm run typecheck` | Run TypeScript type checking |
27+
| `npm test` | Run all tests |
28+
| `npm run test:watch` | Run tests in watch mode |
29+
| `npm run deploy` | Deploy to Cloudflare Workers |
30+
31+
## Architecture
32+
33+
Full TypeScript stack: Cloudflare Workers + Durable Objects on the server, HTML5 Canvas on the client.
34+
35+
```
36+
src/
37+
shared/ Shared game logic (server + client)
38+
hex.ts Axial hex math library
39+
movement.ts Vector movement + gravity engine
40+
combat.ts Gun combat, damage tables, dice
41+
game-engine.ts Pure game state machine (no IO)
42+
map-data.ts Solar system map + scenarios
43+
types.ts All type definitions
44+
constants.ts Ship stats, game constants
45+
server/
46+
index.ts Worker entry (HTTP + WebSocket routing)
47+
game-do.ts Durable Object (game state, turn lifecycle)
48+
client/
49+
main.ts Client state machine + WebSocket
50+
renderer.ts Canvas rendering + camera + animation
51+
input.ts Mouse/touch input, burn planning
52+
ui.ts HTML overlay UI (menus, HUD, game over)
53+
```
54+
55+
The game engine (`game-engine.ts`) is a pure-function module with no IO, making it fully unit-testable. The Durable Object (`game-do.ts`) is a thin wrapper handling WebSocket lifecycle and storage.
56+
57+
## Game Rules
58+
59+
See [SPEC.md](SPEC.md) for the full game specification including movement, combat, damage, and scenario rules.
60+
61+
### Implemented
62+
63+
- Vector movement with velocity persistence
64+
- Planetary gravity (full and weak)
65+
- Fuel management and resupply at bases
66+
- Overload maneuver (warships burn 2 fuel for 2-hex acceleration)
67+
- Landing and takeoff mechanics
68+
- Gun combat with odds-based damage table
69+
- Counterattack system
70+
- Damage tracking (disabled turns, cumulative elimination)
71+
- Asteroid hazard rolls
72+
- Phase cycling (astrogation -> combat -> resupply -> next player)
73+
- Bi-Planetary scenario (Mars vs Venus race)
74+
75+
### Planned
76+
77+
- Combat target selection UI
78+
- Ordnance (mines, torpedoes)
79+
- Additional scenarios (Escape, Merchant, Piracy, Fleet)
80+
- Full solar system map with asteroid belt
81+
- Detection and fog of war
82+
83+
## Tech Stack
84+
85+
- **Runtime**: Cloudflare Workers + Durable Objects
86+
- **Language**: TypeScript (full stack)
87+
- **Rendering**: HTML5 Canvas 2D
88+
- **Build**: esbuild (client), wrangler (server)
89+
- **Testing**: vitest
90+
- **CI**: GitHub Actions (typecheck + test + build)
91+
92+
## License
93+
94+
All rights reserved.
File renamed without changes.

src/client/main.ts

Lines changed: 31 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ type ClientState =
1010
| 'connecting'
1111
| 'waitingForOpponent'
1212
| 'playing_astrogation'
13+
| 'playing_combat'
1314
| 'playing_movementAnim'
1415
| 'playing_opponentTurn'
1516
| 'gameOver';
@@ -40,6 +41,7 @@ class GameClient {
4041
this.ui.onCreate = () => this.createGame();
4142
this.ui.onJoin = (code) => this.joinGame(code);
4243
this.ui.onConfirm = () => this.confirmOrders();
44+
this.ui.onSkipCombat = () => this.sendSkipCombat();
4345
this.ui.onRematch = () => this.sendRematch();
4446
this.ui.onExit = () => this.exitToMenu();
4547

@@ -90,6 +92,11 @@ class GameClient {
9092
this.renderer.frameOnShips();
9193
break;
9294

95+
case 'playing_combat':
96+
this.ui.showHUD();
97+
this.updateHUD();
98+
break;
99+
93100
case 'playing_movementAnim':
94101
this.ui.showHUD();
95102
this.ui.showMovementStatus();
@@ -180,16 +187,20 @@ class GameClient {
180187
});
181188
break;
182189

190+
case 'combatResult':
191+
this.gameState = this.deserializeState(msg.state);
192+
this.renderer.setGameState(this.gameState);
193+
this.input.setGameState(this.gameState);
194+
// After combat resolves, transition based on new state
195+
this.transitionToPhase();
196+
break;
197+
183198
case 'stateUpdate':
184199
this.gameState = this.deserializeState(msg.state);
185200
this.renderer.setGameState(this.gameState);
186201
this.input.setGameState(this.gameState);
187202
if (this.state !== 'playing_movementAnim') {
188-
if (this.gameState.activePlayer === this.playerId) {
189-
this.setState('playing_astrogation');
190-
} else {
191-
this.setState('playing_opponentTurn');
192-
}
203+
this.transitionToPhase();
193204
}
194205
break;
195206

@@ -240,16 +251,30 @@ class GameClient {
240251
}
241252

242253
private onAnimationComplete() {
254+
if (!this.gameState) return;
255+
this.transitionToPhase();
256+
}
257+
258+
private transitionToPhase() {
243259
if (!this.gameState) return;
244260
if (this.gameState.phase === 'gameOver') return;
245261

246-
if (this.gameState.activePlayer === this.playerId) {
262+
const isMyTurn = this.gameState.activePlayer === this.playerId;
263+
264+
if (this.gameState.phase === 'combat' && isMyTurn) {
265+
this.setState('playing_combat');
266+
} else if (this.gameState.phase === 'astrogation' && isMyTurn) {
247267
this.setState('playing_astrogation');
248268
} else {
249269
this.setState('playing_opponentTurn');
250270
}
251271
}
252272

273+
private sendSkipCombat() {
274+
if (!this.gameState || this.state !== 'playing_combat') return;
275+
this.send({ type: 'skipCombat' });
276+
}
277+
253278
private sendRematch() {
254279
this.send({ type: 'rematch' });
255280
}

src/client/ui.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ export class UIManager {
88
onCreate: (() => void) | null = null;
99
onJoin: ((code: string) => void) | null = null;
1010
onConfirm: (() => void) | null = null;
11+
onSkipCombat: (() => void) | null = null;
1112
onRematch: (() => void) | null = null;
1213
onExit: (() => void) | null = null;
1314

@@ -44,6 +45,7 @@ export class UIManager {
4445
});
4546

4647
document.getElementById('confirmBtn')!.addEventListener('click', () => this.onConfirm?.());
48+
document.getElementById('skipCombatBtn')!.addEventListener('click', () => this.onSkipCombat?.());
4749
document.getElementById('rematchBtn')!.addEventListener('click', () => this.onRematch?.());
4850
document.getElementById('exitBtn')!.addEventListener('click', () => this.onExit?.());
4951
}
@@ -87,13 +89,19 @@ export class UIManager {
8789
const confirmBtn = document.getElementById('confirmBtn')!;
8890
confirmBtn.style.display = isMyTurn && phase === 'astrogation' ? 'inline-block' : 'none';
8991

92+
const skipCombatBtn = document.getElementById('skipCombatBtn')!;
93+
skipCombatBtn.style.display = isMyTurn && phase === 'combat' ? 'inline-block' : 'none';
94+
9095
const statusMsg = document.getElementById('statusMsg')!;
9196
if (!isMyTurn) {
9297
statusMsg.textContent = 'Waiting for opponent...';
9398
statusMsg.style.display = 'block';
9499
} else if (phase === 'astrogation') {
95100
statusMsg.textContent = 'Select your ship and set a burn direction, then confirm';
96101
statusMsg.style.display = 'block';
102+
} else if (phase === 'combat') {
103+
statusMsg.textContent = 'Combat phase — skip or engage enemy ships';
104+
statusMsg.style.display = 'block';
97105
} else {
98106
statusMsg.style.display = 'none';
99107
}

src/server/game-do.ts

Lines changed: 44 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import { DurableObject } from 'cloudflare:workers';
2-
import type { GameState, C2S, S2C, AstrogationOrder } from '../shared/types';
2+
import type { GameState, C2S, S2C, AstrogationOrder, CombatAttack } from '../shared/types';
33
import { getSolarSystemMap, SCENARIOS, findBaseHex } from '../shared/map-data';
44
import { INACTIVITY_TIMEOUT_MS } from '../shared/constants';
5-
import { createGame, processAstrogation } from '../shared/game-engine';
5+
import { createGame, processAstrogation, processCombat, skipCombat } from '../shared/game-engine';
66

77
export interface Env {
88
ASSETS: Fetcher;
@@ -103,6 +103,12 @@ export class GameDO extends DurableObject {
103103
case 'astrogation':
104104
await this.handleAstrogation(playerId, ws, msg.orders);
105105
break;
106+
case 'combat':
107+
await this.handleCombat(playerId, ws, msg.attacks);
108+
break;
109+
case 'skipCombat':
110+
await this.handleSkipCombat(playerId, ws);
111+
break;
106112
case 'rematch':
107113
await this.initGame();
108114
break;
@@ -148,21 +154,47 @@ export class GameDO extends DurableObject {
148154
return;
149155
}
150156

151-
// Broadcast movement results
152-
this.broadcast({ type: 'movementResult', movements: result.movements, state: result.state });
157+
this.broadcast({ type: 'movementResult', movements: result.movements, events: result.events, state: result.state });
158+
this.broadcastEndOrUpdate(result.state);
159+
await this.saveGameState(result.state);
160+
}
161+
162+
private async handleCombat(playerId: number, ws: WebSocket, attacks: CombatAttack[]) {
163+
const gameState = await this.getGameState();
164+
if (!gameState) return;
165+
166+
const result = processCombat(gameState, playerId, attacks);
167+
168+
if ('error' in result) {
169+
this.send(ws, { type: 'error', message: result.error });
170+
return;
171+
}
172+
173+
this.broadcast({ type: 'combatResult', results: result.results, state: result.state });
174+
this.broadcastEndOrUpdate(result.state);
175+
await this.saveGameState(result.state);
176+
}
177+
178+
private async handleSkipCombat(playerId: number, ws: WebSocket) {
179+
const gameState = await this.getGameState();
180+
if (!gameState) return;
181+
182+
const result = skipCombat(gameState, playerId);
153183

154-
if (result.state.phase === 'gameOver') {
155-
this.broadcast({
156-
type: 'gameOver',
157-
winner: result.state.winner!,
158-
reason: result.state.winReason!,
159-
});
184+
if ('error' in result) {
185+
this.send(ws, { type: 'error', message: result.error });
186+
return;
160187
}
161188

189+
this.broadcast({ type: 'stateUpdate', state: result.state });
162190
await this.saveGameState(result.state);
191+
}
163192

164-
if (result.state.phase !== 'gameOver') {
165-
this.broadcast({ type: 'stateUpdate', state: result.state });
193+
private broadcastEndOrUpdate(state: GameState) {
194+
if (state.phase === 'gameOver') {
195+
this.broadcast({ type: 'gameOver', winner: state.winner!, reason: state.winReason! });
196+
} else {
197+
this.broadcast({ type: 'stateUpdate', state });
166198
}
167199
}
168200

0 commit comments

Comments
 (0)