Back

Mutiplayer Shooter

In this tutorial we will experimet with networking using WebSockets and we will create a simple networked multiplayer game. For this tutorial node.js will be used to develop the server aplication. I have decided to use node.js simply because it uses the same language (javascript) used inside nunuStudio, but you can choose another framework/language to implement the server.

Lets start by covering the basics about WebSockets, what they are and how can we use them to create a web based multiplayer game, websockets are an technology that makes it possible to open an interactive communication session between the user's browser and a server, using them its possible to send messages to a server and receive event-driven responses without having to poll the server for a reply.

A single websocket server can be used to interconnect multiple clients, the server works as an intermediary in data exchange, lets take a message chat as an example, each clients connects to the server and tells the server who he is, when sending a message the client tells the server for who the message is destinated and the server redirects that message to its destination, that is also is a client connected to the server.

Lets start by installing the required nodejs dependencies for this tutorial, we will only need the websocket dependency use the command bellow to install it using your computer terminal/command.

npm -g install websocket

To set up our server code, we will start by importing the required node modules, after that a http server instance is created and configured with the correct port (we can use any port we want to), after that we create a WebSocketServer that will allow us to send and receive messages to the clients connected to the server. During the tutorial for convenience we will only use JSON messages but we can send any type of data.

The code bellow will be used as a base for our server, it takes care of all the steps necessary to establish connection with a client, from now on we will only focus on messages exchange. For the tutorial we will be using the port 1111 and localhost for communication make sure that you change this to the correct IP and configure port access if you are using an external server.

var WebSocketServer = require("websocket").server;
var http = require("http");

var port = 1111;

var server = http.createServer();
server.listen(port);

console.log("Server running at port " + port);

var wsServer = new WebSocketServer({httpServer: server});
wsServer.on("request", function(request)
{
	var connection = request.accept(null, request.origin);

	connection.on("message", function(message)
	{
		//Message handling code here
	});
	
	connection.on("close", function(connection)
	{
		//User disconnected code here
	});
});

The code bellow is the client part that we will write inside nunuStudio, as we can see the code to connect to a WebSocket is really simple and actually similar to the one used to create the server itself, when starting the client part in nunuStudio you should be able to see a "Connected to server" message in the console.

var connection, connected = false;

function initialize()
{	
	connection = new WebSocket("ws://127.0.0.1:1337");
	
	connection.onopen = function()
	{
		connected = true;
		console.log("Connected to server");
	};
}

Now lets send a simple message to the server and create a response to the message we sent, we will include in every message sent a type value to allow the server to differentiate message and a uuid to allow the server to distinguish clients. To send a message we will use the WebSocket send method, all of our message will be JSON encoded so we have to call JSON.stringify on our client to transform our objects to text format and JSON.parse to recreate the object on the server.

The code bellow is the the client side.

uuid = THREE.Math.generateUUID();
...
connection.onopen = function()
{
	console.log("Connected to server");
	connection.send(JSON.stringify(
	{
		type:"connected",
		uuid:uuid
	}));
};

The code bellow is the server side.

connection.on("message", function(message)
{
	console.log("Message received");
	var data = JSON.parse(message.utf8Data);
});

If everything is working as expected we should now be able to exchange messages between the client and the server, we can use the serialization method provided by nunuStudio to send and update states of objects in our scene between multiple clients.

Let’s now start creating our level, let’s start with a couple of walls preferably in a defined position in my case I have placed my walls exactly at position 20 on the X axis and 12 on the Z axis, knowing the wall position will be helpful later for manual collision checking.

After creating the level lets now create our player, i started by just using a cube, and programmed the movement of the player, the player will move using WASD keys and will always be rotated in the mouse direction, when the mouse button is pressed the player will shoot a bullet.

To make the player always look at the mouse position we need to get world coordinates for where our mouse is pointing, for that we can use the ray caster object available in our scene and use it to check mouse intersection with the ground object.

The code bellow implements all the player initialization and movement. After the player status is updated an "update" message will be sent to the server, when the server receives this message it will redistribute it to all other clients so that they can update the remote player instance status.

function initialize()
{		
	//Player
	player = scene.getObjectByName("player");
	player.material.color.setHex(Math.random() * 0xFFFFFF);
	player.uuid = THREE.Math.generateUUID();
	player.canFire = true;
	player.alive = true;
	
	//Ground
	ground = scene.getObjectByName("ground");

	//Clock
	clock = new Clock();
	clock.start();

	...
}

function update()
{
	var delta = clock.getDelta();

	if(connected)
	{
		if(player.alive)
		{
			//Move player
			var speed = delta * 10;
			if(Keyboard.keyPressed(Keyboard.W)) player.position.z -= speed;
			if(Keyboard.keyPressed(Keyboard.S)) player.position.z += speed;
			if(Keyboard.keyPressed(Keyboard.A)) player.position.x -= speed;
			if(Keyboard.keyPressed(Keyboard.D)) player.position.x += speed;

			//Limit player movement
			if(player.position.x > 19) player.position.x = 19;
			if(player.position.x < -19) player.position.x = -19;
			if(player.position.z > 11) player.position.z = 11;
			if(player.position.z < -11) player.position.z = -11;			

			//Mouse rotation
			var intersect = scene.raycaster.intersectObject(ground);
			if(intersect.length > 0)
			{
				var point = intersect[0].point;
				point.y = player.position.y;
				player.lookAt(point);

				//Fire bullet
				if(player.canFire && Mouse.buttonJustPressed(Mouse.LEFT))
				{
					//TODO Create bullet

					player.canFire = false;
					setTimeout(function()
					{
						player.canFire = true;
					}, 500);
				}
			}
		}
	}
}

If you want to you can customize your player to look a bit better, i added a couple eyes to mine and a stick that will act as its weapon.

Now let’s synchronize the position between multiple clients, let’s create a simple data structure to store Players in our server and in our clients. The code bellow implements the base data structures required in the server, the server will store Players and Connections in a list and redistribute the player status to all other players in the server.

var players = [], clients = [];

function Player(uuid, color)
{
	this.uuid = uuid;
	this.color = color;
	this.position = null;
	this.rotation = null;
}

function Client(uuid, connection)
{
	this.uuid = uuid;
	this.connection = connection;
}

When the player connects to the server it needs to send a "connected" message with initial data about the player like its UUID and the color used to represent it in the world. When the server receives this message the player is added to the players and connections arrays. When the player updates its state the "update" message is send, this message contains the player actual position and rotation and when it disconnects or dies a "disconnected" message is sent.

When the client receives an "update" message from the server (the server only redistributes these messages), the client needs to check if the player is already known if thats the case its position and rotation is updated, otherwise the player is created with the color and uuid indicated in the message, when a "disconnected" message is received the player indicated is destroyed.

The code bellow is the client part, with all code necessary to add, update and remove players from the game.

var players = [];

function initialize()
{	
	...

	websocket.onopen = function()
	{
		websocket.send(JSON.stringify(
		{
			type: "connected",
			uuid: player.uuid,
			color: player.material.color.getHex()
		}));
		...
	};
	
	websocket.onmessage = function(message)
	{
		var data = JSON.parse(message.data);
		
		if(data.type === "update")
		{	
			var uuid = data.uuid;

			if(data.uuid !== player.uuid)
			{
				var object = players[data.uuid];

				if(object === undefined)
				{
					var material = new MeshPhongMaterial({color: data.color});
					var object = player.clone();
					object.material = material;
					object.uuid = data.uuid;
					object.color = data.color;
					scene.add(object);
					players[uuid] = object;
				}

				object.position.fromArray(data.position);
				object.rotation.fromArray(data.rotation);
			}
		}
		else if(data.type === "disconnect")
		{
			if(players[data.uuid] !== undefined)
			{
				players[data.uuid].destroy();
				delete players[data.uuid];
			}
		}
	};
}

function update()
{
	...

	if(connected)
	{
		...

		//Update message
		websocket.send(JSON.stringify(
		{
			type: "update",
			uuid: player.uuid,
			position: player.position.toArray(),
			rotation: player.rotation.toArray()
		}));
	}
}

The code bellow is the server part, for the server two arrays are maintaned one with connections and one with players.

server.on("request", function(request)
{
	var connection = request.accept(null, request.origin);

	//Message
	connection.on("message", function(message)
	{
		var data = JSON.parse(message.utf8Data);
		
		//Connected
		if(data.type === "connected")
		{
			players.push(new Player(data.uuid, data.color));
			clients.push(new Client(data.uuid, connection));

			console.log("Player " + data.uuid + " connected");
		}
		//Update
		else if(data.type === "update")
		{
			var player = getPlayer(data.uuid);

			if(player !== null)
			{
				player.position = data.position;
				player.rotation = data.rotation;
			}

			for(var i = 0; i < clients.length; i++)
			{
				clients[i].connection.sendUTF(message.utf8Data);
			}				
		}
		//Disconnected
		else if(data.type === "disconnect")
		{
			removePlayer(data.uuid);

			for(var i = 0; i < clients.length; i++)
			{
				clients[i].connection.sendUTF(message.utf8Data);
			}

			console.log("Player " + data.uuid + " disconnected");
		}
	});
}

If everything is working as expected you should be able to connect multiple clients to the server and see them move around, to test with multiple clients you can export a web version of the project and open inside your browser of choice.

We are almost finished, just need to add the bullets and we are ready for the bullets we will do something similar to the player movement but instead of having the bullet position being updated by the client who sent it each client will update the bullets position and check collision locally, this should allow to reduce the amount of data transferred trough the server.

The code bellow implements the bullet creation and update, all bullets are store in an array and updated every frame, when a player shoots a bullet a "bullet" message is sent to the server and redistributed to all clients. If the bullets hits the player the player dies and a "disconnected" message is sent to the server.

function update()
{
	...

	{
		...
		{
			...
			//Mouse rotation
			var intersect = scene.raycaster.intersectObject(ground);
			if(intersect.length > 0)
			{
				var point = intersect[0].point;
				point.y = player.position.y;
				player.lookAt(point);

				//Fire bullet
				if(player.canFire && Mouse.buttonJustPressed(Mouse.LEFT))
				{
					var bullet = new Mesh(bulletGeometry, bulletMaterial);
					bullet.owner = player.uuid;
					bullet.velocity = point;
					bullet.velocity.sub(player.position);
					bullet.velocity.normalize();
					bullet.velocity.multiplyScalar(20);
					bullet.position.copy(player.position);
					bullets.push(bullet);
					scene.add(bullet);

					player.canFire = false;
					setTimeout(function()
					{
						player.canFire = true;
					}, 500);
					
					websocket.send(JSON.stringify(
					{
						type: "bullet",
						uuid: player.uuid,
						position: bullet.position.toArray(),
						velocity: bullet.velocity.toArray()
					}));
				}
			}
		}
		
		//Update bullets
		for(var i = 0; i < bullets.length; i++)
		{
			var bullet = bullets[i];

			bullet.position.x += bullet.velocity.x * delta;
			bullet.position.y += bullet.velocity.y * delta;
			bullet.position.z += bullet.velocity.z * delta;

			//Check bullet out of arena
			if(bullet.position.x > 20 || bullet.position.x < -20 || bullet.position.z > 12 || bullet.position.z < -12)
			{
				bullet.destroy();
				bullets.splice(i, 1);
				continue;
			}

			//Check collision with players
			if(bullet.owner !== player.uuid && bullet.position.distanceTo(player.position) < 0.8)
			{
				bullet.destroy();
				bullets.splice(i, 1);
				
				player.destroy();
				player.alive = false;

				websocket.send(JSON.stringify(
				{
					type: "disconnect",
					uuid: player.uuid
				}));
				break;
			}
		}

		...
	}	
}

In the server side the "bullet" message is only distributed to all connected clients.

connection.on("message", function(message)
{
	...
	else if(data.type === "bullet")
	{
		for(var i = 0; i < clients.length; i++)
		{
			clients[i].connection.sendUTF(message.utf8Data);
		}

		console.log("Player " + data.uuid + " fired bullet!");
	}
	...
}

The game is pretty much ready now, we just need to add some code to make sure that the clients sends a disconnected message when the user closes the window, this can be easily done by adding a dispose function to the script. The dispose message is called automatically when the application terminates.

function dispose()
{
	websocket.send(JSON.stringify(
	{
		type: "disconnect",
		uuid: player.uuid
	}));
	
	websocket.close();
}

If you were able to follow all the steps congratulations, the mechanism explain in this tutorial for message exchange using websockets can easily adapted for other type of applications and used outside of nunuStudio.

I hope this tutorial was helpful, you can donwload the project files here. Any question feel free to email me or open an issue in GitHub.