「逆さスイカ祭り」って知っていますか?——実はどこにも存在しないお題で、知ったかリレー というゲームがその場で作ります。それを全員が知ったかぶりで語り継ぐ、というパーティーゲームを、僕は Astro 7 と Remotion と VOICEVOX の組み合わせで作りました。インストール不要・会員登録なしで、スマホ 1 台をまわして 3〜8 人が遊べる設計です。執筆時点 (2026-07-28) では「知ったかリレー」のほか「きもちバレ」「くるしいわけ」など 5 本のゲームを公開しています。

サイト自体は Astro 7 の静的ビルドで、ゲーム画面のような動的な部分だけを island (ページの中でそこだけブラウザ側で動くコンポーネント) として読み込む構成です。ホスティングは Cloudflare Pages で、main に push すればサーバー側の設定を何も触らずにデプロイが終わります。ゲームを追加するたびに動かす仕組みを増やしたくなかったので、動的なサーバーサイド機能は最初から使わない方針にしました。この方針を貫くうちに気づいたのは、個人開発の技術選定は「あとから触らずに済むか」を軸に置くと決まる、ということです。個人開発に使える時間は細切れなので、公開したものが手をかけ続けないと動かない形だと、成果ごと風化していきます。だから公開後の自分の手間を減らせる選択をいつも優先しています。

静的サイトや動画生成パイプラインを一人で保守している人、これから同じ構成を検討している人に向けた記事です。

ゲーム定義を 1 つの JSON に押し込む

ゲームごとにエンジンが違うので、src/content/games/*.json の schema はテンプレートを discriminated union で分岐させています。Content Collections で Zod schema が使えるので、テンプレート別の required 項目を型で担保できます。

const games = defineCollection({
  loader: glob({ pattern: '*.json', base: './src/content/games' }),
  schema: z
    .discriminatedUnion('template', [
      z.object({
        template: z.literal('shittaka'),
        ...gameBase,
        talkSeconds: z.number().int().positive(),
        deckIds: z.array(reference('decks')).min(1),
      }),
      z.object({
        template: z.literal('kimochi'),
        ...gameBase,
        performSeconds: z.number().int().positive(),
        deckIds: z.array(reference('serifuDecks')).min(1),
      }),
      // ... iiwake / wolf / ate
    ])
    .refine((g) => g.minPlayers <= g.maxPlayers, {
      message: 'minPlayers must be <= maxPlayers',
    }),
});

deckIdsreference('decks') で別コレクションへの参照になっているので、存在しない id を書くとビルドが落ちます。ゲームを増やすときは JSON を 1 個足すだけで、対応する紹介ページ (/games/[slug]) とプレイ画面 (/play/[slug]) がビルド時に生えます。スキーマを型で縛っておくと、半年後にどの項目が必須だったかを自分で思い出す作業をしなくて済みます。

OGP 動画を build 時に pre-render する

SNS にリンクを貼ったときの CTR を上げたくて、og:video メタタグに動画を割り当てています。動的に .mp4 を配信サーバーで吐く手もあったのですが、それをやると @astrojs/cloudflare adapter を入れて、全ページ静的の構成から、一部をサーバーで描画する構成 (Astro の言う hybrid output) に切り替える必要が出てくる。今のシンプルな障害面を手放すほどの理由が無かったので、build 時に全 slug 分をレンダリングして public/og/<slug>.mp4 に静的に置く方針にしました。

レンダリングは Remotion に投げます。ゲーム名・タグライン・お題例を props に渡すコンポーネントを 1 つ書いて、全ゲームぶんの Composition (Remotion における「動画 1 本」の登録単位) を Root.tsx という登録簿のファイルにループで並べるだけです (同じファイルに、SNS 配信用のナレーション付き動画や /games/[slug] の紹介ページに置く動画の Composition も登録しています)。

{OG_ASSETS.map((asset) => (
  <Composition
    key={`OGPreview-${asset.gameId}`}
    id={`OGPreview-${asset.gameId}`}
    component={OGPreview}
    durationInFrames={OGP_DURATION_FRAMES}
    fps={OGP_FPS}
    width={OGP_WIDTH}
    height={OGP_HEIGHT}
    defaultProps={asset}
  />
))}

生成した mp4 は Astro が public/ を静的アセットとしてそのままコピーしてくれるので、/og/shittaka.mp4 として配信されます。ゲームを増やしたら og-assets.ts に 1 エントリー足して pnpm --dir video render:og を叩き直すだけです。OGP 動画の生成を build 時に閉じておいたおかげで、公開後にサーバー側の配信設定を増やさずに済んでいます。

VOICEVOX は「エンジンが立っていない」を前提にする

YouTube Shorts や TikTok に上げる説明動画 (og:video 用の無音の OGP 動画とは別の生成物で、ナレーション付きの縦型動画です) は VOICEVOX で音声合成しています。ローカルで engine を立てないと合成できないので、開発中に毎回起動する必要があり、そのままだと開発体験がだいぶ重くなります。

そこで、台本生成とレンダリングを「音声合成の有無に依存しない」設計にしました。台本 (data/<slug>.script.json) は文字数から尺を推定した値を持ち、実測 timings (data/<slug>.timings.json) は VOICEVOX を回した人だけが上書きします。timings が空 ({}) のときは推定値でレンダリングされる、つまり音声合成を丸ごとスキップしてもパイプラインの残り全部を検証できる、という形です。

// 文字数からの尺推定 (VOICEVOX 合成後は実測 timings が優先される)
const estSec = (text) =>
  Math.min(10, Math.max(1.6, Math.round((text.length * 0.16 + 0.8) * 10) / 10));

地味ですが、この estSec のおかげで CI や別マシンでも音声合成なしで動画が出せます。VOICEVOX が立っていない環境に変わっても、パイプラインを触り直さずに済んでいます。

timings.json を {} のままコミットする理由

配布上の落とし穴を 1 つ書いておきます。実測 timings を素直に commit すると、fresh clone で render が壊れます。

data/<slug>.timings.json{ "hook-1": { "sec": 3.5, "audio": "hook-1.wav" } } のように埋まっている状態で clone すると、audio 側が参照する wav ファイル (data/wav/hook-1.wav) は gitignore 済みで存在しません。timings 側だけ配布して audio を配らないと、Remotion は「audio へのポインタはあるのに実体が無い」状態でクラッシュします。「生成物とその生成に必要な入力を非対称に配布すると、fresh checkout でパイプラインが壊れます」というのが、ここから引ける一般則です。

対処は timings.json を {} のままコミットして、実測値は各自ローカルで合成し直したときにだけ埋めること。前述の推定尺で埋まる仕組みが元々あるので、fresh clone は推定値で最後まで走り切れます。この方針はリポジトリの作業規約ファイルにも明記して、二度と踏まない形にしてあります。

まとめ

ここで挙げた 4 つの判断は、どれも公開後に僕がもう一度手を動かさずに済むかを基準に選んでいます。同じ理由で /play/ 画面では Service Worker を使い、遊び始めた後は通信が途切れてもゲームが進行できるよう、あらかじめ画面をキャッシュしています (この設計はオフライン対応の記事に書きました)。ただ、この軸で消せるのは実行時のトラブルだけで、ゲームを増やすたびに JSON の追加や動画の再生成といった作業自体は残ります。摩擦を減らす設計と、作業をゼロにする設計は別物だ、というのが今のところの実感です。

よかったら 知ったかリレー で遊んでみてください。動画生成の video/ パッケージはサイト本体のビルドから独立しているので、そこだけ持ち出しても動きます。