xkcdの「Machine」開発ノート
(chromakode.com)- xkcdのMachineは、読者が作ったタイル型の装置をつなぎ合わせる巨大なルーブ・ゴールドバーグ式の玉転がしマシンゲームで、アイデアを3週間で実際のインタラクティブ漫画として実装した
- 以前のユーザー参加型プロジェクトの経験から、共有キャンバスがうまく機能するには共通の文脈と目的が必要だという設計基準に至った
- プレイヤーの表現力を活かしつつタイル互換性を保つため、入出力の制約を強く設け、各装置が30秒以内に安定状態へ到達するよう制限した
- 全体のマシンをリアルタイムですべてシミュレーションせず、見えている領域だけをRapierで動かし、承認時点のスナップショットによってすでに稼働中の装置のように見せている
- ReactとDOMレンダリング、Haskellバックエンド、Redis、OpenAPI、TanStack Query、モデレーションUIを組み合わせて、投稿の承認と配信フローを運用している
Machineの出発点
- xkcdは4月5日にMachineを公開した
- Machineは古典ゲーム The Incredible Machine スタイルの巨大な Rube Goldberg machine ビルダーである
- 全体のマシンは、個々のxkcd読者が作った小さな装置タイルをつなぎ合わせた構成になっている
- チームはMachineを3週間で作り上げ、アイデアの出発点は2005年の協業GIF Blue Ball Machine にあった
- 初期ブレインストーミングの中心的な問いは、玉の出所、みんなが見るマシンの同一性、マシンの目的、プレイヤー同士の相互作用、参加動機だった
ユーザー参加型xkcdで得た教訓
- ユーザー生成コンテンツが中心だった以前のxkcdインタラクティブ漫画のうち、Lorenz は読者がパネルのテキストを書いてジョークや物語を発展させる方式で、よい体験として残っている
- 2020年の Collector’s Edition は、プレイヤーがxkcdアーカイブからステッカーを探してグローバル共有キャンバスに1回ずつ貼る方式だったが、期待したほどには機能しなかった
- すべてのプレイヤーが空の地図の中央から始まり、すぐに混沌とした画面が第一印象になってしまった
- ステッカーの位置を慎重に選ぶ動機が乏しく、個々の行動だけで筋書きを前に進めるのが難しかった
- 共有された物語や目標がなく、各ステッカーがページ内のほかの要素とどうつながるのかが明確でなかった
- 集団キャンバスがうまく機能するには、ユーザーが何を作るとかっこよくなるのかを実例から学べる必要がある
- 創作結果を一方向へ集約するには、何を作るべきかを同期させる共有の文脈と目的が必要である
制約設計: 表現力、互換性、30秒の安定状態
- 大規模な協業玉転がし装置を作ると決めた後も、マシン全体のサイズ、シミュレーション方式、タイル統合の方法が課題として残った
- 100x100サイズのマシンを想定すると、10,000個のタイルをクライアント上でリアルタイムに動かし、各タイルで数十個の玉を処理する目標は危険だと判断した
-
正確性より表現力を優先
- マシン全体をサーバーで動かす案や、個別タイルをシミュレーションして検証する案が検討された
- プロトタイプエディタでは混沌とした玉の衝突パターンが簡単に作れてしまい、予測可能なマシンを要求するとプレイヤーの自由度が下がるという結論に至った
- 最終設計では、非常に非決定的だったり壊れていたりする装置まで作れるプレイヤーの柔軟性を優先した
- この選択により、タイルが制約を満たしているかの確認と攻撃的なコンテンツの除去を行う能動的なモデレーションが必要になった
-
タイル間の互換性のための入出力制約
- 当初は、前のタイルの出力位置に合わせて次のプレイヤーが自由に拡張していく方式も検討された
- しかし初期に配置されたタイルを後で差し替える必要が生じると、そのタイルに依存していた広い領域が壊れる可能性があった
- そこで、同じタイル空間内で複数のプレイヤーが互換性のある設計を作れるよう、入出力の制約を強く設けた
- このアプローチは、Robustness principle の「送るものには保守的に、受け取るものには寛容に」という原則にも通じている
- Kevinの map generator は、単純な1入力1出力のパズルから始まり、中盤では4入力4出力の合流で複雑化し、終盤ではタイルあたり2出力へ戻る構成になっている
- エディタは、プレイヤーがタイルを作る際にリアルタイムのフィードバックを提供する
- タイルは平均して、受け取る玉とおおむね同じ速度で玉を出力しなければならない
- 玉を飲み込んでしまったり、大きな遅延を生んだりする装置を減らそうとしている
- 上流入力の変動を反映するため、エディタに入る玉の速度をランダム化する chaos testing を適用している
-
30秒以内に安定状態へ到達する必要
- モデレーターがどれだけ長く見続ける必要があるかを減らすため、装置は30秒以内に安定状態へ入らなければならないという任意基準を設けた
- 10,000個のタイルを各30秒ずつ見ると、全体のモデレーション時間が約83.3時間になるという試算が基準になった
- 玉も30秒後に失効するようにした
- 失効がない場合、初心者プレイヤーの最初の体験が玉が画面にたまり続ける状況になってしまう
- アクティブな rigid body の数が増え、物理シミュレーションも遅くなる
- 玉の失効により、時間の経過とともに誤差が蓄積しないようにしつつ、30秒の観察だけでほとんどの玉の到達位置を確認できるため、モデレーションが簡単になった
マシン全体をリアルタイムで動かさない方式
- Machineアーキテクチャの最初の大きな前提は、上記の制約を守れば異なるタイルをつなぎ合わせて1つの全体マシンのように見せられる、という点だった
- いくつかの小さなマップを生成して解き、この前提を確認した
- マシン全体をサーバーやクライアントでリアルタイムに動かすことはできなかったため、ユーザーが見ている領域の周囲だけをシミュレーションする方式が必要だった
- 目標は、1つの玉をマシンの上から下まで追いかけられるようにすることだった
-
見えている領域だけが存在する物理世界
- 初期のマップビューアは表示領域だけをシミュレーションしていたが、スクロール時に新たに入ってくるタイルが空の状態から始まるため、流れの切れ目が見えてしまった
- 空のタイルではなく、すでに活動中の状態に見せるため、安定状態に到達したタイルのスナップショットを保存し、画面に入る直前に読み込む方式を採用した
- 最終的な漫画で実際に物理シミュレーション上に存在するのは、レンダリングされているタイルだけである
- 画面の上側にもさらに多くのマシンが続いているように見せるため、シミュレーション上端の行のタイルには、入力制約で想定された速度に合わせて玉を生成して供給する
-
承認時点のスナップショット
- スナップショット生成はモデレーションUIと連動している
- モデレーターはタイル承認前に少なくとも30秒待つ必要があり、承認ボタンを押した時点の状態がスナップショットとして保存される
- 装置が見栄えのよい状態になるまで少し長く待つ裁量も、モデレーターに与えられている
- スナップショット方式には累積誤差をリセットする効果があり、ユーザーがスクロールして新しいタイルを初めて見るときには、モデレーターがよいと判断したきれいな状態が表示される
- 長く観察すると多くの装置が停止したり壊れたりした状態になることもあるが、探索を続ければ新しいスナップショットに出会う
- マシン全体が完全にシミュレーションされているわけではなく、結果として hyperreality に近い構造になっている
React、DOM、Rapierのレンダリング構造
- Machineは Rapier 物理エンジン上に構築されている
- Rapierは、ドキュメント、API、有用な基本要素、Rust実装によるWASMのブラウザ性能が利点として採用された
- 当初はRapierの決定性保証にも関心があったが、最終的にサーバー側シミュレーションは行わなかった
- Rapierの上にはカスタムReact contextである
<PhysicsContext>を実装した- Reactコンポーネントのライフサイクル内でRapierの物理オブジェクトを作成・管理する
- 配置可能な各オブジェクトや衝突面を「widget」コンポーネントとして開発しやすくなった
- Reactは高速で大ざっぱな scene graph のように機能する
- タイルがアンマウントされると、関連する物理オブジェクトとDOMも後始末される
- 高速リフレッシュによるホットリロードで衝突形状の調整がしやすかった
- 物理フックは
<PhysicsContext>の外では動作しないように作られており、モデレーションUIの静的プレビューに活用された - 後から振り返ると、Rapierオブジェクトはフックではなくコンポーネントとして作る方がよかったと考えている
- react-three-rapier はこの方式を採っており、Reactのdiffingにより適している
useEffectベースの方式では、依存関係が変わるたびに以前のインスタンスを破棄して新しく作り直すことになる
-
DOM専用レンダリング
- MachineはすべてDOMでレンダリングされている
- 当初は性能の限界が来たら PixiJS やcanvasへ移行できると考えていたが、構築すべきものが少ないDOM方式を可能な限り押し通した
- レンダリング性能のため、フレームループが物理シミュレーション対象ウィジェットのスタイルを直接適用する
- React diffはシーングラフの構造が変わるときだけ実行される
- 当初は玉もReactでレンダリングしていたが、頻繁な生成・削除がdiffコストを増やすため、別の optimized renderer を作成した
- 画面外の玉やウィジェットには draw culling を適用している
- この方式は、シミュレーション中の玉4,000個と画面上の数百個の玉でも十分機能し、DOM専用レンダリングで確定した
API、モデレーション、投稿運用
- バックエンドはdaveanとKevinがHaskellで書き、ストレージにはRedisを使用した
- コードベース間の型共有にはOpenAPIと OpenAPI fetch を用いた
- Haskellの型に合わせる初期の不便さはあった
- 終盤のAPI変更の調整には役立った
- TanStack Query は、サーバープッシュなしでキャッシュと自動更新を処理するのに有用だった
-
モデレーションUIと優先順位
- Ed Whiteが設計したモデレーションUIは、すべての投稿が公開前に通るボトルネックだった
- モデレーターは特定タイルについて、数百件の候補デザインから選ぶ必要がある場合もあった
- キューの優先順位は、ウィジェット種別ごとの interestingness score を置き、各インスタンス数を数えて候補タイルを並べ替える方式で決めていた
- この方式は多くの要素を入れた解法に偏りやすいが、モデレーターがリストの途中も確認して、よりミニマルな解法を補っていた
- 投稿されたデザイン数と、実際にマシンへ掲載されたデザイン数の大きな不均衡は課題として残った
- 公開前により多くのバックログを公開する方法も検討したが、モデレーション時間の制約内でよい折衷案は見つからなかった
- ライブ投稿の終了後には、投稿データセットをより多く共有する方法を見つけたいとしている
-
承認クールダウンと速度調整
- タイルのスナップショット品質が重要だったため、モデレーターの承認ボタンはシミュレーションを最低30秒実行するまで無効化されていた
- このクールダウンにより、安定状態のスナップショットを作り、出力が想定速度で玉を受け取っているかを確認できる
- 当初はモデレーターにとって煩わしいと予想していたが、性急な判断を防げるとして好意的に受け止められた
- 公開後には、モデレーターがシミュレーションをリアルタイムよりはるかに速く回せるスライダーが追加された
- この機能により、投稿物の最初の30秒を5秒未満で確認でき、より長時間の挙動も見やすくなった
意図しないタイル間相互作用
- 「Jamslunt Interfoggle」は、公開後最初の数時間のうちに投稿された装置で、ファンの狭い範囲を利用するメカニズムである
- この装置は青い玉を通路に集め、十分な重さになると両側へあふれ出すようにする
- 上に配置された「Bouncy」は、三叉の交差経路で玉を発射するカオスエンジンである
- Bouncyは時々緑の玉を誤った出力へ送り、この玉が停滞していた青い玉の山を崩し、Interfoggleに連鎖的な流れを生む
- エディタでは入力を理解しやすくするため正しい色だけを供給していたので、Interfoggleはこの緑の玉の挙動を前提に設計されていたわけではなかった
- このような意図しない組み合わせは、共有キャンバス上で人々が道具を創造的に使うあり方として、プロジェクトの大きな楽しさになった
コードと残された実験
- Machineのソースコードは GitHubリポジトリ で確認できる
- マシン全体をグローバルに完全シミュレーションする実装は、興味深いハック課題として残っている
- Machineに直接デザインを追加するリンクは xkcd 2916 にある
1件のコメント
Hacker Newsのコメント
この記事を読んで面白かったのは、当時はこういうことが起きているなんてまったく気づいていなかったこと
何が起きているのかの説明もないように見えたし、みんなで共有している体験だということも分からず、ただいろいろなランダムなことが混乱したまま起きているとしか感じていなかった
タイルをいくつか完成させて提出したが、それが「次の段階」に進む方法だと思っていたので、
test 1bみたいな間抜けな名前を付けた。シングルプレイだと思っていて、名前は自分にしか見えないと思っていたからいくつか作ったあとで飽きて歩き回り、複雑なものも見かけたが、それが投稿作品だとは思わず、単にレベル攻略用の開始地点だと思っていた。結局のところ、自分はエイプリルフールのいたずらに引っかかったわけだ
たぶん元ネタになったあのマシンゲームをやったことがなかったからかも :-)
bonk要素を大量に追加しているうちに rapier を壊してしまったっぽいUncaught Error: recursive use of an object detected which would lead to unsafe aliasing in rustat jt (rapier_wasm2d_bg.js:4836:11)at 4ea5626ea4b1e4145572.module.wasm:0xf061cat 4ea5626ea4b1e4145572.module.wasm:0xf0638at 4ea5626ea4b1e4145572.module.wasm:0xb5e7bat H.remove (rapier_wasm2d_bg.js:1051:14)at l.remove (collider_set.js:87:18)at y.removeCollider (world.js:343:28)at PhysicsContext.tsx:258:15それでも本当に楽しかったし、リアルタイムで公開されていたときに気づけなかったのが残念。みんなが作った個別のマシンにもパーマリンクを作れたら本当にいいと思う
ストレージの問題はあるかもしれないが、JSONをbase64でエンコードしてURLパラメータに入れるような方式は無理だろうか? 変なマップを作ってみんなと共有したい
全体公開版に入ったマシンにはパーマリンクを付けられるが、キュレーション待ちのキューで選ばれなかった個別作品にはパーマリンクがない
漫画ドメインで未審査のユーザー生成コンテンツをホスティングするリスクを避けるため、意図的にそうしている
ちなみにHNでは4月6日にもこの話題が投稿されていて、コメントが14件あった
https://news.ycombinator.com/item?id=39953514
「ステッカーをどこに貼るかを慎重に考える動機がなかった。プレイヤーには個々の行動で筋書きを前進させるだけの十分な主体性がなかった。そのため創造性は、似たようなステッカーをタイルのように繰り返したり線を作ったりする単純なパターンに制限された。」
ああ、ゲームが大企業での会社員生活になってしまったのか
公開されたときに参加してみた。正しいボールが正しい出力に行くよう、できるだけ信頼性高く作ろうとして1時間ほど使ったと思う
提出してからページを再読み込みすると、その場所には別の人の装置があった。認めるが、そっちのほうが見た目は良かった。ただ、信頼性は低かった
そういう仕組みだということを、もう少し早い段階で知らせてほしかった。それと、建築ブロックの一覧がスクロール可能だと気づかなかったのは自分だけではなかったようだ
もう戻って確認する気力はない :(
うわ、自分と友人も2014年に同じアイデアを思いついて、Ludum Dare向けに実装していた。https://nickfa.ro/wiki/CoinSlot
そのアイデアがさらに洗練され、うまく動く形で出てきたのを見るのはすごくいい
若いころにこれで遊んだのを思い出す。本当にたくさんの時間をものすごく楽しく無駄にした
https://www.myabandonware.com/game/the-incredible-machine-1m...
何か見落としている気がするが、なぜ特定の要素はマシン内で特定の色のボールにしか作用しないように見えるのだろう?
色が完全に混ざってしまうのを防ぐための仕組みだとは思うが、この記事では説明されていないようだ
黄色いボールは軽くて空気抵抗が大きく、緑のボールは重く、赤いボールは非常によく跳ねる
そのため物理的な分類器を設計できる
自分の作ったマシンのうち、最終版に入ったものがあるか簡単に確認する方法があればよかった
次の設計では、以前の投稿タイトルをローカルストレージのような場所に保存して通知を出せるとよさそう