プレイする
Supabase & SQL 投稿日: 2026年6月27日

サーバーレス時代の個人開発:Supabaseを活用した『戦闘力が近い相手』を探す非同期マッチングSQL設計

1. はじめに:なぜ個人開発でSupabaseを選んだのか?

サーバーレスアーキテクチャ(AWS LambdaやCloudflare Workersなど)を採用した個人開発プロジェクトにおいて、データベース(RDB)の選定は極めて重要です。従来型のリレーショナルDB(AWS RDSなど)をサーバーレスから直接利用しようとすると、以下の2つの大きな壁にぶつかります。

  1. コネクション制限のバースト: サーバーレス関数はリクエストに応じて瞬時にスケール(並列起動)するため、データベースへの同時接続数(コネクション数)が急増し、あっという間に上限に達してエラー(Too many connections)を引き起こします。
  2. 月額コスト: RDSなどのマネージドデータベースは、インスタンスが起動している限り固定費(数千円〜/月)が発生し、アクセスがない時期でもコストがかかり続けます。

これらの課題を解決するために「Doodle Fighter」で採用したのが「Supabase」です。SupabaseはPostgreSQLをベースにしたオープンソースのBaaSであり、以下のメリットがあります。

  • コネクションプーラ(Supavisor / PgBouncer)の標準搭載: サーバーレス環境からトランザクションモードの接続ポート(6543)経由でアクセスすることで、Lambdaインスタンスが大量に増えても、Supabase側でインテリジェントにコネクションを束ねて再利用してくれます。
  • 寛大な無料枠と従量課金: 個人開発フェーズではほぼ0円で運用可能であり、低コストで本番級のPostgreSQLデータベースを持てるメリットがあります。

2. ランダムかつ「戦闘力が近い」相手を高速に抽出するSQL設計

非同期お絵描きバトルゲームである本作では、他のユーザーが作成したキャラクターと対戦を行います。ここで「ランダムマッチング」を実装する際、単純な ORDER BY RANDOM() LIMIT 1 では大きな問題が生じます。

  • 問題点: 自分が生成したばかりの「ゴミ級(戦闘力5,000)」のキャラクターが、サーバー上のトッププレイヤーである「超越級(戦闘力800,000)」とマッチングされてしまい、一瞬でワンパン(瞬殺)されてしまうという、不条理で理不尽なゲームバランスになります。

対戦ゲームとして機能させるためには、「自分と実力(戦闘力)が近く、かつ毎回同じ相手にならないようにランダム性を持たせた抽出」が必要です。

解決策:戦闘力近似値マッチングのSQLクエリ設計

この課題をPostgreSQLのSQLクエリで解決した手法が以下です。

-- プレイヤー自身のキャラクターIDを除外し、戦闘力の絶対値差が最も小さい上位5件を抽出する
SELECT id, name, combat_power, player_id
FROM characters
WHERE player_id != :exclude_player_id
ORDER BY ABS(combat_power - :player_combat_power) ASC
LIMIT 5;

クエリの工夫点と解説:

  1. ABS(combat_power - :player_combat_power) ASC: プレイヤーキャラクターの戦闘力と、データベース内の他キャラクターの戦闘力との「差の絶対値」を算出し、差が小さい順(=実力が近い順)に並べ替えます。
  2. LIMIT 5 とアプリケーション側でのランダム抽出: データベース側で最も実力が近い上位5件に絞り込み、受け取ったバックエンド(FastAPI / Python)側で random.choice() を用いて1件を選択します。これにより、「実力が最も近いライバル」を抽出しつつ、対戦するたびに相手が変わるという動的なランダムマッチングを最小のクエリ負荷で両立しています。

3. 勝敗確定時における「戦闘力奪取」トランザクションの安全性

Doodle Fighterのゲーム性の特徴は「弱肉強食」です。バトルに勝利すると、「相手の現在の総戦闘力(total_power)の1%」を勝者が恒久的に吸収(強奪)して成長します。

この「勝敗処理と戦闘力移動」は、データの整合性を担保するために同一トランザクション内で確実にアトミックに処理される必要があります。

SQLAlchemyによるアトミックトランザクション実装

バックエンド(FastAPI)では、以下のようにSQLAlchemyのORM機能を活用し、データベースのセッションをコミットすることでアトミック性を保証しています。

async def process_battle_result(db: AsyncSession, challenger: Character, opponent: Character, winner_id: str):
    # 1. 勝敗フラグのインクリメント
    if winner_id == challenger.id:
        challenger.wins += 1
        opponent.losses += 1
        
        # 2. 相手のトータル戦闘力の1%を算出し、自身のボーナスパワーへ移行
        stolen_power = int(opponent.total_power * 0.01)
        challenger.bonus_power += stolen_power
        challenger.total_power += stolen_power
        
    elif winner_id == opponent.id:
        opponent.wins += 1
        challenger.losses += 1
        
        # 相手側が勝った場合の処理
        stolen_power = int(challenger.total_power * 0.01)
        opponent.bonus_power += stolen_power
        opponent.total_power += stolen_power

    # 3. バトル履歴レコードの作成
    new_record = BattleRecord(
        player_id=challenger.player_id,
        challenger_id=challenger.id,
        opponent_id=opponent.id,
        winner_id=winner_id
    )
    db.add(new_record)
    
    # 4. 同一トランザクション内で一括コミット(アトミックな更新)
    await db.commit()

なぜ安全なのか?

  • ロールバック保障: 万が一、AIのバトル判定エラーや履歴書き込み処理中に例外が発生した場合、await db.commit() が実行されずにトランザクション全体がロールバックされます。そのため、「勝敗数だけ増えて、戦闘力が移行しない」といったデータの不整合(バグ)が原理的に発生しません。
  • トランザクションモードでのプール利用: Supabaseのプールポート(6543)を経由することで、データベース側のリソースを最小限に抑えつつ、このようなアトミックな更新クエリを数ミリ秒で完了させることができます。

4. まとめ

Supabase(PostgreSQL)を採用することで、コネクションプーラの設定に悩むことなく、サーバーレス環境から高速にデータベースを操作可能になりました。

また、ABS 関数を使った戦闘力近似値マッチングと、ORMによるアトミックトランザクション設計を組み合わせることで、「公平かつスリリングで、バグのない堅牢な非同期対戦システム」が個人開発の規模でも簡単に実現できます。

リレーショナルデータベースならではのトランザクションの強みとサーバーレスの俊敏性を両立させたい開発者にとって、Supabaseは最高の選択肢と言えます。