Unity × AWS AppSync × Lambda でターン制対戦を実現する通信設計

Epic Quest CTOの森本です。

前回の記事では、サーバーレス構成を選んだ理由を書いた。今回は、Unity クライアントと Lambda バックエンド間の通信設計について深掘りする。

全体アーキテクチャ

Unity (C#)
    ↓ HTTPS + GraphQL
AWS AppSync
    ↓
AWS Lambda (Python)
    ↓
DynamoDB

シンプルな構成。ポイントは GraphQL を採用したこと。

なぜ GraphQL か

REST API でも作れる。でも、カードゲームは「状態」が複雑。

  • プレイヤーのHP、手札、フィールド
  • 各カードのステータス、装備、バフ/デバフ
  • ターン情報、バトルフェーズ、待機中の選択

REST だと、これらを取得するために複数のエンドポイントを叩く必要がある。GraphQL なら、1リクエストで必要なデータだけ取れる。

query GetMatch($id: ID!) {
  getMatch(id: $id) {
    id
    matchVersion
    phase
    turnPlayerId
    players {
      id
      hp
      levelPoints { color isUsed }
    }
    cards {
      id
      zone
      power
      statuses { key value }
    }
  }
}

クライアントが欲しいデータを指定できる。オーバーフェッチがない。

なぜ AppSync か

API Gateway + Lambda でも GraphQL は動く。でも AppSync には利点がある。

  • スキーマ駆動schema.graphql をアップロードするだけで API ができる
  • リゾルバ設定: Mutation/Query ごとに Lambda を紐づけられる
  • 認証の柔軟性: API Key、Cognito、IAM など複数対応

今回は開発スピード優先で API Key 認証を採用。本番では Cognito に切り替える予定。

Unity 側の実装

GraphQL クライアント

Unity 標準の UnityWebRequest で HTTP POST する。特別なライブラリは使わない。

public class GraphQLClient : MonoBehaviour
{
    // ※ 以下は例。実際は環境変数やScriptableObjectで管理
    private const string Endpoint = "https://xxx.appsync-api.ap-northeast-1.amazonaws.com/graphql";
    private const string ApiKey = "da2-xxxxxx";

    public IEnumerator PostGraphQL(string query, object variables,
                                   Action<string> onSuccess,
                                   Action<string> onError)
    {
        var body = new { query, variables };
        var json = JsonConvert.SerializeObject(body);
        var bytes = Encoding.UTF8.GetBytes(json);

        var req = new UnityWebRequest(Endpoint, "POST");
        req.uploadHandler = new UploadHandlerRaw(bytes);
        req.downloadHandler = new DownloadHandlerBuffer();
        req.SetRequestHeader("Content-Type", "application/json");
        req.SetRequestHeader("x-api-key", ApiKey);

        yield return req.SendWebRequest();

        if (req.result == UnityWebRequest.Result.Success)
        {
            onSuccess?.Invoke(req.downloadHandler.text);
        }
        else
        {
            onError?.Invoke(req.error);
        }
    }
}

ポイントは Coroutine + Callback パターン

Unity 2023 以降は Awaitable が標準で使えるし、UniTask を導入すれば async/await も快適。ただ今回は以下の理由で Coroutine を選んだ。

  • 外部ライブラリへの依存を減らしたい
  • チームに Coroutine 経験者が多い
  • 通信処理以外(演出の待機など)も Coroutine で統一したかった

プロジェクトの状況次第。async/await のほうがコードは読みやすくなる場面も多い。

リトライとタイムアウト

実運用では通信エラーへの対策が必要。

private IEnumerator PostWithRetry(string query, object variables,
                                  Action<string> onSuccess,
                                  Action<string> onError,
                                  int maxRetries = 3)
{
    for (int i = 0; i < maxRetries; i++)
    {
        bool done = false;
        string result = null;
        string error = null;

        yield return PostGraphQL(query, variables,
            r => { result = r; done = true; },
            e => { error = e; done = true; });

        if (result != null)
        {
            onSuccess?.Invoke(result);
            yield break;
        }

        // 指数バックオフ
        yield return new WaitForSeconds(Mathf.Pow(2, i));
    }
    onError?.Invoke("Max retries exceeded");
}

指数バックオフで 1秒 → 2秒 → 4秒 と間隔を空ける。サーバーが一時的に落ちてる場合に、リトライが集中するのを防ぐ。

使用側のコード

public void PlayCard(string cardId)
{
    var query = @"
        mutation PlayCard($matchId: ID!, $cardId: ID!) {
            playCard(matchId: $matchId, cardId: $cardId) {
                match { id matchVersion cards { id zone } }
                events { type payload }
            }
        }";

    var variables = new { matchId = _currentMatchId, cardId };

    StartCoroutine(_client.PostGraphQL(query, variables,
        raw => {
            var response = JsonConvert.DeserializeObject<PlayCardResponse>(raw);
            ApplyMatchState(response.Data.PlayCard.Match);
            PlayEvents(response.Data.PlayCard.Events);
        },
        err => Debug.LogError($"PlayCard failed: {err}")
    ));
}

リクエストを投げて、レスポンスを受け取ったら状態を更新する。シンプル。

Lambda 側の実装

エントリポイント

AppSync からのリクエストを受けて、field 名で処理を分岐する。

def lambda_handler(event, context):
    field = event["info"]["fieldName"]
    args = event["arguments"]

    if field == "playCard":
        return handle_play_card(args)
    elif field == "declareAttack":
        return handle_declare_attack(args)
    elif field == "endTurn":
        return handle_end_turn(args)
    # ... 30以上のリゾルバ

リゾルバが増えてきたので、レジストリパターンで整理している。

# action_registry.py
ACTION_HANDLERS = {
    "Move": handle_move,
    "Draw": handle_draw,
    "Summon": handle_summon,
    "Destroy": handle_destroy,
    # ...
}

def dispatch(action_type, payload, item, events):
    handler = ACTION_HANDLERS.get(action_type)
    if handler:
        return handler(payload, item, events)

レスポンス形式

全ての Mutation は同じ形式でレスポンスを返す。

return {
    "match": serialize_match(item),
    "events": events
}
  • match: 更新後のゲーム状態
  • events: クライアントに通知するイベント配列

同期戦略

ターン制対戦で一番難しいのは「同期」。

ポーリング方式を採用

WebSocket(AppSync Subscription)は使わず、1秒間隔のポーリングで同期している。

// MatchUpdateManager.cs
private IEnumerator PollLoop()
{
    while (_isPolling)
    {
        yield return FetchLatestMatch();
        yield return new WaitForSeconds(1f);
    }
}

なぜ WebSocket じゃないのか。理由は2つ。

1. 実装の複雑さ

WebSocket は接続管理が面倒。切断時の再接続、ハートビート、順序保証。ターン制ゲームでそこまで頑張る必要があるか。

2. コスト

AppSync の Subscription は接続時間で課金される。常時接続だと意外とコストがかかる。ポーリングなら Lambda の実行時間だけ。

matchVersion による整合性担保

ポーリングの弱点は「古いデータを受け取る可能性」。解決策が matchVersion

private int _localMatchVersion = 0;

private void ApplyMatchState(MatchModel match)
{
    if (match.MatchVersion <= _localMatchVersion)
    {
        // 古いデータは捨てる
        return;
    }
    _localMatchVersion = match.MatchVersion;
    // 状態を適用
}

サーバーは操作のたびにバージョンをインクリメント。クライアントは自分より新しいバージョンだけ受け入れる。

これで「自分の操作 → ポーリング結果(古い)→ 自分の操作結果」という順序で届いても、古いデータを無視できる。

なぜ1秒か

ターン制ゲームの「許容できる遅延」から逆算した。

  • 相手の操作を待つ場面が多い → 0.5〜1秒の遅延は気にならない
  • 3秒以上 → 「固まった?」と不安になる
  • Lambda実行コスト → 1秒でも月額数ドル程度

結果、1秒がバランス点だった。アクション性が高いゲームなら 200〜500ms を検討する。

イベント駆動設計

カードゲームは「1つの操作が連鎖的に効果を発生させる」。これをどう設計するか。

events 配列

Lambda は操作結果を events 配列で返す。

events = []

# カードを場に出す
events.append({
    "type": "MoveZone",
    "payload": {"cardId": card_id, "fromZone": "Hand", "toZone": "Field"}
})

# 召喚時効果が発動
events.append({
    "type": "OnSummon",
    "payload": {"cardId": card_id, "triggeredAbility": "DrawOne"}
})

# ドロー効果
events.append({
    "type": "Draw",
    "payload": {"playerId": player_id, "cardId": drawn_card_id}
})

クライアントは events を順番に再生する。

private IEnumerator PlayEvents(List<GameEvent> events)
{
    foreach (var ev in events)
    {
        yield return PlaySingleEvent(ev);
        yield return new WaitForSeconds(0.3f); // 演出の間
    }
}

これで「カードが場に出る → 効果発動のエフェクト → カードを引く」という演出が自然につながる。

Deferred Action パターン

プレイヤーの選択が必要な効果は、一旦保留する。

# サーバー側
if requires_player_choice(effect):
    item["choiceRequests"].append({
        "requestId": generate_id(),
        "playerId": player_id,
        "options": get_valid_targets(effect)
    })
    item["pendingDeferred"].append(effect)
    return  # ここで一旦返す

クライアントは選択肢を表示し、プレイヤーが選んだら submitChoiceResponse を送る。

// クライアント側
var response = await ShowTargetSelectionUI(choiceRequest.Options);
SubmitChoiceResponse(choiceRequest.RequestId, response);

サーバーは選択を受け取って、保留していた効果を実行する。

セキュリティ:サーバー権威モデル

オンラインゲームで最も重要なのはチート対策。

クライアントを信用しない

全ての計算はサーバーで行う。クライアントは「意図」だけを送る。

❌ クライアント: "このカードのパワーを500にして"
⭕ クライアント: "このカードで攻撃して"
   サーバー: パワー計算、バフ/デバフ適用、ダメージ計算

クライアントが嘘をついても、サーバーが正しい値を計算し直す。

入力バリデーション

def handle_play_card(args, item):
    card = find_card(item, args["cardId"])

    # 自分のカードか
    if card["ownerId"] != args["playerId"]:
        raise ValueError("Not your card")

    # 手札にあるか
    if card["zone"] != "Hand":
        raise ValueError("Card not in hand")

    # コストを払えるか
    if not can_pay_cost(item, card):
        raise ValueError("Cannot pay cost")

    # 実行
    execute_play_card(item, card)

全ての操作で「本当にその操作ができるか」をサーバーが検証する。

実装で苦労したポイント

1. JSON シリアライズのズレ

Python の Decimal が Unity の JsonConvert で読めない問題。

# NG
{"hp": Decimal(5)}

# OK
{"hp": 5}

DynamoDB は数値を Decimal で返すので、レスポンス前に int に変換する処理を入れた。

2. 条件付きエラーのハンドリング

「コストが足りない」「対象がいない」などのエラーをどう返すか。

最初は HTTP 400 で返していたが、Unity 側でエラーハンドリングが煩雑になった。結局、正常レスポンスに error フィールドを含める方式に変更。

return {
    "match": None,
    "events": [],
    "error": {
        "code": "INSUFFICIENT_COST",
        "message": "コストが足りません"
    }
}

クライアントは error があれば UI で表示する。

3. デバッグの難しさ

Lambda のログは CloudWatch。Unity のログはコンソール。両方を突き合わせるのが面倒。

解決策として、リクエストに requestId を含めるようにした。

var variables = new {
    matchId = _currentMatchId,
    cardId,
    requestId = Guid.NewGuid().ToString()  // 追加
};

これで CloudWatch を requestId で検索すれば、該当のリクエストを追える。

なぜこの構成でも複雑化しないか

本来、ステートレス + DB にロジックを持たせる構成は複雑化しやすい。

  • ロジックが分散して追いにくい
  • クライアントとサーバーで同じ計算を二重実装しがち
  • 仕様変更時に両方直す必要がある

でも、うちではこの問題がほぼ起きてない。理由は AI駆動開発 + モノレポ の組み合わせ。

モノレポで全部読める

Unity クライアントと Lambda バックエンドを1つのリポジトリで管理している。

/
├── client/          # Unity (C#)
│   └── Scripts/
├── server/          # Lambda (Python)
│   └── handlers/
└── docs/            # 仕様書

AI(Claude)にコードを読ませるとき、クライアントもサーバーも両方見える。「この処理、クライアントとサーバーで整合性取れてる?」と聞けば、両方チェックしてくれる。

仕様変更が楽

「カードのコスト計算を変えたい」となったとき。

従来なら: 1. サーバーのコスト計算ロジックを修正 2. クライアントの表示ロジックを修正 3. 両方テスト 4. 漏れがないか目視確認

AI駆動なら: 1. 「コスト計算をこう変えて」と指示 2. AI が両方まとめて修正 3. 差分を確認して承認

ロジックが分散してても、AI が全体を把握してるから問題にならない。

認知限界をAIが補う

人間がコードベース全体を頭に入れるのは限界がある。特にクライアント/サーバー両方を1人で見るのは辛い。

AI はその認知限界を補ってくれる。「このAPIのレスポンス形式を変えたら、クライアントのどこに影響する?」と聞けば、即座に影響範囲を列挙してくれる。

ステートレス構成の「分散して追いにくい」というデメリットが、AI によって打ち消されてる。

まとめ

Unity × AppSync × Lambda の通信設計で重視したこと:

  1. GraphQL で必要なデータだけ取得
  2. ポーリングでシンプルに同期
  3. matchVersion で整合性担保
  4. events 配列で演出と同期を分離
  5. サーバー権威で不正防止

WebSocket を使わずにポーリングで済ませているのは、ターン制だからこそ。FPS やアクションゲームなら別の設計が必要。

でも、この構成で十分「対戦ゲームとして成立する」レスポンス感は得られている。


質問や感想があれば、お問い合わせからどうぞ。