CONTRACT HUB / CURRENT
게임 연결 계약을 한곳에.
게임은 자기 화면과 데이터를 소유하고, KOISCORE는 플레이어 셸·로그인·공용 젬을 담당합니다. 출시할 때 필요한 API와 브릿지 계약을 구현 순서대로 정리했습니다.CONTRACT MAP
상품 등록·원스토어 연결 가이드 · 게임 선택 후 상품 탭 열기
어떤 문서를 봐야 하나요?
KOISCORE에 웹게임을 올리려면 “게임 화면만 배포”하는 것으로 끝나지 않습니다. 플레이어가 실제로 경험하는 화면은 포털·앱인토스 공통 셸 안의 iframe이고, 로그인·젬·광고·커뮤니티·친구 초대는 모두 플랫폼과 약속된 메시지·API로 연결해야 합니다. 아래 카드는 그 연결 지점을 계약 단위로 나눈 지도입니다. 각 카드의 protocol 이름은 코드와 postMessage 검증에 그대로 쓰이므로, 구현 전에 어떤 책임이 게임 쪽인지·플랫폼 쪽인지부터 구분해 두는 것이 좋습니다.
신규 게임은 다섯 계약을 모두 적용하는 것을 권장합니다. 기존 라이브 게임은 사용자에게 보이는 동작을 깨지 않도록 Portal Bridge v1을 유지한 채, Community·Identity·Runtime 계약부터 점진적으로 전환하면 됩니다. “일단 iframe만 넣고 나중에 로그인 붙이기”는 검수 단계에서 반려되는 경우가 많습니다. 특히 identity assertion 없이 서버에 playerId를 신뢰하거나, 포털 origin 검증 없이 postMessage를 받는 패턴은 보안·정산 모두에서 문제가 됩니다.
Rewarded Ad Bridge
게임 요청을 H5·AdMob·Apps in Toss 광고로 연결하고 검증된 결과를 돌려줍니다.
game:rewarded-ad-requestMULTIPLAYERFriend Match Invite
열린 매치를 DB로 검증하고 초대 URL에서 게임 참가 파라미터까지 전달합니다.
game:match-inviteCOMMUNITYLounge & Chat
게임 로비와 매치·파티별 인스턴스 채팅방을 포털 UI로 여는 계약입니다.
game:communityPLATFORMPlayer Session · Progress
등록된 모든 게임이 공유하는 세션·진행 저장 API와 portal:player-update 계약입니다.
koiscore.platform.player-session.v1REQUIREDPortal Bridge v1
iframe 준비, 초기화, 음소거, 게임 설명과 identity 갱신 메시지 계약입니다.
koiscore.portal.v1IDENTITYGame Identity v2
게임별 playerId와 Ed25519 단기 assertion으로 로그인 사용자를 식별합니다.
koiscore.game-identity.v2CANVASRuntime Policy v2
Pixi·WASM·WebGL 게임의 프레임, 캔버스, 호스트와 출시 단계 정책입니다.
koiscore.game-runtime.v2RELEASEIntegration v1
게임 등록, 광고 소유권, 공용 젬과 게임 전용 데이터 경계를 정의합니다.
koiscore.game-integration.v1IMPLEMENTATION ORDER
연동 순서
아래 순서는 “검수에서 자주 막히는 지점”을 기준으로 정렬했습니다. 등록만 해두고 브릿지를 붙이지 않으면 샌드박스에서도 타이틀·음소거·로그인 동기화가 되지 않고, 브릿지만 붙이고 identity 검증을 서버에 넣지 않으면 젬·랭킹·세이브가 다른 사용자와 섞일 수 있습니다. 각 단계가 끝날 때마다 포털 샌드박스에서 실제 iframe 크기·내부 스크롤·로그인 전환을 한 번씩 확인해 두면 출시 직전 재작업을 줄일 수 있습니다.
고정 gameId와 런타임 URL, 장르, 언어, 화면 비율을 draft로 등록합니다.
game:ready 이후 portal:init을 받고 origin·source·gameId와 커뮤니티 요청을 검증합니다.
identity.playerId로 게임 데이터를 분리하고 assertion을 서버에서 검증합니다.
프레임 크기, 무스크롤, 결제 멱등성, 플레이타임 조건을 확인합니다.
PRODUCTS / STORE MAPPING
상품 등록 · 결제 방식 · 원스토어 연결
내부 상품 ID는 지급할 상품을 식별합니다. 같은 상품의 Google Play·ONE store 등 외부 상품 ID는 플랫폼별 연결값으로 저장합니다. 스토어마다 내부 상품을 다시 만들지 않습니다. 연결값 저장과 실제 판매 가능 여부는 별개이며, 앱 등록·영수증 검증·지급 연동까지 확인해야 합니다.
| 상품 | 결제 방식 | 원스토어 등록 | 지급 방식 |
|---|---|---|---|
| 코이젬 충전팩 | 현금 일회성 | 관리형 · 반복 구매 | 구매 영수증별 젬 지급 및 소비 처리 |
| 광고 제거 영구권 | 현금 일회성 · 영구 권한 | 관리형 · 영구성 | 구매 확인(acknowledge), 권한 복원 · 소비하지 않음 |
| 리플레이 슬롯 확장 | 현금 일회성 · 반복 구매 | 관리형 · 소비성 | 구매마다 슬롯 수 누적 · 소비 처리 |
| 젬으로 사는 게임 아이템 | 코이젬 | 현금 인앱상품 등록 대상 아님 | 젬 차감 영수증으로 게임 아이템 지급 |
| 광고 시청 보상 | 광고 완료 | 인앱상품 등록 대상 아님 | 검증된 광고 완료와 게임 보상 규칙 연결 |
| 실제 정기 결제 상품 | 구독 | 구독형 | 결제 주기·만료·해지 상태에 따른 권한 |
원스토어는 관리형 상품을 소비성 또는 영구성으로 구현할 수 있습니다. 코이젬 충전·슬롯 확장·광고 제거를 구독형으로 등록하지 않습니다. 원스토어 상품 유형·구매 확인 공식 문서
1. 원스토어에서 앱과 인앱상품 등록
- 원스토어 개발자센터에서 앱을 선택합니다. 새 앱이면 상품등록을 진행하고 Android 앱의 패키지명, 바이너리, 노출 정보와 정산정보를 준비합니다. 기존 앱이면 그 앱에 인앱상품을 추가합니다.
- 앱의 In-App정보에서 현금 상품마다 인앱상품 ID·상품명·가격·유형을 등록합니다. 코이젬은 판매할 충전팩별로 등록하고, 광고 제거와 슬롯 확장은 별도 상품으로 등록합니다.
- 이미 등록한 상품 ID는 그대로 사용합니다. 신규 예시는
koi_gems_8000,billiards_remove_ads,billiards_replay_slots_6입니다. 이는 제안값이며 실제 원스토어에 등록 완료된 ID가 아닙니다. - 공통정보 → 라이선스 관리에서 라이선스 키와 OAuth 인증 정보를 확인합니다. OAuth Client ID·Client Secret은 서버 인증 설정이며 인앱상품 ID 입력란에 넣지 않습니다.
앱 상품 등록 공식 문서 · 라이선스·SDK 사전 준비
2. KOISCORE에서 같은 상품에 연결값 입력
- 게임 관리에서 ONE store 앱을 등록합니다. 현재 Android IAP 계약의 앱 식별자는 패키지명입니다. 당구는
com.koiscore.billiards이며 실제 업로드 앱의 패키지명과 같아야 합니다. 원스토어의 PID·AID·OAuth Client ID와 구분합니다. - 상품 목록에서 내부 상품을 선택하고 ONE store 열의 연결값을 입력합니다. 플랫폼은
onestore, 환경은 테스트 또는 운영, 앱 식별자는 등록한 패키지명, 상품 ID는 원스토어에 실제 등록한 인앱상품 ID입니다. - 코이젬 충전팩은 IAP 충전팩 등록에서 중앙 젬 상품 ID·충전량·원스토어 상품 ID를 연결합니다. 게임 아이템의 젬 가격은 젬 충전량이 아닙니다.
- 테스트와 운영 연결은 각각 관리합니다. 활성 연결의 ID를 바꿀 때는 사용을 끄고 저장한 뒤 수정합니다. 기존 주문은 구매 당시 연결값으로 검증합니다.
당구 광고 제거의 기존 내부 ID billiards.banner_remove.lifetime.googleplay는 기존 구매 이력 보존을 위해 유지합니다. 이름에 googleplay가 있어도 원스토어 상품 ID는 같은 내부 상품의 ONE store 연결값에 입력합니다. 리플레이 슬롯 확장은 6칸 지급 상품이며, 광고를 보고 리플레이 1개를 저장하는 보상과 다릅니다.
원스토어 인증 정보 저장
개발자센터에서 게임을 선택하고 상품 탭의 ‘원스토어 결제 설정’에 Client ID, Client Secret, 라이선스 공개키를 입력합니다. 운영·테스트 환경과 대한민국·글로벌 설정은 각각 저장됩니다. Secret은 암호화해 저장하며 다시 표시하지 않습니다. 비워 두고 저장하면 기존 값을 유지합니다. ‘저장하고 연결 확인’을 한 번 누르면 입력 내용을 저장한 뒤 서버 OAuth 인증을 검사합니다. 이 확인은, 상품 승인과 실제 구매·지급 완료를 의미하지 않습니다.
상품 아이콘과 지갑 표시
게임의 상품 탭에서 상품 정보 수정을 열고 PNG·JPG·WebP 이미지(5MB 이하)를 업로드하거나 HTTPS 이미지 주소를 저장합니다. 저장한 아이콘은 상품 목록과 해당 게임 앱의 지갑 현금 상품 목록에 함께 표시됩니다. 젬 충전팩은 충전팩에 등록한 아이콘을 사용합니다. 상품의 플랫폼 연결과 결제 준비 상태에 따라 구매 버튼이 활성화되며, 이미지 등록만으로 판매가 시작되지는 않습니다.
3. 앱에서 결제·지급까지 확인
- In-App정보 → 결제 테스트 → 테스트 ID 등록/관리에서 원스토어 계정을 Sandbox로 등록합니다. 테스트 환경을 변경했다면 앱을 완전히 종료한 뒤 다시 실행합니다.
- 원스토어 결제가 연결된 Android 빌드에서 상품명·가격 조회 → 구매 → 서버 영수증 검증 → 지급 → 소비 또는 구매 확인까지 확인합니다. 상품 ID를 등록하는 것만으로 앱에 결제 SDK가 추가되지는 않습니다.
- 코이젬 잔액 증가, 광고 제거 재로그인 후 유지, 슬롯 6칸 증가 및 반복 구매, 동일 영수증 재전송 시 중복 지급 방지를 확인합니다. 취소·실패에는 지급하지 않아야 합니다.
- 검수 요청 전 Android Sandbox 테스트를 완료합니다. 상용테스트는 과금될 수 있으며 테스트 후 취소 여부를 확인합니다.
상태 해석: 미등록은 앱 또는 상품 연결이 없는 상태, 연결됨은 ID가 저장된 상태, 검증 대기는 영수증·지급 연결 확인이 남은 상태입니다. 판매 가능은 해당 플랫폼과 환경에서 전체 구매·지급 흐름을 통과했을 때만 표시해야 합니다.
PORTAL BRIDGE / V1
포털과 게임 iframe
KOISCORE 플랫폼에 웹게임을 정상적으로 연동하려면, 게임 런타임을 HTML5 표준 iframe 안에서 실행하고 포털과 window.postMessage로 상태를 주고받아야 합니다. 포털은 게임 URL을 직접 조작하지 않고, 승인된 HTTPS 진입점을 iframe src로 로드한 뒤 “준비됐는지”를 게임이 먼저 알려주길 기다립니다. 이 순서를 지키지 않으면 locale·음소거·identity가 아직 적용되기 전에 게임이 시작되어, 사용자마다 다른 언어·볼륨·로그인 상태가 보이는 문제가 생깁니다.
게임 쪽에서는 메시지 리스너를 DOM보다 먼저 등록한 다음 game:ready를 부모 포털로 보냅니다. 포털은 origin·iframe window·protocol·gameId를 확인한 뒤 portal:init으로 locale, 셸 설정, 오디오, 게임별 identity를 한 번에 내려줍니다. 이후 실행 중에는 portal:audio로 마스터 음소거가 바뀔 수 있고, 로그인 갱신이 필요하면 game:identity-refresh로 새 assertion을 요청합니다. 아래 표는 자주 쓰이는 메시지 타입만 모은 것이며, 전체 필드 정의는 공개 JSON Schema와 Markdown 원문을 함께 참고하세요.
| 방향 | TYPE | 역할 |
|---|---|---|
| 게임 → 포털 | game:ready | 메시지 수신 준비 완료 |
| 포털 → 게임 | portal:init | locale, shell, audio, identity 전체 동기화 |
| 포털 → 게임 | portal:player-update | 로그인·연결·로그아웃 후 player 컨텍스트만 갱신 (등록 gameId 공통) |
| 포털 → 게임 | portal:audio | 실행 중 마스터 음소거 변경 |
| 게임 → 포털 | game:identity-refresh | 만료 전 새 identity assertion 요청 |
| 게임 → 포털 | game:how-to | 현지화된 게임 방법과 규칙 전달 |
| 게임 → 포털 | game:community | 상시 로비 또는 게임 인스턴스 채팅방 열기 |
| 게임 → 포털 | game:community-state-request | 현재 라운지·풀스크린 상태 조회 |
| 포털 → 게임 | portal:community-state | 라운지·풀스크린 상태 변경 통지 |
| 게임 → 포털 | game:wallet | 교환 후 공용 젬 잔액 갱신 요청 |
| 게임 → 포털 | game:match-invite | 현재 열린 매치의 친구 초대 요청 |
| 포털 → 게임 | portal:match-invite | 공유·복사·취소 결과 |
| 게임 → 포털 | game:rewarded-ad-request | 사용자 동작으로 보상형 광고 요청 |
| 포털 → 게임 | portal:rewarded-ad-result | earned·dismissed·failed 결과 |
표에 있는 타입 이름은 문자 그대로 protocol payload의 type 필드 값입니다. “비슷한 이름으로 보내면 알아듣겠지” 하고 임의 문자열을 쓰면 포털은 조용히 무시합니다. 반대로 포털이 보내는 portal:* 메시지도 게임에서 동일한 protocol(koiscore.portal.v1)과 자신의 gameId가 일치할 때만 처리해야 합니다. 커뮤니티·지갑·친구 초대·보상형 광고는 모두 이 브릿지 위에 얹혀 있으므로, 최소한 ready/init/audio/identity 흐름이 안정적이어야 나머지 기능을 검수에서 통과할 수 있습니다.
아래 예시는 “가장 작은 올바른 구현”입니다. 실제 서비스에서는 applyLocale, setMuted, setIdentity 안에서 UI 문자열 교체, BGM 볼륨, 서버 API 호출용 assertion 저장까지 이어져야 합니다. 특히 identity를 받은 직후 게임 서버에 assertion을 넘겨 JWT 서명을 검증하지 않으면, 클라이언트에서 playerId만 바꿔 치는 공격에 노출됩니다. 예시 코드의 origin 문자열은 운영 환경 기준이며, 로컬 샌드박스에서는 포털이 제공하는 허용 origin 목록을 그대로 사용하세요.
window.addEventListener('message', (event) => {
if (event.source !== window.parent) return;
if (event.origin !== 'https://koiscore.com') return;
const message = event.data;
if (message?.protocol !== 'koiscore.portal.v1') return;
if (message?.gameId !== 'your-game-id') return;
if (message.type === 'portal:init') {
applyLocale(message.locale);
setMuted(message.audio?.muted === true);
setIdentity(message.identity);
}
});
window.parent.postMessage({
protocol: 'koiscore.portal.v1',
type: 'game:ready',
gameId: 'your-game-id'
}, 'https://koiscore.com');위 코드에서 특히 주의할 점은 세 가지입니다. 첫째, event.source !== window.parent 검사로 다른 iframe이나 확장 프로그램이 보낸 메시지를 차단합니다. 둘째, event.origin을 와일드카드(*)로 두지 않고 승인된 포털 origin과만 통신합니다. 셋째, game:ready를 보낼 때도 target origin을 명시합니다. postMessage(data, '*')로 identity나 launch 파라미터를 주고받는 예제는 절대 프로덕션에 넣지 마세요. 브라우저 개발자 도구만 열려 있어도 다른 스크립트가 같은 탭에서 메시지를 가로챌 수 있습니다.
postMessage('*')로 사용자 identity를 보내지 마세요. 승인된 포털 origin, 부모 window, protocol과 gameId를 모두 확인해야 합니다.REWARDED ADS / PORTAL BRIDGE
게임은 한 API만 호출하고 포털이 플랫폼 광고를 선택합니다
보상형 광고를 지원하려면 게임 안에 Google AdMob SDK, Apps in Toss 광고 모듈, 웹 H5 슬롯을 각각 직접 넣을 필요가 없습니다. KOISCORE 포털이 실행 채널(웹·네이티브 앱·앱인토스)을 보고 알맞은 공급자를 고르고, 게임 iframe에는 하나의 JavaScript 클라이언트만 두면 됩니다. portal:init.capabilities.rewardedAds가 true일 때만 요청할 수 있으며, 사용자가 버튼을 눌렀을 때만 호출해야 합니다. 자동 재생·연속 요청·백그라운드 요청은 정책 위반이며 검수에서 거절됩니다.
아래 스니펫은 HTML 페이지에 스크립트를 삽입하는 전형적인 패턴입니다. KoiscorePortalAds.configure로 gameId를 한 번 고정한 뒤, 재시도·추가 목숨·힌트 해금 같은 “게임 세션 기회”에만 requestRewarded를 연결하세요. 젬·현금성 재화·영구 아이템은 광고 완료만으로 지급하지 말고, 서버에서 별도 claim·영수증 검증을 두는 것이 안전합니다.
<script src="https://koiscore.com/portal/koiscore-rewarded-ad-client.js"></script>
<script>
KoiscorePortalAds.configure({ gameId: 'your-game-id' });
async function retryWithAd() {
const result = await KoiscorePortalAds.requestRewarded({
placement: 'retry_once'
});
if (result.status === 'earned' && result.earned) retryGameOnce();
}
</script>광고 요청이 끝나면 포털은 portal:rewarded-ad-result로 상태를 돌려줍니다. 아래 표는 게임 로직에서 분기해야 할 대표적인 status 값입니다. earned일 때만 요청한 보상(재시도 1회, 힌트 1개 등)을 지급하고, dismissed·failed에서는 아무 것도 주지 않아야 합니다. “광고가 안 나왔으니 그냥 기회를 주자”는 UX는 단기적으로는 편해 보여도, 광고주·플랫폼 정산과 맞지 않아 계정 제재로 이어질 수 있습니다.
| 필드 | 값 | 의미 |
|---|---|---|
status | earned | 광고 완료 확인. 요청한 플레이 기회 제공 가능 |
status | dismissed | 사용자가 광고를 닫음. 보상 제공 금지 |
status | failed | 재고 없음·타임아웃·중복 실행. 보상 제공 금지 |
provider | H5·AdMob·Apps in Toss | 포털이 선택한 실제 공급자 |
placement는 소문자 영문·숫자·밑줄·하이픈 3~64자로 지정합니다.- 사용자가 누른 버튼에서만 요청하고 자동 재생이나 반복 요청을 만들지 않습니다.
- 포털은 origin, iframe window, protocol, gameId를 모두 확인하고 requestId별 결과를 캐시합니다.
- 추가 목숨·재시도 같은 게임 세션 기회에는 사용할 수 있지만, 가치가 보존되는 재화는 서버 검증이 필수입니다.
MULTIPLAYER / FRIEND INVITE
친구 링크를 누르면 해당 매치로 바로 연결
멀티플레이 “친구 초대”를 지원하려면 KOISCORE가 매치메이킹을 대신해 주는 것이 아니라, 게임 서버가 만든 매치를 플랫폼 레지스트리에 등록하고 포털이 검증된 초대 URL만 발급하게 해야 합니다. 게임은 자체적으로 https://... 초대 주소를 조립하지 않습니다. 대신 iframe에서 game:match-invite를 보내면 포털이 DB에 열린 매치인지·만료되지 않았는지 확인한 뒤 짧은 추적 URL(/m/{trackingCode})을 만들어 줍니다. 친구가 그 링크로 들어오면 포털이 해당 게임을 열고 portal:init.launch.params.matchId로 참가 정보를 전달합니다.
아래 예시는 서버·클라이언트·종료 처리까지 한 흐름으로 묶은 것입니다. 실제 구현에서는 매치 생성 API, 레지스트리 등록 API, iframe postMessage, 참가 API가 각각 다른 모듈에 있더라도 “등록 → 초대 → launch 수신 → 종료 시 close” 순서는 유지해야 합니다. portal:init은 네트워크 재시도로 여러 번 올 수 있으므로, 같은 matchId로 join을 두 번 호출해도 결과가 같아야 합니다.
// 1. 게임 서버: 매치 생성 직후
await registerMessengerMatch({
gameId: 'example-game',
matchId: match.id,
expiresAt: match.expiresAt
});
// 2. 게임 iframe: 친구 초대 버튼
window.parent.postMessage({
protocol: 'koiscore.portal.v1',
type: 'game:match-invite',
gameId: 'example-game',
requestId: 'invite_01JABCDEF',
matchId: match.id
}, 'https://koiscore.com');
// 3. 초대받은 게임: portal:init 수신
if (message.type === 'portal:init' &&
message.launch?.type === 'match_invite') {
await gameMatchApi.join(message.launch.params.matchId);
}
// 4. 게임 서버: 종료 시
await closeMessengerMatch('example-game', match.id);https://koiscore.com/m/{trackingCode} 형식입니다. URL을 직접 조립하지 말고 게임 iframe의 game:match-invite 요청을 사용하세요.- 포털은 열린 매치만 초대하며 링크 진입 때 다시 검증합니다.
- 초대 launch token은 최대 1시간이며 매치 초대에는 일회용 소비가 적용됩니다.
portal:init은 재전송될 수 있으므로 참가 처리는 멱등이어야 합니다.- 실제 참가 가능 여부와 네트워크 세션은 게임 서버가 최종 결정합니다.
COMMUNITY / MATCH CHAT · 2026-09-18
매치 채팅 연결과 수신 알림
1. 방 생성과 참가자 등록은 서버에서
매칭 서버가 확인한 gameId, roomId와 참가자의 KOISCORE 식별값을 중앙 서비스가 검증한 뒤 방과 참가자를 등록해야 합니다. 브라우저가 보내는 roomId나 참가자 목록만으로 권한을 부여하지 않습니다. 게임 자체 playerId와 KOISCORE profileId/playerId의 매핑도 확인해야 합니다.
아래 active 요청은 이미 등록된 방에 연결하는 요청입니다. 방 생성 API가 아닙니다. 매칭 서버의 인증된 조회 API 또는 서버 간 매치 시작·종료 통지 계약을 KOISCORE 담당자와 연결해야 합니다. 아직 제공되지 않은 공개 등록 API를 추측해서 호출하지 마세요.
2. 게임 → 포털: 패널을 열지 않고 연결
// portalOrigin은 검증된 실제 부모 origin입니다.
// https://koiscore.com 또는 https://dev.koiscore.com
window.parent.postMessage({
protocol: 'koiscore.portal.v1',
type: 'game:community-state',
gameId: 'your-game-id',
requestId: crypto.randomUUID(),
matchState: 'active',
roomId: chatContext.roomId,
roomLabel: '매치 채팅'
}, portalOrigin);- matchState: active(연결), ended(종료), left(이탈). 종료·이탈에도 같은 roomId와 새 requestId를 전달합니다.
- requestId: 영문·숫자·밑줄·하이픈 8~80자. roomId: 같은 문자로 구성된 1~80자, 영문·숫자로 시작. lobby는 인스턴스 방 ID로 사용할 수 없습니다.
- gameId는 현재 등록된 게임 ID와 일치해야 합니다. 앱·앱인토스 지원을 이번 웹 배포만으로 가정하지 않습니다.
- 모바일 패널을 강제로 열지 않습니다. 사용자가 직접 라운지를 열면 선택된 매치 방을 표시합니다.
- game:community-state-request는 기존 라운지·풀스크린 상태 조회입니다. 새 매치 상태 전달인 game:community-state와 구분합니다.
3. 연결 응답
{
protocol: 'koiscore.portal.v1',
type: 'portal:community-state-result',
gameId: 'your-game-id',
requestId: '요청과 동일한 ID',
roomId: '요청한 방 ID',
status: 'connected'
}status는 connected / disconnected / failed / superseded입니다. connected는 방 접근 API 성공이며 방 생성 완료가 아닙니다. failed에는 error가 포함됩니다. invalid_community_state는 요청 형식 오류, room_access_unavailable은 인증·참가 권한 또는 방 접근 문제, chat_unavailable은 통신·서비스 오류입니다. superseded는 더 최신 요청이나 연결 해제로 이전 결과가 폐기된 상태입니다.
4. 포털 → 게임: 점수판 위 새 메시지 알림
{
protocol: 'koiscore.portal.v1',
type: 'portal:chat-message',
gameId: 'your-game-id',
roomId: '현재 방 ID',
message: {
id: 'message-id', displayName: '상대',
body: '안녕하세요', createdAt: '2026-09-18T00:00:00.000Z'
}
}
window.addEventListener('message', event => {
if (event.source !== window.parent || event.origin !== portalOrigin) return;
const data = event.data;
if (data?.protocol !== 'koiscore.portal.v1' || data.gameId !== gameId) return;
if (data.type === 'portal:chat-message' && data.roomId === currentRoomId) {
// 표시 이름·본문은 innerHTML 대신 textContent로 표시합니다.
showScoreboardChatNotice(data.message);
}
});기존 내역과 자기 메시지는 신규 알림에서 제외하고 메시지 ID로 중복을 제거합니다. 약 4초마다 조회하며 일시 실패 시 최대 30초까지 간격을 늘립니다. 연결 권한 상실 등으로 중단되면 portal:chat-status에 gameId, roomId, status: disconnected, error를 전달합니다.
5. 종료와 검증
ended/left는 해당 클라이언트의 수신 연결 정리입니다. 매치 전체의 서버 방 종료·참가 권한 회수는 권위 있는 매칭 서버와 별도로 연결해야 합니다. 게임 전환·계정 변경·프레임 해제·페이지 이탈에서도 수신을 중단합니다.
- 실제 매치의 DB 방과 참가자 행 등록을 확인합니다.
- 두 참가자의 송수신·점수판 알림과 다른 방·비참가자 차단을 확인합니다.
- 모바일 패널 강제 열림, 중복 알림, 종료 후 수신이 없는지 확인합니다.
- 실패 시 비밀값을 제외한 요청 JSON, requestId, roomId, 응답 status/error를 전달합니다.
COMMUNITY / LOUNGE
게임은 방을 요청하고, 포털이 대화를 관리합니다
게임 내 커뮤니티(로비 채팅, 매치방, DM)를 지원하려면 채팅 UI·신고·차단·로그인 게이트를 게임 안에 새로 만들 필요가 없습니다. KOISCORE 포털 라운지가 그 역할을 맡고, 게임 iframe은 “어떤 방을 열지”만 포털에 알려 줍니다. 모든 게임에는 별도 설정 없이 상시 lobby가 붙어 있어, 싱글·퍼즐 게임도 플레이어가 규칙·QA·피드백을 같은 UX로 나눌 수 있습니다. 매치·파티·길드처럼 참가자가 구분되는 모드는 운영 승인 후 room.id에 서버 매치 ID를 넣어 인스턴스방을 열 수 있습니다.
가장 간단한 연동은 공용 헬퍼 window.KoiscorePortalCommunity를 쓰는 방법입니다. 게임 메뉴의 “로비 열기”, “이 매치 채팅”, “쪽지함” 버튼에서 아래 API를 호출하면 포털이 로그인 여부·신고 정책·슬로우모드를 일관되게 적용합니다. Core 채팅 REST API를 게임에서 직접 두르면 계정 연결·차단·DM 권한이 게임마다 달라져 검수와 운영 모두 어려워집니다.
// 게임의 상시 로비 열기
window.KoiscorePortalCommunity?.openLounge();
// 현재 매치 참가자용 인스턴스방 열기
window.KoiscorePortalCommunity?.openLounge({
id: 'match_42',
label: '42번 매치'
});
// DM 메시지함 열기
window.KoiscorePortalCommunity?.openMessages();라운지 상태 확인
const state = window.KoiscorePortalCommunity?.getLoungeState();
console.log(state?.loungeOpen, state?.fullscreen);
// 포털의 최신 상태를 요청합니다.
window.KoiscorePortalCommunity?.requestLoungeState();
window.addEventListener('koiscore:portal-community-state', event => {
console.log(event.detail.loungeOpen, event.detail.fullscreen);
});loungeOpen은 반드시 false입니다. 풀스크린 중 open-lounge 요청은 거절됩니다.로컬 샌드박스 멀티플레이 검수
샌드박스를 두 탭에서 열고 각각 계정 A와 B를 선택하면 서로 다른 로그인 사용자처럼 검수할 수 있습니다. 계정별 profileId, 게임별 playerId, 로비·인스턴스 채팅 작성자는 분리되며 모든 요청은 /api/sandbox/*만 사용합니다.
# 로컬 게임 실행 후 KOISCORE 샌드박스를 자동으로 열기
npm exec --yes --package=https://koiscore.com/tools/koiscore-sandbox-cli.tgz -- \
koiscore-sandbox --port 5173 --game-id your-game직접 상태를 조회하는 경우
// 게임 → 포털
window.parent.postMessage({
protocol: 'koiscore.portal.v1',
type: 'game:community-state-request',
gameId: 'your-game-id',
requestId: 'community_state_01'
}, 'https://koiscore.com');
// 포털 → 게임
{
protocol: 'koiscore.portal.v1',
type: 'portal:community-state',
gameId: 'your-game-id',
requestId: 'community_state_01',
loungeOpen: false,
fullscreen: true
}조회 응답에는 요청의 requestId가 포함됩니다. 사용자 조작으로 상태가 바뀔 때 포털이 먼저 보내는 변경 이벤트에는 requestId가 없을 수 있습니다.
직접 postMessage를 보내는 경우
window.parent.postMessage({
protocol: 'koiscore.portal.v1',
type: 'game:community',
gameId: 'your-game-id',
requestId: crypto.randomUUID(),
action: 'open-lounge',
room: { id: 'match_42', label: '42번 매치' }
}, 'https://koiscore.com');| 필드 | 규칙 |
|---|---|
action | open-lounge 또는 open-messages |
room.id | 선택값. 영문·숫자·_·- 1~80자. 생략하면 lobby |
room.label | 선택값. 사용자에게 보이는 방 이름, 최대 80자 |
requestId | 영문·숫자·_·- 8~80자 고유값 |
| 응답 | 같은 requestId와 action을 가진 portal:community, 처리 여부 accepted |
운영 권장값
- 싱글·퍼즐 게임은 상시 로비만 사용하고 인스턴스방은 끕니다.
- 매치·파티형 게임만 인스턴스방을 켜고 서버의 실제 매치 ID를 불투명 방 키로 사용합니다.
- 슬로우모드 2초, 메시지 보관 30일로 시작하고 신고량에 따라 조정합니다.
- 게임 iframe은 메시지 본문, 다른 사용자의 전역 profileId, 인증 토큰을 받거나 저장하지 않습니다.
PLATFORM / SESSION · PROGRESS
세션·진행 저장은 플랫폼 API
게임별 /api/app/<game-id>/progress를 새로 만들 필요 없습니다. 등록한 gameId만 바꿔서 공통 엔드포인트를 쓰면, 게스트 진행·Google 연결 승계·로그인 전환 알림까지 플랫폼이 처리합니다. 게임은 progress JSON 스키마와 화면 적용만 담당합니다.
| 메서드 | 경로 | 역할 |
|---|---|---|
GET | /api/platform/player/session?gameId=... | 현재 player 컨텍스트 |
GET | /api/platform/player/progress?gameId=... | 등록 gameId 진행 JSON |
PUT | /api/platform/player/progress?gameId=... | version + progress 저장 (409 충돌) |
로그인·연결 후 포털은 portal:player-update를 보냅니다. 게임은 받은 뒤 progress를 다시 GET해야 합니다. 공용 스크립트가 postMessage와 fetch를 묶어 줍니다.
<script src="https://koiscore.com/portal/koiscore-platform-player-client.js"></script>
<script>
KoiscorePlatformPlayer.configure({ gameId: 'your-registered-game-id' });
KoiscorePlatformPlayer.startSessionSync({
onChange: async ({ session, progress }) => {
if (session?.ok) applyPlayerUi(session.player);
if (progress?.ok) applySave(progress.progress, progress.version);
},
});
const { session, progress } = await KoiscorePlatformPlayer.loadState();
</script>- 브라우저:
credentials: 'include'(포털·*.koiscore.com런타임) - 게임 서버:
Authorization: Bearer <game-identity JWT> - 샌드박스:
apiOrigin: 'http://127.0.0.1:9302'+/api/sandbox/platform/player/...
LOGIN IDENTITY / V2
로그인은 공용, 데이터는 게임별
로그인 사용자를 게임 데이터에 연결하려면 Google OAuth를 게임 번들에 넣거나 전역 profileId를 세이브 키로 쓰면 안 됩니다. KOISCORE가 로그인 세션·계정 연결·프로필 사진 선택을 관리하고, 게임에는 게임마다 다른 identity.playerId와 짧게 만료되는 Ed25519 서명 assertion(JWT)만 전달합니다. 같은 사람이라도 게임 A와 게임 B의 playerId는 다르며, 이는 GDPR·데이터 삭제·게임 간 데이터 격리를 위해 의도된 설계입니다. 클라이언트가 보내는 playerId 문자열은 UI 표시용일 뿐 서버 권한 판단에 쓰지 마세요.
portal:init 또는 identity API 응답으로 내려오는 JSON은 대략 아래 형태입니다. authenticated: false인 게스트 세션도 정상 케이스이며, 이때는 게임 서버에 영구 진행도를 묶지 않거나 게스트→로그인 연결 정책을 별도로 두어야 합니다. assertion의 expiresAt은 최대 300초 수준이므로, 만료 임박 시 game:identity-refresh로 새 토큰을 받아 API 호출에 사용하세요.
{
"schemaVersion": "koiscore.game-identity.v2",
"gameId": "your-game-id",
"authenticated": true,
"playerId": "ply_...",
"displayName": "Koi Player",
"avatarUrl": "https://koiscore.com/api/profile/avatar/...",
"locale": "ko",
"provider": "google",
"assertion": {
"format": "jwt",
"token": "header.payload.signature",
"expiresAt": "2026-08-04T00:01:00.000Z"
}
}프로필 사진 선택 규칙
- 사용자가 KOISCORE에 직접 업로드한 프로필 사진을 가장 먼저 사용합니다.
- 업로드 사진이 없으면 현재
provider로 선택된 로그인 플랫폼의 프로필 사진을 사용합니다. - 플랫폼 사진도 없으면 KOISCORE 공통 더미 이미지를 사용합니다.
identity.avatarUrl과 호환용 player.avatarUrl은 게임 iframe에서 바로 표시할 수 있는 HTTP(S) 절대 URL입니다. 게임은 이 URL을 사용자 식별 키로 사용하거나 영구 복제하지 말고 표시용으로만 캐시해야 합니다.
게임 서버 필수 검증
GET /api/platform/identity/jwks의 Ed25519 공개키로 JWT 서명을 검증합니다.iss=https://koiscore.com,aud=gameId,game_id=gameId를 확인합니다.- 검증된
sub만 데이터 키로 사용하고 body의 playerId는 신뢰하지 않습니다. - assertion은 최대 300초만 허용하고 만료되면
game:identity-refresh를 요청합니다.
RUNTIME / V2
게임 프레임과 캔버스 정책
Pixi·Canvas·Unity WebGL 등 어떤 렌더러를 쓰든, KOISCORE 플레이어 iframe 안에서는 “포털이 준 실제 픽셀 크기”가 곧 게임 해상도입니다. 고정 1920×1080 CSS를 강제하거나 내부 document 스크롤을 만들면 모바일 WebView·데스크톱 분할 화면에서 잘리거나 이중 스크롤이 생깁니다. ResizeObserver로 부모 크기 변화(라운지 열림, 풀스크린, 키보드 등)에 맞춰 캔버스를 다시 그리도록 구현해야 검수 뷰포트(390×844, 1366×768 등)를 통과할 수 있습니다. 타이틀 바·공용 젬·광고 슬롯은 호스트 셸 영역이므로 게임 iframe 안에 중복 UI를 그리지 않습니다.
아래 표는 출시 검수에서 반복적으로 확인하는 런타임 항목입니다. “등록만 해두고 나중에 맞추기”보다 샌드박스 단계에서 미리 맞춰 두는 편이 beta→live 전환 비용을 줄입니다. sandbox → shadow → canary → v2-live 단계별 롤백 산출물을 남겨 두면, 문제 발생 시 포털 쪽만 이전 빌드로 되돌릴 수 있습니다.
| 항목 | 필수 정책 |
|---|---|
| Renderer | pixi-canvas, wasm-canvas, unity-webgl 중 등록 |
| Size | iframe 실제 너비·높이를 사용하고 ResizeObserver로 갱신 |
| Mobile | html, body, root 100%, 내부 문서 스크롤 금지 |
| Shell | 타이틀, 광고, 공용 젬 UI는 KOISCORE 호스트가 소유 |
| Launch | URL에 세션 token을 넣지 않고 승인된 호스트 진입만 허용 |
| Migration | sandbox → shadow → canary → v2-live, rollback 산출물 유지 |
INTEGRATION / V1
등록·광고·젬 경계
KOISCORE 연동의 핵심은 “무엇을 게임이 소유하고, 무엇을 플랫폼이 소유하는가”를 명확히 나누는 것입니다. 게임 화면·세이브·점수·게임 전용 재화·인벤토리·매치 상태는 개발사가 책임집니다. 반면 로그인 세션, 게임별 identity 발급, 공용 젬 원장, 결제 영수증, 웹/앱인토스 광고 슬롯, 포털 커뮤니티 정책은 KOISCORE가 책임집니다. 이 경계를 어기면 — 예를 들어 게임 클라이언트가 젬 잔액을 직접 수정하거나, 포털 광고와 별도로 AdSense를 iframe 안에 또 넣거나 — 검수·정산·스토어 심사에서 모두 걸립니다.
개발자 계정은 회사·팀 단위 퍼블리셔로 묶이며, 자신이 등록한 gameId만 콘솔·MCP·릴리즈 API에서 볼 수 있습니다. KOISCORE 내부 운영 계정은 allowlist로 FIRST PARTY로 표시되지만, 연동 계약 자체는 외부 퍼블리셔와 동일합니다. 젬 구매·교환·구독은 반드시 서버 상품 카탈로그와 영수증 API를 거치고, 게임 지급 엔드포인트는 receiptId·requestKey 기준 멱등 처리를 구현해야 합니다.
- 게임 화면, 세이브, 점수, 게임 전용 재화와 인벤토리는 개별 게임이 소유합니다.
- 개발자 계정은 회사·팀 퍼블리셔 단위로 자기 게임만 관리합니다. 승인된 KOISCORE 계정의 게임은 퍼스트파티로 자동 분류됩니다.
- 로그인 세션, 게임별 identity 발급, 공용 젬 원장과 결제 영수증은 KOISCORE가 소유합니다.
- 웹 광고는 포털, Apps in Toss 광고는 미니앱 채널이 담당하며 게임에서 중복 요청하지 않습니다.
- 젬 구매는 서버 상품 카탈로그와 영수증을 사용하고 게임 지급 API는 멱등 처리합니다.
PUBLIC SOURCES
원본 계약 참조
자동화 도구와 CI는 공개 계약 카탈로그에서 현재 Schema URL을 조회할 수 있습니다. 사람이 읽는 브릿지 상세본은 아래 레퍼런스를 사용합니다.