一つの洞窟、すべてのプレイヤー

TUNLが日付だけから日替わりレベルを作る方法と、それを数か月にわたって静かに壊していた二つのバグ。

開発ログ · 2026年9月15日 · TUNL 17.0向けに2026年9月23日更新 · すべての記事

TUNLは、押し続けて推進する洞窟飛行ゲームです。押し続ければ上昇し、離せば落下し、昨日より狭まっていくトンネルの奥へ進もうとします。すべてのプレイヤーが同じ日に同じ洞窟を飛び、UTCの午前0時の境目で別の洞窟に変わります。

レベルファイルはありません。サーバーがマップを配ることはなく、何もダウンロードされず、ゲームは生成したばかりのトンネルを保存することもありません。洞窟は日付の純粋な関数であり、「世界」全体は約四十行の算術です。

この設計は驚くほど多くのものを手に入れます。公平なグローバルランキング、数百バイトで済むゴーストリプレイ、そして共有可能なランの画像です。同時に、最初に見えるよりはるかに厳しい制約を課し、私はそれに気づかないまま二度破りました。この記事は両方の半分についてです。

ファイルではなく関数であるレベル

ほとんどの手続き型ゲームは、ラン開始時にワールドを生成してメモリに保持します。TUNLはそれをまったく保持しません。任意のワールド位置wxにおける通路の中心と半幅は、二つの重なった正弦波から必要に応じて計算されます。

function boundsBase(wx) {
  const p = Math.min(Math.sqrt(wx / 14000), 1);            // wave frequency ramp, sqrt eased
  const g = Math.min(Math.max(wx - 3000, 0) / 90000, 1);   // corridor ramp, slow and linear
  const wA1 = lerp(H * 0.07,  H * 0.12,  g);       // slow wide arc
  const wA2 = lerp(H * 0.035, H * 0.055, g);       // faster shallow ripple
  const wF1 = lerp(0.0025,    0.0048,    p);
  const wF2 = lerp(0.0060,    0.0115,    p);
  const hg  = lerp(H * 0.34,  H * 0.163, g);       // half-gap: 68% -> 33% of the height

  const raw = H / 2
    + wA1 * Math.sin(wx * wF1 + _wavePhase1)
    + wA2 * Math.sin(wx * wF2 + 1.57 + _wavePhase2);

  const cy = clamp(raw, hg + WALL_PAD, H - hg - WALL_PAD);
  return { top: cy - hg, bot: cy + hg };
}

これは骨格です。実際の関数には、後述の深いラン向けの形状モーフのような項がいくつか加わりますが、どれもwxと日付以外は受け取りません。

波が一つではなく二つなのは、一つの正弦波はすぐにリズムとして読み取れ、十秒ほどで面白さが尽きるからです。ゆっくりとした広い弧に、無理数に近い周波数比で速く浅いさざ波を重ねると、決して完全には繰り返さない長い区間が得られます。半幅は先へ進むにつれて、画面の高さの約68%から33%まで狭まり、それをゆっくり行います。最初の区間は開けたホールで何も起きず、その後は直線的に進み、スコア約1,500を過ぎてようやく底に達します。

最初からそうだったわけではありません。通路は当初、スコア233で最も狭くなる平方根の曲線で狭まっており、それは序盤に集中した速い傾斜で、ゲームが難しすぎるというフィードバックが返ってきました。そこで通路とその波の振幅に独自のゆっくりした時計、上のgを与えました。波の周波数は意図的に古い平方根の曲線のままにしました。sin(wx * f)は、wxが大きいとfに非常に敏感なので、ランの途中で周波数のペースを変えると、すでに背後にあるものすべての位相が乱れてしまいます。振幅と幅は安全に遅くできますが、周波数はできません。

重要な性質は形ではありません。boundsBase(wx)がwxだけを受け取るということです。ランの最初のフレームでワールド位置40,000について尋ねても、間に何も生成することなく、どのデバイスでも同一に即座に答えます。

日付が入り込む場所

日付はそのスニペット内のちょうど二つの変数、_wavePhase1と_wavePhase2を通じて入ります。UTC日付は20260915のような整数になり、ハッシュチェーンを通されます。

function seedDailyVariety(dayInt) {
  let h = Math.imul(dayInt ^ 0x9e3779b9, 0x45d9f3b) >>> 0;
  h = Math.imul(h ^ (h >>> 16), 0x45d9f3b) >>> 0;
  h = (h ^ (h >>> 16)) >>> 0;
  _wavePhase1 = (h % 6283) / 1000;                 // 0 .. 2pi

  h = Math.imul(h ^ (h >>> 13), 0xc2b2ae35) >>> 0;
  _wavePhase2 = (h % 6283) / 1000;

  _waveJitterA  = 1 + (draw() * 2 - 1) * 0.08;     // +/- 8% amplitude
  _waveJitterF  = 1 + (draw() * 2 - 1) * 0.08;     // +/- 8% frequency
  _dayArchetype = Math.floor(draw() * DAY_ARCHETYPES.length);
}

連続する日付は1しか違わないので、ハッシュは強く散らさなければ、今日と明日が近い隣人のように見えてしまいます。multiply-xorshiftチェーンがそれを行います。20260915と20260916はまったく無関係な位相を生み出します。

ゆらぎは意図的に小さくしてあります。位相だけではカーブが来る場所は変わっても、その日の感触は変わらず、私は調整に長い時間がかかった基準からどの日もずれることなく、それぞれの日がわずかに違う個性を持つようにしたかったのです。8パーセントは、一週間を通して気づくには十分で、その日が間違っていると感じさせるには足りません。

乱数ストリームは一つではなく四つ

障害物は通路の形とは別の問題です。鍾乳石、コイン、地雷、大砲はそれぞれ独自の独立したジェネレーターを持ち、すべて同じ日から導かれます。

rngStal   = makeRngStream(Math.imul(dayInt ^ 0x5741, 0x2545F491));
rngCoin   = makeRngStream(Math.imul(dayInt ^ 0xC01D, 0x9E3779B1));
rngMine   = makeRngStream(Math.imul(dayInt ^ 0x4D19, 0x85EBCA6B));
rngCannon = makeRngStream(Math.imul(dayInt ^ 0xCA77, 0xC2B2AE35));

単一の共有ストリームの方が単純で、それは間違いだったでしょう。コインの配置は候補位置を拒否することがあります。たとえば鍾乳石に近すぎる位置に着地する場合です。共有ストリームでは、拒否のたびに一つの数を消費し、その後に引かれるすべてがずれるため、配置に失敗したコインが次の地雷を静かに動かしてしまいます。別々のソルトは、四つのシステムを一つの数列の位相がずれたコピーではなく本当に独立したものにし、地雷を一つも動かさずにコインの配置方法を変えられるということです。

日のアーキタイプ

位相とゆらぎは日の形を変えます。その味わいを変えるために、シードは四つのアーキタイプの一つも選び、ゲームにすでにある間隔曲線をスケールします。

アーキタイプ鍾乳石コイン地雷シケイン
クラシック1.001.001.001.00
シケインの日0.851.001.001.35
地雷の回廊1.001.000.751.00
コインラッシュ1.150.721.150.80

これらは間隔の倍率なので、1未満の数はより近いことを意味します。この表に設けたルールは、どの行もクラシックより一様に難しい、または一様に易しいことは許されないというものでした。どのアーキタイプも一つのつまみを押し上げ、別のつまみを引き戻します。コインラッシュははるかに多くのコインを与え、TUNLではそれが通路を広げますが、その代償として鍾乳石が狭くなり地雷が増えます。一日は、良い引きか悪い引きではなく、別の一日として読めるべきです。

そのすべての下にある制約

ここが、ジェネレーター自体より重要だと分かった部分です。

洞窟が共有され、ランキングがグローバルであるため、オブジェクトをどこに配置するかを決めるものはすべて、ワールド位置と日付の純粋な関数でなければなりません。それ以外は何も入り込んではいけません。プレイヤーの画面サイズも、スコアも、たまたまパワーアップが有効かどうかも、どれだけ長くプレイしているかも。

書き出すと当たり前に聞こえます。実際には、書いている最中はまったく合理的に感じられる多くのことを禁じ、違反は自ら知らせてくれません。何もクラッシュしません。ゲームは正しく見えます。洞窟はただ静かに、同じ洞窟ではなくなっているのです。

バグその一: 六つのデバイス、六つの洞窟

障害物の間隔を決める間隔関数は難易度曲線なので、当然現在の難易度を読みます。元のバージョンはグローバルの_progを読んでいましたが、これはプレイヤーの現在位置での難易度です。

スポーンのループはこのようになっています。

while (nextStalWx < scrollX + W + margin) { ... }

これらは画面の右端のわずかに先に障害物を配置します。そのため、あるワールド位置の間隔が抽選される瞬間は、画面幅であるWに依存します。幅の広いデバイスはより先まで届き、その位置をランの早い段階で抽選し、したがって、まったく同じワールドの一区間について、狭いデバイスよりも低い_progで難易度曲線をサンプリングします。

間隔が違う。洞窟が違う。私は六つのデバイスサイズで測定し、ワールド位置938、つまり約スコア15から分岐する六つの異なる洞窟を得ました。共有の日替わりランキングでその地点以降のすべては、同じトンネルではないトンネル同士で比較されていたのです。

修正は、配置される位置をすべての曲線に渡し、「ここ、今の難易度はどれくらいか」ではなく「あそこの難易度はどれくらいか」に答えさせることでした。

function progAt(wx)  { return Math.min(Math.sqrt(Math.max(wx, 0) / 14000), 1); }
function prog2At(wx) { return Math.max(wx - 14000, 0) / 40000; }

中心線のクランプに、同じ種類のバグの二つ目のより小さな例が潜んでいました。WALL_PADは通路を画面端から離しておく余白で、周囲のすべてが画面の高さに合わせてスケールする中、固定の8ピクセルでした。背の高い画面ではその固定パディングは相対的に小さくなり、それがクランプ点をわずかに動かし、それが一つの配置拒否を反転させるのに十分で、それが洞窟を分岐させました。今では他のすべてと同様にHの割合になっています。

両方のバグには名前を付ける価値のある共通の形があります。ワールド位置の純粋な関数にほぼなっている値に、持っていないハードウェアでしか影響しない汚染物が一つ混ざっていたのです。どちらもプレイして見つけることはできませんでした。どちらも今では、複数の画面サイズで洞窟を生成し、結果がバイト単位で同一であることを検証するテストで固定されています。

バグその二: 誰も見たことのなかったコンテンツ

二つ目の失敗は決定性とは何の関係もなく、自分の前提を現実と照らし合わせなかったこととすべて関係していました。

ある深さを過ぎると、通路の幾何学のつまみはすべて上限に達します。それ以上押すとトンネルが本当に通行不能になるからです。その先で変化し続けるのはスクロール速度だけなので、非常に長いランは、凍りついた通路に対して一つの変数がゆっくりと神経質になっていくだけのものでした。それを修正するために、深いラン用の機能群を作りました。二つの波の振幅をシード付きの性格の並び(大きくきれいな掃引、うねる細切れ、ほぼ直線)を通して再配分する形状モーフ、ペースを呼吸させる速度パルス、一息つく場として現れる広い空洞、稀なセットピースとしての岩塊です。

私はそのすべてをワールド位置54,000、スコアで約900でゲートしました。その数字は、プラトーが数学的に始まる難易度曲線から来ていました。幾何学にとっては正しい数字で、プレイヤーにとっては間違った数字でした。

ついに実際のランキングデータをジェネレーターに対して再生したとき、答えは率直でした。事実上、誰もその近くにさえ到達していなかったのです。私は自分を含め、一人のプレイヤーにも見られたことのないコンテンツを作るのに何週間も費やしていました。

そこで私はレッドチームのシミュレーションを作りました。校正された四つの技量層にわたる2,400回のヘッドレスランで、各層が実際にどこまで到達するかを測定します。実際のプレイヤーに一致する層は、600回のランのうち0%でワールド位置30,000に到達しました。「まれに」ではありません。一度もです。すでにゲートを一度30,000まで下げた後でさえ、コンテンツはまだ到達不能でした。それは桁違いに小さいだけの、同じ間違いでした。

ゲートは現在9,000、スコアで約150にあり、シミュレーションによれば実際にかなりの割合のランが到達します。

技量層ゲートに到達するラン
平均3.3%
上級24.7%
エキスパート63.5%

これが安全にできたのは、深いランのすべての機能がモーフされていない同じ位置に対して相対的に制限されているからにほかなりません。モーフの振幅配分は通路の速度に対して上限が設けられているため、モーフされた区間がモーフされていない区間より約3%を超えて多くの揺れエネルギーを持つことはなく、空洞は通路を広げるだけです。そのどれも、どの深さでも、ある位置を手作業で調整した基準より難しくすることはできません。だからゲートを動かすことは、難易度の変更ではなく供給の決定なのです。

一つの要素は意図的に動かしませんでした。頂点寄りの地雷、つまり通路がすでにあなたを強いるカーブの頂点で中心線に向かってスナップするものは、ゲームがズルをしているように読める現実的なリスクを抱えています。それは誰かが実際にデバイスで飛ぶまで、古い深さにとどまります。証明されていない公平性のリスクを750ポイント早めることは、ペース修正の一部ではありません。

決定性が買うもの

その制約のすべては、そうでなければはるかに難しくなる三つのもののために支払われています。

ほとんど何もコストがかからないゴースト

ベストランのリプレイと競うには、通常は入力や障害物の状態を記録する必要があります。ここでは洞窟が日付から再現可能なので、リプレイは時間経過に対する船の垂直位置以外何も必要としません。60ワールドピクセルあたり1バイトで、画面の高さに対して量子化され、これにより完全なゴーストが数百バイトになり、携帯電話で記録したゴーストがタブレットで正しく再生されます。

これは経過時間ではなく移動距離でインデックスされており、コインの種類の一つがスクロールを遅くして数秒かけて戻すため、それが重要です。時間でインデックスされたゴーストは、誰かがそれを拾った瞬間にトンネルからずれてしまいます。

ランの画像であるシェアカード

通路は位置の純粋な関数なので、飛んだトンネル全体を後から再描画でき、帯状に圧縮して、死亡地点と自己ベストを示せます。カードはスコアバッジではなくランの絵であり、それを生成するのに記録はまったく必要なく、到達した距離だけで足ります。

意味のあるランキング

全員が同一の洞窟を飛ぶことが、スコアを比較するすべての基盤です。それはまた、描画幅が画面全体を埋めるのではなく上限を設けられている理由でもあります。障害物の間隔はワールド位置で固定されているため、はるかに幅の広いデバイスでは共有洞窟が比例して速くスクロールして見え、同じ障害物により短い反応時間で立ち向かうことになります。垂直方向の感触も同じ方法で正規化され、重力と推力は基準に対して画面の高さでスケールされるため、軌道はどのデバイスでも1秒あたり通路の同じ割合をカバーします。

もし最初からやり直すなら

ジェネレーターは簡単な部分でした。二つの波とハッシュなど午後一つで済みます。高くついたものはすべて、その周りに巻き付けられた約束、つまり洞窟が同じであるということから来ており、それがすべての将来の機能を、配置に非決定性を漏らさないかという問いに変えます。

私が持ち続けたい教訓はテストの形です。決定性のバグはプレイでは見えず、アサーションでは自明です。六つの画面サイズで最初の数千の位置を生成して差分を取ると数ミリ秒で済み、両方のデバイスバグを書かれたその日に捕まえられたでしょう。コンテンツのゲート設定の失敗にはまったく別の道具、つまりプレイヤーが実際にどこまで到達するかのシミュレーションが必要で、そこでの唯一の本当の教訓は、難易度曲線から導いた数字はプレイヤーについての数字ではないということです。

今日の洞窟は公開中で、UTC午前0時にリセットされます。ブラウザで動き、インストールは不要です。

今日の洞窟を遊ぶ