見出し画像

「ポチポチコンビニ経営」を支えた技術

こんにちは、 @petitviolet です。弊社では「ポチポチコンビニ経営」というライブゲームを提供しておりました。

ゲームの運営というのは簡単なものではなく、誠に残念ながら2023年10月6日を持ってサービス終了となりました。やってる間はとても長く感じましたが、1年継続できなかったんですね。。
サービスについて色々語りたいところでもありますが、今回は技術的な観点でどうだったかについて書いていきます。

アーキテクチャ

最終的なアーキテクチャは以下の図のようになりました。

アーキテクチャ図

今回開発したライブゲームはMirrativアプリ上で動作するものであったためMirrativ社が提供するライブゲーム用のAPIと連携しており、例えばプレイヤーがライブ配信をしている際にそのライブに投稿されたコメントや贈られたギフトの情報が弊社サーバにWebhookとして送信されてきて、ゲーム内にそのコメントやギフトを表示・反映するようになっています。
アーキテクチャとしてはあまり凝ったところはなく、

  • CloudSQL for PostgreSQL

  • Cloud Runで動くGo製API

  • Cloud Runで動くNext.js製のAPI

  • Unityで開発したWebGLアプリ

    • Mirrativアプリ内で起動される

  • Firebase Firestoreでリアルタイムデータの同期

    • コメントやギフトのような鮮度が大事なもの

というのが主なコンポーネントとなっており、インフラについては基本的にTerraformでプロビジョニングしていました。
弊社としていわゆるWebアプリ開発は強みがあるのでAPI周りは問題なかったのですが、Unityとかゲーム用のアセット作成はほぼ経験がなく困ったのですが、協力してくれるパートナーを幸運にも見つけることが出来ました。本当に助かった…。

API + DB

Go製APIサーバではORMとして ent を使ってDBスキーマをGoのコードで記述・管理し、生成したdiffを Atlas でバージョン管理しつつマイグレーションするといったことをしていました。APIとしての実装まわりは特に変わったことはしておらず、OpenAPIで定義したAPIを地道に実装したくらいです。
アプリケーションの実装という観点だと、ゲームのセーブデータをどのようにデータとしてモデリングするか、が面白かったポイントです。結局はイベントソーシングのように、あるプレイヤーに関連する全てのイベントをソースとして積み上げることで現在のプレイヤーの状態、つまりセーブデータを作成することにしました。全てのイベントにtimestampが付与していてセーブデータも作成日時timestampを保持しているので、差分だけを適用することで読み込まれるたびに最新版データに更新するといった流れです。
こうしておくことでプレイヤーの全ての行動がDBに保存されていることになるので色んな観点の分析も可能でしたし(分析できたとは言ってない)、お問い合わせ対応でも役に立ちました(ネットワークの問題でそもそもDBに保存されていない件も多く、必ずしもその限りでは…)。

あと一点苦労したのがCloud Run -> Cloud SQLへの接続まわりで、https://cloud.google.com/sql/docs/postgres/iam-logins によるとIAM認証を用いてDBにログインできるようなのですが、結局その方法がわからず、Cloud Run用にDBユーザを作成してそのID/PASSでログインするようにしていました。
`gcloud auth print-access-token`で生成したトークンをPGPASSWORDとして設定するとログインは出来ましたが、有効期限があるはずなので起動しっぱなしのAPIサーバが使うものではなさそうです。どうやるのが正解だったのかいまだに謎です。

管理画面

Next.js 13 + tRPC + Prisma + ChakraUI で作りました。サーバサイドはNext.jsのAPI Routesで済ますことができたので、こういう小さいアプリケーションを開発するときには非常に便利で助かりました。また、クライアントサイドでは学習がてらApp Routerを使うことも検討しましたが、他の開発に色々と追われていたのでそこまで余裕がなくPages Routerで済ましました。機会があれば使いたいとは思っていますが、まだちょっと早いかなという感覚もあるので様子をみていきたいところです。
API周りの実装ではtRPCに zod を組み合わせることで型安全にAPIを呼び出すことが出来ていい体験でした。とはいえdatetime-localで入力された日付をタイムゾーンを考慮しつつやり取りするあたりはまだ面倒だった印象があります。ここについてはsuperjsonを使ってみれば良かったのかもしれません。https://trpc.io/docs/server/data-transformers

なおアクセス制限にはGoogle Cloudが提供する Identity-Aware Proxy を利用して、弊社のGoogleアカウントを持っていればアクセスできるようにしました。
普段の感覚で管理画面は後回しになってしまっていてサービス開始当初しばらくは存在していなかったのですが、最終的にゲーム運用において管理画面は必須だったなと思うくらいには非常に重要な役割を果たしました。ニュースを配信したり、月替りのチャレンジを入力したり、メンテナンスウィンドウを設定したり、何か不幸があって補填をしたり。

Unity

ゲームのクライアントとしてUnity(C#)で書いたコードをWebGLで動かすということをしましたが、なにかと苦労がありました。

ビルド

まずWebGL向けのビルドについて情報があまり多くなく、まずビルド周りに苦戦しました。たとえば、特定のiOSバージョンで動かなくなるバグがあったために、Unity自体にパッチをあててビルドする必要がありました。ローカルでUnityにパッチをあてたものをDockerに入れておいてGCRにpushしておき、GitHub Actionで game-ci/unity-builder のcustom imageとして指定してビルドするようにしました。どうしてもイメージサイズが非常に大きくなるためGitHub Actions上でpullしたものをsaveしておき、それをloadするといった工夫が必要だったりして何かと辛かったです。
また、WebGLで起動する都合上あまり大量にメモリを浪費しないようにUnity Editorから設定できる項目を1つずつ切り替えてビルドしてみたり、自前のビルドスクリプトを実装してみたり…といったチューニングをする際に、ビルドが毎回30min以上時間がかかって大変だったので、次回やるときは強力なVMをself-hosted runnerとして用意したいと思います。
最終的にビルドするだけのActionsがこんな感じになりました。

name: Unity Build for WebGL
on:
  workflow_call:
    secrets:
      UNITY_LICENSE:
        required: true
    inputs:
      environment:
        description: 'development or production'
        required: true
        type: string
        default: 'production'
    outputs:
      artifact_name:
        description: Path for artifact
        value: ${{ jobs.dev_build_unity.outputs.artifact_name }}

env:
  UNITY_VERSION: 2022.1.17f1
  GCP_PROJECT_ID: ${{ secrets.DEV_GCP_PROJECT_ID }}
  GOOGLE_IAM_WORKLOAD_IDENTITY_POOL_PROVIDER: ${{ secrets.DEV_GOOGLE_IAM_WORKLOAD_IDENTITY_POOL_PROVIDER }}
  SERVICE_ACCOUNT_EMAIL: ${{ secrets.DEV_SERVICE_ACCOUNT_EMAIL }}
  REGISTRY: gcr.io

jobs:
  dev_build_unity:
    name: WebGL build for env
    runs-on: ubuntu-latest
    permissions:
      contents: 'read'
      id-token: 'write'
    steps:
      - name: Set build options
        run: |
          if [ "${{ inputs.environment }}" == "development" ]; then
            echo 'BUILD_OPTIONS=-env development -Development' >> $GITHUB_ENV
          else
            echo 'BUILD_OPTIONS=-env production' >> $GITHUB_ENV
          fi

      - name: Check out
        uses: actions/checkout@v2

      - id: 'gcloud_auth'
        uses: 'google-github-actions/auth@v0'
        with:
          workload_identity_provider: ${{ env.GOOGLE_IAM_WORKLOAD_IDENTITY_POOL_PROVIDER }}
          service_account: ${{ env.SERVICE_ACCOUNT_EMAIL }}
          token_format: 'access_token'

      - name: Set Docker related variables
        run: |
          echo "DOCKER_IMAGE=${{ env.REGISTRY }}/mirrativ-shop-develop/unity:${{ env.UNITY_VERSION }}-webgl-customized" >> $GITHUB_ENV
          echo "DOCKER_CACHE_PATH=unity_${{ env.UNITY_VERSION }}" >> $GITHUB_ENV

      - name: Run apt-get
        run: |
          sudo apt-get update

      - name: Free Disk Space
        uses: jlumbroso/free-disk-space@main
        with:
          tool-cache: false
          android: true
          dotnet: true
          haskell: true
          large-packages: true
          swap-storage: true

      - name: Cache Unity Docker image
        id: cache-docker-image
        uses: actions/cache@v3
        with:
          path: ${{ env.DOCKER_CACHE_PATH }}
          key: ${{ runner.os }}-unity-${{ env.UNITY_VERSION }}

      - name: Login to GCR
        uses: docker/login-action@v2
        with:
          registry: ${{ env.REGISTRY }}
          username: oauth2accesstoken
          password: ${{ steps.gcloud_auth.outputs.access_token }}

      - name: Pull and save Unity Docker image
        if: steps.cache-docker-image.outputs.cache-hit != 'true'
        run: |
          docker pull "${{ env.DOCKER_IMAGE }}"
          docker save "${{ env.DOCKER_IMAGE }}" -o "${{ env.DOCKER_CACHE_PATH }}"

      - name: Load Docker Image
        if: steps.cache-docker-image.outputs.cache-hit == 'true'
        run: |
          docker load -i "${{ env.DOCKER_CACHE_PATH }}"

      - name: Cache Unity Library
        uses: actions/cache@v3
        with:
          path: mirrativ-shop-unity/Library
          key: Library-

      - name: Run WebGL build
        uses: game-ci/unity-builder@v2
        env:
          UNITY_LICENSE: ${{ secrets.UNITY_LICENSE }}
        with:
          targetPlatform: WebGL
          unityVersion: ${{ env.UNITY_VERSION }}
          projectPath: unity
          customParameters: -dest build/WebGL/WebGL ${{ env.BUILD_OPTIONS }}
          customImage: "${{ env.DOCKER_IMAGE }}"
          allowDirtyBuild: true

      # 後続のjobでビルドした結果を利用できるようにuploadしておく。必要ならdownload-artifactを実行する
      - name: Upload Build
        uses: actions/upload-artifact@v3
        with:
          name: build
          path: build
          retention-days: 1
          if-no-files-found: error

      - name: set output
        run: echo "artifact_name=Build" >> $GITHUB_OUTPUT

Firestore

既に記載しましたが、配信に対して投稿されたコメントやギフトをゲーム内に反映するためにFirestoreをデータソースとして利用しました。リアルタイム性の高いデータをクライアントと同期する際には何かと便利なFirestore、サーバ側からデータをadd/setするのは全く問題ないのですが、クライアントたるWebGL(Unity)側からどうやってそれを消費するかというところが非常にやっかいで、Unity用SDKが提供されている(https://firebase.google.com/docs/unity/setup)ので問題ないかと思いきや、 WebGLは対象外。じゃあJavaScript用のSDKを使えば、ということですがひと手間もふた手間も必要で、jslibとして実装したJavaScriptからC#側にデータを送るために`unityInstance.Module.SendMessage`するなどの実装をしましたが、`window.unityInstance`を定義しておかないといけないなど、通常のUnity開発とはちょっと違う困難がありました。

# Plugins/Firestore.jslib
mergeInto(LibraryManager.library, {
  OnSnapshotCollection: function (collectionPath, objectName, onUpdate) {
    const _objectName = UTF8ToString(objectName);
    const _onUpdate = UTF8ToString(onUpdate);
    firebase
      .firestore()
      .collection(UTF8ToString(collectionPath))
      .onSnapshot(function (snapshot) {
        snapshot.docChanges().forEach((change) => {
          if (change.type === "added") {
            const data = JSON.stringify(change.doc.data());
            console.log(`New data: ${data} => ${_objectName}.${_onUpdate}`);

            unityInstance.Module.SendMessage(_objectName, _onUpdate, data);
          }
        });
      });
  },
});

# Scripts/Firestore.cs
public static class Firestore
{
#if UNITY_WEBGL && !UNITY_EDITOR && !UNITY_INCLUDE_TESTS

    [DllImport("__Internal")]
    public static extern void OnSnapshotCollection(string collectionPath, string objectName, string callbackMethodName);
#else
    public static void OnSnapshotCollection(string collectionPath, string objectName, string callbackMethodName)
    {
        Debug.Log($"OnSnapshotCollection {collectionPath} called.");
    }
#endif
}

また、一度firestoreをsubscribeしたUnityのインスタンスをシーンが切り替わっても保持しなければならなかったので `DontDestroyOnLoad` を使ったシングルトンMonoBehaviourとして実装して対応しました。

メンテナンスモード

個人的に工夫を施した箇所だったので紹介します。ゲーム中にメンテナンス時間に入った場合、API側はエラーを返すように、Unity側はプレイを継続できないようにする必要があります。
API側では管理画面から設定したメンテナンス期間を参照するクエリを一定間隔でポーリングして状態を更新し続けます。リクエストが飛んできたらメンテナンス期間であるかどうかをチェックし、そうであった場合はヘッダにメンテナンス終了予定時間を詰めた503を返すようにしました。
Unity側では、メンテナンス期間を問い合わせるようなことはせず、APIレスポンスが503かつメンテナンス終了予定時間がヘッダに入っていた場合にメンテナンスモードに入るような実装にしました。あらゆるシーン・状態からグローバルに状態を操作できる必要があったため、ここでもjslibを使ってUnity側ではなくJavaScript側でメンテナンス用の画像をオーバーレイ表示させることで、プレイの継続を防ぎつつUnity側の処理は裏側で継続させることができるためデータが不整合になることを避けることができました。
jslib側の実装は簡単なもので↓程度のものです。

mergeInto(LibraryManager.library, {
  ShowMaintenanceAlert: function (message) {
    var errorMessage = UTF8ToString(message);
    var wrapper = document.getElementById("maintenance-wrapper");
    wrapper.style.visibility = "visible";
    document.getElementById("maintenance-text").innerText = errorMessage;
  },
});

マスターデータ管理

ゲーム運営ではかならず必要になるであろうマスターデータの管理。
桜井さんのYouTubeでも触れられています。

こんなにちゃんとできなかったですが、弊社ではSpreadSheetにたくさんのシートを用意して、たとえば商品の価格やレア度、エリアに登場するお客さんなど、ほぼ全てのデータを入れていました。
だいたいのデータはAPI and/or Unityで取り込む必要があり、取り込むツールの開発していました。さらにAPIとUnityで同じデータを取り込んでいるはずが、データが更新されたのに片方だけ取り込み忘れているといったことで障害に発生したという経緯から、マスターデータ自体もCSVとしてGitで管理し、GitHub ActionsでAPIとUnityで取り込んだデータに差異がないかを検証するスクリプトなんかも実装しました。

実際にこういうエラーが出た

まとめ

まだまだ書き足りないですが、とりあえずこの辺にしておこうと思います。