# 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](#architecture) 2. [Prerequisites](#prerequisites) 3. [Deploy on a new server](#deploy-on-a-new-server) 4. [Environment variables](#environment-variables) 5. [Runtime config (`game.settings.json`)](#runtime-config-gamesettingsjson) 6. [Local development (without Docker)](#local-development-without-docker) 7. [UI layout](#ui-layout) 8. [Game mechanics](#game-mechanics) 9. [WebSocket real-time layer](#websocket-real-time-layer) 10. [Deterministic planet generation](#deterministic-planet-generation) 11. [Economy engine](#economy-engine) 12. [Military system](#military-system) 13. [API reference](#api-reference) 14. [Database schema](#database-schema) 15. [Docker image notes](#docker-image-notes) 16. [Database persistence](#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 ```bash git clone star-wars-wild-space cd star-wars-wild-space ``` ### 2 — Create the environment file ```bash cp .env.example .env ``` Open `.env` and set every value — see [Environment variables](#environment-variables) for details. At a minimum you **must** change: - `JWT_SECRET` — generate a strong random string: ```bash node -e "console.log(require('crypto').randomBytes(48).toString('hex'))" ``` Alternatives for Linux-based distros: ```bash 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](#runtime-config-gamesettingsjson). ### 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: ```bash mkdir -p data/postgres data/postgres_users ``` ### 5 — Build and start ```bash 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 ```bash # 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://: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 ```bash 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-` 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: ```bash 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-` 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: "" } → 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: `Humains` → `humans`, `Presque'humains` → `near`, `Aliens` → `aliens`. 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` | 2–32 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: ```bash docker compose down rm -rf data/postgres data/postgres_users mkdir -p data/postgres data/postgres_users docker compose up -d ```