@gamerstake/game-core 0.1.0
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/.eslintrc.json +22 -0
- package/.testing-guide-summary.md +261 -0
- package/DEVELOPER_GUIDE.md +996 -0
- package/MANUAL_TESTING.md +369 -0
- package/QUICK_START.md +368 -0
- package/README.md +379 -0
- package/TESTING_OVERVIEW.md +378 -0
- package/dist/index.d.ts +1266 -0
- package/dist/index.js +1632 -0
- package/dist/index.js.map +1 -0
- package/examples/simple-game/README.md +176 -0
- package/examples/simple-game/client.ts +201 -0
- package/examples/simple-game/package.json +14 -0
- package/examples/simple-game/server.ts +233 -0
- package/jest.config.ts +39 -0
- package/package.json +54 -0
- package/src/core/GameLoop.ts +214 -0
- package/src/core/GameRules.ts +103 -0
- package/src/core/GameServer.ts +200 -0
- package/src/core/Room.ts +368 -0
- package/src/entities/Entity.ts +118 -0
- package/src/entities/Registry.ts +161 -0
- package/src/index.ts +51 -0
- package/src/input/Command.ts +41 -0
- package/src/input/InputQueue.ts +130 -0
- package/src/network/Network.ts +112 -0
- package/src/network/Snapshot.ts +59 -0
- package/src/physics/AABB.ts +104 -0
- package/src/physics/Movement.ts +124 -0
- package/src/spatial/Grid.ts +202 -0
- package/src/types/index.ts +117 -0
- package/src/types/protocol.ts +161 -0
- package/src/utils/Logger.ts +112 -0
- package/src/utils/RingBuffer.ts +116 -0
- package/tests/AABB.test.ts +38 -0
- package/tests/Entity.test.ts +35 -0
- package/tests/GameLoop.test.ts +58 -0
- package/tests/GameServer.test.ts +64 -0
- package/tests/Grid.test.ts +28 -0
- package/tests/InputQueue.test.ts +42 -0
- package/tests/Movement.test.ts +37 -0
- package/tests/Network.test.ts +39 -0
- package/tests/Registry.test.ts +36 -0
- package/tests/RingBuffer.test.ts +38 -0
- package/tests/Room.test.ts +80 -0
- package/tests/Snapshot.test.ts +19 -0
- package/tsconfig.json +28 -0
- package/tsup.config.ts +14 -0
|
@@ -0,0 +1,996 @@
|
|
|
1
|
+
# Developer Guide - @gamerstake/game-core
|
|
2
|
+
|
|
3
|
+
> **A comprehensive guide to building multiplayer games with game-core**
|
|
4
|
+
|
|
5
|
+
This guide will teach you everything you need to know to build multiplayer games using the `@gamerstake/game-core` engine.
|
|
6
|
+
|
|
7
|
+
## Table of Contents
|
|
8
|
+
|
|
9
|
+
1. [Introduction](#introduction)
|
|
10
|
+
2. [Core Concepts](#core-concepts)
|
|
11
|
+
3. [Getting Started](#getting-started)
|
|
12
|
+
4. [Architecture Overview](#architecture-overview)
|
|
13
|
+
5. [Building Your First Game](#building-your-first-game)
|
|
14
|
+
6. [Advanced Topics](#advanced-topics)
|
|
15
|
+
7. [Performance Optimization](#performance-optimization)
|
|
16
|
+
8. [Common Patterns](#common-patterns)
|
|
17
|
+
9. [Troubleshooting](#troubleshooting)
|
|
18
|
+
10. [Best Practices](#best-practices)
|
|
19
|
+
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
## Introduction
|
|
23
|
+
|
|
24
|
+
`@gamerstake/game-core` is a **game-agnostic multiplayer engine** that handles the complex infrastructure of real-time multiplayer games so you can focus on your game logic.
|
|
25
|
+
|
|
26
|
+
### What game-core provides
|
|
27
|
+
|
|
28
|
+
**Fixed tick rate game loop** (20 TPS with drift compensation)
|
|
29
|
+
**Authoritative server architecture** (server is the source of truth)
|
|
30
|
+
**Client-server networking** (via Socket.io)
|
|
31
|
+
**State synchronization** (full snapshots + delta updates)
|
|
32
|
+
**Input buffering** (reliable command processing)
|
|
33
|
+
**Entity management** (ECS-lite with dirty tracking)
|
|
34
|
+
**Spatial partitioning** (grid-based O(1) queries)
|
|
35
|
+
**Basic 2D physics** (velocity, position, AABB collision)
|
|
36
|
+
**Performance monitoring** (real-time metrics)
|
|
37
|
+
|
|
38
|
+
### What you need to provide
|
|
39
|
+
|
|
40
|
+
**Game rules** (implement the `GameRules` interface)
|
|
41
|
+
**Game client** (render game state, send inputs)
|
|
42
|
+
**Persistence** (optional - save/load game state)
|
|
43
|
+
**Authentication** (optional - player identity)
|
|
44
|
+
|
|
45
|
+
---
|
|
46
|
+
|
|
47
|
+
## Core Concepts
|
|
48
|
+
|
|
49
|
+
### 1. Game Server
|
|
50
|
+
|
|
51
|
+
The **GameServer** manages multiple rooms (game instances).
|
|
52
|
+
|
|
53
|
+
```typescript
|
|
54
|
+
const gameServer = new GameServer();
|
|
55
|
+
gameServer.setServer(io); // Socket.io instance
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
- One GameServer per process
|
|
59
|
+
- Can host multiple rooms
|
|
60
|
+
- Handles room lifecycle
|
|
61
|
+
|
|
62
|
+
### 2. Room
|
|
63
|
+
|
|
64
|
+
A **Room** is a single game instance (match, level, world).
|
|
65
|
+
|
|
66
|
+
```typescript
|
|
67
|
+
const room = gameServer.createRoom('room-1', gameRules, config);
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
- Has its own game loop running at fixed tick rate (default: 20 TPS)
|
|
71
|
+
- Contains entities (players, items, NPCs)
|
|
72
|
+
- Manages networking, spatial partitioning, input processing
|
|
73
|
+
- Can be **persistent** (metaverse) or **match-based** (battle royale)
|
|
74
|
+
|
|
75
|
+
### 3. GameRules Interface
|
|
76
|
+
|
|
77
|
+
The **GameRules** interface is where YOU implement your game logic.
|
|
78
|
+
|
|
79
|
+
```typescript
|
|
80
|
+
interface GameRules {
|
|
81
|
+
onRoomCreated(room: Room): void;
|
|
82
|
+
onPlayerJoin(room: Room, player: Entity): void;
|
|
83
|
+
onPlayerLeave(room: Room, playerId: string): void;
|
|
84
|
+
onTick(room: Room, delta: number): void;
|
|
85
|
+
onCommand(room: Room, playerId: string, command: Command): void;
|
|
86
|
+
shouldEndRoom(room: Room): boolean;
|
|
87
|
+
}
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
**Think of it as hooks** - the engine calls your code at the right times.
|
|
91
|
+
|
|
92
|
+
### 4. Entity
|
|
93
|
+
|
|
94
|
+
An **Entity** represents any game object (player, NPC, item, projectile).
|
|
95
|
+
|
|
96
|
+
```typescript
|
|
97
|
+
const player = new Entity('player-1', x, y);
|
|
98
|
+
player.setVelocity(100, 0); // Move right at 100 units/sec
|
|
99
|
+
player.updatePosition(deltaMs); // Apply velocity
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
**Key features:**
|
|
103
|
+
|
|
104
|
+
- Position (x, y) and velocity (vx, vy)
|
|
105
|
+
- Dirty flag tracking (only broadcast changed entities)
|
|
106
|
+
- Extensible (subclass for custom properties)
|
|
107
|
+
|
|
108
|
+
### 5. Registry
|
|
109
|
+
|
|
110
|
+
The **Registry** manages entity lifecycle (add, remove, iterate).
|
|
111
|
+
|
|
112
|
+
```typescript
|
|
113
|
+
room.getRegistry().forEach((entity) => {
|
|
114
|
+
// Process all entities
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
const player = room.getRegistry().get(playerId);
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
### 6. Grid (Spatial Partitioning)
|
|
121
|
+
|
|
122
|
+
The **Grid** divides the world into cells for efficient spatial queries.
|
|
123
|
+
|
|
124
|
+
```typescript
|
|
125
|
+
const nearby = room.getGrid().getNearbyEntities(x, y, range);
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
- **O(1) queries** instead of O(n²) distance checks
|
|
129
|
+
- Essential for collision detection, visibility, etc.
|
|
130
|
+
|
|
131
|
+
### 7. Networking
|
|
132
|
+
|
|
133
|
+
The **Network** layer abstracts Socket.io and handles state broadcasting.
|
|
134
|
+
|
|
135
|
+
```typescript
|
|
136
|
+
room.broadcast({ op: 'GAME_EVENT', data: ... });
|
|
137
|
+
room.sendTo(playerId, { op: 'PRIVATE_MSG', msg: 'Hello' });
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
### 8. Snapshot System
|
|
141
|
+
|
|
142
|
+
**Snapshots** synchronize game state to clients.
|
|
143
|
+
|
|
144
|
+
- **Full snapshot** on initial connection (all entities)
|
|
145
|
+
- **Delta updates** every tick (only changed entities)
|
|
146
|
+
- Bandwidth efficient
|
|
147
|
+
|
|
148
|
+
---
|
|
149
|
+
|
|
150
|
+
## Getting Started
|
|
151
|
+
|
|
152
|
+
### Prerequisites
|
|
153
|
+
|
|
154
|
+
- Node.js 20+
|
|
155
|
+
- TypeScript 5.3+
|
|
156
|
+
- Socket.io 4.7+
|
|
157
|
+
|
|
158
|
+
### Installation
|
|
159
|
+
|
|
160
|
+
```bash
|
|
161
|
+
pnpm add @gamerstake/game-core socket.io
|
|
162
|
+
pnpm add -D @types/node typescript
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
### Project Structure
|
|
166
|
+
|
|
167
|
+
```
|
|
168
|
+
my-game/
|
|
169
|
+
server/
|
|
170
|
+
index.ts # Server entry point
|
|
171
|
+
GameRules.ts # Your game logic
|
|
172
|
+
entities/
|
|
173
|
+
Player.ts # Custom entity types
|
|
174
|
+
client/
|
|
175
|
+
index.ts # Client entry point
|
|
176
|
+
renderer.ts # Game rendering
|
|
177
|
+
shared/
|
|
178
|
+
types.ts # Shared types
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
---
|
|
182
|
+
|
|
183
|
+
## Architecture Overview
|
|
184
|
+
|
|
185
|
+
### Data Flow
|
|
186
|
+
|
|
187
|
+
```
|
|
188
|
+
Client Server
|
|
189
|
+
|
|
190
|
+
C_MOVE (input) >
|
|
191
|
+
|
|
192
|
+
[InputQueue]
|
|
193
|
+
|
|
194
|
+
[Game Tick]
|
|
195
|
+
- Process inputs
|
|
196
|
+
- Update entities
|
|
197
|
+
- Check collisions
|
|
198
|
+
|
|
199
|
+
< S_UPDATE (state)
|
|
200
|
+
|
|
201
|
+
[Render]
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
### Tick Cycle (20 TPS = 50ms per tick)
|
|
205
|
+
|
|
206
|
+
```
|
|
207
|
+
1. Process queued inputs (onCommand)
|
|
208
|
+
2. Update game state (onTick)
|
|
209
|
+
3. Check win conditions (shouldEndRoom)
|
|
210
|
+
4. Broadcast state changes (automatic)
|
|
211
|
+
5. Sleep until next tick
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
---
|
|
215
|
+
|
|
216
|
+
## Building Your First Game
|
|
217
|
+
|
|
218
|
+
Let's build a simple **multiplayer tag game** where players chase each other!
|
|
219
|
+
|
|
220
|
+
### Step 1: Define Game Rules
|
|
221
|
+
|
|
222
|
+
```typescript
|
|
223
|
+
// server/TagGameRules.ts
|
|
224
|
+
import { GameRules, Room, Entity, Command, MoveCommand } from '@gamerstake/game-core';
|
|
225
|
+
|
|
226
|
+
interface TagPlayer extends Entity {
|
|
227
|
+
isIt: boolean;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export class TagGameRules implements GameRules<TagPlayer> {
|
|
231
|
+
onRoomCreated(room: Room<TagPlayer>): void {
|
|
232
|
+
console.log('Tag game created! Waiting for players...');
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
onPlayerJoin(room: Room<TagPlayer>, player: TagPlayer): void {
|
|
236
|
+
// Spawn at random position
|
|
237
|
+
player.setPosition(Math.random() * 1000, Math.random() * 1000);
|
|
238
|
+
|
|
239
|
+
// First player is "it"
|
|
240
|
+
const players = Array.from(room.getRegistry().values());
|
|
241
|
+
player.isIt = players.length === 1;
|
|
242
|
+
|
|
243
|
+
room.broadcast({
|
|
244
|
+
op: 'PLAYER_JOINED',
|
|
245
|
+
playerId: player.id,
|
|
246
|
+
isIt: player.isIt,
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
onPlayerLeave(room: Room<TagPlayer>, playerId: string): void {
|
|
251
|
+
room.broadcast({
|
|
252
|
+
op: 'PLAYER_LEFT',
|
|
253
|
+
playerId,
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
// If "it" player left, pick a new one
|
|
257
|
+
const players = Array.from(room.getRegistry().values());
|
|
258
|
+
if (players.length > 0 && !players.some((p) => p.isIt)) {
|
|
259
|
+
players[0].isIt = true;
|
|
260
|
+
room.broadcast({
|
|
261
|
+
op: 'NEW_IT',
|
|
262
|
+
playerId: players[0].id,
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
onTick(room: Room<TagPlayer>, delta: number): void {
|
|
268
|
+
const players = Array.from(room.getRegistry().values());
|
|
269
|
+
|
|
270
|
+
// Update positions
|
|
271
|
+
players.forEach((p) => {
|
|
272
|
+
if (p.vx !== 0 || p.vy !== 0) {
|
|
273
|
+
p.updatePosition(delta);
|
|
274
|
+
|
|
275
|
+
// Keep in bounds
|
|
276
|
+
p.x = Math.max(0, Math.min(1000, p.x));
|
|
277
|
+
p.y = Math.max(0, Math.min(1000, p.y));
|
|
278
|
+
}
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
// Check for tag collisions
|
|
282
|
+
const itPlayer = players.find((p) => p.isIt);
|
|
283
|
+
if (!itPlayer) return;
|
|
284
|
+
|
|
285
|
+
for (const other of players) {
|
|
286
|
+
if (other.id === itPlayer.id) continue;
|
|
287
|
+
|
|
288
|
+
const dist = Math.hypot(itPlayer.x - other.x, itPlayer.y - other.y);
|
|
289
|
+
|
|
290
|
+
if (dist < 50) {
|
|
291
|
+
// Tag range
|
|
292
|
+
// Tagged!
|
|
293
|
+
itPlayer.isIt = false;
|
|
294
|
+
other.isIt = true;
|
|
295
|
+
|
|
296
|
+
room.broadcast({
|
|
297
|
+
op: 'TAGGED',
|
|
298
|
+
tagger: itPlayer.id,
|
|
299
|
+
tagged: other.id,
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
break;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
onCommand(room: Room<TagPlayer>, playerId: string, command: Command): void {
|
|
308
|
+
const player = room.getRegistry().get(playerId);
|
|
309
|
+
if (!player) return;
|
|
310
|
+
|
|
311
|
+
if (command.type === 'move') {
|
|
312
|
+
const moveCmd = command as MoveCommand;
|
|
313
|
+
const speed = player.isIt ? 250 : 200; // "It" is faster!
|
|
314
|
+
|
|
315
|
+
const len = Math.hypot(moveCmd.dir.x, moveCmd.dir.y);
|
|
316
|
+
if (len > 0) {
|
|
317
|
+
player.setVelocity((moveCmd.dir.x / len) * speed, (moveCmd.dir.y / len) * speed);
|
|
318
|
+
}
|
|
319
|
+
} else if (command.type === 'stop') {
|
|
320
|
+
player.setVelocity(0, 0);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
shouldEndRoom(room: Room<TagPlayer>): boolean {
|
|
325
|
+
// End if no players left
|
|
326
|
+
return room.getRegistry().size === 0;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
```
|
|
330
|
+
|
|
331
|
+
### Step 2: Create the Server
|
|
332
|
+
|
|
333
|
+
```typescript
|
|
334
|
+
// server/index.ts
|
|
335
|
+
import { Server } from 'socket.io';
|
|
336
|
+
import { GameServer, Entity } from '@gamerstake/game-core';
|
|
337
|
+
import { TagGameRules } from './TagGameRules.js';
|
|
338
|
+
|
|
339
|
+
const io = new Server(3000, {
|
|
340
|
+
cors: { origin: '*' },
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
const gameServer = new GameServer();
|
|
344
|
+
gameServer.setServer(io);
|
|
345
|
+
|
|
346
|
+
const room = gameServer.createRoom('tag-game', new TagGameRules(), {
|
|
347
|
+
tickRate: 20,
|
|
348
|
+
cellSize: 256,
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
io.on('connection', (socket) => {
|
|
352
|
+
const playerId = socket.id;
|
|
353
|
+
|
|
354
|
+
// Create player
|
|
355
|
+
const player = new Entity(playerId, 0, 0) as any;
|
|
356
|
+
player.isIt = false;
|
|
357
|
+
room.addPlayer(player);
|
|
358
|
+
|
|
359
|
+
// Register socket
|
|
360
|
+
room.getNetwork().registerSocket(playerId, socket);
|
|
361
|
+
|
|
362
|
+
// Send initial state
|
|
363
|
+
socket.emit('S_INIT', {
|
|
364
|
+
playerId,
|
|
365
|
+
...room.getSnapshot(),
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
// Handle inputs
|
|
369
|
+
socket.on('C_MOVE', (data) => {
|
|
370
|
+
room.queueInput(playerId, {
|
|
371
|
+
seq: data.seq,
|
|
372
|
+
type: 'move',
|
|
373
|
+
dir: data.dir,
|
|
374
|
+
timestamp: Date.now(),
|
|
375
|
+
});
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
socket.on('C_STOP', (data) => {
|
|
379
|
+
room.queueInput(playerId, {
|
|
380
|
+
seq: data.seq,
|
|
381
|
+
type: 'stop',
|
|
382
|
+
timestamp: Date.now(),
|
|
383
|
+
});
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
socket.on('disconnect', () => {
|
|
387
|
+
room.removePlayer(playerId);
|
|
388
|
+
room.getNetwork().unregisterSocket(playerId);
|
|
389
|
+
});
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
console.log(' Tag game server running on port 3000');
|
|
393
|
+
```
|
|
394
|
+
|
|
395
|
+
### Step 3: Create the Client
|
|
396
|
+
|
|
397
|
+
```typescript
|
|
398
|
+
// client/index.ts
|
|
399
|
+
import { io } from 'socket.io-client';
|
|
400
|
+
|
|
401
|
+
const socket = io('http://localhost:3000');
|
|
402
|
+
let seq = 0;
|
|
403
|
+
let playerId: string;
|
|
404
|
+
let entities = new Map();
|
|
405
|
+
|
|
406
|
+
// Receive initial state
|
|
407
|
+
socket.on('S_INIT', (data) => {
|
|
408
|
+
playerId = data.playerId;
|
|
409
|
+
data.entities.forEach((e) => entities.set(e.id, e));
|
|
410
|
+
render();
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
// Receive updates
|
|
414
|
+
socket.on('S_UPDATE', (data) => {
|
|
415
|
+
data.entities?.forEach((e) => entities.set(e.id, e));
|
|
416
|
+
data.deleted?.forEach((id) => entities.delete(id));
|
|
417
|
+
render();
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
// Game events
|
|
421
|
+
socket.on('TAGGED', (data) => {
|
|
422
|
+
console.log(`Player ${data.tagged} was tagged by ${data.tagger}!`);
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
// Send movement input
|
|
426
|
+
function move(dirX: number, dirY: number) {
|
|
427
|
+
socket.emit('C_MOVE', {
|
|
428
|
+
seq: ++seq,
|
|
429
|
+
dir: { x: dirX, y: dirY },
|
|
430
|
+
});
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function stop() {
|
|
434
|
+
socket.emit('C_STOP', { seq: ++seq });
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// Keyboard controls
|
|
438
|
+
document.addEventListener('keydown', (e) => {
|
|
439
|
+
switch (e.key) {
|
|
440
|
+
case 'w':
|
|
441
|
+
move(0, -1);
|
|
442
|
+
break;
|
|
443
|
+
case 's':
|
|
444
|
+
move(0, 1);
|
|
445
|
+
break;
|
|
446
|
+
case 'a':
|
|
447
|
+
move(-1, 0);
|
|
448
|
+
break;
|
|
449
|
+
case 'd':
|
|
450
|
+
move(1, 0);
|
|
451
|
+
break;
|
|
452
|
+
}
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
document.addEventListener('keyup', () => stop());
|
|
456
|
+
|
|
457
|
+
// Simple canvas rendering
|
|
458
|
+
function render() {
|
|
459
|
+
const canvas = document.getElementById('game') as HTMLCanvasElement;
|
|
460
|
+
const ctx = canvas.getContext('2d')!;
|
|
461
|
+
|
|
462
|
+
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
463
|
+
|
|
464
|
+
entities.forEach((entity, id) => {
|
|
465
|
+
// Draw player
|
|
466
|
+
ctx.fillStyle = entity.isIt ? 'red' : 'blue';
|
|
467
|
+
ctx.beginPath();
|
|
468
|
+
ctx.arc(entity.x * 0.5, entity.y * 0.5, 10, 0, Math.PI * 2);
|
|
469
|
+
ctx.fill();
|
|
470
|
+
|
|
471
|
+
// Highlight your player
|
|
472
|
+
if (id === playerId) {
|
|
473
|
+
ctx.strokeStyle = 'yellow';
|
|
474
|
+
ctx.lineWidth = 3;
|
|
475
|
+
ctx.stroke();
|
|
476
|
+
}
|
|
477
|
+
});
|
|
478
|
+
}
|
|
479
|
+
```
|
|
480
|
+
|
|
481
|
+
### Step 4: Run It!
|
|
482
|
+
|
|
483
|
+
```bash
|
|
484
|
+
# Terminal 1: Start server
|
|
485
|
+
node server/index.js
|
|
486
|
+
|
|
487
|
+
# Terminal 2: Start client dev server
|
|
488
|
+
npm run dev
|
|
489
|
+
|
|
490
|
+
# Open http://localhost:5173 in multiple browser tabs
|
|
491
|
+
```
|
|
492
|
+
|
|
493
|
+
**Congratulations! You've built your first multiplayer game!**
|
|
494
|
+
|
|
495
|
+
---
|
|
496
|
+
|
|
497
|
+
## Advanced Topics
|
|
498
|
+
|
|
499
|
+
### Custom Entity Types
|
|
500
|
+
|
|
501
|
+
Extend `Entity` to add game-specific properties:
|
|
502
|
+
|
|
503
|
+
```typescript
|
|
504
|
+
class Warrior extends Entity {
|
|
505
|
+
health = 100;
|
|
506
|
+
mana = 50;
|
|
507
|
+
inventory: Item[] = [];
|
|
508
|
+
|
|
509
|
+
takeDamage(amount: number) {
|
|
510
|
+
this.health -= amount;
|
|
511
|
+
this.markDirty(); // Important!
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
serialize() {
|
|
515
|
+
return {
|
|
516
|
+
...super.serialize(),
|
|
517
|
+
health: this.health,
|
|
518
|
+
mana: this.mana,
|
|
519
|
+
};
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
```
|
|
523
|
+
|
|
524
|
+
**Important:** Call `markDirty()` when you modify properties so they're broadcast!
|
|
525
|
+
|
|
526
|
+
### Collision Detection
|
|
527
|
+
|
|
528
|
+
Use the built-in AABB system:
|
|
529
|
+
|
|
530
|
+
```typescript
|
|
531
|
+
import { AABBCollision, AABB } from '@gamerstake/game-core';
|
|
532
|
+
|
|
533
|
+
const collision = new AABBCollision();
|
|
534
|
+
|
|
535
|
+
const a: AABB = { x: 10, y: 10, width: 50, height: 50 };
|
|
536
|
+
const b: AABB = { x: 40, y: 40, width: 50, height: 50 };
|
|
537
|
+
|
|
538
|
+
if (collision.check(a, b)) {
|
|
539
|
+
console.log('Collision detected!');
|
|
540
|
+
}
|
|
541
|
+
```
|
|
542
|
+
|
|
543
|
+
### Spatial Queries
|
|
544
|
+
|
|
545
|
+
Find entities near a point efficiently:
|
|
546
|
+
|
|
547
|
+
```typescript
|
|
548
|
+
onTick(room: Room, delta: number) {
|
|
549
|
+
const player = room.getRegistry().get('player-1');
|
|
550
|
+
|
|
551
|
+
// Find all entities within 200 units
|
|
552
|
+
const nearby = room.getGrid().getNearbyEntities(
|
|
553
|
+
player.x,
|
|
554
|
+
player.y,
|
|
555
|
+
200
|
|
556
|
+
);
|
|
557
|
+
|
|
558
|
+
nearby.forEach(entity => {
|
|
559
|
+
// Check for interactions
|
|
560
|
+
});
|
|
561
|
+
}
|
|
562
|
+
```
|
|
563
|
+
|
|
564
|
+
### Custom Commands
|
|
565
|
+
|
|
566
|
+
Define your own command types:
|
|
567
|
+
|
|
568
|
+
```typescript
|
|
569
|
+
// shared/commands.ts
|
|
570
|
+
interface AttackCommand extends Command {
|
|
571
|
+
type: 'attack';
|
|
572
|
+
targetId: string;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
// server/GameRules.ts
|
|
576
|
+
onCommand(room: Room, playerId: string, command: Command) {
|
|
577
|
+
if (command.type === 'attack') {
|
|
578
|
+
const cmd = command as AttackCommand;
|
|
579
|
+
const attacker = room.getRegistry().get(playerId);
|
|
580
|
+
const target = room.getRegistry().get(cmd.targetId);
|
|
581
|
+
|
|
582
|
+
if (attacker && target) {
|
|
583
|
+
// Process attack
|
|
584
|
+
target.health -= attacker.attackPower;
|
|
585
|
+
target.markDirty();
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
```
|
|
590
|
+
|
|
591
|
+
### State Persistence
|
|
592
|
+
|
|
593
|
+
Save and restore room state:
|
|
594
|
+
|
|
595
|
+
```typescript
|
|
596
|
+
// Save state
|
|
597
|
+
const snapshot = room.getSnapshot();
|
|
598
|
+
await db.saveGameState(roomId, snapshot);
|
|
599
|
+
|
|
600
|
+
// Load state
|
|
601
|
+
const savedState = await db.loadGameState(roomId);
|
|
602
|
+
savedState.entities.forEach((data) => {
|
|
603
|
+
const entity = new Entity(data.id, data.x, data.y);
|
|
604
|
+
// Restore other properties
|
|
605
|
+
room.spawnEntity(entity);
|
|
606
|
+
});
|
|
607
|
+
```
|
|
608
|
+
|
|
609
|
+
### Multiple Rooms
|
|
610
|
+
|
|
611
|
+
Create different room types:
|
|
612
|
+
|
|
613
|
+
```typescript
|
|
614
|
+
// Lobby room (persistent)
|
|
615
|
+
const lobby = gameServer.createRoom('lobby', new LobbyRules(), {
|
|
616
|
+
tickRate: 10, // Lower rate for lobby
|
|
617
|
+
});
|
|
618
|
+
|
|
619
|
+
// Match room (temporary)
|
|
620
|
+
function createMatch(players: string[]) {
|
|
621
|
+
const matchId = `match-${Date.now()}`;
|
|
622
|
+
const match = gameServer.createRoom(matchId, new BattleRules(), {
|
|
623
|
+
tickRate: 20,
|
|
624
|
+
});
|
|
625
|
+
|
|
626
|
+
players.forEach((playerId) => {
|
|
627
|
+
// Transfer player from lobby to match
|
|
628
|
+
});
|
|
629
|
+
|
|
630
|
+
return match;
|
|
631
|
+
}
|
|
632
|
+
```
|
|
633
|
+
|
|
634
|
+
### Visibility/Interest Management
|
|
635
|
+
|
|
636
|
+
Only send updates about nearby entities:
|
|
637
|
+
|
|
638
|
+
```typescript
|
|
639
|
+
onTick(room: Room, delta: number) {
|
|
640
|
+
const snapshot = room.getSnapshot();
|
|
641
|
+
|
|
642
|
+
// Send custom snapshots per player
|
|
643
|
+
room.getRegistry().forEach((player) => {
|
|
644
|
+
const nearby = room.getGrid().getNearbyEntities(
|
|
645
|
+
player.x,
|
|
646
|
+
player.y,
|
|
647
|
+
500 // Visibility range
|
|
648
|
+
);
|
|
649
|
+
|
|
650
|
+
const visibleEntities = snapshot.entities.filter(e =>
|
|
651
|
+
nearby.has(e.id) || e.id === player.id
|
|
652
|
+
);
|
|
653
|
+
|
|
654
|
+
room.sendTo(player.id, {
|
|
655
|
+
op: 'S_UPDATE',
|
|
656
|
+
entities: visibleEntities,
|
|
657
|
+
});
|
|
658
|
+
});
|
|
659
|
+
}
|
|
660
|
+
```
|
|
661
|
+
|
|
662
|
+
---
|
|
663
|
+
|
|
664
|
+
## Performance Optimization
|
|
665
|
+
|
|
666
|
+
### 1. Avoid Allocations in Hot Paths
|
|
667
|
+
|
|
668
|
+
**Bad:**
|
|
669
|
+
|
|
670
|
+
```typescript
|
|
671
|
+
onTick(room: Room, delta: number) {
|
|
672
|
+
const players = room.getRegistry().values(); // Allocates!
|
|
673
|
+
Array.from(players).forEach(...); // Allocates!
|
|
674
|
+
}
|
|
675
|
+
```
|
|
676
|
+
|
|
677
|
+
**Good:**
|
|
678
|
+
|
|
679
|
+
```typescript
|
|
680
|
+
onTick(room: Room, delta: number) {
|
|
681
|
+
room.getRegistry().forEach((player) => {
|
|
682
|
+
// Direct iteration, no allocation
|
|
683
|
+
});
|
|
684
|
+
}
|
|
685
|
+
```
|
|
686
|
+
|
|
687
|
+
### 2. Use Dirty Flags
|
|
688
|
+
|
|
689
|
+
Only process/broadcast changed entities:
|
|
690
|
+
|
|
691
|
+
```typescript
|
|
692
|
+
onTick(room: Room, delta: number) {
|
|
693
|
+
room.getRegistry().forEach((entity) => {
|
|
694
|
+
if (entity.dirty) {
|
|
695
|
+
// This entity changed, process it
|
|
696
|
+
}
|
|
697
|
+
});
|
|
698
|
+
}
|
|
699
|
+
```
|
|
700
|
+
|
|
701
|
+
### 3. Batch Operations
|
|
702
|
+
|
|
703
|
+
Process similar entities together:
|
|
704
|
+
|
|
705
|
+
```typescript
|
|
706
|
+
// Good: Process all projectiles at once
|
|
707
|
+
const projectiles = Array.from(room.getRegistry().values()).filter((e) => e.type === 'projectile');
|
|
708
|
+
|
|
709
|
+
projectiles.forEach((p) => p.updatePosition(delta));
|
|
710
|
+
```
|
|
711
|
+
|
|
712
|
+
### 4. Use Grid for Collision Detection
|
|
713
|
+
|
|
714
|
+
**Bad - O(n²):**
|
|
715
|
+
|
|
716
|
+
```typescript
|
|
717
|
+
const entities = Array.from(room.getRegistry().values());
|
|
718
|
+
for (const a of entities) {
|
|
719
|
+
for (const b of entities) {
|
|
720
|
+
if (checkCollision(a, b)) { ... }
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
```
|
|
724
|
+
|
|
725
|
+
**Good - O(n):**
|
|
726
|
+
|
|
727
|
+
```typescript
|
|
728
|
+
room.getRegistry().forEach((entity) => {
|
|
729
|
+
const nearby = room.getGrid().getNearbyEntities(
|
|
730
|
+
entity.x,
|
|
731
|
+
entity.y,
|
|
732
|
+
entity.collisionRadius * 2
|
|
733
|
+
);
|
|
734
|
+
|
|
735
|
+
nearby.forEach(other => {
|
|
736
|
+
if (checkCollision(entity, other)) { ... }
|
|
737
|
+
});
|
|
738
|
+
});
|
|
739
|
+
```
|
|
740
|
+
|
|
741
|
+
### 5. Monitor Tick Performance
|
|
742
|
+
|
|
743
|
+
```typescript
|
|
744
|
+
const metrics = gameServer.getMetrics();
|
|
745
|
+
metrics.rooms.forEach((room) => {
|
|
746
|
+
if (room.avgTickTime > 40) {
|
|
747
|
+
console.warn(`Room ${room.id} tick time too high: ${room.avgTickTime}ms`);
|
|
748
|
+
}
|
|
749
|
+
});
|
|
750
|
+
```
|
|
751
|
+
|
|
752
|
+
**Target:** Keep average tick time below 40ms (80% of 50ms budget)
|
|
753
|
+
|
|
754
|
+
---
|
|
755
|
+
|
|
756
|
+
## Common Patterns
|
|
757
|
+
|
|
758
|
+
### Pattern 1: Ability System
|
|
759
|
+
|
|
760
|
+
```typescript
|
|
761
|
+
interface Ability {
|
|
762
|
+
cooldown: number;
|
|
763
|
+
lastUsed: number;
|
|
764
|
+
execute(caster: Entity, target?: Entity): void;
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
class FireballAbility implements Ability {
|
|
768
|
+
cooldown = 5000; // 5 seconds
|
|
769
|
+
lastUsed = 0;
|
|
770
|
+
|
|
771
|
+
execute(caster: Entity, target?: Entity) {
|
|
772
|
+
const now = Date.now();
|
|
773
|
+
if (now - this.lastUsed < this.cooldown) {
|
|
774
|
+
return; // Still on cooldown
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
this.lastUsed = now;
|
|
778
|
+
|
|
779
|
+
// Create projectile
|
|
780
|
+
const projectile = new Entity(`fireball-${now}`, caster.x, caster.y);
|
|
781
|
+
// ... set velocity toward target
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
```
|
|
785
|
+
|
|
786
|
+
### Pattern 2: State Machine
|
|
787
|
+
|
|
788
|
+
```typescript
|
|
789
|
+
enum GameState {
|
|
790
|
+
WAITING,
|
|
791
|
+
COUNTDOWN,
|
|
792
|
+
PLAYING,
|
|
793
|
+
ENDED,
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
class MatchRules implements GameRules {
|
|
797
|
+
private state = GameState.WAITING;
|
|
798
|
+
private countdown = 0;
|
|
799
|
+
|
|
800
|
+
onTick(room: Room, delta: number) {
|
|
801
|
+
switch (this.state) {
|
|
802
|
+
case GameState.WAITING:
|
|
803
|
+
if (room.getRegistry().size >= 2) {
|
|
804
|
+
this.state = GameState.COUNTDOWN;
|
|
805
|
+
this.countdown = 5000;
|
|
806
|
+
}
|
|
807
|
+
break;
|
|
808
|
+
|
|
809
|
+
case GameState.COUNTDOWN:
|
|
810
|
+
this.countdown -= delta;
|
|
811
|
+
if (this.countdown <= 0) {
|
|
812
|
+
this.state = GameState.PLAYING;
|
|
813
|
+
room.broadcast({ op: 'GAME_START' });
|
|
814
|
+
}
|
|
815
|
+
break;
|
|
816
|
+
|
|
817
|
+
case GameState.PLAYING:
|
|
818
|
+
// Game logic here
|
|
819
|
+
if (this.checkWinCondition(room)) {
|
|
820
|
+
this.state = GameState.ENDED;
|
|
821
|
+
}
|
|
822
|
+
break;
|
|
823
|
+
|
|
824
|
+
case GameState.ENDED:
|
|
825
|
+
// Cleanup
|
|
826
|
+
break;
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
```
|
|
831
|
+
|
|
832
|
+
### Pattern 3: Event System
|
|
833
|
+
|
|
834
|
+
```typescript
|
|
835
|
+
type GameEvent = { type: 'player_died'; playerId: string } | { type: 'item_collected'; itemId: string; playerId: string } | { type: 'match_ended'; winner: string };
|
|
836
|
+
|
|
837
|
+
class EventBus {
|
|
838
|
+
private listeners = new Map<string, ((event: GameEvent) => void)[]>();
|
|
839
|
+
|
|
840
|
+
on(type: string, handler: (event: GameEvent) => void) {
|
|
841
|
+
if (!this.listeners.has(type)) {
|
|
842
|
+
this.listeners.set(type, []);
|
|
843
|
+
}
|
|
844
|
+
this.listeners.get(type)!.push(handler);
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
emit(event: GameEvent) {
|
|
848
|
+
const handlers = this.listeners.get(event.type) || [];
|
|
849
|
+
handlers.forEach((h) => h(event));
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
// Usage in GameRules
|
|
854
|
+
class MyRules implements GameRules {
|
|
855
|
+
private events = new EventBus();
|
|
856
|
+
|
|
857
|
+
constructor() {
|
|
858
|
+
this.events.on('player_died', (event) => {
|
|
859
|
+
// Award kill to attacker
|
|
860
|
+
// Respawn player
|
|
861
|
+
});
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
```
|
|
865
|
+
|
|
866
|
+
### Pattern 4: Component System (ECS-lite)
|
|
867
|
+
|
|
868
|
+
```typescript
|
|
869
|
+
interface Component {
|
|
870
|
+
update?(delta: number): void;
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
class HealthComponent implements Component {
|
|
874
|
+
constructor(
|
|
875
|
+
public max: number,
|
|
876
|
+
public current: number,
|
|
877
|
+
) {}
|
|
878
|
+
|
|
879
|
+
takeDamage(amount: number) {
|
|
880
|
+
this.current = Math.max(0, this.current - amount);
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
class GameObject extends Entity {
|
|
885
|
+
private components = new Map<string, Component>();
|
|
886
|
+
|
|
887
|
+
addComponent(name: string, component: Component) {
|
|
888
|
+
this.components.set(name, component);
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
getComponent<T>(name: string): T | undefined {
|
|
892
|
+
return this.components.get(name) as T;
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
update(delta: number) {
|
|
896
|
+
this.components.forEach((c) => c.update?.(delta));
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
```
|
|
900
|
+
|
|
901
|
+
---
|
|
902
|
+
|
|
903
|
+
## Troubleshooting
|
|
904
|
+
|
|
905
|
+
### Problem: Tick time too high (>50ms)
|
|
906
|
+
|
|
907
|
+
**Solutions:**
|
|
908
|
+
|
|
909
|
+
- Profile with `console.time()` to find slow code
|
|
910
|
+
- Use grid for spatial queries
|
|
911
|
+
- Reduce entity count
|
|
912
|
+
- Optimize collision detection
|
|
913
|
+
- Use dirty flags
|
|
914
|
+
|
|
915
|
+
### Problem: State desync between client/server
|
|
916
|
+
|
|
917
|
+
**Solutions:**
|
|
918
|
+
|
|
919
|
+
- Always trust the server
|
|
920
|
+
- Implement client-side prediction
|
|
921
|
+
- Send position corrections from server
|
|
922
|
+
- Add sequence numbers to commands
|
|
923
|
+
|
|
924
|
+
### Problem: Network lag
|
|
925
|
+
|
|
926
|
+
**Solutions:**
|
|
927
|
+
|
|
928
|
+
- Implement client-side prediction
|
|
929
|
+
- Use delta compression
|
|
930
|
+
- Reduce broadcast frequency for distant entities
|
|
931
|
+
- Add interest management
|
|
932
|
+
|
|
933
|
+
### Problem: Memory leaks
|
|
934
|
+
|
|
935
|
+
**Solutions:**
|
|
936
|
+
|
|
937
|
+
- Call `room.destroyEntity()` to remove entities
|
|
938
|
+
- Unregister sockets on disconnect
|
|
939
|
+
- Clear intervals/timeouts
|
|
940
|
+
- Destroy rooms when empty
|
|
941
|
+
|
|
942
|
+
---
|
|
943
|
+
|
|
944
|
+
## Best Practices
|
|
945
|
+
|
|
946
|
+
### DO
|
|
947
|
+
|
|
948
|
+
- **Keep tick time under 40ms** (80% of 50ms budget)
|
|
949
|
+
- **Use dirty flags** to minimize broadcasts
|
|
950
|
+
- **Validate all client inputs** (never trust the client!)
|
|
951
|
+
- **Use spatial grid** for collision/visibility queries
|
|
952
|
+
- **Log metrics** for monitoring
|
|
953
|
+
- **Implement reconnection logic** for clients
|
|
954
|
+
- **Version your protocol** for updates
|
|
955
|
+
- **Test with multiple clients** regularly
|
|
956
|
+
|
|
957
|
+
### DON'T
|
|
958
|
+
|
|
959
|
+
- **Don't allocate in hot paths** (onTick, etc.)
|
|
960
|
+
- **Don't trust client positions** (validate server-side)
|
|
961
|
+
- **Don't do O(n²) operations** every tick
|
|
962
|
+
- **Don't forget to call markDirty()** on entity changes
|
|
963
|
+
- **Don't broadcast to all players** if unnecessary
|
|
964
|
+
- **Don't block the tick loop** (use async wisely)
|
|
965
|
+
- **Don't forget error handling** (socket disconnects, etc.)
|
|
966
|
+
|
|
967
|
+
---
|
|
968
|
+
|
|
969
|
+
## Next Steps
|
|
970
|
+
|
|
971
|
+
Now that you understand game-core, you can:
|
|
972
|
+
|
|
973
|
+
1. **Build your game** - Start with the tag game example and expand
|
|
974
|
+
2. **Add a client UI** - Use Phaser, Three.js, or canvas
|
|
975
|
+
3. **Implement game mechanics** - Combat, inventory, progression
|
|
976
|
+
4. **Add persistence** - Save game state to database
|
|
977
|
+
5. **Deploy** - Docker, Kubernetes, cloud hosting
|
|
978
|
+
6. **Monitor** - Add logging, metrics, alerting
|
|
979
|
+
|
|
980
|
+
### Resources
|
|
981
|
+
|
|
982
|
+
- **Examples:** `/examples/` directory
|
|
983
|
+
- **Tests:** `/tests/` for usage patterns
|
|
984
|
+
- **API Docs:** See README.md
|
|
985
|
+
- **Architecture:** See RFC documents
|
|
986
|
+
|
|
987
|
+
### Need Help?
|
|
988
|
+
|
|
989
|
+
- Check the examples directory
|
|
990
|
+
- Read the test files for usage patterns
|
|
991
|
+
- Review the source code (it's well-documented!)
|
|
992
|
+
- Contact the GamerStake engineering team
|
|
993
|
+
|
|
994
|
+
---
|
|
995
|
+
|
|
996
|
+
**Happy game building!**
|