Frontend & Backend

【QTEバトル&マスタ管理】requestAnimationFrameとAlembicで作るリアルタイムWebゲームとDBマイグレーション自動化

DF
Doodle Fighter 開発チーム
Engineering & Architecture

1. 背景:静的コマンドバトルからの脱却とハードコードの負債

「Doodle Fighter」のリリース初期段階では、バトルシステムは非常にシンプルでした。攻撃ボタンを押してAPIを叩き、乱数で勝敗が決まるだけの、いわゆる静的な「コマンドポチポチゲーム」です。これはプロトタイプとしては機能したものの、アクション性に乏しく、プレイヤーに長期的なモチベーションを提供するには力不足でした。

また、バックエンドには別の大きな課題が潜んでいました。AIが生成したキャラクターが使用する「必殺技(スキル)」の情報(威力、属性、特殊効果など)が、Pythonコードやプロンプト定義の中に直接 ハードコード されていたのです。これにより、ゲームバランスを調整するために毎回アプリケーションコードを修正し、GitHub経由でデプロイするという、運用保守において非常にコストの高いフローが発生していました。

そこで今回、プレイヤーの反射神経が試される動的な QTE(Quick Time Event)バトルシステム を導入すると同時に、裏側のアーキテクチャを劇的にクリーンにする スキルマスタデータのDB移行(マスタ化)およびCI自動マイグレーション を実施しました。

2. 業界標準のベストプラクティス

① Webブラウザにおけるリアルタイムアクション実装

Webブラウザ上で滑らかなアニメーションやQTEのようなタイミングゲームを実装する際、setIntervalsetTimeout を使用するのは悪手です。ブラウザのタブが非アクティブになった際にタイマーが間引かれ、著しいラグや時間のズレを引き起こすからです。 業界標準のアプローチは、ディスプレイのリフレッシュレートに正確に同期する window.requestAnimationFrame を利用し、フレーム間の経過時間(Delta time)を自前で計算して描画状態を更新することです。

② ゲームデータのマスタ化とDBマイグレーション

RPGやソーシャルゲームの運用において、キャラクターやスキルのパラメータは「マスタデータ」としてデータベースで一元管理されるのが常識です。ロジック(コード)とデータ(パラメータ)を分離することで、エンジニア以外のプランナーでも調整が可能になります。 さらに、そのDBスキーマの変更を安全に行うため、Alembic のようなマイグレーションツールを導入し、CI/CDパイプライン上で無人かつ自動的にスキーマと初期データが同期される仕組み(Infrastructure as Codeのデータ版)を作るのがモダンな開発手法です。

3. 個人開発における制約と技術的壁

しかし、個人開発のWebアプリケーション特有の壁が立ちはだかりました。

レイテンシと不正対策のジレンマ: 完全にフロントエンド(JavaScript)だけでQTEの合否を判定すると、チート(クライアントコードの改ざん)が容易になってしまいます。一方で、リアルタイムな入力を毎回バックエンドに送ると、HTTP通信のレイテンシ(数十〜数百ミリ秒)によって「プレイヤーは成功タイミングで押したのに、サーバー側では遅延して失敗扱いになる」という理不尽な体験を生んでしまいます。

小規模サーバーレス環境でのDBマイグレーション: Doodle FighterはGitHub Actionsを用いてFastAPIをデプロイしていますが、マスタデータを定義するJSONファイルからRDB(SQL)への同期をどのようにデプロイフローに組み込むか。安全かつダウンタイムを最小限にするパイプラインの再設計が必要でした。

4. 実践:独自のハイブリッドQTE判定と完全自動CIの実装

これらの課題を解決するため、私たちは以下のようなアーキテクチャとコードを実装しました。

QTE実装:フロントエンドでの滑らかな描画とバックエンド検証

QTEのアニメーションは requestAnimationFrame で管理し、画面上を収縮するサークルを描画します。プレイヤーがボタンをタップした瞬間の「サークルの半径(タイミングの正確さ)」をクライアント側でミリ秒単位のタイムスタンプと共に記録し、それをAPIリクエストのペイロードとしてバックエンドに送信します。

// frontend/static/js/app.js (QTE描画ループの抜粋)
let qteStartTime = null;
let qteDuration = 2000; // 収縮にかかる時間(ミリ秒)

function renderQTELoop(timestamp) {
    if (!qteStartTime) qteStartTime = timestamp;
    const elapsed = timestamp - qteStartTime;
    const progress = Math.min(elapsed / qteDuration, 1.0);
    
    // サークルの半径を1.0から0.0に向けて収縮させる
    const currentRadius = 100 * (1 - progress);
    drawCircle(currentRadius);
    
    if (progress < 1.0) {
        requestAnimationFrame(renderQTELoop);
    } else {
        handleQTEFail(); // タイムアウト処理
    }
}
requestAnimationFrame(renderQTELoop);

バックエンド側(FastAPI)では、送信されたタイムスタンプとクライアントの申告したタイミングスコアを受け取ります。ここで単にクライアントを信じるのではなく、API発行時刻との大幅な乖離がないか(チート検証)をサーバー時間でチェックするハイブリッド方式を採用しました。これにより、レイテンシの影響を排除しつつ、極端なスコア改ざんを防いでいます。

スキルのマスタ化とAlembicによる自動移行スクリプト

ハードコードされていた必殺技情報を backend/data/skill_master.json に外出しし、起動時・DB更新時にこれを読み込む仕組みを作りました。Alembicのマイグレーションスクリプトでは、テーブル構造の変更だけでなく、このJSONファイルからデータを読み取ってDBの skills テーブルに INSERT / UPDATE を行うロジックを注入しました。

# backend/alembic/versions/xxx_add_skillmaster.py 抜粋
def upgrade() -> None:
    # 1. スキルマスタテーブルの作成
    op.create_table(
        'skill_master',
        sa.Column('id', sa.String(), nullable=False),
        sa.Column('name', sa.String(), nullable=False),
        sa.Column('power_multiplier', sa.Float(), nullable=False),
        sa.PrimaryKeyConstraint('id')
    )
    
    # 2. JSONファイルからデータをロードして初期投入
    with open('backend/data/skill_master.json', 'r', encoding='utf-8') as f:
        skills = json.load(f)
        
    bind = op.get_bind()
    for skill in skills:
        bind.execute(
            sa.text("INSERT INTO skill_master (id, name, power_multiplier) VALUES (:id, :name, :power)"),
            {"id": skill["id"], "name": skill["name"], "power": skill["power_multiplier"]}
        )

GitHub Actionsへの組み込み (CI自動化)

最後に、このマイグレーションをGitHub Actionsのデプロイワークフローに組み込みました。

# .github/workflows/deploy-backend.yml 抜粋
jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - name: Run DB Migrations
        run: |
          cd backend
          alembic upgrade head
        env:
          DATABASE_URL: ${{ secrets.DATABASE_URL }}

これにより、開発者が skill_master.json を編集して main ブランチにマージするだけで、自動的に本番DBのパラメータが更新される「完全なCI/CDデータパイプライン」が完成しました。

5. 導入結果:UXとDXの飛躍的な向上

QTEバトルの導入により、プレイヤーは単に絵を描いて眺めるだけでなく、戦闘中も自らのタイミングスキルで勝敗を覆せるようになり、ゲームとしてのエンゲージメント(滞在時間)が大幅に向上しました。

また、開発者体験(DX: Developer Experience)の観点では、必殺技の効果や新しいスキルを追加する際、Pythonの複雑なビジネスロジックに一切触れることなく、JSONマスタデータを1行追記するだけで済むようになりました。バグの混入率が下がり、バランス調整のイテレーション速度が数倍に跳ね上がっています。

6. まとめ:データ駆動とリッチUIの両立

今回の大規模なリファクタリングを通じて、「フロントエンドの滑らかなリッチUI(QTE)」「バックエンドの堅牢なデータ駆動設計(マスタ化・DBマイグレーション自動化)」を同時に達成することができました。

個人開発のWebアプリであっても、こうした「マスタデータの分離とCIでの自動同期」を早い段階で構築しておくことで、後々の機能拡張(新しいキャラクタークラスの実装、イベント限定武器の追加など)が驚くほど容易になります。QTEのようなブラウザ上でのリッチアクションを実装する際や、ハードコードの負債に悩んでいる方の参考になれば幸いです!