Real-time Multi-player Terminal Game (Part - 2)

ဒီတစ်ခေါက်မှာတော့ ကျနော်တို့ Game Logic နဲ့ ပတ်သတ်တာတွေချည်းပဲ လုပ်သွားပါမယ်။

Game Logic

အရင်ဆုံး User က Connection ချိတ်တဲ့အခါမှာ Player တစ်ယောက်အနေနဲ့ Identify လုပ်ဖို့လုပ်လာပါမယ်။ အဲဒီအတွက် Player Instance ရနိုင်ဖို့အတွက် Player Class ရှိမယ်ပေါ့။ ဒီထဲမှာ သူ့ရဲ့ username နဲ့ socket connection သိမ်းပါမယ်။

"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;

Game အနေနဲ့ဆိုရင်တော့ Track ရမယ့် information တွေရှိပါတယ်။

  • ဘယ် Player တွေက in-game လဲ
  • Gameboard
  • Score တွေ (ဘယ်သူဘယ်နှစ်ပွဲနိုင်လဲဆိုတာတွေပေါ့)
  • Turn - ဘယ်သူ့အလှည့်လဲ
  • Game ရဲ့ Status (ပြီးသွားရင် - 3, သရေ - 2, စပြီဆိုရင် - 1)
  • 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;

Game မှာ အကွက်ရွှေ့တိုင်း နိုင်မနိုင်စစ်တာမျိုးတွေအတွက် helper နှစ်ခုထပ်လိုပါမယ်။ ဒါ့ကြောင့် server/utils/helpers.js ထဲမှာ

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,
};

အခု game.js ကို ပြန်သွားပြီး status နဲ့ gameboard ပေးတဲ့ getter နှစ်ခုထည့်ပါမယ်။

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 || '.'}|`, '');
}

...

progress ကတော့ 3 Rows, 3 Columns ရှိတဲ့ Gameboard ကို string အနေနဲ့ ပြန်ပါတယ်။ ဒီ string

မှာ Player တွေ ရွှေ့ထားတဲ့အကွက်နေရာတွေမှာ Player ရဲ့ Mark ပေါ့ (X သို့ O) နဲ့ သတ်မှတ်ပါတယ်။ မရွှေ့ရသေးတဲ့ အကွက်ဆိုရင် dot နဲ့ သတ်မှတ်ပါလိမ့်မယ်။ | ကတော့ row တစ်ခုအဆုံးကို သတ်မှတ်ပါတယ်။

နောက်ပြီး အလှည့် Toggle လုပ်တာတွေ ၊ အကွက်ရွှေ့တာတွေ ၊ scoreboard ကို update လုပ်တာတွေအတွက် Method တွေပေါ့၊ ဒီဟာတွေလည်း game.js ထဲ ထည့်ပါမယ်။

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

Multi-player ဖြစ်ဖို့အတွက် Ongoing ဖြစ်နေတဲ့ Game တွေ ၊ Player တွေကို Queue ထဲထည့်တာမျိုးတွေ ၊ ဘယ် Player တွေ ထွက်သွားလဲ ၊ Room assign ချတာတွေ စတာတွေကို ထိန်းချုပ်ဖို့ တစ်ခု လိုလာပါပြီ။ အရင်ဆုံး ကျနော်တို့ Room နဲ့ ပတ်သတ်ပြီး လုပ်ဆောင်ဖို့အတွက် Controller တစ်ခု တည်ဆောက်ပါမယ်။

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;

ဒီ Controller မှာဆိုရင် အရှေ့မှာပြောခဲ့တဲ့ Socket.io ရဲ့ Room ကို သုံးပြီးတော့ Game Room တွေကို Manage လုပ်တာဖြစ်ပါတယ်။ Connected ဖြစ်နေတဲ့ Socket တစ်ခုတိုင်းမှာ rooms ဆိုတဲ့ Property တစ်ခုပါတာဖြစ်တဲ့အတွက် ဒီကနေပြီး လက်ရှိ Player က ဘယ် Game Room မှာ ဆော့နေလဲဆိုတာကို သိနိုင်ပါလိမ့်မယ်။ Player တစ်ယောက်ဟာ တစ်ကြိမ်မှာ Game Room တစ်ခုထဲမှာပဲ ရှိမှာဖြစ်ပါတယ်။ ပြီးတော့ ကျနော်တို့ Game Room တွေ Create တဲ့အချိန်မှာ roomPrefix ဆိုတဲ့ Constant ကို သုံးထားတာဖြစ်တဲ့အတွက် လက်ရှိ Player ရဲ့ Game Room ကို ပြန်ရှာတဲ့အခါမှာ ဒီ prefix ကိုပဲ ပြန်သုံးပြီးရှာပါတယ်။ ဒီကနေ Room ရလာပြီဆိုမှ Player ရွှေ့တဲ့ အကွက်က အဲဒီ Room ထဲမှာ သွားသက်ရောက်မယ့် ကိစ္စကို ဆက်လုပ်နိုင်မှာပါ။

ကျနော်တို့ Player တွေအတွက်လည်း Controller တစ်ခုတည်ဆောက်ပါမယ်။ အဓိကကတော့ Queue နဲ့ လက်ရှိ Play နေတဲ့ Player တွေကို Track လုပ်ပါတယ်။ add2Store ဆိုတဲ့ Method က Game စဖို့အတွက် Player နှစ်ယောက်ကို Queue ထဲကထုတ်ပြီး Match ပေးတာဖြစ်ပါတယ်။ add2Queue ကတော့ User ဝင်လာလာချင်းမှာ တခြား Player အဆင်သင့်မရှိသေးရင် စောင့်ခိုင်းတာပေါ့။ ပြီးတော့ User ကပေးတဲ့ သူ့ရဲ့ Username က ရှိပြီးသားလားဆိုတာကို Check လုပ်တဲ့ checkExists ဆိုတဲ့ Method တစ်ခုရှိပါမယ်။

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;

အခုဆိုရင် Multi-player အတွက် အဆင်သင့်ဖြစ်ပြီဆိုတော့ ကျနော်တို့ Game Flow ကို ကိုင်တွယ်ဖို့ ဆက်လုပ်ပါမယ်။ ဒီမှာမှ ကျနော်တို့ Server နဲ့ Client ကြား အပြန်အလှန်ပို့တဲ့ Event တွေကို ထိန်းမယ့် Handler တွေရှိမှာဖြစ်ပါတယ်။ ရှေ့မှာတုန်းက ကျနော်တို့ Server ကနေ Listen လုပ်မယ့် event တွေကို ကြေညာခဲ့ပြီးဖြစ်ပါတယ်။

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;

အခု Event Handler တွေလည်း ရှိပြီဆိုတော့ ကျနော်တို့ server.js မှာ သွားထည့်ပါမယ်။ ဆိုတော့ server.js က ဒီလိုဖြစ်လာပါမယ်။

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("----------------------------------------");
});

အခုကျနော်တို့ app.js ကို ပြန်သွားပြီး User တစ်ယောက်ဝင်လာတဲ့အချိန်မှာဖြစ်တဲ့ enter Event ကို Handle လုပ်ပါမယ်။ ဒီမှာဆိုရင် Constant ထဲက messages ကို Message တွေအနေနဲ့ သုံးသွားမှာပါ။

server/app.js

...

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);
    }
  }
}

...

Logic ကတော့ ရှင်းပါတယ်။ User က username တစ်ခုနဲ့ဝင်လာမယ်။ ရှိပြီးသား username ဆိုရင် uname-exists ဆိုတဲ့ Event ပြန်ပို့ပြီး ရှိပြီးသားဖြစ်ကြောင်း ပြန်ပြောပါမယ်။ မဟုတ်ရင်တော့ User Controller ထဲက Queue Size ကို ကြည့်ပြီး Player နှစ်ယောက်ရှိပြီလား စစ်မယ်။ ရှိရင် တစ်ခါတည်း Match ပေးလိုက်ပါမယ်။ မရှိသေးရင်တော့ waiting message ပြန်ပို့ပါမယ်။

Match လုပ်တာအတွက် ကျနော်တို့ ဒီ handleEnter အပေါ်မှာ Method တစ်ခုထည့်ရပါမယ်။ ဒီ match Method မှာတော့ Game Room ထောင်တာတွေ ၊ Player တွေကို Game Room ထဲ join ခိုင်းတာတွေ ၊ Game ကို စပြီးတော့ Player တွေဆီဘက်ကို Message ပြန်ပို့တာတွေ လုပ်ပါတယ်။ ဒီဟာပြီးတာနဲ့ Client ဘက်မှာ Gameboard ကို စမြင်ရမှာ ဖြစ်ပြီးတော့ Player တွေက စဆော့နိုင်မှာဖြစ်ပါတယ်။

server/app.js

...

// 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));
}

...

အခု ကျနော်တို့ Player တွေ အကွက်ရွှေ့တဲ့အခါမှာဖြစ်တဲ့ move Event ကို handle လုပ်ပါမယ်။ Player ဘက်က အကွက်တစ်ခါရွှေ့တိုင်းမှာ Game ရဲ့ Status ပြောင်းပြီလားစစ်ပါတယ်။ Game ရဲ့ Status Code ကတော့

  • 3 ဆိုရင် ဘယ်သူနိုင်တယ် ၊ ရှုံးတယ်ဆိုတဲ့ Outcome ထွက်တာဖြစ်ပါတယ်
  • 2 ဆိုရင်တော့ သရေပေါ့

ဒီ Status Code တွေမဟုတ်သေးသမျှ Game က ဆက်ပြီး သွားနေမှာပါ။

server/app.js

...

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);
  }
}

...

နောက်ဆုံးကျန်တဲ့ Event နှစ်ခုဖြစ်တဲ့ replayConfirm နဲ့ disconnect အတွက် handler တွေ ဆက်ရေးပါမယ်။

...

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);
}
...

ဒီအထိရောက်ပြီဆိုရင်တော့ ကျနော်တို့ Game Server နဲ့ Overall Flow ဘက်ပိုင်း အကုန်ပြီးသွားပြီဖြစ်ပါတယ်။ ကျနော်တို့ ကျန်တာဆိုလို့ Game UI တည်ဆောက်ဖို့ပါပဲ။

Game UI and Interaction

ကျနော်တို့ရဲ့ Client က Terminal ထဲမှာ run မှာဖြစ်ပြီးတော့ UI ကလည်း Terminal ပါပဲ။ ဒါကြောင့် ကျနော်တို့ blessed ဆိုတဲ့ Library တစ်ခုကို သုံးပြီးတည်ဆောက်သွားမှာ ဖြစ်ပါတယ်။

Tic-Tac-Toe Gameboard မှာ Row သုံးခု Column သုံးခု ရှိပါတယ်။ ဒီအတွက်ကို ကျနော်တို့ blessed ရဲ့ box ဆိုတဲ့ API ကို သုံးသွားမှာပေါ့။ ရလာတဲ့ Box တွေကိုမှ Row/Column ဖြစ်အောင် Layout ချသွားမှာပါ။ စဝင်ဝင်ချင်းမှာ Username တောင်းတာဆိုတော့ blessed ရဲ့ form API ကို သုံးပြီးတော့ Input ယူပါမယ်။ ပြီးတော့ ကျနော်တို့ Score တွေပြမယ့်ဟာ တစ်ခုလိုပါတယ်။ ပြီးရင်ကျတော့ Server ဘက်က Message တွေကို ပြမယ့် Text တစ်ခုပေါ့ ၊ ဥပမာ - အလှည့်မရောက်သေးဘူး စသဖြင့်ပြောတဲ့ Message တွေကို ပြဖို့အတွက်ဟာ တစ်ခုလိုပါတယ်။ ပွဲတစ်ခုပြီးသွားတဲ့အခါတိုင်းမှာ ထပ်ဆော့ဦးမလားမေးမှာဆိုတော့ ဒီအတွက်လည်း Confirmation လုပ်နိုင်မယ့်ဟာလိုပါတယ်။ ဒီအတွက်ကိုတော့ blessed ရဲ့ question API ကို သုံးပါမယ်။

အပေါ်ကပြောခဲ့တဲ့ UI တည်ဆောက်ဖို့လိုတာတွေအကုန်လုံးကို ကျနော်တို့ utility function တွေအနေနဲ့ ထားသွားမှာ ဖြစ်ပါတယ်။

client/utils/helpers.js

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,
};

အခုကျနော်တို့ client.js မှာ ဒီ utility function တွေကို Event handler တွေထဲမှာ သုံးဖို့ပဲ လိုပါတော့တယ်။

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();
});

ဒီအထိရောက်ခဲ့ရင်တော့ ဝမ်းသာပါတယ်။ စာဖတ်သူအနေနဲ့ Real-time အလုပ်လုပ်နိုင်တဲ့ Multi-player Terminal Game တစ်ခုကို ရေးလိုက်နိုင်ပြီ ဖြစ်ပါတယ်။ ကျနော်တို့ စမ်းကြည့်မယ်ဆိုရင် Terminal Window တစ်ခုမှာ Server ကို run ထားရပါမယ်။ ပြီးရင်တော့ Player နှစ်ယောက်ထက်မက Terminal Window တွေဖွင့်ပြီး ချိတ်ကြည့်ပေါ့။ ဒါဆိုရင် Queue ထဲမှာ User နှစ်ယောက်ရှိိတိုင်း Game Match တစ်ခုစသွားတာတို့ ၊ Queue ထဲဝင်ပြီး စောင့်ခိုင်းတဲ့ Message ပြတာတို့ စတဲ့ Game Flow တစ်ခုလုံးကို စမ်းကြည့်နိုင်ပါလိမ့်မယ်။