マガジン

ゲームのデイリーボーナスの実装:時刻の扱い

遊びをつくる

執筆にClaudeを使用しています。

壊れたビー玉コースを四枚の記録カードと虫眼鏡を使って調べ、修理する手元
記事のテーマをもとに制作したAI生成イラスト。

デイリーボーナスとは「1日1回だけ受け取れる報酬」の仕組みです。シンプルに見えますが、「1日」をどう定義するかを誤ると、プレイヤーが数時間で2回受け取れたり、逆に25時間以上待たされたりするバグが起きます。そのほとんどは時刻の扱い方の問題です。 この記事では、デイリーボーナスで起きやすい時刻の罠を整理し、ブラウザゲームで使える実装パターンと判断基準を示します。セーブデータの形式や保存先の選び方は別の問題なので、ここでは「いつ受け取ったか」の記録と判定ロジックに絞ります。

この記事の目次
  1. 「24時間経過」と「日付変わり」は別物です
  2. タイムゾーンの落とし穴
  3. 実装の基本パターン
  4. 保存値の設計で防ぐバグ
  5. 連続ログイン日数と組み合わせるときの注意
  6. 次の行動

「24時間経過」と「日付変わり」は別物です

デイリーボーナスの判定には大きく2つのアプローチがあります。

経過時間方式は「前回受け取った時刻から24時間が過ぎたか」を確認します。実装がシンプルで、サーバー側の処理も軽いのが利点です。一方で、今日の23時に受け取ると、次に受け取れるのは明日の23時になります。毎日少しずつ受け取り時刻がずれていくため、「今日のボーナスを逃した」という感覚が出にくい反面、「毎朝ログインしてボーナスを受け取る習慣」を作りたいゲームには向きません。

日付変わり方式は「前回受け取った日付と今日の日付が違うか」を確認します。たとえば23時59分に受け取っても、0時を過ぎればすぐ次のボーナスが解放されます。連続ログイン日数のカウントとも相性がよく、多くのゲームアプリが採用しています。ただし「日付」の定義を誰の視点で決めるかが重要になります。

どちらが正解ということはなく、ゲームデザインの意図によって選びます。「毎日のルーティンを作りたい」なら日付変わり方式、「ログイン頻度に関係なく公平に配りたい」なら経過時間方式が向いています。

タイムゾーンの落とし穴

ブラウザで new Date() を呼ぶと、プレイヤーの端末が設定しているローカル時刻が返ります。日本のプレイヤーだけを想定するなら問題になりにくいですが、次の2点に注意が必要です。

端末時刻は操作できます。 プレイヤーが端末の時計を変更すると、ローカル時刻に依存した判定は簡単に破られます。報酬の価値が低い個人制作ゲームでは許容範囲ですが、不正対策が必要な場合は、後述のサーバー時刻を使う方法を検討してください。

日付変わりをどのタイムゾーンで定義するかを決めます。 海外プレイヤーも想定するなら、「UTC(協定世界時)の0時」または「固定のオフセット(例:UTC+9)の0時」のどちらかに統一します。プレイヤーごとのローカル時刻を使うと、同じゲームのプレイヤーが異なるタイミングでボーナスを受け取ることになり、将来ランキングやギルド機能を追加したときに混乱の原因になります。

実装の基本パターン

経過時間方式の判定

前回受け取ったときの Date.now()(ミリ秒のUNIXタイムスタンプ)を保存しておき、次回ログイン時に差分を計算します。

前回受け取り時刻を lastClaimedAt(数値)として保存
現在時刻 = Date.now()
経過ミリ秒 = 現在時刻 - lastClaimedAt
受け取り可能 = 経過ミリ秒 >= 86_400_000  // 24時間 = 86400秒

UNIXタイムスタンプは数値なので比較が単純です。保存するのも1つの数値だけで済みます。

日付変わり方式の判定

前回受け取った「日付文字列」を保存し、今日の日付文字列と比べます。

タイムゾーンを決める(例:UTC+9固定)
前回の日付文字列 = lastClaimedDate(例:"2026-10-19")
今日の日付文字列 = 現在時刻をUTC+9で "YYYY-MM-DD" に変換
受け取り可能 = lastClaimedDate !== 今日の日付文字列

JavaScriptで日本時間(UTC+9)の日付文字列を取り出す場合、toLocaleDateString はブラウザ環境によって挙動が変わることがあります。確実なのは、new Date() で取得した getTime()(UTCミリ秒)に9時間分のオフセット(9 * 60 * 60 * 1000)を加算してから Date オブジェクトを作り、getUTCFullYear()・getUTCMonth()・getUTCDate() で年月日を取り出す方法です。ロケール依存の関数を避けることで挙動が安定します。

サーバー時刻を使う場合

端末時刻の操作を防ぎたいときは、APIで現在時刻を取得します。無料で使えるAPIとして worldtimeapi.org などが知られていますが、外部サービスへの依存はダウン時のリスクを伴います。自前のバックエンドがある場合は、そこから時刻を返すエンドポイントを用意するのが安定します。

なお、判定そのものをサーバー側で行うと、クライアント(ブラウザ)からは「受け取り可能かどうか」の結果だけを受け取る形になるため、時刻操作による不正をほぼ防げます。ただし、個人制作のブラウザゲームでサーバーを用意するのはコストがかかるので、用途に合わせて判断してください。

保存値の設計で防ぐバグ

時刻の判定ロジックが正しくても、保存値の設計が甘いと問題が起きます。

型を統一します。 タイムスタンプは必ず数値として保存します。文字列として保存して後から parseInt で戻す実装は、保存値が null や undefined のときに NaN になり、判定が壊れます。保存前に typeof チェックか、デフォルト値(例:0)を使うのが安全です。

初回プレイの扱いを明示します。 lastClaimedAt が存在しない(初回起動)とき、経過時間方式では Date.now() - undefined が NaN になります。「保存値がなければ受け取り可能」というフォールバックを必ず書いておきます。

タイムスタンプと日付文字列を混在させません。 途中でロジックを変更したとき、古い形式のデータが残っているとバグの原因になります。形式を変えるときはマイグレーション処理(古いキーを削除して新しいキーに書き直す)をセットで実装します。

保存先のクリアに注意します。 localStorage はブラウザのデータ消去やシークレットモードで消えます。デイリーボーナスの記録を localStorage に置く場合、プレイヤーがブラウザをリセットすると何度でも受け取れます。これを問題とするかどうかは、ゲームの設計次第です。重要な報酬であれば、アソバースのSDKを使ったクラウドセーブへの保存を検討してください。

連続ログイン日数と組み合わせるときの注意

デイリーボーナスを「3日連続でログインすると追加報酬」のような連続ログイン報酬と組み合わせるケースもあります。このとき、「1日休んだらカウントをリセットする」ロジックには判定タイミングの問題があります。

よくある実装は「前回から2日以上空いていたらリセット」ですが、日付変わり方式では「昨日受け取ったか」を判断するために「今日の日付」「昨日の日付」「前回受け取った日付」の3つを比較する必要があります。「24時間以上空いたか」という経過時間での判定は連続ログインの文脈では誤りになることが多いので注意が必要です。

具体的には、前回受け取った日付を文字列で保存しておき、今日の日付との差を日数で計算します。差が1なら連続、差が2以上ならリセット、差が0なら今日は受け取り済みという3パターンに分岐させます。日数差の計算は、両方の日付をUTC基準のミリ秒に変換してから差を取り、Math.round で日数に丸める方法が安定しています(サマータイムの影響がないUTC基準を使う理由はここにもあります)。

次の行動

時刻の扱い方針(経過時間方式か日付変わり方式か)と、タイムゾーンの基準(UTC固定など)をまず決めてから実装に入ると、後からの修正コストが大きく減ります。動かせるようになったら、端末時刻を意図的に変えてテストし、想定外の受け取りが起きないかを確認してください。クラウドへの保存が必要になった段階では、アソバースのSDKドキュメントでクラウドセーブの対応方法を確認してみてください。