Skip to main content
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)
ScriptClassNameRole
ScriptScriptMain server logic, initializes PlaywaveService
PlaywaveServiceModuleScriptAPI communication, session management,
ClientLocalScriptSends client ready signal to server
RedeemUIModuleScriptPC 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

EnvironmentURLStatus
Devhttps://wave-api.playwave.dev/v1Available
QAhttps://wave-api-qa.playwave.dev/v1Coming soon
Livehttps://wave-api.playwave.io/v1Coming 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 (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.