@phalanx-engine/server 0.1.1 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +282 -214
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -2,13 +2,29 @@
|
|
|
2
2
|
|
|
3
3
|
Server component of [Phalanx Engine](../README.md) - a game-agnostic deterministic lockstep multiplayer engine with authentication, matchmaking, and command synchronization.
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
-
|
|
10
|
-
-
|
|
11
|
-
-
|
|
5
|
+
For a high-level overview of engine features, see the [root README](../README.md).
|
|
6
|
+
|
|
7
|
+
## Table of Contents
|
|
8
|
+
|
|
9
|
+
- [Installation](#installation)
|
|
10
|
+
- [Requirements](#requirements)
|
|
11
|
+
- [Quick Start](#quick-start)
|
|
12
|
+
- [Minimal Configuration](#minimal-configuration)
|
|
13
|
+
- [Configuration Reference](#configuration-reference)
|
|
14
|
+
- [Game Types](#game-types)
|
|
15
|
+
- [Game Modes](#game-modes)
|
|
16
|
+
- [Event Hooks](#event-hooks)
|
|
17
|
+
- [API](#api)
|
|
18
|
+
- [Phalanx Class](#phalanx-class)
|
|
19
|
+
- [Socket.IO Protocol Reference](#socketio-protocol-reference)
|
|
20
|
+
- [Game Start Synchronization](#game-start-synchronization)
|
|
21
|
+
- [TLS / WSS Configuration](#tls--wss-configuration)
|
|
22
|
+
- [Advanced Topics](#advanced-topics)
|
|
23
|
+
- [HTTP Extension Hooks](#http-extension-hooks)
|
|
24
|
+
- [Private Room Recovery](#private-room-recovery)
|
|
25
|
+
- [Trusted Server-Side Private Room Creation](#trusted-server-side-private-room-creation)
|
|
26
|
+
- [Related Packages](#related-packages)
|
|
27
|
+
- [License](#license)
|
|
12
28
|
|
|
13
29
|
## Installation
|
|
14
30
|
|
|
@@ -16,6 +32,10 @@ Server component of [Phalanx Engine](../README.md) - a game-agnostic determinist
|
|
|
16
32
|
npm install @phalanx-engine/server
|
|
17
33
|
```
|
|
18
34
|
|
|
35
|
+
## Requirements
|
|
36
|
+
|
|
37
|
+
- Node.js `>= 24.0.0`
|
|
38
|
+
|
|
19
39
|
## Quick Start
|
|
20
40
|
|
|
21
41
|
```typescript
|
|
@@ -32,7 +52,32 @@ app.start().then(() => {
|
|
|
32
52
|
});
|
|
33
53
|
```
|
|
34
54
|
|
|
35
|
-
## Configuration
|
|
55
|
+
## Minimal Configuration
|
|
56
|
+
|
|
57
|
+
A typical production-ready configuration looks like this:
|
|
58
|
+
|
|
59
|
+
```typescript
|
|
60
|
+
import { Phalanx } from '@phalanx-engine/server';
|
|
61
|
+
|
|
62
|
+
const app = new Phalanx({
|
|
63
|
+
port: 3000,
|
|
64
|
+
tickRate: 20,
|
|
65
|
+
gameMode: '3v3',
|
|
66
|
+
countdownSeconds: 5,
|
|
67
|
+
readyTimeoutMs: 30000,
|
|
68
|
+
enableStateHashing: true,
|
|
69
|
+
desync: {
|
|
70
|
+
enabled: true,
|
|
71
|
+
action: 'end-match',
|
|
72
|
+
},
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
await app.start();
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
See the full reference below for every available option.
|
|
79
|
+
|
|
80
|
+
## Configuration Reference
|
|
36
81
|
|
|
37
82
|
```typescript
|
|
38
83
|
import { Phalanx, PhalanxConfig } from '@phalanx-engine/server';
|
|
@@ -130,207 +175,6 @@ const config: Partial<PhalanxConfig> = {
|
|
|
130
175
|
const app = new Phalanx(config);
|
|
131
176
|
```
|
|
132
177
|
|
|
133
|
-
## Event Hooks
|
|
134
|
-
|
|
135
|
-
```typescript
|
|
136
|
-
app.on('match-created', (match) => {
|
|
137
|
-
console.log(`Match ${match.id} created with ${match.players.length} players`);
|
|
138
|
-
});
|
|
139
|
-
|
|
140
|
-
app.on('match-started', (match) => {
|
|
141
|
-
console.log(`Match ${match.id} started!`);
|
|
142
|
-
});
|
|
143
|
-
|
|
144
|
-
app.on('player-command', (playerId, command) => {
|
|
145
|
-
// Custom command validation
|
|
146
|
-
return true; // or false to reject
|
|
147
|
-
});
|
|
148
|
-
|
|
149
|
-
app.on('player-timeout', (playerId, matchId) => {
|
|
150
|
-
console.log(`Player ${playerId} timed out`);
|
|
151
|
-
});
|
|
152
|
-
|
|
153
|
-
app.on('player-disconnected', (playerId, matchId) => {
|
|
154
|
-
console.log(`Player ${playerId} disconnected from match ${matchId}`);
|
|
155
|
-
});
|
|
156
|
-
|
|
157
|
-
app.on('player-reconnected', (playerId, matchId) => {
|
|
158
|
-
console.log(`Player ${playerId} reconnected to match ${matchId}`);
|
|
159
|
-
});
|
|
160
|
-
```
|
|
161
|
-
|
|
162
|
-
## HTTP Extension Hooks
|
|
163
|
-
|
|
164
|
-
Use `extraRequestHandler` when an embedding server needs a small HTTP surface on the same port as Phalanx, such as a Telegram webhook, an internal health probe, or a signed partner callback.
|
|
165
|
-
|
|
166
|
-
```typescript
|
|
167
|
-
import { Phalanx } from '@phalanx-engine/server';
|
|
168
|
-
|
|
169
|
-
const app = new Phalanx({
|
|
170
|
-
port: 3000,
|
|
171
|
-
extraRequestHandler: async (req, res) => {
|
|
172
|
-
if (req.method !== 'POST' || req.url !== '/telegram/webhook') {
|
|
173
|
-
return false; // fall through to Phalanx built-in routes
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
if (req.headers['x-telegram-bot-api-secret-token'] !== process.env.WEBHOOK_SECRET) {
|
|
177
|
-
res.writeHead(401);
|
|
178
|
-
res.end();
|
|
179
|
-
return true;
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
// Parse and handle the request body here.
|
|
183
|
-
res.writeHead(200);
|
|
184
|
-
res.end();
|
|
185
|
-
return true;
|
|
186
|
-
},
|
|
187
|
-
});
|
|
188
|
-
```
|
|
189
|
-
|
|
190
|
-
Request routing order is:
|
|
191
|
-
|
|
192
|
-
1. CORS preflight (`OPTIONS`) is answered by Phalanx.
|
|
193
|
-
2. `extraRequestHandler` is called.
|
|
194
|
-
3. Built-in routes run (`/`, `/health`, `/auth/token`).
|
|
195
|
-
4. Unknown routes receive `404`.
|
|
196
|
-
|
|
197
|
-
Handler contract:
|
|
198
|
-
|
|
199
|
-
- Return `true` only after fully handling the request, including writing headers/body and ending the response.
|
|
200
|
-
- Return `false` to let Phalanx continue with its built-in routing.
|
|
201
|
-
- Set any route-specific CORS or content headers yourself for handled custom routes.
|
|
202
|
-
- Parse and size-limit request bodies yourself; Phalanx only parses bodies for its own built-in routes.
|
|
203
|
-
- If the handler throws, Phalanx logs the error and sends a generic `500` response when headers have not already been sent.
|
|
204
|
-
|
|
205
|
-
Treat custom HTTP routes as public internet-facing endpoints. Validate webhook secrets or signatures, avoid exposing trusted administrative operations to browsers, and apply rate limiting where appropriate.
|
|
206
|
-
|
|
207
|
-
## API
|
|
208
|
-
|
|
209
|
-
### Phalanx Class
|
|
210
|
-
|
|
211
|
-
```typescript
|
|
212
|
-
class Phalanx {
|
|
213
|
-
constructor(config?: Partial<PhalanxConfig>);
|
|
214
|
-
|
|
215
|
-
// Lifecycle
|
|
216
|
-
start(): Promise<void>;
|
|
217
|
-
stop(): Promise<void>;
|
|
218
|
-
|
|
219
|
-
// Trusted server-side integrations
|
|
220
|
-
createPrivateRoomForHost(params: {
|
|
221
|
-
playerId: string;
|
|
222
|
-
username?: string;
|
|
223
|
-
gameType?: string;
|
|
224
|
-
}): RoomCreatedEvent;
|
|
225
|
-
|
|
226
|
-
// Events
|
|
227
|
-
on(event: PhalanxEvent, handler: Function): this;
|
|
228
|
-
off(event: PhalanxEvent, handler: Function): this;
|
|
229
|
-
|
|
230
|
-
// Runtime info
|
|
231
|
-
getActiveMatches(): MatchInfo[];
|
|
232
|
-
getQueueSize(): number;
|
|
233
|
-
getConfig(): PhalanxConfig;
|
|
234
|
-
}
|
|
235
|
-
```
|
|
236
|
-
|
|
237
|
-
### Client Events
|
|
238
|
-
|
|
239
|
-
| Event | Emit | Receive | Description |
|
|
240
|
-
| --------------------- | ---- | ------- | --------------------------------- |
|
|
241
|
-
| `queue-join` | ✅ | | Join matchmaking queue |
|
|
242
|
-
| `queue-leave` | ✅ | | Leave matchmaking queue |
|
|
243
|
-
| `queue-status` | | ✅ | Queue join/leave confirmation |
|
|
244
|
-
| `match-found` | | ✅ | Match created, countdown starting |
|
|
245
|
-
| `match-waiting-for-players` | | ✅ | Match is waiting for disconnected participants before countdown |
|
|
246
|
-
| `game-start` | | ✅ | Countdown finished, load assets |
|
|
247
|
-
| `client-ready` | ✅ | | Client finished loading, ready for ticks |
|
|
248
|
-
| `player-ready` | | ✅ | A player reported ready |
|
|
249
|
-
| `match-end` | | ✅ | Match has ended |
|
|
250
|
-
| `submit-commands` | ✅ | | Send game commands |
|
|
251
|
-
| `submit-commands-ack` | | ✅ | Command acknowledgment |
|
|
252
|
-
| `commands-batch` | | ✅ | All commands for a tick |
|
|
253
|
-
| `tick-sync` | | ✅ | Periodic tick synchronization |
|
|
254
|
-
| `countdown` | | ✅ | Countdown before game starts |
|
|
255
|
-
| `submit-state-hash` | ✅ | | Submit state hash for desync detection |
|
|
256
|
-
| `hash-comparison` | | ✅ | Server hash comparison result |
|
|
257
|
-
| `pause-game` | ✅ | | Request game pause |
|
|
258
|
-
| `resume-game` | ✅ | | Request game resume |
|
|
259
|
-
| `game-paused` | | ✅ | Game was paused |
|
|
260
|
-
| `game-resumed` | | ✅ | Game was resumed |
|
|
261
|
-
| `reconnect-match` | ✅ | | Attempt to rejoin a match |
|
|
262
|
-
| `reconnect-status` | | ✅ | Reconnection result |
|
|
263
|
-
| `reconnect-state` | | ✅ | Game state for reconnection |
|
|
264
|
-
| `player-disconnected` | | ✅ | Another player disconnected |
|
|
265
|
-
| `player-reconnected` | | ✅ | Another player reconnected |
|
|
266
|
-
| `room-create` | ✅ | | Create a private room |
|
|
267
|
-
| `room-join` | ✅ | | Join a private room by invite code |
|
|
268
|
-
| `room-cancel` | ✅ | | Cancel a previously created room |
|
|
269
|
-
| `room-recover` | ✅ | | Reclaim a room after socket drop (see Private Room Recovery) |
|
|
270
|
-
| `room-created` | | ✅ | Room created; contains invite `code` |
|
|
271
|
-
| `room-error` | | ✅ | Room operation failed; contains `message` |
|
|
272
|
-
| `room-expired` | | ✅ | Room TTL exceeded; server evicted the room |
|
|
273
|
-
| `room-cancelled` | | ✅ | Host cancelled the room |
|
|
274
|
-
| `room-recovered` | | ✅ | Room successfully reclaimed after socket drop |
|
|
275
|
-
|
|
276
|
-
### Game Start Synchronization
|
|
277
|
-
|
|
278
|
-
After the countdown completes, the server emits `game-start` and enters a `waiting-for-ready` state. The tick loop does **not** start until all connected clients send `client-ready`. This prevents desync caused by clients with different asset loading times missing early ticks.
|
|
279
|
-
|
|
280
|
-
If a client does not send `client-ready` within 30 seconds, the match ends with reason `'ready-timeout'`.
|
|
281
|
-
|
|
282
|
-
### Private Room Recovery
|
|
283
|
-
|
|
284
|
-
Private rooms support a `room-recover` handshake so a participant whose socket was dropped (e.g. mobile OS suspended the WebSocket while the user was sharing the invite link) can seamlessly reclaim either a waiting room or a private-room match without losing the pending join or countdown state.
|
|
285
|
-
|
|
286
|
-
`room-recover` requires both the deterministic `playerId` and the original room `code`. Treat the room code as a bearer recovery secret: possession of the code is what proves the caller is allowed to recover that waiting room or match.
|
|
287
|
-
|
|
288
|
-
#### Recovery protocol
|
|
289
|
-
|
|
290
|
-
1. Client calls `room-recover` with `{ playerId, code }` after reconnecting.
|
|
291
|
-
2. If the code still maps to a waiting room, only the original host can recover it. The server rebinds the host socket and emits `room-recovered`.
|
|
292
|
-
3. If the code has already been consumed into a match, any participant in that match can recover with the original code. The server emits `room-recovered` and delegates to the match reconnect flow.
|
|
293
|
-
4. If the match is still waiting for disconnected participants, reconnecting everyone starts the countdown. If the match is already in progress, the normal reconnect state is delivered.
|
|
294
|
-
5. Server responds with `room-error: { message: 'Room expired' }` on terminal failure. The same generic message is used for missing, expired, wrong-player, and already-finished cases so callers cannot probe room ownership.
|
|
295
|
-
|
|
296
|
-
#### Server-side room TTL
|
|
297
|
-
|
|
298
|
-
Waiting rooms are kept alive for `ROOM_TTL_MS` (default 5 minutes) from creation. If the host disconnects, the room remains recoverable until that original TTL expires; if no host socket is connected at expiry time, there is no `room-expired` recipient to notify. Clients should mirror this TTL in their local persistence layer so they do not attempt `room-recover` for a room the server has certainly already evicted.
|
|
299
|
-
|
|
300
|
-
The client-side [`RoomRecoveryController`](../phalanx-client/README.md#mobile-friendly-room-recovery) handles the full recovery lifecycle automatically when `roomRecovery.enabled: true` is passed to `PhalanxClient`.
|
|
301
|
-
|
|
302
|
-
### Trusted Server-Side Private Room Creation
|
|
303
|
-
|
|
304
|
-
Trusted integrations can create a private room for a host that is not currently connected over Socket.IO. This is intended for server-side entry points such as bot commands: the bot creates a room, sends the invite link containing the room code, and the host later opens the game and recovers the room with the same deterministic `playerId`.
|
|
305
|
-
|
|
306
|
-
```typescript
|
|
307
|
-
const room = app.createPrivateRoomForHost({
|
|
308
|
-
playerId: `telegram:${telegramUserId}`,
|
|
309
|
-
username: telegramUsername,
|
|
310
|
-
gameType: 'chapayev',
|
|
311
|
-
});
|
|
312
|
-
|
|
313
|
-
const inviteUrl = `https://game.example.com/?room=${room.code}`;
|
|
314
|
-
```
|
|
315
|
-
|
|
316
|
-
`createPrivateRoomForHost`:
|
|
317
|
-
|
|
318
|
-
- Requires `playerId` and accepts optional `username` and `gameType`.
|
|
319
|
-
- Defaults `username` to `playerId` and `gameType` to the default game type when omitted.
|
|
320
|
-
- Returns `{ code }`. If the same host already has an active waiting room, the existing room code is reused rather than creating a duplicate room.
|
|
321
|
-
- Throws if Phalanx is not running, if the host is already queued for matchmaking, or if the host is already in an active private-room match.
|
|
322
|
-
- Does not emit `room-created`, because there is no host socket yet.
|
|
323
|
-
|
|
324
|
-
Typical bot/webhook flow:
|
|
325
|
-
|
|
326
|
-
1. A trusted HTTP handler validates the webhook request.
|
|
327
|
-
2. The handler derives a stable server-trusted `playerId` from the external identity, such as `telegram:123456`.
|
|
328
|
-
3. The handler calls `createPrivateRoomForHost` and sends the invite URL/code back through the external channel.
|
|
329
|
-
4. The host opens the game and emits `room-recover` with the same `playerId` and code.
|
|
330
|
-
5. The guest opens the invite and emits `room-join` with the code.
|
|
331
|
-
|
|
332
|
-
Do not expose `createPrivateRoomForHost` as an unauthenticated browser endpoint. Browser clients should continue using the Socket.IO `room-create` flow, while trusted server integrations should authenticate the external request before creating rooms.
|
|
333
|
-
|
|
334
178
|
## Game Types
|
|
335
179
|
|
|
336
180
|
Phalanx supports running multiple game types on a single server instance. Each game type can override base config values like `tickMode`, `tickRate`, `gameMode`, `countdownSeconds`, and `turnTimeoutMs`.
|
|
@@ -401,7 +245,130 @@ import { GAME_MODES } from '@phalanx-engine/server';
|
|
|
401
245
|
// 'FFA4' - 4 players, 4 teams (Free For All)
|
|
402
246
|
```
|
|
403
247
|
|
|
404
|
-
##
|
|
248
|
+
## Event Hooks
|
|
249
|
+
|
|
250
|
+
```typescript
|
|
251
|
+
app.on('match-created', (match) => {
|
|
252
|
+
console.log(`Match ${match.id} created with ${match.players.length} players`);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
app.on('match-started', (match) => {
|
|
256
|
+
console.log(`Match ${match.id} started!`);
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
app.on('player-command', (playerId, command) => {
|
|
260
|
+
// Custom command validation
|
|
261
|
+
return true; // or false to reject
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
app.on('player-timeout', (playerId, matchId) => {
|
|
265
|
+
console.log(`Player ${playerId} timed out`);
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
app.on('player-disconnected', (playerId, matchId) => {
|
|
269
|
+
console.log(`Player ${playerId} disconnected from match ${matchId}`);
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
app.on('player-reconnected', (playerId, matchId) => {
|
|
273
|
+
console.log(`Player ${playerId} reconnected to match ${matchId}`);
|
|
274
|
+
});
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
## API
|
|
278
|
+
|
|
279
|
+
### Phalanx Class
|
|
280
|
+
|
|
281
|
+
```typescript
|
|
282
|
+
class Phalanx {
|
|
283
|
+
constructor(config?: Partial<PhalanxConfig>);
|
|
284
|
+
|
|
285
|
+
// Lifecycle
|
|
286
|
+
start(): Promise<void>;
|
|
287
|
+
stop(): Promise<void>;
|
|
288
|
+
|
|
289
|
+
// Trusted server-side integrations
|
|
290
|
+
createPrivateRoomForHost(params: {
|
|
291
|
+
playerId: string;
|
|
292
|
+
username?: string;
|
|
293
|
+
gameType?: string;
|
|
294
|
+
}): RoomCreatedEvent;
|
|
295
|
+
|
|
296
|
+
// Events
|
|
297
|
+
on(event: PhalanxEvent, handler: Function): this;
|
|
298
|
+
off(event: PhalanxEvent, handler: Function): this;
|
|
299
|
+
|
|
300
|
+
// Runtime info
|
|
301
|
+
getActiveMatches(): MatchInfo[];
|
|
302
|
+
getQueueSize(): number;
|
|
303
|
+
getConfig(): PhalanxConfig;
|
|
304
|
+
}
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
### Socket.IO Protocol Reference
|
|
308
|
+
|
|
309
|
+
| Event | Emit | Receive | Description |
|
|
310
|
+
| --------------------------- | ---- | ------- | --------------------------------------------------------------- |
|
|
311
|
+
| `queue-join` | ✅ | | Join matchmaking queue |
|
|
312
|
+
| `queue-leave` | ✅ | | Leave matchmaking queue |
|
|
313
|
+
| `queue-status` | | ✅ | Queue join/leave confirmation |
|
|
314
|
+
| `match-found` | | ✅ | Match created, countdown starting |
|
|
315
|
+
| `match-waiting-for-players` | | ✅ | Match is waiting for disconnected participants before countdown |
|
|
316
|
+
| `game-start` | | ✅ | Countdown finished, load assets |
|
|
317
|
+
| `client-ready` | ✅ | | Client finished loading, ready for ticks |
|
|
318
|
+
| `player-ready` | | ✅ | A player reported ready |
|
|
319
|
+
| `match-end` | | ✅ | Match has ended |
|
|
320
|
+
| `submit-commands` | ✅ | | Send game commands |
|
|
321
|
+
| `submit-commands-ack` | | ✅ | Command acknowledgment |
|
|
322
|
+
| `commands-batch` | | ✅ | All commands for a tick |
|
|
323
|
+
| `tick-sync` | | ✅ | Periodic tick synchronization |
|
|
324
|
+
| `countdown` | | ✅ | Countdown before game starts |
|
|
325
|
+
| `submit-state-hash` | ✅ | | Submit state hash for desync detection |
|
|
326
|
+
| `hash-comparison` | | ✅ | Server hash comparison result |
|
|
327
|
+
| `pause-game` | ✅ | | Request game pause |
|
|
328
|
+
| `resume-game` | ✅ | | Request game resume |
|
|
329
|
+
| `game-paused` | | ✅ | Game was paused |
|
|
330
|
+
| `game-resumed` | | ✅ | Game was resumed |
|
|
331
|
+
| `reconnect-match` | ✅ | | Attempt to rejoin a match |
|
|
332
|
+
| `reconnect-status` | | ✅ | Reconnection result |
|
|
333
|
+
| `reconnect-state` | | ✅ | Game state for reconnection |
|
|
334
|
+
| `player-disconnected` | | ✅ | Another player disconnected |
|
|
335
|
+
| `player-reconnected` | | ✅ | Another player reconnected |
|
|
336
|
+
| `room-create` | ✅ | | Create a private room |
|
|
337
|
+
| `room-join` | ✅ | | Join a private room by invite code |
|
|
338
|
+
| `room-cancel` | ✅ | | Cancel a previously created room |
|
|
339
|
+
| `room-recover` | ✅ | | Reclaim a room after socket drop (see Private Room Recovery) |
|
|
340
|
+
| `room-created` | | ✅ | Room created; contains invite `code` |
|
|
341
|
+
| `room-error` | | ✅ | Room operation failed; contains `message` |
|
|
342
|
+
| `room-expired` | | ✅ | Room TTL exceeded; server evicted the room |
|
|
343
|
+
| `room-cancelled` | | ✅ | Host cancelled the room |
|
|
344
|
+
| `room-recovered` | | ✅ | Room successfully reclaimed after socket drop |
|
|
345
|
+
|
|
346
|
+
## Game Start Synchronization
|
|
347
|
+
|
|
348
|
+
Phalanx uses a **ready handshake** protocol to ensure all clients are fully initialized before the simulation begins. This prevents desync caused by clients with different asset download speeds missing early ticks.
|
|
349
|
+
|
|
350
|
+
### How it works
|
|
351
|
+
|
|
352
|
+
1. Server emits `game-start` after the countdown completes and enters a `waiting-for-ready` state.
|
|
353
|
+
2. Each client receives `game-start`, loads assets, sets up the game world, and initializes all systems.
|
|
354
|
+
3. Each client calls `client.sendReady()` to emit `client-ready` to the server.
|
|
355
|
+
4. Server receives `client-ready` from **all** connected players, then starts the tick loop.
|
|
356
|
+
5. All clients are guaranteed to be subscribed to tick events before tick 0.
|
|
357
|
+
|
|
358
|
+
If any client fails to send `client-ready` within 30 seconds, the match ends with reason `'ready-timeout'`.
|
|
359
|
+
|
|
360
|
+
### Usage
|
|
361
|
+
|
|
362
|
+
Clients **must** call `sendReady()` after initialization:
|
|
363
|
+
|
|
364
|
+
```typescript
|
|
365
|
+
client.on('gameStart', async () => {
|
|
366
|
+
await game.initialize(); // Load assets, set up ECS, etc.
|
|
367
|
+
client.sendReady(); // Tell the server we're ready
|
|
368
|
+
});
|
|
369
|
+
```
|
|
370
|
+
|
|
371
|
+
## TLS / WSS Configuration
|
|
405
372
|
|
|
406
373
|
Phalanx supports secure WebSocket connections (WSS) for production environments.
|
|
407
374
|
|
|
@@ -452,16 +419,19 @@ const app = new Phalanx({
|
|
|
452
419
|
### Let's Encrypt Setup
|
|
453
420
|
|
|
454
421
|
1. Install certbot:
|
|
422
|
+
|
|
455
423
|
```bash
|
|
456
424
|
sudo apt install certbot
|
|
457
425
|
```
|
|
458
426
|
|
|
459
427
|
2. Obtain certificates:
|
|
428
|
+
|
|
460
429
|
```bash
|
|
461
430
|
sudo certbot certonly --standalone -d game.example.com
|
|
462
431
|
```
|
|
463
432
|
|
|
464
433
|
3. Configure Phalanx:
|
|
434
|
+
|
|
465
435
|
```typescript
|
|
466
436
|
const app = new Phalanx({
|
|
467
437
|
port: 443,
|
|
@@ -494,13 +464,111 @@ const client = await PhalanxClient.create({
|
|
|
494
464
|
});
|
|
495
465
|
```
|
|
496
466
|
|
|
497
|
-
##
|
|
467
|
+
## Advanced Topics
|
|
498
468
|
|
|
499
|
-
|
|
469
|
+
### HTTP Extension Hooks
|
|
500
470
|
|
|
501
|
-
|
|
471
|
+
Use `extraRequestHandler` when an embedding server needs a small HTTP surface on the same port as Phalanx, such as a Telegram webhook, an internal health probe, or a signed partner callback.
|
|
472
|
+
|
|
473
|
+
```typescript
|
|
474
|
+
import { Phalanx } from '@phalanx-engine/server';
|
|
475
|
+
|
|
476
|
+
const app = new Phalanx({
|
|
477
|
+
port: 3000,
|
|
478
|
+
extraRequestHandler: async (req, res) => {
|
|
479
|
+
if (req.method !== 'POST' || req.url !== '/telegram/webhook') {
|
|
480
|
+
return false; // fall through to Phalanx built-in routes
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
if (
|
|
484
|
+
req.headers['x-telegram-bot-api-secret-token'] !==
|
|
485
|
+
process.env.WEBHOOK_SECRET
|
|
486
|
+
) {
|
|
487
|
+
res.writeHead(401);
|
|
488
|
+
res.end();
|
|
489
|
+
return true;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
// Parse and handle the request body here.
|
|
493
|
+
res.writeHead(200);
|
|
494
|
+
res.end();
|
|
495
|
+
return true;
|
|
496
|
+
},
|
|
497
|
+
});
|
|
498
|
+
```
|
|
499
|
+
|
|
500
|
+
Request routing order is:
|
|
501
|
+
|
|
502
|
+
1. CORS preflight (`OPTIONS`) is answered by Phalanx.
|
|
503
|
+
2. `extraRequestHandler` is called.
|
|
504
|
+
3. Built-in routes run (`/`, `/health`, `/auth/token`).
|
|
505
|
+
4. Unknown routes receive `404`.
|
|
502
506
|
|
|
503
|
-
|
|
507
|
+
Handler contract:
|
|
508
|
+
|
|
509
|
+
- Return `true` only after fully handling the request, including writing headers/body and ending the response.
|
|
510
|
+
- Return `false` to let Phalanx continue with its built-in routing.
|
|
511
|
+
- Set any route-specific CORS or content headers yourself for handled custom routes.
|
|
512
|
+
- Parse and size-limit request bodies yourself; Phalanx only parses bodies for its own built-in routes.
|
|
513
|
+
- If the handler throws, Phalanx logs the error and sends a generic `500` response when headers have not already been sent.
|
|
514
|
+
|
|
515
|
+
Treat custom HTTP routes as public internet-facing endpoints. Validate webhook secrets or signatures, avoid exposing trusted administrative operations to browsers, and apply rate limiting where appropriate.
|
|
516
|
+
|
|
517
|
+
### Private Room Recovery
|
|
518
|
+
|
|
519
|
+
Private rooms support a `room-recover` handshake so a participant whose socket was dropped (e.g. mobile OS suspended the WebSocket while the user was sharing the invite link) can seamlessly reclaim either a waiting room or a private-room match without losing the pending join or countdown state.
|
|
520
|
+
|
|
521
|
+
`room-recover` requires both the deterministic `playerId` and the original room `code`. Treat the room code as a bearer recovery secret: possession of the code is what proves the caller is allowed to recover that waiting room or match.
|
|
522
|
+
|
|
523
|
+
#### Recovery protocol
|
|
524
|
+
|
|
525
|
+
1. Client calls `room-recover` with `{ playerId, code }` after reconnecting.
|
|
526
|
+
2. If the code still maps to a waiting room, only the original host can recover it. The server rebinds the host socket and emits `room-recovered`.
|
|
527
|
+
3. If the code has already been consumed into a match, any participant in that match can recover with the original code. The server emits `room-recovered` and delegates to the match reconnect flow.
|
|
528
|
+
4. If the match is still waiting for disconnected participants, reconnecting everyone starts the countdown. If the match is already in progress, the normal reconnect state is delivered.
|
|
529
|
+
5. Server responds with `room-error: { message: 'Room expired' }` on terminal failure. The same generic message is used for missing, expired, wrong-player, and already-finished cases so callers cannot probe room ownership.
|
|
530
|
+
|
|
531
|
+
#### Server-side room TTL
|
|
532
|
+
|
|
533
|
+
Waiting rooms are kept alive for `ROOM_TTL_MS` (default 5 minutes) from creation. If the host disconnects, the room remains recoverable until that original TTL expires; if no host socket is connected at expiry time, there is no `room-expired` recipient to notify. Clients should mirror this TTL in their local persistence layer so they do not attempt `room-recover` for a room the server has certainly already evicted.
|
|
534
|
+
|
|
535
|
+
The client-side [`RoomRecoveryController`](../phalanx-client/README.md#mobile-friendly-room-recovery) handles the full recovery lifecycle automatically when `roomRecovery.enabled: true` is passed to `PhalanxClient`.
|
|
536
|
+
|
|
537
|
+
### Trusted Server-Side Private Room Creation
|
|
538
|
+
|
|
539
|
+
Trusted integrations can create a private room for a host that is not currently connected over Socket.IO. This is intended for server-side entry points such as bot commands: the bot creates a room, sends the invite link containing the room code, and the host later opens the game and recovers the room with the same deterministic `playerId`.
|
|
540
|
+
|
|
541
|
+
```typescript
|
|
542
|
+
const room = app.createPrivateRoomForHost({
|
|
543
|
+
playerId: `telegram:${telegramUserId}`,
|
|
544
|
+
username: telegramUsername,
|
|
545
|
+
gameType: 'chapayev',
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
const inviteUrl = `https://game.example.com/?room=${room.code}`;
|
|
549
|
+
```
|
|
550
|
+
|
|
551
|
+
`createPrivateRoomForHost`:
|
|
552
|
+
|
|
553
|
+
- Requires `playerId` and accepts optional `username` and `gameType`.
|
|
554
|
+
- Defaults `username` to `playerId` and `gameType` to the default game type when omitted.
|
|
555
|
+
- Returns `{ code }`. If the same host already has an active waiting room, the existing room code is reused rather than creating a duplicate room.
|
|
556
|
+
- Throws if Phalanx is not running, if the host is already queued for matchmaking, or if the host is already in an active private-room match.
|
|
557
|
+
- Does not emit `room-created`, because there is no host socket yet.
|
|
558
|
+
|
|
559
|
+
Typical bot/webhook flow:
|
|
560
|
+
|
|
561
|
+
1. A trusted HTTP handler validates the webhook request.
|
|
562
|
+
2. The handler derives a stable server-trusted `playerId` from the external identity, such as `telegram:123456`.
|
|
563
|
+
3. The handler calls `createPrivateRoomForHost` and sends the invite URL/code back through the external channel.
|
|
564
|
+
4. The host opens the game and emits `room-recover` with the same `playerId` and code.
|
|
565
|
+
5. The guest opens the invite and emits `room-join` with the code.
|
|
566
|
+
|
|
567
|
+
Do not expose `createPrivateRoomForHost` as an unauthenticated browser endpoint. Browser clients should continue using the Socket.IO `room-create` flow, while trusted server integrations should authenticate the external request before creating rooms.
|
|
568
|
+
|
|
569
|
+
## Related Packages
|
|
570
|
+
|
|
571
|
+
- [@phalanx-engine/client](../phalanx-client) - Client library for connecting to Phalanx servers
|
|
504
572
|
|
|
505
573
|
## License
|
|
506
574
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@phalanx-engine/server",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"description": "Phalanx Engine Server - A game-agnostic deterministic lockstep multiplayer server with authentication, matchmaking, and command synchronization",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"types": "./dist/index.d.ts",
|
|
@@ -43,7 +43,7 @@
|
|
|
43
43
|
"dependencies": {
|
|
44
44
|
"pure-rand": "^7.0.1",
|
|
45
45
|
"socket.io": "^4.7.0",
|
|
46
|
-
"@phalanx-engine/math": "0.1.
|
|
46
|
+
"@phalanx-engine/math": "0.1.3"
|
|
47
47
|
},
|
|
48
48
|
"devDependencies": {
|
|
49
49
|
"@types/node": "^20.0.0",
|