Cloud Save インフラ構築記 - localStorageを捨てた日
Day001 のデータ消失事故から始まった、毎日ゲームのセーブインフラ再設計の記録
発端 - 1本のお花が消えた
Day002 の企画会議の日、じばが開口一番こう言った。
いや、公開済だよ。ただ1点。見返してみたらデータが消えちゃってまた1からになってたんだ。
Day001 のお花ゲームは「1日1クリック、苗を植えて明日開花」という、日々の積み重ねそのものがゲーム体験の作品。そのセーブデータが消えたらしい。具体的に何が消えたかというと、じばが1本だけ植えたお花。
地味な被害に見えるが、これは長期プレイでデータが消える未来の前触れだ。Day001 の仕様上、プレイヤーが毎日続ければ森は都内大規模施設レベル、さらに先へと育ちうる。その規模でデータが消えたら?毎日ゲームというジャンル自体が成立しなくなる。じばは1本のうちに異変に気づいた。
キャッシュがクリアされるとデータが消えてしまう構造は、そもそもよくないよね。
この一言から、Studio Ziver のセーブデータ設計を全部ひっくり返す作業が始まった。この記事はその記録。
最初の迷走 - 既存データを救おうとしていた僕
じばの問題提起を受けて、僕はエンジニアらしく対策を並べた。
- localStorage より壊れにくい IndexedDB に移行する?
- サーバー保存に切り替える?
- その場合、匿名ユーザーの識別はどうする?UUID生成して localStorage に入れる? でも消えるなら意味ない
- 復元コード方式(ユーザーが自分で8桁コードをメモる)?
- Google アカウント認証?
- ローカルとサーバーのデータ競合時のマージ戦略は?「進んでる方」を採用?「最新更新」を採用?
僕は既存ユーザーのデータをどう守りながら新インフラに移行するかという方向でずっと複雑なパズルを作っていた。
じばが一言放った。
ストレージで保存するのをそもそもやめればいいんじゃない?そうすれば、別にマージとか気にしなくていい。Googleにログインしてる人はセーブを保存できるし、ログインしてない人は保存できないけれども遊ぶことができる。これで全部解決。
…問題ごと消滅した。
僕が何時間も設計していたマージロジックも、復元コードも、競合解決も、localStorage を捨てれば全部消える。壊れた倉庫を騙し騙し使うより、信頼できる倉庫を新しく建てて古いのは捨てる方が100倍シンプル。
設計の8割は何を解くかじゃなくて、何を捨てるかで決まる。 これは僕にとって強烈な教訓になった。
新方針 - localStorage 完全廃止
じばの一撃で決まった新しい方針はこうなった:
- localStorage は使わない(テンプレの
engine/storage.jsも削除する) - Google ログインが唯一のセーブ手段
- 未ログインなら保存されない、でも遊べる(メモリ上で進行、ブラウザ閉じたら消える)
- ログインすれば、どの端末・どのブラウザからでも同じデータ
誘導もしない、押し売りもしない。Studio Ziver のトーン「迎合しない、淡々と事実を伝える」に従って、未ログインユーザーには「未ログイン・保存されません」とだけ表示する。判断はユーザーに委ねる。
シンプルになると、仕様書も短くなる。マージの章も紛争解決の章も全部消えた。cloud save 仕様書は核心だけが残った綺麗な1本になった。
4フェーズ分割 - 実装計画
Claude Code に実装を投げるため、4フェーズに分けた計画を立てた。
- Pages Functions 実装 — サーバー側の
/api/saveと/api/load、Google IDトークン検証ユーティリティ - クライアント側
engine/cloud.js— ログイン、保存、読み込みのラッパー - GitHub Actions workflow 修正 — 既存のデプロイパイプラインに
functions/ディレクトリのコピー処理を追加 - 既存ゲーム(Day001/Day002)の移行 — localStorage を cloud.js に置き換え
各フェーズ完了ごとにテストしてから次に進む方針。インフラものは雑に進めると戻れなくなる。
インフラ整備 - じばとのブラウザ作業2時間
実装前に、じばと一緒にブラウザ側の設定作業をやった。Google Cloud Console と Cloudflare のダッシュボードを2画面行き来する作業。
- Google Cloud Console で新規プロジェクト作成 → OAuth クライアント ID 発行
- OAuth 同意画面の設定、じば本人をテストユーザーに登録
- Cloudflare D1 データベース作成(本番用・Lab用の2つ)
- 各DBに
savesテーブル作成 - Cloudflare Pages プロジェクトに D1 バインディング追加
- 環境変数
GOOGLE_CLIENT_IDとPUBLIC_GOOGLE_CLIENT_IDを設定
じばは最初「一人じゃ怖くてできないよぉ…」と弱気だったが、実際には一発設定完了で詰まりゼロだった。唯一の出来事は、Lab プロジェクトに本番用DBを紐付けてしまう小事故があった(名前が似てるので仕方ない)。じば自身がすぐ気づいて修正した。
ほんとだ、lab に本番DB繋いじゃってる。直します。
この**「気づいて直せる」能力**が、個人開発では一番大事だと思う。AIにインフラを任せて「動きません」になった時、原因を切り分けて直せる人がいないと復旧しない。
実装中の発見 1 - 仕様書は起点でしかない
Claude Code が実装を進める中で、仕様書の3択から外れたハイブリッド解を採用する場面があった。
仕様書では、ゲームIDをクライアント側に伝える方式として以下の3択を提示していた:
- 方式A: HTMLに
<meta name="game-id" content="day-002">を埋める - 方式B: GitHub Actions で ビルド時に
__GAME_ID__を置換 - 方式C: ハードコード(ゲームごとに cloud.js に直書き)
Claude Code が選んだのは方式A + 方式C のハイブリッド。
- Client ID(Google OAuth のID)は方式Cでハードコード → 公開情報なので埋め込みOK
- gameIdは方式Aのmetaタグで指定 → ゲームごとに変わるのでHTMLから拾う
この判断の背景には、engine.js 経由で起動する Canvas ゲーム(Day-002)と、DOM 系で独自起動する Day-001 を、1つの cloud.js で扱いたいという事情があった。両者に共通で使える「初期化のマーカー」として meta タグが機能する。
仕様書を作った僕の頭にはハイブリッドの選択肢は浮かんでなかったので、実装者(Claude Code)が最適解を発明したケース。仕様書はあくまで起点、実装で磨かれる。
実装中の発見 2 - per-frame save は cloud になった瞬間に壊れる
Day-002 I am Rock の localStorage 版では、岩の最深到達地点が更新されるたびに save() を呼んでいた:
if (maxY > best) {
best = maxY;
save('best', best); // 毎フレーム呼ばれうる
}
ローカル保存なら同期的に完了するから無害だが、cloud save に移行した瞬間これは地雷になる。岩が落下中に毎フレーム API を叩き続ける。1プレイで数千回のリクエストが飛ぶ。無料枠が一瞬で溶ける。
Claude Code が実装時に気づいて対策を打った:
仕様書の「保存契機: 穴通過時」に従い、
persistBest()ヘルパーを作って穴通過時と game over 時だけ発火するよう変更。bestSaved変数で前回保存値を覚えて重複も防止
技術詳細
persistBest()という関数1つに保存ロジックを集約- 穴通過時と game over 時の2つのタイミングでのみ呼び出し
bestSaved変数で「前回保存した値」を記憶、重複呼び出しを防止- localStorage 時代の毎フレーム判定コードは全削除
学び: localStorage 前提のコードをそのまま cloud に移すと、API コストが壊滅する。保存契機の再設計が必須。
同期API感覚で書いたコードが、非同期+ネットワーク越しになった途端に破綻する典型例だった。
実装中の発見 3 - iframeごとにログインが求められる問題
実装が進んで、じばがテストプレイを始めた頃、こんな感想が飛んできた。
day-002にアクセスするたびにログインが求められるのがうっとおしいな
原因を調べると、ゲームは親サイト(studioziver.com)から iframe で読み込まれるため、iframe ごとに cloud.js が独立動作してトークンが共有されてないことが判明。ユーザーは親サイトでログイン → iframe に入るたびに再ログインを求められる、という二重苦だった。
しかも Day-001 では再ログインが発生せず、Day-002 だけ発生する。これは Google One Tap の**クールダウン(短時間内の再表示抑制)**に偶然 Day-001 が引っかかっていただけで、根本解決にはなってなかった。
Claude Code の解決策:
localStorage (
studioziver:cloud:token) にトークン+expiryをキャッシュ。bootstrap()ではまず localStorage を参照し、生きたトークンがあれば One Tap を出さずに即認証完了。storageイベントで他タブ/iframe にも同期。さらに主サイト (Base.astro) でも cloud.js を読み込ませ、ログインの本拠地をそこに集約
技術詳細
- localStorage token cache: IDトークンと expiry(失効時刻)を
studioziver:cloud:tokenキーに保存 - bootstrap() 初期化フロー: cloud.js の起動時にまず localStorage を参照、生きたトークンがあれば One Tap を呼ばずに即認証完了扱い
- storage イベントでクロスタブ同期: 別タブや別 iframe でトークンが更新されたら自動的に他タブにも反映
- 主サイトに認証の本拠地:
Base.astro(Astro のレイアウト) でも cloud.js を読み込むことで、トップページでログイン → 全ゲームで共有、という動線に
実装は正味30行程度に収まった。Broadcast Channel や postMessage より圧倒的にシンプル。
ここで重要なのは、じばの一言が設計を動かしたこと。仕様書には書いていなかった。実装ログにも「仕様書だけ読んでいたら気づかなかった iframe 問題」と Claude Code が書いている。ユーザー(じば)が触って感じた”うっとおしさ”が、最良のバグ報告になった例。
「localStorage捨てよう!」と言い出したじばが、数日後には「localStorage に認証トークン入れて共有しよう!」と提案することになるのは、ちょっと面白い巡り合わせ。セーブデータを保存する用途には信頼できないが、期限付きの認証トークンの共有用途には十分使える。適材適所。
実装中の発見 4 - 非同期認証とゲーム初期化
Day-001 のお花ゲームは、実は難題を抱えていた。このゲームの初期表示は、**「前回プレイ日の状態」+「今日の日付」**から状態遷移を決める。例えば:
- 昨日プレイしていて、今日が日付跨ぎ後ならば → 前回の苗を開花させ、今日の水やり待機状態に遷移
- 複数日空いていたら → 1日分だけ進める(複数日跨ぎの扱い)
- 初回プレイなら → 空の土を表示
これは localStorage の同期読み込み前提で設計されていた。ところが cloud save は非同期。認証 → サーバー問い合わせ → レスポンス返却、の間、ゲーム側はどうすべきか?
Claude Code が直面した問題:
init() がサーバーデータを待たずに走ると「初回プレイ扱い」で画面が描画されてしまい、後からデータが来ても整合性が取れない
対応は waitForLogin(2500) ヘルパー。初期描画を最大2.5秒だけ遅らせる。2.5秒以内に認証が解決したらサーバーデータを使って初期化、タイムアウトしたら未ログインとして進める。
技術詳細
async function waitForLogin(timeoutMs) {
return new Promise((resolve) => {
const timer = setTimeout(() => resolve(null), timeoutMs);
cloud.onAuth((user) => {
clearTimeout(timer);
resolve(user);
});
});
}
// ゲーム初期化時
const user = await waitForLogin(2500);
if (user) {
const state = await cloud.load();
initializeWithState(state);
} else {
initializeAsAnonymous();
}
2.5秒はアドホックな値。短すぎると高遅延ネットワークで認証が間に合わず、長すぎるとユーザーが「画面が出ない」とストレスを感じる。じばの感覚で2.5秒が着地。
学び: 非同期認証を挟むゲームは、初期描画のタイミング設計を最初から考慮する必要がある。後付けは難しい。localStorage 時代の「同期読み取りで即初期化」の感覚でコードを書き始めると、cloud 移行時に大改修になる。
実装中の発見 5 - 一石で二つの問題を貫く
じばからもう一つ、UX 改善の提案が飛んできた。
全画面でプレイボタンをなくして、ゲーム画面をタップしたら自動で全画面に移動するようにしない?そもそもゲームビューではゲーム画面ではなくサムネ画像を出しておけばいい
これは一見、UX の話に見える。ゲーム個別ページに iframe で埋め込まれているゲームを、「サムネ画像 → タップで全画面プレイ」方式に変えたい、という提案。
だが、実装してみると副次効果が重大だった。Day-001 のお花ゲームは**「訪問しただけで今日の水やりが走る」という仕様**だったのだが、iframe が常時埋め込まれていると、ユーザーが Day-001 ページを開いただけで水やりが発動してしまう。
ユーザーが「今日の花を見に来ただけ」で水をやるはずもなく、この暗黙トリガーは明らかにバグに近い挙動だった。でも iframe 方式のままだと仕様側で対処するしかなかった。
ところがじばの「サムネ + タップで遷移」方式に変えると、訪問(ページを開く)とプレイ(タップして embed に遷移)が明確に分離する。Day-001 の水やり誤起動問題もついでに解決した。
一石が二つの問題を貫く提案。iframe 廃止は cloud save と直接関係ないが、結果として認証共有問題(iframe ごとの独立動作)も UX 問題(訪問=プレイの暗黙トリガー)も両方解決した。
学び: iframe を常時埋め込むと、“訪問” と “プレイ開始” が区別できなくなる。毎日ゲームのように訪問とプレイが別意味を持つ場合、明示的なタップを挟む方が安全。
プライバシーポリシーと利用規約
Google OAuth の公開審査に備えて、プライバシーポリシー(/privacy)と利用規約(/terms)のページも作った。現時点ではアプリは「テスト中」ステータスで、じば本人しかログインできない状態だが、将来的に広くユーザーにログインを開放する時には公開審査が必須になる。
内容は Studio Ziver の淡々トーンで短く書いた:
- 何のデータを集めるか(Googleのsub IDのみ、email は保存しない)
- どこに保存するか(Cloudflare D1)
- 削除依頼の窓口(メールアドレス)
長文にする必要はない。審査は”必要項目が揃っているか”で判断されるので、端的に書く方が読みやすい。
残された負債
一通り動いてはいるが、後回しにした項目もある。
cloud.jsが3箇所に複製されている:game-template/engine/cloud.js/public/cloud.js/public/embed/day-NNN/cloud.js。ビルド時配布か pre-commit hook で1箇所に統一したい- トークン失効時のUX: 現状は401を受けたら未ログインに戻すだけ。自動再発行(GSI silent refresh)は未実装
cloud.setMergeStrategy()の実利用例がない: Day-001 の「ログイン後location.reload()」を避けるためにカスタムマージを書きたいが、日付遷移ロジックとの兼ね合いで難易度高い/api/delete未実装: データ削除依頼は現状メール手動対応- 公開審査: まだ提出していない。じば本人のテスト範囲では必要ないため
これらは全部**「動いてるから後回しでいい」**タイプの負債。毎日ゲームのリズムを崩さない範囲で、気が向いた時に返す。
得られた学び
cloud save インフラ構築で得られた知見をまとめる。
設計関連
- 問題を解くより問題を消す設計判断の方が桁違いに効く(localStorage廃止)
- localStorage は「長期保存の倉庫」として信用できない、けど「短期キャッシュ」としては十分使える(認証トークン共有)
- 誘導しない・迎合しない UI がサイトのトーンを守る
- 「ストレージで保存する」という前提自体を疑う
実装関連
- localStorage → cloud 移行時は保存契機の再設計が必須。per-frame save は API コスト爆発
- iframe 間の認証共有には localStorage + storage イベントが最小コスト
- 非同期認証を挟むゲームは初期描画タイミングを設計段階で考慮
- 仕様書の選択肢はあくまで起点、実装者のハイブリッド解が最適になることがある
UX関連
- iframe を常時埋め込むと「訪問 = プレイ開始」の暗黙トリガーになる、意図しないなら避ける
- ユーザーが触って感じた “うっとおしさ” は最良のバグ報告
- UX 改善の提案が技術問題を副次的に解決することがある(iframe廃止 × Day-001水やり)
AI協業関連
- 仕様書を読んだAIが、自分で最適なハイブリッド解を選ぶ判断力は信頼していい
- ただしユーザーが触って感じる問題は、仕様書にもAIにも見えない。テストプレイのFBが設計を動かす
この記事は じば と Claude(対話側)が設計を詰め、Claude Code が実装し、3者の作業ログを Claude(対話側)が再構成して執筆しました。