Please enable JavaScript to view the comments powered by Disqus.

Part 5: Phaser Multiplayer Game Tutorial: Creating a leaderboard and store top player scores

Create a LeaderBoard

Introudction

Repository available here: https://github.com/dci05049/Phaser-Multiplayer-Game-Tutorial/tree/master/Part5

Welcome to part 5 of multiplayer game tutorial series. In this tutorial, we’ll make a simple leaderboard system to keep track of player ranks in the game.

Client Side: Main.js


var socket = io({transports: ['websocket'], upgrade: false});


canvas_width = window.innerWidth * window.devicePixelRatio;
canvas_height = window.innerHeight * window.devicePixelRatio;

game = new Phaser.Game(canvas_width,canvas_height, Phaser.CANVAS, 'gameDiv');

//the enemy player list 
var enemies = [];

var gameProperties = { 
	gameWidth: 4000,
	gameHeight: 4000,
	game_elemnt: "gameDiv",
	in_game: false,
};

var main = function(game){
};

function onsocketConnected () {
	console.log("connected to server"); 
	gameProperties.in_game = true;
	// send the server our initial position and tell it we are connected
	socket.emit('new_player', {x: 0, y: 0, angle: 0});
}

// When the server notifies us of client disconnection, we find the disconnected
// enemy and remove from our game
function onRemovePlayer (data) {
	var removePlayer = findplayerbyid(data.id);
	// Player not found
	if (!removePlayer) {
		console.log('Player not found: ', data.id)
		return;
	}
	
	removePlayer.player.destroy();
	enemies.splice(enemies.indexOf(removePlayer), 1);
}

function createPlayer (data) {
	player = game.add.graphics(0, 0);
	player.radius = data.size;

	// set a fill and line style
	player.beginFill(0xffd900);
	player.lineStyle(2, 0xffd900, 1);
	player.drawCircle(0, 0, player.radius * 2);
	player.endFill();
	player.anchor.setTo(0.5,0.5);
	player.body_size = player.radius; 
	//set the initial size;
	player.initial_size = player.radius;
	player.type = "player_body"; 

	// draw a shape
	game.physics.p2.enableBody(player, true);
	player.body.clearShapes();
	player.body.addCircle(player.body_size, 0 , 0); 
	player.body.data.shapes[0].sensor = true;
	//enable collision and when it makes a contact with another body, call player_coll
	player.body.onBeginContact.add(player_coll, this); 
	
	//camera follow
	game.camera.follow(player, Phaser.Camera.FOLLOW_LOCKON, 0.5, 0.5);
}

//get random intenger
function getRndInteger(min, max) {
    return Math.floor(Math.random() * (max - min + 1) ) + min;
}

// this is the enemy class. 
var remote_player = function (id, startx, starty, startSize, start_angle) {
	this.x = startx;
	this.y = starty;
	//this is the unique socket id. We use it as a unique name for enemy
	this.id = id;
	this.angle = start_angle;
	
	this.player = game.add.graphics(this.x , this.y);
	//intialize the size with the server value
	this.player.radius = startSize

	// set a fill and line style
	this.player.beginFill(0xffd900);
	this.player.lineStyle(2, 0xffd900, 1);
	this.player.drawCircle(0, 0, this.player.radius * 2);
	this.player.endFill();
	this.player.anchor.setTo(0.5,0.5);
	//we set the initial size;
	this.initial_size = startSize;
	this.player.body_size = this.player.radius; 
	this.player.type = "player_body";
	this.player.id = this.id;

	// draw a shape
	game.physics.p2.enableBody(this.player, true);
	this.player.body.clearShapes();
	this.player.body.addCircle(this.player.body_size, 0 , 0); 
	this.player.body.data.shapes[0].sensor = true;
}

//Server will tell us when a new enemy player connects to the server.
//We create a new enemy in our game.
function onNewPlayer (data) {
	//enemy object 
	console.log(data);
	var new_enemy = new remote_player(data.id, data.x, data.y, data.size, data.angle); 
	enemies.push(new_enemy);
}

//Server tells us there is a new enemy movement. We find the moved enemy
//and sync the enemy movement with the server
function onEnemyMove (data) {
	var movePlayer = findplayerbyid (data.id); 
	
	if (!movePlayer) {
		return;
	}
	
	var newPointer = {
		x: data.x,
		y: data.y, 
		worldX: data.x,
		worldY: data.y, 
	}
	
	
	//check if the server enemy size is not equivalent to the client
	if (data.size != movePlayer.player.body_size) {
		movePlayer.player.body_size = data.size; 
		var new_scale = movePlayer.player.body_size / movePlayer.initial_size; 
		movePlayer.player.scale.set(new_scale);
		movePlayer.player.body.clearShapes();
		movePlayer.player.body.addCircle(movePlayer.player.body_size, 0 , 0); 
		movePlayer.player.body.data.shapes[0].sensor = true;
	}
	
	var distance = distanceToPointer(movePlayer.player, newPointer);
	speed = distance/0.05;
	
	movePlayer.rotation = movetoPointer(movePlayer.player, speed, newPointer);
}

//we're receiving the calculated position from the server and changing the player position
function onInputRecieved (data) {
	
	//we're forming a new pointer with the new position
	var newPointer = {
		x: data.x,
		y: data.y, 
		worldX: data.x,
		worldY: data.y, 
	}
	
	var distance = distanceToPointer(player, newPointer);
	//we're receiving player position every 50ms. We're interpolating 
	//between the current position and the new position so that player
	//does jerk. 
	speed = distance/0.05;
	
	//move to the new position. 
	player.rotation = movetoPointer(player, speed, newPointer);

}

function onGained (data) {
	player.body_size = data.new_size;
	var new_scale = data.new_size/player.initial_size;
	player.scale.set(new_scale);
	//create new body
	player.body.clearShapes();
	player.body.addCircle(player.body_size, 0 , 0); 
	player.body.data.shapes[0].sensor = true;
}

function onKilled (data) {
	player.destroy();
}


//This is where we use the socket id. 
//Search through enemies list to find the right enemy of the id.
function findplayerbyid (id) {
	for (var i = 0; i < enemies.length; i++) {
		if (enemies[i].id == id) {
			return enemies[i]; 
		}
	}
}

//create leader board in here.
function createLeaderBoard() {
	var leaderBox = game.add.graphics(game.width * 0.81, game.height * 0.05);
	leaderBox.fixedToCamera = true;
	// draw a rectangle
	leaderBox.beginFill(0xD3D3D3, 0.3);
    leaderBox.lineStyle(2, 0x202226, 1);
    leaderBox.drawRect(0, 0, 300, 400);
	
	var style = { font: "13px Press Start 2P", fill: "black", align: "left", fontSize: '22px'};
	
	leader_text = game.add.text(10, 10, "", style);
	leader_text.anchor.set(0);

	leaderBox.addChild(leader_text);
}

//leader board
function lbupdate (data) {
	//this is the final board string.
	var board_string = ""; 
	var maxlen = 10;
	var maxPlayerDisplay = 10;
	var mainPlayerShown = false;
	
	for (var i = 0;  i < data.length; i++) {
		//if the mainplayer is shown along the iteration, set it to true
	
		if (mainPlayerShown && i >= maxPlayerDisplay) {
			break;
		}
		
		//if the player's rank is very low, we display maxPlayerDisplay - 1 names in the leaderboard
		// and then add three dots at the end, and show player's rank.
		if (!mainPlayerShown && i >= maxPlayerDisplay - 1 && socket.id == data[i].id) {
			board_string = board_string.concat(".\n");
			board_string = board_string.concat(".\n");
			board_string = board_string.concat(".\n");
			mainPlayerShown = true;
		}
		
		//here we are checking if user id is greater than 10 characters, if it is 
		//it is too long, so we're going to trim it.
		if (data[i].id.length >= 10) {
			var username = data[i].id;
			var temp = ""; 
			for (var j = 0; j < maxlen; j++) {
				temp += username[j];
			}
			
			temp += "...";
			username = temp;
		
			board_string = board_string.concat(i + 1,": ");
			board_string = board_string.concat(username," ",(data[i].size).toString() + "\n");
		
		} else {
			board_string = board_string.concat("\n");
		}
		
	}
	
	console.log(board_string);
	leader_text.setText(board_string); 
}

main.prototype = {
	preload: function() {
		game.stage.disableVisibilityChange = true;
		game.scale.scaleMode = Phaser.ScaleManager.RESIZE;
		game.world.setBounds(0, 0, gameProperties.gameWidth, gameProperties.gameHeight, false, false, false, false);
		game.physics.startSystem(Phaser.Physics.P2JS);
		game.physics.p2.setBoundsToWorld(false, false, false, false, false)
		game.physics.p2.gravity.y = 0;
		game.physics.p2.applyGravity = false; 
		game.physics.p2.enableBody(game.physics.p2.walls, false); 
		// physics start system
		//game.physics.p2.setImpactEvents(true);

    },
	
	create: function () {
		game.stage.backgroundColor = 0xE1A193;
		
		console.log("client started");
		socket.on("connect", onsocketConnected); 
		
		//listen for main player creation
		socket.on("create_player", createPlayer);
		//listen to new enemy connections
		socket.on("new_enemyPlayer", onNewPlayer);
		//listen to enemy movement 
		socket.on("enemy_move", onEnemyMove);
		//when received remove_player, remove the player passed; 
		socket.on('remove_player', onRemovePlayer); 
		//when the player receives the new input
		socket.on('input_recieved', onInputRecieved);
		//when the player gets killed
		socket.on('killed', onKilled);
		//when the player gains in size
		socket.on('gained', onGained);
		// check for item removal
		socket.on ('itemremove', onitemremove); 
		// check for item update
		socket.on('item_update', onitemUpdate); 
		// check for leaderboard
		socket.on ('leader_board', lbupdate); 
		
		createLeaderBoard();
	},
	
	update: function () {
		// emit the player input
		
		//move the player when the player is made 
		if (gameProperties.in_game) {
		
			//we're making a new mouse pointer and sending this input to 
			//the server.
			var pointer = game.input.mousePointer;
					
			//Send a new position data to the server 
			socket.emit('input_fired', {
				pointer_x: pointer.x, 
				pointer_y: pointer.y, 
				pointer_worldx: pointer.worldX, 
				pointer_worldy: pointer.worldY, 
			});
		}
	}
}

var gameBootstrapper = {
    init: function(gameContainerElementId){
		game.state.add('main', main);
		game.state.start('main'); 
    }
};;

gameBootstrapper.init("gameDiv");
			

There are two things we need to do in client side.

Firstly, we have to create a leaderboard, secondly we need to update the leader board when the score changes in the server.

We initialize a leaderboard with createLeaderBoard() function. We use phaser’s graphics library to create a rectangle, named leaderBox. We then generate a phaser text, which contains one formatted string for players’ ranks. We add the text as the child of leaderBox to position the text based on the leaderBox. We update the leaderboard with lbupdate. We wait for leader_board message from the server, and then call lbupdate as callback. The “data” parameter in lbupdate is a list that is sorted in a descending order based on the player score. Of course, in order to catch leader_board message from the server, we need to include “socket.on ('leader_board', lbupdate); “ in oncreate.

Logic for lbUpdate:

We need to update the strings in the leader board text based on the sorted player list that we get from the server. the "maxlen" local variable in the function indicates the maximum length of the player's name in each line. If the player's name is too long, we need to trim it down so that it fits in one line.

We then concatenate by iterating through the player's name in the sorted list. After each iteration, we add a new line at the end so that the next player's name will begin in new line. Let's say player's rank is too low, then we simply show max - 1 players, and then put our player's name followed by dots.

Server: App.js:


var express = require('express');
//require p2 physics library in the server.
var p2 = require('p2'); 
//get the node-uuid package for creating unique id
var unique = require('node-uuid')

var app = express();
var serv = require('http').Server(app);
//get the functions required to move players in the server.
var physicsPlayer = require('./server/physics/playermovement.js');

app.get('/',function(req, res) {
	res.sendFile(__dirname + '/client/index.html');
});
app.use('/client',express.static(__dirname + '/client'));

serv.listen(process.env.PORT || 2000);
console.log("Server started.");

var player_lst = [];

//needed for physics update 
var startTime = (new Date).getTime();
var lastTime;
var timeStep= 1/70; 

//the physics world in the server. This is where all the physics happens. 
//we set gravity to 0 since we are just following mouse pointers.
var world = new p2.World({
  gravity : [0,0]
});

//create a game class to store basic game data
var game_setup = function() {
	//The constant number of foods in the game
	this.food_num = 100; 
	//food object list
	this.food_pickup = [];
	//game size height
	this.canvas_height = 4000;
	//game size width
	this.canvas_width = 4000; 
}

// createa a new game instance
var game_instance = new game_setup();


//a player class in the server
var Player = function (startX, startY, startAngle) {
	this.id;
	this.x = startX;
	this.y = startY;
	this.angle = startAngle;
	this.speed = 500;
	//We need to intilaize with true.
	this.sendData = true;
	this.size = getRndInteger(40, 100); 
	this.dead = false;
}

var foodpickup = function (max_x, max_y, type, id) {
	this.x = getRndInteger(10, max_x - 10) ;
	this.y = getRndInteger(10, max_y - 10);
	this.type = type; 
	this.id = id; 
	this.powerup; 
}

//We call physics handler 60fps. The physics is calculated here. 
setInterval(heartbeat, 1000/60);



//Steps the physics world. 
function physics_hanlder() {
	var currentTime = (new Date).getTime();
	timeElapsed = currentTime - startTime;
	var dt = lastTime ? (timeElapsed - lastTime) / 1000 : 0;
    dt = Math.min(1 / 10, dt);
    world.step(timeStep);
}

function heartbeat () {
	
	//the number of food that needs to be generated 
	//in this demo, we keep the food always at 100
	var food_generatenum = game_instance.food_num - game_instance.food_pickup.length; 
	
	//add the food 
	addfood(food_generatenum);
	//physics stepping. We moved this into heartbeat
	physics_hanlder();
}

function addfood(n) {
	
	//return if it is not required to create food 
	if (n <= 0) {
		return; 
	}
	
	//create n number of foods to the game
	for (var i = 0; i < n; i++) {
		//create the unique id using node-uuid
		var unique_id = unique.v4(); 
		var foodentity = new foodpickup(game_instance.canvas_width, game_instance.canvas_height, 'food', unique_id);
		game_instance.food_pickup.push(foodentity); 
		//set the food data back to client
		io.emit("item_update", foodentity); 
	}
}


// when a new player connects, we make a new instance of the player object,
// and send a new player message to the client. 
function onNewplayer (data) {
	//new player instance
	var newPlayer = new Player(data.x, data.y, data.angle);
	newPlayer.id = this.id;
	
	//create an instance of player body 
	playerBody = new p2.Body ({
		mass: 0,
		position: [0,0],
		fixedRotation: true
	});
	
	//add the playerbody into the player object 
	newPlayer.playerBody = playerBody;
	world.addBody(newPlayer.playerBody);
	
	console.log("created new player with id " + this.id);
	newPlayer.id = this.id; 	
	
	this.emit('create_player', {size: newPlayer.size});
	
	//information to be sent to all clients except sender
	var current_info = {
		id: newPlayer.id, 
		x: newPlayer.x,
		y: newPlayer.y,
		angle: newPlayer.angle,
		size: newPlayer.size
	}; 
	
	//send to the new player about everyone who is already connected. 	
	for (i = 0; i < player_lst.length; i++) {
		existingPlayer = player_lst[i];
		var player_info = {
			id: existingPlayer.id,
			x: existingPlayer.x,
			y: existingPlayer.y, 
			angle: existingPlayer.angle,	
			size: existingPlayer.size
		};
		console.log("pushing player");
		//send message to the sender-client only
		this.emit("new_enemyPlayer", player_info);
	}
	
	//Tell the client to make foods that are exisiting
	for (j = 0; j < game_instance.food_pickup.length; j++) {
		var food_pick = game_instance.food_pickup[j];
		this.emit('item_update', food_pick); 
	}
	
	//send message to every connected client except the sender
	this.broadcast.emit('new_enemyPlayer', current_info);
	
	player_lst.push(newPlayer); 
	sortPlayerListByScore();
}

//instead of listening to player positions, we listen to user inputs 
function onInputFired (data) {
	var movePlayer = find_playerid(this.id, this.room); 
	
	
	if (!movePlayer || movePlayer.dead) {
		return;
		console.log('no player'); 
	}

	//when sendData is true, we send the data back to client. 
	if (!movePlayer.sendData) {
		return;
	}
	
	//every 50ms, we send the data. 
	setTimeout(function() {movePlayer.sendData = true}, 50);
	//we set sendData to false when we send the data. 
	movePlayer.sendData = false;
	
	//Make a new pointer with the new inputs from the client. 
	//contains player positions in server
	var serverPointer = {
		x: data.pointer_x,
		y: data.pointer_y,
		worldX: data.pointer_worldx, 		
		worldY: data.pointer_worldy
	}
	
	//moving the player to the new inputs from the player
	if (physicsPlayer.distanceToPointer(movePlayer, serverPointer) <= 30) {
		movePlayer.playerBody.angle = physicsPlayer.movetoPointer(movePlayer, 0, serverPointer, 1000);
	} else {
		movePlayer.playerBody.angle = physicsPlayer.movetoPointer(movePlayer, movePlayer.speed, serverPointer);	
	}
	
	movePlayer.x = movePlayer.playerBody.position[0]; 
	movePlayer.y = movePlayer.playerBody.position[1];
	
	//new player position to be sent back to client. 
	var info = {
		x: movePlayer.playerBody.position[0],
		y: movePlayer.playerBody.position[1],
		angle: movePlayer.playerBody.angle
	}

	//send to sender (not to every clients). 
	this.emit('input_recieved', info);
	
	//data to be sent back to everyone except sender 
	var moveplayerData = {
		id: movePlayer.id, 
		x: movePlayer.playerBody.position[0],
		y: movePlayer.playerBody.position[1],
		angle: movePlayer.playerBody.angle,
		size: movePlayer.size
	}
	
	//send to everyone except sender 
	this.broadcast.emit('enemy_move', moveplayerData);
}

function onPlayerCollision (data) {
	var movePlayer = find_playerid(this.id); 
	var enemyPlayer = find_playerid(data.id); 
	
	
	if (movePlayer.dead || enemyPlayer.dead)
		return
	
	if (!movePlayer || !enemyPlayer)
		return

	
	if (movePlayer.size == enemyPlayer)
		return
	//the main player size is less than the enemy size
	else if (movePlayer.size < enemyPlayer.size) {
		var gained_size = movePlayer.size / 2;
		enemyPlayer.size += gained_size; 
		this.emit("killed");
		//provide the new size the enemy will become
		this.broadcast.emit('remove_player', {id: this.id});
		this.broadcast.to(data.id).emit("gained", {new_size: enemyPlayer.size}); 
		playerKilled(movePlayer);
	} else {
		var gained_size = enemyPlayer.size / 2;
		movePlayer.size += gained_size;
		this.emit('remove_player', {id: enemyPlayer.id}); 
		this.emit("gained", {new_size: movePlayer.size}); 
		this.broadcast.to(data.id).emit("killed"); 
		//send to everyone except sender.
		this.broadcast.emit('remove_player', {id: enemyPlayer.id});
		playerKilled(enemyPlayer);
	}
	
	sortPlayerListByScore();
	console.log("someone ate someone!!!");
}

function find_food (id) {
	for (var i = 0; i < game_instance.food_pickup.length; i++) {
		if (game_instance.food_pickup[i].id == id) {
			return game_instance.food_pickup[i]; 
		}
	}
	
	return false;
}

function sortPlayerListByScore() {
	player_lst.sort(function(a,b) {
		return b.size - a.size;
	});
	
	var playerListSorted = [];
	for (var i = 0; i < player_lst.length; i++) {
		playerListSorted.push({id: player_lst[i].id, size: player_lst[i].size});
	}
	console.log(playerListSorted);
	io.emit("leader_board", playerListSorted);
}

function onitemPicked (data) {
	var movePlayer = find_playerid(this.id); 

	var object = find_food(data.id);	
	if (!object) {
		console.log(data);
		console.log("could not find object");
		return;
	}
	
	//increase player size
	movePlayer.size += 3; 
	//broadcast the new size
	this.emit("gained", {new_size: movePlayer.size}); 
	
	game_instance.food_pickup.splice(game_instance.food_pickup.indexOf(object), 1);
	sortPlayerListByScore();
	console.log("item picked");

	io.emit('itemremove', object); 
	this.emit('item_picked');
}

function playerKilled (player) {
	//find the player and remove it.
	var removePlayer = find_playerid(player.id); 
		
	if (removePlayer) {
		player_lst.splice(player_lst.indexOf(removePlayer), 1);
	}
	
	player.dead = true; 
}

function getRndInteger(min, max) {
    return Math.floor(Math.random() * (max - min + 1) ) + min;
}

//call when a client disconnects and tell the clients except sender to remove the disconnected player
function onClientdisconnect() {
	console.log('disconnect'); 

	var removePlayer = find_playerid(this.id); 
		
	if (removePlayer) {
		player_lst.splice(player_lst.indexOf(removePlayer), 1);
	}
	
	console.log("removing player " + this.id);
	
	sortPlayerListByScore();
	//send message to every connected client except the sender
	this.broadcast.emit('remove_player', {id: this.id});
	
}

// find player by the the unique socket id 
function find_playerid(id) {

	for (var i = 0; i < player_lst.length; i++) {

		if (player_lst[i].id == id) {
			return player_lst[i]; 
		}
	}
	
	return false; 
}

 // io connection 
var io = require('socket.io')(serv,{});

io.sockets.on('connection', function(socket){
	console.log("socket connected"); 
	
	// listen for disconnection; 
	socket.on('disconnect', onClientdisconnect); 
	
	// listen for new player
	socket.on("new_player", onNewplayer);
	/*
	//we dont need this anymore
	socket.on("move_player", onMovePlayer);
	*/
	//listen for new player inputs. 
	socket.on("input_fired", onInputFired);
	
	socket.on("player_collision", onPlayerCollision);
	
	//listen if player got items 
	socket.on('item_picked', onitemPicked);
});
			

We could send the sorted player list in the “heartbeat” function. However, this is not a very efficient method to update the leaderboard in the client side. We can limit the rate by sending the updated list in certain situations. These situations are:

When the player picks up food

When the player kills someone

When the new player connects

When the player disconnects (exits without dying).

We use the sortPlayerListByScore() method to sort the player list, and send the updated list back to the player. We can easily sort the player_lst that we have by using the Array.sort javascript method. It takes in a function that needs to return an integer. In this case, if b is greater than a, then b has to be in front of a.

We are going to create an input username feature later in the series, but for this part, we are going to use socket.id as our username. You might question “why do you make a new playerListSorted local variable to store player ranks”. This is because our player class size is too big. You might recall that we attached a phaser “player body”. This field is too big to be sent. If we don’t we will get “maximum call stack” error.

What if there are too many people to be in one game?. In the next tutorial, we will go over “rooms” to create multiple games with fixed number of players in each game.

<< Part 4 Part 6 >>