NOTE
Here is a link to a video I made demonstrating the issue at hand on Dropbox: https://www.dropbox.com/scl/fo/p19oite64o22sh9bl8s1b/ALnUvrJzNbK7QixHrgHGgdY?rlkey=gpxahqur1kmfdqr1ulow4bk04&st=hybj5h8y&dl=0
CONTEXT
I’m trying to create an agario-type game where the player spawns in as a circle and is able to eat small, circular foods to get bigger. I’m using HTML canvas on the client side and Node.js on the server side.
PROBLEM
I’ve been having great difficulty figuring out how to correctly scale the world as the player eats more food. In this game, when the player touches the food at all, they eat it. I’m using the .scale() method to slowly zoom out, so that as the player gets bigger, they don’t eventually totally overtake the screen so that they can’t see anything but themselves. However, as the player gets bigger, the hit detection gets worse — the player will be on top of a food and won’t eat it or will eat it before they touch it. It also seems that this poor hit detection corresponds to the direction: when the player moves upwards, the food is eaten late, as in the food will overlap the player and not be eaten. The same happens when the players moves left, where the food will overlap the player and not be eaten. Oppositely, when the player moves either right or down, the food will be eaten before the player makes contact with food. It’s as if I just have to move the player to the right a certain amount, but I don’t know which code to change or why it is causing issues in the first place.
CODE
I’ve removed code that does not seem to be relevant to the issue.
The Node.js server currently handles everything (spawning foods, calculating collisions between players, foods, etc.) except for giving player coordinates, as the client sends their coordinates to the server on every frame.
The “Player” class creates the structure for player objects. Within the Player class, I have the draw() method, which looks like this:
draw(viewport) {
if (this.isLocal) { //if it's the local player, send coordinates to server
socket.emit('currentPosition', {
x: this.x / zoom,
y: this.y / zoom,
radius: this.radius / zoom,
speedMultiplier: this.speedMultiplier,
vx: this.vx, //update these on server side?
vy: this.vy
});
}
ctx.save();
ctx.translate(-viewport.x, -viewport.y);
//scale the player
ctx.scale(zoom, zoom);
ctx.beginPath();
ctx.arc(this.x, this.y, this.radius, 0, Math.PI * 2);
ctx.fillStyle = this.color;
ctx.fill();
ctx.strokeStyle = this.strokeColor;
ctx.lineWidth = 5;
ctx.stroke();
ctx.closePath();
ctx.restore();
}
Within this draw() method, I use ctx.scale(zoom, zoom)
to scale the player. My understanding is that this essentially multiplies the x/y position and the radius by the zoom factor.
I also have a Food class, which creates food objects. The Food class’s draw() method looks like this:
draw(viewport) {
ctx.save();
ctx.translate(-viewport.x, -viewport.y);
ctx.scale(zoom, zoom); //scale foods
ctx.beginPath();
ctx.arc(this.x, this.y, this.radius, 0, Math.PI * 2);
ctx.fillStyle = this.color;
ctx.fill();
ctx.strokeStyle = this.strokeColor;
ctx.lineWidth = 3.5;
ctx.stroke();
ctx.closePath();
ctx.restore();
}
Both of these draw() methods are meant to scale the player and foods according to how many foods the player has eaten. When the player eats an individual food, the zoom rate decreases. The server tells the player when they have eaten food:
socket.on('foodEaten', (data) => {
const player = gamePlayers.get(data.playerId);
if (player.id == localPlayer.id) {
zoom *= 0.9999;
}
…
So, for each food the player eats, they zoom out by 0.9999.
The player’s movement is determined by where their mouse moves. When they move their mouse, this event listener calculates the direction the mouse is pointing in and sets the player on that path:
canvas.addEventListener('mousemove', (event) => {
const rect = canvas.getBoundingClientRect();
mouse.x = (event.clientX - rect.left) / zoom;
mouse.y = (event.clientY - rect.top) / zoom;
// Update player's target position
localPlayer.targetX = (mouse.x + viewport.x / zoom);
localPlayer.targetY = (mouse.y + viewport.y / zoom);
const dx = (localPlayer.targetX - localPlayer.x) * zoom;
const dy = (localPlayer.targetY - localPlayer.y) * zoom;
const distance = Math.sqrt(dx * dx + dy * dy);
...does some other stuff in between...
const xMovingDirection = dx / distance;
const yMovingDirection = dy / distance;
localPlayer.movingDirection = { x: xMovingDirection, y: yMovingDirection};
});
I mentioned in the draw() method of the Player class that they emit their current position to the server before the player is drawn:
socket.emit('currentPosition', {
x: this.x / zoom,
y: this.y / zoom,
radius: this.radius / zoom,
speedMultiplier: this.speedMultiplier,
vx: this.vx,
vy: this.vy
});
I divide the x and y coordinates and the radius by the zoom level to allow the server to disregard individual player’s zoom levels, so that every other player in the game is being sent non-zoomed coordinates.
After the server receives this information, it evaluates the player’s position against every food in the game, checking if they are colliding:
socket.on('currentPosition', (data) => { //get player's current x/y coordinates and update them
const player = room.players.get(socket.id);
if (player) {
player.x = data.x; //update player x position
player.y = data.y; //update player y position
room.foods.forEach((food, foodId) => { //check for foods being eaten
if (checkCollision(player, food)) {
player.radius += food.radius * normalRadiusIncreaseRate; //increase player radius
let newFood; //add food back in
newFood = new Food(); //add new food item
room.foods.set(newFood.id, newFood);
//let player know that they ate food
io.to(room.roomId).emit('foodEaten', {
food: food,
playerId: player.id,
radius: player.radius,
newFood: newFood
});
room.foods.delete(food.id); //delete eaten food
}
//send this player’s data to other players
socket.to(room.roomId).emit('updatePlayerTarget', {
id: socket.id,
x: player.x,
y: player.y
// radius: player.radius
});
}
});
If a player collides with a food, they should eat it. Node.js emits ‘foodEaten’ to the client, which allows the client to update the radius of the player who ate the food. It also gives the player’s x and y coordinates at the end of the block.
QUESTION
Why is it that, when using .scale(), the synchronization between the player and the food gets worse over time?