Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Server side game state #42

Merged
merged 28 commits into from
Oct 6, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
72f31db
FRONT: Send players input instead of sending positions
benjaminbours Sep 27, 2023
6d05961
FRONT: WIP, refactor inputs, collisions and movements
benjaminbours Sep 29, 2023
f6c7288
FRONT: Clean inputs data structure
benjaminbours Sep 29, 2023
3565690
Move physics logic from FRONT to CORE
benjaminbours Sep 29, 2023
8fb3962
Fix issue where velocity and position in X
benjaminbours Sep 29, 2023
26a12f4
FRONT: Reconciliate game state checked by the server
benjaminbours Sep 30, 2023
0454889
BACK: Store and update game state every 100ms.
benjaminbours Sep 30, 2023
2da08bb
FRONT: Add client side interpolation to smooth transition
benjaminbours Oct 1, 2023
39be38f
Split as much as possible the components with shader
benjaminbours Oct 2, 2023
6827db0
Remove unused code
benjaminbours Oct 2, 2023
e0b01b1
BACK: Process collision server side
benjaminbours Oct 2, 2023
80f0139
Fix bug with jump
benjaminbours Oct 2, 2023
18d6fa6
Restore graphics when levels are instantiated client side
benjaminbours Oct 2, 2023
0c2e4f9
Update project setup
benjaminbours Oct 2, 2023
a3181f8
FRONT: Receive initial game state from server
benjaminbours Oct 2, 2023
620bc96
CORE: Clean folder structure
benjaminbours Oct 2, 2023
59b2deb
Server send initial state with game start event
benjaminbours Oct 2, 2023
acfb4f6
Improve / extends GameState structure
benjaminbours Oct 2, 2023
620971a
Apply some optimization inside loops
benjaminbours Oct 2, 2023
807ebef
FRONT: Apply interpolation only to second player
benjaminbours Oct 2, 2023
d4b72b1
Add physic loop with fixed rate
benjaminbours Oct 5, 2023
264131a
Add time sync mechanism to synchronize client and server loop
benjaminbours Oct 5, 2023
74c3f66
FRONT: Fix bug with starting position of light
benjaminbours Oct 5, 2023
4148539
FRONT: Fix bug with light player position update
benjaminbours Oct 5, 2023
b0a3d7e
FRONT: Restore and fix other players interpolation
benjaminbours Oct 5, 2023
a513144
Update time sync mechanism to trigger only when the tab is focused
benjaminbours Oct 6, 2023
aedce65
Disable max frame skip security for now to allow
benjaminbours Oct 6, 2023
e07644e
BACK: Fix CI
benjaminbours Oct 6, 2023
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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