This guide is for manual setup without the PlayWave plugin. For the recommended approach, see the Quickstart guide which uses the plugin.
Project structure
ServerScriptService
Script
PlaywaveService (ModuleScript)
StarterPlayer
StarterPlayerScripts
Client (LocalScript)
RedeemUI (ModuleScript)
| Script | ClassName | Role |
|---|---|---|
| Script | Script | Main server logic, initializes PlaywaveService |
| PlaywaveService | ModuleScript | API communication, session management, |
| Client | LocalScript | Sends client ready signal to server |
| RedeemUI | ModuleScript | PC cafe status display UI optional |
PlaywaveService module
The core module. Handles API communication, OTT verification, heartbeat loop, and session termination.Replace
API_URL and API_KEY with your issued values. API Key must only be used server-side.API Server URLs
| Environment | URL | Status |
|---|---|---|
| Dev | https://wave-api.playwave.dev/v1 | Available |
| QA | https://wave-api-qa.playwave.dev/v1 | Coming soon |
| Live | https://wave-api.playwave.io/v1 | Coming soon |
-- PlaywaveService (ModuleScript)
-- Place under ServerScriptService/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 -- 2 minutes
local activeSessions = {}
local PlaywaveService = {}
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 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 ok, decoded = pcall(function() return HttpService:JSONDecode(response.Body) end)
if not ok then
warn("[Playwave] JSON decode failed")
return nil, "DECODE_ERROR"
end
return decoded, 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" or string.sub(err, 1, 7) == "HTTP_5" 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:", err)
continue
end
if result and result.data and result.data.result == "CHARGE_EXHAUSTED" then
local player = Players:GetPlayerByUserId(userId)
if player then warn("[Playwave] Charge exhausted for", player.Name) 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 _, err = apiRequest("DELETE", "/game/session/end", {
game_session_id = session.gameSessionId,
play_duration_sec = clientPlayDuration,
})
if err and err ~= "HTTP_404" and err ~= "HTTP_409" then
warn("[Playwave] End session failed for UserId", userId, ":", err)
end
end
function PlaywaveService.init(clientReadyEvent, onPcCafe)
clientReadyEvent.OnServerEvent:Connect(function(player)
local joinData = player:GetJoinData()
local ott = joinData.LaunchData
if not ott or ott == "" then
return
end
local result, err = apiRequest("POST", "/game/session/verify", {
ott = ott,
provider_user_id = tostring(player.UserId),
})
if err then
warn("[Playwave] Verify error:", err)
return
end
if result and result.success and result.data and result.data.is_valid then
local data = result.data
activeSessions[player.UserId] = {
gameSessionId = data.game_session_id,
startTime = os.clock(),
heartbeatThread = nil,
}
activeSessions[player.UserId].heartbeatThread = startHeartbeatLoop(
player.UserId,
data.game_session_id,
tostring(player.UserId)
)
if data.is_pc_cafe and onPcCafe then
onPcCafe(player)
end
else
if result and result.data then
warn("[Playwave] Verify failed:", player.Name, result.data.reason)
end
end
end)
Players.PlayerRemoving:Connect(function(player)
endPlayerSession(player.UserId)
end)
game:BindToClose(function()
for userId in pairs(activeSessions) do
endPlayerSession(userId)
end
end)
end
return PlaywaveService
Server script (Script)
Initializes PlaywaveService and connects it to your game logic.-- Script (ServerScriptService)
local PlaywaveService = require(script.PlaywaveService)
local ReplicatedStorage = game:GetService("ReplicatedStorage")
-- Create RemoteEvents
local launchDataEvent = Instance.new("RemoteEvent")
launchDataEvent.Name = "LaunchDataEvent"
launchDataEvent.Parent = ReplicatedStorage
local clientReadyEvent = Instance.new("RemoteEvent")
clientReadyEvent.Name = "ClientReadyEvent"
clientReadyEvent.Parent = ReplicatedStorage
local respawnEvent = Instance.new("RemoteEvent")
respawnEvent.Name = "RespawnEvent"
respawnEvent.Parent = ReplicatedStorage
respawnEvent.OnServerEvent:Connect(function(player)
player:LoadCharacterAsync()
end)
-- Initialize PlayWave
PlaywaveService.init(clientReadyEvent, function(player)
launchDataEvent:FireClient(player, "PC_CAFE")
end)
Client script (LocalScript)
-- Client (StarterPlayerScripts/LocalScript)
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local RedeemUI = require(script.RedeemUI)
local redeem = RedeemUI.new()
redeem:start()
-- Send ready signal to server
local clientReadyEvent = ReplicatedStorage:WaitForChild("ClientReadyEvent")
clientReadyEvent:FireServer()
RedeemUI module (optional)
A UI module that displays PC cafe authentication status in the top-right corner of the screen.RedeemUI full code
RedeemUI full code
-- RedeemUI (ModuleScript)
-- Place under StarterPlayerScripts/Client
local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local TweenService = game:GetService("TweenService")
local RedeemUI = {}
RedeemUI.__index = RedeemUI
function RedeemUI.new()
local self = setmetatable({}, RedeemUI)
self._player = Players.LocalPlayer
return self
end
function RedeemUI:_build()
local playerGui = self._player.PlayerGui
local screenGui = Instance.new("ScreenGui")
screenGui.Name = "RedeemUI"
screenGui.ResetOnSpawn = false
screenGui.IgnoreGuiInset = true
screenGui.Parent = playerGui
local frame = Instance.new("Frame")
frame.Size = UDim2.fromOffset(220, 80)
frame.Position = UDim2.new(1, 20, 0, 16)
frame.AnchorPoint = Vector2.new(1, 0)
frame.BackgroundColor3 = Color3.fromRGB(15, 10, 30)
frame.BackgroundTransparency = 0.5
frame.BorderSizePixel = 0
frame.Parent = screenGui
local corner = Instance.new("UICorner")
corner.CornerRadius = UDim.new(0, 12)
corner.Parent = frame
local label = Instance.new("TextLabel")
label.Size = UDim2.new(1, -16, 0.45, 0)
label.Position = UDim2.fromOffset(10, 8)
label.BackgroundTransparency = 1
label.Text = "REDEEM CODE"
label.TextColor3 = Color3.fromRGB(255, 255, 255)
label.TextXAlignment = Enum.TextXAlignment.Left
label.Font = Enum.Font.GothamBold
label.TextSize = 13
label.Parent = frame
local codeLabel = Instance.new("TextLabel")
codeLabel.Name = "CodeLabel"
codeLabel.Size = UDim2.new(1, -16, 0.3, 0)
codeLabel.Position = UDim2.new(0, 10, 0.45, 0)
codeLabel.BackgroundTransparency = 1
codeLabel.Text = "INACTIVE"
codeLabel.TextColor3 = Color3.fromRGB(255, 255, 255)
codeLabel.TextXAlignment = Enum.TextXAlignment.Left
codeLabel.Font = Enum.Font.GothamBold
codeLabel.TextSize = 16
codeLabel.Parent = frame
local userLabel = Instance.new("TextLabel")
userLabel.Size = UDim2.new(1, -16, 0.25, 0)
userLabel.Position = UDim2.new(0, 10, 0.72, 0)
userLabel.BackgroundTransparency = 1
userLabel.Text = "ID: " .. tostring(self._player.UserId)
userLabel.TextColor3 = Color3.fromRGB(255, 255, 255)
userLabel.TextXAlignment = Enum.TextXAlignment.Left
userLabel.Font = Enum.Font.Gotham
userLabel.TextSize = 13
userLabel.Parent = frame
TweenService:Create(frame, TweenInfo.new(0.4, Enum.EasingStyle.Back, Enum.EasingDirection.Out), {
Position = UDim2.new(1, -16, 0, 16),
}):Play()
return frame, label, codeLabel
end
function RedeemUI:start()
local frame, label, codeLabel = self:_build()
local event = ReplicatedStorage:WaitForChild("LaunchDataEvent", 30)
if not event then return end
event.OnClientEvent:Connect(function(code)
label.TextColor3 = Color3.fromRGB(0, 255, 200)
codeLabel.Text = code
codeLabel.TextColor3 = Color3.fromRGB(255, 255, 255)
end)
end
return RedeemUI
How it works
1
Client ready
The LocalScript sends a
ClientReadyEvent:FireServer() signal to the server.2
OTT verification
PlaywaveService extracts the OTT from
GetJoinData().LaunchData and sends a verification request.3
Session activation
On successful verification, the game session is stored and a 2-minute heartbeat loop starts.
4
Benefit grant
The
onPcCafe callback is invoked, executing your game-specific benefit logic.5
Session end
On
PlayerRemoving or BindToClose, the session end API is called.