본문 바로가기
tokenAppsPLAY & COLLECT

TokenApps Platform SDK — 정식 규격서 (SDK_SPEC) v1.4

문서 지위: 이 문서가 규범(normative) 이다. SDK 가이드는 같은 내용을 설명하는 튜토리얼이며, 둘이 다르면 이 문서가 이긴다. 규범 문서의 관례에 따라 규정체로 쓴다. 키워드 MUST / MUST NOT / SHOULD / MAY 는 RFC 2119 의미로 쓴다.

대상물: SDK 파일 https://tokenapps.io/sdk/v1.js · TokenApps 플레이어(호스트) · REST /api/game/*, /api/apps/*, /api/ads/*. 마지막 갱신 2026-08-25.


1. 버전 정책

  • SDK 파일 경로가 메이저 버전이다: https://tokenapps.io/sdk/v1.js. 메시지의 v: 1과 일치한다.
  • v1 안에서의 변경은 항상 하위호환이다. 허용되는 변경: 새 메시지 type 추가, 기존 payload에 선택 필드 추가, 새 SDK 메서드 추가. 금지되는 변경: 기존 필드의 삭제· 의미 변경·타입 변경, 기존 메시지의 필수 필드 추가.
  • 게임은 모르는 메시지 type과 모르는 payload 필드를 MUST ignore (무시하고 계속 동작). 호스트도 마찬가지다 — 이 규칙 덕에 양쪽이 독립적으로 업데이트된다.
  • 하위호환이 불가능한 변경은 v2.js + v: 2로만 나간다. v1은 v2 출시 후에도 유지된다.
  • SDK는 TokenApps.version("1.4.0" 형식)을 노출한다. 다만 분기가 필요하면 버전 비교보다 기능 감지(typeof TokenApps.requestContinue === "function")를 권장한다.
  • 이 문서의 버전(v1.x)은 v1 안의 추가 이력만 표시한다. v1.0 = 세션·세이브·TAP(토큰앱스 포인트), v1.1 = 보상형 광고(request_ad/ad_result), v1.2 = 이어하기 시트 (request_continue/continue_result), v1.3 = 게임 내 재화 (get_products/request_purchase/인벤토리).

2. 전송 계층과 메시지 형식

게임은 샌드박스 iframe 안에서 돈다. 외부 HTTPS 프레임은 자체 출처를 유지하고(allow-same-origin), 플랫폼 상대경로 프레임은 opaque origin이다. 플랫폼과의 모든 대화는 postMessage 하나로 오간다. 모든 메시지는 다음 형식(envelope)을 따른다:

{ "__tokenapps": 1, "v": 1, "type": "<메시지 이름>", "payload": { … } }
  • targetOrigin은 외부 출처와 opaque origin 호환을 위해 양방향 모두 "*"이다. 인증은 window 동일성으로 한다:
    • 호스트는 event.source === iframe.contentWindow인 메시지만 처리한다 (MUST).
    • SDK는 event.source === window.parent인 메시지만 처리한다 (MUST).
  • __tokenapps: 1이 없거나 type이 문자열이 아닌 메시지는 조용히 버린다 (MUST).

3. 신뢰 모델 (규범)

# 규칙 수준
T1 게임은 요청만 한다. TAP 지급·액수·상한·광고 보상 등 모든 판정은 서버가 한다. 게임이 보낸 값(이벤트 이름, 점수, 시청 완료 주장)은 검증 없이 신뢰되지 않는다 구조적
T2 게임은 플랫폼 계정 식별자를 받을 수 없다. player.id는 (유저, 게임) 쌍 고정 가명이다 MUST NOT
T3 게임 토큰은 자신의 (유저, 게임) 세이브 슬롯 밖에서 무효다 구조적
T4 게임은 게스트 상태로도 완전히 플레이 가능해야 한다. 로그인 강제·게스트 차단은 심사 반려 사유다 MUST
T5 점수(score)는 표시용이며 TAP·보상으로 이어지지 않는다 (게임산업법 경품 규정) 구조적
T6 광고: unavailable정상 응답이다. 게임은 광고 없이도 진행되는 경로를 항상 유지해야 한다. 플레이·콘텐츠를 광고 시청에 종속시키는 게임은 심사 반려 사유다 MUST
T7 플레이 시간 측정은 호스트가 한다. 게임은 아무것도 할 필요가 없고, 아무것도 조작할 수 없다 구조적

4. 메시지 규격 (전체)

4.1 게임 → 호스트

type payload 의미 응답
ready {} 세션 요청. 재전송 = 토큰 갱신 요청 (401을 받은 SDK가 자동으로 보낸다). 첫 session이 올 때까지 SDK는 0.4초부터 두 배 간격으로 5회 다시 보낸다 — 게임이 호스트 페이지보다 먼저 준비돼도 핸드셰이크가 사라지지 않게 하기 위해서다. 호스트는 ready마다 session으로 응답한다 (MUST) session
track { event: string } 게임플레이 이벤트 보고. event는 64자 이하 (초과분은 호스트가 버린다) points
score { score: number } 점수 제출 (음수·소수는 호스트가 0/내림 정규화) score_ack
request_signin {} 로그인 제안 요청. 호스트가 물을지 여부와 방법을 정한다. 로그인되면 session이 다시 온다 없음 (필요 시 session)
open_purchase {} 플랫폼 TAP 구매 시트 열기 요청. 플랫폼이 거부하면 아무 일도 없다 없음
request_ad { placement: string } 보상형 광고 요청. placement는 32자 이하 ad_result
request_continue {} 이어하기 시트(TAP/광고/거절) 요청 continue_result
get_products {} 이 게임의 서버 등록 상품 목록 요청. 게스트도 가능 products
request_purchase { sku: string } 재화 구매 요청. sku^[a-z0-9_-]{1,64}$가격 필드는 존재하지 않는다 purchase_result
match_find { ref: string, mode?: string, kind?: "auto" | "async" | "sync", timeout?: number } 대전 상대 요청. ref는 SDK가 find() 한 번마다 붙이는 식별자이며 이후 이 매치의 모든 메시지를 잇는다. kind: "sync" 실시간 상대만, "async" 기록과의 비동기 대전, "auto"(기본) 실시간을 먼저 찾고 timeout초(5–120, 기본 45) 안에 상대가 없으면 비동기 대전으로 바뀐다. mode는 현재 "duel" match_result
match_send { ref: string, data: any } 실시간 대전의 게임 메시지. JSON 직렬화 4KB 이하, 초당 8건 이하 — 넘는 것은 버리고 ratelimited 이벤트를 낸다. 호스트는 내용을 읽지 않고 상대에게 그대로 전달한다 없음 (상대 쪽 match_event message)
match_report { ref: string, score: number, outcome?: "win" | "lose" | "draw" } 대전 결과 보고. 매치당 1회. outcome은 실시간 대전에서만 쓰며, 생략하면 서버가 두 점수를 비교한다 match_outcome
match_leave { ref: string } 대기 취소·매치 이탈. 비동기 대전과 시작 전 실시간 매치는 단순 취소, 진행 중인 실시간 매치는 기권(패) 없음

4.2 호스트 → 게임

type payload 발생 시점
session §5 참조 ready 수신 시. 로그인 상태 변화·토큰 갱신 때 다시 온다
points { event, status, delta?, balance? } — status: "awarded" | "duplicate" | "capped" | "ignored" | "signin_required" track 수신 후 서버 판정이 나오면
score_ack { status, best?, week?, rank?, weekRank? } — status: "recorded" | "kept" | "throttled" | "rejected" | "signin_required" | "failed" score 수신 시. recorded는 자기 최고 기록 갱신, kept는 기존 기록 유지
ad_result { placement, status, reward?, balance?, reason? } — status: "rewarded" | "dismissed" | "unavailable" | "failed" request_ad의 최종 결과 확정 시 (플레이어 선택 포함)
continue_result { status, cost?, balance?, reason? } — status: "paid" | "rewarded" | "declined" | "unavailable" request_continue의 최종 결과 확정 시
products { items: [{ sku, name_ko, name_en, price_points, kind }] } — kind: "consumable" | "durable" get_products 수신 시
purchase_result { status, sku?, balance?, reason? } — status: "purchased" | "already_owned" | "declined" | "unavailable" | "failed" request_purchase의 최종 결과 확정 시 (플레이어 확인 포함)
match_result { ref, status: "searching" } · { ref, status: "matched", match: Match, reused?: boolean, fallback?: "async" } · { ref, status: "unavailable", reason } — reason: "signin_required" | "live_unavailable" | "capped" | "suspended" | "rate_limited" | "not_found" | "failed" | "unavailable" match_find 수신 시. searching은 실시간 대기에 들어갔다는 뜻이며 매치는 이후 match_eventready로 시작한다. matched는 비동기 대전이 바로 준비됐다는 뜻이다. fallback: "async"는 실시간을 요청했지만 이 배포에서 실시간 대전이 꺼져 있어 비동기 대전으로 응답했다는 뜻
match_event { ref, event, data } — event: "ready" | "message" | "opponent_left" | "closed" | "ratelimited" 매치 진행 중. ready의 data { id, kind, seed, you, opponent }가 매치의 시작점이다(대기가 만료돼 비동기 대전으로 바뀐 경우 kind: "async"). message는 상대가 보낸 값, opponent_left{ reason: "left" | "disconnected" }, closed{ reason }
match_outcome { ref, status, outcome?, ratingDelta?, opponentScore?, verified?, rating? } — status: "finished" | "disputed" | "already_reported" | "expired" | "not_started" | "not_found" | "rejected" | "failed", outcome: "win" | "lose" | "draw" | null match_report 수신 후 서버 판정이 확정되면. 실시간 대전은 상대도 보고(또는 기권)한 뒤에 온다. disputed는 두 보고가 엇갈려 결과 없이 닫혔다는 뜻이다. 비동기 대전에서 verified: false면 결과는 있으나 레이팅 변동은 0

ad_result·continue_result·purchase_result는 각 요청 1건당 정확히 1번 온다 (MUST). 순서는 요청 순서를 따른다. 시트 안에서 광고를 골라 시청한 경우에도 게임에는 continue_result 하나만 온다 — ad_result는 오지 않는다.

5. TypeScript 타입 정의 (참조 구현)

/** postMessage 공통 메시지 형식(envelope) */
type Envelope<T extends string, P> = { __tokenapps: 1; v: 1; type: T; payload: P };

/** 호스트 → 게임: session */
type SessionPayload = {
  signedIn: boolean;
  locale: "ko" | "en";
  /** 이하 4개는 로그인 + 토큰 발급 성공 시에만 존재. 없으면 게스트로 취급한다. */
  player?: {
    id: string;
    handle: string | null;
    displayName: string | null;
    /** TokenPost 신원이 이 계정에 연결되어 있는지. 자격 판정용 불리언이며 신원은 담지 않는다. */
    verified: boolean;
  };
  token?: string;      // Bearer. /api/game/* 전용
  expiresIn?: number;  // 초
  api?: string;        // API 오리진, e.g. "https://tokenapps.io"
};

/** 호스트 → 게임: points */
type PointsPayload = {
  event: string;
  status: "awarded" | "duplicate" | "capped" | "ignored" | "signin_required";
  delta?: number;
  balance?: number;
};

/** 호스트 → 게임: ad_result */
type AdResultPayload = {
  placement: string;
  status: "rewarded" | "dismissed" | "unavailable" | "failed";
  reward?: number;   // rewarded일 때 지급된 포인트
  balance?: number;  // rewarded일 때 갱신 잔고
  reason?: string;   // unavailable/failed의 사유 (아래 §7.2 표) — 표시용, 분기 금지
};

/** window.TokenApps */
interface TokenAppsSdk {
  ready(): void;
  track(event: string): void;
  submitScore(score: number): void;
  openPointsPurchase(): void;
  requestRewardedAd(placement: string): Promise<AdResultPayload>;
  requestContinue(): Promise<{
    status: "paid" | "rewarded" | "declined" | "unavailable";
    cost?: number;
    balance?: number;
    reason?: string;
  }>;
  getProducts(): Promise<
    { sku: string; name_ko: string; name_en: string | null;
      price_points: number; kind: "consumable" | "durable" }[]
  >;
  requestPurchase(sku: string): Promise<{
    status: "purchased" | "already_owned" | "declined" | "unavailable" | "failed";
    sku?: string;
    balance?: number;
    reason?: string;
  }>;
  getInventory(): Promise<
    { sku: string; kind: string; count: number; firstAt: string }[]
  >;
  getSession(): SessionPayload | null;
  getUser(): SessionPayload["player"] | null;
  canSave(): boolean;
  save(data: unknown): Promise<{ ok: true; updatedAt: string }>;
  load(): Promise<unknown | null>;
  on(type: "session" | "points" | "score_ack" | "ad_result" | "continue_result"
        | "products" | "purchase_result",
     fn: (payload: unknown) => void): void;
}

save()/load()의 거부(reject): Error("guest") = 게스트, Error("expired") = 토큰 만료(SDK가 이미 재핸드셰이크를 보냈다 — 다음 session 메시지 후 재시도), Error("save_failed_<status>") = 그 외 HTTP 오류.

/** match.find()의 결과. 요청이 접수된 순간부터 있는 핸들이며, 필드는 `ready`에서 채워진다. */
export type TokenAppsMatch = {
  id: string | null;                    // ready 전 null
  kind: "async" | "sync" | null;        // ready 전 null. sync = 실시간 상대, async = 기록
  mode: string;
  /** 양쪽이 같은 판을 만들기 위한 시드 — 게임의 난수는 이 값으로만 초기화한다. ready 전 null. */
  seed: number | null;
  you: { slot: number; handle: string | null; rating: number } | null;
  /** 비동기 대전의 상대는 기록이다: score가 목표 점수. 아직 아무도 기록이 없으면 null. */
  opponent: { slot: number; handle: string | null; rating: number; score?: number } | null;
  expiresAt: string | null;
  state: "searching" | "ready" | "closed";
  /**
   * searching { elapsed }   대기 중 1초마다
   * ready { id, kind, seed, you, opponent }   매치 시작 — find() 해결보다 늦은 틱에 온다
   * message (상대의 send 값) · opponent_left { reason } · result (report의 판정)
   * closed { reason } · ratelimited { dropped }
   */
  on(event: "searching" | "ready" | "message" | "opponent_left" | "result" | "closed" | "ratelimited",
     fn: (payload: unknown) => void): () => void;
  send(data: unknown): void;            // 실시간 전용 — JSON 4KB 이하, 초당 8건 이하
  report(result: { score: number; outcome?: "win" | "lose" | "draw" }): Promise<MatchOutcomePayload>;
  leave(): void;                        // 진행 중인 실시간 대전에서는 기권(패)
};

export type MatchOutcomePayload = {
  status: "finished" | "disputed" | "already_reported" | "expired" | "not_started"
        | "not_found" | "rejected" | "failed" | "closed";
  outcome?: "win" | "lose" | "draw" | null;   // disputed면 null
  ratingDelta?: number;
  opponentScore?: number | null;
  verified?: boolean;
  rating?: number;
};

6. REST 규격

세이브 API는 CORS 전면 허용(Bearer 전용, 쿠키 없음)이라 opaque origin에서도 호출된다. 광고 API는 호스트 페이지가 호출한다 — 게임은 §4의 메시지로만 광고를 다룬다.

6.1 GET /api/game/token — 토큰 발급 (호스트 전용)

쿠키 인증, CORS 없음. 게임은 이 엔드포인트를 호출할 수 없다 — 토큰은 session 메시지로만 받는다. 쿼리 ?slug=<앱 슬러그>.

상태 응답
200 { token, expiresIn, player: { id, handle, displayName } }
401 { error: "unauthorized" } — 로그인 없음
404 { error: "not_found" } — 그 슬러그의 플레이 가능한 앱 없음
503 { error: "not_configured" } — 데모 모드 (게임은 게스트로 돈다)

6.2 GET · PUT /api/game/save — 세이브 슬롯

Authorization: Bearer {token} 필수. 게임당·유저당 슬롯 1개.

메서드 요청 성공 오류
GET 200 { data: <값 | null>, updatedAt } 401 invalid_token
PUT { "data": <아무 JSON> } (data 필드 필수) 200 { ok: true, updatedAt } 401 invalid_token · 413 too_large (직렬화 64KB 초과) · 400 data_required

401을 받은 SDK는 자동으로 ready를 재전송한다. 게임은 새 session을 기다렸다가 재시도하면 된다 (SHOULD).

6.3 POST /api/ads/offer — 광고 오퍼 발급 (호스트 전용)

요청: { slug, placement }. 쿠키 인증.

상태 응답
200 { available: true, offerId, watchSeconds, reward, creative } 또는 { available: false, reason }
400 { error: "slug_and_placement_required" }
404 { error: "not_found" }
503 { error: "not_configured" }

부적격은 오류가 아니라 200 + available: false다. reason: signin_required · placement_not_allowed · not_monetizable(Basic 단계 앱) · too_soon(전역 최소 간격) · daily_cap(유저 일일 한도) · app_daily_cap · point_cap(일일 TAP 상한 도달 — 0 보상 시청 원천 차단).

6.4 POST /api/ads/complete — 오퍼 청구 (호스트 전용)

요청: { offerId }. 쿠키 인증. 서버는 오퍼의 서명·소유자·만료·서버 시계 기준 시청 시간을 검증한다. 클라이언트 카운트다운은 증거가 아니다.

상태 응답
200 { status: "awarded", delta, balance } · { status: "duplicate", balance }(재사용 오퍼 — 지급 없음) · { status: "capped", … }
400 { error: "offer_required" }
401 { error: "unauthorized" }
422 { error: "invalid_offer" }(위조·만료·타인 오퍼) · { error: "too_early" }(시청 시간 미달)

향후 광고망 연동 시 이 엔드포인트 자리에 S2S 콜백이 들어가고, 멱등키 규율 (ad:{uid}:{nonce})은 그대로 유지된다.

6.5 POST /api/apps/{slug}/continue — TAP 이어하기 (호스트 전용)

요청: { requestId: UUID } (호스트가 발급 — 시트 상호작용 1회 = 지출 1회). 쿠키 인증. 단가는 서버 상수(CONTINUE_COST_POINTS)이며 클라이언트가 바꿀 수 없다. 차감은 spend_points SQL 함수(사용자별 advisory lock + 잔고 검사 + 멱등키)로만 이루어진다 — 잔고 미만 차감·경쟁 이중 지출이 구조적으로 불가능하다.

상태 응답
200 { status: "spent", cost, balance } · { status: "insufficient", balance } · { status: "duplicate", balance }
400 { error: "request_id_required" }
401 { error: "unauthorized" }
403 { error: "not_monetizable" } — Basic 단계 앱 (TAP 싱크 포함 전면 비수익화)
404 { error: "not_found" }

6.6 GET /api/apps/{slug}/products — 상품 목록 (호스트 전용)

공개 데이터(가격표)지만 호스트 페이지가 대신 조회해 products 메시지로 전달한다 — 게임 쪽 CORS 표면이 없다. 노출 필드는 §4.2 products payload가 전부다(내부 id 없음). 가격의 정본은 이 목록뿐이다. 게임 UI에 뭐라고 적혀 있든 청구는 이 값으로 된다.

6.7 POST /api/apps/{slug}/purchase — 재화 구매 (호스트 전용)

요청: { sku, requestId: UUID } (호스트 발급). 쿠키 인증 — 샌드박스 iframe에서는 도달 자체가 불가능하고, 플레이어가 플랫폼 시트에서 확인을 눌러야만 호출된다. 정산은 purchase_game_item SQL 함수 하나로: 사용자별 advisory lock → 멱등키 → durable 중복 보유 거절 → 잔고 검사 → 원장 차감 + 인벤토리 기록이 한 트랜잭션. TAP이 나갔는데 기록이 없는(또는 그 반대) 경로가 존재하지 않는다.

상태 응답
200 { status: "purchased", sku, kind, balance } · already_owned · insufficient · duplicate(재전송 — 이미 1회 결제됨) · not_found(모르는/비활성 sku)
400 { error: "bad_request" }
401 { error: "unauthorized" }
403 { error: "not_monetizable" } — Basic 단계 앱
404 { error: "not_found" } — 앱 없음

6.8 GET /api/game/inventory — 보유 재화 (Bearer)

Authorization: Bearer {token}. /api/game/save와 같은 접근 모델(CORS 전면 허용, 토큰이 유일한 크리덴셜, (유저, 게임) 쌍으로 스코프).

상태 응답
200 { items: [{ sku, kind, count, firstAt }] } — durable은 1회 보유, consumable은 누적 구매 수
401 { error: "invalid_token" }

6.10 POST /api/match/find — 대전 시작 (호스트 전용)

요청 { app: string, mode?: "duel", kind?: "auto" | "async" | "sync", timeout?: number }. 플레이어 세션 쿠키로 인증한다. 게스트 401, 게임 없음 404.

  • kind: "async" — 즉시 200 { status: "matched", match, reused }. 같은 게임에 만료되지 않은 비동기 매치가 있으면 새로 만들지 않고 그것을 돌려준다(reused: true). 하루 300회를 넘기면 429 { status: "unavailable", reason: "capped" }.
  • kind: "sync"·"auto" — 실시간 대기열에 등록한다. 바로 짝이 맺어지면 200 { status: "matched", queueId, match }(match.kind"sync", match.status"pending"), 아니면 202 { status: "queued", queueId, expiresAt }. 대기는 timeout초 (5–120, 기본 45)가 지나면 서버가 비동기 대전으로 바꾼다. 한 사람은 한 번에 대기표 하나만 가진다 — 같은 게임에서 다시 부르면 같은 표를 돌려주고, 이미 앉아 있는 실시간 매치가 있으면 그 매치를 돌려준다. 분당 10회를 넘기면 429 rate_limited, 분쟁으로 정지된 계정은 403 suspended.
  • 이 배포에서 실시간 대전이 꺼져 있으면(Realtime 서명 키 미설정) "auto"는 비동기 대전으로 응답하고(fallback: "async"), "sync"503 { status: "unavailable", reason: "live_unavailable" }.

비동기 대전의 상대는 이 게임에서 레이팅이 가장 가까운 다른 플레이어의 최고 기록이며, 매치 생성 시점의 값으로 고정된다. 비동기 매치는 6시간 안에 보고해야 한다. 실시간 짝은 같은 게임·모드에서 레이팅 차이 100 이내로 시작해, 대기 10초마다 허용 범위가 50씩 넓어진다.

6.11 POST /api/match/{id}/result — 대전 결과 보고 (호스트 전용)

요청 { score: number, outcome?: "win" | "lose" | "draw" }. 응답 { status, result? }result{ outcome, ratingDelta, opponentScore, verified, rating }. 매치당 1회 — 두 번째 보고는 already_reported와 첫 결과를 돌려준다.

  • 비동기 대전: 서버는 매치 생성 이후 이 플레이어가 score 메시지로 기록한 리더보드 값이 보고 점수 이상일 때만 verified: true로 두고 레이팅(Elo, K=24)을 움직인다. 상대는 기록을 빌려준 것이라 상대의 레이팅은 변하지 않는다.
  • 실시간 대전: 첫 보고의 응답은 { status: "pending" }이다. 두 번째 보고가 들어오면 서버가 두 보고를 대조한다. 각 보고의 outcome(없으면 두 점수를 비교해 읽은 값)이 서로 맞으면(승·패 또는 무·무) finished로 확정하고 양쪽 레이팅을 Elo(K=32)로 움직인다. 맞지 않으면 disputed로 닫고 아무것도 움직이지 않는다. 같은 상대와 3회 연속 disputed면 두 사람 모두 하루 동안 실시간 대전에서 정지된다. verified는 기록되지만 실시간 판정의 조건은 아니다 — 두 사람의 합의가 검증이다.
  • 진행 중 이탈(leave, 또는 보고 없이 하트비트가 20초 끊김)은 남은 쪽의 승리로 확정하고 양쪽 레이팅을 절반 가중치(K=16)로 움직인다. 두 사람 모두 끊기면 abandoned로 닫고 변동이 없다. 시작 후 30분이 지나도 보고하지 않은 쪽은 끊긴 것으로 본다.

6.12 POST /api/match/{id}/leave · GET /api/match/{id} (호스트 전용)

leave는 비동기 대전과 시작 전 실시간 매치를 취소하고(레이팅 변동 없음), 진행 중인 실시간 매치에서는 기권으로 처리한다(§6.11). GET은 매치 상태와 결과를 돌려주는 폴링 경로다. 실시간 매치의 seed는 양쪽이 준비를 마쳐 active가 될 때까지 null이다. 참가자가 아니면 둘 다 404.

6.13 실시간 대전 경로 (호스트 전용)

실시간 대전은 플레이어 셸(게임을 품은 페이지)이 Supabase Realtime에 직접 연결해 진행한다. 게임은 이 연결·토큰·식별자를 보지 못하고 §4의 postMessage만 주고받는다.

경로 쓰임
POST /api/match/token Realtime 접속 정보 { url, key, jwt, exp, topic }. jwt는 15분짜리 사용자 토큰, topicuser:{id}. 셸은 만료 5분 전에 다시 받는다. 실시간 대전이 꺼져 있으면 503
GET /api/match/queue/{id} 대기표 상태 { ticket: { status, matchId, createdAt, expiresAt } } — status: "waiting" | "matched" | "cancelled" | "fallback". user:{id} 알림을 받지 못한 셸의 폴링 경로
DELETE /api/match/queue/{id} 대기 취소. 그 사이 짝이 맺어졌으면 409 { status, matchId } — 호출자는 그 매치를 leave한다
POST /api/match/{id}/ready 매치 채널 입장 후 준비 알림. 두 번째 준비가 매치를 시작한다: { status: "active", seed, startedAt }. 짝이 맺어진 뒤 30초 안에 둘 다 준비하지 않으면 abandoned
POST /api/match/{id}/beat 진행 중 5초마다 보내는 하트비트. 응답 { status }가 곧 매치 상태 폴링이다

채널은 둘 다 private이며 입장은 RLS가 정한다.

  • user:{id} — 본인만 읽는다. 데이터베이스가 matched { queueId, matchId }fallback { queueId, matchId }(대기 만료로 만든 비동기 대전)를 보낸다. 클라이언트는 쓸 수 없다.
  • match:{id} — 그 매치에 앉은 두 사람만, 매치가 pending·active인 동안 읽고 쓴다. Presence 키는 slot-1·slot-2, 게임 메시지는 broadcast msg { d }, 상태 변화는 데이터베이스가 보내는 status { matchId, status, seed?, players[] }다.

6.14 대전(매칭) 적합성 요건

  • 게임의 난수는 readyseed로만 초기화한다 (MUST). 같은 시드는 같은 판이어야 한다. 화면 크기나 주사율이 달라도 난이도가 같도록 판을 구성한다 (SHOULD).
  • ready가 오기 전에는 판을 시작하지 않는다 (MUST). kind로 실시간과 비동기를 나눠 처리한다.
  • 대전 결과에 TAP을 걸지 않는다 (MUST). 결과는 레이팅과 기록에만 반영되며, 적립은 §8의 세션 완주 규칙 그대로다. 점수·승패에 보상을 거는 구조는 게임산업법 경품 규정에 걸린다.
  • report() 전에 같은 점수를 submitScore()로 제출한다 (SHOULD). 비동기 대전은 그래야 verified가 된다.
  • send()는 초당 8건, 4KB 이하로 쓴다 (MUST). 넘는 것은 버려진다.
  • opponent_left를 받으면 판을 마무리하고 report()를 보낸다 (SHOULD).
  • find()가 거절돼도 게임은 혼자 플레이할 수 있어야 한다 (MUST). unavailable은 정상 응답이다.

6.9 재화 판매 적합성 요건

  • 상품은 플랫폼에 사전 등록된다(운영 콘솔 경유). 게임은 SKU만 언급하며, 어떤 메시지에도 금액 필드는 없다 — 가격을 주장할 방법 자체가 규격에 없다.
  • 게임은 "purchased" 또는 "already_owned"에만 재화를 지급해야 한다 (MUST). 자체 장부·로컬 플래그로 지급 MUST NOT.
  • 재시작 시 보유 재화는 getInventory()로 복원해야 한다 (MUST). 로컬 상태는 캐시일 뿐이다.
  • declined·unavailable 뒤에도 게임은 정상 진행되어야 한다 (MUST) — 구매는 선택지지 벽이 아니다.
  • 게임 서버가 있는 경우의 검증 (원스토어·구글플레이의 서버 영수증 검증과 같은 역할): 클라이언트의 purchase_result를 그대로 믿지 말고, 클라이언트가 세션에서 받은 token을 게임 서버로 올려 보내고, 게임 서버가 GET {api}/api/game/inventory를 그 토큰으로 호출해 보유를 확인한 뒤 지급하라 (SHOULD). 토큰은 그 (유저, 게임) 쌍에만 유효하므로 게임 서버가 얻는 권한도 딱 그만큼이다. 전용 S2S 영수증 API는 후속 증분으로 예약되어 있다.

7. 보상형 광고 적합성 요건

7.1 게임 측 (심사 기준)

  • 게임은 광고를 MAY 요청한다. 요청 지점(placement)은 서버 정책이 장르별로 허용한 것만 통과한다 — 현재 games: revive, bonus_points.
  • unavailable·dismissed·failed 모두에서 게임은 진행 가능한 경로를 MUST 유지한다. "광고를 봐야 계속할 수 있다"는 설계는 심사에서 반려된다.
  • 게임은 ad_resultstatus로만 분기해야 하며 reason으로 분기 SHOULD NOT (reason은 진단·표시용이고 값이 추가될 수 있다).
  • 보상 지급은 서버가 원장에 쓴 것만 유효하다. 게임이 자체 화폐를 얹는 것은 자유지만 플랫폼 TAP을 자체 표시로 위조 MUST NOT.

7.2 플랫폼 측 (구현이 보장하는 것)

  • 시청은 항상 플레이어의 명시적 선택 화면을 거친다. 광고 카드에는 "광고" 라벨이 붙는다.
  • 오퍼는 단일 사용(멱등키), 120초 만료, 발급 유저에게 바인딩된다.
  • 빈도 제한: 전역 180초 간격 · 유저당 일 5회 · (유저, 앱)당 일 2회 · 일일 TAP 상한 선검사. 수치는 사업 결정으로 조정될 수 있다(코드 리뷰를 거치는 상수, lib/ads.ts).

8. 플레이 측정과 지급 게이트 (게임 무관여)

  • 호스트(GamePlayer)가 15초 하트비트로 플레이 시간을 측정한다. 서버는 하트비트당 최대 75초만 적립하므로 측정치는 실제 경과 시간을 초과할 수 없다. 탭이 숨겨진 시간은 적립되지 않는다.
  • track("session_complete")의 일일 지급은 그날 호스트 측정 플레이 ≥ 45초일 때만 통과한다. 미달이면 points.status = "ignored" — 지급은 멱등이므로 실제 플레이 후 재전송하면 그때 1번 지급된다.
  • 게임이 이 절에서 할 일은 없다. SDK 계약도 변하지 않는다. 이 절이 존재하는 이유는 "로드 직후 session_complete를 쏘면 왜 ignored인가"라는 질문에 답하기 위해서다.

9. 게스트 (비로그인) 규격

2026-09-17 플랫폼 갱신: 자동 파밍은 SDK 이벤트 없이 호스트 실측 60초 이후 지급한다. 기존 SDK 완료 경로와 같은 일일 지급 키를 공유한다. 계정·게임·UTC 날짜별 15 TAP, 공통 일일 적립 상한 150 TAP이며 이중 지급되지 않는다. 개발자 전달 문서모바일·PC 대응을 참고한다.

  • session.player가 없으면 게스트다. canSave() === false, save()/load()Error("guest")로 거부된다.
  • 게스트의 track()points.status = "signin_required"를 받는다. 이벤트 자체는 측정용으로 기록된다.
  • 게스트의 request_adad_result.status = "unavailable" (reason: "signin_required")를 받는다.
  • T4 재확인: 게임은 위 세 가지 축소를 안내할 수는 있어도, 게스트의 플레이를 막아서는 안 된다.

10. 실행 환경 요건 (규범)

게임은 항상 파티션된 서드파티 iframe 안에서 실행된다. 최상위 탭에서만 성립하는 가정을 두면 안 된다.

  • 스토리지는 실패할 수 있다. localStorage · sessionStorage · document.cookie · IndexedDB 접근은 브라우저 설정(서드파티 데이터 차단)에 따라 예외를 던진다. 모든 접근은 try/catch로 감싸야 하며(MUST), 스토리지가 하나도 없는 상태에서도 최소 플레이 경로는 살아 있어야 한다(MUST).
  • 프레임 여부로 분기하지 않는다. window.top !== window.self를 근거로 기능을 막거나 프레임 탈출(frame busting)을 시도해서는 안 된다(MUST NOT).
  • 부트스트랩은 예외에 강해야 한다. 첫 진입 핸들러(게스트 시작·새 게임 등)가 예외로 죽으면 화면은 그대로인데 버튼만 안 눌리는 상태가 되고, 호스트는 프레임 내부를 볼 수 없어 이를 감지하지 못한다.
  • 심사 기준: 등록 전 프레임 안에서 로그인 없이 첫 판이 시작되는지 확인한다. 실측 사례는 연동 킷의 차트런 선결 이슈를 참고한다.

15. 로그인 (규범)

게임은 자체 로그인 UI를 제공해서는 안 된다 (MUST NOT). 신원은 호스트가 정하고 session 메시지로 전달한다. 근거는 세 가지다: 주요 OAuth 제공자는 프레임 렌더를 거부하고(구글 X-Frame-Options: DENY), 샌드박스·브라우저 정책이 리다이렉트 복귀를 제한하며, 서드파티 쿠키 차단 시 세션이 유지되지 않는다.

  • 로그인 제안이 필요하면 request_signin을 보낸다 (SHOULD). 호스트가 물을지 여부와 방법을 결정한다.
  • 자체 계정 체계가 있는 게임은 player.id를 외부 식별자로 매핑한다 (SHOULD). 게임 서버는 클라이언트가 보낸 player.id를 신뢰해서는 안 되며 (MUST NOT), POST /api/game/verify로 토큰을 검증해야 한다 (MUST).
  • player.verified는 자격 판정용 불리언이다. 이를 근거로 플레이를 막아서는 안 되고 (MUST NOT), 이벤트·보상 자격 등 부가 기능의 조건으로만 쓴다 (MAY).
  • /api/game/verify는 서버 전용이다. CORS 헤더가 없으며 응답에 플랫폼 사용자 ID는 포함되지 않는다.