Paiza Engineering Blog

Engineering blog of browser-based web development environment PaizaCloud Cloud IDE ( https://paiza.cloud/ ), online compiler and editor Paiza.IO( https://paiza.IO/ )

Creating a simple 3D online multiplayer battle game using JavaScript and Node.js

f:id:paiza:20180621152554g:plain

Play it!

(Japanese article is here)

f:id:paiza:20151217152725j:plainHello, I'm Tsuneo!(@)

Do you like online multiplayer battle games?

With the online multiplayer game, we can have fun with friends, or anyone in the world!

Now, 3D online battle royal games like PUBG or Fortnite is also getting popular.

How about creating the 3D online multiplayer battle game?

It used to be difficult to create online multiplayer games as it requires server program, client program with network programming and 3D programming with many libraries, frameworks, or even languages.

However, now, as we can build all of those just with JavaScript, it is getting easier to build the 3D online multiplayer games!

So, in this article, we'll build a 3D online multiplayer battle game with JavaScript like below! (And yes, as it runs in the browser, we can run it on both PC and smartphones.)

f:id:paiza:20180621141501g:plain

Play it!

Development Environment

We'll build the game using Node.js(Server Side JavaScript), Three.js(for WebGL), and Socket.IO(for networking).

While it is getting easier to build, to develop applications in practice, you need to install and setup Node.js and related commands or packages. These installations and setting up can be frustrating. Just following the installation instruction does not work or cause errors because of OS, versions, other software dependencies, etc.

Also, if you publish the service, feedback from your friends or others motivate you. But, this requires "deployment" of the service. The "deployment" also frustrates us...

So, here comes PaizaCloud Cloud IDE, a browser-based online web and application development environment.

As PaizaCloud have Node.js application development environment, you can just start coding for the Node.js application program in your browser.

And, as you can develop in the cloud, you can just run the Node.js application on the same machine without setting up another server and deploying to it.

Here, at first, we'll create a 2D online multiplayer battle game, and evolve it to a 3D online multiplayer game.

Following the instruction below, you'll build and run the 3D online multiplayer game just in about 1 hour.

You can play the 3D online multiplayer game here .

Source code is available on GitHub.

Getting started with PaizaCloud Cloud IDE

Let's start!

Here is the website of PaizaCloud Cloud IDE.

https://paiza.cloud/

Just sign up with email and click a link in the confirmation email. You can also sign up with GitHub or Google.

Create new server

Let's create a new server for the development workspace.

f:id:paiza:20171214154558p:plain

Click "new server" to open a dialog to set up the server. Just click "New Server" button in the dialog without any settings.

f:id:paiza:20171219143410p:plain

Just in 3 seconds, you'll get a browser-based online development environment for Node.js development.

f:id:paiza:20180116171931p:plain

Create a project

So, let's create the online multiplayer battle game using Node.js.

At first, to create the Node.js application, we use "npm init" command.

On PaizaCloud, you can use a Terminal application in the browser to run the command.

Click "Terminal" button at the left side of the PaizaCloud page.

f:id:paiza:20171214154805p:plain

The Terminal launched. Type the command "npm init -y [APPLICATION NAME]", and type enter key. "[APPLICATION NAME]" can be anything like "game-app" or "music-app".

Here, we use application name "myapp", so run the command "npm init -y myapp", and type enter key.

$ npm init -y myapp

f:id:paiza:20180607142828p:plain

On the file management view at the left-side of the page, we see the file "package.json" created. The "package.json" is the file to manage Node.js packages used on the application.

f:id:paiza:20180607140318p:plain

To create the web application with Node.js, we'll need Express as a web application framework. So, let's install the Express package. To install Node.js package, we can use the command "npm install [PACKAGE NAME] --save".

$ npm install express --save

The directory "node_modules" is created. The directory contains the packages used by the application.

Now, let's create the Node.js program. At first, we'll create a simple program just showing a text "Hello Node.js!".

On PaizaCloud, you can create and edit the file in the browser. To create the program file, on the PaizaCloud, click "New File" button on the left-side of the page, or right-click file management view to open the context menu and choose "New File".

f:id:paiza:20171219143513p:plain

As a dialog box to put filename is shown, type a filename "server.rb" and click "Create" button.

f:id:paiza:20180626165630p:plain The file is created!

Edit the "server.js" like below.

server.js:

const express = require('express');
const app = express();

app.get('/', (req, res) => {
  res.send('Hello Node.js!');
});

app.listen(3000, () => {
    console.log("Starting server on port 3000!");
});

f:id:paiza:20180626165746p:plain After editing the code click "Save" button or type "Command-S" or "Ctrl-S" to save the file.

Let's see the code.

On the first and second line, it loads "express" library.

On line 4, "app.get('/'..." specify the action for the top page(URL path "/") as a function. It send a message 'Hello Node.js!' using "res.send()" function.

On line 8, "app.listen(3000,...)" run the Node.js server on port 3000.

Start Node.js

Now, you can run the application. Let's start the application.

On PaizaCloud, click "Terminal" icon to start Terminal and type "node server.js" to start the server!

$ node server.js

f:id:paiza:20180626170009p:plain

You'll get a new button with text "3000" on the left side of the page.

f:id:paiza:20171213234820p:plain

The server runs on port 3000. PaizaCloud Cloud IDE detects the port number(3000), and automatically adds the button to open a browser for the port.

Click the button, and you'll get Browser application(a Browser application in the PaizaCloud). Now, you'll see a web page with the message "Hello Node.js!", that is your application!

f:id:paiza:20180607141152p:plain

While you can run the Node.js server with "node server.js" command, it needs to restart when we change any code. So, from now, we use "nodemon" command that automatically restarts server when the program is changed.

$ npm install nodemon -g --save
$ nodemon server.js

Simple Game(Server)

Next, let's create a simple game where there are only players.

Let's build a server program. Edit the file "server.js" like below:

server.js:

'use strict';

const express = require('express');
const http = require('http');
const path = require('path');
const socketIO = require('socket.io');
const app = express();
const server = http.Server(app);
const io = socketIO(server);

const FIELD_WIDTH = 1000, FIELD_HEIGHT = 1000;
class Player{
    constructor(obj={}){
        this.id = Math.floor(Math.random()*1000000000);
        this.width = 80;
        this.height = 80;
        this.x = Math.random() * (FIELD_WIDTH - this.width);
        this.y = Math.random() * (FIELD_HEIGHT - this.height);
        this.angle = 0;
        this.movement = {};
    }
    move(distance){
        this.x += distance * Math.cos(this.angle);
        this.y += distance * Math.sin(this.angle);
    }
};

let players = {};

io.on('connection', function(socket) {
    let player = null;
    socket.on('game-start', (config) => {
        player = new Player({
            socketId: socket.id,
        });
        players[player.id] = player;
    });
    socket.on('movement', function(movement) {
        if(!player){return;}
        player.movement = movement;
    });
    socket.on('disconnect', () => {
        if(!player){return;}
        delete players[player.id];
        player = null;
    });
});

setInterval(function() {
    Object.values(players).forEach((player) => {
        const movement = player.movement;
        if(movement.forward){
            player.move(5);
        }
        if(movement.back){
            player.move(-5);
        }
        if(movement.left){
            player.angle -= 0.1;
        }
        if(movement.right){
            player.angle += 0.1;
        }
    });
    io.sockets.emit('state', players);
}, 1000/30);

app.use('/static', express.static(__dirname + '/static'));

app.get('/', (request, response) => {
  response.sendFile(path.join(__dirname, '/static/index.html'));
});

server.listen(3000, function() {
  console.log('Starting server on port 3000');
});

After editing the code click "Save" button or type "Command-S" or "Ctrl-S" to save the file.

Let's see the code.

'use strict';

Use 'use strict' to specify using the latest JavaScript version(ECMAScript6).

const express = require('express');
const http = require('http');
const path = require('path');
const socketIO = require('socket.io');
const app = express();
const server = http.Server(app);
const io = socketIO(server);

Load libraries and create objects used on the server. Here, we use libraries "express", "path", "socket.io".

const FIELD_WIDTH = 1000, FIELD_HEIGHT = 1000;

Define the size of the game screen. Here, we set both width and height to 1000.

class Player{
    constructor(){
        this.id = Math.floor(Math.random()*1000000000);
        this.width = 80;
        this.height = 80;
        this.x = Math.random() * (FIELD_WIDTH - this.width);
        this.y = Math.random() * (FIELD_HEIGHT - this.height);
        this.angle = 0;
        this.movement = {};
    }
    move(distance){
        this.x += distance * Math.cos(this.angle);
        this.y += distance * Math.sin(this.angle);
    }
};

Next, create a Player class to manage players. The Player class have instance variables "id" to identify the player, "with" and "height" for the size of the player, "x" and "y" for the position of the player, "angle" for the direction of the player, "movement" to store the player's current movement(which have forward, back, left or right boolean properties).

On the constructor, set "id" to random value, set size("width" and "height") to 80, set the player's position("x", "y") to the random value, and set "angle" to 0(right).

move() function moves the player's position with "distance" argument. We can get x-axis distance using Math.cos(), and y-axis distance using Math.sin(), and add those values to the current position.

let players = {};

Manage the list of player using "player" variable.

io.on('connection', function(socket) {
    let player = null;
    socket.on('game-start', (config) => {
        player = new Player();
        players[player.id] = player;
    });
    socket.on('movement', function(movement) {
        if(!player){return;}
        player.movement = movement;
    });
    socket.on('disconnect', () => {
        if(!player){return;}
        delete players[player.id];
        player = null;
    });
});

The Node.js server and browser(client) communicate using Socket.IO. io.on('connection',...) set the callback function when the connection is established. So, let's write codes for networking here.

socket.on('game-start',...) handle the message for starting game. Here, it creates a "Player" object and add it to the "players" variable.

socket.on('movement',...) handle the message for the player's movement. It just set the value received to the player's "movement" variable.

On socket.on('disconnect',...), callback function is called then the connection is closed(Ex: The browser is closed, the page is reloaded or changed.) Here, it removes the player from "players" variable.

setInterval(() => {
    Object.values(players).forEach((player) => {
        const movement = player.movement;
        if(movement.forward){
            player.move(5);
        }
        if(movement.back){
            player.move(-5);
        }
        if(movement.left){
            player.angle -= 0.1;
        }
        if(movement.right){
            player.angle += 0.1;
        }
    });
    io.sockets.emit('state', players);
}, 1000/30);

Use "setIntervall" to move players every 1/30 seconds. For each player on "players" variable, move the player using "move" method or change "angle" based on "movement" variable.

app.use('/static', express.static(__dirname + '/static'));

app.use() manage middlewares. Here, use "express.static" to return static files for the URL path begin with '/static'.

app.get('/', (request, response) => {
  response.sendFile(path.join(__dirname, '/static/index.html'));
});

The top page returns the file "static/index.html".

server.listen(3000, function() {
  console.log('Starting server on port 3000');
});

At last, start the Node.js server on port 3000.

Simple game(HTML)

Then, edit an HTML page for the top page "static/index.html".

On PaizaCloud file management view, right-click home directory("/home/ubuntu") to open the context menu, choose "New Directory" to create a directory named "static". Right-click the "static" directory and choose "New File" menu to create a file "index.html".

Edit the create "static/index.html" like below.

static/index.html:

<html>
  <head>
    <title>Paiza Battle Ground</title>
    <meta name="viewport" content="width=device-width,initial-scale=1.0,minimum-scale=1.0,maximum-scale=1.0,user-scalable=no">
    <script src="/socket.io/socket.io.js"></script>
    <script src="https://ajax.googleapis.com/ajax/libs/jquery/3.3.1/jquery.min.js"></script>
  </head>
  <body style="width:100%;height:100%;margin:0;">
    <canvas id="canvas-2d" width="1000" height="1000" style="width:100%;height:100%;object-fit:contain;"></canvas>
    <img id="player-image" src="https://paiza.jp//images/bg_ttl_01.gif" style="display: none;">
    <script src="/static/game.js"></script>
  </body>
</html>

Let's see the HTML file.

    <script src="/socket.io/socket.io.js"></script>
    <script src="https://ajax.googleapis.com/ajax/libs/jquery/3.3.1/jquery.min.js"></script>

HEAD element have SCRIPT elements that load libraries: "socket.IO" and "jQuery".

    <canvas id="canvas-2d" width="1000" height="1000" style="width:100%;height:100%;object-fit:contain;"></canvas>

The canvas element is for the game screen. As the canvas can show 2D figures, we use it for drawing players. The width and height for canvas size are 1000, and the size on the page is 100%(all of the page).

    <img id="player-image" src="/static/player.gif" style="display: none;">

Set the image for the player we'll upload later on. As the image is only for drawing canvas, we set the style to "display: no" so that we don't show the image here.

    <script src="/static/game.js"></script>

At last, load a JavaScript program "static/game.js" we'll create. Add the line at the end of BODY element so that the script runs after all the HTML file is loaded.

Simple game(Player image)

Prepare a player image file("player.gif"). Here, we use a paiza logo image below.

f:id:paiza:20180620183108g:plain

On PaizaCloud, we can upload files using Drag and Drop.

Save the "player.gif" file on the desktop, and Drag and Drop to "static" directory on the file management view of PaizaCloud to upload.

f:id:paiza:20180620183145p:plain

Simple game(client)

Next, create a JavaScript program that runs in the browser(client).

On PaizaCloud, right-click "static" directory on the file management view, click "New File" to create a file "game.js".

Edit the "game.js" file like below.

static/game.js:

'use strict';

const socket = io();
const canvas = $('#canvas-2d')[0];
const context = canvas.getContext('2d');
const playerImage = $('#player-image')[0];
let movement = {};


function gameStart(){
    socket.emit('game-start');
}

$(document).on('keydown keyup', (event) => {
    const KeyToCommand = {
        'ArrowUp': 'forward',
        'ArrowDown': 'back',
        'ArrowLeft': 'left',
        'ArrowRight': 'right',
    };
    const command = KeyToCommand[event.key];
    if(command){
        if(event.type === 'keydown'){
            movement[command] = true;
        }else{ /* keyup */
            movement[command] = false;
        }
        socket.emit('movement', movement);
    }
});

socket.on('state', (players, bullets, walls) => {
    context.clearRect(0, 0, canvas.width, canvas.height);

    context.lineWidth = 10;
    context.beginPath();
    context.rect(0, 0, canvas.width, canvas.height);
    context.stroke();

    Object.values(players).forEach((player) => {
        context.drawImage(playerImage, player.x, player.y);
        context.font = '30px Bold Arial';
        context.fillText('Player', player.x, player.y - 20);
    });
});

socket.on('connect', gameStart);

Let's see the code.

const socket = io();

Connect to the server using Socket.IO. We use the "socket" variable to communicate with the server.

const canvas = $('#canvas-2d')[0];
const context = canvas.getContext('2d');

Get canvas object for the game screen on the HTML. As we need drawing context to draw canvas, get drawing context using "getContext('2d')".

const playerImage = $('#player-image')[0];

Get a player image on the IMG element from HTML.

let movement = {};

"movement" represents the player's movement. It have boolean properties "forward", "back", "left", "right".

function gameStart(){
    socket.emit('game-start');
}

gameStart() is to start the game. Here, send 'game-start' message to the server.

$(document).on('keydown keyup', (event) => {
    const KeyToCommand = {
        'ArrowUp': 'forward',
        'ArrowDown': 'back',
        'ArrowLeft': 'left',
        'ArrowRight': 'right',
    };
    const command = KeyToCommand[event.key];
    if(command){
        if(event.type === 'keydown'){
            movement[command] = true;
        }else{ /* keyup */
            movement[command] = false;
        }
        socket.emit('movement', movement);
    }
});

To move the player using a keyboard, handle 'keydown' and 'keyup' events. The key name pressed is "event.key". Convert the key name to movement properties("forward", "back", "left", "right") using "KeyToCommand" hash.

movement[command] store the key state. It is true when the key is on, and false when the key is off. Send the movement object to the server using "socket.emit()".

socket.on('state', (players) => {
    context.clearRect(0, 0, canvas.width, canvas.height);

    context.lineWidth = 10;
    context.beginPath();
    context.rect(0, 0, canvas.width, canvas.height);
    context.stroke();

    Object.values(players).forEach((player) => {
        context.drawImage(playerImage, player.x, player.y);
        context.font = '30px Bold Arial';
        context.fillText('Player', player.x, player.y - 20);
    });
});

On state.on('state',...), the specified callback function is called when the 'state' message(the player's state) is received. So, draw the player based on the state.

Clear the canvas with "clearRect()", and draw border rectangle of the game screen using "context.rect()". The functions like rect() to draw path requires "context.beginPath()" before the function, and "context.stroke()" after the function.

For each player, draw the image using "drawImage()", and write text 'Player' using "fillText()".

Run Simple game

Let's run the program.

To make sure, type Ctrl-C to exit current server.

Type "nodemon server.js" to start the server!

$ nodemon server.js

You'll get a new button with text "3000" on the left side of the page. Click the button, and you'll get Browser application(a Browser application in the PaizaCloud).

f:id:paiza:20180620183217p:plain

Now, you'll see the players! Let's move the player with arrow keys.

You can run multiple browsers to run multiple players!

If it does not run, right-click the browser and choose "inspect" menu to see the error message. Confirm that the "static/player.gif" file exists on the server.

2D game(server)

While the "Simple game" is a kind of online multiplayer game, there are only players who can only move. Next, let's make the players shoot bullets to fight each other. Also, let's add walls, and play cannot move over the player or boundary of the game board.

Let's start with the server code.

Open "server.js" and edit it like below.

server.js:

'use strict';

const express = require('express');
const http = require('http');
const path = require('path');
const socketIO = require('socket.io');
const app = express();
const server = http.Server(app);
const io = socketIO(server);


const FIELD_WIDTH = 1000, FIELD_HEIGHT = 1000;
class GameObject{
    constructor(obj={}){
        this.id = Math.floor(Math.random()*1000000000);
        this.x = obj.x;
        this.y = obj.y;
        this.width = obj.width;
        this.height = obj.height;
        this.angle = obj.angle;
    }
    move(distance){
        const oldX = this.x, oldY = this.y;
        
        this.x += distance * Math.cos(this.angle);
        this.y += distance * Math.sin(this.angle);
        
        let collision = false;
        if(this.x < 0 || this.x + this.width >= FIELD_WIDTH || this.y < 0 || this.y + this.height >= FIELD_HEIGHT){
            collision = true;
        }
        if(this.intersectWalls()){
            collision = true;
        }
        if(collision){
            this.x = oldX; this.y = oldY;
        }
        return !collision;
    }
    intersect(obj){
        return (this.x <= obj.x + obj.width) &&
            (this.x + this.width >= obj.x) &&
            (this.y <= obj.y + obj.height) &&
            (this.y + this.height >= obj.y);
    }
    intersectWalls(){
        return Object.values(walls).some((wall) => {
            if(this.intersect(wall)){
                return true;
            }
        });
    }
    toJSON(){
        return {id: this.id, x: this.x, y: this.y, width: this.width, height: this.height, angle: this.angle};
    }
};

class Player extends GameObject{
    constructor(obj={}){
        super(obj);
        this.socketId = obj.socketId;
        this.nickname = obj.nickname;
        this.width = 80;
        this.height = 80;
        this.health = this.maxHealth = 10;
        this.bullets = {};
        this.point = 0;
        this.movement = {};

        do{
            this.x = Math.random() * (FIELD_WIDTH - this.width);
            this.y = Math.random() * (FIELD_HEIGHT - this.height);
            this.angle = Math.random() * 2 * Math.PI;
        }while(this.intersectWalls());
    }
    shoot(){
        if(Object.keys(this.bullets).length >= 3){
            return;
        }
        const bullet = new Bullet({
            x: this.x + this.width/2,
            y: this.y + this.height/2,
            angle: this.angle,
            player: this,
        });
        bullet.move(this.width/2);
        this.bullets[bullet.id] = bullet;
        bullets[bullet.id] = bullet;
    }
    damage(){
        this.health --;
        if(this.health === 0){
            this.remove();
        }
    }
    remove(){
        delete players[this.id];
        io.to(this.socketId).emit('dead');
    }
    toJSON(){
        return Object.assign(super.toJSON(), {health: this.health, maxHealth: this.maxHealth, socketId: this.socketId, point: this.point, nickname: this.nickname});
    }
};
class Bullet extends GameObject{
    constructor(obj){
        super(obj);
        this.width = 15;
        this.height = 15;
        this.player = obj.player;
    }
    remove(){
        delete this.player.bullets[this.id];
        delete bullets[this.id];
    }
};
class BotPlayer extends Player{
    constructor(obj){
        super(obj);
        this.timer = setInterval(() => {
            if(! this.move(4)){
                this.angle = Math.random() * Math.PI * 2;
            }
            if(Math.random()<0.03){
                this.shoot();
            }
        }, 1000/30);
    }
    remove(){
        super.remove();
        clearInterval(this.timer);
        setTimeout(() => {
            const bot = new BotPlayer({nickname: this.nickname});
            players[bot.id] = bot;
        }, 3000);
    }
};
class Wall extends GameObject{
};

let players = {};
let bullets = {};
let walls = {};

for(let i=0; i<3; i++){
    const wall = new Wall({
            x: Math.random() * FIELD_WIDTH,
            y: Math.random() * FIELD_HEIGHT,
            width: 200,
            height: 50,
    });
    walls[wall.id] = wall;
}

const bot = new BotPlayer({nickname: 'bot'});
players[bot.id] = bot;

io.on('connection', function(socket) {
    let player = null;
    socket.on('game-start', (config) => {
        player = new Player({
            socketId: socket.id,
            nickname: config.nickname,
        });
        players[player.id] = player;
    });
    socket.on('movement', function(movement) {
        if(!player || player.health===0){return;}
        player.movement = movement;
    });
    socket.on('shoot', function(){
        console.log('shoot');
        if(!player || player.health===0){return;}
        player.shoot();
    });
    socket.on('disconnect', () => {
        if(!player){return;}
        delete players[player.id];
        player = null;
    });
});

setInterval(() => {
    Object.values(players).forEach((player) => {
        const movement = player.movement;
        if(movement.forward){
            player.move(5);
        }
        if(movement.back){
            player.move(-5);
        }
        if(movement.left){
            player.angle -= 0.1;
        }
        if(movement.right){
            player.angle += 0.1;
        }
    });
    Object.values(bullets).forEach((bullet) =>{
        if(! bullet.move(10)){
            bullet.remove();
            return;
        }
        Object.values(players).forEach((player) => {
           if(bullet.intersect(player)){
               if(player !== bullet.player){
                   player.damage();
                   bullet.remove();
                   bullet.player.point += 1;
               }
           } 
        });
        Object.values(walls).forEach((wall) => {
           if(bullet.intersect(wall)){
               bullet.remove();
           }
        });
    });
    io.sockets.emit('state', players, bullets, walls);
}, 1000/30);


app.use('/static', express.static(__dirname + '/static'));

app.get('/', (request, response) => {
  response.sendFile(path.join(__dirname, '/static/index.html'));
});

server.listen(3000, function() {
  console.log('Starting server on port 3000');
});

So, let's see the codes.

In the "Simple Game" have only one class "Player". "2D game" also have "Wall" class and "Bullet" class. As those "Player", "Wall", and "Bullet" class are objects existing on the game board. So, we create a GameObject class for common parts for those classes on the game board.

Also, for the test playing or for the case there is no other players on the game board, we create a bot player class "BotPlayer" inherited from the "Player" class.

class GameObject{
    constructor(obj={}){
        this.id = Math.floor(Math.random()*1000000000);
        this.x = obj.x;
        this.y = obj.y;
        this.width = obj.width;
        this.height = obj.height;
        this.angle = obj.angle;
    }
    ...

"GameObject" class represents objects existing on the game board. The constructor function generates "id" from a random value. "x", "y" represents the position, "width", "height" represents the size, and "angle" represents the direction of the object. Those variables can be passed as a argument of the constructor.

    move(distance){
        const oldX = this.x, oldY = this.y;
        
        this.x += distance * Math.cos(this.angle);
        this.y += distance * Math.sin(this.angle);
        
        let collision = false;
        if(this.x < 0 || this.x + this.width >= FIELD_WIDTH || this.y < 0 || this.y + this.height >= FIELD_HEIGHT){
            collision = true;
        }
        if(this.intersectWalls()){
            collision = true;
        }
        if(collision){
            this.x = oldX; this.y = oldY;
        }
        return !collision;
    }

move() of GameObject moves the object for the "distance" parameter. Use Math.cos() to get the distance in the x-direction, and "Math.sin()" to get the distance in the y-direction. It also test that the position moving to is not out of the game board by testing that the "x" is between 0 and FIELD_WIDTH - this.width, and "y" is between 0 and FIELD_HEIGHT - this.height. Also, it test that the object is not collided with walls using intersectWalls() we'll create later on.

The function returns true if moving succeeded, or move back to the original position(oldX, oldY) and return false if the object collides with walls or boundaries.

    intersect(obj){
        return (this.x <= obj.x + obj.width) &&
            (this.x + this.width >= obj.x) &&
            (this.y <= obj.y + obj.height) &&
            (this.y + this.height >= obj.y);
    }

The intersects() of GameObject test if the object collides with the other objects. As GameObject represents the rectangular object, it tests whether there are any intersections of objects for x-direction, and y-direction.

    intersectWalls(){
        return Object.values(walls).some((wall) => this.intersect(wall));
    }

The intersectWalls() of GameObject test for the collision with the all walls, and return true or false.

    toJSON(){
        return {id: this.id, x: this.x, y: this.y, width: this.width, height: this.height, angle: this.angle};
    }

The toJSON() function of the GameObject represents how to transform the object to JSON format for sending the object to the client. Here, we use "id", x", "y", "width", "height", and "angle" variables.

JSON.serialize() will use this "toJSON()" function to create the JSON string from the object.

Next, let's see the "Player" class.

class Player extends GameObject{
    constructor(obj={}){
        super(obj);
        this.socketId = obj.socketId;
        this.nickname = obj.nickname;
        this.width = 80;
        this.height = 80;
        this.health = this.maxHealth = 10;
        this.bullets = {};
        this.point = 0;
        this.movement = {};

        do{
            this.x = Math.random() * (FIELD_WIDTH - this.width);
            this.y = Math.random() * (FIELD_HEIGHT - this.height);
            this.angle = Math.random() * 2 * Math.PI;
        }while(this.intersectWalls());
    }

Player class inherits GameObject class, and have "socketId" variable for communication socket, "nickname" variable for the nickname, "health" variable for the player's health point, "bullets" for the collection of the bullets the player shot, and the "point" variable for the points. It set the initial "health" to 10, and the maxHealth to the initial "health".

The player's position is set using random value. If the position is in the wall, other position is created.

    shoot(){
        if(Object.keys(this.bullets).length >= 3){
            return;
        }
        const bullet = new Bullet({
            x: this.x + this.width/2,
            y: this.y + this.height/2,
            angle: this.angle,
            player: this,
        });
        bullet.move(this.width/2);
        this.bullets[bullet.id] = bullet;
        bullets[bullet.id] = bullet;
    }

The shoot() function of the Player class shoot bullets. Let's set the maximum number of concurrent bullets to three. If there are already three or more bullets exists, it does not shoot more bullets.

It creates the bullets objects with "new Bullets()". It set the position of the bullets to the center of the player. Also, call "player.move(this.width/2)" to move the bullet to around the edge of the player. It keeps the bullets shot to the "bullets" variable.

    damage(){
        this.health --;
        if(this.health === 0){
            this.remove();
        }
    }

The damage() of the Player class represents the action on the damage when the player was shot. It decreases the "health" by one, and remove the player if the health is zero.

    remove(){
        delete players[this.id];
        io.to(this.socketId).emit('dead');
    }

The remove() of the Player class removes the player from the "players", and notify it to the browser(client).

    toJSON(){
        return Object.assign(super.toJSON(), {health: this.health, maxHealth: this.maxHealth, socketId: this.socketId, point: this.point, nickname: this.nickname});
    }

The toJSON() of the Player represents the list of variables of the object used when the object information is sent to the browser(client). Here, we use "health", "maxHealth", "socketId", "point", and "nickname" variables in addition to the variables set on the toJSON() of GameObject class.

Next, let's see the Bullet class.

class Bullet extends GameObject{
    constructor(obj){
        super(obj);
        this.width = 15;
        this.height = 15;
        this.player = obj.player;
    }
    remove(){
        delete this.player.bullets[this.id];
        delete bullets[this.id];
    }
};

As the bullets exist on the game board, Bullet class inherits GameObject class. In the constructor function, the player shot the bullet is stored as the "player" variable.

remove() function is to remove the bullets. It removes the bullet from global "bullets" variable, and the shooter's bullets("this.player.bullets").

Next, let's see the BotPlayer class representing bot players.

class BotPlayer extends Player{
    constructor(obj){
        super(obj);
        this.timer = setInterval(() => {
            if(! this.move(4)){
                this.angle = Math.random() * Math.PI * 2;
            }
            if(Math.random()<0.03){
                this.shoot();
            }
        }, 1000/30);
    }
    remove(){
        super.remove();
        clearInterval(this.timer);
        setTimeout(() => {
            const bot = new BotPlayer({nickname: this.nickname});
            players[bot.id] = bot;
        }, 3000);
    }
};

As a bot player a kind of the player, BotPlayer class inherits Player class. In the constructor function, it moves the bot player on every 1/30 seconds using a timer. When the move() fails, it changes the direction of the player randomly. It also shoots bullets randomly.

The remove() function is called when the bot was killed. It creates a new bot after 3 seconds.

Next, the Wall class simply just inherits GameObject class.

class Wall extends GameObject{
};

Now, as all the classes are prepared, let's create the objects.

for(let i=0; i<3; i++){
    const wall = new Wall({
            x: Math.random() * FIELD_WIDTH,
            y: Math.random() * FIELD_HEIGHT,
            width: 200,
            height: 50,
    });
    walls[wall.id] = wall;
}

At first, create 3 walls. The position of the walls is set from random values. The created walls are set to global "walls" variable.

const bot = new BotPlayer({nickname: 'bot'});
players[bot.id] = bot;

Create a bot player.

io.on('connection', function(socket) {
    let player = null;
    socket.on('game-start', (config) => {
        player = new Player({
            socketId: socket.id,
            nickname: config.nickname,
        });
        players[player.id] = player;
    });
    socket.on('movement', function(movement) {
        if(!player || player.health===0){return;}
        player.movement = movement;
    });
    socket.on('shoot', function(){
        console.log('shoot');
        if(!player || player.health===0){return;}
        player.shoot();
    });
    socket.on('disconnect', () => {
        if(!player){return;}
        delete players[player.id];
        player = null;
    });
});

Set the actions for the messages sent from the browser(client).

'game-start' is the message to start the game. Create a player with the specified nickname and socket ID.

'movement' is the message to move the player. Just set it to the player's message variable.

'shoot' is the message to shoot a bullet. Just call shoot() function of the player.

'disconnect' is the event called when the socket communication is closed. Remove the player from the global player's list "players".

setInterval(() => {
    Object.values(players).forEach((player) => {
        const movement = player.movement;
        if(movement.forward){
            player.move(5);
        }
        if(movement.back){
            player.move(-5);
        }
        if(movement.left){
            player.angle -= 0.1;
        }
        if(movement.right){
            player.angle += 0.1;
        }
    });
    Object.values(bullets).forEach((bullet) =>{
        if(! bullet.move(10)){
            bullet.remove();
            return;
        }
        Object.values(players).forEach((player) => {
           if(bullet.intersect(player)){
               if(player !== bullet.player){
                   player.damage();
                   bullet.remove();
                   bullet.player.point += 1;
               }
           } 
        });
    });
    io.sockets.emit('state', players, bullets, walls);
}, 1000/30);

Next, move the bullets and players every 1/30 seconds.

For each player of the "players", move the player based on player's "movement" variable.

For each bullet of the "bullets", move the bullet. If the bullet reaches to the edge of the game board of the walls, the bullet is removed. If the bullet collides with any players, then remove the player's health using damage(), remove the bullet, and increase the player's point by one.

After the movings, send the positions and other statuses of the players, bullets, walls to the browser(client) as 'state' message.

2D game(HTML)

Next, let's create an HTML file.

Open "static/index.html", and edit the file like below.

static/index.html:

<html>
  <head>
    <title>Paiza Battle Ground</title>
    <meta name="viewport" content="width=device-width,initial-scale=1.0,minimum-scale=1.0,maximum-scale=1.0,user-scalable=no">
    <script src="/socket.io/socket.io.js"></script>
    <script src="https://ajax.googleapis.com/ajax/libs/jquery/3.3.1/jquery.min.js"></script>
  </head>
  <body style="display: flex; flex-direction: column; width: 100%; height: 100%; margin: 0;">
    <div>Paiza Battle Field</div>
    <div style="flex: 1 1; position: relative; overflow:hidden;">
        <div id="start-screen" style="width:100%; height:100%; display: flex; align-items: center; position:absolute; z-index:10;background-color:rgba(128,128,128,0.5);">
            <div style="text-align: center; width: 100%; font-size: xx-large;">
                <input type="text" name="nickname" id="nickname" placeholder="Your nickname" autofocus><br/><br/>
                <button style="font-size: xx-large;" id="start-button">Start</button>
            </div>
        </div>
        <canvas id="canvas-2d" width="1000" height="1000" style="position:absolute;width:100%;height:100%;object-fit:contain;"></canvas>
    </div>
    <img id="player-image" src="/static/player.gif" style="display: none;">
    <script src="/static/game.js"></script>
  </body>
</html>

It almost same as the "Simple Game", but it adds game starting screen with "start-screen" which have a text input field for the player.

2D版 game(client)

Next, let's change the browser(client) JavaScript code.

Open "static/game.js", and edit the file like below:

static/game.js:

'use strict';

const socket = io();
const canvas = $('#canvas-2d')[0];
const context = canvas.getContext('2d');
const playerImage = $('#player-image')[0];

function gameStart(){
    socket.emit('game-start', {nickname: $("#nickname").val() });
    $("#start-screen").hide();
}
$("#start-button").on('click', gameStart);

let movement = {};

$(document).on('keydown keyup', (event) => {
    const KeyToCommand = {
        'ArrowUp': 'forward',
        'ArrowDown': 'back',
        'ArrowLeft': 'left',
        'ArrowRight': 'right',
    };
    const command = KeyToCommand[event.key];
    if(command){
        if(event.type === 'keydown'){
            movement[command] = true;
        }else{ /* keyup */
            movement[command] = false;
        }
        socket.emit('movement', movement);
    }
    if(event.key === ' ' && event.type === 'keydown'){
        socket.emit('shoot');
    }
});

socket.on('state', function(players, bullets, walls) {
    context.clearRect(0, 0, canvas.width, canvas.height);

    context.lineWidth = 10;
    context.beginPath();
    context.rect(0, 0, canvas.width, canvas.height);
    context.stroke();

    Object.values(players).forEach((player) => {
        context.save();
        context.font = '20px Bold Arial';
        context.fillText(player.nickname, player.x, player.y + player.height + 25);
        context.font = '10px Bold Arial';
        context.fillStyle = "gray";
        context.fillText('♥'.repeat(player.maxHealth), player.x, player.y + player.height + 10);
        context.fillStyle = "red";
        context.fillText('♥'.repeat(player.health), player.x, player.y + player.height + 10);
        context.translate(player.x + player.width/2, player.y + player.height/2);
        context.rotate(player.angle);
        context.drawImage(playerImage, 0, 0, playerImage.width, playerImage.height, -player.width/2, -player.height/2, player.width, player.height);
        context.restore();
        
        if(player.socketId === socket.id){
            context.save();
            context.font = '30px Bold Arial';
            context.fillText('You', player.x, player.y - 20);
            context.fillText(player.point + ' point', 20, 40);
            context.restore();
        }
    });
    Object.values(bullets).forEach((bullet) => {
        context.beginPath();
        context.arc(bullet.x, bullet.y, bullet.width/2, 0, 2 * Math.PI);
        context.stroke();
    });
    Object.values(walls).forEach((wall) => {
        context.fillStyle = 'black';
        context.fillRect(wall.x, wall.y, wall.width, wall.height);
    });
});

socket.on('dead', () => {
    $("#start-screen").show();
});

Let's see the main difference of the code from the "Simple Game".

function gameStart(){
    socket.emit('game-start', {nickname: $("#nickname").val() });
    $("#start-screen").hide();
}

gameStart() represents the behavior for starting the game. It retrieves the nickname from the start screen, and send 'game-start' message to the server with it. It also hides the game starting screen.

$("#start-button").on('click', gameStart);

When the "start" button of the game starting screen, call the gameStart() function to start the game.

$(document).on('keydown keyup', (event) => {
    ...
    if(event.key === ' ' && event.type === 'keydown'){
        socket.emit('shoot');
    }
});

"keydown", "keyup" event handler is almost same as "Simple game", but it also handles the "space key" to shoot bullet by sending the 'shoot' message to the server.

socket.on('state', function(players, bullets, walls) {
...
    Object.values(players).forEach((player) => {
        context.fillText(player.nickname, player.x, player.y + player.height + 25);
        ...
        context.fillText('♥'.repeat(player.health), player.x, player.y + player.height + 10);
        context.translate(player.x + player.width/2, player.y + player.height/2);
        context.rotate(player.angle);
        context.drawImage(playerImage, 0, 0, playerImage.width, playerImage.height, -player.width/2, -player.height/2, player.width, player.height);
        ...
        if(player.socketId === socket.id){
            ...
            context.fillText('You', player.x, player.y - 20);
            context.fillText(player.point + ' point', 20, 40);
            ...
        }
    });
    Object.values(bullets).forEach((bullet) => {
        context.beginPath();
        context.arc(bullet.x, bullet.y, bullet.width/2, 0, 2 * Math.PI);
        context.stroke();
    });
    Object.values(walls).forEach((wall) => {
        context.fillStyle = 'black';
        context.fillRect(wall.x, wall.y, wall.width, wall.height);
    });
});

When the client receives the 'state' message from the server, draw the objects.

For each player of the "players" variable sent, draw "nickname" and "health" using context.fillText() function. It also draw the player's image("playerImage") using context.drawImage(), with context.translate()/context.rotate() to rotate the player to the direction specified by "angle".

If the player's "socketId" and the socket's is the same, the player is the current player. So, add the text 'You' to the player image, and show the point of the player.

For each bullet of the "bullets" variable sent, draw a circle using "context.arc()".

For each wall of the "walls" variable sent, draw a rectangle of the wall size.

socket.on('dead', () => {
    $("#start-screen").show();
});

When the 'dead' message representing the player's death, it shows the start screen.

2D game(client, mobile)

Now the game support keyboard input, but it cannot be played from smartphones(mobile devices). So, let's handle the touch events to make it possible to play the game on the smartphones. To make it simple, "tap" shoot a bullet, "tapping" moves the player, and sliding will change the direction of the player to left or right.

Add the code below to the browser(client) JavaScript code "static/game.js".

static/game.js

...
const touches = {};
$('#canvas-2d').on('touchstart', (event)=>{
    // console.log('touchstart', event, event.touches); 
    socket.emit('shoot');
    movement.forward = true;
    Array.from(event.changedTouches).forEach((touch) => {
        touches[touch.identifier] = {pageX: touch.pageX, pageY: touch.pageY};
    });
    event.preventDefault();
    console.log('touches', touches, event.touches);
});
$('#canvas-2d').on('touchmove', (event)=>{
    movement.right = false;
    movement.left = false;
    Array.from(event.touches).forEach((touch) => {
        const startTouch = touches[touch.identifier];
        movement.right |= touch.pageX - startTouch.pageX > 30;
        movement.left |= touch.pageX - startTouch.pageX < -30;
    });
    socket.emit('movement', movement);
    event.preventDefault();
});
$('#canvas-2d').on('touchend', (event)=>{
    Array.from(event.changedTouches).forEach((touch) => {
        delete touches[touch.identifier];
    });
    if(Object.keys(touches).length === 0){
        movement = {};
        socket.emit('movement', movement);
    }
    event.preventDefault();
});

Let's see the code.

On the "touchstart" event handler, it sends the 'shoot' message to the server to shoot a bullet, and set "move.forward" to true to move forward. It stores the position of the touch starts to "touches" variable.

On the "touchmove" event handler that is called when the touch position is changed, it changes the player direction left or right when the position is changed over 30.

On the "touchend" event handler that is called when the touched finger is left from the screen, it removes the touch from "touches" variable. If all the touch event ends, the movement is cleared.

Run 2D game

Let's run the 2D game program.

To make sure, type Ctrl-C to exit current server.

Type "nodemon server.js" to start the server!

$ nodemon server.js

You'll get a new button with text "3000" on the left side of the page. Click the button, and you'll get Browser application(a Browser application in the PaizaCloud).

f:id:paiza:20180620183217p:plain

Now, you'll see your player and bot player! You can move the player using arrow keys, and shoot bullets using space key. If your health reaches zero, the game is over.

As it is a multiplayer game, you can play with your friends. On the smartphone, you can play using finger touches.

3D game (HTML)

2D game is already enjoyable enough. But, the 3D game is more powerful, let's change the 2D game to the 3d game. Here, we use Three.js library to do 3D rendering with WebGL.

Download the "three.js" library below to the desktop of your PC.

http://threejs.org/build/three.js

Drag and Drop from the desktop to "static" directory on the file management view of the PaiaCloud to upload the file.

Next, edit the HTML file "static/index.html"

Load the "three.js" file by adding a SCRIPT tag to the HEAD element like below.

    <script src="/static/three.js"></script>

Add a canvas with ID "canvas-3d" for 3D rendering below the existing canvas with ID "canvas-2d". Set "z-index" to put the 2D canvas in front of the 3D canvas.

        <canvas id="canvas-2d" width="1000" height="1000" style="position: absolute; width: 100%; height: 100%; z-index:2;object-fit: contain;"></canvas>
        <canvas id="canvas-3d" width="1000" height="1000" style="position: absolute; width: 100%; height: 100%; z-index:1;object-fit: contain;"></canvas>

After the editing, HTML file will be like below.

static/index.html:

<html>
  <head>
    <title>A Multiplayer Game</title>
    <meta name="viewport" content="width=device-width,initial-scale=1.0,minimum-scale=1.0,maximum-scale=1.0,user-scalable=no">
    <script src="/socket.io/socket.io.js"></script>
    <script src="https://ajax.googleapis.com/ajax/libs/jquery/3.3.1/jquery.min.js"></script>
    <script src="/static/three.js"></script>
  </head>
  <body style="display: flex; flex-direction: column; width: 100%; height: 100%; margin: 0;">
    <div>Paiza Battle Ground</div>
    <div style="flex: 1 1; position: relative; overflow:hidden;">
        <div id="start-screen" style="width:100%; height:100%; display: flex; align-items: center; position:absolute; z-index:10;">
            <div style="text-align: center; width: 100%; font-size: xx-large;">
                <input type="text" name="nickname" id="nickname" placeholder="Your nickname" autofocus><br/><br/>
                <button style="font-size: xx-large;" id="start-button">Start</button>
            </div>
        </div>
        <canvas id="canvas-2d" width="1000" height="1000" style="position: absolute; width: 100%; height: 100%; z-index:2;object-fit: contain;"></canvas>
        <canvas id="canvas-3d" width="1000" height="1000" style="position: absolute; width: 100%; height: 100%; z-index:1;object-fit: contain;"></canvas>
    </div>
    <img id="player-image" src="/static/player.gif" style="display: none;">
  </body>
  <script src="/static/game-3d.js"></script>
</html>

3D game (Client)

Next, let's change browser(client) JavaScript code.

As rendering text in WebGL requires font files, download the font file below to desktop, and Drag and Drop the file to "static" directory on file management view on PaizaCloud for uploading the file.

https://raw.githubusercontent.com/mrdoob/three.js/master/examples/fonts/helvetiker_bold.typeface.json

Open the file "static/game.html" and edit like below.

static/game.js:

const socket = io();
const canvas2d = $('#canvas-2d')[0];
const context = canvas2d.getContext('2d');
const canvas3d = $('#canvas-3d')[0];
const playerImage = $("#player-image")[0];

const renderer = new THREE.WebGLRenderer({canvas: canvas3d});
renderer.setClearColor('skyblue');
renderer.shadowMap.enabled = true;

const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera( 100, 1, 0.1, 2000 );

// Floor
const floorGeometry = new THREE.PlaneGeometry(1000, 1000, 1, 1);
const floorMaterial = new THREE.MeshLambertMaterial({color : 'lawngreen'});
const floorMesh = new THREE.Mesh(floorGeometry, floorMaterial);
floorMesh.position.set(500, 0, 500);
floorMesh.receiveShadow = true;
floorMesh.rotation.x = - Math.PI / 2; 
scene.add(floorMesh);

camera.position.set(1000, 300, 1000);
camera.lookAt(floorMesh.position);

// Materials
const bulletMaterial = new THREE.MeshLambertMaterial( { color: 0x808080 } );
const wallMaterial = new THREE.MeshLambertMaterial( { color: 'firebrick' } );
const playerTexture = new THREE.Texture(playerImage);
playerTexture.needsUpdate = true;
const playerMaterial = new THREE.MeshLambertMaterial({map: playerTexture});
const textMaterial = new THREE.MeshBasicMaterial({ color: 0xf39800, side: THREE.DoubleSide });
const nicknameMaterial = new THREE.MeshBasicMaterial({ color: 'black', side: THREE.DoubleSide });

// Light
const light = new THREE.DirectionalLight(0xffffff, 1);
light.position.set(-100, 300, -100);
light.castShadow = true;
light.shadow.camera.left = -2000;
light.shadow.camera.right = 2000;
light.shadow.camera.top = 2000;
light.shadow.camera.bottom = -2000;
light.shadow.camera.far = 2000;
light.shadow.mapSize.width = 2048;
light.shadow.mapSize.height = 2048;
scene.add(light);
const ambient = new THREE.AmbientLight(0x808080);
scene.add(ambient);

const loader = new THREE.FontLoader();
let font;
loader.load('/static/helvetiker_bold.typeface.json', function(font_) {
    font = font_;
});
        

// Helpers
// scene.add(new THREE.CameraHelper(light.shadow.camera));
// scene.add(new THREE.GridHelper(200, 50));
// scene.add(new THREE.AxisHelper(2000));
// scene.add(new THREE.DirectionalLightHelper(light, 20));

function animate() {
  requestAnimationFrame( animate );
  renderer.render( scene, camera );
}
animate();

function gameStart(){
    const nickname = $("#nickname").val();
    socket.emit('game-start', {nickname: nickname});
    $("#start-screen").hide();
}
$("#start-button").on('click', gameStart);

let movement = {};
$(document).on('keydown keyup', (event) => {
    const KeyToCommand = {
        'ArrowUp': 'forward',
        'ArrowDown': 'back',
        'ArrowLeft': 'left',
        'ArrowRight': 'right',
    };
    const command = KeyToCommand[event.key];
    if(command){
        if(event.type === 'keydown'){
            movement[command] = true;
        }else{ /* keyup */
            movement[command] = false;
        }
        socket.emit('movement', movement);
    }
    if(event.key === ' ' && event.type === 'keydown'){
        socket.emit('shoot');
    }
});

const touches = {};
$('#canvas-2d').on('touchstart', (event)=>{
    socket.emit('shoot');
    movement.forward = true;
    socket.emit('movement', movement);
    Array.from(event.changedTouches).forEach((touch) => {
        touches[touch.identifier] = {pageX: touch.pageX, pageY: touch.pageY};
    });
    event.preventDefault();
});
$('#canvas-2d').on('touchmove', (event)=>{
    movement.right = false;
    movement.left = false;
    Array.from(event.touches).forEach((touch) => {
        const startTouch = touches[touch.identifier];
        movement.right |= touch.pageX - startTouch.pageX > 30;
        movement.left |= touch.pageX - startTouch.pageX < -30;
    });
    socket.emit('movement', movement);
    event.preventDefault();
});
$('#canvas-2d').on('touchend', (event)=>{
    Array.from(event.changedTouches).forEach((touch) => {
        delete touches[touch.identifier];
    });
    if(Object.keys(touches).length === 0){
        movement = {};
        socket.emit('movement', movement);
    }
    event.preventDefault();
});

const Meshes = [];
socket.on('state', (players, bullets, walls) => {
    Object.values(Meshes).forEach((mesh) => {mesh.used = false;});
    
    // Players
    Object.values(players).forEach((player) => {
        let playerMesh = Meshes[player.id];
        if(!playerMesh){
            console.log('create player mesh');
            playerMesh = new THREE.Group();
        playerMesh.castShadow = true;
        Meshes[player.id] = playerMesh;
        scene.add(playerMesh);
        }
        playerMesh.used = true;
        playerMesh.position.set(player.x + player.width/2, player.width/2, player.y + player.height/2);
    playerMesh.rotation.y = - player.angle;
        
        if(!playerMesh.getObjectByName('body')){
            console.log('create body mesh');
        mesh = new THREE.Mesh(new THREE.BoxGeometry(player.width, player.width, player.height), playerMaterial);
        mesh.castShadow = true;
        mesh.name = 'body';
        playerMesh.add(mesh);
        }

        if(font){
            if(!playerMesh.getObjectByName('nickname')){
                console.log('create nickname mesh');
                mesh = new THREE.Mesh(
                    new THREE.TextGeometry(player.nickname,
                        {font: font, size: 10, height: 1}),
                        nicknameMaterial,
                );
                mesh.name = 'nickname';
                playerMesh.add(mesh);

                mesh.position.set(0, 70, 0);
                mesh.rotation.y = Math.PI/2;
            }
            {
                let mesh = playerMesh.getObjectByName('health');

                if(mesh && mesh.health !== player.health){
                    playerMesh.remove(mesh);
                    mesh.geometry.dispose();
                    mesh = null;
                }
                if(!mesh){
                    console.log('create health mesh');
                    mesh = new THREE.Mesh(
                        new THREE.TextGeometry('*'.repeat(player.health),
                            {font: font, size: 10, height: 1}),
                            textMaterial,
                    );
                    mesh.name = 'health';
                    mesh.health = player.health;
                    playerMesh.add(mesh);
                }
                mesh.position.set(0, 50, 0);
                mesh.rotation.y = Math.PI/2;
            }
        }
        
        
        if(player.socketId === socket.id){
            // Your player
      camera.position.set(
          player.x + player.width/2 - 150 * Math.cos(player.angle),
          200,
                player.y + player.height/2 - 150 * Math.sin(player.angle)
            );
      camera.rotation.set(0, - player.angle - Math.PI/2, 0);
      
      // Write to 2D canvas
            context.clearRect(0, 0, canvas2d.width, canvas2d.height);
            context.font = '30px Bold Arial';
            context.fillText(player.point + ' point', 20, 40);
        }
    });
    
    // Bullets
    Object.values(bullets).forEach((bullet) => {
        let mesh = Meshes[bullet.id];
        if(!mesh){
            mesh = new THREE.Mesh(new THREE.BoxGeometry(bullet.width, bullet.width, bullet.height), bulletMaterial);
        mesh.castShadow = true;
        Meshes[bullet.id] = mesh;
        // Meshes.push(mesh);
        scene.add(mesh);
        }
        mesh.used = true;
        mesh.position.set(bullet.x + bullet.width/2, 80, bullet.y + bullet.height/2);
    });
    
    // Walls
    Object.values(walls).forEach((wall) => {
        let mesh = Meshes[wall.id];
        if(!mesh){
        mesh = new THREE.Mesh(new THREE.BoxGeometry(wall.width, 100, wall.height), wallMaterial);
        mesh.castShadow = true;
        Meshes.push(mesh);
        Meshes[wall.id] = mesh;
        scene.add(mesh);
        }
        mesh.used = true;
        mesh.position.set(wall.x + wall.width/2, 50, wall.y + wall.height/2);
    });
    
    // Clear unused Meshes
    Object.keys(Meshes).forEach((key) => {
        const mesh = Meshes[key];
        if(!mesh.used){
            console.log('removing mesh', key);
            scene.remove(mesh);
            mesh.traverse((mesh2) => {
                if(mesh2.geometry){
                    mesh2.geometry.dispose();
                }
            });
            delete Meshes[key];
        }
    });
});

socket.on('dead', () => {
    $("#start-screen").show();
});

Let's see the code focusing on the difference from the "2D game" code.

const renderer = new THREE.WebGLRenderer({canvas: canvas3d});
renderer.setClearColor('skyblue');
renderer.shadowMap.enabled = true;

At first, create a THREE.WebGLRender object for 3D rendering in WebGL using Three.js, and set color and enable shadowing.

const scene = new THREE.Scene();

The "scene" global variable is to manage all the 3D objects on Three.js.

const camera = new THREE.PerspectiveCamera( 100, 1, 0.1, 2000 );

Create a camera object from THREE.PerspectiveCamera class, representing the viewpoint for the 3D rendering,

// Floor
const floorGeometry = new THREE.PlaneGeometry(1000, 1000, 1, 1);
const floorMaterial = new THREE.MeshLambertMaterial({color : 'lawngreen'});
const floorMesh = new THREE.Mesh(floorGeometry, floorMaterial);
floorMesh.position.set(500, 0, 500);
floorMesh.receiveShadow = true;
floorMesh.rotation.x = - Math.PI / 2; 
scene.add(floorMesh);

Create a floor objects using THREE.Mesh class from THREE.PlaneGeometry object representing plane figure, and THREE.MeshLambertMaterial object representing a color.

Set the position using "position" property, and rotate by setting "rotation" property. Here, as we create a plane on X-Y plane (0,0)-(1000,1000), set the position representing the center of the object to (500,500). As the plane is vertical, rotate -PI/2 to make the plane horizontal.

Add the created 3D object wit sence.add().

camera.position.set(1000, 300, 1000);
camera.lookAt(floorMesh.position);

Set the camera position using "camera.position", and set the direction of the camera to the floor using "camera.lookAt()".

// Light
const light = new THREE.DirectionalLight(0xffffff, 1);
...
scene.add(light);
const ambient = new THREE.AmbientLight(0x808080);
scene.add(ambient);

Create a DirectionalLight with and an AmbilentLight.

const loader = new THREE.FontLoader();
let font;
loader.load('/static/helvetiker_bold.typeface.json', function(font_) {
    font = font_;
});

Load a font file needed to rendering text in WebGL using THREE.FontLoader().

function animate() {
  requestAnimationFrame( animate );
  renderer.render( scene, camera );
};
animate();

Call render.render() for 3D rendering. Use requestAnimationFrame() to render on the appropriate time for the animation.

Meshes = [];
socket.on('state', (players, bullets, walls) => {
});

socket.on('state') receives the state of players, bullets, walls from the server, and create, change, or delete 3D objects. The 3D objects using is stored in "Meshes" global variable.

            playerMesh = new THREE.Group();
...
            mesh = new THREE.Mesh(new THREE.BoxGeometry(player.width, player.width, player.height), playerMaterial);
            playerMesh.add(mesh);
...
                mesh = new THREE.Mesh(
                    new THREE.TextGeometry(player.nickname,
                        {font: font, size: 10, height: 1}),
                        nicknameMaterial,
                );
                playerMesh.add(mesh);
...
                    mesh = new THREE.Mesh(
                        new THREE.TextGeometry('*'.repeat(player.health),
                            {font: font, size: 10, height: 1}),
                            textMaterial,
                    );
                    playerMesh.add(mesh);

Create a Player 3D object. As the player object have body, nickname, and health to show, make a group for those using "THREE.GROUP()".

        if(player.socketId === socket.id){
            // Your player
            camera.position.set(
                player.x + player.width/2 - 150 * Math.cos(player.angle),
                200,
                      player.y + player.height/2 - 150 * Math.sin(player.angle)
                  );
            camera.rotation.set(0, - player.angle - Math.PI/2, 0);
      
      // Write to 2D canvas
            context.clearRect(0, 0, canvas2d.width, canvas2d.height);
            context.font = '30px Bold Arial';
            context.fillText(player.point + ' point', 20, 40);
        }

Set the camera position and direction based on your player, to make the game like TPS(Third person shooter). Draw the player's point on the 2D canvas.

            mesh = new THREE.Mesh(new THREE.BoxGeometry(bullet.width, bullet.width, bullet.height), bulletMaterial);
            scene.add(mesh);

Create a bullet 3D object, and add it to the scene.

          mesh = new THREE.Mesh(new THREE.BoxGeometry(wall.width, 100, wall.height), wallMaterial);
          scene.add(mesh);

Create a wall object, and add it to the scene.

Also, store those object to "Meshes" array so that we can reuse the object for next rendering.

Run 3D game

Let's run the 3D game program.

To make sure, type Ctrl-C to exit current server.

Type "nodemon server.js" to start the server!

$ nodemon server.js

You'll get a new button with text "3000" on the left side of the page. Click the button, and you'll get Browser application(a Browser application in the PaizaCloud).

f:id:paiza:20180620183217p:plain

Now, you see the 3D game with 3D players! You can move the player using arrow keys, and shoot bullets using space key. If your player's health reaches zero, the game is over.

As it is a multiplayer game, you can play with your friends. On the smartphone, you can play using finger touches.

Note that on PaizaCloud free plan, the server will be suspended. To run the bot continuously, please upgrade to the BASIC plan.

Source code

The source code for the game is available below:

GitHub - yoshiokatsuneo/paiza-battle-ground

Demo

You can play the game on the following URL.

2D game: Paiza Battle Ground - JavaScriptで作れる3Dマルチプレイヤー対戦ゲーム! 3D game: Paiza Battle Ground

Summary

With PaizaCloud Cloud IDE, we created a 3D online multiplayer game just in your browser, without installing or setting up any development environments. We run the server in PaizaCloud so that we can play the game with friends, or anyone in the world! Now, let's create your own games!


With「PaizaCloud Cloud IDE」, you can flexibly and easily develop your web application or server application, and publish it, just in your browser. https://paiza.cloud