スマホを 1 台だけ使って、手渡ししながら遊ぶパーティーゲームを作っています。この形式のゲームには、Web アプリではあまり見ない敵がいます。手渡しの最中に、誰かの指がボタンに触れるのです。1 回の誤タップで投票が飛んだら場が白けます。この問題に対処するため、ゲームのルールをぜんぶ transition(state, event) という純関数 1 本に集約し、いまのフェーズで意味を持たないイベントはルール側で黙って無視する形にしました。

この誤タップ対策は、実は境界の引き方の一事例です。乱数やお題の抽選、現在時刻のような非決定的な入力は「生成」として UI 側に置き、transition はその結果を受け取って「検証」だけに専念する——この境界の引き方が、テスト可能性と早期のバグ発見を生みました。reducer 自体の形は目新しくありませんが、境界をどちらに引くかで拾えるバグの種類が変わります。

状態遷移を reducer で書いていて、乱数やタイマーの扱いをどこに置くか迷っている個人開発者向けの記事です。

何を作ったか

mawashite.com というパーティーゲームサイトで、執筆時点 (2026-08-02) で 10 本のゲームがあります。インストール不要・登録なしで、URL を開いてスマホ 1 台を回すだけ。サイトは静的ビルドで、ゲーム画面の部分だけブラウザ側で動くコンポーネントとして読み込みます。ルールは src/lib/engine/ のテンプレートごとに 1 本の遷移関数がすべてを決めていて、状態はフェーズを判別子にした discriminated union です。

export type GameState =
  | { phase: 'setup' }
  | (RoundBase & { phase: 'topicReveal' })
  | (RoundBase & {
      phase: 'talk';
      /** このラウンドの語り順 (プレイヤー index の順列) */
      order: number[];
      /** order の何番目まで進んだか。話者は order[turn] */
      turn: number;
      /** エポックミリ秒の締切。表示側が残り時間を計算する */
      deadline: number;
    })
  | (RoundBase & { phase: 'handover'; order: number[]; nextTurn: number; votes: number[] })
  | (RoundBase & { phase: 'vote'; order: number[]; turn: number; votes: number[]; selected: number | null })
  | (RoundBase & { phase: 'roundResult'; roundVotes: number[] })
  | (RoundBase & { phase: 'finalResult' });

handover は「スマホを次の人に渡している最中」を表すフェーズで、トークから投票への橋渡しに挟まります。

「投票中の状態には votes がある」「トーク中には締切がある」をフェーズ単位で型にしておくと、遷移関数の switch の網羅は末尾の assertNever が守ってくれます。フェーズを 1 つ足すと、case を書き忘れた switch がその場でコンパイルエラーになる、という体験です。

誤タップ対策は「同じ state を返す」の 1 行

この形のいちばんの実利は、現在のフェーズで意味を持たないイベントを、状態を変えずに無視できることです。トーク中に投票ボタンの残像を連打されても、talk フェーズの case は TALK_DONE しか処理しないので、何も起きません。

case 'talk': {
  if (event.type === 'TALK_DONE') {
    const nextTurn = state.turn + 1;
    if (nextTurn < state.order.length) {
      // トーク中はスマホを回さないので、話者は直接切り替わる
      return { ...base(state), phase: 'talk', order: state.order,
               turn: nextTurn, deadline: event.now + state.talkSeconds * 1000 };
    }
    // 全員が語ったら、手渡しフェーズを挟んで投票へ。投票も同じ順でまわす
    return { ...base(state), phase: 'handover', order: state.order, nextTurn: 0, votes: [] };
  }
  return state; // それ以外のイベントはすべて無視
}

ボタン連打・受け渡し中の誤タップ・古い画面からの二重送信を、UI に散らばる個別のガードとしてではなく「フェーズとイベントの対応表」として 1 か所に書けます。UI 側のボタンを disable し忘れても、ルール側が最後の砦になります。

乱数と現在時刻は、イベントに乗せて渡す

transition の中では Math.random()Date.now() も呼びません。乱数が要る場面 (語り順のシャッフル、お題の抽選) は UI 側が結果を作ってイベントに乗せて渡し、エンジンは検証だけをします。UI との接続部は、素の useState にこれだけです。

const [state, setState] = useState<GameState>(() => loadState(slug) ?? { phase: 'setup' });

const dispatch = (event: GameEvent) => {
  setState((prev) => {
    const next = transition(prev, event);
    saveState(slug, next); // sessionStorage への保存もここ (エンジンの外)
    return next;
  });
};

// 乱数と時刻は、イベントを作るこの瞬間に UI 側で確定させる
dispatch({ type: 'BEGIN_TALK', now: Date.now(), order: shuffled });

order はプレイヤー index の順列で、エンジン側は「0..n-1 が 1 回ずつ」だけを確認し、壊れていたら座席順にフォールバックします。

トークの残り時間は deadline (エポックミリ秒) として state に持ち、表示側の useCountdown(state.deadline) が現在時刻と引き算して秒数だけを再描画します。state は 1 秒ごとに変わったりしません。リロードしても deadline は絶対時刻なので、タイマーは正しい残り時間から再開します。sessionStorage の読み書きも上のコードのとおり UI 側の仕事で、エンジンのテストにストレージのモックは出てきません。

2 人で遊ぶと、ルールが数学的に壊れていた

この分離の見返りはテストです。乱数のモックも fake timer も DOM も要らず、「この state にこの event を入れたらこの state」を並べるだけ。その形式のテストが最初に拾った仕様バグが「2 人プレイの縮退」でした。投票には自票禁止ルールがあるので、2 人だとお互いに入れ合う以外の選択肢がなく、毎ラウンド必ず 1 対 1 の同点になります。ゲームとして成立していない。

it('ignores START with fewer than 3 players', () => {
  // 2 人だと自票禁止で毎ラウンド 1-1 の同点になり成立しないので、下限は 3 人
  expect(transition(SETUP, start(['A']))).toEqual(SETUP);
  expect(transition(SETUP, start(['A', 'B']))).toEqual(SETUP);
  expect(transition(SETUP, start(['A', 'B', 'C'])).phase).toBe('topicReveal');
});

対処は「3 人未満の START は無視する」をルール側に入れること。セットアップ画面も人数の下限を塞いでいるので通常この分岐には届きませんが、リロード復帰 (sessionStorage からの復元) という侵入経路が別にあるため、二重に塞いでいます。復元側も同じ思想で、シリアライズはバージョン番号つきの封筒に包む純粋な文字列変換、復元はフェーズごとに構造を検証して壊れていたら null——エンジンには検証済みの state しか入りません。

ハマった点: 語り順が 62.5% のゲームで偏っていた

AI に立場の違うレビュー役を演じさせる仕組みであるペルソナレビューで指摘された問題を 1 つ深掘りします。語り順は「毎ラウンド独立にシャッフル」で実装していました。一見公平ですが、リレー系ゲームの最終手は「積み上がった設定に乗って締める」いちばんおいしい役です。

そして 4 人 3 ラウンド (いちばんよくある卓の代表例) で独立シャッフルすると、3 ラウンドの最終話者が全員別人になる確率は (4×3×2)/4³ = 37.5% しかありません (分母は「各ラウンドの最終話者 4 通り × 3 ラウンド」の 64 通り)。4 人 3 ラウンドではそもそも 1 人は最後があぶれるうえ、62.5% のゲームでは誰かが最後を 2 回以上引くわけです。

直し方の第一候補は「エンジンが前ラウンドを覚えて順番を作る」ですが、そうすると乱数がエンジンに入り、上に書いた分離が壊れます。採ったのは、UI 側が前ラウンドの最終話者を覚えておき、「最後がその人にならない順列」を作って渡す形です。エンジンは今までどおり順列であることだけ検証します。

export function shuffledIndicesAvoidingLast(
  count: number,
  avoidLast: number | null,
  random: () => number,
): number[] {
  const order = shuffledIndices(count, random);
  if (count < 2 || avoidLast === null) return order;
  if (order[count - 1] !== avoidLast) return order;
  // 最後が avoidLast になったら、手前のどれかと入れ替える。
  // これで最後は必ず別人になり、順列であることも保たれる
  const j = Math.floor(random() * (count - 1));
  [order[count - 1], order[j]] = [order[j]!, order[count - 1]!];
  return order;
}

効果を同じ算数で閉じておくと、対策後の最終話者は「前ラウンドの最終話者を除く 3 人の一様分布」になるので、全員別人になる確率は 2/3 ≒ 66.7%。偏りは 62.5% から 33.3% まで下がりました。

ゼロにしないのは意図的で、完全なローテーション制にすると「いつも同じ人の後に語る」ことになり、席替えのないパーティーになるからです。シャッフルの意外性は保ったまま、連続で最後を引くことだけを禁じました。

なお「前ラウンドの最終話者」は UI 側の ref に居るだけなのでリロードで消え、そのラウンドは制約なしのシャッフルに戻ります——公平性の制約は correctness ではなく best-effort、という割り切りです。random を関数で受け取っているのはエンジンと同じ発想で、このユーティリティも固定乱数でテストできます。この語り順が動いている現物は知ったかリレー (全員が嘘の解説をでっち上げるゲーム) です。

まとめ

手渡しプレイの誤タップという泥くさい問題から始めて、「ルールは純関数・乱数と時刻はイベントで注入・検証はエンジン、生成は UI」という置き場所の規律に落ち着きました。2 人縮退も 62.5% の偏りも、この置き場所だったからテストと算数で潰せた、というのが振り返っての実感です。

効いているのは、ルールを読む場所が 1 テンプレートにつき transition.ts 1 本で済むことです。新しいゲームの得点仕様をレビューするときも、過去のゲームのバグを疑うときも、開くのは transition.ts だけで、UI をどれだけ作り替えてもルールの diff はそこにしか出ません。現物は mawashite.com で動いているので、よければ。