연동 흐름
전제조건
- API Key 발급: PlayWave 서비스 운영 담당자에게 요청
- Roblox Studio: Game Settings → Security → Allow HTTP Requests 활성화
- 서버 사이드 전용: 모든 API 호출은
ServerScriptService에서 실행
API 서버 주소
| 환경 | URL | 상태 |
|---|---|---|
| Dev | https://wave-api.playwave.dev/v1 | 사용 가능 |
| QA | https://wave-api-qa.playwave.dev/v1 | 준비 중 |
| Live | https://wave-api.playwave.io/v1 | 준비 중 |
인증
모든 Game Server API 요청에는X-Api-Key 헤더가 필요합니다.
X-Api-Key: {발급받은_api_key}
Content-Type: application/json
API Key는 반드시 ServerScriptService의 Script에서만 사용하세요.
ReplicatedStorage, StarterPlayerScripts, LocalScript 등 클라이언트에서 접근 가능한 곳에 절대 배치하지 마세요.OTT 검증 — POST /v1/game/session/verify
플레이어가 게임에 접속하면 가장 먼저 호출합니다. OTT를 검증하고 게임 세션을 생성합니다.OTT 추출
PlayWave 런처는 게임 실행 시LaunchData에 OTT UUID를 직접 전달합니다.
local function extractOtt(player)
local success, joinData = pcall(function()
return player:GetJoinData()
end)
if not success or not joinData then
return nil
end
local launchData = joinData.LaunchData
if not launchData or type(launchData) ~= "string" or #launchData == 0 then
return nil
end
return launchData
end
요청
local HttpService = game:GetService("HttpService")
local API_URL = "https://wave-api.playwave.dev/v1"
local API_KEY = "your-api-key-here"
local function verifySession(player)
local ott = extractOtt(player)
if not ott then
return { is_valid = false, reason = "NO_OTT" }
end
local success, response = pcall(function()
return HttpService:RequestAsync({
Url = API_URL .. "/game/session/verify",
Method = "POST",
Headers = {
["X-Api-Key"] = API_KEY,
["Content-Type"] = "application/json",
},
Body = HttpService:JSONEncode({
ott = ott,
provider_user_id = tostring(player.UserId),
}),
})
end)
if not success then
warn("[Playwave] HTTP request failed:", response)
return { is_valid = false, reason = "NETWORK_ERROR" }
end
local decodeOk, body = pcall(function()
return HttpService:JSONDecode(response.Body)
end)
if not decodeOk or not body.success or not body.data then
return { is_valid = false, reason = "API_ERROR" }
end
return body.data
end
성공 응답
{
"success": true,
"request_id": "req_...",
"data": {
"is_valid": true,
"game_session_id": "gs_550e8400-e29b-41d4-a716-446655440000",
"is_pc_cafe": true
}
}
| 필드 | 타입 | 설명 |
|---|---|---|
is_valid | boolean | 검증 성공 여부 |
game_session_id | string | 게임 세션 ID (하트비트/종료에 사용) |
is_pc_cafe | boolean | PC방 여부 |
실패 응답
{
"success": true,
"request_id": "req_...",
"data": {
"is_valid": false,
"reason": "OTT_EXPIRED"
}
}
비즈니스 로직 실패도 HTTP 200으로 반환됩니다. 반드시
is_valid 값으로 분기하세요.reason 값과 처리
| reason | 설명 | 게임 서버 처리 |
|---|---|---|
OTT_EXPIRED | OTT 만료 (발급 후 1분 초과) | 유저에게 런처로 돌아가서 재실행 안내 |
OTT_ALREADY_USED | OTT 이미 사용됨 | 중복 접속 방지 — 유저 킥 |
GAME_MISMATCH | OTT의 게임과 API Key의 게임 불일치 | 잘못된 OTT — 유저 킥 |
NOT_CHARGEABLE | 과금 불가 (G-coin 부족 등) | 유저에게 G-coin 충전 안내 |
하트비트 — PATCH /v1/game/session/heartbeat
게임 세션이 활성 상태임을 서버에 알립니다. 2분(120초) 간격으로 호출해야 합니다. 하트비트를 보내지 않으면 Redis TTL(4분) 만료로 세션이 자동 삭제됩니다.요청
local function sendHeartbeat(gameSessionId, providerUserId)
local success, response = pcall(function()
return HttpService:RequestAsync({
Url = API_URL .. "/game/session/heartbeat",
Method = "PATCH",
Headers = {
["X-Api-Key"] = API_KEY,
["Content-Type"] = "application/json",
},
Body = HttpService:JSONEncode({
game_session_id = gameSessionId,
provider_user_id = providerUserId,
}),
})
end)
if not success then
warn("[Playwave] Heartbeat request failed:", response)
return nil
end
local decodeOk, body = pcall(function()
return HttpService:JSONDecode(response.Body)
end)
if not decodeOk then
return nil
end
return body
end
응답
{
"success": true,
"request_id": "req_...",
"data": {
"result": "OK",
"next_heartbeat_in": 120,
"play_duration_sec": 1800
}
}
| 필드 | 타입 | 설명 |
|---|---|---|
result | string | 세션 상태 |
next_heartbeat_in | integer | 다음 하트비트까지 권장 대기 시간 (초) |
play_duration_sec | integer | 서버 기준 누적 플레이 시간 (초) |
result 값과 처리
| result | 설명 | 게임 서버 처리 |
|---|---|---|
OK | 정상 | next_heartbeat_in 후 다음 하트비트 전송 |
CHARGE_EXHAUSTED | G-coin 소진 | 유저에게 안내. 서버가 2분 유예 후 자동 종료 |
에러 응답
| HTTP | 코드 | 조건 | 게임 서버 처리 |
|---|---|---|---|
| 404 | SESSION_NOT_FOUND | 세션 없음 (만료/삭제) | 하트비트 중지, 유저 킥 |
| 410 | SESSION_ENDED | 이미 종료된 세션 | 하트비트 중지, 유저 킥 |
| 410 | SESSION_ENDING | 종료 진행 중 | 하트비트 중지, 유저 킥 |
세션 종료 — DELETE /v1/game/session/end
플레이어가 게임을 떠날 때 호출합니다. 과금을 중지하고 세션을 정리합니다.요청
local function endSession(gameSessionId, playDurationSec)
local requestBody = {
game_session_id = gameSessionId,
}
if playDurationSec then
requestBody.play_duration_sec = playDurationSec
end
local success, response = pcall(function()
return HttpService:RequestAsync({
Url = API_URL .. "/game/session/end",
Method = "DELETE",
Headers = {
["X-Api-Key"] = API_KEY,
["Content-Type"] = "application/json",
},
Body = HttpService:JSONEncode(requestBody),
})
end)
if not success then
warn("[Playwave] End session request failed:", response)
return nil
end
local decodeOk, body = pcall(function()
return HttpService:JSONDecode(response.Body)
end)
if not decodeOk then
return nil
end
return body
end
응답
{
"success": true,
"request_id": "req_...",
"data": {
"result": "SUCCESS",
"play_duration_sec": 1800,
"client_play_duration_sec": 1795
}
}
| 필드 | 타입 | 설명 |
|---|---|---|
result | string | 항상 "SUCCESS" |
play_duration_sec | integer | 서버 기준 플레이 시간 (초) — 정산에 사용 |
client_play_duration_sec | integer | null | 요청에서 보낸 값. 미전달 시 null |
에러 응답
| HTTP | 코드 | 조건 | 게임 서버 처리 |
|---|---|---|---|
| 404 | SESSION_NOT_FOUND | 세션 없음 (이미 만료/삭제) | 무시 (이미 정리됨) |
| 409 | ALREADY_ENDED | 이미 종료된 세션 | 무시 (중복 호출) |
전체 구현 예제
ServerScriptService에 아래 Script를 추가합니다.
-- ServerScriptService/PlaywaveIntegration (Script)
local Players = game:GetService("Players")
local HttpService = game:GetService("HttpService")
-----------------------------------------------------------------
-- 설정
-----------------------------------------------------------------
local API_URL = "https://wave-api.playwave.dev/v1"
local API_KEY = "your-api-key-here"
local HEARTBEAT_INTERVAL = 120
-----------------------------------------------------------------
-- 플레이어별 세션 데이터
-----------------------------------------------------------------
local activeSessions = {}
-----------------------------------------------------------------
-- OTT 추출
-----------------------------------------------------------------
local function extractOtt(player)
local success, joinData = pcall(function()
return player:GetJoinData()
end)
if not success or not joinData then
return nil
end
local launchData = joinData.LaunchData
if not launchData or type(launchData) ~= "string" then
return nil
end
return launchData
end
-----------------------------------------------------------------
-- API 호출 헬퍼
-----------------------------------------------------------------
local function apiRequest(method, path, body)
local requestInfo = {
Url = API_URL .. path,
Method = method,
Headers = {
["X-Api-Key"] = API_KEY,
["Content-Type"] = "application/json",
},
}
if body then
requestInfo.Body = HttpService:JSONEncode(body)
end
local success, response = pcall(function()
return HttpService:RequestAsync(requestInfo)
end)
if not success then
warn("[Playwave] HTTP request failed:", response)
return nil, "NETWORK_ERROR"
end
if response.StatusCode >= 400 then
local errorBody = nil
pcall(function()
errorBody = HttpService:JSONDecode(response.Body)
end)
return errorBody, "HTTP_" .. tostring(response.StatusCode)
end
local decodeSuccess, decoded = pcall(function()
return HttpService:JSONDecode(response.Body)
end)
if not decodeSuccess then
warn("[Playwave] JSON decode failed:", decoded)
return nil, "DECODE_ERROR"
end
return decoded, nil
end
-----------------------------------------------------------------
-- OTT 검증 + 게임 세션 생성
-----------------------------------------------------------------
local function verifySession(player)
local ott = extractOtt(player)
if not ott then
return nil
end
local result, err = apiRequest("POST", "/game/session/verify", {
ott = ott,
provider_user_id = tostring(player.UserId),
})
if err then
warn("[Playwave] Verify failed for", player.Name, ":", err)
return nil
end
if result and result.success and result.data then
return result.data
end
return nil
end
-----------------------------------------------------------------
-- 하트비트 루프
-----------------------------------------------------------------
local function startHeartbeatLoop(userId, gameSessionId, providerUserId)
return task.spawn(function()
while activeSessions[userId] do
task.wait(HEARTBEAT_INTERVAL)
if not activeSessions[userId] then
break
end
local result, err = apiRequest("PATCH", "/game/session/heartbeat", {
game_session_id = gameSessionId,
provider_user_id = providerUserId,
})
if err then
if err == "HTTP_404" or err == "HTTP_410" then
warn("[Playwave] Session lost for UserId", userId, ":", err)
activeSessions[userId] = nil
local player = Players:GetPlayerByUserId(userId)
if player then
player:Kick("PlayWave session expired")
end
break
end
warn("[Playwave] Heartbeat error for UserId", userId, ":", err)
continue
end
if result and result.data then
local hbResult = result.data.result
if hbResult == "CHARGE_EXHAUSTED" then
local player = Players:GetPlayerByUserId(userId)
if player then
warn("[Playwave] Charge exhausted for", player.Name)
end
end
end
end
end)
end
-----------------------------------------------------------------
-- 세션 종료
-----------------------------------------------------------------
local function endPlayerSession(userId)
local session = activeSessions[userId]
if not session then
return
end
activeSessions[userId] = nil
if session.heartbeatThread then
task.cancel(session.heartbeatThread)
end
local clientPlayDuration = math.floor(os.clock() - session.startTime)
local result, err = apiRequest("DELETE", "/game/session/end", {
game_session_id = session.gameSessionId,
play_duration_sec = clientPlayDuration,
})
if err then
if err ~= "HTTP_404" and err ~= "HTTP_409" then
warn("[Playwave] End session failed for UserId", userId, ":", err)
end
end
end
-----------------------------------------------------------------
-- 이벤트 연결
-----------------------------------------------------------------
Players.PlayerAdded:Connect(function(player)
local data = verifySession(player)
if not data or not data.is_valid then
if data and data.reason then
warn("[Playwave] Verify failed:", player.Name, data.reason)
end
return
end
local providerUserId = tostring(player.UserId)
activeSessions[player.UserId] = {
gameSessionId = data.game_session_id,
startTime = os.clock(),
heartbeatThread = nil,
}
activeSessions[player.UserId].heartbeatThread = startHeartbeatLoop(
player.UserId,
data.game_session_id,
providerUserId
)
-- PC방 혜택 부여 (게임별 구현)
if data.is_pc_cafe then
grantPcCafeBonus(player)
end
end)
Players.PlayerRemoving:Connect(function(player)
endPlayerSession(player.UserId)
end)
game:BindToClose(function()
for userId, _ in pairs(activeSessions) do
endPlayerSession(userId)
end
end)
-----------------------------------------------------------------
-- 게임별 혜택 로직 (직접 구현)
-----------------------------------------------------------------
function grantPcCafeBonus(player)
-- 예: XP 부스트, 보너스 코인, 전용 아이템 등
print("[Playwave] PC cafe bonus granted to", player.Name)
end
보안 주의사항
API Key 보호
- API Key는 반드시
ServerScriptService의 Script에서만 사용 ReplicatedStorage,StarterPlayerScripts,LocalScript등 클라이언트 접근 가능한 곳에 절대 배치 금지RemoteEvent/RemoteFunction을 통해 API Key를 클라이언트에 전달 금지
HttpService
HttpService:RequestAsync()는 ServerScript에서만 호출 가능 (Roblox 보안 정책)- Game Settings → Security → Allow HTTP Requests를 반드시 활성화
OTT
- OTT는 1회용 — verify 호출 시 즉시 소비, 재사용 불가
- 발급 후 1분(60초) 이내에 사용해야 함
- OTT 형식: UUID (예:
a1b2c3d4-e5f6-7890-abcd-ef1234567890)