Little City Story는 Godot 4.7로 만든 3D 도시 건설 게임입니다. 데스크톱과 Android 빌드가 먼저 나왔는데, 도시 게임은 백 마디 설명보다 한 번 보여주는 게 낫죠. 설치 없이 주소만 열면 돌아가는 웹 버전이 필요했습니다. 목표는 하나였습니다. ntsapp.dev/city 를 열면 게임이 그냥 켜질 것.
파일 하나가 업로드 한도보다 컸다
Godot의 Web 내보내기는 게임을 몇 개의 큰 파일로 뱉습니다. 크기가 문제였습니다.
- WebAssembly 런타임(index.wasm): 약 39.5MB
- 게임 데이터(index.pck): 약 26.3MB
호스팅으로 쓰는 Cloudflare의 정적 자산은 파일당 25MiB까지만 받습니다. 런타임은 처음부터 한도를 넘었고, 게임 데이터도 콘텐츠가 늘면서 곧 넘어섰습니다. 그대로는 못 올리는데, 브라우저의 Godot 로더는 정확히 그 파일 이름을 요청합니다.
쪼개서 올리고, 원래 주소에서 이어 붙인다
업로드는 조각으로, 요청은 통째로. 이 두 가지를 갈라놨습니다.
- 배포 준비 스크립트가 25MiB를 넘는 파일을 20MiB 단위로 자릅니다.
index.wasm.part0, index.wasm.part1 같은 이름이 됩니다.
- 조각들은 전부 한도 아래라 그냥 정적 자산으로 올라갑니다.
- 경로 앞에 작은 Worker를 세웁니다. 브라우저가 원래 이름(index.wasm)을 요청하면
Worker가 조각을 순서대로 읽어 하나의 응답 스트림으로 이어 붙입니다. Content-Type과 전체 길이도 원본 기준으로 되돌려 줍니다.
브라우저와 Godot 로더 쪽에서 보면 아무 일도 없습니다. 큰 파일이 원래 주소에 원래 타입으로 있을 뿐입니다. 라우트는 ntsapp.dev/city* 하나로 좁혀 뒀습니다. 같은 도메인의 다른 페이지에는 손대지 않습니다.
렌더러 선택이 배포를 단순하게 만들었다
웹 프리셋은 Compatibility 렌더러와 단일 스레드 WebAssembly를 씁니다. 스레드를 안 쓰면 cross-origin isolation 헤더가 필요 없습니다. 그러면 호스팅 쪽 헤더 구성이 통째로 사라지죠. 성능이 아쉬워질 때 그때 가서 들여도 되는 복잡성이라면, 처음부터 안 들이는 편이 낫습니다.
저장은 어디에 남는가
웹 버전의 저장은 브라우저 IndexedDB에 남고 도메인과 경로에 묶입니다. 같은 브라우저로 다시 오면 도시가 그대로 있습니다. 대신 시크릿 모드나 저장소를 막아 둔 환경에서는 저장이 날아갈 수 있어서, 이 한계는 앱 소개 페이지의 플레이 안내에 그대로 적어 뒀습니다.
배포를 검증하는 방법
배포가 끝났다는 건 파일이 올라갔다는 말이 아니라, 실제 주소에서 받은 바이트가 로컬 빌드와 같다는 말이어야 합니다. 그래서 검증 스크립트가 라이브 주소에서 wasm과 pck를 내려받아 해시를 로컬 산출물과 대조합니다. 맞으면 버전 문자열과 함께 성공을 찍습니다.
여기서 한 번 데었습니다. 배포 직후 1~2분은 엣지 캐시 전파가 안 끝나서 조각 일부가 이전 버전과 섞입니다. 해시가 어긋나죠. 몇 분 뒤 다시 돌리면 멀쩡합니다. 처음 이걸 봤을 때는 배포가 실패한 줄 알았습니다. 지금은 재시도가 검증 절차에 기본으로 들어가 있습니다.
아직 안 한 것
웹 빌드에 WebSocket 클라이언트는 이미 들어 있지만 공개 멀티플레이 서버는 아직 안 열었습니다. 권위 서버 없이 엔드포인트만 열어 두면 치트와 데이터 훼손을 막을 방법이 없거든요. 그래서 지금 웹 버전은 오프라인 도시로 시작하고, 서버는 별도의 결정으로 남겨 뒀습니다.