Files
star-wars-wild-space/README.md
T
2026-04-03 14:25:19 +02:00

24 KiB
Raw Blame History

Star Wars Wild Space

A persistent, real-time team strategy game set in the Star Wars universe. Two teams (blue / red) compete to explore and capture planets in the Wild Space ring. Planet positions are deterministic from the server world seed — the same coordinates always yield the same planet, on every epoch. Cell stats are hidden until a team reveals them; visibility is per-team and private. All game state is persisted in PostgreSQL.

Desktop only — the galaxy map is not playable on phones or narrow screens (< 768 px wide).


Table of contents

  1. Architecture
  2. Prerequisites
  3. Deploy on a new server
  4. Environment variables
  5. Runtime config (game.settings.json)
  6. Local development (without Docker)
  7. UI layout
  8. Game mechanics
  9. WebSocket real-time layer
  10. Deterministic planet generation
  11. Economy engine
  12. Military system
  13. API reference
  14. Database schema
  15. Docker image notes
  16. Database persistence

Architecture

Component Technology
Web server / API Node.js 20 + Express
Real-time push WebSocket (ws library) — upgrade on the same HTTP port
Game database PostgreSQL 16 (star_wars_grid)
Users database PostgreSQL 16 (star_wars_users)
Auth JWT (7-day tokens, signed with JWT_SECRET, bcrypt password hashing with cost 12)
Config hot-reload File mtime polling (game.settings.json) — no restart needed
Economy engine Server-side tick loop (every 5 s), runs headlessly
Container runtime Docker / Docker Compose

The app runs as a single Node.js process that serves the static frontend (public/), the REST API (/api/*), and the WebSocket endpoint (/ws), all on the same port. Two separate PostgreSQL instances isolate game state from user accounts.

Browser ──HTTP──► Express (/api/*, static)
        ──WS───► WebSocket hub (/ws)
                      │
          ┌───────────┼───────────────┐
          ▼           ▼               ▼
       econTick   configPoll     route handlers
       (5 s)      (variable)    (reveal/capture/attack)
          │           │               │
          └───────────┴───────────────┘
                      │  broadcast()
                      ▼
              Connected clients

Prerequisites

Requirement Minimum version
Docker Engine 24+
Docker Compose plugin v2 (docker compose)
Git any recent

No Node.js or PostgreSQL installation is needed on the host — everything runs inside Docker.


Deploy on a new server

1 — Clone the repository

git clone <repo-url> star-wars-wild-space
cd star-wars-wild-space

2 — Create the environment file

cp .env.example .env

Open .env and set every value — see Environment variables for details. At a minimum you must change:

  • JWT_SECRET — generate a strong random string:

    node -e "console.log(require('crypto').randomBytes(48).toString('hex'))"
    

    Alternatives for Linux-based distros:

    openssl rand -hex 48
    # or from the entropy pool:
    head -c 48 /dev/urandom | xxd -p -c 48
    
  • POSTGRES_PASSWORD

  • POSTGRES_USERS_PASSWORD

3 — Review the game config

config/game.settings.json is mounted into the container and hot-reloaded at runtime. Adjust values before first boot if needed — see Runtime config.

4 — Create the data directories

Docker bind-mounts the PostgreSQL data directories from the host. Create them before the first run so that Docker does not create them as root:

mkdir -p data/postgres data/postgres_users

5 — Build and start

docker compose up --build -d

The app service waits for both databases to pass their health checks before starting (pg_isready polling every 3 s, up to 15 retries). On first boot the server automatically creates all required tables via idempotent CREATE TABLE IF NOT EXISTS and ALTER TABLE ... ADD COLUMN IF NOT EXISTS migrations.

6 — Verify the deployment

# Check all three containers are running
docker compose ps

# Tail logs
docker compose logs -f app

# Health check (should return JSON)
curl http://localhost:8080/api/config

Open http://<server-ip>:8080 in a browser.

7 — (Optional) Reverse proxy

To expose the app on port 80/443, put Nginx or Caddy in front and proxy to http://localhost:8080. WebSocket (/ws) proxying requires setting appropriate Upgrade and Connection headers. Set CORS_ORIGIN in .env to your public domain.

Stopping / restarting

docker compose down          # stop (data preserved)
docker compose down -v       # stop and remove named volumes (data lost)
docker compose restart app   # restart only the app container

Environment variables

Never commit secrets. All credentials live in .env (git-ignored). Copy from .env.example before the first run.

Variable Default Description
PORT 8080 HTTP port exposed by the app
JWT_SECRET (required) Secret used to sign JWT tokens — use a long random string in production
POSTGRES_USER game Game-DB username
POSTGRES_PASSWORD (required) Game-DB password
POSTGRES_DB star_wars_grid Game-DB name
POSTGRES_USERS_USER users Users-DB username
POSTGRES_USERS_PASSWORD (required) Users-DB password
POSTGRES_USERS_DB star_wars_users Users-DB name
CORS_ORIGIN * Access-Control-Allow-Origin header — set to your public domain in production
CONFIG_FILE_PATH config/game.settings.json Override path to the config file (set by Docker Compose)

Runtime config (game.settings.json)

Edit config/game.settings.json on the host (bind-mounted into the container at /app/config/game.settings.json). The server checks the file's mtime on a schedule and applies new values without a restart; the change propagates to all connected clients via a config-updated WebSocket broadcast within at most configReloadIntervalSeconds seconds.

Core fields

Field Default Meaning
dailyActionQuota 100 Per-user action budget, refilled on the schedule below. Each new cell reveal costs 1 action.
teamActionQuota 100 Shared team action pool, used for planet captures. Refilled on the same schedule.
actionsResetIntervalHours 24 How often both quotas reset; aligned to UTC boundaries. Must be a divisor of 24: 1, 2, 3, 4, 6, 8, 12, 24.
databaseWipeoutIntervalSeconds 604800 World period length in seconds (default 7 d). The world seed is swg-<slot> where slot = floor(UTC unix seconds / this value). When the slot advances, grid_cells is truncated and a new era begins. Minimum 60.
configReloadIntervalSeconds 30 How often the server polls the config file. Minimum 5.

Economy fields

Field Meaning
elementWorth Per-element bonus multipliers (common, food, petrol, goods, industry, medic, money, science). The sum of (productionShare% / 100) × worth across all owned planets gives the team's element bonus (%). This bonus scales the effective dailyActionQuota.
resourceWorth.common / resourceWorth.rare Credit-per-second weight per resource type for common and rare planet tiers. Drives income in the server-side economy tick.

Military fields

Field Meaning
militaryPower.humans / .near / .aliens Percentage of a planet's population (in billions) that contributes to the team's military power. E.g. a planet with 5 billion humans and humans: 10 contributes 0.5 billion military.

GET /api/config returns the current values of all fields above plus worldSeed, seedPeriodStartsAtUtc, seedPeriodEndsAtUtc, and (if called with a valid Bearer token + ?team=) actionsRemaining and teamActionsRemaining.


Local development (without Docker)

Requires two running PostgreSQL 16 instances, then:

cp .env.example .env   # fill in all values

npm install

# Set connection strings (PowerShell example)
$env:DATABASE_URL       = "postgres://game:game@localhost:5432/star_wars_grid"
$env:USERS_DATABASE_URL = "postgres://users:users@localhost:5433/star_wars_users"
$env:JWT_SECRET         = "dev_secret_change_me"

npm start

The server creates all tables automatically on first run. Ensure config/game.settings.json exists (included in the repository).

Ports (local)

Service Port
App (HTTP + WS) 8080 (or PORT env var)
Game Postgres 5432
Users Postgres 5433

UI layout

Pane Content
Left column Title, team tile & econ scores, player info, action quotas, world seed & period countdown, active players, military power, victory points, selection details
Right side Galaxy map — a perfect square anchored to the right edge of the viewport

Game mechanics

Teams

Users register as either blue or red. The team is locked to the account and embedded in the JWT so the server always trusts the token, never the client.

Per-team cell visibility

Each team has its own private visibility layer (team_cell_visibility). Revealing a cell marks it as visible only for your team: you see the planet data, the other team does not. Both teams can independently scout the same tile. Ownership (discovered_by) is entirely separate from visibility.

Action quotas

Two quota systems gate how fast teams can act:

Quota Scope Refills
dailyActionQuota Per user Every actionsResetIntervalHours UTC
teamActionQuota Shared per team Same schedule
  • Revealing a new (previously unseen by your team) cell costs 1 user action.
  • Re-clicking a cell your team has already revealed is free (no action consumed).
  • The team's element bonus boosts the effective user quota: floor(dailyActionQuota × (1 + elementBonus / 100)).
  • Military power can increase the team quota: teamActionQuota + floor(max(0, netMilitaryPower) / 1_000) extra actions.

Reveal flow

POST /api/cell/reveal:

  1. Checks the seed is current (410 if stale).
  2. If already visible for this team → free re-view, quota untouched, returns current cell data.
  3. If new → deduct 1 user action (429 if exhausted), mark team_cell_visibility, insert into grid_cells (deterministically computed from seed+coords), broadcast cell-updated to your team over WebSocket.

Planet capture

POST /api/cell/capture transfers ownership (discovered_by) of a planet to your team:

  • Cost in team actions: max(1, ceil(population_billions / 10)).
  • If the tile is currently enemy-controlled, cost is doubled.
  • A successful capture broadcasts cell-updated to your team, and to the opposing team only if they had already revealed that cell.
  • A team-quota-updated broadcast is sent to your own team after deduction.

World seed & wipe

The world seed is swg-<slot> where slot = floor(utcUnixSeconds / databaseWipeoutIntervalSeconds). When the slot advances:

  1. The team with the highest economic score for the expiring epoch receives a victory point.
  2. grid_cells and all per-epoch tables (team_cell_visibility, cooldowns, econ scores, element bonus, military deductions, attack log) are truncated or cleaned.
  3. Both team and user quotas reset to server defaults.
  4. A seed-changed WebSocket broadcast triggers all clients to resync.

User accounts, victory points table, and the db_metadata row survive all wipes.


WebSocket real-time layer

The WebSocket server is co-hosted with Express on the same HTTP port, upgraded at path /ws (ws://host/ws or wss://host/ws over TLS). There is no separate port or process.

Connection lifecycle

Client connects
  → Server sends:  { type: "welcome", authenticated: false, team: null, timestamp: … }

Client sends:     { type: "auth", token: "<JWT>" }
  → Server sends: { type: "auth-state", authenticated: true|false, team: "blue"|"red"|null, timestamp: … }

(ongoing)
  ← Server sends periodic snapshots, cell updates, etc.

Heartbeat: server pings every 30 s; clients that don't pong are terminated and removed.

Server → client message types

Type Trigger Payload highlights
welcome On connect { authenticated: false, team: null }
auth-state After client sends auth { authenticated, team }
snapshot Every economy tick (5 s) { worldSeed, scores, elementBonus, militaryDeductions, activePlayers, playerNames, victoryPoints, incomePerSecond, militaryPowerGross }
cell-updated Cell revealed / captured / attacked { worldSeed, cell: { x, y, exploitable, hasPlanet, planet, discoveredBy, capturedBy } }targeted to team(s)
team-quota-updated After a capture { team, actionsRemaining }targeted to the acting team
military-deductions-updated After a military attack { worldSeed, deductions: { blue, red } }broadcast to all
config-updated Config file changed { worldSeed, config: { … } } — broadcast to all
seed-changed Epoch rolls over { worldSeed } — broadcast to all; clients resync from REST API

Client reconnection & fallback

The frontend (public/src/realtime.js) implements exponential back-off reconnection (1 s → 2 s → 4 s … capped at 10 s). While disconnected, it automatically falls back to HTTP polling every configReloadIntervalSeconds for config/scores and every 5 s for economy data. Polling stops the moment a WebSocket connection is re-established (onOpen callback).

The snapshot message carries the same data as calling several REST endpoints in parallel, making it the primary update path when connected.


Deterministic planet generation

Planet positions and stats are computed purely from the world seed and coordinates — they are never stored until a team reveals a cell. The same (seed, x, y) triple always produces the same planet.

RNG pipeline (public/src/rng.js, shared server-side)

seedString ──FNV-1a 32──► seedU32
(x, y, seedU32) ──hash2u32──► presenceU32   → planet present if presenceU32 / 2³² < 0.10  (10% chance)
(x, y, seedU32 ^ 0xa5a5a5a5) ──hash2u32──► statsU32 ──mulberry32──► per-planet PRNG stream
Function Algorithm Purpose
fnv1a32(str) FNV-1a 32-bit Converts the seed string to a stable uint32
hash2u32(a, b, seed) Integer avalanche mix Maps (x, y) to a deterministic uint32 for planet presence and stats seeding
mulberry32(seedU32) Mulberry32 PRNG Fast, high-quality stateless PRNG stream for planet attribute generation

The hash2u32 / mulberry32 functions are in public/src/rng.js and are imported directly by server/helpers/cell.js, ensuring client and server always agree on cell contents without any network round-trip.

Galaxy ring geometry

The exploitable zone is an annular ring centered on the 100×100 grid:

  • Outer radius: 50 cells (Ø100)
  • Inner radius: 30 cells (Ø60)
  • Planet density: ~10% of exploitable ring cells

Economy engine

server/econTick.js runs a setInterval every 5 seconds, independent of connected clients:

  1. Calls ensureSeedEpoch() — detects and handles epoch rollovers.
  2. Queries all grid_cells rows for the current seed.
  3. Computes income per second for each team: iterates owned planets, sums (resourceShare% / 100) × resourceWorth × populationFactor.
  4. Population factor applies a small-population efficiency boost: (billions / 10) × boost where boost is 10× (< 10 B), 5× (< 100 B), 2.5× (< 1 000 B), 1× (≥ 1 000 B).
  5. Adds income × 5 credits to each team's team_econ_scores.
  6. Overwrites team_element_bonus with the current bonus computed from production percentages across all owned planets.
  7. Builds a realtime snapshot (via buildRealtimeSnapshot) containing econ scores, element bonus, military state, active player counts & names, victory points, and income/s — then broadcasts it as a snapshot message to all connected WebSocket clients.

Military system

Military power represents a team's capacity for aggressive tile takeovers.

Power computation

For each team-owned planet:
militaryContribution = population.billions × militaryPower[species] / 100

Species → config key mapping: Humainshumans, Presque'humainsnear, Aliensaliens.

The gross military power is shown in the UI. The net military power accounts for deductions from attacks spent: netPower = grossPower teamMilitaryDeductions.

Military attack (POST /api/military/attack)

  • Target must be an enemy-owned tile with a planet.
  • Costs exactly 1.0 billion military power (added to team_military_deductions).
  • Transfers tile ownership to the attacker immediately.
  • Records the event in cell_attack_log.
  • Broadcasts military-deductions-updated to all clients and cell-updated to the relevant team(s).

Team action quota bonus

Net military power also feeds into capture capacity: every 10 000 units of net military above 0 grants 1 extra team action on quota refresh.


API reference

All endpoints are relative to the app root. All responses are JSON. Cache-Control is no-store on every route.

Public (no auth required)

Method Path Description
GET /api/config Game config + world seed + period timestamps. Accepts ?team=blue|red and a Bearer token to also return per-user and team actionsRemaining.
GET /api/grid/:seed All cells visible to the requesting team for seed (auth-aware). Returns 410 if seed ≠ current world seed. Requires a valid Bearer token; returns empty grid if token is absent or invalid.
GET /api/scores { blue: N, red: N } — captured tile counts for the current period.
GET /api/econ-scores { blue: N, red: N } — cumulative economic scores for the current period.
GET /api/element-bonus { blue: N, red: N } — element bonus percentages.
GET /api/victory-points Array of all awarded victory points across all eras.
GET /api/db-info { createdAt } — database creation timestamp.
GET /api/active-players { blue: N, red: N } — players active in the current epoch.
GET /api/active-players/names { blue: [...], red: [...] } — usernames of active players.
GET /api/team-quota?team=blue|red { team, actionsRemaining } — current shared team action pool.
GET /api/military-deductions { blue: N, red: N } — cumulative military power spent this epoch.
GET /api/cell/attacks?x=&y= { x, y, attackCount } — number of attacks on a specific cell this epoch.

Auth (no token required)

Method Path Body Response
POST /api/auth/register { username, password, team } { token, user }
POST /api/auth/login { username, password } { token, user }
GET /api/auth/player-counts { blue: N, red: N } — total registered players per team

Auth (Bearer token required)

Method Path Description
GET /api/auth/me Returns refreshed { token, user }.
POST /api/cell/reveal Body { seed, x, y }. Marks cell as visible for your team. Costs 1 user action if not yet seen. Returns { x, y, exploitable, hasPlanet, planet, discoveredBy, capturedBy }. 429 if quota exhausted; 410 if seed stale.
POST /api/cell/capture Body { seed, x, y }. Transfers planet ownership. Costs team actions based on population. 409 if already owned; 429 if quota exhausted; 410 if seed stale.
POST /api/military/attack Body { seed, x, y }. Attacks an enemy tile. Costs 1 billion military power. 409 if targeting own/neutral tile.

Registration field rules

Field Rules
username 232 characters
password Minimum 6 characters
team "blue" or "red"

Tokens are valid for 7 days. Call GET /api/auth/me to silently renew.

Error codes

HTTP error value Meaning
400 invalid_body / missing_fields Malformed request
401 unauthorized / invalid_token Missing or invalid JWT
404 cell_not_found Cell does not exist in the DB yet
409 already_owned / no_planet / cannot_attack_own_or_neutral_tile Business logic conflict
410 seed_expired Client is using a stale world seed — resync from /api/config
429 quota_exhausted / team_quota_exhausted Action budget depleted
500 database_error / config_error Server-side failure

Database schema

star_wars_grid (game DB)

Table Key columns Notes
grid_cells (world_seed, x, y) UNIQUE Cells inserted on first reveal. discovered_by is NULL (neutral), 'blue', or 'red'. captured_by stores the capturer's username.
team_cell_visibility (world_seed, team, x, y) PK Per-team visibility — independent of ownership. Truncated on epoch change.
team_econ_scores (world_seed, team) PK Cumulative credits per epoch.
team_element_bonus (world_seed, team) PK Overwritten every 5 s tick.
team_military_deductions (world_seed, team) PK Cumulative military power spent.
team_action_quota team PK Shared team action pool, persists across epochs.
cell_attack_log id PK, index on (world_seed, x, y) One row per military attack event.
victory_points id PK, index on team One row per awarded VP; survives all wipes.
db_metadata id PK Single row with DB creation timestamp.

star_wars_users (users DB)

Table Key columns Notes
users id PK, username UNIQUE team constrained to 'blue' or 'red'; password_hash is bcrypt cost-12.
user_action_quota user_id PK → users.id Per-user daily action budget with quota_reset_at timestamp.

Docker image notes

  • Build uses a two-stage Dockerfile:
    • Stage 1 (node:20-alpine): installs production dependencies via npm ci --omit=dev.
    • Stage 2 (gcr.io/distroless/nodejs20-debian12:nonroot): minimal runtime with no shell or package manager; runs as UID 65532 (non-root).
  • NODE_ENV=production is baked into the image.
  • Built-in health check: calls server/healthcheck.js via the Node binary every 15 s (GET /api/config, exits 0 on HTTP 200). No wget/curl needed — distroless has none.
  • Secrets are never baked into the image — always injected at runtime via .env / Compose environment.
  • config/ is a volume mount, not baked in, enabling hot-reload without rebuilding the image.

Database persistence

PostgreSQL data lives in bind-mount directories on the host:

Directory Database
./data/postgres Game DB (star_wars_grid)
./data/postgres_users Users DB (star_wars_users)

A world wipe only cleans epoch-scoped tables when the UTC period slot changes; it does not touch the host data directories. Only user accounts (users), victory_points, and db_metadata survive all wipes. Both user_action_quota and team_action_quota are truncated and reset to config defaults on wipe.

To fully reset the game state (including all user accounts), stop the stack and delete both data directories, then start again:

docker compose down
rm -rf data/postgres data/postgres_users
mkdir -p data/postgres data/postgres_users
docker compose up -d