個人開発で、複数人が同時に同じ文章を打って速さを競える日本語タイピング練習サイト カケルタイピング(https://kakeru-typing.com)を作っています。
この記事では、その目玉機能である「みんなで対戦(VS)モード」を支えるリアルタイム通信を、Socket.IO と無料の Render だけでどう実現したかを解説します。WebSocket をゼロから書くのではなく、ルーム管理・進捗同期・ボット対戦・無料運用のコツまで、実装の勘どころをまとめます。
全体構成
カケルタイピングは「画面(Next.js)」と「対戦サーバー(Socket.IO)」を別プロセスに分けています。
[ブラウザ] ──HTTP──> [Next.js / Vercel] … 画面・認証・DB
│
└──WebSocket──> [Socket.IO / Render] … 対戦のリアルタイム同期
Vercel は基本的にサーバーレスで、常時接続の WebSocket を張りっぱなしにする用途には向きません。そこで対戦サーバーだけを Render の常駐プロセスとして切り出しました。フロントからは環境変数で接続先を渡します。
// 接続先はビルド時に環境変数で差し替え(ローカルは localhost)
const SERVER_URL =
process.env.NEXT_PUBLIC_BATTLE_SERVER_URL ?? "http://localhost:3002";
サーバー側:ルームと進捗同期
対戦は「ルーム」単位。参加者が入室し、全員そろったらカウントダウン→同じ文章を配信し、各自の打鍵進捗をブロードキャストします。
import { Server } from "socket.io";
const io = new Server(PORT, {
cors: { origin: process.env.CORS_ORIGIN?.split(",") ?? [] },
});
type Player = { id: string; name: string; progress: number };
const rooms = new Map<string, { players: Map<string, Player>; textKana: string }>();
io.on("connection", (socket) => {
socket.on("join", ({ roomId, name }) => {
socket.join(roomId);
const room = rooms.get(roomId) ?? createRoom(roomId);
room.players.set(socket.id, { id: socket.id, name, progress: 0 });
// 入室を全員に通知
io.to(roomId).emit("players", [...room.players.values()]);
});
// 各自の進捗(0..1)を受け取り、同じルームの全員へ転送
socket.on("progress", ({ roomId, value }) => {
const p = rooms.get(roomId)?.players.get(socket.id);
if (!p) return;
p.progress = value;
socket.to(roomId).emit("progress", { id: socket.id, value });
});
socket.on("disconnect", () => {
for (const [roomId, room] of rooms) {
if (room.players.delete(socket.id)) {
io.to(roomId).emit("players", [...room.players.values()]);
}
}
});
});
ポイントは 進捗を 0..1 の正規化値で送ること。文字数や難易度に依存せず、相手の「車」をバーやトラック上で動かす描画にそのまま使えます。カケルタイピングでは進捗をレースの距離に変換して、相手との差をリアルタイムに見せています。
一人でも対戦が成立する「ボット」
マッチング相手がいないと過疎って見えるので、人数が足りないときはボットを入れるようにしました。ボットの肝は「自然な速さ」。実在プレイヤーの体感に合わせ、WPM 150〜300 のあいだでランダムに走らせています。
// WPM(=1分あたりの打鍵数) を「1秒あたりの打鍵数(cps)」に変換してボットを走らせる
const KEYSTROKES_PER_KANA = 1.9; // かな1文字あたり平均ローマ字キー数(実測)
const targetWpm = 150 + Math.random() * 150; // 150〜300 WPM
const cps = targetWpm / (60 * KEYSTROKES_PER_KANA);
// 文章(かな長 textLength)を打ち切るのにかかる総時間
const totalMs = (textLength / cps) * 1000;
KEYSTROKES_PER_KANA(かな1文字あたりのローマ字打鍵数)を挟むことで、「WPM」という人間に分かりやすい単位のままボット強度を調整できます。この値の出し方は別記事の日本語タイピングエンジン編で詳しく書きます。
無料運用の落とし穴:Render はスリープする
Render の無料プランは15分アクセスがないとスリープし、次のアクセスで起動に十数秒かかります。対戦に入ろうとした瞬間に固まると致命的なので、UptimeRobot で /health を5分おきに叩いて起こし続けています。
// 対戦サーバー側にヘルスチェック用エンドポイントを用意
httpServer.on("request", (req, res) => {
if (req.url === "/health") {
res.writeHead(200);
res.end("ok");
}
});
CORS も忘れがちなポイント。フロント(Vercel)と対戦サーバー(Render)はオリジンが違うので、CORS_ORIGIN に本番ドメインと localhost の両方を入れておきます。
まとめ
- WebSocket 常駐が必要な部分だけ Render に切り出すと、Vercel と無料で共存できる
- 進捗は
0..1の正規化値で送ると描画が楽 - ボットは「WPM」基準にすると強さ調整が直感的
- 無料プランのスリープは UptimeRobot で回避
実際の挙動は カケルタイピング(https://kakeru-typing.com)の「みんなで対戦」で試せます。一人でアクセスしてもボットが入るので、リアルタイム同期の手触りをすぐ確認できます。
次の記事では、この上で動く**日本語タイピングエンジン(ローマ字入力・ふりがな・WPM算出)**の中身を解説します。
完成品で遊べます → カケルタイピング https://kakeru-typing.com
↑ 公開後、
URL0〜URL4を Qiita の記事URLに置き換えてください。