도믿맨: 퇴근길 생존기를 만들었다

강남역에서 집까지, 다가오는 저 사람은 도믿맨인가 시민인가. '도를 아십니까'를 소재로 만든 선택형 인터랙티브 픽션. 시나리오 111개, 엔딩 8개, 무조건 거절해도 무조건 의심해도 지는 게임을 만든 이야기.

퇴근길에 누가 다가오면 일단 긴장부터 한다. “저기요, 혹시 인상이 좋으셔서…” 하면 아, 왔구나 싶다. 도믿맨이다. 근데 가끔은 진짜 길 물어보는 사람이거나, 지갑 떨어뜨린 걸 주워주려는 사람이기도 하다. 문제는 그 짧은 순간에 판단해야 한다는 거다. 이 애매함이 게임 소재로 괜찮겠다 싶어서 만들었다.

이름은 도믿맨: 퇴근길 생존기. 강남역에서 집까지 무사히 도착하는 게 목표인 선택형 인터랙티브 픽션이다.

도믿맨 시작 화면. '도믿맨 / 퇴근길 생존기' 타이틀과 '강남역에서 집까지, 당신의 퇴근길은 안전하지 않다.'라는 문구, 로그인 버튼과 게임 시작·오늘의 도전 메뉴가 보인다.

다가오는 저 사람은 누구인가

기본 구조는 단순하다. 퇴근길에 인카운터가 몇 번 발생하고, 그때마다 누군가 말을 건다. 나는 선택지를 골라서 대응한다. 무사히 통과하면 다음 스테이지로, 집에 도착하면 엔딩이다.

핵심은 다가오는 사람이 도믿맨인지 시민인지 처음엔 모른다는 거다. 시나리오는 네 부류로 나눠서 넣었다. “도를 아십니까”로 직진하는 클래식 도믿맨, 설문조사나 무료 이벤트로 위장한 진화형 도믿맨, 그리고 진짜로 길을 묻거나 도움이 필요한 시민, 마지막으로 도믿맨끼리 서로 포교하는 것 같은 스페셜/레어 시나리오까지. 여기에 기본 시나리오 11개를 더해서 전부 111개다. 카테고리별로 세면 기본 11 + 클래식 25 + 진화형 25 + 시민 25 + 스페셜 25.

숫자가 어중간한 게 아니라 카테고리마다 25개씩 채우다 보니 이렇게 됐다. 아이디어는 순전히 관찰과 상상력의 문제라, 한번 각 잡고 앉으니까 생각보다 잘 나왔다. MBTI 무료 검사 부스, 사주 봐준다는 사람, SNS 팔로우 이벤트, 신규 입주자라며 인사하는 사람… 다 어디서 한 번쯤 겪어본 것들이다.

도믿맨 시나리오 화면. '대학교 학생회관 앞 1/5' 표시와 함께 MBTI 무료 검사 부스 상황이 나오고, 정신력 100·사회성 100·카르마 0 상태 바, 그리고 네 개의 선택지가 보인다.

위 스크린샷이 딱 위장형 시나리오 하나다. “MBTI 무료 정밀 검사”라는데, 게임이 슬쩍 힌트를 준다. “MBTI 정밀 검사? 공식 검사는 유료인데. 그리고 부스에 학과 로고가 없다.” 선택지 중엔 “저 INFP인 거 이미 알아요” 같은 회피도 있고, “MBTI 말고 도믿맨BTI 검사 아니에요? T면 도망, F면 팔려가는?” 하고 대놓고 받아치는 것도 있다.

무조건 피하면 지는 게임

처음에 대충 만들었을 때는 밸런스가 완전히 망가졌다. 모든 사람을 다 무시하고 도망치면 도믿맨한테 안 걸리니까 무조건 이기는 거다. 근데 그건 게임이 아니라 그냥 벽 보고 걷기다.

그래서 스탯을 세 개 뒀다. 정신력, 사회성, 카르마. 정신력과 사회성은 100에서 시작하고 카르마는 0에서 시작한다. 도믿맨한테 휘말리면 정신력이 깎이고, 아무나 다 피해 다니면 사회성이 깎인다. 시민을 도와주면 카르마가 오른다.

여기서 안티 익스플로잇 장치를 몇 개 넣었다. 우선 연속 거절 페널티. 계속 다 거절하면서 도망만 다니면 사회성이 점점 더 크게 깎인다. 2연속이면 -10, 3연속이면 -20, 4연속부터는 -30. 게다가 4연속쯤 되면 이런 메시지가 뜬다.

🚨 주변 사람들이 수군거립니다. “혹시 쟤가 도믿맨 아냐?” 아이러니하게도 당신이 의심받고 있습니다.

도믿맨을 피하려고 아무한테도 말을 안 걸었더니, 이제 내가 수상한 사람이 돼버리는 거다. 이 아이러니가 마음에 들어서 넣었다.

두 번째는 카르마 최소치다. 집에 도착해도 카르마가 2 미만이면 진짜 승리로 안 쳐준다. “집에는 도착했지만… 퇴근길에 만난 모든 사람을 무시했습니다. 아무도 돕지 않은 당신, 이게 정말 생존일까요?” 하고 찝찝하게 끝난다. 몸만 사리는 플레이로는 좋은 엔딩을 못 본다는 얘기다.

점수 계산도 적극적인 플레이에 후하게 줬다. 정신력·사회성은 각각 2배, 카르마는 무려 50배로 환산된다. 카르마 하나가 정신력 25점만큼의 가치라, 시민을 돕는 게 결국 점수로 이득이 되게 설계했다.

반대로 아무나 도믿맨으로 몰아도 진다

거절만 페널티를 주면 밸런스가 한쪽으로 기운다. 그럼 반대로 “다 도믿맨이다” 하고 선제공격하면 되는 거 아닌가? 그래서 반대쪽 축도 만들었다. 오인 비난 페널티다.

진짜 시민한테 “당신 도믿맨이죠?” 하고 몰아붙이면 그 시나리오는 wrongAccuse 결과로 끝난다. 그때마다 게임이 내가 놓친 걸 굳이 알려준다.

진짜 중기부 소속 창업 멘토였다… 정부 지원금 300만원을 놓쳤다.

이런 식이다. 여기선 정신력 -10, 사회성 -20, 카르마 -1이 한꺼번에 깎인다. 진짜 카페 블로거를 쫓아내면 맛집 정보를 날리고, 친절한 이웃한테 각을 세우면 당근마켓에 “비매너 구매자” 후기가 올라간다. 시나리오마다 오인 비난 문구를 따로 써서, 의심이 항상 옳은 게 아니라는 걸 계속 상기시킨다. 결국 거절만 해도 지고, 의심만 해도 진다. 재미는 그 사이를 계속 저울질하는 데 있다.

엔딩 8개

엔딩은 8개다. 무난하게 통과하면 무사 귀가고, 망하는 쪽이 셋이다. 정신력이 0이 되면 입교 엔딩 — “정신력이 바닥나 결국 도를 믿게 되었습니다… ‘형제여, 환영합니다.’” — 이게 개인적으로 제일 웃겨서 일부러 정신력을 깎아가며 몇 번 봤다. 사회성이 0이 되면 방에서 배달음식만 시키는 사회 부적응자 엔딩이고, 카르마가 모자라면 위에서 말한 냉혈한 귀가다.

나머지 넷은 히든이다. 모든 인카운터를 첫 라운드에 회피하면 투명인간(“완벽한 스텔스 퇴근… 그런데 이게 맞나?”), 정신력·사회성 90 이상에 카르마까지 채우면 완벽한 퇴근, 도믿맨을 세 번 이상 역으로 관광 보내면 역관광의 달인, 사회성을 90 이상으로 유지하면 인싸의 귀환(“연락처가 100개 늘었습니다!”)이다. 엔딩 도감을 따로 만들어서 모은 엔딩을 확인할 수 있게 했는데, 히든 엔딩 수집하는 재미로 다시 하게 만드는 게 목적이었다.

애매함을 지키는 게 제일 어려웠다

만들면서 제일 신경 쓴 건 시나리오가 너무 티나면 안 된다는 거였다. 딱 봐도 “나 도믿맨이오” 하는 시나리오만 있으면 그냥 도믿맨 찾기 게임이 되고, 반대로 진짜 시민이 너무 착하게만 나오면 이건 그냥 착한 사람 골라내기가 된다. 재미는 그 사이 애매한 구간에 있다.

그래서 시민 시나리오도 일부러 좀 수상하게 썼다. 진짜 설문조사인데 옷차림이 애매하다든가, 길을 묻는데 하필 으슥한 골목이라든가. 반대로 도믿맨은 처음엔 완전 정상인처럼 접근하게 했다. 게임이 주는 힌트도 결정적인 단서가 아니라 “부스에 학과 로고가 없다” 정도의 미묘한 것들로 뒀다. 확신은 못 주고 의심만 심어주는 거다.

이걸 111개나 쓰다 보니 나중엔 길에서 마주치는 모든 사람이 다 수상해 보이는 부작용이 생겼다. 직업병인가 싶다.

웹게임으로 만든 이유

프레임워크 없이 그냥 바닐라 JavaScript로 만들었다. ES 모듈로 시나리오 파일을 카테고리별로 쪼개고, 게임 로직·UI·사운드·공유 정도로 파일을 나눴다. 이 정도 규모에 React까지 얹을 이유가 없었다. 오히려 의존성 없이 가벼운 게 배포도 편했다.

몇 가지는 신경 써서 넣었다.

사운드는 Web Audio API로 직접 합성했다. 음원 파일 없이 클릭·성공·실패·역관광·경고·타자기, 여섯 가지 효과음을 오실레이터로 만들었다. 화음이 필요한 데는 오실레이터를 두세 개 겹쳐서, 다 합치면 오실레이터 11개가 이 게임 소리의 전부다. 파일이 없으니 로딩도 없고 용량도 안 먹는다.

PWA로 만들어서 홈 화면에 설치하면 앱처럼 열리고 오프라인에서도 돌아간다. 지하철에서 신호 끊겨도 퇴근길 생존은 계속할 수 있다.

리더보드는 OAuth로 붙였다. Google, GitHub, Kakao 중 하나로 로그인하면 점수가 저장된다. 로그인 안 해도 플레이는 되지만, 랭킹에 이름을 올리려면 로그인이 필요하다. 여기에 매일 같은 시나리오로 전체 유저가 경쟁하는 ‘오늘의 도전’도 넣어서, 남들이랑 같은 상황에서 몇 점 냈나 비교하는 재미를 더했다.

백엔드는 Bun + Hono + MongoDB로 가볍게 짰고, 배포는 늘 쓰던 대로 K3s에 ArgoCD로 GitOps 돌리는 구성이다. GitHub에 푸시하면 Gitea로 미러링되고 Gitea Actions가 도커 이미지를 빌드해서, dev에서 먼저 검증한 다음 같은 SHA 이미지를 prod로 승격시킨다. 승격은 자동이 아니고, dev에서 확인한 이미지 태그를 워크플로에 직접 넣고 확인 문자열까지 입력해야 넘어간다. 게임 하나 만드는데 인프라가 좀 과한가 싶긴 한데, 이미 있는 파이프라인에 얹는 거라 손이 별로 안 갔다.

출시 직전엔 AI 서브에이전트를 여러 개 풀어놨다

게임 본체는 4~5월에 다 만들었다. 커밋 로그를 보면 4월 5일 밤 11시에 첫 커밋으로 게임이 통째로 들어왔고, 다음날 애널리틱스와 광고, 그다음 날 로고를 붙였다. 그러고 한 달을 묵혔다가 5월 4일 저녁에 — 그날만 커밋이 네 개다 — 사운드·PWA·엔딩 도감·오늘의 도전·튜토리얼·카카오 공유를 한꺼번에 넣었다. 여기까지가 “돌아가는 게임”이었다. 참고로 이 프로젝트의 커밋은 전부 합쳐 14개다. 세션 단위로 왕창 만들어서 한 번에 커밋하는 방식이라 그렇다.

문제는 그다음이었다. 재밌는 건 다 끝났는데, 실제로 남한테 내놓으려면 손봐야 할 자잘한 게 산더미였다. 접근성, 인증 안전성, 개인정보, 오프라인 복원력, 배포 품질… 하나하나는 지루하고 종류는 제각각이라 혼자 순서대로 하기엔 지쳤다. 그래서 7월 15일 오후 5시 12분, 같은 출시 점검 프롬프트를 Codex 세션 세 개에 10초 간격으로 던졌다. 각 세션은 받은 일을 “작업 A — 화면·접근성”, “작업 B — 게임 로직·상호작용”, “작업 C — PWA·네트워크·성능”으로 쪼개 서브에이전트한테 나눠줬고, “PWA 파일은 수정하지 않는다” 같은 파일 소유권 규칙으로 서로 밟지 않게 했다. 리뷰도 정확성·보안·성능·아키텍처 리뷰어 페르소나로 나눠 병렬로 돌렸다. 그 결과를 취합한 게 그날 저녁의 하드닝 커밋 두 개고, 7월 18일에 같은 방식으로 한 번 더 돌려서 마지막 커밋을 만들었다.

이 과정에서 나온 삽질들이 재밌었다.

KST 연속 기록 버그. ‘오늘의 도전’은 매일 자정(KST) 기준으로 시나리오가 바뀌는데, 저장할 때 KST 날짜를 UTC로 되돌려 계산하는 바람에 하루가 밀렸다. 자정 직전에 시작해서 자정을 넘겨 끝내면, 실제로 플레이한 건 어제 시나리오인데 저장은 오늘 키로 되고, 어제 기록은 미완료로 남았다. 연속 출석 기록이 과거로 리셋되는 거다. 시작 시점의 날짜 키를 세션에 잡아두고 그 키로 저장하게 고쳤다.

유령 광고 박스. AdSense가 광고를 못 채우면(unfilled) 빈 iframe이 흰 박스로 화면에 남아서 메뉴랑 결과 흐름을 뚝 끊어놨다. 광고가 명시적으로 안 나오는 상태(data-ad-status='unfilled')면 컨테이너째 접도록 CSS를 넣어서 빈자리가 안 남게 했다.

inert와 포커스가 싸운 문제. 화면을 전환할 때 이전 화면을 inert로 비활성화하는 타이밍이 어긋나서, 포커스가 갈 곳을 잃고 body로 튕겨나갔다. 키보드/스크린 리더 사용자한테는 “방금 뭘 눌렀는데 커서가 사라진” 상태라 접근성 결함이다. 모달과 화면 전환의 inert·포커스 복귀를 한 곳에서 직렬로 관리하게 정리했다.

여기에 하나 더, 시나리오 원본을 건드리던 버그도 잡았다. 게임 진행 중 스탯 효과를 시나리오 객체에 바로 적용하다 보니 원본 데이터가 mutate돼서, 같은 시나리오를 다시 뽑으면 이미 변형된 상태로 나왔다. 시나리오를 뽑을 때 structuredClone으로 깊은 복사를 하고 초기 스탯 같은 상수는 Object.freeze로 잠가서 원본이 안 바뀌게 했다.

가벼운 웹게임에 프라이버시를 진심으로

마무리하면서 개인정보 동의를 제대로 다시 짰다. 원래는 페이지 열면 Google Analytics랑 AdSense가 그냥 로드됐는데, 이걸 동의 게이팅으로 바꿨다. 사용자가 명시적으로 고르기 전까지는 Google·AdSense·jsDelivr(폰트/공유 스크립트) 쪽으로 나가는 요청이 0건이다. ‘전체 허용’을 눌러야 그 3개가 로드되고, 나중에 ‘필수 기능만’으로 철회하면 다시 차단된다. 이걸 실제 Chromium을 띄워서 네트워크 탭으로 확인했다. 동의 전 0건, 허용 후 로드, 철회 후 다시 denied. 가벼운 퇴근길 게임에 이렇게까지 하나 싶긴 한데, 남한테 링크 던지려면 이 정도는 지키는 게 맞다 싶었다.

인증도 손봤다. 원래 access token을 localStorage에 영구 저장했는데, 이건 XSS 나면 토큰이 통째로 새는 구조다. 그래서 access token은 메모리에만 두고, 페이지를 새로 열면 HttpOnly refresh 쿠키로 세션을 복원하게 바꿨다. 동시에 여러 요청이 갱신을 때려도 refresh는 한 번만 나가게 합쳤다.

리더보드 문구도 정직하게 바꿨다. ‘오늘의 도전’ 결과에 “오늘 플레이어 중 상위 X%“라고 떴는데, 사실 서버에서 실제 분포를 안 가져오고 점수로 시그모이드 계산한 가짜 백분위였다. 실제 유저랑 비교하는 것처럼 쓰면 거짓말이라, getDailyPerformanceRating()이라는 이름으로 바꿔서 “이건 서버 비교가 아니라 로컬 점수 등급”이라는 걸 코드 계약 수준에서 못 박았다.

이 모든 게 회귀하지 않게 테스트도 깔았다. 외부 라이브러리 없이 Node 내장 테스트 러너로, 시나리오 구조 검증·스탯 경계·연속 거절 페널티·광고 보상 1회 제한 같은 안티 익스플로잇 규칙을 고정했고, CI는 이 테스트와 도커 이미지 스모크 테스트를 통과해야 dev로 나가게 했다. Playwright는 CI에 넣는 대신 하드닝 세션에서 썼다. 실제 브라우저를 390×844 모바일 화면으로 띄워서 시작부터 엔딩까지 완주되는지 확인하는 용도다.

게임이 은근슬쩍 하고 싶었던 말

만들고 나서 몇 번 해봤는데, 의외로 “이거 실제로 이렇게 대응하면 되겠다” 싶은 순간이 있었다. 도믿맨BTI 검사 아니냐고 받아치는 선택지 같은 건 실전에서 써먹고 싶어진다.

밸런스 잡는 게 제일 재밌었다. 무조건 도망치면 이기는 게임을 어떻게 하면 도망만으로는 못 이기게 만들까, 그러면서도 아무나 도믿맨으로 몰아붙이는 것도 손해가 되게. 연속 거절하면 내가 도믿맨으로 의심받고, 진짜 시민을 몰아세우면 좋은 기회를 놓치고. 게임이 은근슬쩍 “너도 적당히 사람이랑 어울려라”라고 말하는 셈이다.

정작 게임 자체는 봄에 다 만들어놓고, 여름에 며칠 몰아서 한 건 대부분 출시 하드닝이었다. 재밌는 부분은 짧고 지루한 부분이 길다는 게 사이드 프로젝트의 진실인 것 같다. 그래도 서브에이전트한테 지루한 부분을 병렬로 떠넘기니 그럭저럭 넘어갔다.

강남역 퇴근길이 무섭다면 여기서 미리 연습해보시길. 엔딩 8개 다 모으면 아마 실전에서도 살아남을 수 있을 거다. 아마도.

Comments

Loading comments...