Skip to content

Commit

Permalink
Merge pull request #42 from benjaminbours/server-side-game-state
Browse files Browse the repository at this point in the history
Server side game state
  • Loading branch information
benjaminbours authored Oct 6, 2023
2 parents c695267 + e07644e commit 69ba353
Show file tree
Hide file tree
Showing 49 changed files with 10,341 additions and 11,644 deletions.
4 changes: 2 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,13 @@ DOCKER_FILE_ENVIRONMENT := -f ./docker-compose-$(ENVIRONMENT).yml
# endif

start:
docker-compose -f ./docker-compose.yml $(DOCKER_FILE_ENVIRONMENT) config > docker-compose-final.yml
docker-compose -f ./docker-compose.yml $(DOCKER_FILE_ENVIRONMENT) up

build:
docker-compose -f ./docker-compose.yml $(DOCKER_FILE_ENVIRONMENT) build

build_workspace:
docker build --platform linux/amd64 -t boursbenjamin/composite-workspace .
docker buildx use mybuilder && docker buildx build --platform linux/amd64,linux/arm64 -t boursbenjamin/composite-workspace:latest --push .

push_workspace:
docker push boursbenjamin/composite-workspace:latest
Expand Down
12 changes: 0 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,18 +10,6 @@

### How to run the application

Start DB

`make start_db`

Start all the containers

`make start`

Give it some time for the DB to be ready then run

`make initial_db_setup`

To access API logs, use

`make display_api_logs`
1 change: 1 addition & 0 deletions TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
- Orchestrate efficiently the transition between the menu, the game, the state on the server, the connection, etc
- Fix responsive menu this last changes
- Ensure all click action on menu are on buttons element to maximize browser compatibility
- Fix bug where camera of other player have the same behavior as the first one while on interactive element

## Nice to have

Expand Down
Binary file added back/assets.glb
Binary file not shown.
5 changes: 4 additions & 1 deletion back/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
},
"dependencies": {
"@benjaminbours/composite-core": "^0.1.0-next.0",
"@injectit/threejs-nodejs-exporters": "^0.0.2",
"@nestjs/cache-manager": "^2.1.0",
"@nestjs/common": "^10.2.5",
"@nestjs/core": "^10.2.5",
Expand All @@ -37,7 +38,8 @@
"redis": "^4.6.9",
"reflect-metadata": "^0.1.13",
"rxjs": "^7.2.0",
"socket.io": "^4.7.2"
"socket.io": "^4.7.2",
"three": "^0.156.1"
},
"devDependencies": {
"@nestjs/cli": "^10.1.17",
Expand All @@ -47,6 +49,7 @@
"@types/jest": "^29.5.5",
"@types/node": "^20.7.0",
"@types/supertest": "^2.0.11",
"@types/three": "^0.156.0",
"@typescript-eslint/eslint-plugin": "^6.7.0",
"@typescript-eslint/parser": "^6.7.0",
"eslint": "^8.50.0",
Expand Down
18 changes: 18 additions & 0 deletions back/src/main.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,27 @@
// vendors
import { NestFactory } from '@nestjs/core';
// eslint-disable-next-line
const NodeThreeExporter = require('@injectit/threejs-nodejs-exporters');
import * as fs from 'fs';
import type { Mesh } from 'three';
// our libs
import { addToGeometries } from '@benjaminbours/composite-core';

import { AppModule } from './app.module';
import { RedisIoAdapter } from './redis-io.adapter';
import { ENVIRONMENT } from './environment';

async function bootstrap() {
// load 3d assets for physics calculation server side
const assetsFile = fs.readFileSync(`${process.cwd()}/assets.glb`);
const onParse = (object) => {
object.scene.children.forEach((mesh: Mesh) => {
addToGeometries(mesh);
});
};
const exporter = new NodeThreeExporter();
exporter.parse('glb', assetsFile, onParse);

const app = await NestFactory.create(AppModule);
// disable while cors is managed in load balancer
// app.enableCors({ origin: [ENVIRONMENT.CLIENT_URL] });
Expand Down
235 changes: 215 additions & 20 deletions back/src/socket/socket.gateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,23 @@ import {
} from '@nestjs/websockets';
import type { Server, Socket } from 'socket.io';
import { GameStatus, Level } from '@prisma/client';
import { Logger } from '@nestjs/common';
import { Scene } from 'three';
// our libs
import {
SocketEventType,
MatchMakingPayload,
Levels,
GamePositionPayload,
GamePlayerInputPayload,
Side,
SocketEvent,
GameState,
PositionLevel,
FLOOR,
RedisGameState,
TimeSyncPayload,
PhysicLoop,
applyInputs,
} from '@benjaminbours/composite-core';
// local
import { PrismaService } from '../prisma.service';
Expand All @@ -38,13 +49,18 @@ import {
// },
})
export class SocketGateway {
private gameLoopsRegistry: Record<string, NodeJS.Timeout> = {};
@WebSocketServer() server: Server;

constructor(
private prismaService: PrismaService,
private temporaryStorage: TemporaryStorageService,
) {}

emit = (roomName: string, event: SocketEvent) => {
this.server.to(roomName).emit(event[0], event[1]);
};

@SubscribeMessage(SocketEventType.CONNECTION)
async handleConnection(socket: Socket) {
if (socket.recovered) {
Expand All @@ -57,12 +73,12 @@ export class SocketGateway {
}

@SubscribeMessage(SocketEventType.MATCHMAKING_INFO)
async handleMatchMakingPayload(
async handleMatchMakingInfo(
@ConnectedSocket() socket: Socket,
@MessageBody() data: MatchMakingPayload,
) {
console.log('matchmaking info', socket.id, data);
const playerFound = await this.temporaryStorage.findMatch(data, 0);
const playerFound = await this.temporaryStorage.findMatchInQueue(data, 0);
if (!playerFound) {
console.log('no match, add to queue');
const player = new Player(
Expand All @@ -84,20 +100,65 @@ export class SocketGateway {
}
}

@SubscribeMessage(SocketEventType.GAME_POSITION)
async handleGamePosition(
@SubscribeMessage(SocketEventType.GAME_PLAYER_INPUT)
async handleGamePlayerInput(
@ConnectedSocket() socket: Socket,
@MessageBody() data: GamePlayerInputPayload,
) {
const player = await this.temporaryStorage.getPlayer(socket.id);
await this.temporaryStorage.addToGameInputsQueue(player.gameId, data);
}

// @SubscribeMessage(SocketEventType.GAME_ACTIVATE_ELEMENT)
// async handleGameActivateElement(
// @ConnectedSocket() socket: Socket,
// @MessageBody() data: GameActivateElementPayload,
// ) {
// const gameRoom = Array.from(socket.rooms)[1];
// socket.to(gameRoom).emit(SocketEventType.GAME_ACTIVATE_ELEMENT, data);
// }

// @SubscribeMessage(SocketEventType.GAME_DEACTIVATE_ELEMENT)
// async handleGameDeactivateElement(
// @ConnectedSocket() socket: Socket,
// @MessageBody() data: GameActivateElementPayload,
// ) {
// const gameRoom = Array.from(socket.rooms)[1];
// socket.to(gameRoom).emit(SocketEventType.GAME_DEACTIVATE_ELEMENT, data);
// }

@SubscribeMessage(SocketEventType.TIME_SYNC)
async handleTimeSync(
@ConnectedSocket() socket: Socket,
@MessageBody() data: GamePositionPayload,
@MessageBody() data: TimeSyncPayload,
) {
const gameRoom = Array.from(socket.rooms)[1];
socket.to(gameRoom).emit(SocketEventType.GAME_POSITION, data);
console.log('received time sync event', data);
const player = await this.temporaryStorage.getPlayer(socket.id);
const gameState = await this.temporaryStorage
.getGameState(player.gameId)
.then((redisState) => GameState.parseRedisGameState(redisState));
this.emit(socket.id, [
SocketEventType.TIME_SYNC,
{
...data,
serverGameTime: gameState.game_time,
},
]);
}

@SubscribeMessage(SocketEventType.DISCONNECT)
async handleDisconnect(@ConnectedSocket() socket: Socket) {
console.log('disconnect', socket.id);
// TODO: Investigate if in case of recovery session, I should better keep the data for a while
this.temporaryStorage.removePlayer(socket.id);
const player = await this.temporaryStorage.getPlayer(socket.id);
if (player.gameId) {
try {
clearTimeout(this.gameLoopsRegistry[`game:${player.gameId}`]);
} catch (error) {
Logger.error(error);
}
}
this.temporaryStorage.removePlayer(socket.id, player);
}

/**
Expand Down Expand Up @@ -125,23 +186,157 @@ export class SocketGateway {
}
})();

const [game] = await Promise.all([
// store persistent game data
this.prismaService.game.create({
data: {
level: dbLevel,
status: GameStatus.STARTED,
// store persistent game data
const game = await this.prismaService.game.create({
data: {
level: dbLevel,
status: GameStatus.STARTED,
},
});

const level = (() => {
switch (playerFoundInQueue.player.selectedLevel) {
case Levels.CRACK_THE_DOOR:
return new PositionLevel();
}
})();

// create initial game data
const initialGameState = new GameState(
[
{
position: {
x: level.startPosition.shadow.x,
y: level.startPosition.shadow.y,
},
velocity: {
x: 0,
y: 0,
},
},
}),
// update queue in temporary storage
this.temporaryStorage.createGame(playerFoundInQueue, playerArriving),
]);
{
position: {
x: level.startPosition.light.x,
y: level.startPosition.light.y,
},
velocity: {
x: 0,
y: 0,
},
},
],
level.state,
0,
0,
);

// store game state and update queue in temporary storage
await this.temporaryStorage.createGame(
playerFoundInQueue,
playerArriving,
game.id,
RedisGameState.parseGameState(initialGameState),
);
const roomName = String(game.id);
this.addSocketToRoom(playerFoundInQueue.socketId, roomName);
this.addSocketToRoom(playerArriving.socketId, roomName);
this.server.to(roomName).emit(SocketEventType.GAME_START);
this.emit(roomName, [
SocketEventType.GAME_START,
{ gameState: initialGameState },
]);
this.registerGameLoop(game.id, level);
}

registerGameLoop = (gameId: number, level: PositionLevel) => {
// TODO: The following variable declared here and accessible in the process
// input queue closure are potential memory leaks.
// Let's try to declare them only once somewhere else, or to update
// the game state if it should be stored per game and between iteration
const lastPlayersInput: (GamePlayerInputPayload | undefined)[] = [
undefined,
undefined,
];

const collidingScene = new Scene();
collidingScene.add(FLOOR, ...level.collidingElements);
collidingScene.updateMatrixWorld();

const TICK_RATE = 10;
// let tick = 0;
// let previous = hrtimeMs();
const tickLengthMs = 1000 / TICK_RATE;
const physicLoop = new PhysicLoop();

const networkUpdateLoop = () => {
const timerId = setTimeout(networkUpdateLoop, tickLengthMs);
this.gameLoopsRegistry[`game:${gameId}`] = timerId;
// const now = hrtimeMs();
// const delta = (now - previous) / 1000;
// console.log('delta', delta);
Promise.all([
this.temporaryStorage.getGameInputsQueue(gameId).then((inputs) =>
inputs.map((input) => {
const parts = input.split(':');
const player: Side = Number(parts[0]);
const inputs = parts[1]
.split(',')
.map((val) => (val === 'true' ? true : false));
const sequence = Number(parts[2]);
const time = Number(parts[3]);

const parsedInput: GamePlayerInputPayload = {
time,
inputs: { left: inputs[0], right: inputs[1], jump: inputs[2] },
sequence,
player,
};
return parsedInput;
}),
),
this.temporaryStorage
.getGameState(gameId)
.then((redisState) => GameState.parseRedisGameState(redisState)),
]).then(([inputsQueue, gameState]) => {
// console.log('inputs queue before', inputsQueue.length);
physicLoop.run(() => {
gameState.game_time++;
const inputsForTick = inputsQueue.filter(
({ sequence }) => sequence == gameState.game_time,
);
applyInputs(
lastPlayersInput,
inputsForTick,
collidingScene.children,
gameState,
// true,
);
// then we remove it from the list
for (let i = 0; i < inputsForTick.length; i++) {
const input = inputsForTick[i];
inputsQueue.splice(inputsQueue.indexOf(input), 1);
}
});
// emit updated game state to room
this.emit(String(gameId), [
SocketEventType.GAME_STATE_UPDATE,
{ gameState },
]);

// console.log('inputs queue after', inputsQueue.length);

// update state and inputs queue
this.temporaryStorage.updateGameStateAndInputsQueue(
gameId,
RedisGameState.parseGameState(gameState),
);

// previous = now;
});
};

networkUpdateLoop();
};

// TODO: As a room is equivalent to a game, lets make a proper
// room rotation (leave the last one and add to next one) while finishing
// a game and going into another one
Expand Down
Loading

0 comments on commit 69ba353

Please sign in to comment.