@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,233 @@
1
+ /**
2
+ * Manual Testing Server
3
+ *
4
+ * A simple multiplayer game server for manual testing of game-core.
5
+ * Players can move around a 2D world and see each other.
6
+ */
7
+
8
+ import { Server } from 'socket.io';
9
+ import {
10
+ GameServer,
11
+ Room,
12
+ Entity,
13
+ GameRules,
14
+ Command,
15
+ MoveCommand,
16
+ ActionCommand,
17
+ logger,
18
+ } from '../../dist/index.js';
19
+
20
+ // ============================================================================
21
+ // Simple Game Rules
22
+ // ============================================================================
23
+
24
+ class SimpleGameRules implements GameRules {
25
+ onRoomCreated(room: Room): void {
26
+ logger.info('Room created! Waiting for players...');
27
+
28
+ // Spawn some static objects (obstacles)
29
+ for (let i = 0; i < 5; i++) {
30
+ const obstacle = new Entity(`obstacle-${i}`,
31
+ Math.random() * 800 + 100,
32
+ Math.random() * 800 + 100
33
+ );
34
+ room.spawnEntity(obstacle);
35
+ }
36
+ }
37
+
38
+ onPlayerJoin(room: Room, player: Entity): void {
39
+ // Spawn player at random position
40
+ const x = Math.random() * 800 + 100;
41
+ const y = Math.random() * 800 + 100;
42
+ player.setPosition(x, y);
43
+
44
+ logger.info(`Player ${player.id} joined at (${x.toFixed(0)}, ${y.toFixed(0)})`);
45
+
46
+ // Broadcast to other players
47
+ room.broadcast({
48
+ op: 'PLAYER_JOINED',
49
+ playerId: player.id,
50
+ x: player.x,
51
+ y: player.y,
52
+ });
53
+ }
54
+
55
+ onPlayerLeave(room: Room, playerId: string): void {
56
+ logger.info(`Player ${playerId} left`);
57
+
58
+ room.broadcast({
59
+ op: 'PLAYER_LEFT',
60
+ playerId,
61
+ });
62
+ }
63
+
64
+ onTick(room: Room, delta: number): void {
65
+ // Update all entities with velocity
66
+ room.getRegistry().forEach((entity) => {
67
+ if (entity.vx !== 0 || entity.vy !== 0) {
68
+ entity.updatePosition(delta);
69
+
70
+ // Keep entities in bounds (0-1000)
71
+ if (entity.x < 0) entity.setPosition(0, entity.y);
72
+ if (entity.x > 1000) entity.setPosition(1000, entity.y);
73
+ if (entity.y < 0) entity.setPosition(entity.x, 0);
74
+ if (entity.y > 1000) entity.setPosition(entity.x, 1000);
75
+ }
76
+ });
77
+ }
78
+
79
+ onCommand(room: Room, playerId: string, command: Command): void {
80
+ const player = room.getRegistry().get(playerId);
81
+ if (!player) return;
82
+
83
+ if (command.type === 'move') {
84
+ const moveCmd = command as MoveCommand;
85
+ const speed = 200; // units per second
86
+
87
+ // Normalize direction
88
+ const len = Math.sqrt(moveCmd.dir.x ** 2 + moveCmd.dir.y ** 2);
89
+ if (len > 0) {
90
+ const nx = moveCmd.dir.x / len;
91
+ const ny = moveCmd.dir.y / len;
92
+ player.setVelocity(nx * speed, ny * speed);
93
+ }
94
+
95
+ logger.debug(`Player ${playerId} moving: (${moveCmd.dir.x}, ${moveCmd.dir.y})`);
96
+ }
97
+ else if (command.type === 'stop') {
98
+ player.setVelocity(0, 0);
99
+ logger.debug(`Player ${playerId} stopped`);
100
+ }
101
+ else if (command.type === 'action') {
102
+ logger.info(`Player ${playerId} performed action: ${JSON.stringify(command)}`);
103
+
104
+ // Example: broadcast action to nearby players
105
+ room.broadcast({
106
+ op: 'PLAYER_ACTION',
107
+ playerId,
108
+ action: (command as any).action,
109
+ });
110
+ }
111
+ }
112
+
113
+ shouldEndRoom(room: Room): boolean {
114
+ // Persistent world - never end
115
+ return false;
116
+ }
117
+ }
118
+
119
+ // ============================================================================
120
+ // Server Setup
121
+ // ============================================================================
122
+
123
+ const PORT = process.env.PORT ? parseInt(process.env.PORT) : 3000;
124
+
125
+ // Create Socket.io server
126
+ const io = new Server(PORT, {
127
+ cors: {
128
+ origin: '*', // For manual testing only!
129
+ },
130
+ });
131
+
132
+ // Create game server
133
+ const gameServer = new GameServer();
134
+ gameServer.setServer(io);
135
+
136
+ // Create a test room
137
+ const room = gameServer.createRoom('test-room', new SimpleGameRules(), {
138
+ tickRate: 20, // 20 TPS
139
+ cellSize: 512,
140
+ maxEntities: 100,
141
+ });
142
+
143
+ logger.info(`Game server started on port ${PORT}`);
144
+ logger.info(`Room: ${room.id}`);
145
+ logger.info(`Tick rate: 20 TPS`);
146
+ logger.info('');
147
+ logger.info('Connect with: node examples/simple-game/client.js');
148
+ logger.info('');
149
+
150
+ // ============================================================================
151
+ // Socket.io Connection Handling
152
+ // ============================================================================
153
+
154
+ io.on('connection', (socket) => {
155
+ const playerId = socket.id;
156
+ logger.info(`Socket connected: ${playerId}`);
157
+
158
+ // Create player entity
159
+ const player = new Entity(playerId, 0, 0);
160
+ room.addPlayer(player);
161
+
162
+ // Register socket for networking
163
+ room.getNetwork().registerSocket(playerId, socket);
164
+
165
+ // Send initial state to the new player
166
+ const snapshot = room.getSnapshot();
167
+ socket.emit('S_INIT', {
168
+ op: 'S_INIT',
169
+ playerId,
170
+ entities: snapshot.entities,
171
+ timestamp: Date.now(),
172
+ });
173
+
174
+ // Handle player inputs
175
+ socket.on('C_MOVE', (data) => {
176
+ room.queueInput(playerId, {
177
+ seq: data.seq,
178
+ type: 'move',
179
+ dir: data.dir,
180
+ timestamp: Date.now(),
181
+ } as MoveCommand);
182
+ });
183
+
184
+ socket.on('C_STOP', (data) => {
185
+ room.queueInput(playerId, {
186
+ seq: data.seq,
187
+ type: 'stop',
188
+ timestamp: Date.now(),
189
+ });
190
+ });
191
+
192
+ socket.on('C_ACTION', (data) => {
193
+ room.queueInput(playerId, {
194
+ seq: data.seq,
195
+ type: 'action',
196
+ action: data.action,
197
+ data: data.data,
198
+ timestamp: Date.now(),
199
+ } as ActionCommand);
200
+ });
201
+
202
+ socket.on('disconnect', () => {
203
+ logger.info(`Socket disconnected: ${playerId}`);
204
+ room.removePlayer(playerId);
205
+ room.getNetwork().unregisterSocket(playerId);
206
+ });
207
+ });
208
+
209
+ // ============================================================================
210
+ // Metrics Logging
211
+ // ============================================================================
212
+
213
+ setInterval(() => {
214
+ const metrics = gameServer.getMetrics();
215
+ const roomMetrics = metrics.rooms[0];
216
+
217
+ if (roomMetrics) {
218
+ logger.info(`Room: ${roomMetrics.playerCount} players, ${roomMetrics.entityCount} entities, ` +
219
+ `Avg tick: ${roomMetrics.avgTickTime?.toFixed(2) ?? 'N/A'}ms`);
220
+ }
221
+ }, 10000); // Log every 10 seconds
222
+
223
+ // ============================================================================
224
+ // Graceful Shutdown
225
+ // ============================================================================
226
+
227
+ process.on('SIGINT', () => {
228
+ logger.info('');
229
+ logger.info('Shutting down...');
230
+ gameServer.destroyRoom(room.id);
231
+ io.close();
232
+ process.exit(0);
233
+ });
package/jest.config.ts ADDED
@@ -0,0 +1,39 @@
1
+ import type { Config } from 'jest';
2
+
3
+ const config: Config = {
4
+ displayName: 'game-core',
5
+ preset: '../../jest.preset.js',
6
+ testEnvironment: 'node',
7
+ transform: {
8
+ '^.+\\.ts$': [
9
+ 'ts-jest',
10
+ {
11
+ tsconfig: '<rootDir>/tsconfig.json',
12
+ useESM: true,
13
+ },
14
+ ],
15
+ },
16
+ extensionsToTreatAsEsm: ['.ts'],
17
+ moduleNameMapper: {
18
+ '^(\\.{1,2}/.*)\\.js$': '$1',
19
+ },
20
+ testMatch: ['<rootDir>/tests/**/*.test.ts'],
21
+ collectCoverageFrom: [
22
+ 'src/**/*.ts',
23
+ '!src/**/*.d.ts',
24
+ '!src/index.ts',
25
+ '!src/types/**',
26
+ ],
27
+ coverageDirectory: 'coverage',
28
+ coverageReporters: ['text', 'lcov', 'html'],
29
+ coverageThreshold: {
30
+ global: {
31
+ branches: 80,
32
+ functions: 80,
33
+ lines: 80,
34
+ statements: 80,
35
+ },
36
+ },
37
+ };
38
+
39
+ export default config;
package/package.json ADDED
@@ -0,0 +1,54 @@
1
+ {
2
+ "name": "@gamerstake/game-core",
3
+ "version": "0.1.0",
4
+ "description": "Reusable multiplayer game engine for GamerStake platform",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "module": "dist/index.js",
8
+ "types": "dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "import": "./dist/index.js",
12
+ "types": "./dist/index.d.ts"
13
+ }
14
+ },
15
+ "scripts": {
16
+ "dev": "tsup --watch",
17
+ "build": "tsup",
18
+ "typecheck": "tsc --noEmit",
19
+ "test": "jest",
20
+ "test:watch": "jest --watch",
21
+ "test:coverage": "jest --coverage",
22
+ "lint": "eslint src/**/*.ts",
23
+ "clean": "rm -rf dist"
24
+ },
25
+ "keywords": [
26
+ "multiplayer",
27
+ "game-engine",
28
+ "websocket",
29
+ "real-time",
30
+ "networking"
31
+ ],
32
+ "author": "GamerStake",
33
+ "license": "UNLICENSED",
34
+ "dependencies": {
35
+ "socket.io": "^4.7.4",
36
+ "pino": "^9.0.0",
37
+ "zod": "^3.22.4"
38
+ },
39
+ "devDependencies": {
40
+ "@types/node": "^20.11.24",
41
+ "typescript": "^5.3.3",
42
+ "tsup": "^8.0.0",
43
+ "jest": "^29.7.0",
44
+ "@types/jest": "^29.5.12",
45
+ "ts-jest": "^29.1.2",
46
+ "socket.io-client": "^4.7.4",
47
+ "eslint": "^8.57.0",
48
+ "@typescript-eslint/eslint-plugin": "^6.21.0",
49
+ "@typescript-eslint/parser": "^6.21.0"
50
+ },
51
+ "engines": {
52
+ "node": ">=20.0.0"
53
+ }
54
+ }
@@ -0,0 +1,214 @@
1
+ /**
2
+ * Game Loop
3
+ *
4
+ * Fixed tick rate server loop with drift compensation.
5
+ * Processes inputs, updates state, broadcasts changes.
6
+ */
7
+
8
+ import { logger } from '../utils/Logger.js';
9
+ import type { LoopMetrics } from '../types/index.js';
10
+
11
+ /**
12
+ * Tick handler interface.
13
+ * Implement this to receive tick callbacks.
14
+ */
15
+ export interface TickHandler {
16
+ onTick(tickNumber: number, deltaMs: number): void;
17
+ }
18
+
19
+ /**
20
+ * High-precision game loop using setTimeout with drift compensation.
21
+ */
22
+ export class GameLoop {
23
+ private readonly tickRate: number;
24
+ private readonly tickMs: number;
25
+ private tickNumber = 0;
26
+ private lastTickTime = 0;
27
+ private running = false;
28
+ private timeout: NodeJS.Timeout | null = null;
29
+ private readonly handlers: TickHandler[] = [];
30
+
31
+ // Performance metrics
32
+ private tickTimes: number[] = [];
33
+ private readonly metricsWindowSize = 100;
34
+
35
+ /**
36
+ * Create a new game loop.
37
+ *
38
+ * @param tickRate - Ticks per second (default: 20)
39
+ */
40
+ constructor(tickRate = 20) {
41
+ this.tickRate = tickRate;
42
+ this.tickMs = 1000 / tickRate;
43
+ }
44
+
45
+ /**
46
+ * Register a tick handler.
47
+ */
48
+ addHandler(handler: TickHandler): void {
49
+ this.handlers.push(handler);
50
+ }
51
+
52
+ /**
53
+ * Remove a tick handler.
54
+ */
55
+ removeHandler(handler: TickHandler): void {
56
+ const index = this.handlers.indexOf(handler);
57
+ if (index !== -1) {
58
+ this.handlers.splice(index, 1);
59
+ }
60
+ }
61
+
62
+ /**
63
+ * Start the game loop.
64
+ */
65
+ start(): void {
66
+ if (this.running) {
67
+ logger.warn('Game loop already running');
68
+ return;
69
+ }
70
+
71
+ this.running = true;
72
+ this.lastTickTime = performance.now();
73
+ this.tickNumber = 0;
74
+
75
+ logger.info(
76
+ { tickRate: this.tickRate, tickMs: this.tickMs },
77
+ 'Game loop started',
78
+ );
79
+
80
+ this.scheduleNextTick();
81
+ }
82
+
83
+ /**
84
+ * Stop the game loop.
85
+ */
86
+ stop(): void {
87
+ this.running = false;
88
+
89
+ if (this.timeout) {
90
+ clearTimeout(this.timeout);
91
+ this.timeout = null;
92
+ }
93
+
94
+ logger.info({ totalTicks: this.tickNumber }, 'Game loop stopped');
95
+ }
96
+
97
+ /**
98
+ * Check if loop is running.
99
+ */
100
+ isRunning(): boolean {
101
+ return this.running;
102
+ }
103
+
104
+ /**
105
+ * Get current tick number.
106
+ */
107
+ getCurrentTick(): number {
108
+ return this.tickNumber;
109
+ }
110
+
111
+ /**
112
+ * Get tick rate (TPS).
113
+ */
114
+ getTickRate(): number {
115
+ return this.tickRate;
116
+ }
117
+
118
+ /**
119
+ * Get tick duration (ms).
120
+ */
121
+ getTickMs(): number {
122
+ return this.tickMs;
123
+ }
124
+
125
+ /**
126
+ * Get performance metrics.
127
+ */
128
+ getMetrics(): LoopMetrics {
129
+ if (this.tickTimes.length === 0) {
130
+ return {
131
+ avgTickTime: 0,
132
+ maxTickTime: 0,
133
+ minTickTime: 0,
134
+ ticksPerSecond: 0,
135
+ };
136
+ }
137
+
138
+ const sum = this.tickTimes.reduce((a, b) => a + b, 0);
139
+ const avg = sum / this.tickTimes.length;
140
+
141
+ return {
142
+ avgTickTime: avg,
143
+ maxTickTime: Math.max(...this.tickTimes),
144
+ minTickTime: Math.min(...this.tickTimes),
145
+ ticksPerSecond: 1000 / avg,
146
+ };
147
+ }
148
+
149
+ /**
150
+ * Schedule the next tick with drift compensation.
151
+ */
152
+ private scheduleNextTick(): void {
153
+ if (!this.running) return;
154
+
155
+ const now = performance.now();
156
+ const elapsed = now - this.lastTickTime;
157
+ const drift = elapsed - this.tickMs;
158
+
159
+ // Compensate for drift (but never go negative)
160
+ const nextDelay = Math.max(0, this.tickMs - drift);
161
+
162
+ this.timeout = setTimeout(() => this.tick(), nextDelay);
163
+ }
164
+
165
+ /**
166
+ * Execute one tick.
167
+ */
168
+ private tick(): void {
169
+ const tickStart = performance.now();
170
+ const deltaMs = tickStart - this.lastTickTime;
171
+ this.lastTickTime = tickStart;
172
+ this.tickNumber++;
173
+
174
+ try {
175
+ // Call all handlers
176
+ for (const handler of this.handlers) {
177
+ handler.onTick(this.tickNumber, deltaMs);
178
+ }
179
+ } catch (error) {
180
+ logger.error({ error, tick: this.tickNumber }, 'Error in tick handler');
181
+ }
182
+
183
+ // Record tick processing time
184
+ const tickDuration = performance.now() - tickStart;
185
+ this.recordTickTime(tickDuration);
186
+
187
+ // Warn if tick took too long (>80% of budget)
188
+ if (tickDuration > this.tickMs * 0.8) {
189
+ logger.warn(
190
+ {
191
+ tickNumber: this.tickNumber,
192
+ duration: tickDuration,
193
+ budget: this.tickMs,
194
+ },
195
+ 'Tick took too long',
196
+ );
197
+ }
198
+
199
+ // Schedule next tick
200
+ this.scheduleNextTick();
201
+ }
202
+
203
+ /**
204
+ * Record tick time for metrics.
205
+ */
206
+ private recordTickTime(duration: number): void {
207
+ this.tickTimes.push(duration);
208
+
209
+ // Keep only last N samples
210
+ if (this.tickTimes.length > this.metricsWindowSize) {
211
+ this.tickTimes.shift();
212
+ }
213
+ }
214
+ }
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Game Rules Interface
3
+ *
4
+ * Pluggable game logic interface.
5
+ * Implement this to create custom multiplayer games.
6
+ */
7
+
8
+ import type { Room } from './Room.js';
9
+ import type { Entity } from '../entities/Entity.js';
10
+ import type { Command } from '../input/Command.js';
11
+
12
+ /**
13
+ * Game-specific logic interface.
14
+ *
15
+ * This is the core abstraction that makes game-core reusable.
16
+ * All game-specific logic is implemented through this interface,
17
+ * keeping the engine generic.
18
+ *
19
+ * @example
20
+ * ```typescript
21
+ * class MyGameRules implements GameRules {
22
+ * onRoomCreated(room: Room): void {
23
+ * // Spawn initial entities
24
+ * }
25
+ *
26
+ * onPlayerJoin(room: Room, player: Entity): void {
27
+ * // Setup player
28
+ * }
29
+ *
30
+ * onTick(room: Room, delta: number): void {
31
+ * // Update game state
32
+ * }
33
+ *
34
+ * onCommand(room: Room, playerId: string, command: Command): void {
35
+ * // Handle player input
36
+ * }
37
+ *
38
+ * onPlayerLeave(room: Room, playerId: string): void {
39
+ * // Cleanup player
40
+ * }
41
+ *
42
+ * shouldEndRoom(room: Room): boolean {
43
+ * // For match-based games, return true when match ends
44
+ * // For persistent worlds, return false
45
+ * return false;
46
+ * }
47
+ * }
48
+ * ```
49
+ */
50
+ export interface GameRules<TEntity extends Entity = Entity> {
51
+ /**
52
+ * Called once when room is created.
53
+ * Use for initialization (spawn items, set boundaries, etc).
54
+ *
55
+ * @param room - The newly created room
56
+ */
57
+ onRoomCreated(room: Room<TEntity>): void;
58
+
59
+ /**
60
+ * Called when a player joins the room.
61
+ *
62
+ * @param room - The room
63
+ * @param player - The entity representing the player
64
+ */
65
+ onPlayerJoin(room: Room<TEntity>, player: TEntity): void;
66
+
67
+ /**
68
+ * Called when a player leaves the room.
69
+ *
70
+ * @param room - The room
71
+ * @param playerId - The ID of the leaving player
72
+ */
73
+ onPlayerLeave(room: Room<TEntity>, playerId: string): void;
74
+
75
+ /**
76
+ * Called every tick (20 TPS by default).
77
+ * Process inputs, update game state, check win conditions.
78
+ *
79
+ * @param room - The room
80
+ * @param delta - Time since last tick (milliseconds)
81
+ */
82
+ onTick(room: Room<TEntity>, delta: number): void;
83
+
84
+ /**
85
+ * Called when a player sends a command.
86
+ *
87
+ * @param room - The room
88
+ * @param playerId - The player who sent the command
89
+ * @param command - The input command from the player
90
+ */
91
+ onCommand(room: Room<TEntity>, playerId: string, command: Command): void;
92
+
93
+ /**
94
+ * Check if the room should be destroyed.
95
+ *
96
+ * For match-based games, return true when match ends.
97
+ * For persistent worlds, return false.
98
+ *
99
+ * @param room - The room
100
+ * @returns True if room should end
101
+ */
102
+ shouldEndRoom(room: Room<TEntity>): boolean;
103
+ }