Context
I’m having trouble figuring out exactly how to implement server reconciliation in my game, which uses JavaScript, Node.js, and WebSockets. I’m creating an Agar.io-type game where the player is a circle that controls their movement with their mouse. When the player moves their mouse, it calculates the direction they are moving in and applies that value to the player on each frame.
TL;DR / ESSENTIAL QUESTIONS
My current implementation of server reconciliation is creating a choppiness/jittering effect on the player’s screen, so I’m doing something wrong.
I’m wondering how to fix the disparity between the number of frames the server runs and the number of frames the client runs — the client runs more times than the server, which I track with a variable. This discrepancy seems to be causing issues with reconciliation — how would a loop running at the same rate give different values on the server vs. the client?
Additionally, I’m wondering if there are any overarching ideas on how I can more effectively implement server reconciliation in my game. The examples I’ve seen don’t fit my exact scenario as several factors influence the exact position the player is in at a given time. I’m tracking the number of mouse movements the player makes the how many frames they move in that direction for, and I don’t see any other way to do it, but are there any other ideas? Especially because I’m not sure how to allow the server to be authoritative while allowing client prediction (because of the difference between how many frames run according to the server and according to the player).
Problem
While I understand the concept of server reconciliation, I’m having a tough time implementing it in my game as there are several factors that play into the player’s position at any given moment: their current X/Y positions, speed, direction they are moving in (determined by the mouse’s movements), and their velocity (velocity occurs when the players bump into each other).
When I first tried implementing server reconciliation, I just tracked the user’s mouse movements and made the server process those events. However, I realized that this doesn’t really track the player’s position at all because what matters is how many frames the user runs while going in the specific direction the mouse dictates. So, I decided to count the number of frames that run with each mouse movement on the client side. Here is just the game loop code that runs on each frame and the incrementing of the number of frames:
const fps = 66.67;
const msPerFrame = 1000 / fps;
function gameLoop() {
window.requestAnimationFrame(gameLoop);
const msNow = window.performance.now();
const msPassed = msNow - msPrev;
if (msPassed < msPerFrame) return; //limit client-side frame rate as different screens can have different rates
framesWithCurrentDirection++; // add 1 to frames that have run with current direction player is moving in
...
Then, when the mouse moves, it adds the player’s current position information to an array along with the number of frames that ran while the player was moving in that direction:
$(canvas).on('mousemove', (event) => {
...
sequenceNumber++; //keep track of sequence number
//add all relevant player info to use when server responds with its sequence number
playerInputs.push({
sequenceNumber: sequenceNumber,
framesWithCurrentDirection: framesWithCurrentDirection, //tracks # of frames run
movingDirection: { x: xMovingDirection, y: yMovingDirection}, //calculated with each mouse move
speedMultiplier: multiplier, //how fast player should go (based on how far mouse is from player)
vx: localPlayer.vx, //x velocity
vy: localPlayer.vy, //y velocity
speed: localPlayer.speed, //player speed
radius: localPlayer.radius //player radius
});
//send the direction the player moves in to the server
const moveData = {
playerID: localPlayer.id,
xMovingDirection: xMovingDirection,
yMovingDirection: yMovingDirection,
speedMultiplier: multiplier,
sequenceNumber: sequenceNumber
};
socket.emit('playerMove', moveData);
framesWithCurrentDirection = 0; //reset frames with current direction
});
On the server, the player’s mouse movements are acknowledged and the server updates the player’s sequence number to track how many mouse move events have been processed (which is stored in the Player class):
socket.on('playerMove', (data) => {
const sequenceNumber = data.sequenceNumber;
player.sequenceNumber = sequenceNumber;
player.framesWithCurrentDirection = 0; //reset # of frames moving in current direction
});
Additionally, the server has a gameLoop() running at the same FPS (66.67) as the client side, which does several things, including emitting player’s positions to all connected players in a given room and updating the number of frames the game runs while the player is going in the direction their mouse dictates:
const frameRate = 1000 / 66.67;
setInterval(() => {
rooms.forEach((room, roomId) => {
gameLoop(room, roomId);
});
}, frameRate);
function gameLoop() {
...
room.players.forEach((player) => {
player.framesWithCurrentDirection++; //increase frames moving in current direction for each player
...
//emits player positions to all room’s connected players
const playerPositions = Array.from(room.players.values()).map(player => ({
playerID: player.id,
x: player.x,
y: player.y,
speed: player.speed,
sequenceNumber: player.sequenceNumber,
frames: player.framesWithCurrentDirection
}));
io.to(roomId).emit('playerPositionsUpdate', playerPositions);
}
Back on the client side, when ‘playerPositionsUpdate’ is received, it checks the received sequenceNumber against the stored mouse movements input array and splices the sequenceNumber values that are less than what the server provided. It then applies the unfinished mouse movements to move the player from the server’s last processed position to their correct position. The idea is that, for each unprocessed mouse movement, the client checks the number of frames the server gives back against the number of frames it was keeping track of on its own. For each unprocessed frame, it runs the move() method for the player:
socket.on('playerPositionsUpdate', (playerPositions) => {
playerPositions.forEach(({ playerID, x, y, speed, sequenceNumber, frames }) => {
//set player’s position to whatever server says
player.x = x;
player.y = y;
player.speed = speed;
//find the index of the last event that the server has processed
const lastBackendIndex = playerInputs.findIndex(input => input.sequenceNumber === sequenceNumber);
//if the index exists (if something is unprocessed), remove everything up to that point from the playerInputs array, which stores the player’s mousemove info
if (lastBackendIndex > -1) {
playerInputs.splice(0, lastBackendIndex + 1);
}
// Apply any unprocessed inputs
playerInputs.forEach(input => {
const framesLeft = input.framesWithCurrentDirection - frames;
const framesStartingPoint = Math.max(0, input.framesWithCurrentDirection - framesLeft);
//start at the frame that has not been processed yet
for (let i = framesStartingPoint; i < input.framesWithCurrentDirection; i++) {
const velocity = { vx: input.vx, vy: input.vy };
const coordinates = { x: x, y: y };
player.move(input.movingDirection, input.speedMultiplier, input.speed, velocity, coordinates); //normal player movement method
}
});
} else {
// For other players, smoothly interpolate to the server position
gsap.to(player, {
x: x,
y: y,
speed: speed,
duration: 0.05,
ease: 'linear'
});
});
});
With all of this said, I’m finding that the server gives a lower number of frames than the client does. It seems that the frames need to match up exactly for this to work. For example, after about 2 seconds, the server says that much fewer frames have run than the client says. This is an issue because it makes it difficult to track how many frames have truly run. And it means that extra frames are being processed, leading to choppiness in player movement.
Question
This code is creating choppiness (my game is smooth on my local computer without server reconciliation, but I have to implement it because my live server is a lot slower). Any ideas how to fix this issue with the inconsistency in the frames? Additionally, do you see anything wrong with the way I’m going about server reconciliation? It’s the type of issue where something small could be wrong and fixing that would fix the whole issue, but I’m not sure if that’s the case or if I’m just completely off. Any suggestions?