1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

新卒3ヶ月、AIと作った「リアルタイム対戦タイピングゲーム」── 自作ローマ字判定エンジンとサーバー権威設計

1
Posted at

新卒3ヶ月、AIと作った「リアルタイム対戦タイピングゲーム」── 自作ローマ字判定エンジンとサーバー権威設計

はじめに

IT企業に新卒入社して3ヶ月目のエンジニアです。研修期間中に、リアルタイムでオンライン対戦できる日本語タイピングゲームを一人でフルスタック開発し、本番公開して運営しています。

「タイピングゲームを作った」記事は世にたくさんありますが、この記事で書きたいのはそこではありません。①対戦相手とリアルタイムに同期する(WebSocket)、②チートを防ぐためにサーバー側でも勝敗を判定する、③日本語特有のローマ字表記揺れをライブラリに頼らず自前で判定する ── この3つを、新卒3ヶ月の自分がAIをペアプロ相手にどう設計・実装したか、という話です。

実装の加速にはAI(コーディングアシスタント)を全面的に使いました。ただ、設計判断は自分でやっています。その判断の中身を、コードと「なぜそうしたか」まで含めて書きます。AI開発に挑戦したい人にも、リアルタイム通信や日本語入力判定でハマっている人にも、何か持ち帰ってもらえれば嬉しいです。

公開中のサイト → https://typinggaming.com/


作ったもの「TypingGaming」

PCのキーボード入力と、スマホのフリック入力の両方に対応した、リアルタイム日本語タイピングゲームです。

モード 内容
🏆 レート戦 実力が近い相手と自動マッチング。勝敗で Elo レーティングが変動(全10問)
⚔️ カジュアル対戦 ランダムマッチ、または「ルームコード」共有で友達と対戦
⚡ スコアアタック 60秒で正確さとスピードを競う。ノーミス継続でボーナス
🔥 サバイバル 3ミスで終了のライフ制。何問連続クリアできるか
🎯 練習プレイ 1〜100問までお題数を自由設定できる個人練習

技術スタック

レイヤー 技術
フロントエンド React, TypeScript, Vite, Vanilla CSS
バックエンド Java 21, Spring Boot 4.1.0
データベース MySQL, Spring Data JPA
リアルタイム通信 Spring WebSocket (STOMP), SockJS
その他 Lucide React, Lombok

構成で一番悩んだこと:判定ロジックを「二重に」持つ理由

このアプリで一番効いている設計判断は、ローマ字判定エンジンを TypeScript(フロント)と Java(バックエンド)の両方に実装したことです。一見すると DRY 原則に反する二重実装で、面倒です。それでもこうした理由は明確で ──

対戦ゲームでは、クライアントが申告するスコアは信用できないからです。JavaScript はブラウザ上で書き換えられるので、「全問正解した」と嘘の申告を送ることが原理的に可能です。だから**勝敗の最終判定はサーバーが持つ(サーバー権威)**必要があり、結果としてフロントと同じ判定ロジックを Java 側にもう一度実装することになりました。

  • フロント側のエンジン … 1打鍵ごとの即時フィードバック(UX のため)
  • Java 側のエンジン … 勝敗・レーティングの確定(公平性のため)

WebSocket / STOMP を選んだのも同じ文脈です。相手の進捗を1文字単位で可視化するには双方向通信が要り、Java/Spring との親和性から STOMP を採用しました。


技術的こだわり①:自作ローマ字判定エンジン

タイピングゲームの心臓部であるローマ字入力判定を、ライブラリを使わず自作しました。ここがこの記事の本体です。

なぜ自作したか(=なぜ難しいか)

日本語のローマ字入力は「かな1文字 ↔ ローマ字1パターン」の単純な対応ではありません。1つのかなに複数の正しい打ち方が存在し、しかも次の文字によって正解が変わるからです。例えば:

  • 「し」→ shi でも si でも ci でも正解
  • 「ち」→ chi でも ti でも正解
  • 「っか」→ kka(子音重ね)でも ltuka / xtuka(促音単独)でも正解
  • 「おう」→ ou、「おー」→ o- でも oo でも正解
  • 「ん」→ 次の文字次第で n 1回で確定したり、nn を要求したり

当初は、1問のお題全体の許容ローマ字スペルを再帰的に組み合わせ、事前に全パターン列挙しようと考えました。しかし、「ん」「っ」「ー」や表記揺れの多い文字が連続するお題(例:「かんしんしゃー」など)では、たった数文字のお題でも許容パターンが数万通りに膨れ上がる「組み合わせ爆発」を起こしてしまいました。
これでは事前列挙時のメモリ消費も多く、動的に前方一致をチェックするパフォーマンスが破綻します。

そこで事前列挙をあきらめ、**「かなの最長一致で局所的なユニットに分割し、1打鍵ごとにバッファを状態遷移させていく」**方式へと設計を切り替えました。

設計:かな単位への分割と状態遷移

最終的に採った方針は、お題テキストを TypingUnit という最小のかな単位に分割し、各ユニットが「許容スペル一覧」を持ち、1打鍵ごとにバッファを状態遷移させる方式です。

TypeScript側でのデータ構造定義は以下の通りです。

export type TypingUnit = {
  kana: string;        // 表示用("し" "っか" "ん" など)
  spellings: string[]; // 許容ローマ字(前処理で確定済み)
};

export type EngineState = {
  units: TypingUnit[];
  unitIndex: number;   // 現在判定しているユニットのインデックス
  buffer: string;      // 現ユニットに入力済みのローマ字
  keystrokes: number;
  mistakes: number;
  startedAt: number;   // 開始ミリ秒
  committed: string[]; // 確定済みユニットで実際に打たれた綴り
};

具体例:「しっか」の入力遷移

お題「しっか」を例に、1打鍵ごとの遷移を追ってみます。
まず、前処理関数 build("しっか") によって、以下のように2つの TypingUnit が生成されます。

  • Unit 0: kana: "し", spellings: ["shi", "si", "ci"]
  • Unit 1: kana: "っか", spellings: ["kka", "cca", "ltuka", "xtuka", "ltsuka", "xtsuka", ...]
    • ※「っ」の直後は母音やな行等を除き、後ろの「か」とマージして子音重ね等のスペルを事前生成します。

プレイヤーが shikka と打鍵したときのトレース:

  1. 初期状態: unitIndex = 0, buffer = ""
  2. 1打鍵目 s:
    • buffer + "s" = "s" となります。
    • Unit 0 の spellings"s" で始まる要素があるか検証します。"shi""si" が該当するため、**入力は受理(accept)**され、buffer = "s" に遷移します。(この時点では si / shi どちらのルートも活きています)
  3. 2打鍵目 h:
    • buffer + "h" = "sh" となります。
    • Unit 0 の中で "sh" で始まるのは "shi" のみです。前方一致するため受理され、buffer = "sh" となります。この時点で "si" ルートは脱落します。
  4. 3打鍵目 i:
    • buffer + "i" = "shi" となります。
    • Unit 0 の spellings"shi" が完全一致します。かつ "shi" より長いスペル候補は存在しないため、ここで**ユニット確定(commit)**が発生します。
    • committed[0] = "shi" が記録され、unitIndex = 1, buffer = "" に遷移します。
  5. 4打鍵目 k:
    • Unit 1 に対し、buffer + "k" = "k""kka" などの前方一致なので受理され、buffer = "k" になります。
  6. 5打鍵目 k:
    • buffer + "k" = "kk" となり、同様に前方一致で受理されます。
  7. 6打鍵目 a:
    • buffer + "a" = "kka" となり、Unit 1 に完全一致します。
    • これ以上長い候補はないため確定し、最後のユニットが完了したためゲームがクリア(finish)となります。

もし、1打鍵目に s、2打鍵目に i と入力された場合は、2打鍵目の時点で si が完全一致し、余計な打鍵を待たずに即時確定して Unit 1 に進みます。この状態遷移方式により、表記揺れを完全にカバーできています。

特にハマった3つの仕様

エンジンの中でも、日本語ならではで特に苦労したのが次の3つです。

「ん」の確定遅延
後ろに続く文字が母音(あ行)・や行・な行以外なら、n 1回で「ん」を確定とみなします。そうでなければ nnn' を要求します(例:「かんい」は kani だと「かに」になってしまうので nn が要る)。「次の文字を見るまで現在の文字を確定できない」という先読みが必要で、1打鍵ごとの単純な状態機械から一歩踏込む必要がありました。

促音「っ」
次の文字の子音を重ねる表記(「っか」→ kka)と、単独表記(ltu / xtu)の両方を許容します。前者は「次のユニットの先頭子音」に依存するため、ユニットをまたいだ判定が必要になります。

長音「ー」
ハイフン - だけでなく、直前の母音の繰り返し(「おー」→ oo)も正解として受け入れます。

🛠️ 開発中に遭遇した「ん」の先読みバグ

「ん」の先読み処理を実装した際、お題が「ん」で終わる(例:「しん」)ケースで、後続のユニット rawUnits[i + 1] が存在しないため、undefined のプロパティ(next.kana)を読み込もうとして、フロント・バックエンド双方で例外エラーが発生するバグを踏みました。
文末の「ん」は後ろに文字がないため、無条件に nnn' のみを要求するように if (!next) の例外ガードを入れることで解決しました。


技術的こだわり②:サーバー権威による同期とチート防止

フロントと同じ判定エンジンを Java 側に持たせたうえで、対戦の進行と確定をすべてサーバーが握っています。

問題の同期と同着問題のクリア
対戦中に2人のプレイヤーがほぼ同時に問題を解き終える「同着問題」が発生します。
サーバー側の GameController.java は、以下のように排他制御を行い、最初に届いたクリアシグナルのみを同期トリガーにします。

@MessageMapping("/room/{roomId}/questionDone")
public void handleQuestionDone(@DestinationVariable String roomId, @Payload QuestionDoneMsg msg) {
    GameRoom room = rooms.get(roomId);
    if (room == null) return;
    synchronized (room) {
        if (room.isFinished()) return;
        // 古い問題からの通知や、既に予約済みなら無視(最初の1人だけがトリガー)
        if (msg.questionIndex() == null || msg.questionIndex() != room.getCurrentQuestionIndex()) return;
        if (room.isAdvanceScheduled()) return;
        room.setAdvanceScheduled(true);
        scheduleAdvance(room, ADVANCE_DELAY_MS); // 1秒後に次の問題に進める
    }
}

最初の1人目のクリアを契機に room.isAdvanceScheduled()true にし、1秒間のバッファを挟んで全員を一斉に次の問題へ遷移させます。2人目のクリア信号が遅れて到着しても、すでに予約フラグが立っているため無視されます。進行の権威が完全にサーバーにあるため、ネットワークの揺らぎに対しても強固に同期できます。

切断時の救済と二重レーティング適用の防止
対戦中にプレイヤーが切断した場合、WebSocket の SessionDisconnectEvent を検知し、残ったプレイヤーの不戦勝としてゲームを終了します。この際、ほぼ同時に「全員完走による正常終了」と「切断処理」の並行スレッドが走り、レーティング計算が二重で適用されるリスクがありました。

これに対し、部屋のステータスに ratingApplied フラグを持たせ、計算処理の開始直前にフラグを検査・適用することで、二重更新を完璧に防ぎました。

private List<Map<String, Object>> applyRankedRating(GameRoom room, String winnerId, boolean draw) {
    if (!room.isRanked() || room.isRatingApplied()) return List.of();
    room.setRatingApplied(true); // 最初に適用済みフラグを立てて、後続の並行スレッドを弾く
    // ...Eloレーティングの更新ロジック...
}

AIとのペアプロで効いたこと(と、効かせ方)

実装の速度はAIで劇的に上がりました。ただ、丸投げでは上のエンジンは絶対に完成しなかったので、どう使ったかを具体的に書きます。

仕様をコードより先に、テキストで固めた
いきなり実装させるのではなく、まず「『ん』の入力仕様を、後続文字のパターン別に全網羅した表を作って」と仕様の言語化から始めました。仕様が文章で確定してからコードに落とすことで、手戻りが激減します。これは人間同士の設計レビューと同じで、AI相手でも「曖昧なまま書かせない」が効きました。

同一ロジックの言語間移植に強い
ローマ字判定を TS から Java に移すとき、「この TypeScript のクラスと完全に同じアルゴリズムで Java 版を書いて」と指示すると、かなり正確に移植してくれました。サーバー権威のための二重実装と相性が良かった部分です。

エッジケースの責任は人間が持つ
AIは動くコードを速く出しますが、WebSocket のラグや同着のような並行性のエッジケースは考慮が漏れます。ローカルで動かしてログを見て、「この入力でこう壊れたから直して」とフィードバックを回す ── この検証ループは人間の仕事でした。


さいごに

新卒3ヶ月で、リアルタイム対戦・サーバー権威・自作判定エンジンまで載せたフルスタックアプリを本番運営できているのは、AIで実装を加速しつつ、設計の手綱は自分で握ったからだと思っています。

このエンジンや同期まわりは、まだ詰めきれていない論点(同着の厳密化、判定エンジンのテスト網羅、CSR のSEO対策など)が残っているので、続編で書く予定です。

設計や実装で「ここはこうした方がいい」「うちはこう解いた」みたいなツッコミ、大歓迎です。コメントでフィードバックをもらえると嬉しいです。

最後まで読んでいただき、ありがとうございました。

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?