What is this article all about π
In this article, I'd like to walk you through the steps I took to create a multiplayer browser game using Babylonjs. My initial plan was to lay out each step in tutorial form, but as I've been writing this thing out, and in the interest of time, I'm realizing it's much quicker to just dump the code there and highlight the important parts in this article. Would be great to get your feedback, if anyone finds this at all interesting or useful, would help me decide what to talk about in my next article π
I'll be building on the work that I covered in my previous tutorial on laying out a basic game scene using Babylonjs.
Today I'd like to build on this by adding a basic single player mode.
If you'd like to play the game first, check it out here:
1. House keeping
Lets start with a bit of tidy up, we'll move stuff around to give our selfs more space.
Delete the contents of shared.ts
, and backend.ts
, we don't need this code.
Move the following enum
and interface
declarations from frontend.ts
into shared.ts
: PlayerSide
, PieceType
, AliveState
, PlayerPiece
, Player
. Update these declaration to include the 'export' keyword so that these declarations can be referrenced externally.
Next lets move code from frontend.ts
into to a new file called babylonGameScene.ts
, the simplest way to do this is to rename frontend.ts
to babylonGameScene.ts
, and create a new frontend.ts
file.
2. Adding Movement
Ok, so now we need a way to keep track of which chess piece or grid block the player has selected and where they want to move. A player move consists of an action and a chess piece. An action consists of a grid number, an action type and optionally details about the opponents piece (we'll use this later). Add the following declarations to shared.ts
export enum ActionType {
move,
attack,
defend,
}
export interface PlayerAction {
gridPosition: number;
actionType: ActionType;
opponentPiece: { playerSide: PlayerSide; playerPiece: PlayerPiece } | undefined;
}
export interface RequestMove {
playerPiece: PlayerPiece;
playerAction: PlayerAction | undefined;
}
Next update the Player
interface declaration to include a new property requestMove: RequestMove | undefined
, is should now look as follows:
export interface Player {
aliveState: AliveState;
playerSide: PlayerSide;
playerName: string;
pieces: { [name: string]: PlayerPiece };
requestMove: RequestMove | undefined,
}
Next we'll add the following interfaces, the first that extends Player
and will house our chess mesh objects, and the second that will encapsulate a collection of players.
export interface FrontendPlayer extends Player {
meshes: { [name: string]: AbstractMesh },
}
export interface FrontendGame {
players: { [playerSide: number]: FrontendPlayer }
}
Back inside babylonGameScene.ts
, somewhere towards the top of the file where we create variables, add the following:
export const frontendGame: FrontendGame = { players: {} };
Find where we create the scene
and camera
objects and add the export
keyword
// This creates a basic Babylon Scene object (non-mesh)
export const scene = new Scene(engine);
// ....
export const camera = new ArcRotateCamera( ... );
Find the createNewPlayerMesh
function, and just after the foreach, add the following
return <FrontendPlayer>{
meshes: meshes,
requestMove: undefined,
pieces: player.pieces,
playerSide: player.playerSide,
aliveState: AliveState.alive,
};
Find the foreach loop where we call createNewPlayerMesh
and replace with the following:
frontendGame.players[+playerSide] = createNewPlayerMesh(player, chessPieces);
So much code and still no movement, I hear you cry. Well hold one, we've done a lot, we've defined our main game objects and now we're ready to add behavior.
Turn to frontend.ts
, this file should be empty at this point, lets add the following:
import './babylonGameScene';
Your code should look as follows:
Stackblitz
Lets capture a pointer event in order to move our chess pieces
scene.onPointerUp = function castRay() {
var ray = scene.createPickingRay(scene.pointerX, scene.pointerY, Matrix.Identity(), camera);
var hit = scene.pickWithRay(ray, mesh => { return mesh && mesh.name == "block" });
console.log(hit?.pickedMesh?.id);
if (hit?.pickedMesh != undefined && hit?.pickedPoint != undefined) {
onSelection(myPlayer, +hit.pickedMesh.id);
}
}
We use a ray to indicate where on the chess board the player has touched or clicked, and we call onSelection
with this information
function onSelection(myPlayer: FrontendPlayer, gridPosition: number) {
if (myPlayer.requestMove != undefined &&
myPlayer.requestMove.playerPiece.gridPosition != gridPosition) {
var playerAction = <PlayerAction>{
actionType: ActionType.move,
gridPosition: gridPosition,
opponentPiece: undefined
}
if (playerAction != undefined) {
var requestMove = <RequestMove>{
playerAction: playerAction,
playerPiece: myPlayer.requestMove.playerPiece
};
playerMove(myPlayer.playerSide, requestMove);
}
myPlayer.requestMove = undefined;
} else {
selectPiece(myPlayer, gridPosition);
}
}
In our on selection function, we use the requestMove
property of our FrontendPlayer
object to identify if we've already selected a chess piece, in which case the action is to move this piece. Or else we're just selecting the piece. Our selectPiece
looks like this:
function selectPiece(myPlayer: FrontendPlayer, gridPosition: number) {
for (const pieceName in myPlayer.pieces) {
const piece = myPlayer.pieces[pieceName]!!;
if (piece.gridPosition == gridPosition && piece.aliveState == AliveState.alive) {
if (!myPlayer.requestMove) {
myPlayer.requestMove = {
playerPiece: piece,
playerAction: undefined
};
return;
}
if (myPlayer.requestMove.playerPiece.gridPosition == gridPosition) {
myPlayer.requestMove = undefined;
return;
}
}
}
}
We loop though our chess pieces to see if the gird positions match, if so, we select this piece. Our player move looks like this:
function playerMove(playerSide: PlayerSide, confirmedMove: RequestMove) {
const player = frontendGame.players[playerSide]!!;
const piece = player.pieces[confirmedMove.playerPiece.pieceName]!!
const pieceMesh = player.meshes[confirmedMove.playerPiece.pieceName]!!
const newWorldPosition = gridToWorld(confirmedMove.playerAction!!.gridPosition);
pieceMesh.position = newWorldPosition;
piece.gridPosition = confirmedMove.playerAction!!.gridPosition;
piece.isFirstMove = false;
}
We pass in our player object ad details of the confirmed move, and use this to update our mesh position. At this point we should have movement, a bit jumpy and hard to follow at this point but a good start.
Stackblitz
Lets add some animation to make things clearer to see.
2. Adding Animation
Lets improve our chess piece movement by adding in some animation so that we can more easily track grid transitions.
We'll put all of our animation code into a file called animations.ts
Add the following helper functions getRotateAnim
, getRotateAlphaAnim
, getRotateBetaAnim
, getDiffuseColorAnim
, getPositionAnim
, getVisibilityAnim
:
import { Animation, Color3, Vector3 } from "babylonjs"
var frameRate = 25;
export function getRotateAnim(name: string, rotationFrom: Vector3, rotationTo: Vector3, repeatType: number): Animation {
var movein = new Animation(
name,
"rotation",
frameRate,
BABYLON.Animation.ANIMATIONTYPE_VECTOR3,
repeatType
);
var movein_keys = [];
movein_keys.push({
frame: 0,
value: rotationFrom
});
movein_keys.push({
frame: frameRate / 2,
value: rotationTo
});
movein_keys.push({
frame: frameRate,
value: rotationFrom
});
movein.setKeys(movein_keys);
return movein;
}
export function getRotateAlphaAnim(rotationFrom: number, rotationTo: number): Animation {
var movein = new Animation(
"movein",
"alpha",
frameRate,
BABYLON.Animation.ANIMATIONTYPE_FLOAT,
BABYLON.Animation.ANIMATIONLOOPMODE_CONSTANT
);
var movein_keys = [];
movein_keys.push({
frame: 0,
value: BABYLON.Tools.ToRadians(rotationFrom)
});
movein_keys.push({
frame: frameRate,
value: BABYLON.Tools.ToRadians(rotationTo)
});
movein.setKeys(movein_keys);
return movein;
}
export function getRotateBetaAnim(rotationFrom: number, rotationTo: number): Animation {
var movein = new Animation(
"movein",
"beta",
frameRate,
BABYLON.Animation.ANIMATIONTYPE_FLOAT,
BABYLON.Animation.ANIMATIONLOOPMODE_CONSTANT
);
var movein_keys = [];
movein_keys.push({
frame: 0,
value: BABYLON.Tools.ToRadians(rotationFrom)
});
movein_keys.push({
frame: frameRate,
value: BABYLON.Tools.ToRadians(rotationTo)
});
movein.setKeys(movein_keys);
return movein;
}
export function getDiffuseColorAnim(colorFrom: Color3, colorTo: Color3): Animation {
var movein = new Animation(
"color",
"material.diffuseColor",
frameRate,
BABYLON.Animation.ANIMATIONTYPE_COLOR3,
BABYLON.Animation.ANIMATIONLOOPMODE_CONSTANT
);
var movein_keys = [];
movein_keys.push({
frame: 0,
value: colorFrom
});
movein_keys.push({
frame: frameRate,
value: colorTo
});
movein.setKeys(movein_keys);
return movein;
}
export function getPositionAnim(positionFrom: Vector3, positionTo: Vector3): Animation {
var movein = new Animation(
"position",
"position",
frameRate,
BABYLON.Animation.ANIMATIONTYPE_VECTOR3,
BABYLON.Animation.ANIMATIONLOOPMODE_CONSTANT
);
var movein_keys = [];
movein_keys.push({
frame: 0,
value: positionFrom
});
movein_keys.push({
frame: frameRate,
value: positionTo
});
movein.setKeys(movein_keys);
return movein;
}
export function getVisibilityAnim(visibilityFrom: number, visibilityTo: number): Animation {
var movein = new Animation(
"visibility",
"visibility",
frameRate,
BABYLON.Animation.ANIMATIONTYPE_FLOAT,
BABYLON.Animation.ANIMATIONLOOPMODE_CONSTANT
);
var movein_keys = [];
movein_keys.push({
frame: 0,
value: visibilityFrom
});
movein_keys.push({
frame: frameRate,
value: visibilityTo
});
movein.setKeys(movein_keys);
return movein;
}
For each of these helper functions, we build an animation that we can apply to a gamescene object based on some parameters.
Open up shared.ts
and add the following
export const rotations: { [playerSide in PlayerSide]: number } = {
0: 180,
1: 90,
2: 0,
3: 270
}
We'll find our playerMove
function inside frontend.ts
and replace:
// replace this line
pieceMesh.position = newWorldPosition;
with:
scene.beginDirectAnimation(
player.meshes[piece.pieceName],
[
getPositionAnim(
player.meshes[piece.pieceName].position,
newWorldPosition
),
],
0,
25,
false,
10
);
At the bottom of our file, we'll add the following animation to transition to our start position
scene.beginDirectAnimation(
camera,
[getRotateAlphaAnim(0, rotations[PlayerSide.bottom]), getRotateBetaAnim(10, 40)],
0, 25, false, 0.5);
We should how have the following:
Stackblitz
3. Adding movement rules
Now that we have movement and animation, lets add in our chess movement rules, for instance our pawns will be able to move one grid at a time, except for the first move in which they can move 3 (it's chess royale so we're allowed to bend the rules a bit :).
Lets open up shared.ts
and add the following definitions that we'll use to define how our chess pieces will move
export enum MovementType {
fixed,
continuous
}
export enum GridBlockStatus {
unselected,
primary,
secondary,
path,
blocked,
hit
}
export interface Movement {
movementType: MovementType,
moveDirection: { x: number, y: number }[],
attackDirection: { x: number, y: number }[]
}
export interface GridBlock {
mesh: Mesh,
material: StandardMaterial,
defaultPosition: Vector3,
status: GridBlockStatus
}
Now we'll use these definitions to construct a list of valid moves for each chess piece type
export const validMoves: { [pieceTypeName: string]: Movement } = {
"pawn-first-move": {
movementType: MovementType.fixed,
moveDirection: [{ x: 0, y: 1 }, { x: 0, y: 2 }, { x: 0, y: 3 }, { x: 1, y: 0 }, { x: -1, y: 0 }],
attackDirection: [{ x: 1, y: 1 }, { x: -1, y: 1 }]
},
"pawn": {
movementType: MovementType.fixed,
moveDirection: [{ x: 0, y: 1 }, { x: 1, y: 0 }, { x: -1, y: 0 }],
attackDirection: [{ x: 1, y: 1 }, { x: -1, y: 1 }]
},
"tower": <Movement>{
movementType: MovementType.continuous,
moveDirection: [{ x: 0, y: 1 }, { x: 1, y: 0 }, { x: 0, y: -1 }, { x: -1, y: 0 }],
attackDirection: [{ x: 0, y: 1 }, { x: 1, y: 0 }, { x: 0, y: -1 }, { x: -1, y: 0 }]
},
"knight": <Movement>{
movementType: MovementType.fixed,
moveDirection: [{ x: 1, y: 2 }, { x: -1, y: 2 }, { x: 2, y: -1 }, { x: 2, y: 1 }, { x: -1, y: -2 }, { x: 1, y: -2 }, { x: -2, y: 1 }, { x: -2, y: -1 }],
attackDirection: [{ x: 1, y: 2 }, { x: 2, y: -1 }, { x: -1, y: -2 }, { x: -2, y: 1 }]
},
"bishop": <Movement>{
movementType: MovementType.continuous,
moveDirection: [{ x: 1, y: 1 }, { x: -1, y: 1 }, { x: -1, y: -1 }, { x: 1, y: -1 }],
attackDirection: [{ x: 1, y: 1 }, { x: -1, y: 1 }, { x: -1, y: -1 }, { x: 1, y: -1 }]
},
"queen": <Movement>{
movementType: MovementType.continuous,
moveDirection: [{ x: 0, y: 1 }, { x: 1, y: 0 }, { x: 0, y: -1 }, { x: -1, y: 0 }, { x: 1, y: 1 }, { x: -1, y: 1 }, { x: -1, y: -1 }, { x: 1, y: -1 }],
attackDirection: [{ x: 0, y: 1 }, { x: 1, y: 0 }, { x: 0, y: -1 }, { x: -1, y: 0 }, { x: 1, y: 1 }, { x: -1, y: 1 }, { x: -1, y: -1 }, { x: 1, y: -1 }]
},
"king": <Movement>{
movementType: MovementType.fixed,
moveDirection: [{ x: 0, y: 1 }, { x: 1, y: 0 }, { x: 0, y: -1 }, { x: -1, y: 0 }, { x: 1, y: 1 }, { x: -1, y: 1 }, { x: -1, y: -1 }, { x: 1, y: -1 }],
attackDirection: [{ x: 0, y: 1 }, { x: 1, y: 0 }, { x: 0, y: -1 }, { x: -1, y: 0 }, { x: 1, y: 1 }, { x: -1, y: 1 }, { x: -1, y: -1 }, { x: 1, y: -1 }]
}
}
We'll now turn to frontend.ts
and add a few functions that will allow us to visualize these movements. The first is a large function (which can definitely be simplified), the purpose of this function is to return a list of allowed moves for a selected chess piece
function getValidMoves(playerPiece: PlayerPiece, playerSide: PlayerSide, players: { [playerSide: number]: Player | FrontendPlayer }): PlayerAction[] {
var actualMoveList: PlayerAction[] = [];
var myplayer = players[playerSide]
if (myplayer.aliveState == AliveState.alive && playerPiece.aliveState == AliveState.alive) {
var pieceTypeName: string = PieceType[playerPiece.pieceType]
var moves = playerPiece.pieceType == PieceType.pawn &&
playerPiece.isFirstMove == true ? validMoves["pawn-first-move"] : validMoves[pieceTypeName];
moves.moveDirection.forEach(m => {
var myRelativeGridPos = roatate(playerPiece.gridPosition, playerSide, false);
var nextPosition = myRelativeGridPos + m.x + m.y * gridWidth;
var rawMoveList: number[] = [];
// first work out player moves without cosidering collisions
if (moves.movementType == MovementType.continuous) {
while (
nextPosition >= 0 &&
nextPosition < gridWidth * gridWidth &&
Math.floor((myRelativeGridPos + m.y * gridWidth) / gridWidth) == Math.floor((nextPosition) / gridWidth)) {
rawMoveList.push(nextPosition);
myRelativeGridPos = nextPosition;
nextPosition += m.x + m.y * gridWidth;
}
} else {
if (nextPosition >= 0 &&
nextPosition < gridWidth * gridWidth &&
Math.floor((myRelativeGridPos + m.y * gridWidth) / gridWidth) == Math.floor((nextPosition) / gridWidth)) {
rawMoveList.push(nextPosition)
}
}
// now itterate throgh moves and exclude collisions, for valid moves, work out if the movement will
// hit an apponents piece
for (const move of rawMoveList) {
for (const pieceName in myplayer.pieces) {
const piece = myplayer.pieces[pieceName];
var playerGridPos = roatate(piece.gridPosition, playerSide, false);
if (playerGridPos == move && piece.aliveState == AliveState.alive) {
return;
}
}
for (const opponentSide in players) {
const opponent = players[+opponentSide];
if (+opponentSide == playerSide) continue;
if (opponent.aliveState == AliveState.dead) continue;
for (const opponentPieceName in opponent.pieces) {
var opponentPiece = opponent.pieces[opponentPieceName];
var relativePosition = roatate(opponentPiece.gridPosition, playerSide, false);
if (relativePosition == +move && opponentPiece.aliveState == AliveState.alive) {
actualMoveList.push({
actionType: ActionType.attack,
gridPosition: roatate(move, playerSide, true),
opponentPiece: { playerSide: +opponentSide, playerPiece: opponentPiece }
});
return;
}
}
}
actualMoveList.push({ actionType: ActionType.move, gridPosition: roatate(move, myplayer.playerSide, true), opponentPiece: undefined });
}
});
}
console.log(`${playerPiece.pieceName}: ${AliveState[playerPiece.aliveState]}, player: ${AliveState[myplayer.aliveState]} moves`, actualMoveList)
return actualMoveList;
}
We first build a list of potential moves based on the correct piece position, and the list of allowed movements. We then exclude from this list any collisions with our own chess pieces. We then return this list along with whether this move will result in an attack on our opponents piece.
Next we'll add the following helper function
function roatate(gridPosition: number, count: number, isClockwise: boolean): number {
var gridSize = gridWidth * gridWidth;
for (let index = 0; index < count; index++) {
var y = Math.floor(gridPosition / gridWidth);
var x = (gridPosition % gridWidth);
if (isClockwise) {
gridPosition = (x * gridWidth) + ((gridWidth - 1) - y);
} else {
gridPosition = (((gridWidth - 1) - x) * gridWidth) + y;
}
}
return ((gridPosition % gridSize) + gridSize) % gridSize;
}
We'll now add functions to update our grid colors based on this information
function updateGridColor(myPlayer: FrontendPlayer) {
var validMoveList:PlayerAction[] = []
if (myPlayer.requestMove != undefined) {
validMoveList = getValidMoves(myPlayer.requestMove.playerPiece, myPlayer.playerSide, frontendGame.players);
}
setGridColor(validMoveList);
}
function setGridColor(validMoveList: PlayerAction[]) {
if (myPlayer?.requestMove == undefined) {
for (const gridPos in grid) {
const gridEl = grid[gridPos];
scene.beginDirectAnimation(gridEl, [getDiffuseColorAnim(gridEl.material.diffuseColor, Color3.FromInts(255, 255, 255))], 0, 25, false, 3);
}
} else {
for (const gridPos in grid) {
if (myPlayer.requestMove.playerPiece.gridPosition == +gridPos) continue;
const gridEl = grid[gridPos];
var blocked = false;
for (const pieceName in myPlayer.pieces) {
var piece = myPlayer.pieces[pieceName];
if (piece.gridPosition == +gridPos && piece.aliveState == AliveState.alive) blocked = true;
}
var color = Color3.FromInts(90, 90, 90);
if (blocked) {
color = Color3.FromInts(255, 50, 50);
} else if (validMoveList.some(m => m.gridPosition == +gridPos && m.actionType == ActionType.attack)) {
color = Color3.FromInts(0, 100, 255);
} else if (validMoveList.some(m => m.gridPosition == +gridPos && m.actionType == ActionType.move)) {
color = Color3.FromInts(255, 255, 255);
}
scene.beginDirectAnimation(gridEl, [getDiffuseColorAnim(gridEl.material.diffuseColor, color)], 0, 25, false, 3);
}
}
}
Stackblitz
4. Adding Game Logic
For the game logic, please refer to the stackblitz link below
Stackblitz
5. Adding AI
For an example of implementing some crude player AI, please refer to the stackblitz link below
Stackblitz
Thanks again for stopping by, really hope you enjoyed the read.
Have a nice day π
Top comments (0)