incheon
Version:
A Node.js based real-time game server
368 lines (285 loc) • 11.4 kB
Markdown
This tutorial takes about 30 minutes. It will guide you in building the simplest
JavaScript networked game, [Pong](https://en.wikipedia.org/wiki/Pong). It starts with a walk-through of environment setup,
then proceeds with the writing of client code, server
code, and game logic.

## Setting up the Environment
*Side Note:* we use [yarn](https://yarnpkg.com/) in this tutorial, however you can use the `npm` command in each case - it will work just as well.
The creation of a new game starts by cloning boilerplate code:
```shell
git clone https://github.com/namel/incheongame.git
cd incheongame
yarn install
```
You now have the basic directory structure of a game. Look around.
The boilerplate includes an `index.html` file, which will be served
to the clients, and a `main.js` file, which is the entry point of the node.js server.
The game code is inside the `src` directory, divided into
sub-directories `client`, `server`, and `common`.
## Step 1: Create the Game Object Classes
We have two kinds of objects in Pong, the paddle and the ball.
These files extend the `DynamicObject` class, but are quite simple.
The boilerplate includes a sample game object class, in the file
`src/common/PlayerAvatar.js`
Create the following two classes in the `src/common` directory:
### src/common/Paddle.js
The Paddle class is similar to
the sample `PlayerAvatar.js` object, except that it is called `Paddle` and it asks the renderer to create a sprite.
```javascript
'use strict';
const DynamicObject = require('incheon').serialize.DynamicObject;
class Paddle extends DynamicObject {
constructor(id, x, playerId) {
super(id);
this.position.set(x, 0);
this.playerId = playerId;
this.class = Paddle;
}
onAddToWorld(gameEngine) {
if (gameEngine.renderer) {
gameEngine.renderer.addSprite(this, 'paddle');
}
}
}
module.exports = Paddle;
```
### src/common/Ball.js
The Ball class is only slightly more complicated than the Paddle
class. It adds two getters, which define "bending" properties.
The bending properties below indicate that the client object's position should
gradually *bend* towards the server object's position at a rate of 80% each time the server sends position updates. The client object's
velocity should not bend at all, because the ball's velocity can change
suddenly as it hits a wall or a paddle.
We also give the Ball an initial velocity when it is created.
```javascript
'use strict';
const DynamicObject = require('incheon').serialize.DynamicObject;
class Ball extends DynamicObject {
get bendingMultiple() { return 0.8; }
get bendingVelocityMultiple() { return 0; }
constructor(id, x, y) {
super(id);
this.position.set(x, y);
this.class = Ball;
this.velocity.set(2, 2);
}
onAddToWorld(gameEngine) {
if (gameEngine.renderer) {
gameEngine.renderer.addSprite(this, 'ball');
}
}
}
module.exports = Ball;
```
## Step 2: Implement the MyGameEngine class
The game engine class runs on both the server and the client,
and executes the game's logic. The client runs the game engine to
predict what will happen, but the server execution is the true
game progress, overriding what the clients might have predicted.
For Pong, we will need to bounce the ball around the board, and check if it
hit a paddle. We will also need to respond to the user's up/down inputs.
### src/common/MyGameEngine.js
The MyGameEngine class implements the actual logic of the game. First add the objects we created, and some constants, at the top of the file:
```javascript
const Paddle = require('./Paddle');
const Ball = require('./Ball');
const PADDING = 20;
const WIDTH = 400;
const HEIGHT = 400;
const PADDLE_WIDTH = 10;
const PADDLE_HEIGHT = 50;
```
* **start()**: registers the game logic to run as a post-step function, and keep references to the game objects. Modify the start method to match the following:
```javascript
start() {
super.start();
this.on('postStep', () => { this.postStepHandleBall(); });
this.on('objectAdded', (object) => {
if (object.id == 1) {
this.paddle1 = object;
} else if (object.id == 2) {
this.paddle2 = object;
} else if (object.class == Ball) {
this.ball = object;
}
});
}
```
* **registerClasses**: register all the game objects on the serializer. Add this function:
```javascript
registerClasses(serializer) {
serializer.registerClass(require('../common/Paddle'));
serializer.registerClass(require('../common/Ball'));
}
```
* **processInput**: handle user inputs by moving the paddle up or down.
Modify the processInput method to match the following:
```javascript
processInput(inputData, playerId) {
super.processInput(inputData, playerId);
// get the player paddle tied to the player socket
let playerPaddle = this.world.getPlayerObject(playerId);
if (playerPaddle) {
if (inputData.input === 'up') {
playerPaddle.position.y -= 5;
} else if (inputData.input === 'down') {
playerPaddle.position.y += 5;
}
}
}
```
* **initGame**: create two paddles, a ball, and add these objects to the game world. This method will be called only on the server. Add the following `initGame()` method:
```javascript
initGame() {
// create the paddle objects
this.addObjectToWorld(new Paddle(++this.world.idCount, PADDING, 1));
this.addObjectToWorld(new Paddle(++this.world.idCount, WIDTH - PADDING, 2));
this.addObjectToWorld(new Ball(++this.world.idCount, WIDTH / 2, HEIGHT / 2));
}
```
* **postStepHandleBall**: this method is executed after the ball has moved. It contains **all the core pong game logic**: it checks if the ball has hit any wall, or any paddle, and decides if a player has scored.
```javascript
postStepHandleBall() {
if (!this.ball)
return;
// CHECK LEFT EDGE:
if (this.ball.position.x <= PADDING + PADDLE_WIDTH &&
this.ball.position.y >= this.paddle1.y && this.ball.position.y <= this.paddle1.position.y + PADDLE_HEIGHT &&
this.ball.velocity.x < 0) {
// ball moving left hit player 1 paddle
this.ball.velocity.x *= -1;
this.ball.position.x = PADDING + PADDLE_WIDTH + 1;
} else if (this.ball.position.x <= 0) {
// ball hit left wall
this.ball.velocity.x *= -1;
this.ball.position.x = 0;
console.log(`player 2 scored`);
}
// CHECK RIGHT EDGE:
if (this.ball.position.x >= WIDTH - PADDING - PADDLE_WIDTH &&
this.ball.position.y >= this.paddle2.position.y && this.ball.position.y <= this.paddle2.position.y + PADDLE_HEIGHT &&
this.ball.velocity.x > 0) {
// ball moving right hits player 2 paddle
this.ball.velocity.x *= -1;
this.ball.position.x = WIDTH - PADDING - PADDLE_WIDTH - 1;
} else if (this.ball.position.x >= WIDTH ) {
// ball hit right wall
this.ball.velocity.x *= -1;
this.ball.position.x = WIDTH - 1;
console.log(`player 1 scored`);
}
// ball hits top
if (this.ball.position.y <= 0) {
this.ball.position.y = 1;
this.ball.velocity.y *= -1;
} else if (this.ball.position.y >= HEIGHT) {
// ball hits bottom
this.ball.position.y = HEIGHT - 1;
this.ball.velocity.y *= -1;
}
}
```
## Step 3: Extend the MyServerEngine Class
The server engine will initialize the game engine when the game is started, and handle player connections and "disconnections".
### src/server/MyServerEngine.js
```javascript
'use strict';
const ServerEngine = require('incheon').ServerEngine;
class MyServerEngine extends ServerEngine {
constructor(io, gameEngine, inputOptions) {
super(io, gameEngine, inputOptions);
}
start() {
super.start();
this.gameEngine.initGame();
this.players = {
player1: null,
player2: null
};
}
onPlayerConnected(socket) {
super.onPlayerConnected(socket);
// attach newly connected player an available paddle
if (this.players.player1 === null) {
this.players.player1 = socket.id;
this.gameEngine.paddle1.playerId = socket.playerId;
} else if (this.players.player2 === null) {
this.players.player2 = socket.id;
this.gameEngine.paddle2.playerId = socket.playerId;
}
}
onPlayerDisconnected(socketId, playerId) {
super.onPlayerDisconnected(socketId, playerId);
if (this.players.player1 == socketId) {
console.log('Player 1 disconnected');
this.players.player1 = null;
} else if (this.players.player2 == socketId) {
console.log('Player 2 disconnected');
this.players.player2 = null;
}
}
}
module.exports = MyServerEngine;
```
## Step 4: the Client Code
The client-side code must implement a renderer, and a client engine.
The renderer, in our case, will simply update HTML elements created for
each paddle and the ball:
### src/client/MyRenderer.js
Change the `draw()` method and add the `addSprite()` methods as shown here:
```javascript
draw() {
super.draw();
// note - animating via the top attribute of a DOM element is a usually
// bad practice, but used here for code brevity
for (let objId of Object.keys(this.sprites)) {
if (this.sprites[objId].el) {
this.sprites[objId].el.style.top = this.gameEngine.world.objects[objId].position.y + 'px';
this.sprites[objId].el.style.left = this.gameEngine.world.objects[objId].position.x + 'px';
}
}
}
addSprite(obj, objName) {
if (objName === 'paddle') objName += obj.playerId;
this.sprites[obj.id] = {
el: document.querySelector('.' + objName)
};
}
```
## Step 4: the Client Visuals
The client visuals code are simple HTML so we don't discuss them in detail.
Change the `<body>` DOM element contain just the following snippet.
```HTML
<div style="width: 400px; height: 400px; background: black">
<div style="position:absolute;width:10px;height:50px;background:white" class="paddle1"></div>
<div style="position:absolute;width:10px;height:50px;background:white" class="paddle2"></div>
<div style="position:absolute;width:5px; height:5px;background:white" class="ball"></div>
</div>
```
## Step 5: Running the Game
Once everything has been put together the end result should look like
the pong branch of the repository.
If you prefer to get a clean working copy, run:
```shell
git clone https://github.com/namel/incheongame.git pong
cd pong
git checkout pong
yarn install
```
To run the game you must first build the JavaScript bundle. The `yarn install`
command above already did this for you, but if you change code, you can rebuild by
executing:
```shell
yarn run build
```
To run the game, type:
```shell
yarn start
```
Open two browser windows and point them to the local host. The URL is
`http://127.0.0.1:3000/` on windows, and `http://localhost:3000/`
on a Mac.
## Next Steps
Your next steps might be to get a deeper understanding by going through
the Spaceships Tutorial, which introduces the concepts and components
of an Incheon networked game.