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 |
tickRate | number | 20 | Global tick rate (Hz) |
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 |
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
// 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 }) }
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 }
@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 | 10 | Maximum players |
tickRate | number | 20 | Tick rate (Hz) |
autoDispose | boolean | true | Auto-dispose empty rooms |
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
// Broadcast to all players broadcast<T>(type: string, data: T): void
// Broadcast to all except one broadcastExcept<T>(type: string, data: T, except: Player): 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}Lifecycle Methods
Section titled “Lifecycle Methods”| Method | Trigger | Purpose |
|---|---|---|
onCreate() | Room created | Initialize game state |
onJoin(player) | Player joins | Welcome message, assign position |
onLeave(player) | Player leaves | Cleanup player data |
onTick(dt) | Every frame | Game logic, state sync |
onDispose() | Before disposal | Save data, cleanup resources |
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 data: TData // Custom data
// Send message to this player send<T>(type: string, data: T): void
// Leave room leave(): void}@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 }}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}
// 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 roomconst { roomId, playerId } = await client.call('JoinRoom', { roomType: 'game', playerName: 'Alice',})
// 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.
Server Startup
Section titled “Server Startup”import { Core } from '@esengine/ecs-framework';import { createServer } from '@esengine/server';import { GameRoom } from './rooms/GameRoom.js';
// Initialize CoreCore.create();
// 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
-
Disconnect Handling
- Implement reconnection logic
- Use
onLeaveto save player state
-
Room Lifecycle
- Use
autoDisposeto clean up empty rooms - Save important data in
onDispose
- Use