대부분의 "AI 게임"은 외부 서버와 통신합니다. 매 턴마다 API 왕복이 발생하고, 플레이어가 움직일 때마다 당신의 토큰(token)을 소모하며, 비용 청구서가 무서워지는 날 모든 것이 끝납니다. 저는 그 반대를 원했습니다. 던전 마스터가 플레이어의 브라우저에서 완전히 실행되는 텍스트 로그라이크(roguelike) 게임을 만들고 싶었습니다. 서버도, API 키도, 토큰당 비용도 필요 없으며, 첫 로드 이후에는 오프라인에서도 계속 작동합니다.
여기에 아키텍처와 저에게 가장 많은 것을 가르쳐준 단 하나의 버그가 있습니다.
핵심 트릭: WebLLM + WebGPU
WebLLM은 양자화된 모델(quantized models)을 WebGPU로 컴파일하므로, 추론(inference)이 플레이어의 GPU에서 실행됩니다. 백엔드는 전혀 존재하지 않습니다.
const cdn = "https://esm.run/@mlc-ai/web-llm";
const webllm = await import(/* webpackIgnore: true */ cdn);
const engine = await webllm.CreateMLCEngine(MODEL_ID, {
...
첫 로드 시 가중치(weights)를 한 번 가져오면(브라우저가 이를 캐싱합니다), 그 이후의 모든 턴은 로컬에서 무료로 진행됩니다.
모델이 서사를 쓰게 하되, 판정은 맡기지 마세요
던전 마스터는 창의적이어야 하지만, 규칙을 어기게 해서는 안 됩니다. 시스템을 안정적으로 만든 분리 방식은 다음과 같습니다:
- **서사(Narrative)**는 자유로운 산문 형태로 돌아옵니다. 모델이 마음껏 창작하게 두세요.
- 메커니즘(Mechanics) — HP 변화량, 아이템, 게임 종료 여부 등 — 은 엔진이 _검증(validate)_하는 작은 JSON 객체로 돌아옵니다. 게임 루프는 산문이 아닌 JSON을 신뢰합니다.
가장 교훈적이었던 버그: 초기에는 산문을 통해 사망 감지(예: "you die"를 찾는 정규 표현식)를 수행하게 했는데, 모델이 순수하게 분위기를 잡는 텍스트만으로 첫 턴에 플레이어를 즐겁게(?) 죽여버렸습니다. "이것이 당신의 끝이 될 수도 있습니다" → 게임 오버. 사망 판정을 엔진이 소유한 정수(integer)(if (hp <= 0))로 옮기자마자 즉시 해결되었습니다.
경험칙: LLM은 이야기를 쓰고, 당신의 코드는 점수를 기록합니다.
인디 게임에 온디바이스(on-device)가 기본값이 되어야 하는 이유
- 한계 비용 $0 — 플레이어가 10명이든 10,000명이든 서버 비용은 동일합니다: 0원입니다.
- 개인정보 보호(Privacy) — 선택 사항이 기기를 절대 벗어나지 않습니다.
- 오프라인 — 첫 로드 이후에는 비행기 안에서도 실행됩니다.
- 속도 제한(rate limits) 없음, 키 유출 위험 없음.
트레이드오프(tradeoff)는 모델 크기입니다. 브라우저 탭에 로드될 수 있을 만큼 충분히 작은 모델을 실행해야 하므로, 프롬프트 디자인(prompt design)의 비중이 매우 큽니다. 서사 중심의 게임 마스터(game master)를 구현하는 데 있어서 이는 공정한 거래입니다.
하나의 엔진, 다양한 게임 (코드를 이기는 설정)
장르(Genre)는 그저 설정(config) 객체일 뿐입니다 — 팔레트, HUD 레이블, 시드 시나리오(seed scenarios), 시스템 프롬프트(system prompt) 등이 포함됩니다. 동일한 엔진을 사용하되 설정만 교체하면 다른 게임을 출시할 수 있습니다. 장르를 추가하는 것은 코드 변경이 아닌 _데이터(data)_의 추가이므로, 생성기(generator)가 새로운 장르를 직접 작성할 수도 있습니다.
실제로 작동하는 버전을 확인해보고 싶다면, 사이버펑크 빌드(NeonHeist)와 몇 가지 다른 게임들을 bestpaid.app의 Games 섹션에서 확인할 수 있습니다 — 모두 온디바이스(on-device)로 실행됩니다.
JSON 계약 프롬프트(JSON-contract prompt)나 WebGPU 로딩 UX에 대해 더 자세히 다루길 원하신다면 댓글로 알려주세요.