javascriptnode.jssocketscanvascollision-detection

Using .scale() in HTML canvas causes hit detection issues


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?


Solution

  • I finally found the answer. I made the mistake of dividing the player's coordinates by the zoom factor before sending them to the server:

    socket.emit('currentPosition', {
        x: this.x / zoom,
        y: this.y / zoom,
        radius: this.radius / zoom
    });
    

    Instead, I should send the player’s coordinates as they are, without dividing by the zoom factor.

    socket.emit('currentPosition', {
        x: this.x,
        y: this.y,
        radius: this.radius
    });
    

    This is because the zoom factor affects only the visual representation on the client side, not the actual coordinate values.

    When I use .scale(zoom, zoom), it changes how coordinates are displayed on the screen, but the underlying coordinates themselves remain the same. So, to maintain consistency between the client and server, the coordinates sent to the server should be the original world coordinates, unaffected by the zoom.