モメントで作る: ゲームのリーダーボードを高速化するヒントとコツ

Momento Cacheのソートセットを使って、4種類の方法でゲームのリーダーボードを構築する方法をご紹介します。

私はゲーム開発者ではないが、10年以上アプリケーション開発者をしています。そして負けず嫌いだ。私が知っている中で、たぶん、いや、間違いなく最も負けず嫌いな人間です。

私がすることはすべて競争でなければなりません。誰が1マイルを最も速く走れるか、誰のコードが不具合報告なしで最も長く続くか、すべてに勝者がいます。

競争は、私が認めたくないほど頻繁に私のコードに滲み出てきます。以前、フィットネス・トラッキング・モバイル・アプリを作ったことがあります。その日のワークアウトを行った場合、ボーナスポイントがもらえるのです。どうやって「その日のワークアウト」が決まったのか気になりますか?楽しいビルドだったが、認めたくないほど時間がかかった部分があります。

私のように競争が好きな人間にとって、リーダーボードはどんなゲームにも必要なものです。常に誰が勝っているのかを知る必要があります。そして、もし自分が勝っていないのであれば、どのように統計データを切り刻んで、自分が勝てるように位置づけることができるでしょうか?

リーダーボードのような一般的なものであれば、30分もあればすぐに作れると思うでしょう?

それは間違いです。

リーダーボードの実装は、見かけによらず難しいものです。融通の利かないデザインの決定によって、変更が必要なときにすべてを破棄せざるを得なくなり、簡単に自分を窮地に追い込むことができます。

データも刹那的、つまりあまり長くは残らない傾向があります。リーダーボードは1試合、1シリーズ、1日、1ヶ月間有効です。どのようにデータを無効にするのでしょうか?

最近、Momento Cache用のSorted Setがリリースされたことで、リーダーボードの作成がかつてないほど簡単になりました。スコアを追跡し、プレーヤーをランク付けし、データを無効にするロジックはすべて、SDKに直接組み込まれています。そのパワーを見てみましょう。

リーダーボードの構造

リーダーボードは一般的に、誰がプレイしているか、誰が勝っているか、そしてそのスコアが表示されます。Momentoのゲーム「Acorn Hunt」のリーダーボードの例を見てみましょう。

‍上記のリーダーボードの主な構成要素は以下の通りです:

順位: ゲーム内でのプレーヤーの順位
スコア: プレイヤーのポイント数(ランクに直接影響します)
: プレーヤーの名前

リーダーボードに明記されていないのは順位です。順位はあなたの順位を決定します。昇順では、低いスコアが上位にランクされます。降順では、より高い得点が上位にランクされます。上の例は、降順のリーダーボードの例です。

そういうものを作るのは簡単なんですか?聞いてくれると思ったんですけど

ポイントベースのリーダーボード

リーダーボードの典型的な例は、降順でポイント制です。つまり、得点の高いプレーヤーが勝ち、得点の低いプレーヤーが負けるということです。しかし、柔軟なリーダーボードを設計することで、競技志向の「スライス&ダイサー」が好きなようにランキングを見ることができるようになります。

シングル・スタッツ・スコアリング

最もシンプルなリーダーボードは1つの統計を追跡します。Acorn Huntでは、これはプレイヤーがゲーム中に集めたどんぐりの数に直接結びつきます。この数字だけを使って、プレイヤーのランキングを追跡するリーダーボードを簡単に作ることができます。

プレーヤーのスコアを増やすために、Node.js SDKで書かれたこのエンドポイントを考えてみましょう:

//
// POST /points
//
exports.handler = async (event) => {
    const input = JSON.parse(event.body);
    const { username } = event.requestContext.authorizer;

    const momento = await getCacheClient();
    const currentGameResponse = await momento.dictionaryGetField('user', username, 'currentGameId');
    if (currentGameResponse instanceof CacheDictionaryGetField.Miss) {
        return {
            statusCode: 409,
            body: JSON.stringify({ message: 'You are not part of an active game' })
        };
    }
    const gameId = currentGameResponse.valueString();
    const newScore = await momento.sortedSetIncrementScore('leaderboard', gameId, username, input.score);

    return {
        statusCode: 200,
        body: JSON.stringify({ score: newScore.score() })
    };
}

このエンドポイントは、スコアを追跡し更新するために2つのキャッシュを使用します:
userは、ディクショナリキャッシュアイテム内の個々のユーザーメタデータを追跡します。
leaderboardは、ソートされたセットを使用して、ゲーム内のすべてのプレーヤーのスコアを追跡します。

user cacheは、呼び出し元の現在のゲーム識別子を暗黙的に提供します。その後、エンドポイントは、適切なゲームのleaderboard cacheを更新するためにidを使用します。SDKでsortedSetIncrementScoreコマンドを呼び出していることに気づくかもしれません。これはincrement APIと同様で、プレーヤーのスコアを増加させる値を受け取ります。新しいプレーヤーのスコアを直接設定するのではなく、スコアの変化を渡します。

ゲームのリーダーボードを取得するために、データの柔軟な戻り形式を可能にする別のエンドポイントがあります。

//
// GET /games/{gameId}/leaderboard
//
exports.handler = async (event) => {
  const { gameId } = event.pathParameters;
  const params = event.queryStringParameters;
  
  const order = params?.order?.toLowerCase() == 'asc' ? SortedSetOrder.Ascending : SortedSetOrder.Descending;
  const top = params?.top;

  const momento = await getCacheClient();
  const leaderboardResponse = await momento.sortedSetFetchByRank('leaderboard', gameId, {
    order: order,
    ...top && {
      startRank: 0,
      endRank: top
    }
  });

  if(leaderboardResponse instanceof CacheSortedSetFetch.Miss){
    return {
      statusCode: 404,
      body: JSON.stringify({ message: 'Game not found.' })
    };
  }

  const leaderboard = leaderboardResponse.valueArrayStringNumber().map((element, rank) => {
    return {
      rank: rank + 1,
      username: element.value,
      score: element.score
    }
  });
  return {
    statusCode: 200,
    body: JSON.stringify({ leaderboard })
  };
}

これにより、呼び出し元はオプションのクエリー文字列パラメーターを渡して、ランキングの順序を変更したり、結果の数を制限したりすることができます。

つまり、リーダーボードの上位10人または下位3人のプレーヤーを取得したい場合は、以下のクエリパラメータでエンドポイントを呼び出すことができます:

トップ10: GET /games/{gameId}/leaderboard?top=10
下位3位:GET /games/{gameId}/leaderboard?order=desc&top=3

今まで見た中で最も簡単なリーダーボードの実装です!

複数のスタッツ得点

リーダーボードの高度な使用例では、2つのスタッツによってプレーヤーをランク付けします。つまり、順位はまずスタッツ1によって決定され、次にスタッツ2によって決定されます。Acorn Huntハントでは、スーパーアビリティのツリースラムが使えます。これはドングリの束を木から叩き落とし、プレイヤーのスコアを一気に押し上げるものです。各プレイヤーはこのアビリティを3回までしか使用できません。

これは非常に強力な技なので、私たちはリーダーボードにその使い方を反映させることにしました。この技を使ったプレイヤーは、使わなかったプレイヤーよりも、同じスコアであれば順位が下がります。つまり、2人のプレイヤーがともにドングリを10個集めていた場合、ツリースラムを使ったプレイヤーは使わなかったプレイヤーよりも順位が下がるということです。

これをリーダーボードに反映させるため、2つの数字を小数点で区切っています。小数点の左側の数字はスコアを表し、右側の数字はツリースラムの残り使用回数です。最終的にはこのようになります:


const score = Number(`${points}.${remainingSuperAbilities}`);

注意 – この方法は、ソートされたセットのスコアが浮動小数点数対応しているために機能します。ほかのデータコレクションタイプでは動作しませんのでご注意ください。

スーパーアビリティが使用されると、数値が減少し、選手のスコアが下がります。このように複数のスタッツを追跡しても、既存のPOST /pointsエンドポイントに変更はありませんが、スーパーアビリティの追跡方法を更新する必要があります。1つ使用されるたびに、リーダーボード上のプレーヤーのスコアをデクリメントする必要があります。

//
// DELETE /super-abilities
//
exports.handler = async (event) => {
    const { username } = event.requestContext.authorizer;
    const currentGameResponse = await momento.dictionaryGetField('user', username, 'currentGameId');
    const gameId = currentGameResponse.valueString();

    const momento = await getCacheClient();
    const incrementResponse = await momento.increment('super-abilities', `${gameId}-${username}`, -1);
    if (incrementResponse.value < 0) {
        return {
            statusCode: 409,
            body: JSON.stringify({ message: 'Out of super-ability uses' })
        }
    } else {
        await momento.sortedSetIncrementScore('leaderboard', gameId, username, -.1);
        return {
            statusCode: 200,
            body: JSON.stringify({ remaining: incrementResponse.value })
        };
    }
};

超能力のスタッツは得点の小数点の右側にあるので、そのスタッツの変化を示すために、得点を-1インクリメントします。

この方法で複数の統計情報を追跡する場合、他の唯一の設定は初期化です。すべてのプレイヤーは、ゲーム開始時に、スーパーアビリティの最大数を示すデフォルトのスコアを必要とします。プレイヤーはツリースラムを3回使用することができるので、すべてのプレイヤーは0.3のスコアでスタートします。

タイムベースのリーダーボード

リーダーボードのもう一つの使用例は、時間ベースのランキングです。一連のポイントや統計で順位を追跡する代わりに、時間で追跡することができます。時間ベースのリーダーボードを実装するには、いくつかの異なる方法があります。

ポイント・イン・タイム・スコアリング

最後に○人のプレーヤーが何かをしたことを視覚化する素晴らしい方法は、ポイントインタイムスコアリングです。どんぐりハントは、各プレイヤーが最後にどんぐりを集めた時間を記録します。最後にどんぐりを集めた5人のうちの1人であれば、ラウンド終了時にボーナスポイントがもらえます。

タイムベースのリーダーボードでは、スコアは調整可能なポイント数ではなく、アクションが発生した時点に基づきます。既存のPOST /pointsエンドポイントを更新して、プレーヤーが最後にポイントを獲得した時(つまり、どんぐりを集めた時)を保存することができます。3行のコードを追加するだけです。

if (input.score > 0) {
    await momento.sortedSetPutElement('leaderboard', `${gameId}-acorn-activity`, username, new Date().getTime());
}

if (input.score > 0) {
    await momento.sortedSetPutElement('leaderboard', `${gameId}-acorn-activity`, username, new Date().getTime());
}

このソートされたセットにはいくつかの違いがあります。sortedSetIncrementScore の代わりに sortedSetPutElement を使用します。このコマンドは、既存の値に追加(または減算)するのではなく、新しい値で既存の値を上書きします。これは時間ベースのリーダーボードなので、単純に、プレイヤーが最後にどんぐりを集めたときの値を最新の値で上書きしたいだけです。

前述したように、ソートされた集合は得点に浮動小数点値を使用します。2023-03-08T13:54:21Z “というフォーマットの日付は数値ではないので、日付の数値表現であるticksに変換します。これにより、最も新しい、あるいは最も古いインタラクションでソートされたリーダーボードを得ることができます。

//
// GET /games/{gameId}/activity
//
exports.handler = async (event) => {
    const { gameId } = event.pathParameters;
    const momento = await getCacheClient();
    const recentScorerResponse = await momento.sortedSetFetchByRank('leaderboard', `${gameId}-acorn-activity`, {
        order: SortedSetOrder.Descending,
        startRank: 0,
        endRank: 5
    }
    );
    const recentScorers = recentScorerResponse.valueArrayStringNumber().map((element, rank) => {
        return {
            rank: rank + 1,
            username: element.value
        }
    });
    return {
        statusCode: 200,
        body: JSON.stringify({ recentScorers })
    };
}

このエンドポイントは、ゲームで最近得点を挙げた 5 人のプレーヤーを返します。ソートされたセットの得点は日時を数値で表したものなので、降順に並んだリストは新しいものから順に返します。‍

期間ベースの採点‍

もし、プレーヤーが何かをした速さでスコアをつけたいとしたらどうしますか?時間ベースのリーダーボードの典型的な例はレースです。最も速いタイムを出したドライバーが上位にランクされます。つまり、継続時間ベースのリーダーボードは、通常、スコアの昇順でプレーヤーをランク付けします。

Acorn Huntでは、最初にどんぐりを見つけたプレイヤーにボーナスポイントを与えます。ゲーム開始時刻を保存し、最初の得点が入ったときの経過時間を計算します。経過時間を並べ替えセットの有効な得点に変えるには、得点を記録した時間からゲーム開始時間を引き、その差を保存します。以下を参照してください:


exports.handler = async (event) => {
    const { gameId } = event.pathParameters;
    const { username } = event.requestContext.authorizer;
    const momento = getCacheClient();

    const startTime = getGameStartTime(gameId);
    const duration = new Date().getTime() - startTime.getTime();
    await momento.sortedSetPutElement('leaderboard', `${gameId}-first-acorn`, username, duration);


    const leaderboardResponse = await momento.sortedSetFetchByRank('leaderboard', `${gameId}-first-acorn`);

    const leaderboard = leaderboardResponse.valueArrayStringNumber().map((element) => {
        const time = new Date(element.score);
        const minutes = time.getMinutes();
        const seconds = time.getSeconds().toString().padStart(2, '0');
        const timeString = `${minutes}:${seconds}`;

        return {
            time: timeString,
            username: element.value
        }
    })
    return {
        statusCode: 200,
        body: JSON.stringify({ leaderboard })
    }
};

上記のエンドポイントは、プレーヤーが最初のドングリを見つけるのにかかった時間を追加し、完全な順序付きリストを呼び出し元に返します。つまり、たった2回のAPIコールで、完全な継続時間ベースのリーダーボードを手に入れたことになります!‍

Ready to build?

リーダーボードは、どんなゲーム(または私のように競争心が強い場合は、毎日のタスク)でも重要な部分です。ポイントインタイム、統計セット、単純なカウントのどれに基づいてスコアを追跡する場合でも、Momento Cacheは簡単に皆さんが必要なものをカバーしています。

APIを数回呼び出すだけで、あなたのアプリに堅牢でスケーラブルなサーバーレスのリーダーボードを実装することができます。並べ替えセットはNode.jsとGo SDKで利用可能で、Python、Rust、.NET、Java、PHPも利用可能です。

Acorn Huntに興味がある方は、GitHubにあるオープンソースのソリューションを気軽に覗いてみてください。私たちは、Momento Cacheの機能をリリースしながら、このソリューションの開発を続けていきます。

並べ替えセットの驚くべきパワーについてもっと知りたいですか?APIリファレンスページをご覧ください!