2~8명이 카드를 똑같이 나눠 갖고, 차례대로 자기 더미의 맨 윗장을 뒤집어 앞에 냅니다. 각자 앞에 보이는 맨 위 카드 중 같은 색 몇 장의 합이 5나 10이 되면 [지금이니!!]를 누릅니다. 가장 빨리 누른 사람이 테이블의 카드를 모두 가져가고, 조합이 없는데 누르면 벌칙으로 카드 3장을 버립니다. 할리갈리와 같은 부류의 순발력 게임이라, 시작 전에 먼저 이런 질문부터 했습니다 — 웹에서 이런 속도 게임을 여러 명이 하면, 지연 때문에 순서가 꼬이지 않을까?

01무엇을 만들었나 — 규칙

  • 카드 — 색마다 1~9. 1·2는 4장, 3~5는 3장, 6~9는 2장(한 색 25장). 2~4명은 빨강·파랑 2색(50장), 5~8명은 초록까지 3색(75장). 흰 바탕에 숫자를 카드 색으로 크게 그립니다.
  • 나누기 — 모두 섞어 같은 장수씩 나누고, 나누어떨어지지 않는 만큼은 무작위로 빼서 중앙에 뒤집어 둡니다. 자기 카드는 아무도(본인도) 볼 수 없고 순서도 못 바꿉니다.
  • 진행 — 시작하면 모두의 맨 윗장을 동시에 공개하고, 선은 무작위, 이후 대기방 순서대로 한 장씩 냅니다. 한 사람당 10초, 시간이 지나면 맨 윗장이 자동으로 나갑니다(고를 것이 없으니).
  • [지금이니!!] — 각자 앞 더미의 맨 위 카드 중 같은 색 일부의 합이 5나 10이면 성립합니다(5 한 장도 성립). 가장 빠른 사람이 모든 앞 더미와 중앙 카드를 섞어 자기 더미 맨 아래에 넣고, 그 사람부터 다시 냅니다.
  • 잘못 누르면 — 가장 먼저 잘못 누른 한 사람만 위 3장을 중앙에 뒤집어 버리고, 차례는 원래대로 이어집니다.
  • 특별룰 '0' 카드 — 색마다 0을 3장 더합니다. 0이 보이면 조건 없이 누를 수 있고, 가장 빠른 사람이 0이 놓인 더미만 가져갑니다(5·10 조합도 함께 있으면 전부).
  • 탈락과 끝 — 자기 차례에 낼 카드가 없으면 탈락하고 버튼도 못 누릅니다. 차례가 오기 전이면 0장이어도 눌러서 되살아날 수 있습니다. 1명이 남으면 끝나고, 늦게 탈락할수록 순위가 높습니다.
  • 판정은 한 턴에 한 번 — 판정이 끝나면 다음 사람이 낼 때까지 아무도 누를 수 없습니다(→ 06).

02진행 방식 — 코드보다 질문이 먼저

작업은 LOG_05·06과 같은 방식으로 AI 코딩 에이전트와 나눠 진행했습니다. 순서는 규칙 모듈과 단위 테스트만 먼저 → 멈추고 보고 → 확인 후 화면·플랫폼·DB. 이번에는 코드를 한 줄도 쓰기 전에 두 번 멈췄습니다.

첫 번째는 "이런 게임이 웹에서 되는가"라는 질문이었습니다. 에이전트는 플랫폼 코드를 읽고 답했습니다 — 게임 상태가 꼬이지는 않는다, 모든 행동이 버전이 맞을 때만 한 번 반영되기 때문이다. 문제는 공정성이다. 지금 구조로는 먼저 누른 사람이 아니라 서버에 먼저 도착한 사람이 이기고, 카드가 사람마다 다른 시각에 보이며, 이 차이(수십~수백 ms)가 사람의 반응속도 차이와 같은 크기다. 그리고 다른 서비스들이 쓰는 방법(기기에서 잰 반응시간, 서버 시각에 맞춘 동시 공개, 판정 창, 치팅 하한)을 정리했습니다.

두 번째는 새 게임 가이드에 적어 둔 플랫폼 한계였습니다. 규칙 설명을 받은 에이전트는 이 게임이 세 가지 한계에 걸린다고 보고하고 먼저 물었습니다 — 동시에 누른 입력이 버전 충돌로 튕기는 것, 규칙 모듈은 시간을 모르는데 0.7초 판정 창이 필요한 것, 연출 잠금 중에는 서버가 모든 행동을 거부하는 것. 플랫폼 수정 허락과 함께 규칙의 빈칸(끝나는 조건, 조합을 어떤 카드로 판단하는지, 0 카드가 무엇을 가져가는지)도 선택지로 물었고, 묻기엔 사소한 것은 "제가 정한 것"으로 따로 보고했습니다. 원격 DB 반영과 배포는 사람이 실행하고, 에이전트는 명령만 알려 주는 것도 그대로였습니다.

03핵심 — 도착 순서가 아니라 반응시간

누가 먼저 눌렀는지는 서버가 받은 순서로 정하지 않습니다. 각 기기에서 카드가 뒤집힌 순간부터 버튼을 누른 순간까지를 performance.now()로 재서 보내고, 서버는 그 시간을 비교합니다. 네트워크가 느린 사람도 손이 빠르면 이깁니다.

버튼 입력  { type: "now", revealSeq: 17, reactionMs: 312 }
           — revealSeq: 몇 번째 공개를 보고 누른 것인가 (오래된 입력 거르기)
첫 입력 도착   state.phase = "judging"  → 판정 창 0.7초 시작
창 안의 입력   presses에 모은다 (창은 늘리지 않는다)
창 마감        시스템이 judge 적용 → reactionMs가 가장 짧은 사람
               — 같으면 먼저 도착한 사람 · 20ms 이하는 무시(사람이 볼 수 없는 속도)

버튼 하나가 판정되기까지 — 모든 규칙은 순수 TypeScript 모듈 안에 있다

이 방식에는 대가가 있습니다. 기기가 잰 시간이라 브라우저를 조작하면 속일 수 있습니다. 링크로 지인끼리 모여 노는 사이트라 이 절충을 받아들였고, 사람이 낼 수 없는 반응(20ms 이하)은 서버도 거부합니다. 뒤집히기 직전에 "일단 누르고 보는" 어림 누르기는 전략으로 인정해서, 20ms를 넘기면 유효입니다.

규칙 모듈은 여전히 시간을 모릅니다. 0.7초를 세는 것은 플랫폼이고, 모듈은 "지금은 판정 중이고, 이 상태의 제한시간은 0.7초"라고 말하기만 합니다. 판정 창이 끝났을 때 적용할 행동(judge)은 기존의 "시간 초과 자동 행동" 장치를 그대로 씁니다.

04동시에 뒤집기 — 서버 시각으로 약속한다

반응시간을 공정하게 재려면 카드가 모두에게 같은 순간에 뒤집혀야 합니다. 카드를 낸 사람에게는 즉시, 다른 사람에게는 신호를 받고 다시 읽어 온 뒤에 보이면, 이미 출발선이 다릅니다.

그래서 카드를 내면 서버가 커밋한 시각에서 0.8초 뒤를 뒤집는 시각으로 정합니다. 원카드 때 만든 연출 잠금(turn_lock_until)과 시계 오차 보정(serverNow)을 그대로 활용해, 모든 기기가 자기 시계로 그 서버 시각을 환산해 동시에 뒤집습니다. 0.8초의 대기는 네트워크 왕복을 가리는 시간이기도 합니다. 내가 [제출]을 누르면 뒷면 카드가 즉시 내 자리로 미끄러져 올라가고, 약속된 시각에 뒤집힙니다 — 누르는 순간과 뒤집히는 순간 사이의 지연이 "연출"로 보입니다.

신호가 늦게 도착해 이미 뒤집는 시각을 지났으면 도착한 순간 바로 뒤집고, 반응시간도 그 순간부터 잽니다. 기기마다 자기가 실제로 본 순간이 기준입니다.

05플랫폼에 올린 것 — 동시 입력이라는 새 종류의 행동

31 게임과 원카드에서 다듬은 원칙은 "게임 행동은 클라이언트가 본 버전과 같을 때만 반영"이었습니다. 중복 클릭과 동시 타임아웃을 막는 장치인데, 이번에는 그것이 정확히 문제였습니다. 첫 버튼이 반영되는 순간 버전이 올라가서, 0.1초 뒤에 누른 두 번째 사람은 충돌로 튕깁니다. 비교할 데이터가 모이지 않습니다. 그래서 게임이 선언할 수 있는 선택 항목을 엔진에 더했습니다.

isConcurrentAction(action)   여러 명이 동시에 보내도 되는 행동인가
   — 서버는 지금 버전으로 계산하고, 연출 잠금 중에도 받는다
   — 커밋이 409로 부딪히면 Edge Function이 스냅샷을 다시 읽어 최대 4번 재시도
isSystemAction(action)       시간 초과로만 적용되는 행동인가 — 사용자가 보내면 거부 (판정 조작 방지)
getTurnTiming(state)
   lockMs          다음 차례를 멈출 연출 시간
   timeoutMs       이 상태만의 제한시간 — 판정 창 0.7초
   systemDeadline  판정용 마감 — 접속한 모두가 바로 처리 요청, 자리비움으로 세지 않음
   keepDeadline    창에 입력이 더 들어와도 원래 마감까지 남은 시간만

새 게임 하나가 요구했지만 다른 게임에도 쓸모 있어서 플랫폼에 둔 것들

"오래된 입력"은 플랫폼이 아니라 게임이 거릅니다. 버튼 입력에는 몇 번째 공개를 보고 눌렀는지가 실려 있어서, 이미 지나간 카드에 대한 입력은 모듈이 거절합니다. 동시 입력이 늦어서 거절돼도 화면은 오류를 띄우지 않고, 누르는 동안 다른 조작도 잠그지 않습니다. 시간 초과 처리는 원래 "차례인 사람이 먼저, 나머지는 0.7~2초 뒤에 대비용으로" 보내는데, 판정 마감만은 모두가 0.1초 안에 보냅니다. 먼저 도착한 하나만 반영되고 나머지는 버전 충돌로 조용히 버려집니다.

1:1 채팅도 게임별 설정이 됐습니다. 전에는 멀티게임 중이면 모두 막혔는데, 비밀 대화가 규칙을 망치는 게임이 아니면 막을 이유가 없었습니다. games.allow_dm 열을 더해 원카드와 5or10은 허용하고, 막히는 게임 중에는 채팅 창에 "1:1채팅이 허용되지 않는 게임을 진행중입니다."를 띄웁니다. 대화 목록의 "게임 중" 표시는 허용 여부와 상관없이 그대로입니다.

06판정 연출 — 기다리는 0.7초를 가린다

판정 창 0.7초와 왕복 시간을 합치면, 누른 뒤 결과가 오기까지 1초 가까이 걸립니다. 이 시간을 비워 두면 "눌렀는데 반응이 없다"가 됩니다. 그래서 누르는 순간부터 결과가 올 때까지를 한 편의 연출로 채웠습니다.

  • '지금이니!!' (0.5초) — 누가 누르면 바로 테이블 가운데에 파란 글씨의 뾰족한 외침 말풍선이 튀어나옵니다. 내가 누르면 서버 응답을 기다리지 않고 즉시.
  • 조합 표시 (1초) — 맞게 누른 경우 5나 10을 만드는 카드마다 그 카드의 숫자 색(파랑·빨강·초록) 테두리가 깜빡입니다. 틀린 경우엔 빨간 '아니야!!' 말풍선이 0.5초.
  • 결과 — 그다음 서버 판정을 받아 "○○ 님이 가장 빨랐어요! 카드 N장" 또는 "잘못 눌렀어요! 3장 버림"과 각자의 반응시간(0.312초)을 보여 줍니다.

판정 전에는 조합이 있는지를 화면으로 보내지 않습니다. view에 "지금 조합 있음"이 들어 있으면 개발자 도구로 정답을 볼 수 있기 때문입니다. 조합 정보는 누군가 누른 뒤(판정 중)에만 싣습니다. 카드를 가져가 테이블이 비어도 테두리를 그릴 수 있도록, 판정 기록에는 판정 당시의 맨 위 카드를 함께 남깁니다.

한 턴에 판정은 한 번. 처음엔 "한 공개당 한 사람 한 번"이었습니다. 그러면 틀린 판정이 끝난 뒤 느린 기기의 입력이 늦게 도착해 혼자 새 판정을 열고 벌칙을 받는 일이 생깁니다. 제때 도착했다면 더 빠른 사람 뒤에 묻혀 벌칙을 피했을 사람이, 네트워크 때문에 억울해지는 경우입니다. 판정이 한 번 끝나면 다음 사람이 낼 때까지 모든 버튼을 끄는 것으로 바꿨고, 늦은 입력은 조용히 버려집니다.

07동시에 들어온 제출과 버튼 — 규칙으로 정한다

실제로 해 보니 가장 까다로운 충돌은 [지금이니!!]끼리가 아니라 [제출]과 [지금이니!!] 사이에서 났습니다. 누군가 다음 카드를 내는 바로 그 순간 다른 사람이 지금 테이블을 보고 버튼을 누르면, 서버에는 둘 중 하나가 먼저 도착합니다.

  • 버튼이 먼저 도착 — 판정 중이 되고, 뒤따라 온 제출은 거절됩니다. 화면은 먼저 올려 둔 카드를 바로 되돌리고 오류 문구는 띄우지 않습니다.
  • 제출이 먼저 도착 — 전에는 버튼 입력이 "지나간 카드"로 버려져 판정이 아예 일어나지 않았습니다. 이제는 버튼을 우선합니다. 그 입력이 방금 제출 바로 전 카드에 대한 것이고 제출 뒤로 아무 일도 없었다면, 제출을 되돌립니다 — 카드는 낸 사람 더미 맨 위로, 차례도 그 사람에게, 그 제출 때문에 생긴 탈락까지 원래대로. 그리고 제출 전 테이블로 판정합니다.

되돌리면 공개 번호가 하나 줄어듭니다. 화면은 공개 번호별 뒤집기 시각을 기억해 두었다가, 예전 번호로 돌아오면 그때의 시각으로 반응시간을 잽니다. 새 판인지는 한 판 안에서 절대 줄지 않는 사건 번호로 판별하게 바꿨습니다. 공개 번호가 줄어드는 순간이 생겼기 때문입니다.

08해 보고 바꾼 규칙 — 카드 구성

첫 구성은 세 색 1~9를 3장씩(81장)이었습니다. 실제로 해 보니 0이나 5가 보여서 누르는 경우가 대부분이었고, 두세 장을 더해 5·10을 찾아내는 순간이 드물어 게임이 늘어졌습니다. 적은 인원에게는 카드도 너무 많았습니다(2명이면 40장씩).

그래서 작은 숫자를 늘리고 큰 숫자를 줄였습니다 — 1·2는 4장, 3~5는 3장, 6~9는 2장. 합으로 5·10을 만들 재료가 많아지고 5 한 장으로 끝나는 경우는 상대적으로 줄어듭니다. 그리고 2~4명은 빨강·파랑 2색만 써서, 2명이면 25장씩으로 판이 짧아졌습니다. 규칙 버전(rulesVersion)은 1 → 3까지 올라갔습니다.

09문제 해결 기록

  • 눌리는데 제출이 안 되는 [제출] — 앞사람이 낸 카드가 뒤집히기 전에는 서버가 다음 제출을 잠그는데, 화면은 그 사이에도 [제출]을 켜 두었습니다. 누르면 서버는 거절하는데 화면은 카드를 낸 것처럼 그리고, 되돌리는 처리가 없어 4초간 버튼이 잠겼습니다. 상대 화면에는 이전 카드가 그대로였습니다. 카드가 공개되고 1초 뒤에 다음 [제출]이 켜지게 하고, 서버 잠금도 같은 길이(뒤집기 0.8초 + 1초)로 맞췄습니다. 행동 전송은 성공 여부를 돌려주게 해서, 거절되면 먼저 그려 둔 카드를 즉시 되돌립니다.
  • 가끔 뒷면으로 멈추는 상대 카드 — 뒤집을 시각까지만 화면을 50ms마다 다시 그리는 타이머가, 멈춘 뒤에는 마지막으로 기록한 시각을 돌려주고 있었습니다. 마지막 갱신이 뒤집는 시각 직전이면 "아직 뒤집을 때가 아님"으로 영원히 남았습니다. 재현 조건이 들쭉날쭉했던 이유입니다. 그리는 순간의 실제 시각을 쓰게 고쳤습니다.
  • 한 판 더에서 사라진 판정 표시 — 이미 본 판정 번호를 기억해 두고 새 판정만 보여 주는데, 새 판은 번호가 처음부터 다시 시작해 지난 판보다 작았습니다. 새 판을 감지하면 기억을 지웁니다.
  • 8명이면 겹치는 자리 — 다른 참가자를 테이블 위쪽 반원에 놓았더니 7명이 되자 이웃 자리가 겹쳤습니다. 인원이 많으면 호를 아래쪽까지 250°로 넓히고 카드를 작게 합니다. 375px 폭에서 스크린샷으로 확인했습니다.
  • "준비 중이에요" — 배포 뒤 로비에 게임은 보이는데 방을 만들 수 없었습니다. 로비는 DB의 게임 목록에 없는 게임을 "준비 중"으로 막는데, 마이그레이션 목록에 새 파일이 아예 없었습니다. 병합 전의 로컬 저장소에서 반영 명령을 실행한 것이었고, 최신 main을 받은 뒤 다시 반영해 해결했습니다.
  • 기존 테스트가 깨뜨린 가정 — 원카드를 1:1 채팅 허용으로 바꾸자, "멀티게임 중에는 1:1 채팅 금지"를 원카드 방으로 확인하던 예전 DB 테스트가 실패했습니다. 테스트가 틀린 게 아니라 전제가 바뀐 것이라, 여전히 막히는 31 게임으로 확인하도록 고쳤습니다.
  • 서버 없이 해 보는 페이지 — 개발용 페이지(/dev/five-or-ten)가 서버의 시간 처리(뒤집기 잠금, 0.7초 판정 창)를 흉내 냅니다. 인원·시점을 바꾸고, 봇이 자동으로 내고, "봇이 누르기", "봇이 느리게 누르기", "제출과 동시에 누르기" 버튼으로 경쟁 상황을 만듭니다. 말풍선·테두리·결과·충돌 처리를 모두 여기서 Playwright로 찍어 가며 확인했습니다.

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

5or10 단위 테스트 35
  — 카드 구성(숫자별 장수·인원별 색) · 나누기와 중앙 카드 · 무작위 선 · 차례와 시간 초과
  — 조합 찾기(부분합·색·0) · 맞게/잘못 누름 · 0 카드 · 한 턴 한 판정 · 20ms 무시·동점
  — 지나간 입력 거절 · 제출 되돌리기(탈락 복원 포함) · 판정 창 규칙 · 탈락과 부활 · 순위
  — 봇끼리 20판 끝까지(매 수마다 카드 수 보존) · 비공개 정보 · 순수성 · 시드 재현
플랫폼 테스트 5  — 동시 입력(오래된 버전·잠금 통과) · 창을 늘리지 않는 마감 · 판정 위조 거부
                 — 자리비움으로 세지 않는 판정 · 제출과 동시 입력의 되돌리기
DB 테스트 12     — 새 5or10 테스트 + 기존 11개, 로컬 Postgres 16에 전체 마이그레이션을 적용해 실행
화면             — 개발용 페이지 + Playwright (375px, 2·4·8인, 말풍선·테두리·결과·제출 잠금 1.8초)

DB 테스트는 원래 Supabase 로컬 스택에서 돌리는데, 작업 환경에 그 스택이 없었습니다. 대신 일반 Postgres 16을 띄우고 Supabase가 제공하는 것(인증 스키마, 역할, 예약 작업, 비밀 저장소, HTTP 호출)을 작은 스텁으로 흉내 낸 뒤, 전체 마이그레이션을 처음부터 적용해 모든 DB 테스트를 돌렸습니다. 새 게임이 기존 테스트의 가정을 깬 것도 여기서 드러났습니다.

가장 든든한 것은 여전히 봇 대국입니다. 봇들이 무작위로 누르고(맞든 틀리든) 시간 초과로 내며 20판을 끝까지 두고, 매 수마다 모든 더미·앞 더미·중앙을 합친 카드 수가 처음과 같은지 확인합니다. 가져가기·벌칙·되돌리기 어디서든 카드가 생기거나 사라지면 여기서 걸립니다.

11지금 이 자리

지금이니!!는 실제로 여러 명이 하면서 충돌을 찾고 규칙을 고치는 반복(Iterate) 단계에 있습니다. 뒤집기 대기 0.8초, 다음 제출까지 1초, 판정 창 0.7초, 결과 1.5초 — 모두 해 본 감각으로 정한 숫자이고, 카드 구성은 한 번 크게 바꿨습니다.

이번 반복에서 남은 원칙은 세 가지입니다. 도착이 아니라 반응을 잰다. 공개는 서버 시각으로 약속한다. 동시에 일어난 일은 규칙으로 정한다 — 무엇이 이기는지를 네트워크 순서에 맡기지 않고 규칙에 적어 테스트로 고정합니다. 다음은 실제 대국에서 반응시간 분포를 관찰(Observe)하고, 동시 입력 장치를 쓰는 다른 게임을 올려 보는 일입니다.