본문으로 건너뛰기

dotgimon 제작기 27. 중계 양쪽 확인과 숨은 훈련 성과, 낮잠 수면 개편

·40 min read·27 / 32

DOTGIMON은 Expo/React Native로 만드는 LCD 방식 가상 펫 게임이다. 폰 앱이 있고, 같은 게임 엔진을 QuickJS(앱 안에 내장하는 작은 JavaScript 엔진)로 돌리는 애플워치·Wear OS 앱이 있다.

이날은 이 프로젝트에서 손꼽히게 큰 하루였다. 오전부터 밤까지 설계서와 구현이 여러 줄기로 동시에 굴러갔고, 저녁에 하나로 합쳤다.

응답을 놓친 기기 하나 때문에 대전 결과가 한쪽에만 남았다

번호 코드 대전(CODE)은 8자리 번호로 상대를 찾는 대전이라 Cloudflare 중계 서버를 거친다. 두 기기가 같은 방에서 READY(상대를 기다리는 대기 화면)로 기다리다 만나면 서버가 matched(짝이 맞았다는 응답)를 보내고, 받은 기기는 바로 결과를 저장하는 구조였다.

한쪽이 그 응답을 못 받으면(화면을 내렸거나, 끊겼거나, 강제 종료) 한쪽만 반영됐다. 조그레스(두 몬스터를 연결해 합체 진화시키는 기능)라면 한쪽만 합체됐다.

로컬에서 되던 60초 기억이 배포 서버에서 사라지다

오전에는 이를 "서버가 만남을 기억한다"로 줄여 보려 했다. 메신저 앱으로 잠깐 나갔다 와도 READY가 이어지도록 서버가 만남을 60초 기억하게 했다. 로컬에서는 잘 됐는데, 배포 서버에서는 기억이 사라졌다.

원인은 Durable Object(Cloudflare에서 상태와 저장소를 함께 가진 단일 인스턴스 객체)였다. Durable Object는 요청이 없는 빈 방을 10초 뒤 hibernation(메모리에서 내려 재워 두는 상태)으로 바꾼다. 메모리에 둔 기억은 그때 같이 날아간다.

만난 시각을 방 저장소(Durable Object에 딸린 키-값 저장소)에 1분 두고, 알람(정해 둔 시각에 객체를 깨워 코드를 돌리는 예약 기능)으로 지우게 고쳤다. 늦게 돌아온 기기도 결과를 맞추도록 기억 시간은 10분으로 늘렸다. 그래도 이건 결과를 놓치는 기기를 줄일 뿐, 막지는 못했다.

반영 결정을 서버 한 곳으로 모았다

각 기기가 받는 즉시 반영하면 어떤 재시도도 한쪽 반영을 완전히 막지 못한다. 설계서의 목표는 한 줄이었다. 두 기기 모두 반영하거나, 둘 다 반영하지 않는다. 결정은 서버 한 곳이 내리고, 기기는 결정 전에 대기 기록부터 저장한 뒤 서버를 따른다.

  • 만나면 기기는 먼저 "결과 대기 중" 기록을 저장하고, 그다음 서버에 확인(confirm)을 보낸다.
  • 두 기기의 확인이 다 오면 go, 만난 뒤 60초 안에 모이지 않으면 cancel.
  • 저장에 실패하면 확인을 보내지 않는다. 그러면 서버가 60초에 알아서 취소한다.
  • 강제 종료됐다면 다음 실행 때 저장해 둔 대기 기록으로 서버에 다시 묻는다. 결정 기록은 24시간 둔다.
  • 확인을 기다리는 동안 화면은 READY 그대로다. 사용자에게 새 화면이나 문구는 없다.

Durable Object 없이 테스트할 수 있는 방 로직

서버의 방 로직은 저장소와 타이머를 주입받는 순수 클래스로 뒀다. 확인 요청이 오면 아래 confirm이 기록을 읽고, 두 기기가 다 모였는지에 따라 go를 정하거나 남은 시간만큼 기다린다.

  • role: 방의 두 자리('H'·'G') 중 요청한 기기의 자리
  • ticket: 기기가 요청마다 새로 만드는 임의 문자열. 늦게 온 취소가 새 대기를 지우지 않게 요청을 구분한다
  • this.confirmers: 확인 요청을 붙잡아 둔 자리별 대기 목록
  • record(): 방 저장소에서 지금 유효한 만남 기록을 읽는다
  • decide(): go·cancel을 기록에 적고, 기다리던 두 기기의 요청에 같은 답을 돌려준다
  • hold()·take(): 응답을 정한 시간만큼 붙잡아 두는 대기를 만들고(hold), 같은 자리의 앞선 대기를 꺼내 끝낸다(take)
  • CONFIRM_DEADLINE_MS(60초)는 만난 뒤 확인 마감, SERVER_WAIT_MS(18초)는 요청 하나를 붙잡아 두는 최대 시간이다
// server/relay/src/room-logic.ts
confirm(role: Role, ticket: string, decision?: 'cancel'): Promise<ConfirmStatus> {
  this.take(this.confirmers, role)?.resolve('pending')
  const now = this.now()
  const rec = this.record(now)
  if (!rec) return Promise.resolve('cancel') // 만난 적 없음·기록 기간 지남
  if (rec.decision) return Promise.resolve(rec.decision)
  if (decision === 'cancel') return Promise.resolve(this.decide(rec, 'cancel'))
  const confirmed: Role[] = rec.confirmed.includes(role) ? rec.confirmed : [...rec.confirmed, role]
  if (confirmed.length === 2) return Promise.resolve(this.decide({ ...rec, confirmed }, 'go'))
  this.store.set({ ...rec, confirmed })
  const left = rec.matchedAt + CONFIRM_DEADLINE_MS - now
  return this.hold(this.confirmers, role, ticket, Math.min(SERVER_WAIT_MS, left), () => this.record()?.decision ?? 'pending')
}

마감은 알람에만 맡기지 않았다. 기록을 읽을 때마다 마감이 지난 미결정이면 그 자리에서 cancel로 정한다. 알람이 늦게 와도 결과가 같다. 한 번 정한 결정은 뒤집지 않는다.

저녁에는 거절도 바로 처리되게 했다. 만난 상대의 정보가 이상해 내 쪽이 거절하면, 확인 대신 { decision: 'cancel' }을 보내 서버가 바로 취소로 정한다. 이미 확인한 상대가 60초를 멍하니 기다리지 않는다.

워치 호스트(QuickJS 엔진을 감싼 Swift·Kotlin 네이티브 코드)는 confirm 요청에 role·ticket·mon(몬스터 정보 JSON) 세 칸만 싣는다. 그래서 새 필드를 만드는 대신 mon 칸에 거절 표시를 실었다. 워치 네이티브 코드를 건드리지 않으려고 고른 방법이다.

READY 대기도 20초에서 60초로 늘렸다. 서버의 한 번 붙잡기(long-poll, 결과가 날 때까지 요청을 잡아 두었다가 응답하는 방식)는 18초 그대로 두고, 끝나면 앱이 남은 시간 동안 다시 묻는다.

항목이전이후
만남 기억메모리 (빈 방이 내려가면 사라짐)방 저장소 + 알람
반영 시점matched 받는 즉시두 기기 확인 뒤 go에만
확인 마감없음만난 뒤 60초, 못 모이면 양쪽 취소
결정 기록없음24시간 (24시간이 지난 대기 기록은 반영하지 않고 EXPIRED를 보여 줌)
READY 대기20초60초

진화하면 0이 되던 훈련, 숨은 성과로 바꾸다

전투 능력치에는 훈련 횟수, MEGA HIT(훈련 게이지를 맨 위 15% 안에서 멈춘 판정) 횟수, 걷기, 성장 단계 차이 같은 항이 여럿 붙어 있었다. 대전 모드는 혼자 층을 오르는 STAGE, 다른 기기와 붙는 DUAL, 걸음 수로 야생 몬스터를 만나는 WILD 세 가지다. 모드마다 내 펫 쪽 보정이 달라서 같은 펫도 숫자가 제각각이었다.

더 큰 문제는 훈련 횟수(trainCount)가 진화할 때마다 0으로 돌아가는 것이었다. 열심히 훈련한 펫도 진화하면 처음부터였다.

설계서에는 목표를 네 가지로 적었다.

  • 훈련을 많이 한 펫이 세다. 훈련 성과는 진화해도 사라지지 않는다.
  • 내 펫 능력치는 모든 모드에서 같다. 난이도는 적 쪽에서만 조절한다.
  • 같은 성과면 진화한 펫이 세다. 단계 기본치가 이를 보장한다.
  • 평생 키워도 숫자가 폭주하지 않는다. 상한은 없지만 점점 완만해진다.

그래서 화면에 보이지 않는 값 trainMastery를 새로 뒀다. 훈련 한 세트(3라운드)를 완주할 때마다 하나씩 쌓이고, 진화·조그레스에서는 그대로 남고, 새 알부터 다시 키울 때만 0이 된다. 전투 능력치는 이 값이 커질수록 완만하게 강해지는 곡선 하나로 정해진다. 훈련·MEGA 공격 항은 지웠다.

훈련 한 세트를 마칠 때 엔진은 기존 카운터와 함께 숨은 성과를 올린다. trainCount와 달리 trainMastery는 진화 때 초기화하지 않는다.

// src/game/engine/commit-train.ts
s.trainCount++
s.lifeTrain++
s.trainMastery++ // 숨은 성과 — 진화해도 지워지지 않는다
s.weight = Math.max(MIN_WEIGHT, s.weight - TRAIN_WEIGHT_LOSS)
s.strength = Math.max(0, s.strength - STAMINA_COST)

보정을 적 쪽으로 옮기기 전, 3%p 동치 테스트부터

STAGE에서 내 쪽에 걸던 걷기·단계 차이 보정은 적 쪽으로 옮겼다. 옮긴 뒤에도 승률이 같은지 확인하려고 "이전 방식과 3%p 안에서 같다"는 동치 테스트를 먼저 붙였다. 덕분에 구조를 바꾸는 동안 밸런스가 흔들리지 않았다.

평생 밸런스도 난수 시드를 고정한 테스트로 묶었다. 같은 성과면 위 단계가 95% 이상 이기고, 두 달 키운 펫끼리는 4555%로 대등하고, 3일 차이는 6075%, 그리고 한 판이 최소 6타는 가는지 본다.

몬스터는 여섯 계열(버전)로 나뉘고, 계열마다 이기는 상대와 지는 상대가 하나씩 있다. 이 버전 상성의 유리 배율은 1.5에서 1.2로 낮췄다. 숨은 성과 차이가 이미 승패를 꽤 가르니, 상성까지 크면 너무 한쪽으로 기운다.

곡선이 로그를 쓰다 보니 엉뚱한 문제가 하나 나왔다. 폰은 Hermes(React Native의 JavaScript 엔진), 워치는 QuickJS로 엔진이 다르다.

두 엔진의 Math.log1p(log(1+x)를 계산하는 함수)는 마지막 자리 1ulp(부동소수점이 표현하는 최소 간격 한 칸) 차이로 다른 값을 돌려준다. 그게 LINK 대전(근처에 있는 두 폰이 직접 연결해 붙는 대전)의 HP·공격 반올림을 뒤집을 수 있었다. 곡선 값을 소수 6자리로 잘라서 두 엔진이 같은 숫자를 보게 했다.

8자리 번호 코드에는 성과를 담을 자리가 없었다

LINK·CODE(번호 코드) 대전은 상대 펫 정보를 받아 내 기기에서 계산한다. 숨은 성과도 같이 보내야 했다. LINK로 주고받는 펫 정보 형식을 v9로 올려 성과를 필수 항목으로 넣었다.

번호 코드는 8자리 숫자에 성과를 담을 수 없었다. 그래서 중계 서버가 매칭할 때 두 기기가 보낸 펫 정보(1KB 상한)를 서로에게 건네주게 했다. 이 덕분에 번호 코드에서 훈련·MEGA·걸음 자리를 빼고 종 번호만 남겼고, 333종까지 담을 수 있게 됐다. 옛 버전 코드는 검사 숫자(코드 버전까지 넣어 계산하는 체크섬 자리)가 맞지 않아 거부된다.

진화 갈림길을 세 단계로 넓히고 방치한 펫이 가는 길도 뒀다

펫은 유년기→성장기→성숙기→완전체→궁극체 순으로 진화한다. 도감을 다시 보니 갈림길이 성장기→성숙기 한 단계뿐이었다. 성숙기 21종 중 18종, 완전체 30종 전부가 진화 대상이 하나였다. 여섯 버전이 같은 조건 틀에 종 이름만 바꿔 넣어 쓰고 있었다.

성숙기에서 대전 조건을 못 채우면 영원히 머무는 펫도 있었다. 하루 훈련 8세트 상한 때문에 훈련 수로 갈림길을 크게 만들기도 어려웠다.

설계서에서는 세 단계 모두에 갈래를 두고, 조건을 구간으로 걸었다. "승률을 맞춰 놓고 대전을 멈추기", "훈련을 참기" 같은 노림수가 생기게 하려는 것이다.

축은 단계마다 다르다. 성장기는 케어 실수(배고픔·똥·아픔을 오래 내버려 둬서 쌓인 횟수)·훈련·과식·대전 수, 성숙기는 케어 실수·대전 수·승률, 완전체는 훈련·대전 수·승률이다. 수면방해(자는 펫을 깨운 횟수)·MEGA HIT·걸음은 진화에 쓰지 않는다. 하루 훈련 상한도 없앴다.

방치하는 유저용 더티 라인(돌보지 않고 키운 펫이 가는 진화 경로)도 버전마다 성숙기 1종 + 완전체 1종씩, 모두 12종을 새로 뒀다. 한국어 이름은 "~몬" 규칙으로 붙였다.

진화 조건은 엔진이 아니라 데이터 표에

조건은 엔진 코드에서 분리해 규칙 모듈의 데이터 표로 만들었다. 진화 조건 함수는 이 표를 읽어서 만든다. 성장기 종마다 어느 칸에 어떤 성숙기가 들어가는지가 아래처럼 적힌다. A~E 칸은 성장기에서 성숙기로 갈 때 케어 실수·훈련·과식·대전 횟수 조합으로 나뉜 진화 칸이다.

// src/game/evolution-rules/table.ts
export type GrowthSlot = 'X' | 'A' | 'B' | 'D' | 'C' | 'E'
/** 성장기 → 성숙기 칸 배정 (§4.2). 없는 칸은 비워 두고, F 는 폴백이다 */
export const GROWTH_SLOTS: Readonly<Record<string, { version: number; F: string } & Partial<Record<GrowthSlot, string>>>> = {
  rexling: { version: 1, X: 'smoglet', A: 'rexor', B: 'grimm', D: 'pyron', C: 'saurn', F: 'sluggo' },
  newtle: { version: 1, X: 'smoglet', A: 'grimm', B: 'pyron', C: 'marloch', E: 'zephyr', F: 'sluggo' },
  // ...
}

X 칸이 더티 성숙기, F 칸이 어떤 조건에도 맞지 않을 때 가는 기본 진화 대상이다. 이 기본값이 생겨서 영원히 머무는 펫은 없어졌다. 승률은 정수로 내림해 폰·워치 계산이 갈리지 않게 했다.

도감 생성 스크립트가 이 규칙 모듈을 import해야 했는데, 도트 그림(art)·밸런스 계산(balance) 스크립트들이 각자 다른 방식으로 TS 파일을 읽고 있었다. import 한 번에 줄줄이 깨졌다. 스크립트에서 TS 파일을 불러오는 공용 로더를 만들어, import 문의 상대 경로를 그 문장이 들어 있는 파일 위치 기준으로 풀게 했다.

더티 12종은 도감 맨 뒤에 등록하고, 진화로 도달할 수 있는 종 목록과 버전별 종 목록에도 넣었다. 이날은 규칙과 이름만 들어갔고, 도감에는 아직 그림 없이 ? 기호로 표시된다.

업데이트 직후 바로 진화해 버릴 성숙기 펫

진화 규칙이 바뀌면 이미 성숙기인 펫이 문제다. 예전에는 대전 횟수 조건을 못 채워 머물던 펫이 새 규칙에서는 진화 필요 시간만 채우면 바로 진화할 수 있다. 업데이트하자마자 대전 한 번 못 해 보고 진화하게 된다.

저장 데이터 형식을 v5로 올리면서, v4 형식에서 성숙기였던 펫은 쌓인 진화 시간을 되돌려 최소 24시간은 대전할 시간을 갖게 했다. 저장본을 불러오는 복원과, 기록된 행동을 다시 돌려 상태를 만드는 재계산(replay)이 같은 규칙을 쓴다.

수면 규칙이 하루에 세 번 바뀌었다

이날 가장 많이 흔들린 부분이다. 바뀐 과정이 그대로 설계의 근거가 됐다.

정오: 깨우면 수면방해

정오 무렵 첫 변경은 "자는 중에 먹이·훈련·대전·청소·주사를 하면 깨우고 수면방해 +1, 그 밤은 다시 자지 않는다"였다. 잘 때 불을 켜 두면 생기던 조명 페널티는 지웠다.

45분 뒤: 고정 시간대, 수면방해 삭제

45분 뒤 다시 바꿨다. 모든 종이 00:0007:00에 자고, 22:0010:00은 방치 페널티와 호출(배고픔·똥·아픔을 알리는 알림)이 없는 무적 시간대로 뒀다. 수면방해는 아예 없앴다. 성장기 갈림길의 한 축이 수면방해였는데, 그 자리는 대전 횟수(0~2 / 3 이상)로 바꿨다.

저녁: 정해진 수면 시간대마저 없앴다

수면방해까지 지우고 나니 수면에는 페널티도 보상도 남지 않았다. 정해진 시간대가 하는 일은 "그 시간에 못 놀게 막기"뿐이었다. 시각으로 판정하다 보니 "왜 낮에 자지?" 같은 혼란도 생겼다.

그래서 설계서에서 수면을 산책·두리번 같은 기본 행동 중 하나로 바꿨다.

  • 하루 46회, 회당 20분3시간, 하루 합 8시간 이하. 낮잠·밤잠 구분 없이 아무 때나 잔다.
  • LCD 행동(먹이·훈련·대전·청소·주사)만 깨운다. 페널티는 없고, 그 잠만 끝난다.
  • 걸음 수가 게임에 들어와도 깨우지 않는다.
  • 자는 동안은 전면 정지한다. 배고픔·똥 타이머, 케어 실수·발병·사망으로 이어지는 방치가 모두 멈춘다.
  • 진화 시간은 자는 시간도 포함해 센다.
  • 조명은 게임에 영향 없는 순수 연출이 됐다.

잠은 깨워도 되는 평범한 행동이 됐다. 재워 두면 그동안 방치 걱정이 없고, 깨웠다면 평소처럼 돌보면 된다.

Math.random()을 쓸 수 없는 낮잠 일정

폰·워치 동기화, 오프라인 정산, 호출 예측이 모두 같은 일정을 봐야 한다. 동기화는 마지막으로 맞춘 상태에서 그 뒤 행동 기록을 다시 돌려 같은 상태를 만든다. 오프라인 정산은 앱이 꺼져 있던 시간을 한꺼번에 계산하고, 호출 예측은 다음 호출 시각을 미리 계산한다. 그래서 Math.random()은 쓸 수 없다.

펫마다 고정된 시드와 현지 날짜로 PRNG(시드가 같으면 같은 수열을 만드는 의사 난수 생성기)를 만들고, 그날 안에서만 일정을 짰다. 자정을 넘는 잠을 만들지 않으면 날끼리 서로 의존하지 않는다.

아래는 하루치 잠 길이를 뽑는 부분이다. r()은 0 이상 1 미만을 돌려주는 PRNG이고, MIN_LEN은 한 번 잠의 최소 20분, MAX_TOTAL은 하루 합 480분이다. 한 번 잠은 20분에 0~160분을 더해 정하고, 합이 480분을 넘으면 비율대로 줄인 뒤 남는 분을 가장 긴 잠에서 하나씩 깎는다.

// src/game/nap-schedule.ts
function lengthsOf(r: () => number): number[] {
  const n = 4 + Math.floor(r() * 3)
  const lens: number[] = []
  for (let i = 0; i < n; i++) lens.push(MIN_LEN + Math.floor(r() * 161))
  let sum = lens.reduce((a, b) => a + b, 0)
  if (sum > MAX_TOTAL) {
    for (let i = 0; i < n; i++) lens[i] = Math.max(MIN_LEN, Math.floor(lens[i] * MAX_TOTAL / sum))
    sum = lens.reduce((a, b) => a + b, 0)
    while (sum > MAX_TOTAL) {
      lens[lens.indexOf(Math.max(...lens))]--
      sum--
    }
  }
  return lens
}

잠 길이를 먼저 뽑고, 하루(00:15~23:45) 안에 잠끼리 30분 간격을 두고 자리를 찾는다. 8번 시도해서 못 찾으면 남은 빈자리 중 맞는 곳에 둔다.

PRNG 호출 순서는 설계서에 고정해 두었다. 알고리즘을 바꾸면 잠 일정 알고리즘 번호(NAP_ALGO)와 폰·워치 동기화 버전을 같이 올린다. 돌려주는 배열은 얼려(변경할 수 없게 만들어) 뒀다. 어딘가에서 실수로 바꾸면 모든 기기의 일정이 오염되기 때문이다.

자는 동안 방치가 멈추자 따라 움직인 숫자들

항목이전이후
케어 실수 판정 시간120분60분
진화 필요 시간 (자는 시간 포함)—17h / 42h / 72h / 120h
훈련 게이지 속도전 단계 같음유년기 0.8 · 성장기 그대로 · 성숙기 1.2 · 완전체 1.4 · 궁극체 1.6
호출 종류배고픔 0·똥·아픔·잘 때 불 켜짐잘 때 불 켜짐 삭제, 자는 동안 호출 없음
동기화 버전12 (옛 엔진을 쓰는 워치와는 동기화하지 않는다)

케어 실수 분포는 시뮬레이션 도구로 다시 쟀다.

밤에는 상태가 바뀌는 틈에서 생기는 버그를 몇 개 잡았다. 진화 애니메이션이 도는 중에도 자는 동안에는 타이머를 멈추게 했고, LINK 대전·조그레스를 확정할 때도 펫을 깨우게 했다.

가장 까다로운 건 "잠 시간대에 들어왔지만 통신 중이라 아직 잠들지 않은" 상태였다. 이때 LCD 행동을 하면 실시간 엔진과 재계산이 깬 시각을 다르게 기록할 수 있었다. 두 경로가 "자고 있으면 깨우는" 함수 wakeIfSleeping 하나를 같이 쓰게 해서, 잠 시간대 안의 LCD 행동은 아직 잠들지 않았어도 그 잠을 끝내게 맞췄다.

펫을 구분하는 키 규칙도 petKeyOf 함수 한 곳으로 모았다. 이 키는 진화해도 바뀌지 않고 펫 한 생애를 따라간다. 잠 시드·저장 키·번호 코드 대기 기록이 모두 같은 함수를 쓴다.

LINK, 블루투스만 켜고 톡 맞대고 2초

LINK(근처 통신)는 Google Nearby Connections(근처 기기를 찾아 연결해 주는 Google API)로 READY인 근처 기기를 찾으면 처음 찾은 상대와 바로 연결했다. 기기를 맞대지 않아도 연결되니 옆 테이블의 READY와 이어질 수 있었다. 주고받는 데이터는 몇 KB뿐인데 Bluetooth·Wi-Fi·로컬 네트워크 권한이 다 필요했다.

설계서의 목표는 "블루투스만 켜고, 두 폰을 2초 대고 있으면 연결된다"였다. Nearby를 빼고 iOS CoreBluetooth(iOS의 블루투스 API)·Android BLE(Bluetooth Low Energy) API만 쓰는 네이티브 모듈로 바꿨다. 영수증(대전 결과를 반영했다는 기록) 교환·대전 계산·동의 흐름은 그대로 두고, 데이터를 실어 나르는 통신 방식과 "누구와 연결할지"만 바꿨다. 영수증은 같은 대전을 두 번 반영하지 않으려고 서로 주고받는다.

신호 세기만으로는 맞닿음을 가를 수 없었다

조사에서 확인한 사실이 설계를 정했다. RSSI(수신 신호 세기)는 기기·케이스·방향에 따라 ±10dB 이상 흔들린다. "맞닿음"과 "30cm"를 값 하나로 확실히 가를 수는 없다.

그래서 실제 반경은 10~30cm로 보고(놀이용으로는 충분하다), 최근 짧은 구간에 잰 신호 세기의 중앙값으로 흔들림을 줄였다. iOS에서 보내는 BLE 광고(주변 기기에 자기 존재를 알리는 신호)에는 로컬 이름과 서비스 UUID(어떤 기능의 기기인지 알리는 식별자)만 실을 수 있어서, 이름 자리에 이번 연결에만 쓰는 무작위 토큰 8자를 넣었다. 기기 이름은 싣지 않는다.

근접 판정은 React Native나 네이티브 코드에 기대지 않는 순수 모듈로 뒀다. 시각은 호출하는 쪽이 넘긴다. 가장 가까운 상대가 바뀌면 near, 사라지면 far, 두 폰을 가볍게 톡 부딪친 뒤 2초를 대고 있으면 hold(연결)를 돌려준다.

아래는 밤에 톡 조건까지 더한 뒤의 코드다.

  • closest(at): 기준 신호 세기 이상인 상대 중 가장 센 상대를 고른다
  • near: 지금 가깝다고 보고 있는 상대의 토큰과, 가까워진 시각(since)
  • bump: 가속도 센서로 잡은 마지막 톡의 시각(at)과, 그 톡으로 이미 연결을 냈는지(held)
  • HOLD_MS(2초): 톡 뒤 가까운 상태가 이만큼 이어지면 연결한다
  • NEAR_LAG_MS(1초): 신호 세기는 중앙값을 내느라 톡보다 늦게 잡힐 수 있어서, 톡 뒤 1초 안에 가까워졌어도 같은 접촉으로 본다
  • BUMP_FRESH_MS(5초): 톡이 이보다 오래됐으면 다시 톡해야 한다
// src/nearby/proximity.ts
const judge = (at: number): ProximityEvent | null => {
  const best = closest(at)
  if (!best) {
    const was = near !== null
    near = null
    return was ? { type: 'far' } : null
  }
  best.peer.closeAt = at
  if (near?.token !== best.token) {
    near = { token: best.token, since: at }
    return { type: 'near', token: best.token, peerId: best.peer.peerId }
  }
  // 톡 맞댐(가까움이 톡 전후로 이어짐) 뒤 2초 — 떼었다 다시 대면 톡을 다시 해야 한다
  if (bump && !bump.held && near.since <= bump.at + NEAR_LAG_MS && at - bump.at >= HOLD_MS && at - bump.at <= BUMP_FRESH_MS) {
    bump.held = true
    return { type: 'hold', token: best.token, peerId: best.peer.peerId }
  }
  return null
}

hold 조건은 길어 보이지만 '아직 연결에 쓰지 않은 톡이 있고, 그 톡 전후 1초 안에 가까워졌고, 톡 뒤 2~5초 사이'라는 뜻이다.

처음에는 신호 세기만으로 2초를 셌다. 이렇게 하면 가까이 있는 제3의 폰이 끼어들 수 있다. 밤에 가속도 센서로 "톡"을 잡는 조건을 더했다. 두 폰의 톡이 0.7초 안에 겹쳐야 연결한다.

내 폰이 내는 진동은 톡으로 잡히지 않게 센서에서 뺐고, 톡은 READY에서 상대를 찾는 중일 때만 센다. 큰 메시지는 BLE 한 번에 다 못 보내서 조각으로 나누고 다시 합치는 코드도 넣었다.

같은 상대와 LINK로 연속 대전할 때 지난 판의 영수증이 새 판을 끝내 버리는 버그도 이날 고쳤다. 실제 블루투스 대신 테스트용 가짜 연결로 두 기기를 5판 연속 붙여서, 양쪽 전적이 같은지 보는 통합 테스트를 붙였다.

네이티브 빌드와 실기기 확인은 이날 하지 않았다. 근접 기준값은 개발용 RSSI 표시를 보며 실기기에서 맞출 수 있게 상수로 뺐다.

같은 날 손본 화면과 숫자들

  • 상대를 기다리는 동안 색을 뒤집어 그린 READY 글자 상자를 공통 420ms 주기로 깜빡이게 맞췄다.
  • 물음표 버튼으로 여는 도트 가이드 화면을 넣고, 책처럼 쪽을 넘기는 목차·메뉴·훈련·대전 쪽을 만들었다. 이날 바뀐 규칙도 가이드에 넣었다.
  • 배고픔·스태미나를 하트 4칸 게이지로 바꿨다. 내부는 0~1을 1/240 정수 단위로 저장한다. 240은 3·4·5·6·8·16으로 나누어떨어져서 소수 오차가 폰·워치 재계산에 끼어들지 않는다.
  • QuickJS 호스트에 console이 없어도 상태 머신 라이브러리 xstate가 로드되게 shim(빠진 API를 대신 채워 주는 코드)을 넣었다.
  • 워치도 화면에서 내려가면 번호 코드 대기 화면(CODE READY)을 닫지 않고 이어 간다(폰과 같은 규칙).
  • 게임기 본체 테두리(셸) 색: 레몬을 휴대용 가상 펫 장난감(V-pet)에 흔한 머스타드 옐로로, 연분홍을 파스텔 핑크로 바꿨다.
  • 영양제(스태미나를 채우는 먹이)를 너무 많이 먹어 병이 나는 기준을 1시간 4개에서 30분 안에 20개 초과로 완화했다.
  • 도감 진화 경로를 직각 꺾임 대신 도트 직선 화살표로 그렸다.
  • 똥을 최대 4개까지 2×2 무더기로 보여 준다.
  • 상태창에서 체형 라벨과 속성(TYPE) 표시를 지웠다(내부 판정은 유지). 나이는 하루에 1살(1Y)씩 세고, 스태미나 칸은 비타민 캡슐 아이콘으로 바꿨다. 2쪽 상단에는 누적 걸음(WALK)을 넣었다.
  • 대전 메뉴의 SOLO 글자를 STAGE로 바꿨다.
  • 코드 입력 중에도 내 코드를 보여 준다. 폰은 LCD 아래 줄, 워치는 키패드 위.
  • 훈련으로 체중이 하한까지 내려간 펫도 마른 체형(QUICK)이 되게 하한 조건을 지웠다.
  • 상태 바를 숨기고, Android 내비게이션 바도 숨겼다(expo-navigation-bar).
  • 몬스터 도트·모션을 갱신하고(astrox·eggnaut·snowlem 등), 도트 일괄 변환·모션 검토 스크립트를 만들었다.
  • 대전 기록을 규칙 버전에 맞춰 초기화하는 코드를 넣었다.

앱을 지웠다 깔아도 펫이 돌아오게 게스트 백업을 설계했다

펫·생애 기록은 기기의 SQLite에만 있다. 앱을 지우면 OS가 앱 저장소를 지운다. Android 자동 백업이 옛 데이터를 되살리는 경우가 아니면, 다시 깔면 새 알부터다.

설계서는 로그인 없이 기기에 묶인 "게스트 계정"으로 서버에 백업을 쌓는 구조다. 게임은 그대로 기기에서 하고 서버는 백업만 맡아서, 인터넷이 없어도 키울 수 있다. 복구 범위는 같은 기기 재설치뿐이다. 백업에는 번호 코드에 쓰던 Cloudflare 서버를 넓혀 쓴다.

방향은 "최대한 단순하고 안전하게"였다. 오후에 한 번 크게 다시 정리했다. 전체 백업을 통째로만 올리고, 최근 5벌·날짜별 7일·교체 전 1벌만 보관한다.

안전 원칙 맨 앞에는 기기 데이터를 서버 데이터로 덮어쓰지 않는다를 뒀다. 서버에서 받아오는 건 기기가 완전히 비어 있을 때뿐이고, 모르는 상황이면 멈추고 다음 실행 때 다시 한다.

기기 식별자 대신 앱이 만든 무작위 ID

밤에는 스토어 권장 사항에 맞췄다. Android는 공장 초기화 전까지 바뀌지 않는 기기 식별자(ANDROID_ID) 대신, 앱이 만든 무작위 ID를 Google Block Store(앱을 다시 설치하거나 새 기기로 옮길 때 작은 데이터를 되살려 주는 Google Play 서비스 API)에 두게 했다. Apple 심사 기준에 따라 백업 전에 동의도 받게 했다.

iOS 키체인(iOS의 보안 저장소) 저장 옵션도 지원 중단 예정(deprecated)이 아닌 WHEN_UNLOCKED_THIS_DEVICE_ONLY로 골랐다. Android 자동 백업은 껐다. 자동 백업이 옛 펫을 되살려 서버 백업과 엇갈리는 경우를 없애려는 것이다.

자정 직전, 서버 쪽 보관 규칙부터 들어갔다

보관 규칙은 저장소를 주입받는 순수 클래스(vault-logic.ts)로 만들고, 그 위에 계정별 백업을 보관하는 Durable Object(Vault), /v1/vault 요청 경로, 새 Durable Object를 등록하는 마이그레이션 설정, 시험용 staging 환경을 얹었다.

백업은 순번(seq, 업로드할 때마다 1씩 오르는 번호)이 서버의 최신보다 커야 저장한다. 늦게 온 옛 백업은 저장하지 않고 stale(이미 지난 백업) 응답만 돌려준다. 다른 프로필(기기에서 새로 만든 게임 데이터)의 백업이 오면 거부하지 않고, 지금 최신본을 교체 전 보관 칸에 옮겨 둔다. 마지막 백업에서 1년이 지나면 알람이 모두 지운다.