Chickens Must Die.Technical docs / Development

Authoritative Multiplayer Server Starter

Developer Guide

From first boot to your next gameplay system. Learn where code belongs, how to extend the multiplayer server, and how to keep its authoritative boundaries intact.

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_room room 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:

  1. src/app/GameApplication.ts — what gets composed.
  2. src/app/GameRuntime.ts — how the world is constructed.
  3. src/transport/colyseus/createConfiguredGameRoom.ts — how dependencies reach a room.
  4. src/rooms/GameRoom.ts — player lifecycle.
  5. src/core/RoomRuntimeBuilder.ts — registration order of handlers and systems.
  6. An appropriate directory under src/features/* — domain behavior.
  7. src/shared/messages and src/schema — the client contract.

Implementation conventions

  • Use classes and constructor injection.
  • Define a small interface or port for a higher-layer dependency.
  • Use import type for 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 .js extensions in local imports.
  • Cover every authoritative behavior with infrastructure-free tests.

Add a gameplay system

Example: energy regeneration.

  1. Create features/energy/EnergyRegenerationSystem.ts.
  2. Implement ISystem.
  3. Pass configuration and ports through the constructor.
  4. If the feature composes multiple elements, create an EnergyFeature implementing IGameFeature.
  5. Register the feature in RoomRuntimeBuilder.registerFeatures().
  6. 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.
  }
}
Tick order matters. A system before movement can affect the same tick. A system after movement sees the updated position.

Add a realtime message

  1. Add the message name to shared/messages/MessageTypes.ts.
  2. Add a strict Zod schema to ClientMessages.ts.
  3. Create a handler implementing IRoomHandler.
  4. Keep the handler focused: find the player, apply rate limits, validate the data, and store intent or call an application service.
  5. Register the handler in RoomRuntimeBuilder.
  6. If the server responds, add the response type in ServerMessages.ts.
  7. Add a protocol test before changing the Godot client.
Never trust client-side authority. Do not accept the client’s position, mass, collision result, or another player’s ID as an authoritative fact.

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 operation label constant; never include user data.
  • Do not automatically retry non-idempotent writes.
  • Do not return pg.PoolClient beyond the transaction boundary.
This snippet illustrates how to add player queries; it does not imply that a player persistence repository is already implemented.

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.

Cross-system consistency: For a “PostgreSQL write + event” operation, use a transactional outbox. Without one, a failure between commit and publish may leave the two effects inconsistent.

Add a RabbitMQ consumer

Not implemented yet. The consumer is a proposed extension, not an existing runtime feature.
  1. Add a dedicated event-handler port.
  2. Create RabbitConsumer alongside the RabbitMQ adapters.
  3. Do not share a publisher queue across independent projections; each consumer group should have its own durable queue and binding.
  4. Configure prefetch.
  5. ACK only after the use case succeeds.
  6. Distinguish retryable from permanent failures.
  7. Add a dead-letter exchange and queue.
  8. Implement idempotency by eventId.
  9. Attach consumer initialization and shutdown to RabbitModule.

Add an HTTP endpoint

  1. Create a router under transport/http.
  2. Inject an application service, not a data source.
  3. Attach the router in HttpModule.configure().
  4. Define a separate rate-limit policy if the default one is unsuitable.
  5. 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

  1. Extend MapId.
  2. Add its URL to ServerConfig.mapFiles.
  3. Compile the Tiled source into the ServerMap format.
  4. Initialize the corresponding world in GameRuntime.
  5. Select mapId on the server when creating the room.
  6. 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.example are updated.

Common pitfalls

  • MOVEMENT_MODE and GRID_SIZE must match between the map compiler and server.
  • System order changes the outcome of a tick.
  • lastProcessedSeq=0 means the first input should use seq=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.