はじめに
たびたびJISOUさんの記事で学ばせていただいていますが、今回はこちらのタイピングゲームに挑戦しました。
タイピングゲームそのものは、当該記事をご参照いただくとして、この記事の最後にあります「課題」の部分、これについて取り組んだことを備忘録を兼ねて記事にしてみました。
課題
- APIをCloudflare Workersにデプロイする
- リザルト画面でタイプミス回数を表示する
- ランキング機能で最下位5名も表示
ここまでの技術スタック
Frontend
- Framework: Next.js(App Router)
- Language: TypeScript
- Styling: Tailwind CSS
Backend
- Framework: Hono
- Database: Upstash Redis
1. APIをCloudflare Workersにデプロイする
Cloudflare Workersとは?
Cloudflareのエッジネットワーク上でJavaScript/TypeScriptコードを実行できるサーバーレス環境で、ユーザーに近い場所で処理できるため低レイテンシを狙いやすいのが特徴
ということです。こちらの記事がわかりやすかったです。
あらかじめCloudflareでアカウントを作っておく必要があります。「Free」プランでOK。
wranglerのインストール
wranglerとは、Cloudflare Workersの開発、ビルド、デプロイを支援するためのコマンドラインツールです。
typing-game % bun add -d wrangler
パッケージマネージャーはbunを使用しています。
wrangler init の実行。基本的なフォルダ構成が作られます。
typing-game % bunx wrangler init
このあと、設定のためにいろいろと質問されます。
In which directory do you want to create your application? also used as application name
# どのディレクトリに作成しますか? → workersと入力
What would you like to start with?
# どれから始めますか?
● Hello World example
○ Framework Starter
○ Application Starter
○ Template from a GitHub repo
◁ Go back
→ Hello Worldを選択
Which template would you like to use?
# どのテンプレートを使いますか?
● Worker only
○ Static site
○ SSR / full-stack app
○ Worker + Durable Objects
○ Worker + Durable Objects + Assets
○ Workflow
○ Scheduled Worker (Cron Trigger)
○ Queue consumer & producer Worker
○ API starter (OpenAPI compliant)
◁ Go back
→ Workers onlyを選択
Which language do you want to use?
# どの言語を使いますか?
● TypeScript
○ JavaScript
○ Python (beta)
→ TypeScriptを選択
Do you want to add an AGENTS.md file to help AI coding tools understand Cloudflare APIs?Yes / No
# AGENTS.md(AIコーディングツール向けの補助ファイル)が必要か?
→ Noを選択
You're in an existing git repository. Do you want to use git for version control?
# gitバージョン管理をしますか?
→ Yesを選択
Do you want to deploy your application? Yes / No
# デプロイしますか?
→ Noを選択。あとで実行するため。
🎉 SUCCESS Application created successfully!
今回は、上記の順で進めましたが、順番を逆にもできるようで、
bunx wrangler init、cd workers、bun add -d wrangler
の順で進めると、workersフォルダの中にwrangler依存を入れられるので
キレイになると思われます。
Cloudflareアカウントとの連携
# wrangler loginの実行
typing-game % bunx wrangler login
ブラウザに認証画面が現れるので、認証を許可して成功すると以下のような画面になります。

デプロイの実行
bunx wrangler deployを実行すると、いくつか質問を聞かれます。
# workersディレクトリに移動します
typing-game % cd workers
# wrangler deploy実行
workers % bunx wrangler deploy
# 実行結果
⛅️ wrangler 4.65.0
───────────────────
Total Upload: 18.23 KiB / gzip: 4.58 KiB
Worker Startup Time: 22 ms
Uploaded workers (4.14 sec)
▲ [WARNING] You need to register a workers.dev subdomain before publishing to workers.dev
? Would you like to register a workers.dev subdomain now? › (Y/n)
→ Yesを選択:workersを公開するのにxxxx.workers.devというURLのサブドメインの登録が必要
What would you like your workers.dev subdomain to be? It will be accessible at https://<subdomain>.workers.dev … {サブドメイン名}
→ サブドメイン名を入力
? Creating a workers.dev subdomain for your account at https://{サブドメイン名}.workers.dev. Ok to proceed? › (Y/n)
→ Yesを選択
Success! It may take a few minutes for DNS records to update.
# 管理画面へのリンク
Visit https://dash.cloudflare.com/(*****************) to edit your workers.dev subdomain
Deployed workers triggers (298.14 sec)
# 自分のWorkerの公開URL
https://workers.{サブドメイン名}.workers.dev
# 今回デプロイされたWorkerの「バージョン識別子」。デプロイするたびに変わる
Current Version ID: ***********************
workersにHono、Upstash Redisをインストール
一度、以下の記事に沿ってtyping-gameを作っているので、Next.jsのルートディレクトリにはHono、Upstash Redisとも、インストールしてはいたのですが、バックエンドがworkersの中で動くので、あらためてインストールします。
# workersに移動
typing-game % cd workers
# Hono、 @upstash/redisをインストール
workers % bun add hono @upstash/redis
Cloudflare Workersに環境変数を設定
CloudflareのDashboard画面の以下の場所で登録が可能。
Compute > Workers&Pages > workers(該当のデプロイデータ)を選択 > 設定 > 変数とシークレット
私は管理画面から登録しましたが、ドキュメントによればコマンドでも登録することができます。
npx wrangler secret put <KEY>
参考ドキュメント
proxyの設定
Next.jsのrewritesを使って/api/*へのリクエストを WorkersのURLへ転送します。
import type { NextConfig } from "next";
const workerBase = process.env.WORKER_API_BASE;
const nextConfig: NextConfig = {
/* config options here */
/* 以下を追加 */
async rewrites() {
return [
{
source: "/api/:path*",
destination: `${workerBase}/:path*`,
},
];
}
};
export default nextConfig;
destinationはworkersのURLを直書きでもいいと思いますが、私は変数にしています。
2. リザルト画面でタイプミス回数の表示、ランキング機能で最下位5名も表示
事前にフロントエンド、バックエンド共通で使う型を別ファイルで定義しています。
export type Score = {
userName: string;
score: number;
};
export type ResultResponse = {
bests: Score[];
worsts: Score[];
};
workersのコード実装
元々はapp/api/[[...route]]/route.tsにあったコードをworkers/src/index.tsに移植し、ランキング下位5位までのデータ取得をAPIに追加します。
import { Redis } from '@upstash/redis';
import { Hono } from 'hono';
import type { Score, ResultResponse } from "../../shared/types";
type Bindings = {
UPSTASH_REDIS_REST_URL: string;
UPSTASH_REDIS_REST_TOKEN: string;
};
type CreateScoreBody = {
userName: string,
score: number,
}
// Honoでの書き方
const app = new Hono<{ Bindings: Bindings }>();
// 動作確認用
app.get('/ping', (c) => {
return c.text('pong');
});
// スコアデータの登録
app.post('/result', async (c) => {
try {
// リクエストボディからスコアとユーザー名を取得
const { score, userName } = await c.req.json<CreateScoreBody>();
if (score === undefined || userName === undefined || userName === "") {
return c.json({ error: 'Missing score or userName' }, 400);
}
// Redisクライアントを初期化
const redis = new Redis({
url: c.env.UPSTASH_REDIS_REST_URL,
token: c.env.UPSTASH_REDIS_REST_TOKEN,
});
const result = {
score: score,
member: userName,
};
// 注)zadd:下記参照
await redis.zadd('typing-score-rank', result);
return c.json({
message: 'Score submitted successfully',
});
} catch (e) {
return c.json({ error: `Error: ${String(e)}` }, 500);
}
});
// スコアランキング結果の取得
app.get('/result', async (c) => {
try {
const redis = new Redis({
url: c.env.UPSTASH_REDIS_REST_URL,
token: c.env.UPSTASH_REDIS_REST_TOKEN,
});
// 上位5位までの取得
const bests = await redis.zrange('typing-score-rank', 0, 4, {
rev: true,
withScores: true,
});
const bestScores: Score[] = [];
for (let i = 0; i < bests.length; i += 2) {
bestScores.push({
userName: String(bests[i]),
score: Number(bests[i + 1]),
});
}
// 下位5位までを取得
const worsts = await redis.zrange('typing-score-rank', 0, 4, {
withScores: true,
})
const worstScores: Score[] = [];
for (let i = 0; i < worsts.length; i += 2) {
worstScores.push({
userName: String(worsts[i]),
score: Number(worsts[i + 1]),
});
}
const response: ResultResponse = { bests: bestScores, worsts: worstScores };
return c.json(response);
} catch (e) {
return c.json({ message: `Error: ${String(e)}`, 500 });
}
});
// workersではこれ
export default app;
注)Sorted Setはmember(ここではユーザー名)とscore(点数)のペアを保持します。zaddで追加でき、同じmemberを再度登録するとスコアが更新されます。
workersのデプロイ
# workersディレクトリに移動
typing-game % cd workers
# デプロイ実行
workers % bunx wrangler deploy
app/page.tsxの修正
タイプミス(typo)の回数表示とTop5、Bottom5の表示を実装します。
"use client";
import Image from "next/image";
import { useEffect, useRef, useState } from "react";
import type { Score, ResultResponse } from "../shared/types";
export default function Home() {
const questions = [
{ question: "React", image: "/monster1.jpg" },
{ question: "TypeScript", image: "/monster2.jpg" },
{ question: "JISOU", image: "/monster3.jpg" },
{ question: "GitHub", image: "/monster4.jpg" },
{ question: "Next.js", image: "/monster5.jpg" },
];
const [currentQuestionIndex, setCurrentQuestionIndex] = useState<number>(0);
const [currentPosition, setCurrentPosition] = useState<number>(0);
const [isCompleted, setIsCompleted] = useState<boolean>(false);
const [isStarted, setIsStarted] = useState<boolean>(false);
const [userName, setUserName] = useState<string>("");
const [startTime, setStartTime] = useState<number>(0);
const [totalTime, setTotalTime] = useState<number>(0);
const [score, setScore] = useState<number>(0);
// 以下3行追加
const [bestScores, setBestScores] = useState<Score[]>([]);
const [worstScores, setWorstScores] = useState<Score[]>([]);
const [typoCount, setTypoCount] = useState<number>(0);
const bgmRef = useRef<HTMLAudioElement | null>(null);
const shotSoundRef = useRef<HTMLAudioElement | null>(null);
// 一部省略
// Top5、Bottom5のデータを取得
const fetchScores = async (): Promise<ResultResponse> => {
const res = await fetch("/api/result");
return await res.json();
};
useEffect(() => {
const handleKeyDown = async (e: KeyboardEvent) => {
const currentQuestion = questions[currentQuestionIndex];
const exactChar = currentQuestion.question[currentPosition];
// isStarted: ゲーム開始前の入力欄でのキー入力を無視するため
if (isStarted && exactChar && e.key.toLowerCase() === exactChar.toLowerCase()) {
// 入力した文字が合っている場合に、nextPositionを作る
const nextPosition = currentPosition + 1;
setCurrentPosition(nextPosition);
// 問題の最後の文字まで終わった時
if (nextPosition === currentQuestion.question.length) {
// 最後の問題まで終わった時
if (currentQuestionIndex === questions.length - 1) {
if (shotSoundRef.current) {
shotSoundRef.current.currentTime = 0;
shotSoundRef.current.play();
}
const { totalTime, score } = await addResult(userName, startTime);
setTotalTime(totalTime);
setScore(score);
setIsCompleted(true);
// Top5、Bottom5を取り出し、stateに入れる
const { bests, worsts } = await fetchScores();
setBestScores(bests);
setWorstScores(worsts);
} else {
if (shotSoundRef.current) {
shotSoundRef.current.currentTime = 0;
shotSoundRef.current.play();
}
setCurrentQuestionIndex((prev) => prev + 1);
setCurrentPosition(0);
}
}
} else if (isStarted && exactChar) {
// タイプミスの場合、カウント+1
setTypoCount((prev) => prev + 1);
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
// 依存配列にisStartedを追加
}, [isStarted, currentPosition, currentQuestionIndex]);
const handleStart = () => {
if (!userName) {
alert("名前を入力してください");
return;
}
setIsStarted(true);
// ゲームスタート時に時刻を保存
setStartTime(Date.now());
};
// ゲームスタート前
if (!isStarted) {
return (
<main className="flex min-h-screen flex-col items-center justify-center bg-black">
{/* userName入力画面 省略 */}
</main>
);
}
// ゲーム終了後
if (isCompleted) {
return (
<main className="flex min-h-screen flex-col items-center justify-center bg-black text-white">
<div className="text-center p-8 bg-black/50 rounded-lg border border-red-800 shadow-2xl max-w-2xl w-full">
<h2
className="text-4xl font-bold mb-6 text-red-600"
style={{ textShadow: "0 0 10px rgba(255, 0, 0, 0.7)" }}
>
Result
</h2>
<div className="mb-8 space-y-2">
<p className="text-xl">
Player: <span className="text-red-500">{userName}</span>
</p>
<p className="text-xl">
Time:{" "}
<span className="text-red-500">
{(totalTime / 1000).toFixed(2)}
</span>{" "}
seconds
</p>
<p className="text-xl">
Score: <span className="text-red-500">{score}</span>
</p>
{/* 追加:タイプミス回数の表示 */}
<p className="text-xl">
Typo Count: <span className="text-red-500">{typoCount}</span>
</p>
</div>
{/* Top5の表示 */}
<div className="mb-8">
<h3 className="text-2xl font-bold mb-4 text-red-600">Top 5</h3>
{bestScores.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8">
<div className="w-12 h-12 border-4 border-red-600 border-t-transparent rounded-full animate-spin"></div>
<p className="mt-4 text-red-500 animate-pulse">
Loading scores...
</p>
</div>
) : (
<div className="space-y-4">
{bestScores.map((score, index) => (
<div
key={index}
className="flex justify-between items-center p-3 bg-black/30 border border-red-900/50 rounded"
>
<span
className={`text-lg ${score.userName === userName ? "text-red-500" : ""}`}
>
{index + 1}.{score.userName}
</span>
<span className="text-red-500">{score.score}</span>
</div>
))}
</div>
)}
</div>
{/* 追加:Bottom5の表示 */}
<div className="mb-8">
<h3 className="text-2xl font-bold mb-4 text-red-600">Bottom 5</h3>
{worstScores.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8">
<div className="w-12 h-12 border-4 border-red-600 border-t-transparent rounded-full animate-spin"></div>
<p className="mt-4 text-red-500 animate-pulse">
Loading scores...
</p>
</div>
) : (
<div className="space-y-4">
{worstScores.map((score, index) => (
<div
key={index}
className="flex justify-between items-center p-3 bg-black/30 border border-red-900/50 rounded"
>
<span
className={`text-lg ${score.userName === userName ? "text-red-500" : ""}`}
>
{index + 1}.{score.userName}
</span>
<span className="text-red-500">{score.score}</span>
</div>
))}
</div>
)}
</div>
</div>
</main>
);
}
return (
<main className="flex min-h-screen flex-col items-center justify-between">
{/* ゲーム画面 省略 */}
</main>
);
}
Vercelへのデプロイ
Next.jsをVercelにデプロイしますが、
Nextのビルド時にTypeScriptがworkers/src/index.tsまで見に行って、エラーになることがあるので、その回避策として、Next側tsconfig.jsonの"exclude"に"workers/**"を追加しています。
"exclude": ["node_modules", "workers/**"]
Result画面はこのような形。
おわりに
Cloudflareへのデプロイがかなりややこしくなったので、もともとの課題の意図に合致しているのかわかりませんが、Cloudflare、Hono、Upstash Redisなどこれまで触れる機会がなかったものに触れることができ、また、state管理への理解も深めることができました。
参考までに、ディレクトリ構造とGitHubを貼っておきます。
-
typing-gameディレクトリ構造
.
├── app/ # Next.js App Router source code
│ ├── page.tsx # Main game logic and UI
│ └── layout.tsx # Root layout
├── public/ # Static assets (images, sounds)
├── workers/ # Cloudflare Workers source code
│ ├── src/
│ │ └── index.ts # API endpoints (Hono)
│ └── wrangler.jsonc # Wrangler configuration
└── shared/ # Shared types between frontend and backend
- GitHubリポジトリ
参考
