Amazon DynamoDB で作るサーバーレスなゲーム用アチーブメントマイクロサービス

2022-05-02
デベロッパーのためのクラウド活用方法

Author : 石井 宇大

ゲームな皆さんこんにちは、Game Developer Relations の石井宇大こと TAKA (@takahiroishii) です。

この投稿では昨今拡大しつつあるゲームのバックエンドサーバーをいかにマイクロサービス化させ、負荷分散や開発速度といった課題解決に繋げていくか、アチーブメントのマイクロサービスという例を取り上げて具体的なバックエンドコードの書き方までサンプルを使って解説したいと思います。

サンプルコードはこちら »

ご注意

本記事で紹介する AWS サービスを起動する際には、料金がかかります。builders.flash メールメンバー特典の、クラウドレシピ向けクレジットコードプレゼントの入手をお勧めします。

*ハンズオン記事およびソースコードにおける免責事項 »

このクラウドレシピ (ハンズオン記事) を無料でお試しいただけます »

毎月提供されるクラウドレシピのアップデート情報とともに、クレジットコードを受け取ることができます。 


前置き

突然ですが、先日 GDC2022 にて AWS から新しいゲーム向けソリューションとして AWS GameKit が発表されました。この AWS GameKit は様々なゲームに不可欠な機能を AWS 上にマイクロサービスとして Game Engine 側からデプロイできる、そんなソリューションになっています。

AWS GameKit を使ってゲーム機能をデプロイすると、紐付いている AWS アカウントの傘下に Well-Architected に準じて設計されたバックエンドサービスが構築され、実際にどのような AWS サービスを使ってその機能が実装されているのか コードレベル (もし Lambda を使用している機能の場合)  まで見る事ができます。

これからゲームにおける Amazon DynamoDB のテーブル設計や、サーバーレスなゲームのバックエンド構築に興味がある方は、ぜひ OSS として公開されている GameKit Repository をご覧いただき、学習やプロトタイピング、そしてゲーム開発のスタートポイントとしてご活用ください !


本題

GameKit にもあるアチーブメントというゲームに欠かせない機能では Lambda と DynamoDB を使った同期的な処理が利用されていますが、今回はよりアドバンスドで実用的なマイクロサービスとして作る例を見ていきたいと思います。それでは早速本題に入りましょう。

ゲームにおけるアチーブメント

そもそもアチーブメントとはどういった機能でしょうか ? まずはゲーム内のアチーブメントという機能を理解し、なぜアチーブメントはマイクロサービスに向いているのか、そしてなぜ非同期処理に向いているのか考えていきたいと思います。

アチーブメント機能とは一般的に、プレーヤーがある特定の行動を行った際に、その行動や、その行動の積み重ねにより前もって定義しておいた条件を満たした時のみに、そのプレーヤーにとっては一度きりのアチーブメントを達成する仕組みの事です。
そのプレーヤーにとっては一度きりのアチーブメント、例えば初めてゲームに 5 回勝利した時に ”ゴールドトロフィー” というアチーブメントを達成する仕組みの事です。

この定義に沿ってアチーブメント機能を細かく見ていくと、アチーブメント機能に必要なデータの条件は以下となります。

  1. 前もって定義された各アチーブメント、条件が定義されている必要がある
  2. プレーヤーが行える行動の定義、全ての行動がアチーブメントに紐付いている必要はない
  3. プレーヤーが行った行動のデータ
  4. プレーヤーのアチーブメントを達成した証明

実際に GameKit の例を見てみると 1 と 2 の情報は game_achievement というテーブルで各アチーブメントとそれに必要な行動を 1:1 で管理しており、3 と 4 に関しては player_achievement として別のテーブルにこちらも同様にアチーブメントと行動をプレーヤー毎に 1:1 で保存しています。

このようなデータは一般的にはゲームのコアなデータとは切り離して保存する事が可能であり、データそのものが独立している事でゲームの成長と共に大きくなる中心的なゲームのバックエンドから切り離して処理する事が可能になります。

続いてアチーブメント機能として成立させる為の全体的な流れの条件を考えていくと以下のようになり、とてもシンプルです。

  1. プレーヤーが何か行動を起こす
  2. アチーブメント機能がこれをトラックする
  3. アチーブメント機能がプレーヤーのこれまでのデータを元にアチーブメントが達成されたか確認する
  4. アチーブメントが達成されている場合それをプレーヤーに知らせる

ここで特に大事なのは、プレーヤー側から見ると 1 以降のアチーブメント機能の流れは完全にゲームの流れとは独立しているという事です。つまり例えばゲーム内でプレーヤーが森の中でドラゴンを一体倒したとします。これが 1 だとするとその後 4 までの間プレーヤーはアチーブメント機能の流れを待ったり把握する必要なく、森の中をそのまま探索し続けれるべきであると言うことです。

このプレーヤー目線でみるアチーブメントの流れは、バックエンドの他のサーバー目線で見ても変わりません。プレーヤーの位置情報や、例えば Amazon GameLift のような戦闘のシミュレーションを管理しているゲームサーバーも、プレーヤーがドラゴンを倒した際にドロップしたアイテムをプレーヤーのインベントリに入れる作業を行っているプレーヤーデータ管理サーバーも、このドラゴンを倒したという行動がどんなアチーブメントを達成しているか待つ必要も把握する必要もないのです。

もしアチーブメントによって何か報酬があるのであれば、それは 4 以降にプレーヤーデータを管理しているサーバーが行うでしょう。

ここまででゲームにおけるアチーブメント機能というものがいかに独立しているか理解できたと思います。そして大切なのは独立している機能が故に、アチーブメント機能は単独でスケールする必要があります。

ここからは、サーバーレスの特性を生かして DynamoDB、Amazon SQS、そして AWS Lambda の組み合わせを使い、柔軟にスケールするアチーブメントマイクロサービスの例を紹介したいと思います。


テーブルデザイン

DynamoDBを使ってゲームのバックエンドを開発する際、大切なのはテーブルデザインです。作りたいサービスの機能から考えていくととてもシンプルにできるので、今回もまずは機能を定義し考えていきます。

  • プレーヤーの行動を progress と定義する
  • ひとつの achievement はある特定の一つの progress がある一定数に達する事で達成とみなす
  • 一つの progress は必ずしも一つの achievement を達成するためだけではないし、何も達成しないかもしれない
  • ドラゴン討滅の progress は 10 体で一つの achievement、30 体でもう一つの achievement を達成できる

このようにせっかくなので GameKit とは違った機能を定義しました。大きな違いは一つの行動から複数のアチーブメントを達成できる部分です。

この定義を達成するためのテーブルデザインを下のような内容とします。

Player Table (1 つのテーブルに複数種類のエントリー)

Achievement Entries

PK: player_id SK: id achieved_at
string : プレーヤーの ID string : アチーブメントの ID タイムスタンプ

Progress Entries

PK: player_id SK: id progress last_updated
string : プレーヤーの ID string : 行動の ID number : 現時点の行動の回数 タイムスタンプ

Achievement Data Table (1 つのテーブルに 1 種類のエントリー)

Achievement Data Entries

PK: achievement_id GSI PK: required_progress GSI SK: required_amount
string : アチーブメントの ID string : 達成に必要な行動の ID number : 達成に必要な行動の数

サービスデザイン

このテーブルデザインを決めるにあたって処理の仕方も考えていきます。下の図の右半分が今回のサンプルのアーキテクチャになります。

今回は Amazon SQS をマイクロサービスの入り口として設けて、そこで沢山のバックエンドサービスからのリクエストの負荷軽減を狙います。そして沢山のサービスが簡単にこのマイクロサービスを使えるようにインプットのフォーマットとして下記の内容を使います。

{
“player_id”: string,
“progress_id”: string,
“progress_increment”: number,

}

つまりはどんなバックエンドサービスであっても、プレーヤーの ID と行動の ID さえわかっていれば、アチーブメントの条件を知らなくても任意の数だけアチーブメント達成に向けて進行度をあげることができる (progress を変更できる) というわけです。

例えば、GameLift の様なゲームサーバーからはプレーヤーの ID とモンスター討伐の行動の ID、そしてその数 (例えば 3) が送信され、ログイン情報を管理するバックエンドサービスからはプレーヤーの ID とログイン行動の ID、そしてログインした数 (例えば 1) が送られる、といったイメージです。

ここで必要になってくるのが progress_id からの achievement_id の逆引きです。一方的に送られてくる progress_id から Achievement Data Tabl e内の Achievement Entry を引き当てて、現在のプレーヤーの progress の数が達成に足りているか確認する必要があります。

したがって、Achievement Entries には Global Secondary Index (GSI) が設定されていて、GSI の Sort Key として required_amount を指定する事で、一つの progress_id から達成可能な achievement_id のリストが required_amount 順に GSI の PK のみの query で返って来ることになります。

このようにテーブルデザインをしていくと、このマイクロサービスの中心となるアチーブメントの達成チェックの処理は以下のような流れで行うことができるとお分かりいただけると思います。

  1. 入り口の SQS からインプットを受け取る
  2. インプットに含まれる情報から Player Table の Progress Entries に対してプレーヤーの行動回数を Atomic Counter を使い Update を行う
  3. Achievement Data Table から GSI を使ってインプットに含まれた progress_id を PK に紐付いたアチーブメントのリストを取得  (例: 同じ “winBattle” という progress_id に対して、required_amount が “5” の “silverTrophy” というアチーブメントと required_ammount が ”10“ の ”goldTrophy“ というアチーブメントがリストで帰ってくる)
  4. リストはすでに達成への必要回数が少ないものから順に並んでいるので順番に達成・未達成の確認を行う
  5. もし達成していたら
    1. Player Table の Achievement Entries に対して、すでにその achievement_id が達成したとして保存されていないかチェックして Put を行う
    2. 出口である SQS へアウトプットとして、プレーヤー ID とアチーブメント ID を送信
  6. もし未達成だとわかれば、それ以上次のアチーブメントをチェックする必要がないのでそこで Early Out する

ここで DynamoDB の強みが出るのが 2 の Atomic Counter や 5-1 の Conditional Put です。Atomic Counter を使う事で、前の数字が何か読む必要もありませんし、Conditional Put を使う事で、例え同じアチーブメントに対して同時に処理が行われていても、達成だと判断する処理は一度だけに絞れます。

このような一見複雑そうに見える条件でも DynamoDB の特性を活かす事でクエリもシンプルなままサービス全体を通して ACID な処理が可能になります。

サンプル内では Typescript を使って Main Handler の中で 5 及び 6 は以下のように書かれています。

  const { Items } = await db.query(getAchievementDataPrams).promise();
  if (!Items) {
    console.log("get achievement data returned returned null");
    return;
  }
  
  for (const {
    achievement_id,
    required_amount,
  } of Items as AchievementData[]) {
    if (required_amount > progress) {
      // 現時点のrequired_amountが達成できてないので以降のソートされたアチーブメントは無視できる
      return;
    }

    const achievedParams: DocumentClient.PutItemInput = {
      TableName: tableMap.get(PlayerData)!,
      Item: {
        [playerDataPk]: player_id,
        [playerDataSk]: achievement_id,
        achieved_at: timeStamp,
      },
      ConditionExpression: `attribute_not_exists(${keyMap
        .get(PlayerData)!
        .get(Keys.PK)!})`,
    };

    try {
      await db.put(achievedParams).promise();
      await outSQS
        .sendMessage({
          MessageBody: JSON.stringify({
            player_id,
            achievement_id,
          } as OutMessage),
          QueueUrl: outQueueUrl,
        })
        .promise();
    } catch (e) {
      if (e.name == "ConditionalCheckFailedException") {
        // attribute_not_existsが失敗、つまりすでにこのアチーブメントは達成済
        continue;
      }
      throw e;
    }
  }

SQS との組み合わせ

今回はアチーブメントというインプットの行動の順序を無視できる特性と、ゲーム特有のスケール性を重視して SQS のスタンダードキューを仕様しています。そしてそのスケール性とシンプルで柔軟な特徴がとても魅力的な反面、メッセージの重複をサービス側で対処しなければいけないのも事実です。

そこで今回は上の SQS から届く各メッセージに紐付いている message_id を利用して、重複の確認を行います。上の流れの中の 2 の場所が重複を確認するには最も適しているので、追加のテーブルを DynamoDB に構築し、2 の処理を Transaction に変換します

追加のテーブルは具体的には下記のようになります。

Progress Message Table ( 1つのテーブルに 1 つのエントリー)

Progress Message Entries

PK: message_id ttl
string : インプット Message の ID タイムスタンプ

そして Transaction 部分は以下のようになります。

const progressMessageCheckParams: DocumentClient.TransactWriteItem = {
    Put: {
      TableName: progressMessageTableName,
      Item: {
        [keyMap.get(ProgressMessage)!.get(Keys.PK)!]: messageId,
        ttl: Math.floor(
          new Date(Date.now() + progressMessageTTL).getTime() / 1000
        ),
      },
      ConditionExpression: "attribute_not_exists(message_id)",
      ReturnValuesOnConditionCheckFailure: "ALL_OLD",
    },
  };
  const updateProgressParams: DocumentClient.TransactWriteItem = {
    Update: {
      TableName: playerDataTableName,
      Key: {
        [playerDataPk]: player_id,
        [playerDataSk]: progress_id,
      },
      UpdateExpression:
        "SET #progress = if_not_exists(progress, :zero) + :progress_increment," +
        " #last_updated = :last_updated",
      ExpressionAttributeNames: {
        "#progress": "progress",
        "#last_updated": "last_updated",
      },
      ExpressionAttributeValues: {
        ":progress_increment": progress_increment,
        ":last_updated": timeStamp,
        ":zero": 0,
      },
    },
  };

  try {
    await db
      .transactWrite({
        TransactItems: [progressMessageCheckParams, updateProgressParams],
      })
      .promise();
  } catch (e) {
    if (e.name == "TransactionCanceledException") {
      // transactionが失敗したので理由を調べる
      if (e.message.includes("TransactionConflict")) {
        // Conflictの場合はもう一度処理し直したいので意図的にエラーを出す
        console.log(
          `transact write failed, transaction has conflicted, 
            likely due to the same player updating the same progress at the same time`
        );
        throw e;
      }
      // メッセージIDの重複になるのですでに同じメッセージが処理済みと判断する
      console.log(
        `transact write failed, a message with the same message id ${messageId} has already been processed`
      );
      return;
    }
    throw e;
  }

ここで気をつけなければいけないのが TransactionCanceledException には TransactionConflict というタイプの Exception が含まれる事です。この Transaction が同時に複数、同じプレーヤーの同じ行動に対して処理される場合に起こります。

今回の例では以下のように全ての意図的な Exception 及び、意図してない Exception が出たメッセージに対しては SQS のバッチ項目の失敗報告を活用しているので、Conflict が起きた処理は再び SQS に戻り自動的にやり直されます。

  const timeStamp = Date.now();
  const batchItemFailures: SQSBatchItemFailure[] = [];
  await Aigle.forEach(Records, async ({ body, messageId }) => {
    try {
      const message: InMessage = JSON.parse(body!);
      await updateProgress(messageId, message, timeStamp);
    } catch (e) {
      batchItemFailures.push({
        itemIdentifier: messageId,
      });
      console.log(e);
    }
  });
  return { batchItemFailures };

まとめ

今回はこの一連の処理を Main Handler という一つの Lambda で行い、そこから結果をOut Queue という SQS で報告しマイクロサービスを完成させました。このサンプルには他にも Out Queue からトリガーされる Out Handler と前もって定義する必要のある Achievement Data Table に Achievement Entries を入れる Admin Handler もマイクロサービスとしてセットで紹介しています。簡単に Amazon API Gateway 経由でアチーブメントの元データを作り、SQS に Message を送る事で一連の流れをテストできるようになっています。

尚このサンプルの構築には CDK を使っているので、すでにお持ちの AWS アカウントに短時間で deploy する事ができます。是非試してみて、DynamoDB と SQS、そして Lambda を使ったとてもスケールするアチーブメントのマイクロサービスを体感して、これからのゲーム開発に役立ててみてください !

ゲーム開発者向けのその他の記事はこちら

選択
  • 選択
  • Amazon DynamoDB で作るサーバーレスなゲーム用アチーブメントマイクロサービス
  • AWS の知識ゼロから始める AWS GameKit
  • Amazon GameLift を使って「最短」でマルチプレイヤーゲームを作る
  • Amazon DynamoDB で作るサーバーレスなゲーム用フレンドマイクロサービス
  • Amazon GameSparks でインフラを意識せずにゲームのバックエンドサービスを開発しよう
  • 魔法で作る Amazon DynamoDB の 簡単ゲームインベントリ
  • レベル 1 から作るゲームのクエスト手帳
  • サーバーレスなゲームのギルド検索
  • 一緒に学び考える ! ゲームの AI/ML 活用
  • 3 ステップで始める AI モデル開発 ! 始める前に知っておくべきコト !
  • 実践 ! 機械学習でゲームの休眠ユーザーを予測してみよう
  • Amazon GameLift Anywhere でサクッとマルチプレイゲームを開発しよう
  • ゲーム分析を Amazon Aurora と Amazon Redshift の Zero-ETL 統合ではじめよう
  • Amazon Redshift を活用したゲームの行動ログ分析
  • Amazon QuickSight によるゲーム分析ダッシュボードの構築
  • 品質管理に生成 AI は使えるのか !? テキストチェックを生成 AI にやらせてみた
  • 生成 AI を用いたヘルプソリューションを作成する
  • 世界に 1 つだけの AI アシスタント作成 ~オリジナルにカスタマイズ

builders.flash メールメンバーへ登録することで
AWS のベストプラクティスを毎月無料でお試しいただけます


筆者プロフィール

石井 宇大
アマゾン ウェブ サービス ジャパン合同会社
ゲームデベロッパーリレーションズ

カナダ🇨🇦 で 10 年以上生活していたゲームで遊ぶのも作るのも好きな元ゲームエンジニア。趣味は奥さんと季節を感じながら美味しい物を食べ大好きな赤ワイン🍷 を嗜む事。
普段は楽しいゲーム作りに繋がるよう、様々なソリューションを提供するお仕事をしています。

AWS を無料でお試しいただけます

AWS 無料利用枠の詳細はこちら ≫
5 ステップでアカウント作成できます
無料サインアップ ≫
ご不明な点がおありですか?
日本担当チームへ相談する