앞쪽 바닥에 3×3 구멍판이 있는 작은 농구장. 각자 던지는 줄에서 공을 당겨 놓으면 포물선으로 날아가, 바닥에서 튀고 구르다 구멍에 걸칩니다. 내 공 세 개로 한 줄을 만들면 30점. 남의 공을 맞혀 구멍에서 튕겨 낼 수도 있습니다. 시작할 때 이름은 "마구잡이 틱텍토"였습니다 — 정말로 모두가 마구잡이로 던지는 게임이라, 처음 던진 질문은 이것이었습니다. 서버가 1초에 수십 번 물리를 계산하는 루프 없이, Edge Function과 DB만으로 실시간 물리 게임이 되는가?

01무엇을 만들었나 — 규칙

  • 경기장 — 양옆은 벽, 앞·뒤는 열려 있어 공이 떨어질 수 있습니다. 던지는 줄은 정해져 있고 좌우 위치만 고릅니다. 끌어당긴 방향이 좌우 방향, 길이가 세기이고 발사 각도는 52°로 일정합니다.
  • 걸치기 — 공(반지름 21)이 구멍(반지름 16)보다 커서 빠지지 않고 "걸칩니다". 바닥에 닿는 순간 구멍 중심에서 14 이내이거나, 느리게 구르다 20 이내에 들어오면 걸칩니다. 이미 공이 걸친 구멍에는 못 들어갑니다.
  • 빼내기 — 걸친 공은 다른 공에 3번 맞으면(자기 공도 셉니다) 튕겨 나가 뒤로 굴러 떨어집니다. 못 들어간 공은 2.5초 뒤에 사라집니다.
  • 점수 — 내 공이 남의 공과 부딪힘 +3(공 한 쌍당 한 번), 남의 걸친 공을 빼냄 +10, 한 줄 완성 +30(그 줄의 공은 사라집니다). 바닥에 고정된 왕밤빵에 남의 공이 부딪히면 왕밤빵을 던진 사람도 +3. 100초가 끝나면 점수로 순위를 매깁니다(같은 점수는 공동 순위).
  • 쿨타임과 피버타임 — 한 번 던지면 2초 동안 못 던집니다. 남은 25초부터는 피버타임으로, 쿨타임 1초·아이템 3초마다로 빨라지고 시계가 빨갛게 깜빡입니다.
  • 특별룰 '아이템' — 7초마다(피버타임 3초마다) 하나씩, 최대 3개. 불꽃슛(40%, 다음 공에 부딪힌 공이 모두 불타 사라짐), 왕밤빵(40%, 다음 공이 반지름 45로 커져 처음 떨어진 자리에 5초 동안 고정돼 길을 막음), 내자리야(20%, 고른 빈 칸으로 다음 공이 끌려감).

02진행 방식 — 먼저 물은 것

작업은 이전 게임들과 같이 AI 코딩 에이전트와 나눠 진행했습니다. 1단계는 규칙 모듈과 단위 테스트만, 멈추고 보고, 확인한 뒤 2단계로 화면·등록·DB. 규칙 모듈의 약속도 그대로였습니다 — 순수 TypeScript, Math.random()·Date.now() 금지, 난수는 시드로 만들어 상태에 저장, 상태는 JSON으로 저장되는 값만.

코드를 쓰기 전에 에이전트가 물은 것은 세 가지였습니다. 첫째, 이 게임은 판 전체가 하나의 시계로 돌아가는데 일시정지한 시간을 정확히 빼려면 플랫폼을 고쳐야 한다 — 고치지 않고 대충 맞출 것인가. 답은 "일시정지도 정확하게"였습니다. 둘째, 던진 시각을 서버가 받은 시각으로 할지 던진 사람 화면의 시각으로 할지. 답은 던진 사람 화면 기준. 셋째, 판을 저장하는 DB 함수(game_commit_step)를 다시 정의해도 되는지. 원격 DB 반영과 배포는 이번에도 사람이 실행하고, 에이전트는 실행할 명령만 알려 줬습니다.

03핵심 — 서버에 루프가 없다

플랫폼은 "행동이 올 때만" 상태를 바꿉니다. 누군가 던질 때마다 Edge Function이 한 번 깨어나 규칙 모듈을 실행하고 저장하고 끝납니다. 그 사이에 공이 굴러가는 것을 서버가 계산해 줄 수는 없습니다. 그래서 상태에 "움직임의 결과"가 아니라 "움직임의 원인"만 저장하기로 했습니다.

state.base      기준 시각의 경기장 — 모든 공의 위치·속도, 점수, 구멍 상태
state.pending   기준 시각 이후에 던진 공 목록 — { t, 위치, 방향, 세기, 종류 }

advance(base, t, pending)  — 기준 시각부터 t까지 5ms 단위로 계산한 경기장
   서버: 던지기를 검사할 때 (그 칸이 비었나, 쿨타임이 지났나)
   화면: 매 프레임 그릴 때 (requestAnimationFrame)

fold   행동마다 base를 (서버 시각 − 0.5초)까지 당겨 합치고 pending을 줄인다
       — 그보다 이른 던지기는 더 이상 오지 않으므로 다시 계산할 필요가 없다

상태에는 원인만, 결과는 모두가 같은 함수로 계산한다

서버와 모든 화면이 같은 advance()를 같은 입력으로 돌리니, 아무도 공의 위치를 주고받지 않아도 같은 경기장을 봅니다. 서버가 저장하는 것은 던지기 몇 개뿐이라 상태도 작게 유지됩니다. 게임이 끝나면 시스템 행동 end가 끝 시각까지 계산해 점수를 확정합니다.

04같은 입력이면 어디서나 같은 결과

이 구조는 계산이 어느 기기에서나 똑같다는 전제 위에 서 있습니다. 서버(Deno)와 아이폰 사파리, 안드로이드 크롬이 공 하나의 위치를 0.001만큼만 다르게 계산해도, 그 공이 구멍에 걸치느냐 마느냐가 갈리고 그 뒤의 모든 충돌이 달라집니다.

  • 연산은 + − × ÷ √만 — 이 다섯 가지는 IEEE 754에서 결과가 정확히 정해져 있습니다. Math.sin·Math.cos·Math.pow 같은 함수는 엔진마다 마지막 자리가 다를 수 있어 쓰지 않았습니다. 발사 각도 52°의 cos·sin은 상수로 박아 두었습니다.
  • 고정 간격 — 화면 프레임과 상관없이 항상 5ms 단위로 계산하고, 던진 시각도 5ms에 맞춥니다. 60fps 기기와 120fps 기기가 같은 순서로 같은 계산을 합니다.
  • 정해진 순서 — 같은 시각에 던진 공은 (시각, 순번)으로 정렬하고, 충돌은 항상 같은 순서의 공 쌍으로 검사합니다.
  • 아이템도 미리 — 누가 언제 무슨 아이템을 받을지는 시작할 때 시드 난수로 각자의 목록(plan)을 만들어 상태에 넣어 둡니다. 받는 시각은 게임 시계로 정해져 있어서, 받는 순간 서버에 물을 필요가 없습니다.

테스트로 이 전제를 고정했습니다. 같은 입력을 한 번에 계산한 결과와, 여러 시점에서 끊어 JSON으로 저장했다 다시 읽어 이어 계산한 결과가 완전히 같은지 확인합니다. 상태가 DB를 거쳐도 결과가 흔들리지 않는다는 뜻입니다.

05게임 시계 — 멈춘 시간을 빼고 센다

100초짜리 판이 중간에 일시정지되면, 다시 이어졌을 때 공도 아이템도 피버타임도 멈춘 자리에서 이어져야 합니다. 서버 시각을 그대로 쓰면 멈춘 시간만큼 시계가 밀립니다. 그래서 모든 시간을 게임 시계로 셉니다.

게임 시계 = 100초 − (판 마감 시각 − 지금)
          — 0 = 시작, 100초 = 끝
일시정지   플랫폼이 남은 시간을 기억했다가, 이어질 때 그만큼 뒤로 마감을 다시 잡는다
          — 마감이 밀린 만큼 게임 시계는 멈춰 있던 것이 된다

이걸 하려면 규칙 모듈이 판 마감 시각을 알아야 했는데, 지금까지 모듈은 시간을 몰랐습니다. 플랫폼에 두 가지를 더했습니다 — 행동을 처리할 때 마감을 넘기는 ActionContext.deadline, 그리고 마감을 다시 계산하지 않고 그대로 저장하게 하는 StepPlan.turnDeadlineAt(자세한 내용은 LOG_05). 두 번째가 없을 때는 던질 때마다 DB가 "커밋한 시각 + 남은 시간"으로 마감을 다시 잡아서, 행동이 많을수록 판이 몇십 ms씩 늘어나고 있었습니다.

06던진 시각 — 던진 사람 화면 기준

공은 던진 사람 화면에서 던진 순간부터 날아가야 자연스럽습니다. 서버가 받은 시각으로 하면 내 공이 0.2초쯤 뒤에서 갑자기 나타납니다. 그래서 던지기에는 던진 사람 화면의 게임 시계가 실려 가고, 서버는 그 시각을 그대로 씁니다.

  • 늦게 도착한 던지기 — 서버 시계보다 0.5초 넘게 이른 시각이면 (서버 시각 − 0.5초)로 당깁니다. 그래야 base를 그 시각까지 안심하고 합칠 수 있고, 과거를 너무 멀리 고쳐 쓰는 조작도 막습니다. 서버 시계보다 늦은(미래) 시각은 서버 시각으로 맞추고, 한 사람의 행동은 그 사람의 직전 행동보다 앞설 수 없습니다.
  • 다른 사람 화면 — 던지는 순간 방 채널(fx)로 그 던지기를 먼저 보내, 서버를 거치지 않고 다른 화면도 바로 그립니다. 서버가 반영한 view가 오면(그 사람의 lastThrow가 바뀌면) 먼저 그린 것은 버리고 서버 것을 씁니다.
  • 과거가 바뀌는 순간 — 내 화면에 늦게 도착한 남의 던지기는 0.1~0.3초 전에 이미 던져진 공입니다. 같은 advance()로 다시 계산하면 그 공은 이미 조금 날아간 자리에서 나타나고, 그 사이의 충돌도 반영됩니다. 이 정도 어긋남은 공이 빠른 게임이라 거의 보이지 않았습니다.

검사는 서버가 다시 합니다. 쿨타임, 아이템 보유, 내자리야로 고른 칸이 비었는지, 게임 시간 안인지 — 모두 그 시각까지 advance()한 경기장으로 확인하고, 오류 메시지는 한국어로 돌려줍니다. 쿨타임 오류처럼 화면이 이미 막고 있는 것은 오류 문구를 띄우지 않게 했습니다(quietErrorCodes).

07해 보고 조율한 숫자들

규칙의 뼈대보다 시간이 더 걸린 건 숫자였습니다. 실제로 몇 판씩 해 보고 바꾸고, 바꿀 때마다 규칙 버전(rulesVersion)을 올렸습니다. 1에서 시작해 9까지 왔습니다.

걸치는 거리   정확히 떨어짐 12 → 16(공·구멍 1.3배) → 12 → 14
              느리게 굴러   18 → 23(1.3배) → 20
던지기 쿨타임 3초 → 2.5초 → 2초   피버타임 1.5초 → 1초
아이템 주기   7초                피버타임 5초 → 3초
게임 시간     120초(피버 30초) → 100초(피버 25초)
점수          충돌 3 · 빼내기 10 · 한 줄 30 + 고정된 왕밤빵에 맞으면 주인도 3
크기          공 16 → 21, 구멍 12 → 16 — 375px 폰에서 잘 안 보여서
왕밤빵        반지름 30 → 45, 5초

rulesVersion 1 → 9 — 숫자 하나가 게임의 손맛을 바꾼다

걸치는 거리는 특히 예민했습니다. 넓으면 아무렇게나 던져도 들어가 한 줄이 너무 빨리 나오고, 좁으면 공이 구멍 위를 지나치기만 합니다. "바닥에 닿는 순간"과 "느리게 구르다"를 따로 둔 것도 그래서입니다. 정확히 노린 공은 보상하고, 구르다 우연히 들어가는 공은 덜 너그럽게.

08왕밤빵 — 규칙이 세 번 바뀐 아이템

왕밤빵은 처음에 그냥 크고 무거운 공이었습니다. 질량 4배로 다른 공을 밀어내며 굴렀는데, 해 보니 어디로 갈지 몰라 "길을 막는다"는 아이템의 의미가 흐려졌습니다.

  • 1차 — 튕기지 않고 처음 떨어진 자리에 고정됩니다. 다른 공은 벽에 부딪히듯 튕깁니다. 떨어지는 순간 눌렸다 펴지는 출렁임, 먼지, 손으로 찍은 도트 글자 "착!"과 진동을 넣었습니다.
  • 2차 — 날아가는 중에도 다른 공에 맞아 진로가 바뀌니 고정 자리를 예측할 수 없었습니다. 그래서 왕밤빵은 날든 고정됐든 충돌로 움직임이 바뀌지 않게 했습니다. 걸친 공 위를 지나갈 때는 그 공을 한 번 친 것으로 세는데, 5ms마다 겹침을 검사하니 지나가는 동안 매번 친 것으로 세는 문제가 있었습니다. 바로 전 단계에도 닿아 있었으면 세지 않는 조건으로 "처음 닿는 순간 한 번"을 만들었습니다.
  • 3차 — 고정된 왕밤빵에 남의 공이 맞으면 던진 사람도 +3. 길을 막는 것 자체가 점수가 되게 했습니다.

이스터에그도 하나 있습니다. 불꽃슛은 원래 왕밤빵을 태우지 못하는데, 맞으면 그을립니다 — 까만 껍질과 도트 연기. 그을린 상태도 상태에 저장되어 모두에게 같이 보입니다.

09도트 그림 — 작고 선명한 픽셀

처음 화면은 캔버스에 매끄러운 원과 그라디언트로 그렸습니다. 요청은 "작고 선명한 고해상도 도트"였습니다. 캔버스를 그냥 키우면 픽셀이 뭉개지고, CSS로 픽셀을 그리면 기기 배율에 따라 칸 크기가 들쭉날쭉해집니다.

  • 낮은 해상도 버퍼 — 1도트가 약 1.5 CSS px인 작은 버퍼(Raster)에 한 칸씩 직접 칠하고, 그 버퍼를 기기 픽셀의 정수배로 키워 image-rendering: pixelated로 보여 줍니다. 어느 폰에서나 모든 도트의 크기가 같습니다.
  • 색 단계와 디더링 — 그라디언트 대신 계단식 색 단계와 규칙적인 바이어(Bayer) 디더링으로 나무 바닥 판자, 관중석, 벽 쿠션, 공 그림자를 그렸습니다. 사라지는 공은 투명도 대신 체크무늬로 흐려집니다.
  • 손으로 찍은 비트맵 — 아이템 아이콘(16×16), 왕관, 점수 숫자, "착!" 글자는 문자열 행렬로 찍어 둔 비트맵입니다.
  • 피버타임 — 경기장 바깥 하늘에만 무지개 도트 별 42개가 흘러갑니다. 경기장과 공 위에는 그리지 않도록 영역을 가렸습니다.
  • 세로 스크롤 — 경기장 전체가 터치를 잡고 있어 폰에서 페이지를 내릴 수 없었습니다. 던지는 줄 근처 띠만 touch-action: none, 나머지는 pan-y로 나눠 세로로 밀면 화면이 움직이게 했습니다.

10소리 — 파일 대신 코드로 합성

효과음 14종과 120초짜리 BGM을 모두 코드로 만들었습니다. Node 스크립트가 발진기·잡음·필터·엔벨로프로 파형을 합성하고, ffmpeg(libmp3lame)로 mp3를 만듭니다(npm run sounds:hoop, npm run bgm:hoop). 공 당기기·날아가기, 아이템 세 가지, 불꽃에 타는 소리, 왕밤빵 착지, 한 줄 완성, 째깍, 버저, 피버타임 시작. BGM은 농구장·오락실 느낌의 8비트 칩튠이고, 카운트다운이 끝난 뒤에 시작합니다.

소리는 advance()가 내놓는 사건(충돌·걸침·착지·한 줄)에 맞춰 재생합니다. 같은 사건이 짧은 간격으로 몰리면 거릅니다. 째깍은 피버타임과 함께 1초마다 시작해 마지막 10초는 0.5초마다로 빨라집니다.

11문제 해결 기록

  • 행동마다 밀리는 판 시계 — 마감을 유지하는 게임인데도 DB가 커밋할 때마다 "지금 + 남은 시간"으로 마감을 다시 계산해, 던지기가 많은 판일수록 조금씩 길어졌습니다. 서버가 원래 마감 시각을 그대로 넘기고 DB가 그 값을 저장하도록 바꿨습니다(→ 05).
  • db push의 duplicate key — 이름 변경 마이그레이션 20261020000000을 만든 사이, 다른 작업(게임 봇)이 같은 번호로 먼저 원격에 반영돼 db push가 중복 키로 실패했습니다. 이 파일은 그 번호로 한 번도 적용된 적이 없고 내용도 몇 번 실행해도 같은 결과라, 번호만 20261021090000으로 옮겼습니다. 그 뒤로는 새 마이그레이션 번호를 정하기 전에 모든 원격 브랜치의 파일 목록을 확인합니다.
  • 사라지지 않는 아이템 알림 — 아이템 사용 알림을 4초 뒤 닫는 타이머를 React effect 안에 두었는데, 새 view가 올 때마다 effect 정리 단계에서 타이머가 지워졌습니다. 실시간 게임이라 view가 계속 오니 알림이 영원히 떠 있었습니다. 타이머를 ref로 옮겨 마지막 알림 4초 뒤에 닫히게 했습니다.
  • 3×3을 가리는 피버타임 안내 — 처음엔 경기장 가운데 큰 상자였습니다. 깜빡이는 애니메이션의 inline-block이 flex 배치를 깨고, 빨간 바탕에 빨간 제목이라 잘 안 보였습니다. 감싸는 상자를 따로 두고 노랑·흰색으로 깜빡이게 고친 뒤, 구멍판을 가리지 않도록 작게 줄여 경기장 맨 위로 올렸습니다.
  • 들리지 않던 내자리야 소리 — 내자리야 공은 날아가는 소리와 끌려가는 소리가 동시에 나서 겹쳐 묻혔습니다. 끌려가는 소리만 냅니다.
  • 소리 하나를 더했더니 다른 소리가 바뀜 — 합성 스크립트는 시드 난수로 잡음을 만드는데, 목록 중간에 새 소리를 끼우자 그 뒤 소리들의 난수 순서가 밀려 째깍 소리가 달라졌습니다. 새 소리는 항상 목록 끝에 붙입니다.
  • 너무 큰 BGM — 처음 만든 BGM이 −9.9 LUFS로 다른 게임 BGM보다 훨씬 컸습니다. 음량을 절반으로 낮춰 −14.9 LUFS로 맞췄습니다.
  • 서버 없이 해 보는 페이지 — 개발용 페이지(/dev/hoop-tictactoe)가 서버의 시간 처리를 흉내 냅니다. 두 화면을 나란히 띄우고 한쪽에 지연을 넣어 예측 그리기와 되돌리기를 보고, [남은 시간 27초로]·[5초로]로 피버타임과 마지막 째깍을, [내 아이템 3종]으로 아이템을 바로 확인합니다. 화면은 모두 375px 폭에서 Playwright로 찍어 확인했습니다.

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

홉 틱텍토 단위 테스트 46
  — 포물선·튕김·구르기·벽 · 걸치기(14·20) · 3번 맞으면 빼냄 · 한 줄 완성과 제거 · 점수
  — 쿨타임·피버타임 · 아이템 지급 시각과 비율 · 불꽃슛·내자리야·왕밤빵(고정·한 번만 치기·그을림·주인 점수)
  — 늦은 던지기 당기기 · 시작 전·끝난 뒤 거부 · 끝 판정과 공동 순위 · 시스템 행동 위조 거부
  — 한 번에 계산 = 끊어서 JSON 저장 후 이어 계산 (결정론)
플랫폼 테스트  — 마감 유지 게임에서 커밋해도 마감이 밀리지 않음 · deadline으로 센 게임 시계
전체           — npm test 260 통과 · 빌드 · 로컬 Postgres 16에 전체 마이그레이션 적용
화면           — 개발용 페이지 + Playwright (375px, 가로 스크롤 없음)

결정론 테스트가 이 게임의 안전망입니다. 물리 상수나 충돌 순서를 바꿀 때마다 "서버가 계산한 경기장과 화면이 계산한 경기장이 같은가"를 사람이 눈으로 확인할 수는 없습니다. 끊어서 저장했다 이어 계산해도 한 번에 계산한 것과 비트 하나까지 같은지를 테스트가 대신 봅니다.

13지금 이 자리

홉 틱텍토는 실제로 해 보며 숫자를 맞추는 조율(Tune) 단계에 있습니다. 걸치는 거리, 쿨타임, 아이템 주기, 게임 시간은 모두 몇 판씩 해 본 감각으로 정했고, 규칙 버전은 9까지 올라갔습니다.

이번에 남은 원칙은 세 가지입니다. 상태에는 원인만 저장한다. 계산은 어디서나 같게 한다. 시간은 멈춘 만큼 빼고 센다 — 서버 루프 없이도 실시간 물리 게임이 된다는 것을 확인했습니다. 다음은 피버타임 아이템이 많아진 판의 흐름을 관찰(Observe)하고, 이 구조(기준 상태 + 대기 행동 + 같은 계산)를 다른 실시간 게임에도 써 보는 일입니다.