@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.
Files changed (48) hide show
  1. package/.eslintrc.json +22 -0
  2. package/.testing-guide-summary.md +261 -0
  3. package/DEVELOPER_GUIDE.md +996 -0
  4. package/MANUAL_TESTING.md +369 -0
  5. package/QUICK_START.md +368 -0
  6. package/README.md +379 -0
  7. package/TESTING_OVERVIEW.md +378 -0
  8. package/dist/index.d.ts +1266 -0
  9. package/dist/index.js +1632 -0
  10. package/dist/index.js.map +1 -0
  11. package/examples/simple-game/README.md +176 -0
  12. package/examples/simple-game/client.ts +201 -0
  13. package/examples/simple-game/package.json +14 -0
  14. package/examples/simple-game/server.ts +233 -0
  15. package/jest.config.ts +39 -0
  16. package/package.json +54 -0
  17. package/src/core/GameLoop.ts +214 -0
  18. package/src/core/GameRules.ts +103 -0
  19. package/src/core/GameServer.ts +200 -0
  20. package/src/core/Room.ts +368 -0
  21. package/src/entities/Entity.ts +118 -0
  22. package/src/entities/Registry.ts +161 -0
  23. package/src/index.ts +51 -0
  24. package/src/input/Command.ts +41 -0
  25. package/src/input/InputQueue.ts +130 -0
  26. package/src/network/Network.ts +112 -0
  27. package/src/network/Snapshot.ts +59 -0
  28. package/src/physics/AABB.ts +104 -0
  29. package/src/physics/Movement.ts +124 -0
  30. package/src/spatial/Grid.ts +202 -0
  31. package/src/types/index.ts +117 -0
  32. package/src/types/protocol.ts +161 -0
  33. package/src/utils/Logger.ts +112 -0
  34. package/src/utils/RingBuffer.ts +116 -0
  35. package/tests/AABB.test.ts +38 -0
  36. package/tests/Entity.test.ts +35 -0
  37. package/tests/GameLoop.test.ts +58 -0
  38. package/tests/GameServer.test.ts +64 -0
  39. package/tests/Grid.test.ts +28 -0
  40. package/tests/InputQueue.test.ts +42 -0
  41. package/tests/Movement.test.ts +37 -0
  42. package/tests/Network.test.ts +39 -0
  43. package/tests/Registry.test.ts +36 -0
  44. package/tests/RingBuffer.test.ts +38 -0
  45. package/tests/Room.test.ts +80 -0
  46. package/tests/Snapshot.test.ts +19 -0
  47. package/tsconfig.json +28 -0
  48. package/tsup.config.ts +14 -0
@@ -0,0 +1,200 @@
1
+ /**
2
+ * Game Server
3
+ *
4
+ * Multi-room game server.
5
+ * Manages room lifecycle and routing.
6
+ */
7
+
8
+ import type { Server } from 'socket.io';
9
+ import { Room } from './Room.js';
10
+ import type { GameRules } from './GameRules.js';
11
+ import type { Entity } from '../entities/Entity.js';
12
+ import { logger } from '../utils/Logger.js';
13
+ import type { RoomConfig, ServerMetrics } from '../types/index.js';
14
+
15
+ /**
16
+ * Multi-room game server.
17
+ *
18
+ * Orchestrates multiple game rooms and provides routing
19
+ * for Socket.io connections.
20
+ *
21
+ * @example
22
+ * ```typescript
23
+ * const server = new GameServer();
24
+ * server.setServer(io);
25
+ *
26
+ * const room = server.createRoom('room-1', new MyGameRules());
27
+ *
28
+ * // Later...
29
+ * server.destroyRoom('room-1');
30
+ * ```
31
+ */
32
+ export class GameServer {
33
+ private readonly rooms = new Map<string, Room>();
34
+ private io: Server | null = null;
35
+
36
+ /**
37
+ * Set the Socket.io server instance.
38
+ *
39
+ * This should be called before creating rooms to enable
40
+ * network functionality.
41
+ */
42
+ setServer(io: Server): void {
43
+ this.io = io;
44
+
45
+ // Set server on all existing rooms
46
+ for (const room of this.rooms.values()) {
47
+ room.getNetwork().setServer(io);
48
+ }
49
+
50
+ logger.info('Socket.io server set');
51
+ }
52
+
53
+ /**
54
+ * Create a new room.
55
+ *
56
+ * @param id - Unique room identifier
57
+ * @param rules - Game-specific logic
58
+ * @param config - Room configuration
59
+ * @returns The created room
60
+ * @throws If room with same ID already exists
61
+ */
62
+ createRoom<TEntity extends Entity = Entity>(
63
+ id: string,
64
+ rules: GameRules<TEntity>,
65
+ config?: RoomConfig,
66
+ ): Room<TEntity> {
67
+ if (this.rooms.has(id)) {
68
+ throw new Error(`Room ${id} already exists`);
69
+ }
70
+
71
+ const room = new Room(id, rules, config);
72
+
73
+ // Set network server if available
74
+ if (this.io) {
75
+ room.getNetwork().setServer(this.io);
76
+ }
77
+
78
+ this.rooms.set(id, room as Room);
79
+ room.start();
80
+
81
+ logger.info({ roomId: id }, 'Room created and started');
82
+
83
+ return room;
84
+ }
85
+
86
+ /**
87
+ * Destroy a room.
88
+ *
89
+ * Stops the room's tick loop and removes it from the server.
90
+ *
91
+ * @param id - Room identifier
92
+ * @returns True if room was destroyed, false if not found
93
+ */
94
+ destroyRoom(id: string): boolean {
95
+ const room = this.rooms.get(id);
96
+ if (!room) {
97
+ logger.warn({ roomId: id }, 'Cannot destroy - room not found');
98
+ return false;
99
+ }
100
+
101
+ room.stop();
102
+ this.rooms.delete(id);
103
+
104
+ logger.info({ roomId: id }, 'Room destroyed');
105
+
106
+ return true;
107
+ }
108
+
109
+ /**
110
+ * Get a room by ID.
111
+ */
112
+ getRoom(id: string): Room | undefined {
113
+ return this.rooms.get(id);
114
+ }
115
+
116
+ /**
117
+ * Get a room with specific entity type.
118
+ *
119
+ * @param id - Room identifier
120
+ * @returns Room cast to specific entity type, or undefined
121
+ */
122
+ getRoomAs<TEntity extends Entity>(id: string): Room<TEntity> | undefined {
123
+ return this.rooms.get(id) as Room<TEntity> | undefined;
124
+ }
125
+
126
+ /**
127
+ * Check if room exists.
128
+ */
129
+ hasRoom(id: string): boolean {
130
+ return this.rooms.has(id);
131
+ }
132
+
133
+ /**
134
+ * Get all rooms.
135
+ */
136
+ getRooms(): Map<string, Room> {
137
+ return this.rooms;
138
+ }
139
+
140
+ /**
141
+ * Get room count.
142
+ */
143
+ getRoomCount(): number {
144
+ return this.rooms.size;
145
+ }
146
+
147
+ /**
148
+ * Get total player count across all rooms.
149
+ */
150
+ getTotalPlayerCount(): number {
151
+ let total = 0;
152
+ for (const room of this.rooms.values()) {
153
+ total += room.getPlayers().size;
154
+ }
155
+ return total;
156
+ }
157
+
158
+ /**
159
+ * Get server health metrics.
160
+ */
161
+ getMetrics(): ServerMetrics {
162
+ return {
163
+ roomCount: this.rooms.size,
164
+ totalPlayers: this.getTotalPlayerCount(),
165
+ rooms: Array.from(this.rooms.values()).map((room) => ({
166
+ id: room.id,
167
+ playerCount: room.getPlayers().size,
168
+ entityCount: room.getRegistry().size(),
169
+ tickCount: room.getTickCount(),
170
+ isRunning: room.isRunning(),
171
+ avgTickTime: room.getMetrics().avgTickTime,
172
+ ticksPerSecond: room.getMetrics().ticksPerSecond,
173
+ })),
174
+ };
175
+ }
176
+
177
+ /**
178
+ * Stop all rooms.
179
+ */
180
+ stopAll(): void {
181
+ logger.info({ roomCount: this.rooms.size }, 'Stopping all rooms');
182
+
183
+ for (const room of this.rooms.values()) {
184
+ room.stop();
185
+ }
186
+ }
187
+
188
+ /**
189
+ * Destroy all rooms.
190
+ */
191
+ destroyAll(): void {
192
+ logger.info({ roomCount: this.rooms.size }, 'Destroying all rooms');
193
+
194
+ for (const room of this.rooms.values()) {
195
+ room.stop();
196
+ }
197
+
198
+ this.rooms.clear();
199
+ }
200
+ }
@@ -0,0 +1,368 @@
1
+ /**
2
+ * Room
3
+ *
4
+ * A single game room/match instance.
5
+ * Manages entities, tick loop, and networking.
6
+ */
7
+
8
+ import { GameLoop, type TickHandler } from './GameLoop.js';
9
+ import type { GameRules } from './GameRules.js';
10
+ import { Entity } from '../entities/Entity.js';
11
+ import { Registry } from '../entities/Registry.js';
12
+ import { Grid } from '../spatial/Grid.js';
13
+ import { InputQueue } from '../input/InputQueue.js';
14
+ import { Network } from '../network/Network.js';
15
+ import { Snapshot } from '../network/Snapshot.js';
16
+ import { logger } from '../utils/Logger.js';
17
+ import type {
18
+ RoomConfig,
19
+ NetworkEvent,
20
+ StateSnapshot,
21
+ } from '../types/index.js';
22
+ import type { Command } from '../input/Command.js';
23
+
24
+ /**
25
+ * A single game room/match.
26
+ *
27
+ * Orchestrates all game systems: tick loop, entities, spatial partitioning,
28
+ * input processing, and network synchronization.
29
+ *
30
+ * @example
31
+ * ```typescript
32
+ * const room = new Room('room-1', new MyGameRules(), {
33
+ * tickRate: 20,
34
+ * cellSize: 512,
35
+ * });
36
+ *
37
+ * room.start();
38
+ *
39
+ * // Add players
40
+ * const player = new Entity('player-1', 100, 100);
41
+ * room.addPlayer(player);
42
+ * ```
43
+ */
44
+ export class Room<TEntity extends Entity = Entity> implements TickHandler {
45
+ /** Room identifier */
46
+ readonly id: string;
47
+
48
+ /** Game-specific logic */
49
+ readonly rules: GameRules<TEntity>;
50
+
51
+ // Core systems
52
+ private readonly registry: Registry<TEntity>;
53
+ private readonly grid: Grid;
54
+ private readonly loop: GameLoop;
55
+ private readonly inputQueue: InputQueue;
56
+ private readonly network: Network;
57
+
58
+ // State
59
+ private readonly players = new Map<string, TEntity>();
60
+ private tickCount = 0;
61
+ private startTime = 0;
62
+ private readonly config: Required<RoomConfig>;
63
+
64
+ /**
65
+ * Create a new room.
66
+ *
67
+ * @param id - Unique room identifier
68
+ * @param rules - Game-specific logic
69
+ * @param config - Room configuration
70
+ */
71
+ constructor(id: string, rules: GameRules<TEntity>, config?: RoomConfig) {
72
+ this.id = id;
73
+ this.rules = rules;
74
+
75
+ // Apply defaults
76
+ this.config = {
77
+ tickRate: config?.tickRate ?? 20,
78
+ cellSize: config?.cellSize ?? 512,
79
+ maxInputQueueSize: config?.maxInputQueueSize ?? 100,
80
+ maxEntities: config?.maxEntities ?? 1000,
81
+ visibilityRange: config?.visibilityRange ?? 1,
82
+ };
83
+
84
+ // Initialize systems
85
+ this.registry = new Registry<TEntity>();
86
+ this.grid = new Grid(this.config.cellSize);
87
+ this.loop = new GameLoop(this.config.tickRate);
88
+ this.inputQueue = new InputQueue(this.config.maxInputQueueSize);
89
+ this.network = new Network();
90
+
91
+ // Register tick handler
92
+ this.loop.addHandler(this);
93
+
94
+ logger.info({ roomId: id, config: this.config }, 'Room created');
95
+ }
96
+
97
+ /**
98
+ * Start the room (begins tick loop).
99
+ */
100
+ start(): void {
101
+ this.startTime = Date.now();
102
+
103
+ try {
104
+ this.rules.onRoomCreated(this);
105
+ } catch (error) {
106
+ logger.error({ error, roomId: this.id }, 'Error in onRoomCreated');
107
+ }
108
+
109
+ this.loop.start();
110
+ logger.info({ roomId: this.id }, 'Room started');
111
+ }
112
+
113
+ /**
114
+ * Stop the room (ends tick loop).
115
+ */
116
+ stop(): void {
117
+ this.loop.stop();
118
+ logger.info(
119
+ { roomId: this.id, totalTicks: this.tickCount },
120
+ 'Room stopped',
121
+ );
122
+ }
123
+
124
+ /**
125
+ * Add a player to the room.
126
+ */
127
+ addPlayer(player: TEntity): void {
128
+ if (this.players.has(player.id)) {
129
+ logger.warn({ playerId: player.id }, 'Player already in room');
130
+ return;
131
+ }
132
+
133
+ this.players.set(player.id, player);
134
+ this.registry.add(player);
135
+ this.grid.addEntity(player.id, player.x, player.y);
136
+
137
+ try {
138
+ this.rules.onPlayerJoin(this, player);
139
+ } catch (error) {
140
+ logger.error({ error, playerId: player.id }, 'Error in onPlayerJoin');
141
+ }
142
+
143
+ logger.info({ roomId: this.id, playerId: player.id }, 'Player joined');
144
+ }
145
+
146
+ /**
147
+ * Remove a player from the room.
148
+ */
149
+ removePlayer(playerId: string): void {
150
+ const player = this.players.get(playerId);
151
+ if (!player) return;
152
+
153
+ this.players.delete(playerId);
154
+ this.registry.remove(playerId);
155
+ this.grid.removeEntity(playerId);
156
+ this.inputQueue.remove(playerId);
157
+
158
+ try {
159
+ this.rules.onPlayerLeave(this, playerId);
160
+ } catch (error) {
161
+ logger.error({ error, playerId }, 'Error in onPlayerLeave');
162
+ }
163
+
164
+ logger.info({ roomId: this.id, playerId }, 'Player left');
165
+ }
166
+
167
+ /**
168
+ * Spawn a non-player entity.
169
+ */
170
+ spawnEntity(entity: TEntity): void {
171
+ if (this.registry.size() >= this.config.maxEntities) {
172
+ logger.warn({ roomId: this.id }, 'Max entities reached, cannot spawn');
173
+ return;
174
+ }
175
+
176
+ this.registry.add(entity);
177
+ this.grid.addEntity(entity.id, entity.x, entity.y);
178
+
179
+ logger.debug({ entityId: entity.id }, 'Entity spawned');
180
+ }
181
+
182
+ /**
183
+ * Destroy an entity.
184
+ */
185
+ destroyEntity(entityId: string): void {
186
+ const entity = this.registry.get(entityId);
187
+ if (!entity) return;
188
+
189
+ this.registry.remove(entityId);
190
+ this.grid.removeEntity(entityId);
191
+
192
+ logger.debug({ entityId }, 'Entity destroyed');
193
+ }
194
+
195
+ /**
196
+ * Queue a player input.
197
+ */
198
+ queueInput(playerId: string, command: Command): void {
199
+ if (!this.players.has(playerId)) {
200
+ logger.warn({ playerId }, 'Input from non-player');
201
+ return;
202
+ }
203
+
204
+ this.inputQueue.push(playerId, command);
205
+ }
206
+
207
+ /**
208
+ * Broadcast event to all players.
209
+ */
210
+ broadcast(event: NetworkEvent): void {
211
+ this.network.broadcastToRoom(this.id, event);
212
+ }
213
+
214
+ /**
215
+ * Send event to specific player.
216
+ */
217
+ sendTo(playerId: string, event: NetworkEvent): void {
218
+ this.network.sendTo(playerId, event);
219
+ }
220
+
221
+ /**
222
+ * Get full state snapshot.
223
+ */
224
+ getSnapshot(): StateSnapshot {
225
+ return Snapshot.create(this.tickCount, this.registry.getAll());
226
+ }
227
+
228
+ /**
229
+ * Get delta snapshot (only dirty entities).
230
+ */
231
+ getDeltaSnapshot(): StateSnapshot {
232
+ return Snapshot.createDelta(
233
+ this.tickCount,
234
+ this.registry.getDirtyEntities(),
235
+ );
236
+ }
237
+
238
+ /**
239
+ * Main tick function (called by GameLoop).
240
+ */
241
+ onTick(tickNumber: number, deltaMs: number): void {
242
+ this.tickCount = tickNumber;
243
+
244
+ // Process all queued inputs
245
+ for (const [playerId] of this.players) {
246
+ const inputs = this.inputQueue.drain(playerId);
247
+ for (const input of inputs) {
248
+ try {
249
+ this.rules.onCommand(this, playerId, input);
250
+ } catch (error) {
251
+ logger.error(
252
+ { error, playerId, command: input.type },
253
+ 'Error in onCommand',
254
+ );
255
+ }
256
+ }
257
+ }
258
+
259
+ // Run game logic
260
+ try {
261
+ this.rules.onTick(this, deltaMs);
262
+ } catch (error) {
263
+ logger.error({ error, tick: tickNumber }, 'Error in onTick');
264
+ }
265
+
266
+ // Broadcast dirty entities
267
+ const dirtyEntities = this.registry.getDirtyEntities();
268
+ if (dirtyEntities.length > 0) {
269
+ const delta = Snapshot.createDelta(this.tickCount, dirtyEntities);
270
+ this.broadcast({
271
+ op: 'S_UPDATE',
272
+ ...delta,
273
+ });
274
+ }
275
+
276
+ // Check if room should end
277
+ try {
278
+ if (this.rules.shouldEndRoom(this)) {
279
+ logger.info({ roomId: this.id }, 'Room ending per game rules');
280
+ this.stop();
281
+ }
282
+ } catch (error) {
283
+ logger.error({ error }, 'Error in shouldEndRoom');
284
+ }
285
+ }
286
+
287
+ // Getters
288
+
289
+ /**
290
+ * Get all players.
291
+ */
292
+ getPlayers(): Map<string, TEntity> {
293
+ return this.players;
294
+ }
295
+
296
+ /**
297
+ * Get entity registry.
298
+ */
299
+ getRegistry(): Registry<TEntity> {
300
+ return this.registry;
301
+ }
302
+
303
+ /**
304
+ * Get spatial grid.
305
+ */
306
+ getGrid(): Grid {
307
+ return this.grid;
308
+ }
309
+
310
+ /**
311
+ * Get network layer.
312
+ */
313
+ getNetwork(): Network {
314
+ return this.network;
315
+ }
316
+
317
+ /**
318
+ * Get input queue.
319
+ */
320
+ getInputQueue(): InputQueue {
321
+ return this.inputQueue;
322
+ }
323
+
324
+ /**
325
+ * Get current tick number.
326
+ */
327
+ getTickCount(): number {
328
+ return this.tickCount;
329
+ }
330
+
331
+ /**
332
+ * Get uptime in milliseconds.
333
+ */
334
+ getUptime(): number {
335
+ return Date.now() - this.startTime;
336
+ }
337
+
338
+ /**
339
+ * Check if room is running.
340
+ */
341
+ isRunning(): boolean {
342
+ return this.loop.isRunning();
343
+ }
344
+
345
+ /**
346
+ * Get room configuration.
347
+ */
348
+ getConfig(): Required<RoomConfig> {
349
+ return { ...this.config };
350
+ }
351
+
352
+ /**
353
+ * Get room metrics.
354
+ */
355
+ getMetrics() {
356
+ const loopMetrics = this.loop.getMetrics();
357
+ return {
358
+ roomId: this.id,
359
+ tickCount: this.tickCount,
360
+ uptime: this.getUptime(),
361
+ playerCount: this.players.size,
362
+ entityCount: this.registry.size(),
363
+ cellCount: this.grid.getCellCount(),
364
+ queuedInputs: this.inputQueue.getTotalSize(),
365
+ ...loopMetrics,
366
+ };
367
+ }
368
+ }
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Entity System
3
+ *
4
+ * Base entity class for all game objects.
5
+ */
6
+
7
+ /**
8
+ * Base entity class.
9
+ * All game objects (players, NPCs, items) extend this.
10
+ *
11
+ * Includes position, velocity, and dirty flag for
12
+ * efficient network synchronization.
13
+ */
14
+ export class Entity {
15
+ /** Unique entity identifier */
16
+ readonly id: string;
17
+
18
+ /** World X position */
19
+ x: number;
20
+
21
+ /** World Y position */
22
+ y: number;
23
+
24
+ /** Velocity X (units/second) */
25
+ vx: number;
26
+
27
+ /** Velocity Y (units/second) */
28
+ vy: number;
29
+
30
+ /** Dirty flag - entity needs to be broadcast */
31
+ dirty: boolean;
32
+
33
+ /** Last update timestamp */
34
+ lastUpdate: number;
35
+
36
+ /**
37
+ * Create a new entity.
38
+ *
39
+ * @param id - Unique identifier
40
+ * @param x - Initial X position
41
+ * @param y - Initial Y position
42
+ */
43
+ constructor(id: string, x: number, y: number) {
44
+ this.id = id;
45
+ this.x = x;
46
+ this.y = y;
47
+ this.vx = 0;
48
+ this.vy = 0;
49
+ this.dirty = true; // New entities need to be broadcast
50
+ this.lastUpdate = Date.now();
51
+ }
52
+
53
+ /**
54
+ * Update position based on velocity and delta time.
55
+ *
56
+ * @param deltaMs - Time since last update (milliseconds)
57
+ */
58
+ updatePosition(deltaMs: number): void {
59
+ if (this.vx !== 0 || this.vy !== 0) {
60
+ const deltaSec = deltaMs / 1000;
61
+ this.x += this.vx * deltaSec;
62
+ this.y += this.vy * deltaSec;
63
+ this.dirty = true;
64
+ this.lastUpdate = Date.now();
65
+ }
66
+ }
67
+
68
+ /**
69
+ * Set velocity.
70
+ */
71
+ setVelocity(vx: number, vy: number): void {
72
+ if (this.vx !== vx || this.vy !== vy) {
73
+ this.vx = vx;
74
+ this.vy = vy;
75
+ this.dirty = true;
76
+ this.lastUpdate = Date.now();
77
+ }
78
+ }
79
+
80
+ /**
81
+ * Teleport to position (no velocity integration).
82
+ */
83
+ setPosition(x: number, y: number): void {
84
+ if (this.x !== x || this.y !== y) {
85
+ this.x = x;
86
+ this.y = y;
87
+ this.dirty = true;
88
+ this.lastUpdate = Date.now();
89
+ }
90
+ }
91
+
92
+ /**
93
+ * Mark entity as clean (after broadcast).
94
+ */
95
+ markClean(): void {
96
+ this.dirty = false;
97
+ }
98
+
99
+ /**
100
+ * Mark entity as dirty (needs broadcast).
101
+ */
102
+ markDirty(): void {
103
+ this.dirty = true;
104
+ }
105
+
106
+ /**
107
+ * Get entity as plain object for serialization.
108
+ */
109
+ toJSON(): Record<string, unknown> {
110
+ return {
111
+ id: this.id,
112
+ x: this.x,
113
+ y: this.y,
114
+ vx: this.vx,
115
+ vy: this.vy,
116
+ };
117
+ }
118
+ }