24 KiB
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
- Architecture
- Prerequisites
- Deploy on a new server
- Environment variables
- Runtime config (
game.settings.json) - Local development (without Docker)
- UI layout
- Game mechanics
- WebSocket real-time layer
- Deterministic planet generation
- Economy engine
- Military system
- API reference
- Database schema
- Docker image notes
- 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) / 10_000)extra actions.
Reveal flow
POST /api/cell/reveal:
- Checks the seed is current (
410if stale). - If already visible for this team → free re-view, quota untouched, returns current cell data.
- If new → deduct 1 user action (
429if exhausted), markteam_cell_visibility, insert intogrid_cells(deterministically computed from seed+coords), broadcastcell-updatedto 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-updatedto your team, and to the opposing team only if they had already revealed that cell. - A
team-quota-updatedbroadcast 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:
- The team with the highest economic score for the expiring epoch receives a victory point.
grid_cellsand all per-epoch tables (team_cell_visibility, cooldowns, econ scores, element bonus, military deductions, attack log) are truncated or cleaned.- Both team and user quotas reset to server defaults.
- A
seed-changedWebSocket 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:
- Calls
ensureSeedEpoch()— detects and handles epoch rollovers. - Queries all
grid_cellsrows for the current seed. - Computes income per second for each team: iterates owned planets, sums
(resourceShare% / 100) × resourceWorth × populationFactor. - Population factor applies a small-population efficiency boost:
(billions / 10) × boostwhere boost is10× (< 10 B),5× (< 100 B),2.5× (< 1 000 B),1× (≥ 1 000 B). - Adds
income × 5credits to each team'steam_econ_scores. - Overwrites
team_element_bonuswith the current bonus computed from production percentages across all owned planets. - 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 asnapshotmessage 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-updatedto all clients andcell-updatedto 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 vianpm 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).
- Stage 1 (
NODE_ENV=productionis baked into the image.- Built-in health check: calls
server/healthcheck.jsvia the Node binary every 15 s (GET /api/config, exits 0 on HTTP 200). Nowget/curlneeded — 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