Proof-of-concept web strategy game. This repository is a Yarn 4 monorepo: the HTTP API lives in apps/server (@eoneom/server), the React client in apps/web (@eoneom/web), and the shared TypeScript client in packages/api-client.
- Node.js ≥ 22.12 (pg-boss requirement; LTS recommended)
- Yarn 4 — the repo pins Yarn via Corepack (see root
package.jsonpackageManagerfield) - Docker — for local Postgres (optional if you already run Postgres on
localhost:5432)
Enable Corepack once so the correct Yarn version is used:
corepack enableFrom the repository root:
yarn installThe server connects to Postgres via DATABASE_URL (default postgres://eoneom:eoneom@localhost:5432/eoneom).
From the repository root:
docker compose -f containers/docker-compose.yml up -d
yarn workspace @eoneom/server db:migrateData is stored under containers/postgres-data. To stop:
docker compose -f containers/docker-compose.yml down(Legacy Docker Compose v1 users can run the same file with docker-compose instead of docker compose.)
Root package.json exposes convenience scripts that delegate to the @eoneom/server workspace.
-
Build (TypeScript compile and dist layout):
yarn server:build
-
Start (runs compiled output with pretty logs):
yarn server:start
The API listens on port 3000. Endpoints are wired in apps/server/src/web/router.ts.
Copy apps/server/.env.example to apps/server/.env and edit there. The server loads that file on startup (before other modules read configuration).
| Variable | Description |
|---|---|
GAME_TIME_SCALE |
Optional speed multiplier (1 = default). For example 2 makes the game twice as fast: wait times (recruitment, building upgrades, technology research, troop movement) are shortened, and production earnings (BuildingService rates used for gathering and warehouse timers) are multiplied by the same factor. Invalid, empty, or non-positive values fall back to 1. |
DATABASE_URL |
Postgres connection string (default postgres://eoneom:eoneom@localhost:5432/eoneom). |
HTTP_PORT |
API listen port (default 3000). |
Example from the repository root without a .env file:
GAME_TIME_SCALE=2 yarn server:startyarn server:testThe same commands can be run explicitly against the workspace:
yarn workspace @eoneom/server build
yarn workspace @eoneom/server start
yarn workspace @eoneom/server testAdditional scripts (lint, watch build, coverage) are defined only on apps/server/package.json — use yarn workspace @eoneom/server <script> for those.
Root package.json exposes convenience scripts that delegate to the @eoneom/web workspace.
Start the development server (hot-reload, no automatic browser open):
yarn web:startThe React app listens on port 3001 and expects the API on port 3000.
Build a production bundle:
yarn web:buildyarn web:testyarn workspace @eoneom/web start
yarn workspace @eoneom/web build
yarn workspace @eoneom/web test:ciAdditional scripts (coverage, eject) are defined only on apps/web/package.json — use yarn workspace @eoneom/web <script> for those.
| Script | Purpose |
|---|---|
yarn client:build |
Build @eoneom/api-client (shared package) |
Typical full-stack local setup: Postgres running and migrated, yarn server:start in one terminal, yarn web:start in another (API on 3000, UI on 3001).
With the server running, send HTTP commands and queries to port 3000. See apps/server/src/web/router.ts for available routes.
Implements the application’s outbound ports: database, logging, locking, and the job queue.
Postgres (Kysely) under apps/server/src/adapter/database/:
- Infra:
client.ts,types.ts,migrate.ts,migrations/ - Domain repositories under
repository/(auth.ts,city.ts, …) extend the generic Kysely repository - Wired through
Factory.getRepository()→PostgresRepository
pg-boss under apps/server/src/adapter/job-queue/, using the same DATABASE_URL and a dedicated Postgres schema pgboss (created on boss.start(), not via Kysely migrations).
- Wired through
Factory.getJobQueue(); started inapps/server/src/index.tsafter the repository connects - Workers are registered from
apps/server/src/app/job/register.ts
Building upgrades and technology research finish via delayed jobs:
upgradeBuilding/researchTechnologydebit resources and enqueue a delayed job (building.upgrade.finishwithsingletonKey=city_id, ortechnology.research.finishwithsingletonKey=player_id;startAfter= finish time)- The worker runs
finishBuildingUpgrade(level bump,building:upgrade-finished) orfinishTechnologyResearch(level bump,technology:research-finished) - Cancel cancels the pending job (buildings also refund resources; technology does not)
- In-progress state lives in the queue (no timer columns on the building/technology rows). List/get still expose
upgrade_at/research_at(and started-at) for the UI by reading the pending job
City resource gathering runs on a self-rescheduling city.resources.gather job (singletonKey = global, every 5 seconds). On each tick the worker gathers resources for all cities, then schedules the next run. The loop is ensured on server boot. City GET still applies a display-only virtual gather without writing the DB; the UI refreshes via city:resources-gathered WebSocket events.
Features split into commands and queries.
Commands brings modification to the API.
Interfaces for repositories, logger, and lock implementations.
Reads via application services or repositories.
Coordinates multiple commands when a use case spans several commands.
Higher-level use cases for player-facing behavior.
Domain logic and mostly pure functions. Each module is organized as:
constant,value,entity,error,service,typeas needed for that bounded context
Small helpers and types that avoid heavy port indirection.
apps/server/src/app/event-bus.ts exposes AppEventBus, a typed EventEmitter singleton (accessed via Factory.getEventBus()). Commands and sagas emit domain events after persisting their side-effects; the Web layer subscribes to those events to push real-time updates to connected clients.
Current events (apps/server/src/core/events.ts):
| Event | Emitted by | Payload |
|---|---|---|
city:resources-gathered |
gather command (via 5s city.resources.gather job) |
city_id, player_id |
building:upgrade-finished |
finish-upgrade command (via pg-boss worker) |
city_id, player_id |
technology:research-finished |
finish-research command (via pg-boss worker) |
player_id |
troop:movement-finished |
finish/movement saga |
player_id |
outpost:created |
finish/movement saga |
player_id |
outpost:deleted |
troop/movement/create command |
player_id, outpost_id |
http boots Express, middleware, and the router. Handlers map routes to command/query behavior.
ws.ts opens a WebSocket server on the same port. After a player authenticates via the token query parameter, their connection is stored in memory. The server subscribes to all AppEventBus events and forwards the relevant payload to the matching player's socket. The React client (apps/web/src/helpers/websocket.ts) connects on login and invalidates React Query caches through per-module WS listeners (e.g. registerBuildingWsListeners) when messages arrive.