Server Side
Quick Start
Section titled “Quick Start”Create a new game server project using the CLI:
# Using npmnpm create esengine-server my-game-server
# Using pnpmpnpm create esengine-server my-game-server
# Using yarnyarn create esengine-server my-game-serverGenerated project structure:
my-game-server/├── src/│ ├── shared/ # Shared protocol (client & server)│ │ ├── protocol.ts # Type definitions│ │ └── index.ts│ ├── server/ # Server code│ │ ├── main.ts # Entry point│ │ └── rooms/│ │ └── GameRoom.ts # Game room│ └── client/ # Client example│ └── index.ts├── package.json└── tsconfig.jsonStart the server:
# Development mode (hot reload)npm run dev
# Production modenpm run startcreateServer
Section titled “createServer”Create a game server instance:
import { createServer } from '@esengine/server'import { GameRoom } from './rooms/GameRoom.js'
const server = await createServer({ port: 3000, onConnect(conn) { console.log('Client connected:', conn.id) }, onDisconnect(conn) { console.log('Client disconnected:', conn.id) },})
// Register room typeserver.define('game', GameRoom)
// Start serverawait server.start()Configuration Options
Section titled “Configuration Options”| Property | Type | Default | Description |
|---|---|---|---|
port | number | 3000 | WebSocket port. Set to 0 to auto-assign a random available port |
tickRate | number | 20 | Global tick rate (Hz) |
duplicateJoinPolicy | 'auto-leave' | 'reject' | 'auto-leave' | Behavior when a player calls JoinRoom while already in a room. 'auto-leave' automatically leaves the current room first; 'reject' throws an error |
apiDir | string | 'src/api' | API handlers directory |
msgDir | string | 'src/msg' | Message handlers directory |
httpDir | string | 'src/http' | HTTP routes directory |
httpPrefix | string | '/api' | HTTP routes prefix |
cors | boolean | CorsOptions | - | CORS configuration |
onStart | (port) => void | - | Start callback |
onConnect | (conn) => void | - | Connection callback |
onDisconnect | (conn) => void | - | Disconnect callback |
GameServer
Section titled “GameServer”The object returned by createServer():
| Property / Method | Type | Description |
|---|---|---|
server.port | number (readonly) | Actual listening port. Useful when port: 0 is used for auto-assignment |
server.tick | number (readonly) | Current tick count |
server.connections | ReadonlyArray<ServerConnection> | All active connections |
server.define(name, RoomClass) | void | Register a room type |
server.start() | Promise<void> | Start the server |
server.stop() | Promise<void> | Stop the server |
server.broadcast(name, data) | void | Broadcast to all connections |
server.send(conn, name, data) | void | Send to a specific connection |
HTTP Routing
Section titled “HTTP Routing”Supports HTTP API sharing the same port with WebSocket, ideal for login, registration, and similar scenarios.
const server = await createServer({ port: 3000, httpDir: './src/http', // HTTP routes directory httpPrefix: '/api', // Route prefix cors: true,
// Or inline definition http: { '/health': (req, res) => res.json({ status: 'ok' }) }})For detailed documentation, see HTTP Routing
Room System
Section titled “Room System”Room is the base class for game rooms, managing players and game state.
Define a Room
Section titled “Define a Room”import { Room, Player, onMessage } from '@esengine/server'import type { MsgMove, MsgChat } from '../../shared/index.js'
interface PlayerData { name: string x: number y: number}
export class GameRoom extends Room<{ players: any[] }, PlayerData> { // Configuration maxPlayers = 8 tickRate = 20 autoDispose = true reconnectGracePeriod = 10000 // 10s reconnect window metadata = { gameMode: 'deathmatch' }
// Room state state = { players: [], }
// Lifecycle onCreate() { console.log(`Room ${this.id} created`) }
onJoin(player: Player<PlayerData>) { player.data.name = 'Player_' + player.id.slice(-4) player.data.x = Math.random() * 800 player.data.y = Math.random() * 600
this.broadcast('Joined', { playerId: player.id, playerName: player.data.name, }) }
onLeave(player: Player<PlayerData>) { this.broadcast('Left', { playerId: player.id }) }
onPlayerDisconnected(player: Player<PlayerData>) { this.broadcast('PlayerOffline', { playerId: player.id }) }
onPlayerReconnected(player: Player<PlayerData>) { this.broadcast('PlayerOnline', { playerId: player.id }) }
onTick(dt: number) { // State synchronization this.broadcast('Sync', { players: this.state.players }) }
onDispose() { console.log(`Room ${this.id} disposed`) }
// Message handlers @onMessage('Move') handleMove(data: MsgMove, player: Player<PlayerData>) { player.data.x = data.x player.data.y = data.y
// Broadcast to everyone except the sender this.broadcast('Move', { playerId: player.id, x: data.x, y: data.y, }, { exclude: player }) }
@onMessage('Chat') handleChat(data: MsgChat, player: Player<PlayerData>) { this.broadcast('Chat', { from: player.data.name, text: data.text, }) }}Room Configuration
Section titled “Room Configuration”| Property | Type | Default | Description |
|---|---|---|---|
maxPlayers | number | 16 | Maximum players |
tickRate | number | 0 | Tick rate (Hz), 0 = no auto tick |
autoDispose | boolean | true | Auto-dispose when empty |
reconnectGracePeriod | number | 0 | Reconnection grace period in milliseconds. Disconnected players can reconnect within this window. 0 = reconnection disabled |
metadata | Record<string, unknown> | {} | Room metadata, visible to clients via ListRooms and GetRoomInfo |
Room API
Section titled “Room API”class Room<TState, TPlayerData> { readonly id: string // Room ID readonly players: Player[] // All players readonly playerCount: number // Player count readonly isLocked: boolean // Lock status state: TState // Room state metadata: Record<string, unknown> // Room metadata
// Broadcast to all players broadcast<T>(type: string, data: T, options?: { exclude?: Player | Player[] }): void
// Broadcast to all except one (deprecated, use broadcast with exclude option) broadcastExcept<T>(except: Player, type: string, data: T): void
// Get player by ID getPlayer(id: string): Player | undefined
// Kick a player kick(player: Player, reason?: string): void
// Lock/unlock room lock(): void unlock(): void
// Dispose room dispose(): void}The broadcast method now supports an exclude option to skip specific players:
// Broadcast to allthis.broadcast('Chat', { text: 'hello' })
// Exclude one player (e.g. the sender)this.broadcast('Move', data, { exclude: player })
// Exclude multiple playersthis.broadcast('Event', data, { exclude: [player1, player2] })
broadcastExceptis deprecated. Usebroadcast(type, data, { exclude: player })instead.
Lifecycle Methods
Section titled “Lifecycle Methods”| Method | Trigger | Purpose |
|---|---|---|
onCreate(options?) | Room created | Initialize game state |
onJoin(player) | Player joins | Welcome message, assign position |
onLeave(player, reason?) | Player truly leaves (not just disconnected) | Cleanup player data |
onPlayerDisconnected(player) | Player disconnects (reconnect grace period active) | Notify others the player went offline |
onPlayerReconnected(player) | Player reconnects within grace period | Restore state, notify others |
onTick(dt) | Every frame | Game logic, state sync |
onDispose() | Before disposal | Save data, cleanup resources |
onPlayerDisconnected only fires when reconnectGracePeriod > 0. The player is not yet removed from the room and can reconnect within the grace period. If the player does not reconnect in time, onLeave is called with the reason 'reconnect_timeout'.
Player Class
Section titled “Player Class”Player represents a connected player in a room.
class Player<TData = Record<string, unknown>> { readonly id: string // Player ID readonly roomId: string // Room ID readonly sessionToken: string // Session token (used for reconnection) readonly connected: boolean // Whether the player is currently online data: TData // Custom data
// Send message to this player send<T>(type: string, data: T): void
// Send binary data to this player sendBinary(data: Uint8Array): void
// Leave room leave(reason?: string): void}sessionTokenis a unique token generated when the player joins. The client should store it and pass it toReconnectRoomif the connection is lost.connectedistruewhen the player is online andfalseduring the reconnection grace period after a disconnect.sendBinarysends raw binary data over a native WebSocket binary frame. If the underlying transport does not support binary frames, it falls back to base64-encoded JSON.
@onMessage Decorator
Section titled “@onMessage Decorator”Use decorators to simplify message handling:
import { Room, Player, onMessage } from '@esengine/server'
class GameRoom extends Room { @onMessage('Move') handleMove(data: { x: number; y: number }, player: Player) { // Handle movement }
@onMessage('Attack') handleAttack(data: { targetId: string }, player: Player) { // Handle attack }}Built-in APIs
Section titled “Built-in APIs”The server automatically registers several built-in APIs. Clients call them via client.call(name, data).
JoinRoom
Section titled “JoinRoom”Join or create a room. Returns roomId, playerId, and sessionToken.
// Join by room type (joins an available room or creates a new one)const result = await client.call('JoinRoom', { roomType: 'game', playerData: { name: 'Alice' }, // optional, passed to player.data options: { mapName: 'desert' }, // optional, passed to onCreate})// result: { roomId, playerId, sessionToken }
// Join by specific room IDconst result = await client.call('JoinRoom', { roomId: 'room_1', playerData: { name: 'Bob' },})The client should store sessionToken for reconnection.
LeaveRoom
Section titled “LeaveRoom”Leave the current room.
await client.call('LeaveRoom', {})// result: { success: true }ReconnectRoom
Section titled “ReconnectRoom”Reconnect to a room using a previously obtained session token.
const result = await client.call('ReconnectRoom', { sessionToken: savedSessionToken,})// result: { roomId, playerId, sessionToken }Only succeeds if the room still exists and the player is within the reconnection grace period.
ListRooms
Section titled “ListRooms”List available rooms, optionally filtered by type. Returns room metadata.
// List all roomsconst { rooms } = await client.call('ListRooms', {})
// Filter by typeconst { rooms } = await client.call('ListRooms', { type: 'game' })
// Each room entry:// { roomId, playerCount, maxPlayers, locked, metadata }GetRoomInfo
Section titled “GetRoomInfo”Get detailed information about a specific room.
const info = await client.call('GetRoomInfo', { roomId: 'room_1' })// info: { roomId, playerCount, maxPlayers, locked, metadata, players: [{ id }] }Authenticate
Section titled “Authenticate”Authenticate a connection (requires the withAuth mixin to be configured on the server).
const result = await client.call('Authenticate', { token: 'my-jwt-token' })// result: { success: true, user: { ... } }Reconnection
Section titled “Reconnection”To support reconnection, set reconnectGracePeriod on the room and store the sessionToken on the client.
Server Setup
Section titled “Server Setup”class GameRoom extends Room { reconnectGracePeriod = 15000 // 15 seconds
onPlayerDisconnected(player: Player) { // Player went offline but is not removed yet console.log(`${player.id} disconnected, waiting for reconnect...`) this.broadcast('PlayerOffline', { playerId: player.id }) }
onPlayerReconnected(player: Player) { // Player came back console.log(`${player.id} reconnected!`) this.broadcast('PlayerOnline', { playerId: player.id }) }
onLeave(player: Player, reason?: string) { // Truly gone (voluntary leave, kicked, or grace period expired) console.log(`${player.id} left: ${reason}`) }}Client Usage
Section titled “Client Usage”const client = await connect('ws://localhost:3000')
// Join room and save session tokenconst { roomId, sessionToken } = await client.call('JoinRoom', { roomType: 'game',})localStorage.setItem('sessionToken', sessionToken)
// ... connection lost, client reconnects ...
const newClient = await connect('ws://localhost:3000')const saved = localStorage.getItem('sessionToken')if (saved) { try { const result = await newClient.call('ReconnectRoom', { sessionToken: saved, }) console.log('Reconnected to room:', result.roomId) } catch (e) { // Grace period expired or room no longer exists console.log('Reconnection failed, joining new room') const result = await newClient.call('JoinRoom', { roomType: 'game' }) localStorage.setItem('sessionToken', result.sessionToken) }}Schema Validation
Section titled “Schema Validation”Use the built-in Schema validation system for runtime type validation:
Basic Usage
Section titled “Basic Usage”import { s, defineApiWithSchema } from '@esengine/server'
// Define schemaconst MoveSchema = s.object({ x: s.number(), y: s.number(), speed: s.number().optional()})
// Auto type inferencetype Move = s.infer<typeof MoveSchema> // { x: number; y: number; speed?: number }
// Use schema to define API (auto validation)export default defineApiWithSchema(MoveSchema, { handler(req, ctx) { // req is validated, type-safe console.log(req.x, req.y) }})Validator Types
Section titled “Validator Types”| Type | Example | Description |
|---|---|---|
s.string() | s.string().min(1).max(50) | String with length constraints |
s.number() | s.number().min(0).int() | Number with range and integer constraints |
s.boolean() | s.boolean() | Boolean |
s.literal() | s.literal('admin') | Literal type |
s.object() | s.object({ name: s.string() }) | Object |
s.array() | s.array(s.number()) | Array |
s.enum() | s.enum(['a', 'b'] as const) | Enum |
s.union() | s.union([s.string(), s.number()]) | Union type |
s.record() | s.record(s.any()) | Record type |
Modifiers
Section titled “Modifiers”// Optional fields.string().optional()
// Default values.number().default(0)
// Nullables.string().nullable()
// String validations.string().min(1).max(100).email().url().regex(/^[a-z]+$/)
// Number validations.number().min(0).max(100).int().positive()
// Array validations.array(s.string()).min(1).max(10).nonempty()
// Object validations.object({ ... }).strict() // No extra fields alloweds.object({ ... }).partial() // All fields optionals.object({ ... }).pick('name', 'age') // Pick fieldss.object({ ... }).omit('password') // Omit fieldsMessage Validation
Section titled “Message Validation”import { s, defineMsgWithSchema } from '@esengine/server'
const InputSchema = s.object({ keys: s.array(s.string()), timestamp: s.number()})
export default defineMsgWithSchema(InputSchema, { handler(msg, ctx) { // msg is validated console.log(msg.keys, msg.timestamp) }})Manual Validation
Section titled “Manual Validation”import { s, parse, safeParse, createGuard } from '@esengine/server'
const UserSchema = s.object({ name: s.string(), age: s.number().int().min(0)})
// Throws on errorconst user = parse(UserSchema, data)
// Returns result objectconst result = safeParse(UserSchema, data)if (result.success) { console.log(result.data)} else { console.error(result.error)}
// Type guardconst isUser = createGuard(UserSchema)if (isUser(data)) { // data is User type}Protocol Definition
Section titled “Protocol Definition”Define shared types in src/shared/protocol.ts:
// API request/responseexport interface JoinRoomReq { roomType: string playerName: string}
export interface JoinRoomRes { roomId: string playerId: string sessionToken: string}
// Game messagesexport interface MsgMove { x: number y: number}
export interface MsgChat { text: string}
// Server broadcastsexport interface BroadcastSync { players: PlayerState[]}
export interface PlayerState { id: string name: string x: number y: number}Client Connection
Section titled “Client Connection”import { connect } from '@esengine/rpc/client'
const client = await connect('ws://localhost:3000')
// Join room (now returns sessionToken)const { roomId, playerId, sessionToken } = await client.call('JoinRoom', { roomType: 'game', playerData: { name: 'Alice' },})
// Store sessionToken for reconnectionlocalStorage.setItem('sessionToken', sessionToken)
// List available roomsconst { rooms } = await client.call('ListRooms', { type: 'game' })console.log('Available rooms:', rooms)
// Listen for broadcastsclient.onMessage('Sync', (data) => { console.log('State:', data.players)})
client.onMessage('Joined', (data) => { console.log('Player joined:', data.playerName)})
// Send messageclient.send('RoomMessage', { type: 'Move', payload: { x: 100, y: 200 },})ECSRoom
Section titled “ECSRoom”ECSRoom is a room base class with ECS World support, suitable for games that need ECS architecture.
ECSRoom automatically initializes Core if it has not been created yet, so you do not need to call Core.create() manually.
Server Startup
Section titled “Server Startup”import { createServer } from '@esengine/server';import { GameRoom } from './rooms/GameRoom.js';
// No need to call Core.create() -- ECSRoom handles it automatically
// Global game loopsetInterval(() => Core.update(1/60), 16);
// Create serverconst server = await createServer({ port: 3000 });server.define('game', GameRoom);await server.start();Define ECSRoom
Section titled “Define ECSRoom”import { ECSRoom, Player } from '@esengine/server/ecs';import { Component, ECSComponent, sync } from '@esengine/ecs-framework';
// Define sync component@ECSComponent('Player')class PlayerComponent extends Component { @sync("string") name: string = ""; @sync("uint16") score: number = 0; @sync("float32") x: number = 0; @sync("float32") y: number = 0;}
// Define roomclass GameRoom extends ECSRoom { onCreate() { this.addSystem(new MovementSystem()); }
onJoin(player: Player) { const entity = this.createPlayerEntity(player.id); const comp = entity.addComponent(new PlayerComponent()); comp.name = player.id; }}ECSRoom API
Section titled “ECSRoom API”abstract class ECSRoom<TState, TPlayerData> extends Room<TState, TPlayerData> { protected readonly world: World; // ECS World protected readonly scene: Scene; // Main scene
// Scene management protected addSystem(system: EntitySystem): void; protected createEntity(name?: string): Entity; protected createPlayerEntity(playerId: string, name?: string): Entity; protected getPlayerEntity(playerId: string): Entity | undefined; protected destroyPlayerEntity(playerId: string): void;
// State sync protected sendFullState(player: Player): void; protected broadcastSpawn(entity: Entity, prefabType?: string): void; protected broadcastDelta(): void;}@sync Decorator
Section titled “@sync Decorator”Mark component fields that need network synchronization:
| Type | Description | Bytes |
|---|---|---|
"boolean" | Boolean | 1 |
"int8" / "uint8" | 8-bit integer | 1 |
"int16" / "uint16" | 16-bit integer | 2 |
"int32" / "uint32" | 32-bit integer | 4 |
"float32" | 32-bit float | 4 |
"float64" | 64-bit float | 8 |
"string" | String | Variable |
Best Practices
Section titled “Best Practices”-
Set Appropriate Tick Rate
- Turn-based games: 5-10 Hz
- Casual games: 10-20 Hz
- Action games: 20-60 Hz
-
Use Shared Protocol
- Define all types in
shared/directory - Import from here in both client and server
- Define all types in
-
State Validation
- Server should validate all client inputs
- Never trust client-sent data
-
Reconnection Handling
- Set
reconnectGracePeriodto enable reconnection - Use
onPlayerDisconnected/onPlayerReconnectedto manage player state during disconnects - Store
sessionTokenon the client forReconnectRoom
- Set
-
Room Lifecycle
- Use
autoDisposeto clean up empty rooms - Save important data in
onDispose - Use
metadatato expose room info to the lobby (ListRooms)
- Use