Real-time Multi-player Terminal Game

Htoo Pyae Lwin
14 min readJan 28, 2021
Real-time Multi-player Terminal Tic-Tac-Toe Game

In this tutorial, we’re going to make a real-time application using Socket.io. Since many tutorials out there use chat apps as examples, I want to make a different app to showcase the use of Socket.io. So, it’s gonna be a good old tic-tac-toe game but the difference is — it’s gonna be a real-time, multiplayer game with the Terminal interface.

Preview

Here’s how it looks like

Setup

Here’s what we’re gonna use

If you just want to head straight into code, here’s the repo.

First, we’re gonna set up basic things like file structure and stuff. Place the project wherever you wish and

npm init -ynpm i socket.io socket.io-client blessed chalk figlet clear

Our folder structure will be like this

.
├── client
│ ├── client.js
│ └── utils
│ └── helpers.js
└── server
├── app.js
├── controllers
│ ├── room.js
│ └── user.js
├── lib
│ ├── game.js
│ └── player.js
├── server.js
└── utils
├── constants.js
└── helpers.js

So, we got ourselves a nice structure. First, we’re gonna have a server that’s gonna handle all the real-time traffic. This is where we’re gonna use the Socket.io Server library. We will have two controllers for managing the game rooms and the players. We won’t consider having a data layer for this application yet. This is something that can be done by using some form of databases like Redis or MongoDB. Utils folder will have a bunch of utility functions and constants.

Game Flow

Game Flow

Getting Started

First of all, we’re gonna need to declare some constants which will be useful later. We have roomPrefix, an array of winning sequences, and an array of messages to display to the user.

server/utils/constants.js

"use strict";// prefix
const roomPrefix = "game_room_";
// Winning sequences
const combos = [
[0, 1, 2],
[3, 4, 5],
[6, 7, 8],
[0, 3, 6],
[1, 4, 7],
[2, 5, 8],
[0, 4, 8],
[6, 4, 2],
];
// display messages
const messages = {
msg_tie: "Tied!",
msg_win: "U Won!",
msg_lose: "U Lost!",
msg_resign: "The other player has resigned",
msg_replay: "Play one more?",
msg_game_0: "Game has not started yet!",
msg_game_1: "Game started",
msg_invalid: "Invalid move",
msg_not_yet: "It's not your move yet.",
msg_waiting: "Waiting for another player",
msg_player_x: "You are 'Player X.",
msg_player_o: "You are 'Player O.",
msg_uname_exists: "Username already exists!",
};
module.exports = { combos, roomPrefix, messages };

Then we have a bunch of helper functions including logging colorized outputs.

server/utils/helpers.js

"use strict";const chalk = require("chalk");// normalize user inputs
const normalize = (str = "") => str.replace(/[\s\n]/g, "");
// logging
const log = {
error: (msg) => console.log(chalk.red(msg)),
info: (msg) => console.log(chalk.blue(msg)),
warn: (msg) => console.log(chalk.yellow(msg)),
success: (msg) => console.log(chalk.green(msg)),
};
// generate a random number < 1000
const genKey = () => Math.round(Math.random() * 1000).toString();
module.exports = {
normalize,
log,
genKey,
};

Real-time Support

Our game needs to be real-time so that the players can see the progress instantly.

Server

We’re gonna start creating our server with Socket.io. Here, we listen for five types of events.

  • socket connection
  • enter (user entering the game)
  • move (user making move)
  • replayConfirm (user accepting the rematch)
  • disconnect (user quitting from the game)

We also accept a command-line argument for customizing the port that the server will be run.

server/server.js

"use strict";const { createServer } = require("http");
const { Server } = require("socket.io");
const clear = require("clear");
const { log } = require("../utils/helpers");
// server port
const PORT = process.argv[2] || 3000;
const httpServer = createServer();
const io = new Server(httpServer);
clear();io.on("connection", (socket) => {
log.info(`New user connected to the server: ${socket.id}`);
socket.on("enter", (uname) => {
log.info(`${socket.id} has entered.`);
});
socket.on("move", (move) => {
log.info(`${socket.id} has made move.`);
});
socket.on("replayConfirm", (confirmed) => {
log.info(`${socket.id} has confirmed replay.`);
});
socket.on("disconnect", () => {
log.info(`${socket.id} is disconnected.`);
});
});
httpServer.listen(PORT, () => {
log.success(`Game server listening on PORT:${PORT}`);
log.warn("----------------------------------------");
});

So now, if you run

node server/server.js

you should be able to see

Game server listening on PORT:3000
----------------------------------------

Client

So, we got ourselves a socket server. Let’s make a client that can connect to this server. Also, we can get the server URL if the user provides it through a command-line argument. Here, we listen to a number of events that the server will fire to the client.

client/client.js

"use strict";const clear = require("clear");
const socket = require("socket.io-client")(
process.argv[2] || "http://localhost:3000"
);
socket.on("connect", () => {
clear();
console.log("Connected");
});
socket.on("uname-exists", (msg) => {});socket.on("progress", (msg) => {});socket.on("info", (msg) => {});socket.on("over", (msg) => {});socket.on("replay", (msg) => {});socket.on("scoreboard", (msg) => {});socket.on("disconnect", () => {
// disconnected
process.exit();
});

Now, if we run both the server in one terminal window and the client in another, you should see the client showing the “Connected” message and the server showing the message.

node server/server.jsGame server listening on PORT:3000
----------------------------------------
New user connected to the server: s4ZD9R7H9YuZVQShAAAB
node client/client.jsConnected

So, at this point, we have connected our client and the server via a web socket connection.

Game Logic

Let’s implement the game logic from now on. We need to have the players and the game where he/she is playing in. So, for the player, we will store the username and the socket connection that the player is on. By doing so, we can now separate multiple users.

server/lib/player.js

"use strict";/*
* Player
*
* Responsible for:
* - store player information such as username etc.
*/
class Player {
constructor(socket, username) {
this.socket = socket;
this.username = username;
}
}
module.exports = Player;

As for the game, it’s a bit much information we need to keep track of. We’ll need to track

  • who’s in the game
  • the game board
  • the scores
  • whose turn it is
  • the status of the game (finished => 3/tied => 2/ started => 1)
  • the moves of the player

server/lib/game.js

"use strict";/*
* Game Room
*
* Responsible for:
* - hosting the participants
* - tracking the participants' moves
* - and the overall progress of the game
*
*/
class Game {
constructor(gameID, [pX, pO]) {
this.gameID = gameID;
this.board = new Map();
this.moves = {
X: [],
O: [],
};
this.scoreboard = {
total: 0,
X: 0,
O: 0,
tie: 0,
};
this._status = 0;
this._turn = "X";
this.participants = {
[pX]: "X",
[pO]: "O",
};
this.replayConfirmed = 0;
}
/*
* For starting the game
*/
init() {
this._status = 1;
this._turn = "X";
this.replayConfirmed = 0;
// fill the board
Array
.from(Array(9).keys())
.forEach((c) => this.board.set(c + 1, null));
}
}
module.exports = Game;

Since we need to know who’s won the game or is the game tied, we will add two new helpers.

server/utils/helpers.js

"use strict";const chalk = require("chalk");const { combos } = require("./constants");...const checkWin = (moves, player) => {
for (let i = 0, len = combos.length; i < len; i++) {
const combo = combos[i];
if (combo.every((c) => moves[player].includes(c + 1))) {
return true;
}
}
return false;
};
const checkIsTied = (progress = "") => {
return progress
.replace(/\n/g, "")
.split("")
.every((s) => s !== ".");
};
module.exports = {
normalize,
log,
genKey,
checkWin,
checkIsTied,
};

Back to game.js, we will add two getters that will tell us the status of the game and the current state of the game board.

server/lib/game.js

...// get status of the game
get status() {
if (checkWin(this.moves, this._turn)) {
this._status = 3;
} else if (checkIsTied(this.progress)) {
this._status = 2;
}
return this._status;
}
// show the board
get progress() {
return [...this.board.values()]
.reduce((a, b) => `${a}${b || '.'}|`, '');
}
...

the progress getter will return the game board in a string that contains the occupied tiles (indicated by the player mark) and the empty tiles (indicated by the dot). As the game board has 3 rows, we will add a “|” to denote the end of a row so that we can extract it later.

We will also add a number of methods for handling the moves, resetting the game, updating the scoreboard, etc.

server/lib/game.js

...// toggle turn
toggleTurn() {
this._turn = this._turn === "X" ? "O" : "X";
}
confirmReplay() {
this.replayConfirmed = 1;
}
/*
* For tracking the participants' moves
* @param playerMark string
* @param tileNumber number
*/
makeMove(playerMark, tileNumber) {
if (this.board.get(tileNumber)) {
return false;
}
this.moves[playerMark].push(tileNumber);
this.board.set(tileNumber, playerMark);
return true;
}
reset() {
this.board.clear();
this.moves = {
X: [],
O: [],
};
this._status = 0;
}
updateScoreboard(winner) {
this.scoreboard = {
...this.scoreboard,
total: this.scoreboard.total + 1,
[winner]: this.scoreboard[winner] + 1,
};
}
...

Multi-player Support

Since we’re going for multi-player, we need to make our games controllable meaning that we need to allocate the rooms, assign the players and keep track of which games are ongoing and which players have quitted. To do that, we have a couple of controllers.

Controllers

First, we will make our games manageable in the form of rooms.

server/controllers/room.js

"use strict";const Game = require("../lib/game");
const { genKey } = require("../utils/helpers");
const { roomPrefix } = require("../utils/constants");
class RoomController {
constructor() {
this.ongoing = new Map();
}
create(participants) {
const game = new Game(`${roomPrefix}${genKey()}`, participants);
this.ongoing.set(game.gameID, game); return game;
}
getRoom(gameID) {
return this.ongoing.get(gameID);
}
remove(gameID) {
this.ongoing.delete(gameID);
}
getCurrentRoomID(socket) {
const roomID = [...socket.rooms].find((room) =>
`${room}`.includes(roomPrefix)
);
return roomID;
}
}
module.exports = RoomController;

Socket.io has something called room already to manage the socket in a form of collection. So, we will utilize that for our case. Each socket has a property called rooms and this property will have a set of room names that the current socket is in. Since a player can only play a game at a time, (in other words, the player can only be in a room at a time), we can find the current room of the player by finding the room name prefixed by our roomPrefix .

For making our game connectable by multiple users, we will have a controller for users.

server/controllers/user.js

"use strict";const Player = require("../lib/player");class UserController {
constructor() {
this.players = new Map();
this.queue = [];
}
get queueSize() {
return this.queue.length;
}
add2Store() {
const twoPlayers = this.queue.splice(0, 2);
const [p1, p2] = twoPlayers; this.players.set(p1.socket.id, p1);
this.players.set(p2.socket.id, p2);
return twoPlayers;
}
add2Queue(socket, username) {
const player = new Player(socket, username);
this.queue.push(player);
}
getPlayer(socketID) {
return this.players.get(socketID);
}
remove(socketID) {
this.players.delete(socketID);
}
checkExists(username) {
const users = [...this.players.values(), ...this.queue];
return users.find((user) => user.username === username);
}
}
module.exports = UserController;

When a user connects to the server, he/she will have to wait until there’s another user coming in as the game can only start when there are two players. So, we will have a queue to manage that. We need to constantly check the queue size, so we will have a getter for that.

Then we have two methods for storing the users and adding them to the queue. We ask the users about their username when they connect. So we need to check if the username provided already exists. We will add a method for that. When the users quit the game or get disconnected, they will be removed from the store.

So, we now have multi-player support and let’s implement our main game flow. This is where we handle the events sent back and forth between the server and the client. Remember our server listening for four events other than the connection event? we will handle them here.

server/app.js

"use strict";/*
* Main App Logic
*
* Responsible for:
* - creating the games
* - removing the games
* - assigning players to games
*
*/
const UserCtrler = require("./controllers/user");
const RoomCtrler = require("./controllers/room");
class App {
constructor(socketIO) {
this.io = socketIO;
this.dict = new Map();
this.userCtrler = new UserCtrler();
this.roomCtrler = new RoomCtrler();
}
/*
* Whenever a new user joins the server, decides whether to:
* - make the user wait or
* - match the players and start the game
*/
handleEnter(socket, username) {
} handlePlay(socket, message) { } handleReplay(socket, confirmed) { } handleDisconnect(socketID) { }
}
module.exports = App;

We will update our server with our event handlers as below.

server/server.js

"use strict";const { createServer } = require("http");
const { Server } = require("socket.io");
const clear = require("clear");
const App = require("./app");
const { log } = require("./utils/helpers");
// server port
const PORT = process.argv[2] || 3000;
const httpServer = createServer();
const io = new Server(httpServer);
const app = new App(io);clear();io.on("connection", (socket) => {
log.info(`New user connected to the server: ${socket.id}`);
socket.on("enter", (uname) => {
log.info(`${socket.id} has entered.`);
app.handleEnter(socket, uname);
});
socket.on("move", (move) => {
log.info(`${socket.id} has made move.`);
app.handlePlay(socket, move);
});
socket.on("replayConfirm", (confirmed) => {
log.info(`${socket.id} has confirmed replay.`);
app.handleReplay(socket, confirmed);
});
socket.on("disconnect", () => {
log.info(`${socket.id} is disconnected.`);
app.handleDisconnect(socket.id);
});
});
httpServer.listen(PORT, () => {
log.success(`Game server listening on PORT:${PORT}`);
log.warn("----------------------------------------");
});

Back to our app.js , we will first handle the enter event which is fired when the user connects the server with a username.

...const { normalize } = require("./utils/helpers");
const { messages } = require("./utils/constants");
const {
msg_tie,
msg_win,
msg_lose,
msg_resign,
msg_replay,
msg_game_0,
msg_game_1,
msg_not_yet,
msg_waiting,
msg_player_x,
msg_player_o,
msg_uname_exists,
} = messages;
...handleEnter(socket, username) {
const exists = this.userCtrler.checkExists(username);
if (exists) {
socket.emit("uname-exists", msg_uname_exists);
} else {
this.userCtrler.add2Queue(socket, username);
if (this.userCtrler.queueSize >= 2) {
const players = this.userCtrler.add2Store();
this.match(players);
} else {
socket.emit("info", msg_waiting);
}
}
}
...

Here, we check if the username exists and if not we add the user to the queue. If the queue has two waiting players, we match them in a game. For that, we need to add a method called match which will accept the players as a parameter.

...// Match the two participants in a new game
match(players) {
const [playerX, playerO] = players;
const pXSocketID = playerX.socket.id;
const pOSocketID = playerO.socket.id;
const newGame = this.roomCtrler.create([pXSocketID, pOSocketID]);
const roomID = newGame.gameID;
newGame.init(); // players join the room
playerX.socket.join(roomID);
playerO.socket.join(roomID);
// roomID => players
this.dict.set(roomID, {
playerX: pXSocketID,
playerO: pOSocketID,
});
// player => room
this.dict.set(pXSocketID, roomID);
this.dict.set(pOSocketID, roomID);
this.io.to(pXSocketID)
.emit("info", `${msg_game_1} ${msg_player_x}`);
this.io.to(pOSocketID)
.emit("info", `${msg_game_1} ${msg_player_o}`);
this.io.to(roomID)
.emit("progress", newGame.progress);
this.io.to(roomID)
.emit("scoreboard", JSON.stringify(newGame.scoreboard));
}
...

Next, we’ll handle the move event.

...handlePlay(socket, message) {
const normalized = normalize(message);
const roomID = this.dict.get(socket.id);
const game = this.roomCtrler.getRoom(roomID);
const currentPlayer = game.participants[socket.id];
const move = Number(normalized); const playerTurn = game._turn === currentPlayer; // game has started, move is valid and is the player's turn
if (playerTurn && game.status === 1) {
const accepted = game.makeMove(currentPlayer, move);
if (accepted) {
const progress = game.progress;
this.io.to(roomID).emit("progress", progress); if (game.status === 3) {
// game with decisive outcome
socket.emit("over", msg_win);
socket.broadcast.to(roomID).emit("over", msg_lose);
this.io.to(roomID).emit("replay", msg_replay); game.updateScoreboard(currentPlayer); game.reset();
} else if (game.status === 2) {
// game has tied
this.io.to(roomID).emit("over", msg_tie);
this.io.to(roomID).emit("replay", msg_replay);
game.updateScoreboard("tie"); game.reset();
} else {
// toggle turns
socket.broadcast.to(roomID).emit("progress", progress);
game.toggleTurn();
}
}
} else if (!playerTurn) {
socket.emit("info", msg_not_yet);
} else {
socket.emit("info", msg_game_0);
}
}
...

Then, we will handle the rest of the events which are replay and disconnect .

...handleReplay(socket, confirmed) {
const roomID = this.roomCtrler.getCurrentRoomID(socket);
const game = this.roomCtrler.getRoom(roomID);
if (!confirmed) {
this.roomCtrler.remove(roomID);
socket.disconnect();
} else if (game.replayConfirmed === 0) {
game.confirmReplay();
} else {
game.reset();
game.init();
this.io.to(roomID)
.emit("scoreboard", JSON.stringify(game.scoreboard));
this.io.to(roomID).emit("info", msg_game_1);
this.io.to(roomID).emit("progress", game.progress);
}
}
handleDisconnect(socketID) {
const roomID = this.dict.get(socketID);
this.dict.delete(socketID);
this.userCtrler.remove(socketID);
this.io.to(roomID).emit("info", msg_resign);
}
...

Okay, that’s all for the server and the game logic part. We just need to build game UI at the client-side to handle user interaction.

Game UI Interaction

We will use blessed for building our Terminal UI.

client/utils/helpers.js

First of all, we need to show a text box that asks for the username. The gameboard is achieved by laying out a bunch of boxes in a grid manner. We also need to display the scores of the players. And we also need a confirmation whether the player wants to play again.

"use strict";const blessed = require("blessed");
const figlet = require("figlet");
// Create a screen object.
const screen = blessed.screen({
smartCSR: true,
});
screen.title = "Tic Tac Toe";// Quit on Escape, q, or Control-C.
screen.key(["escape", "q", "C-c"], function (ch, key) {
return process.exit(0);
});
const title = blessed.text({
parent: screen,
align: "center",
content: figlet.textSync("Tic-Tac-Toe", {
horizontalLayout: "full"
}),
style: {
fg: "blue",
},
});
const warning = blessed.text({
parent: screen,
bottom: 0,
left: "center",
align: "center",
style: {
fg: "yellow",
},
});
const boardLayout = blessed.layout({
parent: screen,
top: "center",
left: "center",
// border: "line",
width: "50%",
height: "50%",
renderer: function (coords) {
const self = this;
// The coordinates of the layout element
const xi = coords.xi;
// The current row offset in cells (which row are we on?)
let rowOffset = 0;
// The index of the first child in the row
let rowIndex = 0;
return function iterator(el, i) {
el.shrink = true;
const last = self.getLastCoords(i); if (!last) {
el.position.left = "25%";
el.position.top = 0;
} else {
el.position.left = last.xl - xi;
if (i % 3 === 0) {
rowOffset += self.children
.slice(rowIndex, i)
.reduce(function (out, el) {
if (!self.isRendered(el)) return out;
out = Math.max(out, el.lpos.yl - el.lpos.yi);
return out;
}, 0);
rowIndex = i;
el.position.left = "25%";
el.position.top = rowOffset;
} else {
el.position.top = rowOffset;
}
}
};
},
});
const boxes = Array.from(Array(9).keys()).map(() => {
const box = blessed.box({
parent: boardLayout,
width: 10,
height: 5,
border: "line",
clickable: true,
hidden: true,
style: {
hover: {
bg: "green",
},
visible: false,
border: {
fg: "white",
},
},
});
return box;
});
const scoreboard = blessed.text({
parent: screen,
top: 6,
left: "center",
border: "line",
clickable: false,
hidden: true,
style: {
visible: false,
border: {
fg: "cyan",
},
},
});
const gameOver = blessed.text({
parent: screen,
align: "center",
left: "center",
bottom: 0,
hidden: true,
style: {
fg: "cyan",
},
});
function printScoreboard(scores) {
scoreboard.setContent(scores);
scoreboard.show();
screen.render();
}
function hideBoard() {
boxes.forEach((box) => {
box.hide();
});
screen.render();
}
function drawBoard(progress, callback) {
boxes.forEach((box, i) => {
box.setContent(`${progress[i] || "."}`);
box.show();
box.on("click", () => {
callback(`${i + 1}`);
});
});
screen.render();
}
function print(msg) {
warning.setContent(msg);
screen.render();
}
function clearPrint() {
setTimeout(() => print(""), 4000);
}
function confirmReplay(msg, callback) {
const confirm = blessed.question({
parent: screen,
top: "center",
left: "center",
border: "line",
});
confirm.ask(msg, (err, value) => {
if (!err) {
callback(value);
hideBoard();
gameOver.hide();
}
});
screen.render();
}
function askUsername(callback) {
const form = blessed.form({
parent: screen,
top: "center",
left: "center",
});
const question = blessed.textbox({
parent: form,
height: 3,
name: "username",
border: "line",
style: {
border: {
fg: "green",
},
},
});
question.readInput(); question.onceKey("enter", () => {
form.submit();
});
form.on("submit", (data) => {
callback(data);
hideBoard();
screen.remove(form);
});
screen.render();
}
function showGameOver(msg) {
gameOver.setContent(figlet.textSync(msg));
gameOver.show();
screen.render();
}
module.exports = {
print,
drawBoard,
clearPrint,
askUsername,
confirmReplay,
printScoreboard,
showGameOver,
};

After all that, we will utilize these helper functions inside the event handlers. So back to client.js

client/client.js

"use strict";const clear = require("clear");
const socket = require("socket.io-client")(
process.argv[2] || "http://localhost:3000"
);
const {
print,
drawBoard,
clearPrint,
confirmReplay,
askUsername,
printScoreboard,
showGameOver,
} = require("./utils/helpers");
socket.on("connect", () => {
clear();
askUsername((data) => {
socket.emit("enter", data.username);
});
});
socket.on("uname-exists", (msg) => {
print(msg);
askUsername((data) => {
socket.emit("enter", data.username);
});
});
socket.on("progress", (msg) => {
drawBoard(msg.split("|"), (move) => {
socket.emit("move", move);
});
});
socket.on("info", (msg) => {
print(msg);
clearPrint();
});
socket.on("over", (msg) => {
showGameOver(msg);
});
socket.on("replay", (msg) => {
confirmReplay(msg, (value) => {
socket.emit("replayConfirm", value);
});
});
socket.on("scoreboard", (msg) => {
const { total, X, O, tie } = JSON.parse(msg);
printScoreboard(`[Total: ${total} | X: ${X} | O: ${O} | tie: ${tie}]`);
});
socket.on("disconnect", () => {
print("Disconnected 😞");
process.exit();
});

Phew, after all this. We can now play our game. Open up three terminal windows and run the server and the two clients to mimic the two players. Provide the usernames and test the game out!

Final Result

You can connect multiple users and see the queue and the matching and all that stuff we did. If you guys made it to the end, thank you for your hard work and enthusiasm. I hope you learn something from this.

--

--