놀불(Nolbul): AI들끼리 협력이 되는지 궁금해서 카드게임을 만들었다

자기 카드만 못 보는 협동 카드게임 하나비를 웹으로 만들다가, 결국 AI 에이전트끼리 얼마나 협력을 잘하는지 재보는 멀티에이전트 연구 플랫폼이 됐다. LLM만으로는 잘 안 되길래 규칙 기반 봇까지 붙인 이야기.

원래는 그냥 하나비(Hanabi)를 웹으로 만들려던 거였다. 자기 손패만 못 보는 그 협동 카드게임. 2026년 3월 23일 점심에 “Initial release: Hanabi web game platform”이라는 첫 커밋을 올렸는데, 그날 밤 9시 커밋 메시지는 “Rebrand Hanabi → Nolbul (놀불): AI cooperation platform”이다. 하나비 웹게임으로 산 시간은 딱 8시간이었던 셈이다. 만들다 보니 궁금한 게 바뀌었다. 사람 둘이서 하는 것보다 AI 에이전트 둘을 붙여놓으면 얼마나 협력을 잘할까가 훨씬 궁금해진 거다.

원작에 대한 존중은 지키고 싶어서, 서비스 푸터에 “이런 게임이 마음에 드셨다면, 원작을 구매해보세요!”라는 문구와 정품 구매 링크를 넣어뒀다. 라이브는 nolbul.jiun.dev에, 소스는 GitHub에 있다.

놀불 랜딩 화면. 1부터 5까지 다섯 색깔 카드가 부채꼴로 놓여 있고 그 아래 'NOLBUL'과 'An AI cooperation research platform'이라는 문구, 그리고 AI 협력·숨은 정보·2-5인용을 설명하는 세 카드가 보인다.

자기 카드를 못 보는 게 핵심이다

하나비를 안 해본 사람을 위해 규칙만 짧게. 5색 × 1~5, 이걸 색깔별로 1→2→3→4→5 순서대로 쌓아서 불꽃을 완성하는 협동 게임이다. 다 채우면 만점 25점. 그런데 결정적으로, 자기 손패는 못 본다. 남들 카드는 다 보이는데 내 카드만 뒷면이다.

그래서 내 차례엔 셋 중 하나를 한다.

  • 카드를 불꽃 위에 낸다 (내 카드가 뭔지 모르니까 도박이다)
  • 카드를 버려서 힌트 토큰을 하나 회수한다
  • 다른 사람한테 “네 손에서 이거랑 이건 빨강이야” 하고 힌트를 준다 (토큰 소모)

힌트 토큰은 유한하고, 잘못 낸 카드가 3번 쌓이면 그냥 게임 오버다. 결국 이 게임은 제한된 대역폭으로 서로에게 정보를 흘려서, 안 보이는 자기 카드를 추론해 협력하는 문제다. 숨은 정보와 협력. 멀티에이전트 연구에서 하나비를 고전 벤치마크로 쓰는 이유가 이거다.

에이전트가 규칙을 몰라도 되게

이걸 AI 연구용으로 쓰려면, 에이전트가 하나비 규칙을 직접 구현하게 하면 안 된다. 그건 연구가 아니라 규칙 엔진 숙제다. 그래서 서버가 REST API로 상태를 내려줄 때 두 가지를 항상 같이 준다.

  • legalActions — 지금 둘 수 있는 모든 합법적인 수. 에이전트는 이 중에서 고르기만 하면 된다.
  • actionHistory — 지금까지의 전체 행동 로그. 누가 뭘 힌트했고 뭘 냈는지 다 들어있다.

게임 상태는 항상 PlayerView로 내려간다. 서버는 절대 날것의 상태를 노출하지 않고, 요청한 플레이어 자기 카드는 언제나 가려서 보낸다. 이게 게임의 핵심 제약이니까 API 레벨에서 강제한 거다. 재현성을 위해 셔플 시드는 서버가 만들고 클라이언트엔 안 준다.

Terminal window
# 게임 만들기
POST /api/games {"options":{"numPlayers":2},"creatorName":"Agent-1"}
# 상태 받기 (내 카드는 가려진 PlayerView)
GET /api/games/{id} -H 'x-api-key: ...'
# 수 두기
POST /api/games/{id}/actions {"action":{"type":"play","playerIndex":0,"cardIndex":0}}

LLM한테는 한 발 더 갔다. /api/games/{id}/ai-context를 때리면 규칙, 지금 보이는 카드들, 힌트 이력, 위험한 카드 분석, 합법 수까지 전부 구조화된 텍스트 한 덩이(prompt 필드)로 내려온다. LLM은 그걸 그대로 먹고 결정만 뱉으면 된다.

한 가지 의도적으로 밀어붙인 게 있다. README를 에이전트가 스스로 읽고 온보딩하도록 썼다. AI 에이전트가 저장소를 클론해서 서버를 띄우고, REST로 직접 게임을 몰아가는 흐름을 quickstart 섹션에 그대로 정리해뒀다. 사람용 문서 겸 에이전트용 실행 매뉴얼인 셈이다. 이 방향은 ICLR 2025의 Generalist Agent 논문(arXiv:2405.09324)에서 영감을 받았다. 웹 대기실에서 “AI 플레이어 추가”를 누르면 Claude/GPT/Gemini가 빈자리를 채우고 알아서 자기 턴을 둔다. 사람이랑 AI를 섞어서 한 판에 넣을 수도 있다.

놀불 대기실 화면. 게임 ID와 참가자 목록(방장 '관찰자' 1/2)이 보이고, 아래에 '🤖 AI 플레이어 추가' 버튼이 있어 빈자리를 AI로 채울 수 있다.

놀불 게임 판. 위쪽에 AI 팀메이트의 카드(4·2·1·2·5)가 다 보이고, 아래쪽 내 손패는 전부 물음표로 가려져 있다. 상단에 단서 토큰 8개, 실패 0, 덱 40장, 점수 0/25가 표시돼 있다.

위 스크린샷이 그 상황이다. 팀메이트(AI-Gemini) 손패는 4·2·1·2·5까지 훤히 보이는데, 정작 내 손패는 전부 물음표다. 저 상태에서 상대가 주는 힌트만 가지고 내 카드를 추론해야 한다.

LLM만으로는 생각보다 못 하더라

여기서부터가 진짜 재밌었던 부분이다. 처음엔 당연히 LLM을 그대로 앉혀놓으면 알아서 잘할 줄 알았다. 아니었다. 2인 게임에서 만점이 25인데, LLM 자율 플레이는 생각만큼 안 나왔고 무엇보다 매 턴 토큰을 태우니 실험 한 번 돌리는 비용이 아팠다.

프롬프팅을 다듬으면 좀 나아지긴 했다. 한 번에 결정을 뽑는 1-step 대신, 힌트를 먼저 해석하고 그 위에서 수를 고르는 2-step 프롬프팅을 붙였다. 예를 들어 색깔 힌트가 오면, 지금 불꽃이 쌓인 상태를 보고 “이 색이면 다음에 놓일 숫자는 하나로 정해진다”를 먼저 연역하게 하는 식이다(color implies rank). 프로바이더도 Claude/GPT/Gemini에 더해 Azure OpenAI(gpt-5-nano, gpt-5)까지 붙여서, 같은 게임을 여러 모델로 돌려봤다. 그래도 근본적으로 LLM만으로는 비용 대비 만족스럽지 않았다.

그래서 방향을 틀었다. 첫 커밋 이틀 뒤인 3월 25일, LLM을 빼고 규칙 기반 봇을 짰다. 카드 하나하나의 가능성을 추적하는 CardTracker, 그리고 하나비 고수들이 쓰는 H-Group 컨벤션을 코드로 옮긴 RuleBot. 이 조합으로 LLM 비용 0원에 평균 14.0/25가 나왔다. LLM 없이도 이만큼 나온다는 게 오히려 재밌는 결과였다. 여기서부터는 벤치마크를 500판씩 돌리면서 이 14.0을 어디까지 밀어 올릴 수 있는지가 게임이 됐다.

두 봇이 같은 코드라 서로의 의도를 재구성한다

가장 크게 점수를 끌어올린 한 방은 송신자-수신자 동기화였다. 이게 이 프로젝트에서 제일 멀티에이전트다운 지점이라 따로 적어둔다.

핵심은 wouldBePlayClue()라는 함수 하나다. 수신자가 힌트를 받았을 때, “누가 내 카드를 건드렸네” 하고 마는 게 아니라 송신자의 추론을 그대로 시뮬레이션한다. “내가 만약 (내 카드를 볼 수 있는) 송신자였다면, 지금 바로 낼 수 있는 카드를 건드리려고 이 힌트를 줬을까?” 답이 예스고, 그 초점 카드가 지금 불꽃 상태에서도 여전히 낼 수 있는 카드라면, 그냥 바로 낸다.

이게 되는 이유는 두 봇이 완전히 같은 코드를 돌리기 때문이다. 송신자와 수신자의 정책이 동일하니까, 수신자는 힌트를 준 순간 송신자가 무엇을 봤을지를 역으로 복원해 그 의도를 재구성할 수 있다. 명시적으로 “이건 플레이하라는 뜻”이라고 약속한 적이 없는데도, 정책의 대칭성만으로 암묵적 통신이 성립하는 거다.

숫자로도 확실했다. 3월 28일 500판 기준 평균 14.0 → 17.2 (+22.9%), 그런데 실패는 오히려 1.37 → 1.19로 줄었다. 더 공격적으로 낸 게 아니라 더 정확하게 낸 것이다. 20~24점대에 안착한 판이 6판에서 115판으로 19배가 됐고, 500판을 통틀어 처음으로 만점 25가 나왔다. 같은 날 따로 돌린 세이프 모드(확실한 카드만 내는 변형)는 500판 내내 실패 0으로 역시 평균 17.2를 찍었고, 실서비스에는 둘을 합친 조합을 평균 17.1로 배포했다.

예상이 빗나간 실험

다음 큰 레버는 당연히 엔드게임이라고 생각했다. 덱이 마르는 막판엔 더 공격적으로 내고, 막을 수 없는 죽은 카드는 빨리 버리고, 마지막 턴엔 50% 이상이면 도박을 거는 식으로 ruleBotEndgame을 짰다. 기대는 컸다.

결과는 평균 14.0 → 14.1, 딱 +0.1이었다. 심지어 실패는 1.37 → 1.68로 늘었다. 혹시나 해서 1000판으로 다시 돌려도 똑같이 +0.1이었다. 예상이 시원하게 빗나간 것이다. 500판을 뜯어보니, 엔드게임 분기 자체가 거의 발동하지 않았다. 막판에 도달할 즈음이면 낼 수 있는 카드는 이미 다 났거나 잃은 뒤라, “마지막 턴 도박” 같은 로직이 활약할 무대가 없었다. 오히려 공격적인 50% 임계값은 넓게 적용하면 순손해였고, 아주 마지막 턴에 실패 여유가 2 이상일 때로 좁혔을 때만 겨우 도움이 됐다.

그럼 그 +0.1은 어디서 왔나. 도박이 아니라 평시의 죽은 카드 사이클링이었다. 막을 수 없게 된 카드를 평범한 턴에 빨리빨리 버려서 덱을 계속 돌린 게, 실험에서 진짜로 값어치를 한 부분이었다. 화려한 엔드게임 도박에 걸었는데 정작 점수를 낸 건 지루한 살림살이였던 셈이다. 직관적으로 커 보이는 레버가 진짜 레버가 아닐 때가 있다는 걸 다시 배웠다.

인덱스가 밀리는 버그

숫자를 올리다 막힌 지점이 하나 있었는데, 이게 전형적인 삽질이라 기록해둔다.

세이프 모드로 500판을 돌렸더니 실패(strike)가 계속 났다. --diagnose로 50판을 뜯어봤더니, 실패 64건이 전부 한 곳에서 나왔다. “이건 확실히 낼 수 있는 카드”라고 판단한 지점(P1-definitelyPlayable). 가능성 매트릭스가 딱 하나로 좁혀졌는데, 그 하나가 틀린 거였다. 실제론 노랑-1인데 봇은 하양-1이라고 확신하고 냈다.

원인은 applyNegativeClues였다. 누가 “네 손에서 이건 빨강이야” 하고 힌트를 주면, 그 힌트를 안 받은 나머지 카드들엔 “이건 빨강 아님”이라는 부정 정보가 붙는다. 여기까진 맞다. 문제는 이 부정 정보를 과거 행동 이력에서 다시 재생할 때다.

카드를 내거나 버리면 손패에서 그 자리가 비고, 뒤 카드들이 앞으로 당겨지면서 인덱스가 밀린다. 그리고 새 카드가 맨 뒤에 붙는다. 그런데 봇은 옛날 힌트를 지금의 손패 위치에 그대로 다시 적용하고 있었다. 힌트가 나왔을 땐 그 자리에 있지도 않았던 카드에 엉뚱한 부정 정보를 씌운 거다. 그러니 가능성이 공격적으로 잘못 소거되고, 틀린 카드 하나로 좁혀지고, “확실히 낼 수 있음” 검사를 통과하고, 실패.

급한 처방은 부정 힌트 재생을 그냥 꺼버리는 거였다. 인덱스가 밀리는 한 이 로직 자체가 신뢰 불가라, 카드 객체에 직접 박히는 긍정 힌트와 남의 손에 보이는 카드로 하는 소거만 믿게 했다. 그랬더니 실패가 0으로 떨어졌다. 물론 봇이 지나치게 소심해진 대가는 있었다. 제대로 된 해법은 인덱스 대신 카드 ID로 부정 정보를 추적하는 거고, 3월 29일 새벽 1시에 그 수정을 커밋하면서 평균이 18.7로 올라갔다. 한 시간 반 뒤 새벽 2시 34분, 엔드게임 사이클링과 동기화 임계값을 다듬은 커밋에서 평균 19.1/25에 안착했다.

돌이켜보면 흔한 버그다. “배열에서 원소 지우면 인덱스 밀린다”는 건 누구나 안다. 그런데 그게 게임 이력 재생과 얽히니까, 겉으론 “봇이 가끔 이상한 카드를 낸다”는 증상으로만 보였다. 진단 도구 없이 눈으로 봤으면 한참 헤맸을 거다.

일주일 뒤인 4월 5일, 이 19.1 봇으로 인원수를 바꿔가며 1000판씩 돌린 게 마지막 실험이었다. 2인 19.1, 3인 18.8, 4인 16.3, 5인 13.5. 20점 이상 비율은 2인에서 49%인데 5인에선 0.1%다. 힌트 토큰은 그대로인데 조율해야 할 손이 늘어나니, 인원이 늘수록 협력이 가파르게 어려워지는 게 숫자로 보였다.

페르소나와 팀메이트 모델

규칙 봇은 두 봇이 같은 코드라 대칭성으로 협력이 됐다. 그럼 서로 정책이 다른 에이전트끼리도 협력을 맞춰갈 수 있을까? 이게 가장 깊게 파고든 멀티에이전트 실험이었다.

bench-persona.ts에서 각 봇에 세 가지를 줬다.

  • 전략 페르소나 — “힌트가 색이면 그 색의 최신 카드가 낼 수 있다는 뜻”, “신중하게” vs “공격적으로” 처럼, 힌트를 해석하고 행동하는 서로 다른 규칙셋.
  • 팀메이트 모델 — 상대의 행동을 관찰하며 “이 팀메이트는 이런 상황에서 이렇게 두더라”를 누적해가는 예측 모델. 봇마다 다른 팀메이트에 대한 모델을 각자 들고 있다.
  • 충돌 해소 — 내 페르소나 전략과 팀메이트 예측이 어긋날 때 무엇을 믿을지 정하는 규칙.

프롬프트에 “이 팀메이트는 지금까지 이렇게 행동했다”는 요약을 넣어주고, “팀메이트는 내가 뭘 할 거라 기대할까? 걔는 다음에 뭘 둘까?”를 같이 고려해서 결정하게 했다. 정책이 동일해서 거저 얻는 대칭성이 아니라, LLM이 상대의 행동을 관찰해 모델을 세우고 거기에 적응할 수 있는지를 보는 실험이다. 규칙 봇의 동기화 트릭이 “같은 코드라 통한다”였다면, 이쪽은 “다른 정책이라도 관찰로 맞춰갈 수 있나”를 묻는다. 아직 결론을 내릴 만큼 돌려보지 못한, 그래서 제일 재밌는 방향이다.

실험은 CLI로, 관찰은 웹으로

이런 튜닝을 웹 UI로 하면 미쳐버린다. 그래서 웹 없이 게임을 돌리는 CLI 러너를 따로 뒀다. self-play는 모든 자리를 LLM으로 채워서 AI끼리 협력하는 판을 돌리고, 프로바이더를 섞을 수도 있다. 이를테면 Claude와 GPT를 한 팀에 앉혀서 서로 다른 모델이 협력을 얼마나 잘 맞추는지 보는 식이다.

Terminal window
# 두 자리를 각각 Claude와 GPT로 채워서 한 판
npx tsx src/self-play.ts --provider claude,openai
# AI가 방을 만들고 웹에서 사람이 들어오길 기다리기
npx tsx src/play.ts --create --players 2 --provider claude

관찰은 반대로 웹이 편하다. 관리자 패널에서 지금 도는 게임들, 평균 점수, 누적 통계를 실시간으로 본다. 프로바이더/모델도 런타임에 바꿀 수 있어서, 서버를 안 내리고도 “이번엔 Gemini로 100판” 같은 걸 돌린다. 결국 봇이든 사람이든, CLI든 웹이든 전부 같은 게임 엔진 위에서 도는 게 이 구조의 핵심이다.

마무리

하나비 웹게임 하나 만들려다가, 어쩌다 멀티에이전트 협력 벤치마크가 됐다. 첫 커밋부터 마지막 실험까지 2주, 커밋 47개. 엔진은 순수 TypeScript에 의존성 0, 서버는 Hono + SQLite, 프론트는 React 19 + SVG로 그렸다. 규칙은 엔진 하나에 몰아넣고 32개 테스트로 잠가놨는데, 덕분에 봇이든 사람이든 웹이든 CLI든 다 같은 엔진 위에서 논다.

가장 재밌었던 건 LLM을 뺐더니 오히려 점수가 잘 나온 지점, 그리고 두 봇이 같은 코드라는 것만으로 서로의 의도가 재구성되던 순간이었다. 가장 성가셨던 건 인덱스가 밀리는 그 부정 힌트 버그였고, 가장 뜻밖이었던 건 엔드게임 실험이 예상을 시원하게 빗나간 거였다. 사람도 들어와서 AI랑 한 팀으로 붙을 수 있으니, 궁금하면 한 판 돌려보시길. 참고로 나는 아직 AI만큼 협력을 잘 못한다.


스택: TypeScript 5.9, React 19, Hono, libsql(SQLite), Zustand, pnpm + Turborepo

Comments

Loading comments...