SpaceRace: Full-width TV field, death line scrolling, camera following leader, jump fix, path never fully blocked, player color on controller

This commit is contained in:
2026-06-24 16:41:37 +02:00
commit 5e2ab43ca3
46 changed files with 6581 additions and 0 deletions
+10
View File
@@ -0,0 +1,10 @@
FROM node:22-alpine AS server-build
WORKDIR /app
COPY package.json package-lock.json ./
COPY shared/ ./shared/
COPY server/ ./server/
RUN npm ci --workspace=server --workspace=shared
RUN npm -w shared run build 2>/dev/null || true
WORKDIR /app/server
EXPOSE 8080
CMD ["npx", "tsx", "src/index.ts"]
+24
View File
@@ -0,0 +1,24 @@
{
"name": "@spacerace/server",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc --noEmit",
"start": "tsx src/index.ts"
},
"dependencies": {
"@spacerace/shared": "*",
"socket.io": "^4.7.5",
"cors": "^2.8.5",
"express": "^4.19.2",
"nanoid": "^5.0.7"
},
"devDependencies": {
"@types/express": "^4.17.21",
"@types/cors": "^2.8.17",
"@types/node": "^20.14.0",
"tsx": "^4.15.0",
"typescript": "^5.5.0"
}
}
+13
View File
@@ -0,0 +1,13 @@
export const GAME_CONFIG = {
GRID_WIDTH: 8,
VISIBLE_ROWS: 10,
AP_PER_ROUND: 4,
DEATH_LINE_ADVANCE: 1,
MAX_HAND_SIZE: 3,
PLANNING_TIME_SECONDS: 45,
EXECUTION_TICK_MS: 500,
MIN_PLAYERS: 2,
MAX_PLAYERS: 6,
TRACK_DENSITY: 0.2, // probability of asteroid per tile
SERVE_PORT: 8080,
} as const;
+48
View File
@@ -0,0 +1,48 @@
import { Action, actionApCost, CardType, CARD_DEFS } from '@spacerace/shared';
import { GAME_CONFIG } from '../config.js';
export function validatePlan(actions: Action[], hand: CardType[]): { valid: boolean; error?: string } {
if (actions.length === 0) {
return { valid: false, error: 'Plan cannot be empty' };
}
let totalAp = 0;
const usedCards = new Set<CardType>();
for (const action of actions) {
const cost = actionApCost(action);
totalAp += cost;
if (totalAp > GAME_CONFIG.AP_PER_ROUND) {
return { valid: false, error: `Exceeds ${GAME_CONFIG.AP_PER_ROUND} AP limit (used ${totalAp})` };
}
if (action.type === 'CARD') {
if (!action.card) {
return { valid: false, error: 'Card action requires a card type' };
}
if (!CARD_DEFS[action.card]) {
return { valid: false, error: `Unknown card: ${action.card}` };
}
if (!hand.includes(action.card)) {
return { valid: false, error: `Card ${action.card} not in hand` };
}
if (usedCards.has(action.card)) {
return { valid: false, error: `Card ${action.card} used twice` };
}
usedCards.add(action.card);
}
}
return { valid: true };
}
export function removeUsedCards(actions: Action[], hand: CardType[]): CardType[] {
const usedCards = new Set<CardType>();
for (const action of actions) {
if (action.type === 'CARD' && action.card) {
usedCards.add(action.card);
}
}
return hand.filter((c) => !usedCards.has(c));
}
+91
View File
@@ -0,0 +1,91 @@
import { CardType, Position, CARD_DEFS } from '@spacerace/shared';
import { Grid } from './Grid.js';
import { Ship } from './Ship.js';
export interface CardContext {
ship: Ship;
target?: Position;
grid: Grid;
allShips: Ship[];
shipStates: Map<string, Ship>;
}
export function executeCard(cardType: CardType, ctx: CardContext): { success: boolean; message: string; gridUpdates: { position: Position; tile: string }[] } {
const def = CARD_DEFS[cardType];
if (!def) return { success: false, message: 'Unknown card', gridUpdates: [] };
switch (cardType) {
case 'METEOR_STRIKE': {
if (!ctx.target) return { success: false, message: 'No target for Meteor Strike', gridUpdates: [] };
// Check range: within 3 tiles of ship
const dist = Math.abs(ctx.ship.position.x - ctx.target.x) + Math.abs(ctx.ship.position.y - ctx.target.y);
if (dist > 3) return { success: false, message: 'Target out of range', gridUpdates: [] };
ctx.grid.setTile(ctx.target, 'meteor');
return { success: true, message: `${ctx.ship.playerId} summoned a meteor at (${ctx.target.x},${ctx.target.y})`, gridUpdates: [{ position: ctx.target, tile: 'meteor' }] };
}
case 'SHIELD': {
ctx.ship.shielded = true;
return { success: true, message: `${ctx.ship.playerId} activated shield`, gridUpdates: [] };
}
case 'EMP': {
ctx.ship.empActive = true;
return { success: true, message: `${ctx.ship.playerId} activated EMP`, gridUpdates: [] };
}
case 'JUMP': {
const dir = ctx.ship.direction;
const dx = dir === 'N' ? -1 : dir === 'S' ? 1 : 0;
const dy = dir === 'E' ? 1 : dir === 'W' ? -1 : 0;
const jumpPos: Position = {
x: ctx.ship.position.x + dx * 2,
y: ctx.ship.position.y + dy * 2,
};
if (!ctx.grid.isInBounds(jumpPos)) return { success: false, message: 'Jump out of bounds', gridUpdates: [] };
ctx.ship.position = jumpPos;
return { success: true, message: `${ctx.ship.playerId} jumped 2 tiles forward`, gridUpdates: [] };
}
case 'MINE': {
ctx.grid.setTile(ctx.ship.position, 'mine');
return { success: true, message: `${ctx.ship.playerId} dropped a mine`, gridUpdates: [{ position: ctx.ship.position, tile: 'mine' }] };
}
case 'TELEPORT': {
if (!ctx.target) return { success: false, message: 'No target for Teleport', gridUpdates: [] };
const targetX = ctx.target!.x;
const targetY = ctx.target!.y;
const targetShip = ctx.allShips.find(
(s) => s.alive && s.id !== ctx.ship.id &&
s.position.x === targetX && s.position.y === targetY
);
if (!targetShip) return { success: false, message: 'No ship at target location', gridUpdates: [] };
const dist = Math.abs(ctx.ship.position.x - ctx.target.x) + Math.abs(ctx.ship.position.y - ctx.target.y);
if (dist > 5) return { success: false, message: 'Target out of range for Teleport', gridUpdates: [] };
// Check EMP on target ship
const targetState = ctx.shipStates.get(targetShip.id);
if (targetState?.empActive) {
targetState.empActive = false;
return { success: false, message: `Teleport blocked by ${targetShip.playerId}'s EMP`, gridUpdates: [] };
}
const myPos = { ...ctx.ship.position };
ctx.ship.position = { ...targetShip.position };
targetShip.position = myPos;
return { success: true, message: `${ctx.ship.playerId} teleported with ${targetShip.playerId}`, gridUpdates: [] };
}
case 'PHASE_SHIFT': {
ctx.ship.phaseShifting = true;
return { success: true, message: `${ctx.ship.playerId} activated Phase Shift`, gridUpdates: [] };
}
default:
return { success: false, message: 'Unknown card', gridUpdates: [] };
}
}
+309
View File
@@ -0,0 +1,309 @@
import {
Action,
PlayerPlan,
Position,
Direction,
DIRECTION_DELTA,
isBlocked as isTileBlocked,
ExecutionResult,
ShipUpdate,
GridUpdate,
CardType,
} from '@spacerace/shared';
import { Grid } from './Grid.js';
import { Ship } from './Ship.js';
import { executeCard } from './CardHandler.js';
interface TickPlan {
shipId: string;
actionIndex: number;
action: Action;
}
export class Executor {
private ships: Map<string, Ship>;
private grid: Grid;
private deadPlayers: Set<string> = new Set();
constructor(ships: Map<string, Ship>, grid: Grid) {
this.ships = ships;
this.grid = grid;
}
execute(plans: PlayerPlan[]): ExecutionResult[] {
const results: ExecutionResult[] = [];
// Build tick-based action queue
const planMap = new Map<string, Action[]>();
for (const plan of plans) {
const ship = Array.from(this.ships.values()).find((s) => s.playerId === plan.playerId);
if (ship && ship.alive) {
planMap.set(ship.id, [...plan.actions]);
}
}
// Determine max ticks
let maxTicks = 0;
for (const actions of planMap.values()) {
maxTicks = Math.max(maxTicks, actions.length);
}
// Execute tick by tick
for (let tick = 0; tick < maxTicks; tick++) {
const result: ExecutionResult = {
tick,
shipUpdates: [],
gridUpdates: [],
messages: [],
};
// Phase 1: Resolve card actions first
const cardActions: { shipId: string; action: Action }[] = [];
const nonCardActions: { shipId: string; action: Action }[] = [];
for (const [shipId, actions] of planMap) {
if (tick >= actions.length) continue;
const action = actions[tick];
if (action.type === 'CARD') {
cardActions.push({ shipId, action });
} else {
nonCardActions.push({ shipId, action });
}
}
// Execute card actions
for (const { shipId, action } of cardActions) {
const ship = this.ships.get(shipId);
if (!ship || !ship.alive) continue;
const ctx = {
ship,
target: action.target,
grid: this.grid,
allShips: Array.from(this.ships.values()),
shipStates: this.ships,
};
const cardResult = executeCard(action.card as CardType, ctx);
if (cardResult.success) {
result.messages.push(cardResult.message);
for (const gu of cardResult.gridUpdates) {
result.gridUpdates.push({ type: 'tile_change', position: gu.position, tile: gu.tile as any });
}
} else {
result.messages.push(`[${ship.playerId}] Card failed: ${cardResult.message}`);
}
}
// Phase 2: Determine movement intentions
interface MoveIntent {
shipId: string;
from: Position;
to: Position | null;
}
const intents: MoveIntent[] = [];
for (const { shipId, action } of nonCardActions) {
const ship = this.ships.get(shipId);
if (!ship || !ship.alive) continue;
switch (action.type) {
case 'MOVE_FORWARD': {
const delta = DIRECTION_DELTA[ship.direction];
const to: Position = {
x: ship.position.x + delta.x,
y: ship.position.y + delta.y,
};
intents.push({ shipId, from: { ...ship.position }, to });
break;
}
case 'BOOST': {
const delta = DIRECTION_DELTA[ship.direction];
const to: Position = {
x: ship.position.x + delta.x * 3,
y: ship.position.y + delta.y * 3,
};
intents.push({ shipId, from: { ...ship.position }, to });
break;
}
case 'TURN_LEFT': {
const dirs: Direction[] = ['N', 'W', 'S', 'E'];
const idx = dirs.indexOf(ship.direction);
ship.direction = dirs[(idx + 1) % 4];
result.shipUpdates.push({ type: 'turn', shipId, direction: ship.direction });
result.messages.push(`[${ship.playerId}] turned left`);
intents.push({ shipId, from: { ...ship.position }, to: null });
break;
}
case 'TURN_RIGHT': {
const dirs: Direction[] = ['N', 'E', 'S', 'W'];
const idx = dirs.indexOf(ship.direction);
ship.direction = dirs[(idx + 1) % 4];
result.shipUpdates.push({ type: 'turn', shipId, direction: ship.direction });
result.messages.push(`[${ship.playerId}] turned right`);
intents.push({ shipId, from: { ...ship.position }, to: null });
break;
}
case 'TURN_180': {
const dirs: Direction[] = ['N', 'S', 'E', 'W'];
const map: Record<Direction, Direction> = { N: 'S', S: 'N', E: 'W', W: 'E' };
ship.direction = map[ship.direction];
result.shipUpdates.push({ type: 'turn', shipId, direction: ship.direction });
result.messages.push(`[${ship.playerId}] turned 180°`);
intents.push({ shipId, from: { ...ship.position }, to: null });
break;
}
}
}
// Phase 3: Resolve movement conflicts
// Count how many ships want to go to each tile
const destinationCount = new Map<string, string[]>();
for (const intent of intents) {
if (!intent.to) continue;
const key = `${intent.to.x},${intent.to.y}`;
if (!destinationCount.has(key)) {
destinationCount.set(key, []);
}
destinationCount.get(key)!.push(intent.shipId);
}
// Track which ships are leaving their current tile
const leavingPositions = new Set<string>();
const shipsMovedThisTick = new Set<string>();
for (const intent of intents) {
if (!intent.to) continue;
const ship = this.ships.get(intent.shipId);
if (!ship || !ship.alive) continue;
const destKey = `${intent.to.x},${intent.to.y}`;
const shipsToDest = destinationCount.get(destKey) || [];
// Conflict: multiple ships want same tile
if (shipsToDest.length > 1) {
result.messages.push(`[${ship.playerId}] collision conflict at (${intent.to.x},${intent.to.y})`);
result.shipUpdates.push({ type: 'collision', shipId: intent.shipId, position: { ...ship.position } });
continue;
}
// Check bounds
if (!this.grid.isInBounds(intent.to)) {
result.messages.push(`[${ship.playerId}] blocked by track boundary`);
continue;
}
// Check obstacles (phase shift ignores them)
if (!ship.phaseShifting && this.grid.isBlocked(intent.to)) {
if (ship.shielded) {
ship.shielded = false;
result.messages.push(`[${ship.playerId}] shield absorbed obstacle`);
result.shipUpdates.push({ type: 'shield_used', shipId: intent.shipId });
// Still move there with shield
} else {
result.messages.push(`[${ship.playerId}] blocked by obstacle at (${intent.to.x},${intent.to.y})`);
continue;
}
}
// Check if a ship is already at destination and not moving away
const to = intent.to!;
const occupyingShip = Array.from(this.ships.values()).find(
(s) => s.alive && s.id !== intent.shipId &&
s.position.x === to.x && s.position.y === to.y
);
if (occupyingShip) {
// Check if occupying ship is also leaving this tick
const occupierLeaving = intents.find(
(i) => i.shipId === occupyingShip.id && i.to !== null
);
if (!occupierLeaving) {
result.messages.push(`[${ship.playerId}] blocked by ${occupyingShip.playerId}'s ship`);
continue;
}
}
// Move is valid!
const fromPos = { ...ship.position };
ship.position = { ...to };
shipsMovedThisTick.add(intent.shipId);
leavingPositions.add(`${fromPos.x},${fromPos.y}`);
if (ship.phaseShifting) {
ship.phaseShifting = false;
}
result.shipUpdates.push({
type: 'move',
shipId: intent.shipId,
from: fromPos,
to: { ...to },
});
result.messages.push(`[${ship.playerId}] moved to (${to.x},${to.y})`);
}
// Check mine explosions on ships that just moved
for (const shipId of shipsMovedThisTick) {
const ship = this.ships.get(shipId);
if (!ship || !ship.alive) continue;
if (this.grid.getTile(ship.position) === 'mine') {
ship.alive = false;
this.deadPlayers.add(ship.playerId);
this.grid.setTile(ship.position, 'space'); // mine exploded
result.shipUpdates.push({ type: 'eliminated', shipId, position: { ...ship.position }, reason: 'mine' });
result.gridUpdates.push({ type: 'tile_change', position: { ...ship.position }, tile: 'space' });
result.messages.push(`[${ship.playerId}] stepped on a mine and was destroyed!`);
}
}
results.push(result);
}
// After all ticks: advance death line and check eliminations
const deathLineResult: ExecutionResult = {
tick: -1,
shipUpdates: [],
gridUpdates: [],
messages: [],
};
let leadingY = 0;
for (const ship of this.ships.values()) {
if (ship.alive && ship.position.y > leadingY) {
leadingY = ship.position.y;
}
}
const newDeathLine = this.grid.advanceDeathLine(leadingY);
deathLineResult.gridUpdates.push({ type: 'death_line', y: newDeathLine });
const deathLineX = this.grid.deathLineY;
for (const ship of this.ships.values()) {
if (!ship.alive) continue;
if (ship.position.y < deathLineX) {
ship.alive = false;
this.deadPlayers.add(ship.playerId);
deathLineResult.shipUpdates.push({
type: 'eliminated',
shipId: ship.id,
position: { ...ship.position },
reason: 'death_line',
});
deathLineResult.messages.push(`[${ship.playerId}] fell behind and was eliminated!`);
}
}
if (deathLineResult.shipUpdates.length > 0 || deathLineResult.gridUpdates.length > 0) {
results.push(deathLineResult);
}
return results;
}
getDeadPlayerIds(): Set<string> {
return this.deadPlayers;
}
}
+290
View File
@@ -0,0 +1,290 @@
import {
GamePhase,
GameGridState,
GameState as GameStateType,
PlayerPlan,
CardType,
CARD_DEFS,
ExecutionResult,
actionApCost,
} from '@spacerace/shared';
import { Grid } from './Grid.js';
import { Ship } from './Ship.js';
import { Executor } from './Executor.js';
import { validatePlan, removeUsedCards } from './ActionQueue.js';
import { GAME_CONFIG } from '../config.js';
const ALL_CARDS: CardType[] = Object.keys(CARD_DEFS) as CardType[];
function drawCard(): CardType {
return ALL_CARDS[Math.floor(Math.random() * ALL_CARDS.length)];
}
function drawHand(): CardType[] {
return Array.from({ length: GAME_CONFIG.MAX_HAND_SIZE }, () => drawCard());
}
export interface GamePlayer {
id: string;
name: string;
shipId: string;
hand: CardType[];
ap: number;
apUsed: number;
actions: import('@spacerace/shared').Action[];
planSubmitted: boolean;
ready: boolean;
alive: boolean;
}
export class Game {
roomCode: string;
phase: GamePhase = 'LOBBY';
round: number = 0;
hostPlayerId: string = '';
grid: Grid;
ships: Map<string, Ship> = new Map();
players: Map<string, GamePlayer> = new Map();
playerOrder: string[] = [];
// Callbacks
onPhaseChange?: (phase: GamePhase) => void;
onPlanningStart?: (players: GamePlayer[], grid: GameGridState) => void;
onExecutionTick?: (results: ExecutionResult) => void;
onGameOver?: (winner: GamePlayer) => void;
// Timer
private planningTimer: ReturnType<typeof setTimeout> | null = null;
private planningSecondsLeft: number = 0;
constructor(roomCode: string) {
this.roomCode = roomCode;
this.grid = new Grid(Date.now());
}
get alivePlayers(): number {
let count = 0;
for (const p of this.players.values()) {
if (p.alive) count++;
}
return count;
}
addPlayer(playerId: string, name: string): GamePlayer {
const usedPositions = Array.from(this.ships.values()).map((s) => s.position);
const startPos = this.grid.findSafeStart(usedPositions, 2, 0) || { x: 3 + this.ships.size, y: 2 };
const ship = new Ship(`ship_${playerId}`, playerId, name, startPos, 'E');
this.ships.set(ship.id, ship);
const player: GamePlayer = {
id: playerId,
name,
shipId: ship.id,
hand: drawHand(),
ap: GAME_CONFIG.AP_PER_ROUND,
apUsed: 0,
actions: [],
planSubmitted: false,
ready: false,
alive: true,
};
this.players.set(playerId, player);
this.playerOrder.push(playerId);
return player;
}
removePlayer(playerId: string): void {
const player = this.players.get(playerId);
if (!player) return;
const ship = this.ships.get(player.shipId);
if (ship) {
ship.alive = false;
}
player.alive = false;
this.playerOrder = this.playerOrder.filter((id) => id !== playerId);
}
startGame(): boolean {
if (this.phase !== 'LOBBY') return false;
const alive = Array.from(this.players.values()).filter((p) => p.alive);
if (alive.length < GAME_CONFIG.MIN_PLAYERS) return false;
if (alive.length > GAME_CONFIG.MAX_PLAYERS) return false;
this.phase = 'PLANNING';
this.round = 1;
this.startPlanningPhase();
this.onPhaseChange?.('PLANNING');
return true;
}
private startPlanningPhase(): void {
// Reset round state for all players
for (const player of this.players.values()) {
if (!player.alive) continue;
player.ap = GAME_CONFIG.AP_PER_ROUND;
player.apUsed = 0;
player.actions = [];
player.planSubmitted = false;
player.ready = false;
// Reset ship effects
const ship = this.ships.get(player.shipId);
if (ship) ship.resetRoundEffects();
}
// Ensure grid is generated ahead of leading ships
let leadingX = -Infinity;
for (const ship of this.ships.values()) {
if (ship.alive && ship.position.y > leadingX) {
leadingX = ship.position.y;
}
}
if (leadingX > -Infinity) {
this.grid.ensureAheadOf(leadingX);
}
// Start countdown
this.planningSecondsLeft = GAME_CONFIG.PLANNING_TIME_SECONDS;
this.planningTimer = setInterval(() => {
this.planningSecondsLeft--;
if (this.planningSecondsLeft <= 0) {
this.executeRound();
}
}, 1000);
}
submitPlan(playerId: string, actions: import('@spacerace/shared').Action[]): { ok: boolean; error?: string } {
const player = this.players.get(playerId);
if (!player || !player.alive) return { ok: false, error: 'Player not found' };
if (this.phase !== 'PLANNING') return { ok: false, error: 'Not in planning phase' };
if (player.planSubmitted) return { ok: false, error: 'Plan already submitted' };
const result = validatePlan(actions, player.hand);
if (!result.valid) return { ok: false, error: result.error };
player.actions = actions;
player.apUsed = actions.reduce((sum, a) => sum + actionApCost(a), 0);
player.planSubmitted = true;
// Check if all alive players have submitted
this.checkAllPlansSubmitted();
return { ok: true };
}
private checkAllPlansSubmitted(): void {
const alive = Array.from(this.players.values()).filter((p) => p.alive);
const allSubmitted = alive.every((p) => p.planSubmitted);
if (allSubmitted) {
this.executeRound();
}
}
private executeRound(): void {
if (this.planningTimer) {
clearInterval(this.planningTimer);
this.planningTimer = null;
}
this.phase = 'EXECUTING';
this.onPhaseChange?.('EXECUTING');
// Build plans from submitted players
// Players who didn't submit get an empty plan (they stand still)
const plans: PlayerPlan[] = [];
for (const player of this.players.values()) {
if (!player.alive) continue;
plans.push({
playerId: player.id,
actions: player.planSubmitted ? [...player.actions] : [],
});
// Remove used cards from hand
if (player.planSubmitted) {
player.hand = removeUsedCards(player.actions, player.hand);
// Draw new cards to maintain hand size
while (player.hand.length < GAME_CONFIG.MAX_HAND_SIZE) {
player.hand.push(drawCard());
}
}
}
// Execute!
const executor = new Executor(this.ships, this.grid);
const results = executor.execute(plans);
// Emit results one by one
for (const result of results) {
this.onExecutionTick?.(result);
}
// Mark dead players
const deadIds = executor.getDeadPlayerIds();
for (const [id, player] of this.players) {
if (deadIds.has(id)) {
player.alive = false;
}
}
// Check game over
const alivePlayers = Array.from(this.players.values()).filter((p) => p.alive);
if (alivePlayers.length <= 1) {
this.phase = 'FINISHED';
this.onPhaseChange?.('FINISHED');
const winner = alivePlayers[0] || Array.from(this.players.values()).find((p) => !p.alive);
if (winner) {
this.onGameOver?.(winner);
}
return;
}
// Next round
this.round++;
this.phase = 'PLANNING';
this.onPhaseChange?.('PLANNING');
this.startPlanningPhase();
}
getGridState(): GameGridState {
return {
rows: this.grid.toState(),
deathLineY: this.grid.deathLineY,
generatedUpToY: this.grid.generatedUpToY,
};
}
getShipsState(): import('@spacerace/shared').Ship[] {
return Array.from(this.ships.values()).map((s) => s.toState());
}
getPlayerState(playerId: string): import('@spacerace/shared').PlayerView | null {
const player = this.players.get(playerId);
if (!player) return null;
let colorIndex = 0;
for (const [sid, ship] of this.ships) {
if (ship.playerId === playerId) break;
colorIndex++;
}
return {
playerId: player.id,
name: player.name,
ap: player.ap,
apUsed: player.apUsed,
hand: [...player.hand],
alive: player.alive,
actions: [...player.actions],
colorIndex,
};
}
destroy(): void {
if (this.planningTimer) {
clearInterval(this.planningTimer);
}
}
}
+156
View File
@@ -0,0 +1,156 @@
import {
Position,
TileType,
GRID_WIDTH,
} from '@spacerace/shared';
import { GAME_CONFIG } from '../config.js';
export class Grid {
private cols: Map<number, TileType[]> = new Map();
private _deathLineX: number;
private _generatedUpToX: number;
private seed: number;
constructor(seed: number = 42) {
this.seed = seed;
this._deathLineX = 0;
this._generatedUpToX = 12;
this.generateCols(0, 25);
this.clearRunway(0, 20);
}
private clearRunway(fromX: number, toX: number): void {
const start = Math.min(fromX, toX);
const end = Math.max(fromX, toX);
for (let x = start; x <= end; x++) {
this.cols.set(x, Array(GRID_WIDTH).fill('space'));
}
}
get deathLineY(): number {
return this._deathLineX;
}
get generatedUpToY(): number {
return this._generatedUpToX;
}
private randFor(seed: number): number {
let h = ((this.seed + seed) * 31 + seed * 7 + seed * seed * 13) % 2147483647;
return (h & 0x7fffffff) / 0x7fffffff;
}
getTile(pos: Position): TileType {
this.ensureCol(pos.y);
const col = this.cols.get(pos.y);
if (!col) return 'space';
return col[pos.x] ?? 'space';
}
private ensureCol(y: number): void {
if (this.cols.has(y)) return;
if (y > this._generatedUpToX) {
this.generateCols(this._generatedUpToX, y);
} else {
this.generateCols(0, y);
}
}
setTile(pos: Position, tile: TileType): void {
this.ensureCol(pos.y);
const col = this.cols.get(pos.y)!;
col[pos.x] = tile;
}
isBlocked(pos: Position): boolean {
const tile = this.getTile(pos);
return tile !== 'space';
}
isInBounds(pos: Position): boolean {
return pos.x >= 0 && pos.x < GRID_WIDTH;
}
advanceDeathLine(leadingY?: number): number {
let advance: number = GAME_CONFIG.DEATH_LINE_ADVANCE;
if (leadingY !== undefined) {
const maxBehind = 10;
const distance = leadingY - this._deathLineX;
if (distance > maxBehind) {
advance = Math.max(advance, distance - maxBehind);
}
}
this._deathLineX += advance;
return this._deathLineX;
}
generateCols(fromX: number, toX: number): void {
const start = Math.min(fromX, toX);
const end = Math.max(fromX, toX);
for (let x = start; x <= end; x++) {
if (this.cols.has(x)) continue;
const col: TileType[] = [];
const r = this.randFor(x * 7919);
const pattern = Math.abs((x * 13 + this.seed * 7) % 5);
for (let lane = 0; lane < GRID_WIDTH; lane++) {
const tileR = this.randFor(x * 1000 + lane);
switch (pattern) {
case 0: col.push(tileR < GAME_CONFIG.TRACK_DENSITY * 0.5 ? 'asteroid' : 'space'); break;
case 1: col.push(lane < 3 && tileR < 0.6 ? 'asteroid' : 'space'); break;
case 2: col.push(lane >= 5 && tileR < 0.6 ? 'asteroid' : 'space'); break;
case 3: col.push((lane < 2 || lane > 5) && tileR < 0.7 ? 'asteroid' : 'space'); break;
case 4: col.push(tileR < GAME_CONFIG.TRACK_DENSITY * 1.5 ? 'asteroid' : 'space'); break;
}
}
// Ensure at least 1 lane is open
const blocked = col.every((t) => t !== 'space');
if (blocked) {
const openLane = Math.floor(this.randFor(x * 31337) * GRID_WIDTH);
(col as any)[openLane] = 'space';
}
this.cols.set(x, col);
}
if (toX > this._generatedUpToX) {
this._generatedUpToX = Math.max(this._generatedUpToX, toX);
}
}
ensureAheadOf(leadingX: number): number {
const neededX = leadingX + 5;
if (neededX > this._generatedUpToX) {
this.generateCols(this._generatedUpToX + 1, neededX);
this._generatedUpToX = neededX;
}
return this._generatedUpToX;
}
toState(): Record<number, (TileType | null)[]> {
const state: Record<number, (TileType | null)[]> = {};
for (const [x, col] of this.cols) {
state[x] = [...col];
}
return state;
}
findSafeStart(usedPositions: Position[], startX: number, columnSpread: number = 2): Position | null {
const candidates: Position[] = [];
for (let lane = 0; lane < GRID_WIDTH; lane++) {
for (let colX = startX; colX <= startX + columnSpread; colX++) {
const pos = { x: lane, y: colX };
if (
!this.isBlocked(pos) &&
!usedPositions.some((p) => p.y === pos.y && p.x === pos.x)
) {
candidates.push(pos);
}
}
}
if (candidates.length === 0) return null;
return candidates[Math.floor(this.randFor(startX * 7) * candidates.length)];
}
}
+45
View File
@@ -0,0 +1,45 @@
import { Ship as ShipType, Position, Direction, posEqual } from '@spacerace/shared';
export class Ship {
id: string;
playerId: string;
playerName: string;
position: Position;
direction: Direction;
alive: boolean;
shielded: boolean;
phaseShifting: boolean;
empActive: boolean;
boostSteps: number;
constructor(id: string, playerId: string, playerName: string, position: Position, direction: Direction) {
this.id = id;
this.playerId = playerId;
this.playerName = playerName;
this.position = { ...position };
this.direction = direction;
this.alive = true;
this.shielded = false;
this.phaseShifting = false;
this.empActive = false;
this.boostSteps = 0;
}
resetRoundEffects(): void {
this.shielded = false;
this.phaseShifting = false;
this.empActive = false;
this.boostSteps = 0;
}
toState(): ShipType {
return {
id: this.id,
playerId: this.playerId,
playerName: this.playerName,
position: { ...this.position },
direction: this.direction,
alive: this.alive,
};
}
}
+41
View File
@@ -0,0 +1,41 @@
import express from 'express';
import cors from 'cors';
import { createServer } from 'http';
import { WsServer } from './ws/WsServer.js';
import { GAME_CONFIG } from './config.js';
const app = express();
app.use(cors());
app.use(express.json());
const httpServer = createServer(app);
const wsServer = new WsServer(httpServer);
app.get('/api/health', (_req, res) => {
res.json({ status: 'ok', uptime: process.uptime() });
});
httpServer.listen(GAME_CONFIG.SERVE_PORT, () => {
console.log(`[Server] SpaceRace server running on port ${GAME_CONFIG.SERVE_PORT}`);
console.log(`[Server] TV: http://localhost:${GAME_CONFIG.SERVE_PORT}`);
console.log(`[Server] Mobile: http://localhost:${GAME_CONFIG.SERVE_PORT}`);
});
// ── Graceful shutdown ──
function shutdown(signal: string) {
console.log(`\n[Server] Received ${signal}, shutting down...`);
wsServer.destroy();
httpServer.close(() => {
console.log('[Server] HTTP server closed');
process.exit(0);
});
// Force exit after 3s
setTimeout(() => {
console.log('[Server] Force exiting');
process.exit(1);
}, 3000);
}
process.on('SIGINT', () => shutdown('SIGINT'));
process.on('SIGTERM', () => shutdown('SIGTERM'));
+127
View File
@@ -0,0 +1,127 @@
import { Game } from '../game/Game.js';
import { GAME_CONFIG } from '../config.js';
function generateRoomCode(): string {
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
let code = '';
for (let i = 0; i < 4; i++) {
code += chars[Math.floor(Math.random() * chars.length)];
}
return code;
}
function generatePlayerId(): string {
const chars = 'abcdefghijklmnopqrstuvwxyz0123456789';
let id = '';
for (let i = 0; i < 8; i++) {
id += chars[Math.floor(Math.random() * chars.length)];
}
return id;
}
export class LobbyManager {
private rooms: Map<string, Game> = new Map();
private playerRooms: Map<string, string> = new Map(); // playerId -> roomCode
private hostPlayers: Map<string, string> = new Map(); // roomCode -> hostPlayerId
createRoom(): { roomCode: string; game: Game } {
let code: string;
do {
code = generateRoomCode();
} while (this.rooms.has(code));
const game = new Game(code);
this.rooms.set(code, game);
return { roomCode: code, game };
}
joinRoom(roomCode: string, playerName: string): { ok: boolean; playerId?: string; error?: string; game?: Game; isHost?: boolean } {
const game = this.rooms.get(roomCode.toUpperCase());
if (!game) return { ok: false, error: 'Room not found' };
if (game.phase !== 'LOBBY') return { ok: false, error: 'Game already started' };
const aliveCount = Array.from(game.players.values()).filter((p) => p.alive).length;
if (aliveCount >= GAME_CONFIG.MAX_PLAYERS) return { ok: false, error: 'Room is full' };
const playerId = generatePlayerId();
game.addPlayer(playerId, playerName);
this.playerRooms.set(playerId, roomCode.toUpperCase());
// First player becomes host
const uc = roomCode.toUpperCase();
let isHost = false;
if (!this.hostPlayers.has(uc)) {
this.hostPlayers.set(uc, playerId);
game.hostPlayerId = playerId;
isHost = true;
}
return { ok: true, playerId, game, isHost };
}
getGame(roomCode: string): Game | undefined {
return this.rooms.get(roomCode.toUpperCase());
}
getGameForPlayer(playerId: string): Game | undefined {
const roomCode = this.playerRooms.get(playerId);
if (!roomCode) return undefined;
return this.rooms.get(roomCode);
}
getHostPlayerId(roomCode: string): string | undefined {
return this.hostPlayers.get(roomCode.toUpperCase());
}
private reassignHost(roomCode: string, game: Game): void {
const uc = roomCode.toUpperCase();
this.hostPlayers.delete(uc);
// First alive player becomes new host
for (const player of game.players.values()) {
if (player.alive) {
this.hostPlayers.set(uc, player.id);
game.hostPlayerId = player.id;
return;
}
}
}
removePlayer(playerId: string): Game | undefined {
const roomCode = this.playerRooms.get(playerId);
if (!roomCode) return undefined;
const game = this.rooms.get(roomCode);
if (!game) return undefined;
const wasHost = this.hostPlayers.get(roomCode) === playerId;
game.removePlayer(playerId);
this.playerRooms.delete(playerId);
if (game.alivePlayers === 0) {
game.destroy();
this.rooms.delete(roomCode);
this.hostPlayers.delete(roomCode);
} else if (wasHost) {
this.reassignHost(roomCode, game);
}
return game;
}
removeRoom(roomCode: string): void {
const game = this.rooms.get(roomCode.toUpperCase());
if (game) {
game.destroy();
this.rooms.delete(roomCode.toUpperCase());
}
}
destroyAll(): void {
for (const [code, game] of this.rooms) {
game.destroy();
}
this.rooms.clear();
this.playerRooms.clear();
this.hostPlayers.clear();
}
}
+276
View File
@@ -0,0 +1,276 @@
import { Server as HttpServer } from 'http';
import { Server, Socket } from 'socket.io';
import { LobbyManager } from '../lobby/LobbyManager.js';
import { Game } from '../game/Game.js';
import { GamePlayer } from '../game/Game.js';
import {
ServerToTvEvents,
TvToServerEvents,
ServerToMobileEvents,
MobileToServerEvents,
ExecutionResult,
GameGridState,
PlayerView,
Ship,
} from '@spacerace/shared';
export class WsServer {
private io: Server;
private lobby: LobbyManager;
constructor(httpServer: HttpServer) {
this.lobby = new LobbyManager();
this.io = new Server(httpServer, {
cors: {
origin: '*',
methods: ['GET', 'POST'],
},
pingTimeout: 5000,
pingInterval: 10000,
});
this.io.on('connection', (socket: Socket) => {
this.handleConnection(socket);
});
console.log('[WS] Server initialized');
}
destroy(): void {
console.log('[WS] Shutting down...');
this.io.close();
// Clear all game timers by destroying rooms
this.lobby.destroyAll();
}
private handleConnection(socket: Socket): void {
console.log(`[WS] New connection: ${socket.id}`);
socket.on('disconnect', () => {
this.handleDisconnect(socket);
});
// ── TV Events ──
socket.on('tv:createRoom', (data: {}, callback: (res: { roomCode: string }) => void) => {
const { roomCode, game } = this.lobby.createRoom();
socket.join(`room:${roomCode}`);
socket.data.roomCode = roomCode;
socket.data.isTv = true;
this.bindGameEvents(game, roomCode);
callback({ roomCode });
console.log(`[WS] Room created: ${roomCode}`);
});
socket.on('tv:startGame', (data: { roomCode: string }, callback: (res: { ok: boolean }) => void) => {
const game = this.lobby.getGame(data.roomCode);
if (!game) {
callback({ ok: false });
return;
}
const ok = game.startGame();
callback({ ok });
if (ok) {
console.log(`[WS] Game started in room ${data.roomCode}`);
}
});
// ── Mobile Events ──
socket.on('mobile:joinRoom', (
data: { roomCode: string; playerName: string },
callback: (res: { ok: boolean; playerId?: string; error?: string }) => void
) => {
const result = this.lobby.joinRoom(data.roomCode, data.playerName);
if (!result.ok || !result.playerId || !result.game) {
callback({ ok: false, error: result.error });
return;
}
socket.join(`room:${result.game.roomCode}`);
socket.data.roomCode = result.game.roomCode;
socket.data.playerId = result.playerId;
socket.data.isTv = false;
// Notify TV
this.io.to(`room:${result.game.roomCode}`).emit('playerJoined', {
playerId: result.playerId,
name: data.playerName,
});
// Send back player info
callback({ ok: true, playerId: result.playerId });
// Send current player list
const players = Array.from(result.game.players.values())
.filter((p) => p.alive)
.map((p) => ({ id: p.id, name: p.name }));
socket.emit('roomJoined', {
playerId: result.playerId,
roomCode: result.game.roomCode,
players,
isHost: result.isHost ?? false,
});
console.log(`[WS] ${data.playerName} joined room ${result.game.roomCode}`);
});
socket.on('mobile:submitPlan', (
data: { actions: import('@spacerace/shared').Action[] },
callback: (res: { ok: boolean; error?: string }) => void
) => {
const playerId = socket.data.playerId;
const roomCode = socket.data.roomCode;
if (!playerId || !roomCode) {
callback({ ok: false, error: 'Not in a room' });
return;
}
const game = this.lobby.getGame(roomCode);
if (!game) {
callback({ ok: false, error: 'Game not found' });
return;
}
const result = game.submitPlan(playerId, data.actions);
callback(result);
});
socket.on('mobile:setReady', (
data: {},
callback: (res: { ok: boolean }) => void
) => {
callback({ ok: true });
});
// Mobile host can start game
socket.on('mobile:startGame', (
data: { roomCode: string },
callback: (res: { ok: boolean; error?: string }) => void
) => {
const playerId = socket.data.playerId;
const roomCode = socket.data.roomCode || data.roomCode;
if (!roomCode) {
callback({ ok: false, error: 'No room code' });
return;
}
const game = this.lobby.getGame(roomCode);
if (!game) {
callback({ ok: false, error: 'Game not found' });
return;
}
if (game.phase !== 'LOBBY') {
callback({ ok: false, error: 'Game already started' });
return;
}
// Only host can start
const hostId = this.lobby.getHostPlayerId(roomCode);
if (hostId && playerId && hostId !== playerId) {
callback({ ok: false, error: 'Only the host can start the game' });
return;
}
const ok = game.startGame();
callback({ ok: ok ? true : false, error: ok ? undefined : `Need at least ${2} players` });
if (ok) {
console.log(`[WS] Game started by mobile host in room ${roomCode}`);
}
});
}
private handleDisconnect(socket: Socket): void {
console.log(`[WS] Disconnected: ${socket.id}`);
if (socket.data.isTv && socket.data.roomCode) {
// TV disconnected - remove room
this.io.to(`room:${socket.data.roomCode}`).emit('roomClosed', {});
this.lobby.removeRoom(socket.data.roomCode);
} else if (socket.data.playerId) {
const game = this.lobby.removePlayer(socket.data.playerId);
if (game) {
this.io.to(`room:${game.roomCode}`).emit('playerLeft', {
playerId: socket.data.playerId,
});
// Notify about new host
const newHost = this.lobby.getHostPlayerId(game.roomCode);
if (newHost) {
this.io.to(`room:${game.roomCode}`).emit('hostChanged', {
hostPlayerId: newHost,
});
}
}
}
}
private bindGameEvents(game: Game, roomCode: string): void {
game.onPhaseChange = (phase) => {
if (phase === 'PLANNING') {
const gridState = game.getGridState();
const ships = game.getShipsState();
// First planning phase = game just started, notify TV to switch scenes
if (game.round === 1) {
const players = Array.from(game.players.values())
.filter((p) => p.alive)
.map((p) => ({ id: p.id, name: p.name }));
this.io.to(`room:${roomCode}`).emit('gameStarting', {
players,
round: game.round,
grid: gridState,
ships,
});
}
this.io.to(`room:${roomCode}`).emit('planningStarted', {
round: game.round,
timer: 45,
grid: gridState,
ships,
});
// Send individual planning requests to each mobile client
for (const [playerId, player] of game.players) {
if (!player.alive) continue;
const playerView = game.getPlayerState(playerId);
if (!playerView) continue;
// Find the mobile socket for this player
const sockets = this.io.sockets.adapter.rooms.get(`room:${roomCode}`);
if (!sockets) continue;
for (const socketId of sockets) {
const sock = this.io.sockets.sockets.get(socketId);
if (sock && sock.data.playerId === playerId) {
sock.emit('planningRequest', {
round: game.round,
playerView,
grid: gridState,
ships,
timer: 45,
});
break;
}
}
}
}
};
game.onExecutionTick = (result: ExecutionResult) => {
this.io.to(`room:${roomCode}`).emit('executionTick', result);
};
game.onGameOver = (winner) => {
const ships = game.getShipsState();
this.io.to(`room:${roomCode}`).emit('gameOver', {
winnerId: winner.id,
winnerName: winner.name,
ships,
});
};
}
}
+15
View File
@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"outDir": "./dist",
"rootDir": "./src",
"resolveJsonModule": true
},
"include": ["src"]
}