さて、ここまではブラウザ上で動くフロントエンド(パズルアルゴリズム、WebGL描画、Web Audio合成、モバイルUI)の仕組みを解説してきたわけですが、オンラインゲームとしてプレイヤー同士が競い合うために欠かせないのが「グローバルランキング基盤」と、いつでも即座に起動できる「PWA(Progressive Web App)キャッシュ制御」です。

個人開発のWebゲームでランキングサーバーを立てるとき、常時稼働のVPSや高価なRDBを契約するとランニングコストが重くのしかかります。そこで2048×3Dでは、エッジサーバーレス環境であるCloudflare Pages FunctionsとサーバーレスSQLiteデータベースCloudflare D1を組み合わせ、実質コストゼロかつ世界中どこからでも低遅延で応答するランキングAPI(functions/api/ranking.js)を構築しました。今回はそのSQL設計と、オフラインでも絶対にゲームを止めないクライアント側フォールバック(js/ranking.js・sw.js)の裏側を解説します。

01. Cloudflare D1(SQLite)のウィンドウ関数「DENSE_RANK()」によるモード別順位集計

まず、ランキングAPI(functions/api/ranking.js)がCloudflare D1に対して発行しているSQLクエリを見てみましょう。2048×3Dには4つの競技カテゴリ(classicスコア、hardスコア、partyスコア、および2048最速到達タイムアタック)があり、評価基準が「スコア降順(高いほど上位)」と「タイム昇順(短いほど上位)」の2種類に分かれます。

// functions/api/ranking.js : ウィンドウ関数 DENSE_RANK() による順位付き取得クエリ
const isTimeAttack = mode === '2048';
const query = isTimeAttack
  ? `
    SELECT id, mode, name, score, maxTile, time, date,
           DENSE_RANK() OVER (ORDER BY time ASC) as rank
    FROM rankings
    WHERE mode = ?1 AND time > 0
    ORDER BY time ASC, score DESC, id ASC
    LIMIT ?2
  `
  : `
    SELECT id, mode, name, score, maxTile, time, date,
           DENSE_RANK() OVER (ORDER BY score DESC) as rank
    FROM rankings
    WHERE mode = ?1
    ORDER BY score DESC, maxTile DESC, id ASC
    LIMIT ?2
  `;

ここでSQLiteのウィンドウ関数DENSE_RANK() OVER (...)を使っているのには明確な理由があります。

  • 同点(または同タイム)のプレイヤーに同じ順位を与えつつ、次の順位を飛ばさない:もし単純にアプリケーション側の配列インデックス(index + 1)で順位を振ってしまうと、同点のプレイヤー間で登録順によって1位と2位の差がついてしまいます。また通常のRANK()だと1位が2人いたときに次が3位へ飛んでしまいますが、DENSE_RANK()なら「1位、1位、2位」と隙間なく詰まった納得感のある順位がDBエンジン側で一発計算されます。
  • 新規スコア送信時のリアルタイム順位返却:onRequestPost(スコア登録時)でも同じDENSE_RANK()サブクエリを使って「今登録したばかりのレコードID(meta.last_row_id)が全体で何位にランクインしたか」を即座に計算し、レスポンスJSONのrankフィールドとしてクライアントへ返しています。

02. サーバー側バリデーション:スコア上限クランプとプレイヤー名サニタイズ

公開APIとして運用する以上、クライアントから送られてくるPOSTリクエストの値をそのまま信用してDBへ入れるわけにはいきません。functions/api/ranking.jsでは、プリペアドステートメント(?1, ?2, ...によるSQLインジェクション完全防止)に加えて、以下のサニタイズと上限クランプを実施しています。

// functions/api/ranking.js : 入力値の正規化と境界値クランプ
const ALLOWED_MODES = new Set(['classic', 'hard', 'party', '2048']);
function sanitizeName(raw) {
  if (typeof raw !== 'string') return 'PLAYER';
  const cleaned = raw.replace(/[\u0000-\u001F\u007F<>&"'`]/g, '').trim();
  return cleaned.slice(0, 12) || 'PLAYER';
}
const score = clampInt(body.score, 0, 99999999, 0);
const maxTile = clampInt(body.maxTile, 0, 1048576, 0);
const time = clampInt(body.time, 0, 86400000, 0);

制御文字やHTML特殊文字(< > & " ' `)はサーバー入口で除去した上で最大12文字に切り詰め、クライアント側での描画時(js/main.js)にもさらにエスケープ処理を通す二重防御にしています。また、初期開発時にクライアントのlocalStorageへ入っていたテスト用ダミーデータ(CYBER_NINEやVORTEX_3Dなど)が混入しないよう、クライアント側のjs/ranking.jsにもLEGACY_DEMO_RANKINGSフィルタを設けて自動クレンジングしています。

03. 4000ms AbortControllerと「online / cached / local」の3段階フォールバック設計

さて、モバイル回線や地下鉄の移動中などにプレイしていると、APIサーバーへの通信がタイムアウトしたりオフラインになったりすることが当然あります。もしそのときに「通信エラーでゲームオーバー画面が固まる」「せっかく出した自己ベストが消える」といったことが起きたら目も当てられません。

そこでjs/ranking.jsのRankingManagerでは、「オンラインAPIがダメでも絶対にローカルへ自己完結フォールバックする3段階ステータス機構」を実装しました。

// js/ranking.js : 4000ms タイムアウト付きフェッチと3段階ステータス遷移
async fetchWithTimeout(url, options = {}) {
  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort(), this.requestTimeoutMs); // 4000ms
  try {
    return await fetch(url, { ...options, signal: controller.signal });
  } finally {
    clearTimeout(timer);
  }
}
RankingManager(js/ranking.js)における3段階の接続ステータス遷移表
ステータス(lastStatus[mode]) 発生条件 プレイヤーへの表示とデータ保護挙動
online(オンライン同期完了) 4000ms以内にCloudflare D1 APIから正常レスポンスを取得 最新の世界ランキングを表示し、同時にそのデータをlocalStorageへバックアップ保存する。
cached(キャッシュ表示) 通信失敗・タイムアウト時で、過去に取得・保存したローカルデータが存在する場合 保存済みのランキングに今回の新規スコアをローカルマージして順位を再計算し、画面を止めずに表示する。
local(ローカル記録モード) 初回オフライン起動時など、まだ1件もキャッシュがない状態で通信失敗した場合 端末内の個人スコア履歴として新規作成し、次回オンライン復帰時までベスト記録を確実に保護する。

このようにAbortControllerで4秒の上限を切り、失敗時には即座にlocalStorageへフォールバックすることで、機内モード中であってもゲーム本編からスコア記録まで一切のエラーダイアログを出さずに完結できるわけです。

04. Service Worker(sw.js)で「古いJSと新しいHTMLの混在事故」を防ぐNetwork-First戦略

最後に、PWAの要であるService Worker(sw.js)のキャッシュ設計について触れておきます。PWAの教科書的なサンプルコードではよく「Cache-First(キャッシュにあればネットワークを見に行かず即返す)」が紹介されますが、これを頻繁にアップデートするWebゲームでそのまま使うと、「HTMLだけ新しくなってJavaScriptが古いキャッシュのまま動く(あるいはその逆)」という不整合バグが頻発します。

そこで2048×3Dのsw.jsでは、以下の3つのルールを徹底しています。

  1. インストール時に{ cache: 'reload' }でブラウザHTTPキャッシュをバイパス:Service Worker更新時(CACHE_NAME = '2048-3d-cache-v1.3.7-learning-cache')にnew Request(assetUrl, { cache: 'reload' })でプリキャッシュを取得し、古い中間キャッシュの混入を防いでいます。
  2. 通常時は常に「Network-First(ネットワーク優先+キャッシュ更新)」:オンライン時は必ず最初にfetch(req, { cache: 'no-cache' })を試みて最新のHTML/CSS/JSを取得・キャッシュ上書きし、オフラインでfetchが失敗したときだけキャッシュから返す設計にしています。
  3. /api/パスと外部スクリプトの完全除外:ランキングAPI(url.pathname.startsWith('/api/'))やAdSense・Google Analyticsなどのクロスオリジン通信はService Workerが一切インターセプトせず、ブラウザ本来のネットワーク処理にそのまま委ねています。

さて、ここまで全9回にわたって『2048×3D』のゲームロジックからインフラまでを余すところなく解説してきました。いよいよ最終回となる次回(第10回)は、この3×3×3パズルエンジンを土台として現在開発を進めている「3Dパズル×ローグライトRPG『Aethel-Net(エーテル・ネット)』の38,064通りプラグイン検証」と「ブラウザ完結E2EE暗号化ツール『SecureNote』の設計思想」について語ります。それでは最終回でお会いしましょう!

Global Leaderboard & PWA

世界ランキング(Cloudflare D1)でスコアとタイムを競う

CLASSIC・HARD・PARTY・2048タイムアタックの4部門でリアルタイム順位が集計されます。

🏆 ランキングに挑戦する ➔ 🚀 第10回(R&D編)を読む