TokenApps 게임 SDK 가이드
2026-09-18 추가: SDK 설치와 게임 전용 임베드는 별도 납품 항목입니다. 게임 전용 임베드 가이드에 따라 구현·배포한 주소를 등록하세요. SDK를 넣은 홈페이지도 플레이어 안에서는 스크롤이 생길 수 있습니다.
2026-09-17 갱신: 개발자 전달 문서 · 모바일·PC 대응. 자동 파밍은 호스트 실측 60초, 기존 SDK 완료 이벤트는 45초 기준이며 같은 일일 보상을 공유합니다. 계정·게임·UTC 하루 1회 15 TAP, 공통 일일 적립 상한은 150 TAP입니다.
게임 팀에 파일 하나만 전달하실 거라면 SDK 개발자 문서 를 쓰십시오. 5분 연동부터 리더보드·광고·등록 체크리스트까지 한 장에 있습니다. 이 가이드는 기능별 상세 설명을 남겨둔 문서입니다.
TokenApps 안에서 도는 게임(샌드박스 iframe 임베드)이 플랫폼과 대화하는 유일한 통로의 안내서입니다. 게임 개발자에게 그대로 전달할 수 있게 썼습니다. 규범 문서는 SDK 규격서 — 이 가이드와 다르면 규격서가 우선합니다. 구성 요소는 세 가지입니다:
- SDK:
https://tokenapps.io/sdk/v1.js(게임이 포함하는 파일) - 호스트: TokenApps 플레이어 페이지 (플랫폼 쪽 브리지)
- REST:
/api/game/*,/api/apps/*(세이브·인벤토리·상품)
0. 신뢰 모델
게임은 설치 없이 iframe에서 실행되는 신뢰할 수 없는 웹 페이지입니다. 게임은 요청만 하고 판정은 서버가 한다는 원칙이 여기서 나오고, 규격 전체가 이 전제에서 출발합니다:
| 원칙 | 의미 |
|---|---|
| 결과는 게임이 정하지 않습니다 | track()은 이벤트 이름만 보냅니다. TAP(토큰앱스 포인트) 지급 여부·액수·상한은 전부 서버가 결정합니다 |
| 플랫폼 계정 정보는 게임에 전달되지 않습니다 | 게임이 받는 player.id는 이 게임 전용 가명입니다. 같은 유저라도 게임마다 다른 id를 받아 게임끼리 유저를 대조할 수 없습니다 |
| 토큰은 한 (유저, 게임) 쌍에서만 유효합니다 | 토큰이 유출되더라도 그 게임의 해당 유저 세이브 슬롯 밖에서는 아무것도 할 수 없습니다 |
| 게스트는 항상 존재합니다 | 로그인 없이 접속한 유저, 데모 모드, 토큰 발급 실패 — 전부 게스트입니다. 로그인하지 않아도 게임은 모두 즐길 수 있어야 합니다 (세이브만 비활성화됩니다) |
1. 시작하기
게임 HTML에 SDK를 포함하고, 준비되면 ready()를 불러주세요:
<script src="https://tokenapps.io/sdk/v1.js"></script>
<script>
TokenApps.on("session", function (s) {
// 로그인 상태가 바뀌거나 토큰이 갱신될 때마다 다시 온다
if (s.player) {
console.log("플레이어:", s.player.id, s.player.displayName);
TokenApps.load().then(function (save) {
startGame(save); // null이면 첫 플레이
});
} else {
startGame(null); // 게스트 — 세이브 없이 실행
}
});
TokenApps.ready();
</script>
2. 로그인 (identity) 규격
ready를 보내면 호스트가 session 메시지로 답합니다:
{
"signedIn": true,
"locale": "ko",
// 로그인 상태일 때만 존재:
"player": {
"id": "vN3kQ8rT1uY5wZ9aB2cD4e", // 이 게임 전용 가명 id (게임마다 다름, 영구 고정)
"handle": "publsih", // 없으면 null
"displayName": "퍼블리시" // 없으면 null
},
"token": "…", // /api/game/* 호출용 Bearer 토큰
"expiresIn": 43200, // 초 (12시간)
"api": "https://tokenapps.io" // API 오리진
}
player.id에 로컬 상태·리더보드를 키잉해도 안전합니다 — 같은 게임에서는 영원히 같습니다.player/token이 없으면 게스트입니다.TokenApps.getUser()가 null을 돌려줍니다.- 토큰이 만료되면 SDK가 자동으로 재핸드셰이크를 걸고, 새
session메시지가 옵니다.
3. 데이터 저장 — save / load
플레이어 계정에 게임당 세이브 슬롯 1개가 있습니다. 기기를 바꿔도 따라옵니다. 외부 프레임은 자체 출처를 유지하지만 브라우저 설정으로 localStorage가 차단될 수 있습니다. 접근을 예외 처리하고 기기 간 저장에는 SDK 세이브를 사용하세요.
// 저장 — 아무 JSON 값이나, 직렬화 기준 64KB 이하
TokenApps.save({ level: 7, coins: 120, inventory: ["sword"] })
.then(function (r) { console.log("저장됨", r.updatedAt); })
.catch(function (e) {
if (e.message === "guest") { /* 로그인 안 됨 — 저장 불가 안내 */ }
if (e.message === "expired") { /* 새 session 메시지를 기다렸다가 재시도 */ }
});
// 불러오기 — 저장한 적 없으면 null
TokenApps.load().then(function (save) { … });
// 지금 저장 가능한가
TokenApps.canSave(); // boolean
4. TAP 이벤트
TokenApps.track("session_complete");
TokenApps.on("points", function (p) {
// { event, status: "awarded"|"duplicate"|"capped"|"ignored"|"signin_required",
// delta?, balance? }
});
- 지급 이벤트 화이트리스트·단가·1일 상한은 서버에 있습니다. 목록에 없는 이벤트는
ignored로 조용히 무시됩니다 — 보내는 건 자유, 지급은 서버 판단입니다. - 점수는
TokenApps.submitScore(n)— 표시용이며 절대 TAP으로 이어지지 않습니다 (게임산업법 경품 규정 — 배경은 심사 정책 §4). - 플레이 시간은 호스트가 측정합니다 — 게임은 아무것도 할 필요 없습니다. 플레이어 페이지가
15초 간격 하트비트로 실제 플레이 시간을 서버에 쌓고,
session_complete의 일일 지급은 그날 측정 플레이가 45초 이상일 때만 통과합니다. 로드 직후session_complete를 쏘면ignored가 옵니다 — 지급은 멱등이라 실제 플레이 후 다시 보내면 그때 1번 지급됩니다. 세션이 자연스럽게 끝나는 시점(라운드 종료 등)에 보내면 아무 문제 없습니다.
4.5 보상형 광고
선택 기능입니다. 게임이 원하는 지점에서 광고를 요청할 수 있습니다. 보여줄지, 얼마를 줄지는 플랫폼이 정하고, 시청은 항상 플레이어의 명시적 선택 화면을 거칩니다.
// placement는 서버가 장르별로 허용한 것만 통과한다. 게임 장르: "revive", "bonus_points"
TokenApps.requestRewardedAd("revive").then(function (r) {
switch (r.status) {
case "rewarded": revivePlayer(); break; // r.reward P가 서버에서 지급됨
case "dismissed": // 플레이어가 안 보기로 함
case "unavailable": // 지금은 광고 없음 (정상!)
case "failed": offerNonAdPath(); break; // 셋 다 비광고 경로로
}
});
철칙 — unavailable은 오류가 아니라 정상 응답입니다. 게스트, 빈도 상한, 일일
TAP 상한, Basic 단계 앱, 허용 안 된 placement 전부 unavailable로 옵니다. 게임은
광고 없이도 진행되는 경로를 항상 유지해야 합니다 — "광고를 봐야 계속된다"는 설계는
심사에서 반려됩니다. 자세한 사유 코드·빈도 규칙은 SDK_SPEC.md §7을
참조하세요.
4.6 이어하기 시트
선택 기능입니다. 게임오버에서 이어하기를 제공하려면 광고를 직접 호출하는 것보다 이 시트를 권장합니다. 플랫폼이 TAP으로 이어하기 / 광고 보고 이어하기 / 그만하기 3택 시트를 띄우고 결과만 알려줍니다. 가격 책정·차감·광고 처리는 전부 플랫폼이 담당합니다.
TokenApps.requestContinue().then(function (r) {
if (r.status === "paid" || r.status === "rewarded") revivePlayer();
else showNormalGameOver(); // declined / unavailable
});
레퍼런스 구현: Tower Drift의 "이어하기" 버튼. "다시 하기"는 시트 결과와 무관하게 항상 남아 있어야 합니다.
4.7 게임 내 재화 판매
선택 기능입니다. 아이템·테마·부스트를 TAP으로 판매할 수 있습니다. 규칙은 앱스토어와 같습니다: 상품과 가격은 플랫폼에 등록하고(내 게임의 상품 관리에서 직접 등록합니다), 게임은 SKU로만 말합니다. 확인 시트·차감·기록은 전부 플랫폼이 처리합니다.
// 상품 목록 (게스트도 조회 가능 — 상점 진열은 자유)
TokenApps.getProducts().then(function (items) { renderShop(items); });
// 구매 요청 → 플랫폼 확인 시트 → 결과
TokenApps.requestPurchase("golden_tower").then(function (r) {
if (r.status === "purchased" || r.status === "already_owned") unlockGold();
// declined / unavailable → 아무 일 없음. 게임은 그대로 진행.
});
// 재시작할 때 보유 복원 — 로컬 저장이 아니라 이걸 믿는다
TokenApps.getInventory().then(function (items) {
if (items.some(function (i) { return i.sku === "golden_tower"; })) unlockGold();
});
철칙 둘: ① 지급은 purchased/already_owned 응답에만 — 게임 자체 판단으로
지급 금지. ② 복원은 getInventory()로 — 플랫폼 기록이 정본입니다. 레퍼런스:
Tower Drift의 "🏆 골든 테마" 버튼.
4.8 대전 (매칭)
v1.4부터 게임은 TokenApps.match 하나로 대전을 붙일 수 있습니다. 대전은 두 가지입니다.
- 실시간 대전(
kind: "sync")은 지금 같은 게임을 하고 있는 다른 플레이어와 붙습니다. 두 사람은 같은 시드로 같은 판을 하고,send()로 보낸 게임 메시지(진행 상황 같은 것)가 상대에게 그대로 전달됩니다. - 비동기 대전(
kind: "async")은 같은 게임에서 레이팅이 가장 가까운 다른 플레이어의 최고 기록과 겨룹니다. 상대가 접속해 있을 필요가 없습니다.
기본값인 kind: "auto"는 실시간 상대를 먼저 찾고, 45초 안에 아무도 오지 않으면 비동기 대전으로
바꿔 줍니다. 그래서 플레이어는 어떤 경우에도 빈손으로 돌아가지 않습니다. 게임은 ready
이벤트에서 kind만 보고 두 경우를 나눠 처리하면 됩니다.
const match = await TokenApps.match.find({ mode: "duel", kind: "auto", timeout: 45 });
// 게스트면 Error("signin_required"), 다른 find()가 대기 중이면 Error("busy")
match.on("searching", ({ elapsed }) => ui.showWaiting(elapsed)); // 대기 중 1초마다
match.on("ready", ({ kind, seed, opponent }) => {
rng.seed(seed); // 같은 시드 = 같은 판
if (kind === "async" && opponent) ui.showTarget(opponent.handle, opponent.score);
if (kind === "sync") ui.showOpponent(opponent.handle);
startRound();
});
match.on("message", (m) => { if (m.t === "p") ui.opponentProgress(m.v); }); // 실시간만
match.on("opponent_left", () => finishRound()); // 상대가 나갔거나 20초 넘게 끊김
function onProgress(v) {
if (match.kind === "sync") match.send({ t: "p", v }); // 초당 8건, 4KB까지
}
async function finishRound() {
TokenApps.submitScore(myScore); // 먼저 리더보드에 (검증 근거)
TokenApps.track("session_complete"); // 적립은 평소 규칙 그대로
const r = await match.report({ score: myScore }); // 판정은 서버
ui.showResult(r.outcome, r.ratingDelta); // "win" | "lose" | "draw" | null(판정 보류)
}
실시간 대전의 report()는 상대도 결과를 보고한 뒤에 풀립니다. 서버는 두 보고를 대조해 서로
맞을 때만 결과를 확정하고, 엇갈리면 disputed로 닫아 레이팅을 움직이지 않습니다. 판 도중에
나간 쪽(leave(), 또는 연결이 20초 넘게 끊김)은 패배로 기록됩니다. 연결과 토큰, 채널은
플랫폼이 게임 밖에서 관리하므로 게임이 할 일은 위의 호출뿐입니다.
지켜야 할 것은 다섯 가지입니다. 난수는 ready의 seed로만 초기화합니다. ready가 오기 전에는
판을 시작하지 않습니다. 대전 결과에 TAP을 걸지 않습니다(결과는 레이팅에만 반영되고 적립은 세션
완주 규칙 그대로입니다). send()는 초당 8건, 4KB 이하로 씁니다. find()가 거절돼도 혼자
플레이할 수 있게 둡니다. 참고 구현은 Tower Drift의 "⚔️ 대전" 버튼입니다.
규격: 규격서 §6.10-6.14.
5. postMessage·REST 직접 연동
Unity·Godot WebGL처럼 SDK 파일 포함이 번거로운 엔진은 postMessage 규격만 직접
구현해도 됩니다.
모든 메시지는 { __tokenapps: 1, v: 1, type, payload } 형식(envelope)을 따릅니다.
| 방향 | type | payload |
|---|---|---|
| 게임 → 호스트 | ready |
{} — session 요청 (재요청 = 토큰 갱신 요청) |
| 게임 → 호스트 | track |
{ event: string } |
| 게임 → 호스트 | score |
{ score: number } |
| 게임 → 호스트 | open_purchase |
{} |
| 게임 → 호스트 | request_ad |
{ placement: string } |
| 게임 → 호스트 | request_continue |
{} |
| 게임 → 호스트 | get_products |
{} |
| 게임 → 호스트 | request_purchase |
{ sku: string } |
| 호스트 → 게임 | session |
§2 참조 |
| 호스트 → 게임 | points |
§4 참조 |
| 호스트 → 게임 | score_ack |
{} |
| 호스트 → 게임 | ad_result |
{ placement, status, reward?, balance?, reason? } — §4.5 참조 |
| 호스트 → 게임 | continue_result |
{ status, cost?, balance?, reason? } — §4.6 참조 |
| 호스트 → 게임 | products |
{ items: […] } — §4.7 참조 |
| 호스트 → 게임 | purchase_result |
{ status, sku?, balance?, reason? } — §4.7 참조 |
세이브 API는 세션에서 받은 token으로 직접 호출합니다 (CORS 전면 허용 — 쿠키 없이
Bearer만 쓰므로 안전):
GET {api}/api/game/save
Authorization: Bearer {token}
→ 200 { "data": <저장값 | null>, "updatedAt": "…" }
PUT {api}/api/game/save
Authorization: Bearer {token}
Content-Type: application/json
body: { "data": <아무 JSON> } ← 반드시 data 봉투로 감싼다
→ 200 { "ok": true, "updatedAt": "…" }
→ 401 invalid_token (만료 — ready 재송신 후 재시도)
→ 413 too_large (64KB 초과)
data 필드로 감싸는 구조는 의도된 확장 여지입니다 — 슬롯·버전 필드가 나중에 추가되어도 기존 게임이
깨지지 않습니다.
6. 플랫폼 내부 동작
이 절은 참고용입니다 — 게임 개발에는 필요하지 않지만, 보안 검토 시 유용합니다.
- 토큰 발급은
/api/game/token(쿠키 인증, CORS 없음)이라 게임은 토큰을 만들 수 없고 받기만 합니다. 페이로드는{aud:"game", uid, app, exp}HMAC 서명 — 세션 쿠키(did필드)와 형태가 달라 서로 바꿔치기할 수 없습니다. player.id= HMAC(secret,player:{uid}:{appId}) 앞 22자. 유저·게임 쌍마다 고정, 게임 간 연결 불가, 역산 불가.- 세이브는
game_saves (user_id, app_id) PK단일 슬롯, 64KB 체크 제약, RLS 잠금 (service role 전용). - 데모 모드에서는 토큰 발급이 503 → 게임은 게스트로 동작합니다.