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 の通信設計で重視したこと:
- GraphQL で必要なデータだけ取得
- ポーリングでシンプルに同期
- matchVersion で整合性担保
- events 配列で演出と同期を分離
- サーバー権威で不正防止
WebSocket を使わずにポーリングで済ませているのは、ターン制だからこそ。FPS やアクションゲームなら別の設計が必要。
でも、この構成で十分「対戦ゲームとして成立する」レスポンス感は得られている。
質問や感想があれば、お問い合わせからどうぞ。