Node.js・Socket.IO・Redisで構築するスケーラブルなリアルタイム対戦型三目並べ(Tic-Tac-Toe)ゲーム
このチュートリアルでは、Node.js、Socket.IO、Redisを組み合わせて、リアルタイムのマルチプレイヤー対応三目並べ(Tic-Tac-Toe)ゲームを作成します。2人のプレイヤーが異なるブラウザタブから接続し、交互に手を打ちながら、盤面の変化をリアルタイムに確認できるゲームです。Redisを活用して複数のWebSocketサーバー間でゲーム状態を同期することで、スケーラブルなアプリケーションを実現します。
この記事を読み終える頃には、実際に動作するリアルタイムゲームが完成しているだけでなく、WebSocketとRedisを使ってスケーラブルなリアルタイムアプリケーションを構築するための確かな知識も身についています。
このチュートリアルで学べること
- Socket.IOを使ったリアルタイム通信の実装方法
- Redis Pub/Subを利用した複数クライアント間のゲーム状態同期の仕組み
- スケーラブルなWebSocketサーバーアーキテクチャの構築方法
前提条件
始める前に、以下の環境が整っていることを確認してください。
- Node.js(v16以上)
- Redis
- Docker(任意:コンテナでRedisを動かす場合)
- JavaScript、Node.js、WebSocketに関する基礎知識
目次
- プロジェクト概要
- ステップ1:開発環境のセットアップ
- ステップ2:プロジェクトの初期設定
- ステップ3:Redis連携WebSocketサーバーの実装
- ステップ4:Reactフロントエンドの実装
- ステップ5:アプリケーションの起動
- ステップ6:Redisメッセージのリアルタイム監視
- デモ
- まとめ
プロジェクト概要
今回構築するのは、以下の機能を持つリアルタイム三目並べゲームです。
- 2人のプレイヤーが接続して対戦できる
- ゲーム盤面が異なるブラウザタブ間でリアルタイムに更新される
- 勝者が決まった場合や盤面が埋まって引き分けになった場合に結果を表示する
使用する技術スタックは以下の通りです。
- Node.js + Socket.IO:WebSocket接続の処理
- Redis Pub/Sub:クライアント間のゲーム状態同期の管理
ステップ1:開発環境のセットアップ
Node.jsのインストール
まず、システムにNode.jsがインストールされているか確認します。
node -v
インストールされていない場合は、Node.js公式サイトからダウンロードしてください。
Redisのインストール
Redisはローカルに直接インストールするか、Dockerコンテナとして実行できます。
macOS(Homebrewを使用)
以下のコマンドを実行する前に、Homebrewがインストール済みであることを確認してください。
brew install redis
brew services start redis
次のコマンドでRedisが正常に起動しているか確認します。
redis-cli ping
以下のように表示されれば成功です。
PONG
DockerでRedisを起動する場合
docker run --name redis-server -p 6379:6379 -d redis
Redisが動作しているか確認します。
docker exec -it redis-server redis-cli ping
ステップ2:プロジェクトの初期設定
1. プロジェクトディレクトリの作成
mkdir tic-tac-toe
cd tic-tac-toe
npm init -y
2. 依存パッケージのインストール
npm install express socket.io redis dotenv
3. 環境変数の作成
プロジェクトのルートに.envファイルを作成し、以下の内容を記述します。
PORT=3000
REDIS_HOST=localhost
REDIS_PORT=6379
ステップ3:Redis連携WebSocketサーバーの実装
このステップでは、Node.js、Socket.IO、Redisを使ってリアルタイムのゲーム操作を処理するWebSocketサーバーを構築します。このサーバーはゲーム状態の管理、プレイヤーの着手処理、そしてRedis Pub/Subによる複数クライアント間の同期を担います。
コードを各セクションに分けて解説するので、全体がどのように組み合わさっているのかを正確に理解できるでしょう。
サーバーコードの解説
server.jsというファイルを作成し、以下のコードを追加します。
import dotenv from 'dotenv';
import express from 'express';
import http from 'http';
import { Server } from 'socket.io';
import { createClient } from 'redis';
dotenv.config(); // .envファイルから環境変数を読み込む
const app = express();
const server = http.createServer(app);
const io = new Server(server, {
cors: {
origin: "https://localhost:5173",
methods: ["GET", "POST"],
}
});
- dotenv:
.envファイルから環境変数を読み込み、ポート番号などの機密情報を安全に管理します。 - express:HTTPリクエストを処理する基本的なExpressサーバーを立ち上げます。
- http:Node.js標準の
httpモジュールでHTTPサーバーを作成し、Socket.IOと組み合わせてWebSocket通信を行います。 - Socket.IO:サーバーとクライアント間の双方向リアルタイム通信を可能にするライブラリです。
- CORS設定:
localhost:5173で動作するフロントエンドからのクロスオリジンリクエストを許可します。
続いて、Redisのパブリッシャーとサブスクライバーのクライアントを作成するため、以下のコードをserver.jsに追加します。
// Redisクライアントの初期化
const pubClient = createClient();
const subClient = createClient();
await pubClient.connect();
await subClient.connect();
Redisは、接続中のクライアント間でリアルタイムデータを同期するために使用します。
- pubClient:メッセージの公開(パブリッシュ)に使用。ゲーム状態の更新などを行います。
- subClient:メッセージの購読(サブスクライブ)に使用。更新を待ち受けます。
- connect():Redisサーバーへの接続を確立します。
この方式では、片方のクライアントで更新を発行し、もう片方で更新を受け取ります。これはサブスクライブモードのRedisクライアントがメッセージの受信しかできないため、ブロッキングを避けるための重要な設計ポイントです。
次に、ゲーム更新用のRedisチャンネルを購読するコードをserver.jsに追加します。
// ゲーム更新用のRedisチャンネルを購読
await subClient.subscribe('game-moves', (message) => {
gameState = JSON.parse(message);
io.emit('gameState', gameState);
});
- subClient.subscribe:
game-movesチャンネル上のメッセージを待ち受けます。 - プレイヤーが新しい手を打つたびに、Redis内のゲーム状態が更新され、接続中の全クライアントにその状態が通知されます。
message引数には文字列形式のゲーム状態が含まれています。これをJavaScriptオブジェクトにパースし、Socket.IO経由で更新後の状態をブロードキャストします。
さらに、ゲーム状態と関連関数を定義するため、以下のコードをserver.jsに追加します。
// 初期ゲーム状態の定義
let gameState = {
board: Array(9).fill(null),
xIsNext: true,
};
// ゲームをリセットする関数
function resetGame() {
gameState = {
board: Array(9).fill(null),
xIsNext: true,
};
}
- gameState:現在の盤面と手番(
xIsNext)を管理します。- 盤面は9マスの配列で表現され、各セルには「X」「O」または
nullが入ります。 xIsNextフラグによって、どちらのプレイヤーの手番かが判定されます。
- 盤面は9マスの配列で表現され、各セルには「X」「O」または
- resetGame():盤面と手番を初期状態に戻し、新しいゲームを開始できるようにします。
次に、WebSocket接続を処理するため、以下のコードをserver.jsに追加します。
io.on('connection', (socket) => {
console.log('New client connected:', socket.id);
// 新しく接続したクライアントに現在のゲーム状態を送信
socket.emit('gameState', gameState);
io.on('connection')イベントは、新しいクライアントが接続したときに発生します。- socket.id:接続された各クライアントの一意な識別子です。
- 新しいクライアントに対して即座に現在の
gameStateを送信することで、最新の盤面を表示できるようにしています。
プレイヤーの着手を処理するため、以下のコードをserver.jsに追加します。
// プレイヤーの着手を処理
socket.on('makeMove', (index) => {
// セルが既に埋まっている、またはゲーム終了済みの場合は着手を無効化
if (gameState.board[index] || calculateWinner(gameState.board)) return;
// 盤面を更新して手番を切り替え
gameState.board[index] = gameState.xIsNext ? 'X' : 'O';
gameState.xIsNext = !gameState.xIsNext;
// 更新後のゲーム状態をRedisへパブリッシュ
pubClient.publish('game-moves', JSON.stringify(gameState));
io.emit('gameState', gameState);
});
- makeMove:プレイヤーがセルをクリックしたときに発生するイベントです。
- バリデーション:着手前に、セルが既に占有されているか、ゲームが終了していないかをチェックします。
- ゲーム状態の更新:有効な着手であれば、盤面を更新して手番を切り替えます。
- 更新されたゲーム状態は以下のように処理されます。
- Redisへのパブリッシュ:すべてのサーバーインスタンスが同期状態を保てるようにします。
- 全クライアントへのブロードキャスト:全プレイヤーのゲーム盤面を即座に更新します。
ゲームの再開を処理するため、以下のコードをserver.jsに追加します。
// ゲームの再開を処理
socket.on('restartGame', () => {
resetGame();
io.emit('gameState', gameState);
});
クライアントの切断を処理するため、以下のコードをserver.jsに追加します。
socket.on('disconnect', () => {
console.log('Client disconnected:', socket.id);
});
});
最後に、ゲームロジックを処理する以下の関数をserver.jsに追加します。
// 勝者判定関数
function calculateWinner(board) {
const lines = [
[0, 1, 2], [3, 4, 5], [6, 7, 8],
[0, 3, 6], [1, 4, 7], [2, 5, 8],
[0, 4, 8], [2, 4, 6]
];
for (let [a, b, c] of lines) {
if (board[a] && board[a] === board[b] && board[a] === board[c]) {
return board[a];
}
}
return null;
}
function isBoardFull(board) {
return board.every((cell) => cell !== null);
}
- calculateWinner():盤面上に勝利条件を満たすラインがあるかどうかをチェックします。
- isBoardFull():全セルが埋まっているかどうかを確認し、引き分けの判定に使用します。
ステップ4:Reactフロントエンドの実装
このステップでは、三目並べゲームのためのシンプルでインタラクティブなReactフロントエンドを構築します。プレイヤーはWebSocketサーバーに接続し、手を打ちながら盤面のリアルタイム更新を確認できます。
App.jsxに以下のコードを追加します。
import React, { useEffect, useState } from 'react';
import io from 'socket.io-client';
const socket = io('https://localhost:3000');
function App() {
const [gameState, setGameState] = useState({
board: Array(9).fill(null),
xIsNext: true,
winner: null
});
useEffect(() => {
socket.on('gameState', (state) => {
setGameState(state);
});
return () => socket.off('gameState');
}, []);
const handleClick = (index) => {
if (gameState.board[index] || gameState.winner) return;
socket.emit('makeMove', index);
};
const renderCell = (index) => (
<button onClick={() => handleClick(index)}>{gameState.board[index]}</button>
);
return (
<div>
<h1>Multiplayer Tic-Tac-Toe</h1>
<div className="board">
{[...Array(9)].map((_, i) => renderCell(i))}
</div>
<button onClick={() => socket.emit('restartGame')}>Restart Game</button>
</div>
);
}
export default App;
Reactアプリの構成要素をまとめると以下のようになります。
- WebSocket接続:フロントエンドは
socket.io-clientを使ってサーバーとの接続を確立します。 - 状態管理:ゲーム状態(
gameState)はReactのuseStateで管理され、以下を含みます。- board(9マスの盤面)
- 現在の手番を示すxIsNextフラグ
- winner(勝者の状態)
- リアルタイム更新:
useEffectフックが以下の役割を果たします。- サーバーからの
gameState更新を待ち受ける - 変更を検出した際にローカルのゲーム状態を更新する
- コンポーネントのアンマウント時にWebSocketリスナーをクリーンアップする
- サーバーからの
- 着手の処理:
handleClick関数は以下を行います。- セルが既に埋まっている場合や勝者が決定している場合は着手を無効化する
- クリックされたセルのインデックスを添えて
makeMoveイベントをサーバーへ送信する
- 盤面の描画:
renderCell関数が盤面の各セルに対応するボタンを生成し、3×3のグリッドで表示します。 - ゲームの再開:「Restart Game」ボタンが
restartGameイベントを発行し、全プレイヤーの盤面をリセットします。 - ユーザーインターフェース:プレイヤーが交互に手を打ち、リアルタイムの更新を確認できるシンプルで直感的なレイアウトです。
ステップ5:アプリケーションの起動
バックエンドの起動
バックエンドサーバーを起動するには、新しいターミナルウィンドウを開いて以下のコマンドを実行します。
cd tic-tac-toe
npm start
フロントエンドの起動
Reactフロントエンドサーバーを起動するには、別のターミナルウィンドウを開いて以下のコマンドを実行します(バックエンドと同じターミナルは使わないでください。両方を同時に実行する必要があります)。
cd tic-tac-toe-client
npm run dev
ゲームへのアクセス
ブラウザを開き、以下のURLにアクセスします。
https://localhost:5173
ステップ6:Redisメッセージのリアルタイム監視
ゲームの実行中にRedisメッセージを監視すると、ゲーム状態のリアルタイム更新を目で確認できます。
ターミナルを開いて以下を実行します。
redis-cli
SUBSCRIBE game-moves
すると、ゲームの更新情報が以下のように表示されます。
1) "message"
2) "game-moves"
3) "{\"board\":[\"X\",null,\"O\",null,\"X\",null,null,null,null],\"xIsNext\":false}"
着手が行われたりゲーム状態が変わったりするたびに、サーバーは更新後の状態をgame-movesチャンネルへパブリッシュします。redis-cliを使えば、プレイ中のゲームの状態遷移をリアルタイムで監視できるのです。
デモ
このデモでは、ローカル環境で動作する三目並べゲームをご覧いただけます。プレイヤーが交互に手を打ちながら、リアルタイムに盤面が更新される様子を確認できます。
手番の切り替え、盤面の更新、勝敗や引き分けの発表といった機能が紹介されており、WebSocket通信によってスムーズでインタラクティブな体験が実現できていることがわかります。
まとめ
おめでとうございます!Node.js、Socket.IO、Redisを使ったリアルタイムマルチプレイヤー三目並べゲームの構築に成功しました。このチュートリアルで学んだ内容は以下の通りです。
- Socket.IOによるリアルタイムWebSocket通信
- Redis Pub/Subによるゲーム状態管理
- Reactを使ったレスポンシブなフロントエンド構築
次のステップ
- プレイヤー認証機能の追加
- チャット機能の実装
- スケーラビリティ向上のためクラウドプロバイダーへのデプロイ
それでは、Happy Coding!
無料でプログラミングを学びましょう。freeCodeCampのオープンソースカリキュラムは、これまで4万人以上の人々を開発者としての就職へ導いてきました。今すぐ始めてみましょう。
-
Redis SMOVEコマンドの使い方 – セット間で要素を移動する方法
このチュートリアルでは、Redisデータストアに保存されたあるセット(Set)から別のセットへ要素を移動する方法を解説します。これには redis-cli の SMOVE コマンドを使用します。 SMOVEコマンドは、source(送信元)キーに保存されたセットから指定した要素を削除し、その同じ要素をdestination(送信先)キーに保存されたセットへ挿入します。この操作はアトミック(不可分)であるため、どの時点においても、指定された要素は送信元または送信先のいずれか一方のセットにのみ存在することになります。 sourceキーのセットが存在しない場合や、指定した要素がそのセットに含まれてい
-
Blitz.jsとUpstash Redisで作るToDoリストアプリ完全ガイド
Blitz.jsは、Next.jsからフォークされたReactフレームワークです。この記事では、タスクをUpstashに保存するToDoアプリケーションをBlitz.jsで構築する方法を、環境構築からフロントエンドの実装まで順を追って解説します。それでは早速始めましょう! セットアップ まずは、お使いのコンピュータにBlitz.jsをインストールする必要があります。 NPMの場合: npm install -g blitz --legacy-peer-deps Yarnの場合: yarn global add blitz 新しいBlitz.jsアプリを作成するには、blitz newコマン