0
\$\begingroup\$

So I'm working on this multiplayer platformer game using Phaser, Matter.js, and Colyseus.js.

I have a simple map and player controls set up and now I'm trying to copy my physics simulation on the server side, and while everything seems to be working great, I've noticed that if I start doing some crazy movement, like quickly moving to the other direction or start jumping too much, the physics simulation on the client and server goes out of sync and the final player positions are not the same.

Sometimes the difference is not that big, but sometimes it's quite different and I'm not really sure what I'm doing wrong.

This is the client side code, which controls the movement and sends the inputs to the server when the update() method is called.

import MultiKey from './utils/multi-keys'

export default class Player extends Phaser.Physics.Matter.Sprite {

constructor(scene, x, y) {
    super(scene.matter.world, x, y, 'player', 0);

    scene.add.existing(this);

    this.scene = scene;

    this.key = 'player';
    this.dead = false;

    // Create the animations we need from the player spritesheet
    scene.anims.create({
        key: "player-idle",
        frames: scene.anims.generateFrameNumbers("player", {start: 0, end: 3}),
        frameRate: 3,
        repeat: -1
    });
    scene.anims.create({
        key: "player-run",
        frames: scene.anims.generateFrameNumbers("player", {start: 8, end: 15}),
        frameRate: 12,
        repeat: -1
    });


    const {Body, Bodies} = Phaser.Physics.Matter.Matter; // Native Matter modules
    const {width: w, height: h} = this;
    const mainBody = Bodies.rectangle(0, 0, w * 0.6, h, {chamfer: {radius: 10}});
    this.sensors = {
        bottom: Bodies.rectangle(0, h * 0.5, w * 0.25, 2, {isSensor: true}),
        left: Bodies.rectangle(-w * 0.35, 0, 2, h * 0.5, {isSensor: true}),
        right: Bodies.rectangle(w * 0.35, 0, 2, h * 0.5, {isSensor: true})
    };
    const compoundBody = Body.create({
        parts: [mainBody, this.sensors.bottom, this.sensors.left, this.sensors.right],
        frictionStatic: 0,
        frictionAir: 0.02,
        friction: 0.1
    });
    this.setExistingBody(compoundBody)
        .setFixedRotation() // Sets inertia to infinity so the player can't rotate
        .setPosition(x, y);

    // Track which sensors are touching something
    this.isTouching = {left: false, right: false, ground: false};

    // Jumping is going to have a cooldown
    this.canJump = true;
    this.jumpCooldownTimer = null;

    // Before matter's update, reset our record of which surfaces the player is touching.
    scene.matter.world.on("beforeupdate", this.resetTouching, this);

    scene.matterCollision.addOnCollideStart({
        objectA: [this.sensors.bottom, this.sensors.left, this.sensors.right],
        callback: this.onSensorCollide,
        context: this
    });
    scene.matterCollision.addOnCollideActive({
        objectA: [this.sensors.bottom, this.sensors.left, this.sensors.right],
        callback: this.onSensorCollide,
        context: this
    });

    // Track the keys
    const {LEFT, RIGHT, UP, A, D, W} = Phaser.Input.Keyboard.KeyCodes;
    this.leftInput = new MultiKey(this.scene, [LEFT, A]);
    this.rightInput = new MultiKey(this.scene, [RIGHT, D]);
    this.jumpInput = new MultiKey(this.scene, [UP, W]);

    this.destroyed = false;
    this.scene.events.on("update", this.update, this);
    this.scene.events.once("shutdown", this.kill, this);
    this.scene.events.once("destroy", this.kill, this);

    this.debug = this.scene.add.graphics({lineStyle: {width: 2, color: 0xaa0000}});
    this.debug.strokeRect(x - (w / 2), y - (h / 2), w, h);
    this.debug.w = w;
    this.debug.h = h;

    console.log(this.debug);
}

onSensorCollide({bodyA, bodyB, pair}) {
    // Watch for the player colliding with walls/objects on either side and the ground below, so
    // that we can use that logic inside of update to move the player.
    // Note: we are using the "pair.separation" here. That number tells us how much bodyA and bodyB
    // overlap. We want to teleport the sprite away from walls just enough so that the player won't
    // be able to press up against the wall and use friction to hang in midair. This formula leaves
    // 0.5px of overlap with the sensor so that the sensor will stay colliding on the next tick if
    // the player doesn't move.
    if (bodyB.isSensor) return; // We only care about collisions with physical objects
    if (bodyA === this.sensors.left) {
        this.isTouching.left = true;
        if (pair.separation > 0.5) this.x += pair.separation - 0.5;
    } else if (bodyA === this.sensors.right) {
        this.isTouching.right = true;
        if (pair.separation > 0.5) this.x -= pair.separation - 0.5;
    } else if (bodyA === this.sensors.bottom) {
        this.isTouching.ground = true;
    }
}

resetTouching() {
    this.isTouching.left = false;
    this.isTouching.right = false;
    this.isTouching.ground = false;
}

freeze() {
    this.setStatic(true);
}

update() {
    if (this.destroyed) return;

    this.x = this.body.position.x;
    this.y = this.body.position.y;

    let inputs = [];

    const velocity = this.body.velocity;
    const isRightKeyDown = this.rightInput.isDown();
    const isLeftKeyDown = this.leftInput.isDown();
    const isJumpKeyDown = this.jumpInput.isDown();
    const isOnGround = this.isTouching.ground;
    const isInAir = !isOnGround;

    // --- Move the player horizontally ---

    // Adjust the movement so that the player is slower in the air
    const moveForce = 0.005;

    if (isLeftKeyDown) {
        this.setFlipX(true);

        // Don't let the player push things left if they in the air
        if (!(isInAir && this.isTouching.left)) {
            this.applyForce({x: -moveForce, y: 0});
        }

        inputs.push({
            time: new Date().getTime(),
            move: "left"
        });
    } else if (isRightKeyDown) {
        this.setFlipX(false);

        // Don't let the player push things right if they in the air
        if (!(isInAir && this.isTouching.right)) {
            this.applyForce({x: moveForce, y: 0});
        }

        inputs.push({
            time: new Date().getTime(),
            move: "right"
        });
    }

    // Limit horizontal speed, without this the player's velocity would just keep increasing to
    // absurd speeds. We don't want to touch the vertical velocity though, so that we don't
    // interfere with gravity.
    if (velocity.x > 3) this.setVelocityX(3);
    else if (velocity.x < -3) this.setVelocityX(-3);

    // --- Move the player vertically ---

    if (isJumpKeyDown) {
        inputs.push({
            time: new Date().getTime(),
            move: "jump"
        });

        if (this.canJump && isOnGround) {
            this.setVelocityY(-8);
            // Add a slight delay between jumps since the bottom sensor will still collide for a few
            // frames after a jump is initiated
            this.canJump = false;
            this.jumpCooldownTimer = this.scene.time.addEvent({
                delay: 250,
                callback: () => (this.canJump = true)
            });
        }
    }

    // Update the animation/texture based on the state of the player's state
    if (isOnGround) {
        if (this.body.force.x !== 0) this.anims.play("player-run", true);
        else this.anims.play("player-idle", true);
    } else {
        this.anims.stop();
        this.setTexture("player", 10);
    }

    //Send the inputs to server
    if (inputs.length) {
        this.scene.room.send(inputs);
    }
}

kill() {
    //this.freeze();

    this.dead = true;

    // Clean up any listeners that might trigger events after the player is officially destroyed
    this.scene.events.off("update", this.update, this);
    this.scene.events.off("shutdown", this.destroy, this);
    this.scene.events.off("destroy", this.destroy, this);
    if (this.scene.matter.world) {
        this.scene.matter.world.off("beforeupdate", this.resetTouching, this);
    }
    const sensors = [this.sensors.bottom, this.sensors.left, this.sensors.right];
    this.scene.matterCollision.removeOnCollideStart({objectA: sensors});
    this.scene.matterCollision.removeOnCollideActive({objectA: sensors});
    if (this.jumpCooldownTimer) this.jumpCooldownTimer.destroy();

    this.destroyed = true;
}
}

This is the server side version the same player, which checks incoming inputs and applies them

const Matter = require('matter-js');
const _ = require('lodash');

module.exports = class Player {
constructor(clock, engine, options) {
    this.clock = clock;

    this.id = _.get(options, 'id', 0);
    this.x = _.get(options, 'x', 0);
    this.y = _.get(options, 'y', 0);

    this.velocity = {
        x: 0,
        y: 0,
    };

    this.width = 32;
    this.height = 32;

    this.move_force = 0.005;

    this.last_inputs = null;

    const mainBody = Matter.Bodies.rectangle(0, 0, this.width * 0.6, this.height, {chamfer: {radius: 10}});

    Object.defineProperty(this, 'sensors', {
        value: {
            bottom: Matter.Bodies.rectangle(0, this.height * 0.5, this.width * 0.25, 2, {isSensor: true}),
            left: Matter.Bodies.rectangle(-this.width * 0.35, 0, 2, this.height * 0.5, {isSensor: true}),
            right: Matter.Bodies.rectangle(this.width * 0.35, 0, 2, this.height * 0.5, {isSensor: true})
        },
        enumerable: false
    });

    Object.defineProperty(this, 'body', {
        value: Matter.Body.create({
            parts: [mainBody, this.sensors.bottom, this.sensors.left, this.sensors.right],
            frictionStatic: 0,
            frictionAir: 0.02,
            friction: 0.1
        }),
        enumerable: false
    });

    Matter.World.add(engine.world, this.body);

    Matter.Body.setInertia(this.body, Infinity);
    Matter.Body.setPosition(this.body, {
        x: this.x,
        y: this.y
    });
    // Track which sensors are touching something
    this.isTouching = {left: false, right: false, ground: false};

    // Jumping is going to have a cooldown
    this.canJump = true;
    this.jumpCooldownTimer = null;

    Object.defineProperty(this, 'inputs', {
        value: [],
        enumerable: false,
        writable: true
    });

    Matter.Events.on(engine, 'beforeUpdate', () => {
        this.resetTouching();
    });

    this.sensors.left.onCollide((object) => {
        this.onSensorCollide(object);
    });
    this.sensors.right.onCollide((object) => {
        this.onSensorCollide(object);
    });
    this.sensors.bottom.onCollide((object) => {
        this.onSensorCollide(object);
    });

    this.sensors.left.onCollideActive((object) => {
        this.onSensorCollide(object);
    });
    this.sensors.right.onCollideActive((object) => {
        this.onSensorCollide(object);
    });
    this.sensors.bottom.onCollideActive((object) => {
        this.onSensorCollide(object);
    });
}

onSensorCollide({bodyA, bodyB, pair, separation}) {
    // Watch for the player colliding with walls/objects on either side and the ground below, so
    // that we can use that logic inside of update to move the player.
    // Note: we are using the "pair.separation" here. That number tells us how much bodyA and bodyB
    // overlap. We want to teleport the sprite away from walls just enough so that the player won't
    // be able to press up against the wall and use friction to hang in midair. This formula leaves
    // 0.5px of overlap with the sensor so that the sensor will stay colliding on the next tick if
    // the player doesn't move.

    if (bodyA.isSensor) return; // We only care about collisions with physical objects

    if (bodyB === this.sensors.left) {
        this.isTouching.left = true;
        if (separation > 0.5) {
            Matter.Body.setPosition(this.body, {
                x: this.body.position.x + separation - 0.5,
                y: this.body.position.y
            });
        }
    } else if (bodyB === this.sensors.right) {
        this.isTouching.right = true;
        if (separation > 0.5) {
            Matter.Body.setPosition(this.body, {
                x: this.body.position.x - separation - 0.5,
                y: this.body.position.y
            });
        }
    } else if (bodyB === this.sensors.bottom) {
        this.isTouching.ground = true;
    }
}

resetTouching() {
    this.isTouching.left = false;
    this.isTouching.right = false;
    this.isTouching.ground = false;
}

addInput(input) {
    this.inputs.push(input);
}

moveLeft() {
    const isOnGround = this.isTouching.ground;
    const isInAir = !isOnGround;

    if (!(isInAir && this.isTouching.left)) {
        Matter.Body.applyForce(this.body, this.body.position, {
            x: -this.move_force,
            y: 0
        })
    }
}

moveRight() {
    const isOnGround = this.isTouching.ground;
    const isInAir = !isOnGround;

    if (!(isInAir && this.isTouching.right)) {
        Matter.Body.applyForce(this.body, this.body.position, {
            x: this.move_force,
            y: 0
        })
    }
}

jump() {
    const isOnGround = this.isTouching.ground;
    const isInAir = !isOnGround;

    if (this.canJump && isOnGround) {
        Matter.Body.setVelocity(this.body, {
            x: this.body.velocity.x,
            y: -8
        });

        this.canJump = false;

        this.clock.setTimeout(() => {
            this.canJump = true;
        }, 250)
    }
}

update() {
    let velocity = this.body.velocity;

    let lastInputs = _.first(this.inputs);
    if (lastInputs) {
        lastInputs.forEach(lastInput => {
            if (lastInput.move === 'left') {
                this.last_inputs = lastInput;

                this.moveLeft();
            }

            if (lastInput.move === 'right') {
                this.last_inputs = lastInput;

                this.moveRight();
            }
        });
    }

    if (velocity.x > 3) {
        Matter.Body.setVelocity(this.body, {
            x: 3,
            y: this.body.velocity.y
        })
    } else if (velocity.x < -3) {
        Matter.Body.setVelocity(this.body, {
            x: -3,
            y: this.body.velocity.y
        })
    }

    if (lastInputs) {
        lastInputs.forEach(lastInput => {
            if (lastInput.move === 'jump') {
                this.last_inputs = lastInput;

                this.jump();
            }
        });

        this.inputs.splice(0, 1);
    }

    this.x = this.body.position.x;
    this.y = this.body.position.y;
    this.velocity.x = this.body.velocity.x;
    this.velocity.y = this.body.velocity.y;
}
};

For the game loop I use node-gameloop, and the code looks like this

this.loop = gameloop.setGameLoop((delta) => {
        console.log(delta * 1000);
        _.each(this.state.players, player => {
            player.update();
        });

        Matter.Engine.update(this.engine, delta * 1000);
    }, 1000 / 60);

My theory is that there is something I'm doing wrong while sending the inputs or there's something wrong with the gameloop, but I can't wrap my head around this

This is how it looks when I'm testing - https://www.dropbox.com/s/31zedagouhpv2sw/2019-03-02%2009.47.01.gif?dl=0

Any help that could point me in the right direction, would be highly appreciated

\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Browse other questions tagged or ask your own question.