> ## Documentation Index
> Fetch the complete documentation index at: https://playwave.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# Manual setup

> Set up PlayWave manually without the plugin

<Note>
  This guide is for manual setup without the PlayWave plugin. For the recommended approach, see the [Quickstart](/roblox/quickstart) guide which uses the plugin.
</Note>

## Project structure

<Tree>
  <Tree.Folder name="ServerScriptService" defaultOpen>
    <Tree.Folder name="Script" defaultOpen>
      <Tree.File name="PlaywaveService (ModuleScript)" />
    </Tree.Folder>
  </Tree.Folder>

  <Tree.Folder name="StarterPlayer" defaultOpen>
    <Tree.Folder name="StarterPlayerScripts" defaultOpen>
      <Tree.Folder name="Client (LocalScript)" defaultOpen>
        <Tree.File name="RedeemUI (ModuleScript)" />
      </Tree.Folder>
    </Tree.Folder>
  </Tree.Folder>
</Tree>

| Script              | ClassName    | Role                                                                                                                                          |
| ------------------- | ------------ | --------------------------------------------------------------------------------------------------------------------------------------------- |
| **Script**          | Script       | Main server logic, initializes PlaywaveService                                                                                                |
| **PlaywaveService** | ModuleScript | API communication, session management, <Tooltip tip="Periodic signal sent every 2 minutes to keep the game session alive">heartbeat</Tooltip> |
| **Client**          | LocalScript  | Sends client ready signal to server                                                                                                           |
| **RedeemUI**        | ModuleScript | PC cafe status display UI <Badge color="gray" size="sm">optional</Badge>                                                                      |

## PlaywaveService module

The core module. Handles API communication, OTT verification, heartbeat loop, and session termination.

<Warning>
  Replace `API_URL` and `API_KEY` with your issued values. API Key must only be used server-side.
</Warning>

### API Server URLs

| Environment | URL                                   | Status                                    |
| ----------- | ------------------------------------- | ----------------------------------------- |
| **Dev**     | `https://wave-api.playwave.dev/v1`    | <Badge color="green">Available</Badge>    |
| **QA**      | `https://wave-api-qa.playwave.dev/v1` | <Badge color="yellow">Coming soon</Badge> |
| **Live**    | `https://wave-api.playwave.io/v1`     | <Badge color="yellow">Coming soon</Badge> |

```lua theme={null}
-- 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.

```lua theme={null}
-- 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)

```lua theme={null}
-- 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.

<Accordion title="RedeemUI full code">
  ```lua theme={null}
  -- 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
  ```
</Accordion>

## How it works

<Steps>
  <Step title="Client ready">
    The LocalScript sends a `ClientReadyEvent:FireServer()` signal to the server.
  </Step>

  <Step title="OTT verification">
    PlaywaveService extracts the OTT from `GetJoinData().LaunchData` and sends a verification request.
  </Step>

  <Step title="Session activation">
    On successful verification, the game session is stored and a 2-minute heartbeat loop starts.
  </Step>

  <Step title="Benefit grant">
    The `onPcCafe` callback is invoked, executing your game-specific benefit logic.
  </Step>

  <Step title="Session end">
    On `PlayerRemoving` or `BindToClose`, the session end API is called.
  </Step>
</Steps>
