지난 글에서는 서버 없이 폰끼리 연결하는 부분을 다뤘다. 방장 폰이 TCP 서버가 되고, 게스트는 UDP 비콘이나 방 번호로 방을 찾아 들어온다. 여기까지 오면 폰 여러 대가 같은 방에 모인다.
그다음 문제는 게임이다. 눈치 게임에서 두 사람이 "거의 동시에" 눌렀는지는 누가 정하나. 369에서 시간 초과는 어느 폰의 시계로 재나. 연타 대결에서 한 폰이 300회를 보내면 믿어야 하나. 게임이 15개로 늘어나는 동안 이런 질문에 매번 같은 답을 하려고 몇 가지 규칙을 정했다. 이 글은 그 규칙들의 정리다.
판정은 방장 폰 한 대만 한다
처음에 정한 원칙은 하나다. 게임 규칙은 방장 폰만 판정한다. 게스트 폰은 "눌렀다", "몇 초에 뗐다" 같은 입력만 보내고, 방장이 보내 주는 상태와 결과를 받아 그린다. 이른바 호스트 권위(host authority) 방식이다.
각자 판정하게 두면 통신 지연 때문에 폰마다 등수가 다르게 나올 수 있다. 커피값이 걸린 게임에서 그러면 곤란하다. 결과가 한 곳에서만 나오면 그럴 일이 없다.
코드에서는 Room 정적 클래스가 창구다. 방장 쪽 게임 화면은 Room.GuestMessage(게스트 id, 메시지)로 입력을 받아 Room.Host.Broadcast로 상태를 뿌리고, 게스트 쪽 게임 화면은 Room.SendToHost로 입력을 보내고 Room.HostMessage로 상태를 받는다. 폰 하나 모드, 방장 모드, 게스트 모드가 대부분 한 클래스 안에 들어 있다.
방장이 화면을 바꾸면 게스트도 따라간다
게스트가 직접 게임을 고르지는 않는다. 화면 전환은 전부 ScreenManager.Show<T>()를 거치는데, 여기서 화면 이름을 Room에 알려 준다.
public T Show<T>() where T : ScreenBase
{
// ... 지금 화면을 끄고 다음 화면을 켠다
next.OnShow();
Room.NotifyScreen(typeof(T).Name); // 방장이면 게스트에게 지금 화면을 알린다
return next;
}
public static void NotifyScreen(string screen)
{
if (!IsHost) return;
_hostScreen = screen;
Host.Broadcast(Msg.State, new StateMsg { screen = screen });
}
게스트는 state 메시지를 받으면 Room.HostScreen에 저장하고 Room.Changed 이벤트를 낸다. 대기실인 LobbyScreen이 이 값을 보고 게스트를 해당 게임 화면으로 보낸다. 거꾸로 게스트가 게임 화면에 있을 때 HostScreen이 자기 게임이나 ResultScreen이 아니게 되면 LobbyScreen으로 돌아온다. 그래서 방장이 게임을 고르면 모든 폰이 같은 게임으로 넘어가고, 방장이 게임 선택 화면으로 돌아가면 게스트는 대기실로 돌아온다.
게임마다 같은 모양의 메시지 4종
메시지는 길이 4바이트 + JSON 프레임에 t(종류), p(본문)를 담는다. 종류는 NetProtocol.cs의 Msg 상수로 모았는데, 게임 수가 늘면서 이름 짓는 방식이 자연스럽게 하나로 굳었다. 게임마다 접두어 한 글자에 메시지 네 개다.
x_need게스트 → 방장: 지금 상태를 보내 달라 (화면에 들어올 때, 재접속했을 때)x_state방장 → 모두: 단계·차례·참가자 같은 현재 상태x_입력게스트 → 방장: 이 게임의 입력 하나x_result방장 → 모두: 최종 등수와 분담액
| 규약 | 게임 | 접두어 | 입력 메시지 |
|---|---|---|---|
| 1 | 눈치 게임 | n_ |
n_press (상태는 n_phase·n_order) |
| 1 | 사다리 타기 | l_ |
없음 (l_need·l_setup·l_start) |
| 2 | 재접속 토큰 | — | JoinMsg.token / WelcomeMsg.token |
| 3 | 369 | t_ |
t_press |
| 4 | 바니바니 | y_ |
y_pick |
| 5 | 순서 외우기 | m_ |
m_press |
| 6 | 딱 멈춰! | s_ |
s_tap |
| 7 | 제일 작은 숫자 | u_ |
u_pick |
| 8 | 제비뽑기 | d_ |
d_pick |
| 9 | 31 게임 | c_ |
c_pick |
| 10 | 연타 대결 | k_ |
k_count |
| 11 | 업다운 | p_ |
p_guess |
| 12 | 끝까지 버텨! | h_ |
h_release |
초기 게임은 모양이 조금 다르다. 눈치 게임과 사다리는 패턴이 굳기 전에 만들었고, 폭탄 돌리기(b_)·룰렛(r_)·초성 퀴즈(q_)는 규약 2 시절에 같이 하기를 붙였다. 4종 패턴이 정착한 것은 369부터다.
규약 버전과 「모든 폰이 최신 버전이어야」
규약이 바뀌면 NetProtocol.Version을 올린다. 게스트가 입장할 때 JoinMsg.version을 보내고, 방장은 자기 버전과 다르면 바로 거절한다.
if (join == null || join.version != NetProtocol.Version) reject = "앱 버전이 달라요. 같은 버전으로 맞춰 주세요.";
새 게임은 새 메시지를 쓰고, 옛 버전 폰에는 그 게임 화면이 아예 없다. 그래서 369(규약 3)부터는 같이 하기 게임을 하나 넣을 때마다 버전을 하나씩 올렸고, 끝까지 버텨!에서 12가 됐다. 다만 폭탄 돌리기·룰렛·초성 퀴즈는 규약 2 그대로 들어가서, 앱 1.1과 1.2는 규약이 같다. 1.1에는 이 세 게임 화면이 없으니 방에 들어가도 따라갈 수 없다. 이 사례는 NetProtocol.Version 주석에 이력으로 남겼다. 그 뒤로는 게임별 메시지를 추가하거나 바꾸면 반드시 버전을 올리고 그 번호와 접두사를 주석에 적는 것을 규칙으로 삼았다.
비공개 테스트에서는 이 버전 검사가 그대로 테스터 문의로 이어진다. 그래서 앱 빌드 커밋마다 "같이 하기 규약이 12 라 1.5 이하와는 서로 방에 못 들어간다. 테스터 전원이 1.6 으로 올려야 한다" 같은 줄을 적었고, 스토어 출시 노트 관리 문서(Tools/store/release-notes.md)에는 규칙을 하나 넣었다. 규약이 바뀐 버전의 출시 노트에는 「같이 하기는 모든 폰이 최신 버전이어야 함께할 수 있어요」를 반드시 넣는다.
시간은 방장 폰의 시계로 잰다
눈치 게임: 받은 시각으로 판정
눈치 게임은 두 사람이 0.2초 안에 누르면 겹친 것으로 친다. 게스트 폰은 누를 때 시각을 보내지 않고 n_press만 보낸다. 방장은 그 메시지를 메인 스레드에서 처리하는 프레임의 Time.time을 누른 시각으로 쓴다. 방장 자신의 터치도 같은 함수(OnPress)를 거치므로, 모든 입력이 방장 폰 한 시계 위에서 비교된다. 통신 지연이 판정에 섞이는 것은 감수했다. 대신 폰마다 시계가 달라서 생기는 문제는 없다.
게스트 타이머는 표시용
369와 바니바니처럼 제한시간이 있는 게임은 게스트 폰에도 남은 시간이 보인다. 하지만 이 타이머가 0이 돼도 게스트 폰은 탈락 처리를 하지 않는다.
if (left <= 0f)
{
// ... 0.0 표시
if (_guest) yield break; // 게스트는 표시만, 판정은 방장이
Fail("시간 초과!");
yield break;
}
시간 초과는 방장 폰에서만 난다. 통신이 조금 늦어도 게스트가 자기 폰에서 먼저 탈락하는 일은 없다.
turnSeq: 지난 턴의 입력은 버린다
차례가 넘어가는 게임에서는 늦게 도착한 입력이 다음 사람의 차례에 적용될 수 있다. 그래서 상태 메시지에 턴 일련번호 turnSeq를 싣고, 게스트는 입력에 자기가 본 turnSeq를 붙여 보낸다. 방장은 번호가 다르면 버린다.
case Msg.T369Press:
{
if (!_open || _finished) return;
var m = NetProtocol.Get<T369PressMsg>(env);
if (m == null || m.turnSeq != _turnSeq) return; // 지난 턴의 입력
if (_entries[_turnIdx].Id != guestId) return; // 자기 차례가 아니다
if (m.clap) ApplyClap(); else ApplyNumber();
break;
}
369·바니바니·31 게임·제비뽑기·업다운은 turnSeq를, 모두 동시에 하는 딱 멈춰!·연타 대결·끝까지 버텨! 등은 같은 역할의 roundSeq를 쓴다.
elapsed: 늦게 받은 폰은 앞당긴다
룰렛은 방장이 멈출 칸·회전 수·시간을 정해 보내고, 모든 폰이 같은 애니메이션을 재생한다. 그런데 화면에 늦게 들어온 게스트는 원판이 이미 돌고 있을 때 상태를 받는다. 그래서 상태에 "시작한 뒤 지난 시간" elapsed를 넣어 보내고, 게스트는 그만큼 건너뛰어 재생한다. 369·딱 멈춰!·연타 대결·순서 외우기도 같은 필드로 타이머와 움직임을 맞춘다.

딱 멈춰!: 시각만 보내고 점수는 방장이
딱 멈춰!는 왔다 갔다 하는 바늘을 목표에 멈추는 게임이다. 바늘 위치는 Mathf.PingPong(_phase0 + _speed * t, 1f) 공식 하나로 정해지고, 방장이 phase0·speed·목표를 상태로 보내 모든 폰이 같은 바늘을 본다. 게스트는 움직이기 시작한 뒤 몇 초에 눌렀는지 t만 보내고, 점수는 방장이 같은 공식으로 계산한다. 게스트 폰도 같은 공식으로 점수를 먼저 보여 주지만, 확정은 방장 값이다.
연타 대결: 값은 줄지 않고, 사람 한계를 넘으면 잘라 낸다
연타 대결은 10초 동안 누른 횟수를 겨룬다. 게스트는 0.3초마다 지금까지의 누적 횟수를 보내고, 끝나면 final 표시를 붙여 최종값을 보낸다. 방장은 받은 값을 이렇게 다룬다.
// 줄어들 수 없고, 사람 손으로 불가능한 값은 잘라 낸다
e.Count = Mathf.Clamp(Mathf.Max(e.Count, m.count), 0, MaxCount); // MaxCount = 초당 20회 x 10초
순서가 뒤바뀌어 도착한 옛 값이 새 값을 덮지 못하고, 초당 20회를 넘는 값은 상한에서 잘린다. 10초가 끝나면 방장은 최종값을 1.2초 더 기다리고, 그때까지 안 온 사람은 마지막으로 받은 값으로 친다.
끝까지 버텨!: 폭발 직전에 뗀 손은 살려 준다
끝까지 버텨!는 몰래 정해진 폭발 시각 전에 가장 늦게 손을 떼면 이기는 게임이다. 게스트는 손을 뗀 시각만 보낸다. 문제는 폭발 직전에 뗀 경우다. 게스트 폰에서는 폭발 전에 뗐어도 메시지가 도착하기 전에 방장 폰이 먼저 폭발해 버릴 수 있다.
그래서 방장은 폭발한 뒤 바로 확정하지 않고 0.6초를 기다린다. 그 사이에 "폭발 시각보다 앞선 시각에 뗐다"는 입력이 오면 펑 판정을 취소하고 버틴 시간으로 기록한다. 딱 멈춰!도 같은 0.6초 유예를 두어 게스트의 5초 제한을 조금 더 기다려 준다.
폭발 시각 자체는 공개 전까지 게스트에게 절대 보내지 않는다. 업다운의 정답, 제비뽑기의 당첨 위치, 제일 작은 숫자에서 남이 고른 숫자도 마찬가지로 방장 폰만 안다. 이 이야기는 다음 글에서 따로 다룬다.
끊겨도 같은 자리로 돌아온다
폰은 연결이 끊기기 쉽다. 다른 앱으로 잠깐 넘어가거나 화면이 꺼지면 소켓이 끊긴다. 방장은 2초마다 ping을 보내고, 10초 동안 아무것도 안 오면 끊긴 것으로 본다. 끊긴 게스트가 새로 입장하면 다른 사람으로 들어오기 때문에, 같은 자리로 돌려보내는 재접속을 따로 만들었다.
- 방장은 입장할 때 무작위 토큰을 발급해
welcome에 실어 보낸다. - 게스트가 끊기면 방장은 그 자리(id·이름·캐릭터)를 토큰을 키로 3분 기억한다(
LanHost._seats). - 게스트는 끊기면 45초 동안 3초 간격으로 같은 토큰을 넣어 다시 입장한다. 앱이 다시 앞으로 오면(
OnApplicationPause(false)) 기다리지 않고 바로 시도한다. - 그동안 화면 위에 빨간 배너 「방장과 다시 연결하는 중...」을 띄운다.
- 방장이 방을 닫아서
bye를 받은 경우에는 재접속하지 않는다.
방장 쪽 입장 처리는 이렇다.
// 재접속: 아직 살아 있는 같은 토큰의 연결이 있으면 그 자리를 넘겨받고, 끊긴 자리 기록이 있으면 같은 id 로 복귀
Conn old = join.token != 0 ? _conns.Find(x => x.Token == join.token) : null;
Seat seat = null;
if (old == null && join.token != 0) _seats.TryGetValue(join.token, out seat);
bool rejoin = old != null || seat != null;
// ... 버전 검사, 정원 검사(재접속은 정원 검사 제외)
if (old != null) { conn.Id = old.Id; conn.Token = old.Token; old.Replaced = true; _conns.Remove(old); old.Close(); }
else if (seat != null) { conn.Id = seat.Id; conn.Token = join.token; _seats.Remove(join.token); }
else { conn.Id = _nextId++; conn.Token = UnityEngine.Random.Range(1, int.MaxValue); }
방장이 아직 끊김을 알아채기 전에 게스트가 먼저 다시 붙는 경우도 있다. 그때는 살아 있는 옛 연결을 새 연결로 바꿔 끼우고, 옛 연결은 이탈 이벤트 없이 정리한다.
같은 자리로 돌아온 다음에는 게임 화면이 상태를 다시 받아야 한다. 게스트 게임 화면은 Room.Reconnected 이벤트에서 x_need를 다시 보내고, 방장은 진행 중이면 x_state를, 이미 끝났으면 x_result를 보낸다. 화면에 늦게 들어온 게스트와 재접속한 게스트가 같은 길로 따라잡는 셈이다. 9월 21일 재접속을 넣고 실제 폰에서 끊었다 붙이는 시험을 마친 뒤 그날 빌드(1.1)에 넣었다.
정리
- 판정은 방장 폰만 한다. 화면 전환은
Room.NotifyScreen으로 알리고 게스트의LobbyScreen이 따라간다. - 게임마다 메시지 4종(
need·state·입력·result). 게임을 넣을 때마다 규약 버전을 올려 12까지 왔고, 버전이 다르면 입장을 거절한다. - 시간은 방장 시계로 잰다. 게스트 타이머는 표시용,
turnSeq·roundSeq로 지난 입력을 버리고,elapsed로 따라잡는다. - 통신 지연은 규칙으로 흡수한다. 줄지 않는 누적값, 마감 뒤 1.2초 대기, 0.6초 유예.
- 재접속은 토큰 하나. 자리 3분 기억, 45초 동안 3초 간격, 앱 복귀 시 즉시, 복귀 후 상태 재요청.
다음 글 8편 (10월 6일 공개)에서는 15개 게임의 규칙을 다듬은 이야기를 다룬다.