私は2026年ワールドカップ向けにサッカー予測ゲームを構築しました。1ヶ月間、104試合、
1人の勝者によるエンディング。正常に動作し、人々が遊び、何も燃え上がりませんでした。

その後、同じアプリを国内リーグに向けました:リーグ・アン、306試合、34試合日、
9ヶ月間。同じデータモデル、同じスコアリング、同じテンプレート。クラッシュはしませんでした。何も
スローされませんでした。すべてのテストがグリーンのままでした。

そしてアプリ内のほぼすべてのプロダクト決定が突然間違っていることが判明しました。

ここで重要な4つを挙げます。いずれも技術的なものではなく、テストスイートでは
現れることはありませんでした。

1. 累積リーダーボードは10月には決着がつく

トーナメントでは、全体順位がゲーム全体です。4週間プレイし、
最後のテーブルがストーリーとなります。

34試合日では、そのテーブルは8週目頃にゲームではなくなります。最初に
うまく始めたプレイヤーは60ポイントリードし、11月に参加したプレイヤーは数学的に除外され、
他の全員が変更できないスコアボードを読んでいる状態になります。プロダクトはまだ動作していました。
ただ、賭けの要素が残っていなかっただけです。

修正はより良いアルゴリズムではなく、時間の第二単位でした:試合日ごとの
リーダーボードで、各週末に独自の勝者がおり、シーズンアワードテーブルで
各プレイヤーが何回の試合日で勝利したかをカウントします。同じポイント、同じスコアリング、異なる切り口。
全体14位のプレイヤーでも今週末に勝つことができ、それが金曜日に戻ってくる要因となります。

行わなかったことに注目してください:リセットなし、ハンディキャップなし、キャッチアップボーナスなし。
人々が現在プレイしているスコアリングシステムに対して遡及的なものは、
順位表への信頼を破壊し、順位表は全体の資産です。

2. スライド式24時間リマインダーが100通のメールになる

リマインダージョブはトーナメント向けに構築されました:「プレイヤーが24時間以内に
予測していない試合がある場合、メールを送る。」試合は毎日少しずつ入ってくるので、
1日1通のメールとして読み取られ、問題ありません。

リーグ・アン試合日は金曜20:45から日曜20:45までです。同じジョブを変更せずに
使用すると、同じ人に週末に3通のメールが送られることになります:金曜に1試合分、
土曜に3試合分、日曜に5試合分。シーズンあたり、プレイヤー1人につき約100通のメール。
それはリマインダーではなく、余分な手順を加えたスパム苦情です。

また、初試合の朝に届くことになり、リーグでの自然な行動とは逆になります:
9試合すべてを1回の座りで、思いついた時に一度に埋めるものです。

そこでジョブは同じコマンド内に2つの分離されたコードパスを持つようになりました。トーナメントは
スライドウィンドウを維持します。リーグは試合日ごとに1通のメールで、その試合日の
最初のキックオフが24〜48時間前
になった時に発火し、試合日でまだオープンな全試合をリストします。

気に入っている部分: reminder_sent テーブルはありません。冪等性はウィンドウ自体から来ます。
「最初のキックオフが24〜48時間前である」は正確に1つの暦日で真であり、cronは1日1回実行されます。
合法的に2通目のメールを生成する唯一のケースは延期で、最初のキックオフがウィンドウに戻る場合で、
移動したカレンダーこそが人々に再度リマインドしたい時です。

トレードオフはcrontabに平易な言葉で書かれています:cronの頻度を2倍にすると
メールも2倍になります。コメントはテーブルより安価で、ミスが発生する場所にコメントがある限りです。

3. 「今後の試合」は「すべての未来の試合」ではない

ダッシュボードはすべての未来の試合をリストしていました。トーナメントでは最大数十枚の
カードで、進捗バッジは「12/18 predicted」と表示され、達成可能に感じられます。

リーグでは306枚のカードと「12/306」で、宿題のように感じられます。

今、リストは次の2試合日に制限されています。微妙な部分はどのようにそれらを選ぶかです。
私の最初のバージョンは未プレイの試合に対して MIN(round_number) を取得していました。
これは実際のリーグでは間違っています。延期が日常茶飯事だからです:
試合日3の1試合が11月に再試合になると、ダッシュボードを試合日3と4に固定し、
実際の週末を隠すことになります。ウィンドウは最も近いキックオフ時間に従い、
ラウンド番号ではなく、延期された試合は新しいスロットが来ると自動的に再表示されます。

4. トーナメントでは常に何かすることがある

ワールドカップの4週間は4週間の継続的な注意です。9ヶ月間はそうではありません。
リーグシーズンの大部分で、エンゲージメントプロダクトはユーザーが存在を忘れることと競合します。

私が構築した2つの瞬間は、どちらもアプリが既に知っていて捨てていた瞬間でした:
試合日に勝ったプレイヤー(週1回、プライドのピーク)と
次の試合日の予測を完了したプレイヤー(全員、シーズン34回、エンゲージメントのピークと
その後の数日の何もない状態)です。どちらも現在シェアを提供し、モバイルでのネイティブ共有シートと
WhatsAppフォールバックがあり、どちらもプライベートリーグではなくパブリックコンペティションページにリンクします。

最後の詳細は1分の思考を要し、その1分は価値がありました:モバイル共有シートから
リーグ招待コードを共有することはパブリック投稿に着地する可能性があり、
見知らぬ人がいるプライベート順位表は機能ではありません。

実際の教訓

データモデルは正しかったです。EventGamePrediction
既に存在していた round_number カラム。これらのいずれにもマイグレーションは必要ありませんでした。

間違っていたのはケイデンスに関するすべての仮定でした:
ユーザーがどれくらいの頻度で来るか、競争の単位がどれくらい続くか、
どれだけ先まで見通せるか、2つの興味の瞬間の間にどれだけ待つか。
これらの仮定はほぼ決してスキーマにはありません。cron式、クエリ制限、
メール条件、空の状態のコピーなどに散らばっており、まさに「新しい
コンペティション形式をサポートする必要がある」と言った時に誰も見ない場所です。

より長いまたはより短いタイムスケールで動作するプロダクトを再利用しようとしている場合、
コードベースで時間をgrepしてください:すべての 24 hours、すべての setMaxResults
すべての「next」と「current」と「upcoming」。それが本当の差分です。

このアプリは友人や同僚向けの無料予測ゲームで、賭けやお金は一切関与せず、
Symfony、Mercure経由のTurbo Streamsによるライブ順位表、16のロケールで構築されています。
結果を見たい場合は pronoarena.com にあり、
リーグ・アンシーズンは8月21日に始まります。