2~6명이 손패를 받아 맨 위 카드와 모양이나 숫자가 같은 카드를 내고, 먼저 손패를 모두 털면 1등입니다. 공격카드로 몰아붙이고, 7로 모양을 바꾸고, K로 한 번 더 냅니다. 게임은 모든 사람의 순위가 정해질 때까지 이어집니다. 방 만들기·초대 링크·채팅·턴 타이머·재접속은 플랫폼이 이미 하고 있었기 때문에, 원카드가 새로 가져온 것은 규칙 파일 하나, 테스트, 화면, 그리고 등록 세 줄이었습니다. 다만 해 보면서 "게임 하나로는 해결되지 않는" 요구가 계속 나왔고, 그것들은 플랫폼 쪽으로 올려 보냈습니다(→ LOG_05).

01무엇을 만들었나 — 규칙

  • 덱 — A~K 네 모양 52장 + 조커 2장. 4명 이하는 7장, 5명 이상은 5장씩. 남은 더미의 맨 윗장을 펼쳐 시작하고, 선은 매 판 무작위입니다.
  • 공격카드 — 조커(4장), A(3장), 2(2장). 반격은 누적되고, 반격하지 못한 사람이 쌓인 만큼 가져갑니다. 조커 위엔 ♠A·조커만, A 위엔 A·조커만, 2 위엔 2·같은 모양 A·조커만 반격할 수 있습니다.
  • 특수카드 — Q는 방향 반대, J는 한 명 건너뛰기, K는 한 번 더. 둘만 남으면 Q·J도 한 번 더가 됩니다. 7은 낸 사람이 모양을 새로 정하고, 맨 위 카드 아래에 "지정 모양 : ♥"가 뜹니다.
  • 이어 내기 — "한 번 더" 카드 다음에 낼 수 있는 카드는 순서대로 골라 한 번에 냅니다(♥K → ♠K → ♠4). 여러 차례를 한 번에 내는 것과 같아서, 효과는 마지막 카드만 적용합니다.
  • 원카드 — 내고 1장이 남으면 자동으로 "원카드!" 말풍선이 뜹니다.
  • 공격카드로 끝냈을 때 — 순위를 바로 확정하지 않고 "확정 대기"로 둡니다. 반격이 돌아오지 않고 공격이 다른 사람에게서 끝나야 확정되고, 돌아오면 쌓인 장수를 받아 게임에 다시 참여합니다.
  • 특별룰(방 옵션) — ① 같은 모양 연속 숫자 한 번에 내기(K 다음 A), ② 1바퀴 뒤부터 2바퀴마다 모든 참가자의 카드 1장씩 공개, ③ 2바퀴 뒤부터 2바퀴마다 10% 확률의 손패 교환 룰렛. 대기방에서 방장이 체크박스로 켜고 끕니다.
  • 시간 — 한 차례 15초. 낼 수 있는 카드를 고를 때마다 다시 15초로 채워지고, 한 차례는 최대 40초. 시간이 지나면 1장(공격 중이면 쌓인 만큼)을 가져옵니다.

02진행 방식 — 규칙 먼저, 멈추고, 물어본다

작업은 LOG_05와 같은 방식으로 AI 코딩 에이전트와 나눠 진행했습니다. 에이전트는 플랫폼의 "새 게임 추가 가이드"를 끝까지 읽고 그대로 따르는 것에서 시작했고, 순서는 규칙 모듈과 단위 테스트만 먼저 → 멈추고 보고 → 확인 후 화면과 DB였습니다. 가이드에 적어 둔 플랫폼 한계(방 옵션 UI 없음, 게임 중 채팅 없음)에 걸리는 요구는 코드를 쓰기 전에 먼저 물어보게 했습니다.

규칙 설명에는 해석이 갈리는 곳이 생각보다 많았습니다. "모든 참가자의 카드 중 1개 공개"는 한 장인가, 사람마다 한 장인가. 연속 숫자에 특수카드가 섞이면 효과를 다 더하는가. 이런 건 선택지로 묻고, 묻기엔 사소한 것은 에이전트가 정하되 "제가 정한 것"으로 따로 보고해서 바꿀 수 있게 했습니다 — 시작 카드의 특수 효과는 없음, 조커 위에는 아무 카드나, 낼 카드가 있어도 드로우 가능, "1바퀴"는 남은 사람이 모두 한 번씩 차례를 마친 시점, 같은 것들입니다. 원격 DB 반영과 배포는 사람이 직접 실행하고, 에이전트는 실행할 명령만 알려 주는 것도 처음부터 정해 두었습니다.

03규칙 모듈 — 상태, 시점, 사건

원카드도 31 게임처럼 React와 Supabase를 모르는 순수 TypeScript 파일 하나입니다. 같은 파일이 브라우저(개발용 미리보기), Node(테스트), Deno(Edge Function 서버 검증)에서 그대로 돕니다.

카드   "S1"(♠A) … "H13"(♥K), "JK1", "JK2"          — 문자열이라 JSON 저장이 가볍다
state  hands · drawPile · discard · attack · declaredSuit
       ranking · pending(확정 대기) · round · rng · events
view   myHand(내 것만) · 남의 장수 · topCard · attack
       playable · comboAllowed · headsUp · canDraw   — 지금 누를 수 있는 것
events play · draw · oneCard · finish · reshuffle · reveal · swapRoll
       — 마지막 행동 하나가 만든 사건. 화면은 이걸로 애니메이션과 소리를 만든다

규칙 모듈이 다루는 세 가지 — 서버만 아는 상태, 각자에게 주는 시점, 연출용 사건

무작위는 Math.random()을 쓰지 않습니다. 판을 시작할 때 서버가 주는 seed 하나로 mulberry32 난수를 만들고, 난수의 현재 값을 state 안에 저장합니다. 그래서 판 도중의 재셔플·카드 공개·교환 룰렛까지 모두 재현 가능하고, 같은 seed면 같은 판이 나옵니다. 모든 함수는 입력 state를 바꾸지 않고 새 state를 돌려주는 순수 함수이며, 테스트가 이를 확인합니다.

화면은 규칙을 다시 계산하지 않습니다. 낼 수 있는 첫 장(playable)은 view에 담아 보내고, 두 번째 장부터 이어 붙일 수 있는지는 규칙 모듈이 내보내는 canContinue() 하나로만 판단합니다. 7로 지정된 모양, 둘만 남았는지(J·Q가 "한 번 더"가 되는지)도 view가 알려 줍니다.

04정보가 새지 않게 — 규칙을 바꾼 결정들

손패가 있는 게임에서는 "무엇을 보여 주지 않을 것인가"가 규칙만큼 중요했습니다. 서버는 전체 state를 누구에게도 주지 않고 각자에게 getPlayerView(state, 본인)만 줍니다. 그 위에서 몇 가지를 더 걸렀습니다.

  • 가져온 카드 — draw 사건에는 장수만 남기고 어떤 카드였는지는 view에서 지웁니다. 가져온 본인은 자기 손패로 알 수 있습니다.
  • 교환 방향 — 손패가 정방향으로 넘어갔는지 역방향인지는 서버 상태에만 두고, 화면에는 "바뀌었다"만 보냅니다.
  • 확정 대기 중인 사람의 차례 — 처음엔 반격이 돌아오면 손패가 없는 사람이 자동으로 쌓인 장수를 받게 했습니다. 그런데 이러면 차례가 순식간에 지나가서, "반격할 카드가 있는데 일부러 안 내는 사람"과 "아예 없는 사람"이 구분됩니다. 그래서 규칙을 바꿔 보통 차례처럼 제한시간을 기다리게 했습니다.
  • 시간 리셋과 눈치 — "카드를 고르면 시간이 다시 채워진다"는 규칙을 넣을 때, 낼 카드가 없는 사람은 고를 수 없어 타이머가 줄기만 하니 다른 사람이 눈치챌 수 있다는 점을 먼저 짚었습니다. 아무 카드나 눌러도 리셋하는 방안과 비교해 보고, 트레이드오프를 알고 원래 방식을 택했습니다.

05화면 — 사각 텍스트박스가 아니라 카드

카드는 실제 트럼프 비율(5:7)로 CSS만으로 그립니다. 모서리의 숫자와 모양, 가운데 큰 무늬, 빨강(♥♦)·검정(♠♣), 따로 그린 조커, 사선 무늬의 뒷면. 손패는 ResizeObserver로 폭을 재서 한 줄에 들어가도록 카드를 72px에서 46px까지 줄이고, 그래도 넘치면 여러 줄로 놓습니다. 375px 모바일 폭에서 가로 스크롤이 생기지 않는 것을 자동화로 확인했습니다.

움직임은 view.events를 보고 만듭니다. 카드를 낸 사람의 자리(내 카드면 그 카드의 실제 위치)에서 낸 카드 더미로, 더미에서 가져간 사람의 자리로 카드가 날아갑니다. 각 요소의 화면 좌표를 재고 Web Animations API로 복제 카드를 이동시키며, 날아가는 동안에는 더미 맨 위를 이전 카드로 잡아 두었다가 도착하는 순간 바꿔서 "카드가 먼저 보이고 날아오는" 어색함을 없앴습니다. 게임이 시작되면 자리마다 한 장씩 돌아가며 배분하고, 제출·드로우·배분·룰렛마다 소리가 납니다. 여러 장이 연달아 날 때 소리가 겹쳐 들리도록 오디오를 복제해서 재생하고, 소리 끄기는 브라우저에 기억합니다.

  • 선택 — 낼 수 없는 카드는 흐리게 보이고 눌리지 않습니다. 이어 낼 수 있는 카드를 계속 고르면 선택 순서가 번호로 붙고, 7로 끝나면 제출 전에 ♠♥♦♣를 고릅니다.
  • 테이블 — 왼쪽은 뒤집힌 카드 더미, 오른쪽은 낸 카드. 내 차례에만 더미 위에 [드로우](공격 중이면 [N장 드로우])가 생기고, 더미를 눌러도 가져옵니다.
  • 상대 자리 — 내 다음 사람부터 순서대로, 남은 장수와 작은 카드 뒷면 더미, 순위·확정 대기·원카드 표시.

06특별룰 연출 — 화면을 멈추는 팝업과 구슬 룰렛

처음의 카드 공개는 상대 자리 위에 5초간 띠로 떴습니다. 해 보니 두 가지가 문제였습니다. 공개 직후 차례인 사람은 자기 10초가 동시에 흐르고 있어서 공개된 카드를 볼 여유가 없었고, 모두 자기 손패를 보느라 공개가 된 줄도 몰랐습니다. 그래서 공개를 화면 전체를 덮는 팝업으로 바꾸고, 카드가 한 장씩 뒤집히며(rotateY와 뒷면 숨기기) 나오게 했습니다. 그리고 그동안 차례 자체를 멈추게 했습니다.

차례를 멈추는 건 화면이 아니라 서버가 해야 합니다. 그래서 게임 모듈이 "방금 만든 상태에서 다음 차례를 얼마나 멈출지"(getTurnTiming → lockMs)를 말하면, 플랫폼이 그만큼 마감을 늦추고 그 시각 전의 행동은 거부하는 장치를 만들었습니다(→ LOG_05). 원카드는 공개나 룰렛 사건이 있으면 5초, 둘만 남으면 차례가 자주 돌아와 2초 줄인 3초를 돌려줍니다. 연출 시간은 사건 안에 함께 실어 보내서 화면의 팝업 길이와 서버의 멈춤 길이가 항상 같습니다.

손패 교환은 구슬 룰렛이 됐습니다. 10칸짜리 판(conic-gradient) 중 한 칸이 "바꿔!!", 아홉 칸이 "아무 일도.. 없었다..". 결과는 이미 서버가 정해서 보냈고, 화면은 그 칸에 멈추도록 구슬을 몇 바퀴 돌린 뒤 천천히 세웁니다. 칸 안에서 살짝 비껴 멈추게 해서 긴장감을 남겼고, 돌아가는 소리는 구슬이 멈추면 함께 멈춥니다. 규칙도 함께 조율했습니다. 공개는 1·3·5…바퀴, 교환 판정은 2·4·6…바퀴가 끝날 때라서 둘이 같은 바퀴에 겹치지 않습니다. 방향(정·역)은 반반으로 정하되 표시하지 않습니다.

07시간 — 15초, 고르면 다시 15초, 최대 40초

처음엔 30초였던 플랫폼 기본값을 원카드에서 10초로 줄였다가, 여러 장을 이어 낼 때 생각할 시간이 부족해서 "고르면 다시 채운다"를 넣었고, 최종적으로 15초·최대 40초가 됐습니다.

"고르면 다시 채운다"는 매번 게임 상태를 저장하면 너무 무겁습니다. 그래서 판 상태는 건드리지 않고 마감 시각만 늦추는 가벼운 DB 함수를 따로 만들었습니다. 차례인 사람만 부를 수 있고, 연출 중이거나 이미 마감이 지났으면 무시하고, 1초 이상 늘어날 때만 반영하고, 잠금이 끝난 시각부터 최대 40초를 넘기지 않습니다. 이 함수는 방의 version을 올리지 않습니다 — 올리면 카드를 고르고 바로 낸 요청이 버전 충돌로 튕기기 때문입니다. 다른 사람들의 타이머는 새 마감 시각만 담은 신호로 갱신하고, 실제 마감 판정은 언제나 서버가 DB 값으로 합니다.

08즉시 반응 — 누르는 순간 카드가 움직인다

서버 지역은 이미 서울이었는데도 "약간의 딜레이"가 느껴졌습니다. 원인은 한 행동이 거치는 왕복 수였고, 플랫폼 쪽에서 왕복을 줄였습니다(→ LOG_05). 원카드 화면에서는 체감 지연을 한 번 더 줄였습니다. [제출]이나 [드로우]를 누르면 서버 응답을 기다리지 않고 그 자리에서 바로 카드를 날리고, 날아간 카드는 손패에서 숨겨 둡니다. 서버가 받아들이면 응답에 담겨 온 사건 중 이미 날린 카드는 다시 날리지 않고, 거절하면 숨긴 카드를 손패로 되돌립니다.

09문제 해결 기록

  • 닫히지 않는 공개 창 — 공개된 카드는 몇 번의 행동 뒤 view에서 빠지게 해 두었는데, 그 순간 React effect의 정리 함수가 5초 타이머까지 지워서 창이 영영 닫히지 않았습니다. 그 창이 드로우 버튼까지 덮고 있어서 브라우저 자동화가 30초 동안 버튼을 못 누르고 멈춘 덕에 찾았습니다. 타이머를 effect와 떼어 ref로 관리하게 고쳤습니다.
  • 겹쳐 보이는 카드 더미 — 카드 컴포넌트에 원래 붙어 있던 relative와 바깥에서 준 absolute가 한 요소에서 부딪혀, 세 장이 쌓여야 할 더미가 아래로 늘어졌습니다. 위치는 감싸는 요소가 맡게 나눴습니다.
  • 뒤집혀 읽히는 룰렛 글씨 — 칸 글씨를 세로쓰기로 돌렸더니 절반이 거꾸로 읽혔습니다. 가운데에서 바깥으로 향하는 가로쓰기로 바꾸고, 왼쪽 절반만 180도 돌려 모든 칸이 바로 읽히게 했습니다.
  • 7이 봇을 멈췄다 — 7에 "모양 지정"이 생기자, 낼 수 있으면 아무 카드나 내던 테스트 봇이 7에서 "모양을 골라야 합니다"로 거절당했습니다. 규칙이 아니라 봇이 새 규칙을 몰랐던 것이라, 테스트와 개발용 페이지의 봇이 7일 때 모양을 고르게 했습니다.
  • 편집 스크립트 사고 — 파일의 한 구간을 통째로 바꾸는 스크립트가 끝 표식을 파일 앞쪽에서 먼저 찾는 바람에 빈 문자열을 치환해 규칙 파일이 1.2MB로 부풀었습니다. 바로 git으로 되돌리고, 그 뒤로는 정확히 한 번만 일치하는지 확인하는 편집만 쓰기로 했습니다.
  • 화면이 있어야 보이는 것들 — 서버 없이도 모든 시점을 눌러볼 수 있는 개발용 페이지(/dev/one-card)를 두었습니다. 인원과 특별룰을 바꾸고, 한 사람 시점을 고정하고, "차례인 사람 자동 1수"와 "시간 초과 적용" 버튼으로 판을 굴립니다. 공개 팝업·룰렛·모양 선택기·말풍선을 모두 이 페이지에서 Playwright로 찍어 가며 다듬었습니다.

10검증 — 무엇을 어떻게 확인했나

원카드 단위 테스트 41
  — 준비(54장·7장/5장·카드 중복 없음) · 시드별 무작위 선 · 정상 진행 · 잘못된 행동(모양·범위·차례)
  — 공격 누적과 반격 규칙 · Q/J/K · 둘만 남았을 때 · 원카드 · 순위 · 확정 대기와 되돌아오는 공격
  — 재셔플 · 모자란 더미 · 연속 숫자 · 7 모양 지정 · 이어 내기 · 공개/룰렛 주기와 연출 시간
  — 교환 확률 2000판(7~13%) · 비공개 정보 · 시간 초과 200번 연속(카드 54장 보존)
  — 봇끼리 60판 끝까지(2~6인, 매 수마다 54장 보존) · 순수성 · 플랫폼 연동(방 옵션·연출 잠금)
DB 테스트        — 원카드 행·방 옵션 RPC · 차례 연장 · 연출 잠금 (로컬 Postgres)
화면             — 개발용 페이지 + Playwright 스크린샷 (375px·1100px, 가로 스크롤 없음)

교환 확률은 판정 2000번을 돌려 실제로 10% 근처에 들어오는지, 정방향·역방향이 둘 다 나오는지, 넘어간 손패가 정확히 옆 사람의 것인지를 봅니다. 가장 든든했던 건 봇 대국입니다. 2~6인을 섞어 60판을 끝까지 두게 하고, 매 수마다 모든 손패·더미·낸 카드를 합친 수가 54장인지 확인합니다. 카드가 한 장이라도 생기거나 사라지는 버그는 여기서 걸립니다.

11지금 이 자리

원카드는 실제로 여러 명이 하면서 규칙과 시간을 조율하는 단계에 있습니다. 팝업을 몇 초 보여 줄지, 둘만 남으면 얼마나 줄일지, 한 차례를 몇 초로 할지 — 모두 해 본 사람의 감각으로 정한 숫자이고, 규칙 버전(rulesVersion)을 올려 가며 바꿔 왔습니다.

이번 조율(Tune)에서 남은 원칙은 세 가지입니다. 손패는 각자에게만, 무작위는 시드로, 연출 시간은 서버가. 그리고 한 게임이 요구한 것이라도 다른 게임에도 쓸모 있으면 게임이 아니라 플랫폼에 둡니다. 다음은 한 판 더 비율을 관찰(Observe)하고, 이 카드 게임 뼈대 위에 세 번째 게임을 올리는 일입니다.