웹에서도 플레이 가능하도록 할 수 있어?

이 말을 보내기 직전, 나는 「지금 상태로는 무슨 재미가 있는 건지 잘 모르겠어」라고 썼다. 판매 숫자가 튀어 오르고 진열대가 통통 튀는 연출이 들어왔고, 다시 해 본 나는 「움직이니 조금 나은 것 같다」고 했다. 글씨가 잘 안 보인다는 말을 두어 번 더 하고 나서 꺼낸 게 저 한 줄이다.

재미가 있느냐는 질문에 답하지 않은 채, 그 게임을 돌릴 곳을 하나 더 늘리자고 한 것이다. 그때는 브라우저에서 바로 켤 수 있으면 더 자주 해 보게 될 거라고만 생각했다.

Claude Code 의 답은 「가능하다, 다만 세 가지가 필요하다」였다. 한글 폰트 내장, 빌드 자동화, 호스팅 결정. 그리고 웹 버전은 어디까지나 테스트와 공유용으로 권한다고 덧붙였다. 출시의 본선은 Android 라는 로드맵을 기억하고 있었다. 나는 「진행해줘」라고 했다.

□□□

에디터에서는 한글이 멀쩡했다. 말풍선도 HUD 도 Windows 의 맑은 고딕을 빌려 쓰고 있었기 때문이다. 브라우저 안의 WebGL 에는 빌려 올 OS 폰트가 없다. Claude Code 는 이대로 올리면 한글이 전부 □□□로 깨진다고 했다. 「여기는 던전 입구」도, 베테랑 용사의 잔소리도, 「이게 이 게임의 전부입니다」도 네모 상자가 된다는 뜻이다.

고친 방법은 폰트를 게임 안에 넣는 것이었다. Noto Sans KR 을 리소스로 넣고, HUD·말풍선·팝업이 제각각 폰트를 찾던 코드를 한 창구로 모았다.

public static Font Load()
{
    if (_cached != null) return _cached;
    _cached = Resources.Load<Font>("Fonts/NotoSansKR-Regular");      // 내장 폰트가 먼저
    if (_cached != null) return _cached;
    _cached = Font.CreateDynamicFontFromOSFont("Malgun Gothic", 48);  // 에디터용 폴백
    if (_cached != null) return _cached;
    return _cached = Resources.GetBuiltinResource<Font>("LegacyRuntime.ttf");
}

순서가 핵심이다. 내 PC 에서 잘 보이던 OS 폰트는 이제 마지막 보험일 뿐이고, 어느 플랫폼에서든 먼저 읽히는 건 게임이 들고 다니는 폰트다.

하나를 고치니 하나가 튀어나왔다. 폰트를 넣은 빌드에서 상단 설정 버튼이 제대로 안 보였다. 버튼 글자로 톱니바퀴 ⚙ 를 쓰고 있었는데, 새로 넣은 폰트에는 그 글자가 없었다. 버튼 글자를 그냥 「설정」으로 바꿨다. 폰트를 하나로 정하면 그 폰트에 없는 글자는 쓰지 않는다는 규칙이 따라온다는 걸 그때 배웠다.

가로로 누운 가게

빌드는 에디터 메뉴와 명령줄이 같은 함수를 부르게 만들었다. gzip 으로 압축하되 서버가 헤더를 몰라도 로더가 직접 풀도록 폴백을 켰고, 정적 호스팅에 필요한 web.config 도 빌드할 때마다 새로 쓰게 했다. 그렇게 서버에 올린 빌드를 데스크톱 브라우저로 열었다.

화면이 가로여서 그런지 다 안 나온다.

「호구마트」는 세로 화면 게임이다. 그런데 Unity 의 기본 WebGL 템플릿은 캔버스를 960×600 가로로 잡는다. 폰을 눕혀 놓고 세로 게임을 하는 꼴이라 UI 가 서로 밀려 화면을 덮었다. Claude Code 는 빌드가 끝난 index.html 의 데스크톱 분기를 바꿔 끼웠다.

function fitPortrait() {
  var h = Math.floor(window.innerHeight * 0.97);
  var w = Math.floor(h * 9 / 16);
  canvas.style.width = w + 'px';
  canvas.style.height = h + 'px';
}
window.addEventListener('resize', fitPortrait);

창 높이에 맞춰 세우고 너비는 9:16 으로 계산한다. 폰 브라우저 쪽은 손대지 않았다. 거기서는 원래 화면 전체를 쓰니까.

탭을 닫아도, 탭을 떠나도

방치형 게임에서 세이브는 곧 진행이다. 그런데 기본 템플릿에는 저장 폴더를 브라우저의 IndexedDB 와 자동으로 맞추는 설정이 주석 처리돼 있었다. 그대로 두면 탭을 닫는 순간 가게가 사라질 수 있다. 이것도 같은 index.html 에서 // config.autoSyncPersistentDataPath = true; 한 줄의 주석을 벗기는 문자열 치환으로 고쳤다. 템플릿 문구가 바뀌면 소리 없이 실패하는 방식이라는 건 그때도 알았다.

남은 건 내가 브라우저를 쓰는 습관이었다. 다른 창을 클릭하면 게임이 멈췄다. 나는 이렇게 물었다.

브라우저가 선택이 안 된 상태면 게임이 멈추는 건 어쩔 수 없는 건가?

Claude Code 는 그 멈춘 시간이 「온라인도 오프라인도 아닌 죽은 시간」이 된다고 했다. 포커스를 잃어도 돌게 설정을 켜고, 탭이 숨겨져 브라우저가 엔진을 정말로 세운 경우는 돌아왔을 때 이미 있던 오프라인 정산을 다시 부르게 했다.

double elapsedSec =
    (Time.UtcNow.Ticks - _saveData.lastSeenUtcTicks) / (double)TimeSpan.TicksPerSecond;
if (elapsedSec < 15.0) return;

SettleOffline();
SaveNow();   // lastSeen 갱신 — 같은 시간을 두 번 정산하지 않도록

여기서 Time 은 Unity 의 시계가 아니라 「시간은 ITimeService 로만 읽는다」는 규칙이 만든 가짜로 바꿔 끼울 수 있는 시계다. 탭 복귀라는 새 상황을 새 코드 없이 처리할 수 있었던 건 처음 CLAUDE.md 에 적어 둔 그 한 줄 덕분이었다. 웹 빌드 내내 시뮬레이션 코드는 거의 건드리지 않았다.

한 번 더 누르면 초기화

브라우저로 몇 번 하다 보니 처음부터 다시 해 보고 싶어졌다. 「게임 처음부터 하려면 어떻게 해?」라고 물었더니, Claude Code 는 시크릿 창이나 사이트 데이터 삭제 같은 당장의 방법을 알려 준 뒤 테스터들도 똑같이 물어볼 게 뻔하다며 설정 메뉴를 넣자고 했다. 기획 문서의 화면 구성에 상단 [설정]이 이미 있다는 근거도 붙였다.

「처음부터 시작」은 되돌릴 수 없는 버튼이라 두 번 눌러야 한다. 처음 누르면 글자가 「정말요? 되돌릴 수 없습니다 / 한 번 더 누르면 초기화」로 바뀌고, 패널을 닫았다 열면 그 확인 상태는 풀린다. 재미있는 건 그 뒤였다. 세이브 파일을 지우는 것만으로는 초기화가 안 된다.

_save.Delete();      // 본 세이브 + 백업 모두
_saveData = null;    // 이후 어떤 저장 호출도 쓸 것이 없다
Simulation = null;
SceneManager.LoadScene(SceneManager.GetActiveScene().name);

게임은 주기적으로, 그리고 일시정지와 종료 때마다 저장한다. 파일을 지운 직후 저장이 한 번 돌면 메모리에 남은 옛 가게가 다시 파일로 써진다. 그래서 메모리 쪽 데이터를 먼저 비운다. 백업까지 지우는 이유도 같다. 본 파일이 깨지면 백업에서 살려 내도록 공들여 만든 세이브 서비스가, 이번에는 지운 가게를 백업에서 성실하게 되살려 낸다.

손님은 오는데

그렇게 「호구마트」는 브라우저에서 돌았다. 서버에 올려 직접 해 봤고, 공개하지는 않았다.

처음으로 만든 사람이 아니라 하는 사람의 자리에 앉아 처음부터 해 봤다. 손님이 걸어 들어오고, 말풍선이 뜨고, 금화가 튀었다. 한글은 네모가 아니었고, 가게는 세로로 서 있었고, 탭을 닫았다 열어도 진행이 남아 있었다. 다른 탭에 다녀와도 진행이 이어졌다. 기술적으로는 전부 됐다.

그런데 슬라이더를 다시 만지고 싶은 순간은 오지 않았다. 1편에서 쓴 그 취소 지점의 질문이, 그림과 연출과 웹 빌드를 다 거쳐 그대로 돌아와 있었다.

그 뒤로 몇 가지가 더 들어가긴 했다. 「이런 기능들의 설명과 이해 부족」이라고 쓰자 팁과 설명이 늘었고, 구글과 연동하기 전이어도 상점은 미리 만들 수 있지 않냐고 하자 인앱 상점이 생겼다. 그리고 1편 첫 줄에 옮긴 그 질문, 보석으로 뭘 할 수 있느냐는 질문을 보냈다. 내 게임의 규칙을 내가 묻고 있었다. 거기서 멈췄다. 출시하지 않기로 했다.

다음 게임에서 바꾼 것

「호구마트」 다음에 Claude Code 와 게임을 두 개 더 만들었다. 커피 내기 파티게임 「오늘 누가 쏘냐?」와 반사신경 게임 「네온 다이브」다. 이번에는 기술 규칙만큼 진행 규칙을 먼저 챙겼다.

두 저장소의 CLAUDE.md 맨 앞에는 이제 이런 문장이 있다.

한 번에 큰 변경을 하지 말고 단계별로 진행하고, 단계마다 확인을 받는다.

「호구마트」의 규칙 문서는 코드에 대해서는 단호했지만, 언제 멈추고 누구에게 묻는지에 대해서는 한 줄도 없었다. 「진행」 한 단어로 다음 마일스톤에 들어갈 수 있었고, 실제로 그렇게 들어갔다. 이번에는 그 문이 처음부터 닫혀 있다.

「네온 다이브」 로드맵의 첫 단계는 기능 목록이 아니라 「에디터에서 한 판 플레이」다. 거기에 내 체감을 적어 두었다. 어렵지만 괜찮다. 난이도 숫자는 PC 마우스로 만지지 않고, 폰 터치로 해 본 뒤 한 번에 고치기로 했다. 「호구마트」의 M1 에서 끝내 채우지 못한 칸이 바로 그 한 줄이었다.

「오늘 누가 쏘냐?」는 사다리 하나로 시작했다. 직접 해 보다 불편한 게 나올 때마다 기능을 붙였고, 큰 기능의 작업 문서에는 「하지 않는 것(범위 밖)」 절을 따로 두었다. 문서에 없는 큰 결정은 임의로 하지 말고 먼저 묻는다는 규칙도 첫머리에 적었다.

광고는 미뤘다. 「네온 다이브」에는 보상형 광고 인터페이스와 1.2초 뒤 성공을 돌려주는 가짜 광고만 있고, 실제 광고 SDK 연동은 로드맵의 다음 단계로 따로 빼 두었다. 「오늘 누가 쏘냐?」의 결정 사항에도 「광고는 나중에」라고 적었다. 광고를 인터페이스 뒤에 두는 습관은 「호구마트」에서 그대로 가져왔다. 바뀐 건 순서다.

그리고 에셋은 사지 않았다. 「네온 다이브」의 규칙 문서에는 「외부 에셋 없음」이 적혀 있고, 블록과 별은 런타임에 코드로 그린다. Kenmi Cute Fantasy 와 5,600장 넘는 아이콘 팩으로 화면을 채웠던 「호구마트」와는 반대 방향이다. 재미가 확인되기 전에는 도형으로 버틴다.

두 게임이 어떻게 만들어졌는지는 각각 연재로 남겨 두었다. 「오늘 누가 쏘냐?」 개발기와 「네온 다이브」 개발기다.

「호구마트」는 서랍에 들어갔다. 기획 문서 여섯 개와 규칙 문서 하나, 엔진 없이 도는 시뮬레이션과 백 건 넘는 테스트, 그리고 한글이 깨지지 않는 웹 빌드까지 만들고 멈췄다. AI 는 내가 적은 규칙을 끝까지 지켰고, 내가 「진행」이라고 할 때마다 성실하게 진행했다. 비어 있던 건 「여기서 멈추고 한 판 해 보자」고 말할 사람의 자리였다. 다음 게임부터 그 자리는 규칙 문서 맨 앞에 있다.

읽어 주셔서 고맙다.