Your first 30 minutes
Requirements
-
Node.js compatible with
server/package.json(currently>=24.13.0). npm.- Docker with Compose (optional).
-
A Godot client compatible with the
my_roomroom protocol.
Run with Docker
cp .env.example .env
docker compose up --build
Change the PostgreSQL and RabbitMQ passwords before starting. Public
HTTP and WebSocket traffic passes through Nginx on
NGINX_PORT.
Run only the server
cd server
npm ci
npm run start
The process requires reachable DATABASE_URL,
REDIS_URL, and RABBITMQ_URL services.
Initialization deliberately fails when these dependencies are
unavailable.
Essential commands
npm test
npx tsc --noEmit -p tsconfig.json
npm run map:compile
npm run loadtest
Run each command in the directory containing its corresponding
package script and TypeScript configuration.
npm run build creates ESM output in
server/build. Local imports retain their
.js extension even when the source file ends in
.ts, as required by NodeNext.
How to read the code
The shortest path from application setup to game behavior:
-
src/app/GameApplication.ts— what gets composed. -
src/app/GameRuntime.ts— how the world is constructed. -
src/transport/colyseus/createConfiguredGameRoom.ts— how dependencies reach a room. src/rooms/GameRoom.ts— player lifecycle.-
src/core/RoomRuntimeBuilder.ts— registration order of handlers and systems. -
An appropriate directory under
src/features/*— domain behavior. -
src/shared/messagesandsrc/schema— the client contract.
Implementation conventions
- Use classes and constructor injection.
- Define a small interface or port for a higher-layer dependency.
- Use
import typefor type-only imports. - Do not read files or connect to a service during module import.
- Do not import concrete data sources directly into a feature.
- Validate incoming data with Zod at system boundaries.
- Never log secrets, complete connection strings, or user payloads.
-
Preserve ESM and
.jsextensions in local imports. - Cover every authoritative behavior with infrastructure-free tests.
Add a gameplay system
Example: energy regeneration.
-
Create
features/energy/EnergyRegenerationSystem.ts. - Implement
ISystem. - Pass configuration and ports through the constructor.
-
If the feature composes multiple elements, create an
EnergyFeatureimplementingIGameFeature. -
Register the feature in
RoomRuntimeBuilder.registerFeatures(). - Add a unit test using a plain
State.
export class EnergyRegenerationSystem implements ISystem {
public constructor(private readonly pointsPerSecond: number) {}
public update(state: State, delta: number): void {
// Domain logic only: no process.env, Room, or data source.
}
}
Add a realtime message
-
Add the message name to
shared/messages/MessageTypes.ts. - Add a strict Zod schema to
ClientMessages.ts. - Create a handler implementing
IRoomHandler. - Keep the handler focused: find the player, apply rate limits, validate the data, and store intent or call an application service.
- Register the handler in
RoomRuntimeBuilder. -
If the server responds, add the response type in
ServerMessages.ts. - Add a protocol test before changing the Godot client.
Add a domain module
A module groups the use cases and dependencies for an area of the application. The following inventory example is an extension pattern, not a claim that inventory already exists:
export class InventoryModule {
public readonly service: InventoryService;
public constructor(
sql: SqlQueryExecutor,
events: EventPublisher,
logger: AppLogger,
) {
const repository = new PostgresInventoryRepository(sql);
this.service = new InventoryService(repository, events, logger);
}
}
Then GameApplication creates the module using ports
exposed by InfrastructureModule and passes only the
required service to transport.
Add PostgreSQL queries
Keep the data source generic. Put SQL in a feature-oriented class:
interface PlayerRow extends SqlRow {
id: string;
}
export class PlayerQueries {
public constructor(private readonly sql: SqlQueryExecutor) {}
public async findById(id: string): Promise<PlayerRow | undefined> {
const result = await this.sql.query<PlayerRow>(
"SELECT id FROM players WHERE id = $1",
[id],
{ operation: "player-find-by-id" },
);
return result.rows[0];
}
}
For multiple writes, use
PostgresDataSource.withTransaction() through a
dedicated transaction port.
- Always use parameterized SQL.
-
Keep the
operationlabel constant; never include user data. - Do not automatically retry non-idempotent writes.
-
Do not return
pg.PoolClientbeyond the transaction boundary.
Add Redis data
Create a command class under
data-access/<feature> and inject
RedisCommandExecutor. Do not share a raw application
Redis client with Colyseus Presence/Driver.
If an operation combines multiple commands and must be atomic, use a
Lua script, following the pattern in
RedisRateLimitStore.
Publish a RabbitMQ event
Inject EventPublisher into a module or service:
await this.events.publish(
"player.level_changed",
{ playerId, previousLevel, currentLevel },
{ version: 1 },
);
Event types consist of alphanumeric segments separated by periods. The payload must be JSON-serializable and remain within the size limit. Do not include secrets or class instances with methods.
Add a RabbitMQ consumer
- Add a dedicated event-handler port.
-
Create
RabbitConsumeralongside the RabbitMQ adapters. - Do not share a publisher queue across independent projections; each consumer group should have its own durable queue and binding.
- Configure prefetch.
- ACK only after the use case succeeds.
- Distinguish retryable from permanent failures.
- Add a dead-letter exchange and queue.
- Implement idempotency by
eventId. -
Attach consumer initialization and shutdown to
RabbitModule.
Add an HTTP endpoint
- Create a router under
transport/http. - Inject an application service, not a data source.
- Attach the router in
HttpModule.configure(). - Define a separate rate-limit policy if the default one is unsuitable.
- Return stable error codes and add middleware/router tests.
Leaderboard, profile, and authentication should be implemented as
separate domain modules—not as methods of GameRoom.
Add a health check
Implement the shared HealthCheck contract:
export class DependencyHealthCheck implements HealthCheck {
public constructor(private readonly dependency: DependencyPort) {}
public async execute(): Promise<boolean> {
return this.dependency.isReady;
}
}
HealthModule creates the concrete check and passes it
to a single HealthService. Checks should not propagate
exceptions or mutate data.
Add a map
- Extend
MapId. - Add its URL to
ServerConfig.mapFiles. -
Compile the Tiled source into the
ServerMapformat. -
Initialize the corresponding world in
GameRuntime. -
Select
mapIdon the server when creating the room. - Do not accept arbitrary file paths from the client.
Testing
Tests use Node’s built-in node:test,
assert, and tsx. They are grouped by
behavior rather than private methods.
Current regression areas
- Grid and free movement.
- Collision navigation and spawn.
- Growth, seeds, and player eating.
- Auth, nicknames, chat, and room payloads.
- HTTP and realtime rate limiting.
- Health aggregation.
- Zod configuration.
- PostgreSQL error classification.
- Redis factory and health.
- RabbitMQ event envelope, health, and reconnect delay.
Before merging
npx tsc --noEmit -p tsconfig.json
npm test
git diff --check
Definition of done
- Authoritative behavior remains on the server.
- Input and configuration are validated.
- Dependencies are explicit.
- New features have happy-path and safety-case tests.
- The Godot protocol has not changed accidentally.
- Logs contain no sensitive data.
- New resources implement initialization and shutdown.
- Health checks account for critical resources.
- Documentation and
.env.exampleare updated.
Common pitfalls
-
MOVEMENT_MODEandGRID_SIZEmust match between the map compiler and server. - System order changes the outcome of a tick.
-
lastProcessedSeq=0means the first input should useseq=1. - Mass growth may be rejected near a wall.
- If no valid respawn position exists, the eating action is canceled.
- Health intentionally bypasses the rate limiter. Do not copy that exception to data endpoints.
- The data-source singleton is configured once per process. Use ports and fakes in feature tests rather than the real singleton.
- Strict Zod schemas reject additional fields in movement messages.