Environment topology
Docker Compose runs the following services:
| Service | Role | Development access |
|---|---|---|
map-compiler |
Compiles Tiled JSON into the server map | One-shot container |
game-server |
Colyseus + Express on a single port | Internal port 2567 |
postgres |
Persistent application data | 127.0.0.1:5432 |
redis |
Rate limiting, Presence, and Driver | 127.0.0.1:6379 |
rabbitmq |
Integration events |
AMQP 5672, management UI 15672
|
nginx |
Public HTTP/WebSocket reverse proxy | Default port 8080 |
Nginx preserves proxy headers, supports WebSocket upgrades, disables buffering, and applies long timeouts for persistent game connections.
Configuration
Configuration is validated with Zod as the application is assembled. A missing required URL or invalid relationship between values stops startup with a clear error.
General & gameplay
| Variable | Default | Purpose |
|---|---|---|
NODE_ENV |
development |
development, test,
production
|
LOG_LEVEL |
info |
Pino log level |
MOVEMENT_MODE |
free |
free or grid |
GRID_SIZE |
16 |
16, 32, or 64 |
NGINX_PORT |
8080 |
Public proxy port |
PostgreSQL
| Variable | Default | Purpose |
|---|---|---|
DATABASE_URL |
Required | postgres: / postgresql: URL |
POSTGRES_POOL_MIN |
0 |
Minimum clients in the pool |
POSTGRES_POOL_MAX |
20 |
Maximum clients in the pool |
POSTGRES_IDLE_TIMEOUT_MS |
30000 |
Client idle timeout |
POSTGRES_CONNECTION_TIMEOUT_MS |
3000 |
Connection acquisition timeout |
POSTGRES_QUERY_TIMEOUT_MS |
10000 |
Client-side query timeout |
POSTGRES_STATEMENT_TIMEOUT_MS |
10000 |
Statement timeout |
POSTGRES_MAX_LIFETIME_SECONDS |
3600 |
Maximum client lifetime |
POSTGRES_STARTUP_MAX_ATTEMPTS |
10 |
Startup attempts |
POSTGRES_STARTUP_RETRY_DELAY_MS |
1000 |
Delay between startup attempts |
POSTGRES_RECONNECT_INITIAL_DELAY_MS |
1000 |
Initial reconnect backoff |
POSTGRES_RECONNECT_MAX_DELAY_MS |
30000 |
Maximum reconnect backoff |
POSTGRES_SSL |
false |
TLS with certificate verification |
PostgresDataSource is a process-wide singleton backed
by one pg pool. It exposes generic queries and
transactions. Operation logs include the operation name, duration,
row count, and pool statistics, but not query parameters.
A network failure triggers reconnection with exponential backoff.
Failed write queries are not retried automatically because their
outcome may be ambiguous. Domain queries belong in separate
data-access/<feature> classes, not in the
datasource.
Redis
| Variable | Default | Purpose |
|---|---|---|
REDIS_URL |
Required | redis: or rediss: URL |
REDIS_CONNECT_TIMEOUT_MS |
3000 |
Connection timeout |
REDIS_COMMAND_TIMEOUT_MS |
2000 |
Command timeout |
REDIS_MAX_RETRIES_PER_REQUEST |
2 |
Attempts for one command |
REDIS_STARTUP_MAX_ATTEMPTS |
10 |
Startup attempts |
REDIS_STARTUP_RETRY_DELAY_MS |
1000 |
Delay between startup attempts |
REDIS_RECONNECT_INITIAL_DELAY_MS |
500 |
Initial reconnect backoff |
REDIS_RECONNECT_MAX_DELAY_MS |
10000 |
Maximum reconnect backoff |
There are three logical connections:
-
Application
RedisDataSourcefor commands and rate limiting. - Colyseus Presence client.
- Colyseus Driver client.
This separation prevents application-level command load from
interfering with Colyseus communication. The application client uses
lazy connect, a ready check, bounded retries, an offline queue, and
reconnect logic. Presence and Driver are provided directly to
defineServer().
RabbitMQ
| Variable | Default | Purpose |
|---|---|---|
RABBITMQ_URL |
Required | amqp: / amqps: URL |
RABBITMQ_EXCHANGE |
game.events |
Durable topic exchange |
RABBITMQ_QUEUE |
game.events |
Durable queue bound with # |
RABBITMQ_CONNECT_TIMEOUT_MS |
5000 |
Connection timeout |
RABBITMQ_HEARTBEAT_SECONDS |
30 |
AMQP heartbeat |
RABBITMQ_STARTUP_MAX_ATTEMPTS |
10 |
Startup attempts |
RABBITMQ_STARTUP_RETRY_DELAY_MS |
1000 |
Delay between startup attempts |
RABBITMQ_RECONNECT_INITIAL_DELAY_MS |
1000 |
Initial reconnect backoff |
RABBITMQ_RECONNECT_MAX_DELAY_MS |
30000 |
Maximum reconnect backoff |
RABBITMQ_PUBLISH_CONFIRM_TIMEOUT_MS |
5000 |
Publisher-confirm timeout |
RABBITMQ_MAX_EVENT_BYTES |
262144 |
Maximum JSON event size |
RabbitModule exposes only
EventPublisher to higher layers. Each event contains a
UUID, a type used as its routing key, an ISO timestamp, a version,
and a JSON payload.
Messages are persistent and mandatory, and the broker confirms them through a confirm channel. An unroutable message is treated as an error.
The connection recovers automatically after a failure. Publishing may wait for reconnection, but has one shared timeout. If it times out, the publish outcome is marked as unknown; the library does not automatically resend the event.
Example:
await eventPublisher.publish("player.created", {
playerId: "player-1",
});
HTTP & health
Express is attached through the express callback in
defineServer().
GET /api/health
The endpoint checks these dependencies in parallel:
- Map readiness in
GameRuntime. SELECT 1in PostgreSQL.PINGin Redis.- RabbitMQ connection state.
Example of a successful response:
{
"status": "ok",
"ready": true,
"services": {
"gameRuntime": { "ready": true },
"postgres": { "ready": true },
"redis": { "ready": true },
"rabbitMq": { "ready": true }
}
}
HTTP 200 means everything is ready;
503 means at least one dependency is unavailable. The
health endpoint is excluded from request logging and rate limiting.
/monitor and Playground are available only outside
production.
Rate limiting
| Boundary | Default limit | Implementation |
|---|---|---|
| HTTP | 60 / 60 s | Atomic Redis counter per IP |
| Room auth | 10 / 60 s | Atomic Redis counter per IP |
| Movement | 90 / 1 s | In-memory sliding window per client |
| Chat | 5 / 10 s + 750 ms cooldown | In-memory sliding window per client |
The identity used in Redis keys is hashed with SHA-256. HTTP replies
include RateLimit-Limit and
RateLimit-Remaining; blocked requests also include
Retry-After.
If Redis fails, limits fail closed: HTTP returns
503, and room auth rejects the connection as
rate_limited. Movement and chat limits do not require a
Redis round trip.
RATE_LIMIT_TRUST_PROXY_HOPS must match the actual
number of trusted proxies. A wrong value may cause the limiter to
identify the wrong IP address.
Logging
AppLogger wraps Pino and creates child loggers with a
component field. Logs use JSON, ISO timestamps, and
include the service name and environment.
The HTTP middleware:
- Accepts or generates
x-request-id. - Returns the ID in the response header.
- Logs method, path, URL, IP, status, and duration.
-
Chooses
info,warn, orerrorbased on response status.
Map compiler
Source:
map-compiler/tiled-source/map.json.
Output:
server/src/shared/map/map.server.json, or the
/maps volume in Compose.
The compiler requires:
- An orthogonal, non-infinite Tiled map.
- Positive map dimensions and tile size.
- Exactly one object layer named
Collision. - No layer offset.
- Only unrotated rectangles.
- Objects fully contained within the map.
In free mode it preserves geometry. In
grid mode it snaps collision edges to the grid and
requires world dimensions to be divisible by GRID_SIZE.
The server validates the compiled result again with Zod, caps the collision count at 100,000, and memoizes asynchronous loading. The pipeline does not read a map when a module is imported.
Persistent data & migrations
PostgreSQL is connected, but the current gameplay does
not yet persist players or rooms. The
pg/migrations/init.sql file is an initial schema draft;
there is no versioned migration runner yet.
Known limitations before production
The infrastructure provides a working foundation, but the following items are not yet complete or need deployment-specific attention:
- No user/token authentication — current auth is limited to room nicknames.
- No PostgreSQL domain-level persistence.
- No RabbitMQ consumer or dead-letter topology.
- No transactional outbox between PostgreSQL and RabbitMQ.
- Realtime rate limiters are local to each instance.
- The player-eating algorithm has quadratic complexity in player count.
-
EventBusandShootHandlerare inactive. - Playground and monitor must remain disabled in production.
- Remove temporary configuration debug logs, and never log URLs containing passwords.