- 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 아카이브에서 스티커를 찾아 전역 공유 캔버스에 한 번씩 붙이는 방식이었지만 기대만큼 작동하지 않음
- 모든 플레이어가 비어 있는 지도 중앙에서 시작했고, 곧 혼란스러운 화면이 첫인상이 됨
- 스티커 위치를 신중히 고를 유인이 부족했고, 개별 행동만으로 줄거리를 진전시키기 어려웠음
- 공유된 이야기나 목표가 없어 각 스티커가 페이지의 다른 요소와 어떻게 연결되는지 분명하지 않았음
- 집단 캔버스가 잘 작동하려면 사용자가 무엇을 만들면 멋진지 예시로 학습할 수 있어야 함
- 창작 결과를 한 방향으로 모으려면 무엇을 만들지 동기화해 주는 공유 맥락과 목적이 필요함
제약 설계: 표현력, 호환성, 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 아키텍처의 첫 번째 큰 가정은, 위 제약을 지키면 서로 다른 타일을 이어 붙여 전체 기계처럼 보이게 할 수 있다는 점임
- 작은 맵 몇 개를 생성하고 풀어 보며 이 가정을 확인함
- 전체 기계를 서버나 클라이언트에서 실시간으로 돌릴 수 없었기 때문에, 사용자가 보는 영역 주변만 시뮬레이션하는 방식이 필요했음
- 목표는 하나의 구슬을 기계 위에서 아래까지 따라갈 수 있게 만드는 것이었음
-
보이는 영역만 존재하는 물리 세계
- 초기 맵 뷰어는 보이는 영역만 시뮬레이션했지만, 스크롤할 때 새로 들어오는 타일이 빈 상태로 시작해 흐름의 빈틈이 보였음
- 빈 타일 대신 이미 활동 중인 상태로 보이게 하려고, 안정 상태에 도달한 타일의 스냅샷을 저장하고 화면에 들어오기 직전에 불러오는 방식을 선택함
- 최종 만화에서 실제 물리 시뮬레이션에 존재하는 것은 렌더링되는 타일뿐임
- 화면 위쪽에 더 많은 기계가 있는 것처럼 보이도록, 시뮬레이션 상단 행 타일에는 입력 제약의 예상 속도에 맞춰 구슬을 생성해 공급함
-
승인 시점 스냅샷
- 스냅샷 생성은 모더레이션 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에 있음