Direct Play에 가위바위보를 추가하면서 먼저 정한 것은 그림이나 애니메이션이 아니었습니다. 누가 결과를 확정하는가였습니다.
방장 브라우저가 선택을 모아 판정하면 구현은 간단합니다. 하지만 방장이 어떤 참가자의 손을 먼저 받았는지, 공개 전에 다른 선택을 봤는지, 새로고침 뒤 기록이 맞는지 모두 방장 기기를 믿어야 합니다. 참가자가 각자 결과를 계산하게 하면 서로 다른 상태를 보고할 수도 있습니다.
가위바위보는 매 순간의 위치를 초당 여러 번 동기화할 필요가 없습니다. 한 라운드에서 필요한 정보는 “누가 손을 제출했는가”와 “전원이 제출한 뒤 어떤 손을 냈는가”입니다. 그래서 게임 상태를 방장 브라우저가 아니라 방의 Durable Object에 두었습니다. Durable Object 하나가 그 방의 참가자와 선택을 순서대로 처리하고, 판정 결과를 모든 기기에 전달합니다.
화면은 입력하고, 방 서버는 판정한다
방장 브라우저 ─┐
참가자 브라우저 ─┼─ WebSocket 명령 ─▶ GameRoom Durable Object
참가자 브라우저 ─┘ ├─ 참가자·라운드 상태 저장
├─ 선택 비공개 유지
├─ 전원 제출 시 승패 계산
└─ 공개 상태를 각 연결에 전송
프런트엔드는 손 선택 카드와 점수 화면을 그립니다. 참가자가 카드를 누르면 rps-command가 방 서버로 전송되고, 서버가 현재 경기·라운드·참가 자격·중복 제출 여부를 검사합니다. 브라우저가 보내는 판정이나 점수는 신뢰하지 않습니다.
공용 게임 규칙은 DOM에 의존하지 않는 순수 모듈인 shared/rock-paper-scissors.js에 있습니다. 같은 규칙을 Worker와 Node 테스트에서 사용할 수 있어, “가위가 보를 이긴다”는 규칙이 화면 코드의 상태에 따라 달라지지 않습니다.
제출 확인과 손 공개를 분리한다
선택 화면에서 서버가 모든 참가자에게 보내는 공개 상태에는 제출을 마친 참가자의 ID만 들어갑니다. 선택 전이나 다른 사람이 아직 제출하지 않은 동안에는 상대의 손이 포함되지 않습니다. 각 연결에 전달하는 개인 상태에는 그 연결의 참가자가 낸 손만 포함합니다.
서버는 공식 참가자 전원이 제출했을 때만 선택을 모아 판정합니다. 한 종류의 손만 나오거나 세 종류가 모두 나오면 무승부입니다. 두 종류가 나왔을 때 이기는 손을 낸 사람은 모두 승리합니다. 예를 들어 바위 두 명과 가위 한 명이면 바위를 낸 두 사람이 모두 그 라운드의 승수를 받습니다.
const outcome = resolveRpsRound(participantIds, selectionsById);
// { outcome, winningChoice, winners, standingsDelta }
판정이 끝나면 선택을 결과 이력에 기록하고, 누적 승·패·무승부와 유효 라운드 수를 갱신합니다. 결과 화면에서는 각 참가자의 손을 공개하고 승리한 행을 강조합니다. 승리자 이름 옆에는 메달과 별 모양 SVG 배지를 표시해, 기존의 배경색 강조만으로는 놓칠 수 있는 승리 상태를 텍스트와 아이콘으로도 알아볼 수 있게 했습니다.
같은 순간에 들어온 명령은 한 줄로 처리한다
여러 참가자가 거의 동시에 제출할 수 있습니다. 서버에서 각 선택을 따로 읽고 저장하면 두 번째 요청이 첫 번째 저장 전의 상태를 읽어 라운드 완료 여부를 놓칠 수 있습니다. GameRoom은 가위바위보의 설정·등록·상태 조회·명령 처리를 runRpsExclusive() 큐로 직렬화합니다. 각 명령은 최신 저장 상태를 읽고 검증한 뒤, 변경 내용을 저장하고 공개 상태를 보냅니다.
클라이언트 명령에는 commandId, matchId, roundId도 포함됩니다. 서버는 최근 처리한 명령의 접수 결과를 보관해 재전송된 명령을 중복 적용하지 않습니다. 라운드가 이미 바뀌었다면 오래된 roundId를 거부합니다. 따라서 네트워크 재시도나 늦게 도착한 패킷이 다음 라운드 점수까지 바꾸지 않습니다.
참가자 ID를 이름과 분리해 복원한다
닉네임은 참가자가 이름을 바꾸거나 같은 이름을 쓰면 고유 식별자로 쓸 수 없습니다. 입장할 때 서버는 참가자 ID와 재접속 토큰을 발급합니다. 브라우저는 토큰을 방별 로컬 저장소에 저장하고, Durable Object에는 원문 대신 토큰의 해시를 보관합니다. 새로고침 뒤 토큰을 제시하면 같은 참가자 기록을 복원합니다.
WebSocket 연결이 열리면 브라우저가 rps-bind로 참가자를 해당 연결에 묶습니다. 그 뒤 서버가 선택한 손과 접속 상태를 복원해 보냅니다. 아직 선택하지 않았다면 선택을 계속할 수 있고, 이미 제출했다면 선택 버튼은 잠긴 상태로 돌아옵니다. 연결이 끊겼다가 다시 열려도 이미 저장된 선택은 바뀌지 않습니다.
설정한 정원이 모두 들어오면 참가자가 자동 READY가 됩니다. 시작 요청은 서버에서도 방장 권한, 설정 인원, 참가자 연결 상태를 확인합니다. 화면에서 버튼을 숨기는 것만으로 권한을 지키지 않습니다. 진행 중 새로 입장한 사용자는 공식 명단에 편입되지 않고 관전자로 처리합니다.
경기 규칙은 작게, 방의 상태는 명확하게
방장은 2~8명 중 참가 인원을 정하고, 정해진 판 수 또는 목표 승수 방식으로 경기를 만듭니다. 정해진 판 수는 기본 3판이며 1·3·5·10판을 선택할 수 있습니다. 목표 승수는 2·3·5승 중 선택합니다. 목표 승수 방식이 너무 길어지지 않도록 유효 라운드 30개에 안전 한도를 둡니다.
무승부도 정해진 판 수와 유효 라운드 수에는 포함됩니다. 승수가 같으면 공동 순위로 표시합니다. 결과가 나온 뒤 다음 라운드를 여는 일, 현재 기록으로 조기 종료하는 일, 최종 결과 뒤 같은 참가자와 재경기하는 일은 방장이 진행합니다. 참가자 화면에는 게임 시작 버튼 대신 “방장이 게임을 시작하기를 기다리고 있어요”라는 상태를 보여 줍니다.
사진 전달 경로를 재사용하지 않은 이유
Direct Play의 사진 퍼즐에서는 사진 파일이 게임의 재료이므로 방장 기기에서 참가자 기기로 P2P 전달합니다. 가위바위보에는 전달할 게임 파일이 없습니다. 서버에 저장되는 것은 규칙 설정과 필요한 경기 상태뿐입니다. 그래서 가위바위보 참가자는 방 자산을 P2P로 받지 않고 바로 게임 세션을 등록합니다.
이 구분은 단순한 최적화가 아니라 입장 가능성에 영향을 줍니다. 규칙 데이터는 서버에 이미 있으므로, 참가자가 P2P로 자산을 받지 못하더라도 게임을 시작할 수 있어야 합니다. 자산이 필요한 게임과 서버 상태만 필요한 게임을 같은 진입 조건으로 취급하면 초대받은 참가자가 방에 들어와도 게임 규칙을 불러오지 못하는 오류가 생깁니다.
어디까지 확인했나
tests/rock-paper-scissors.test.mjs는 설정 범위, 다인 판정, 무승부, 공동 순위와 종료 조건을 검사합니다. tests/rock-paper-scissors-session.test.mjs는 방장 권한, 정원 검사, 공개 상태에서 선택 숨김, 개인 상태 분리, 중복 제출, JSON 직렬화 후 상태 복원과 재경기를 검사합니다. npm run check와 npm run test:rock-paper-scissors가 통과합니다.
현재는 시간 제한 선택, 팀전, 토너먼트, 장기 전적, 선택 패턴 통계는 제공하지 않습니다. 경기 진행에는 인터넷 연결이 필요하며, 재접속은 같은 브라우저의 방별 토큰을 기준으로 복원합니다.
코드에서 읽기
- 규칙 순수 함수:
shared/rock-paper-scissors.js - Durable Object 게임 상태와 명령 처리:
worker/rock-paper-scissors.js - GameRoom HTTP·WebSocket 연결:
worker/room.js - 브라우저 참가 세션과 복원:
frontend/core/server-session.js - 설정·선택·결과 화면:
frontend/games/rock-paper-scissors/ - 규칙 및 서버 상태 테스트:
tests/rock-paper-scissors.test.mjs,tests/rock-paper-scissors-session.test.mjs