OKONAONLINE
Developer Docs
Developer Portal →OkonaPad
The Unity SDK for phone-controlled multiplayer games on Okona. Import it and every player's phone shows up as a standard Unity Gamepad — your existing Input System bindings just work. No setup, no custom device, no networking code.
Introduction
OkonaPad is the Unity SDK for interactive games published to Okona — the platform that hosts your Unity WebGL build at a URL any browser or webview can display, and turns players' phones into controllers via an on-screen QR code. The SDK's whole job is to make those controllers — phones, plus USB/Bluetooth pads and the keyboard in a desktop browser — appear inside your game as ordinary Unity Gamepad devices.
OkonaPadDevice derives from UnityEngine.InputSystem.Gamepad, so Gamepad.all, buttonSouth, leftStick, rightTrigger, the <Gamepad>/… binding paths, and PlayerInputManager all work with zero Okona-specific code. Build your game against the standard Input System and it runs on Okona.Key Features
- Controllers arrive as standard Unity
Gamepaddevices - Up to 6 players, each in their own slot
- Full controller surface: face buttons, bumpers, both sticks (incl. L3/R3), D-pad, triggers
- Zero-allocation input via WebAssembly memory (no GC hitches)
- Standard rumble through
Gamepad.SetMotorSpeeds() - Self-bootstrapping — no GameObject, no component, no scene wiring
- Works in the Editor with any pad; live with players' phones
What changed in v2
SDK v2 is a clean break from v1. If you integrated against v1, re-import the package and re-bind to the standard Gamepad paths — it's simpler now.
| Area | v1 (retired) | v2 |
|---|---|---|
| Device type | Custom OkonaPadControllerDevice (not a Gamepad) | OkonaPadDevice : Gamepad |
| Binding paths | <OkonaPad>/buttonA | <Gamepad>/buttonSouth |
| Transport | SendMessage + JSON + Base64 per frame | Binary snapshot read from HEAPU8 (zero alloc) |
| Setup | Add an OkonaInputBridge GameObject named exactly | None — the bridge self-bootstraps |
| Controls | A/B/X/Y, Start/Select, D-pad, left stick | Everything a Gamepad has, both sticks, analog triggers |
| Rumble | Custom static event | Standard Gamepad.SetMotorSpeeds() |
<Gamepad>/ bindings for Editor testing, but on Okona every controller arrives as an OkonaPadDevice — which is a Gamepad, so your bindings are unaffected.Requirements
Unity
- Unity 6 (6000.3.x) or newer
- Input System package (
com.unity.inputsystem) 1.17+ - Build target: WebGL
Project Settings
- Install the Input System package via Package Manager.
Edit > Project Settings > Player > Other Settings→ set Active Input Handling to Input System (New) (or Both).- That's it. The SDK registers its layout and starts pumping input automatically at load — there is nothing to add to a scene.
Quick Start
Import the SDK
Download OkonaPad.unitypackage and import it (Assets > Import Package > Custom Package…). It drops a single Assets/OkonaPad/ folder into your project. There is no component to add and no GameObject to create — the bridge self-bootstraps via [RuntimeInitializeOnLoadMethod].
Bind to the standard Gamepad
In your Input Actions asset, bind to <Gamepad>/ paths exactly as you would for any Unity game — <Gamepad>/buttonSouth, <Gamepad>/leftStick, <Gamepad>/rightTrigger, and so on. An OkonaPadDevice is a Gamepad, so these match on Okona and in the Editor with any pad you plug in.
<Gamepad>, not <OkonaPad>. A control scheme that requires <OkonaPad> will reject native pads in the Editor, so a joined player is silently dropped. Use <Gamepad> and everything works in both places.Read input
Use the Input System exactly as normal — PlayerInput / PlayerInputManager (recommended, see below), an InputActions asset, or direct polling:
using UnityEngine;
using UnityEngine.InputSystem;
public class Player : MonoBehaviour
{
void Update()
{
var pad = Gamepad.current; // an OkonaPadDevice on Okona
if (pad == null) return;
if (pad.buttonSouth.wasPressedThisFrame) Jump();
Vector2 move = pad.leftStick.ReadValue();
float gas = pad.rightTrigger.ReadValue();
}
}
Build for WebGL & publish
File > Build Settings > WebGL→ Build.- Upload the build through the Developer Portal and hit Publish.
- Once published, your game has one link — open it in any browser and Okona feeds your game the connected controllers.
Recommended Pattern: PlayerInputManager
For both single- and multi-player, the cleanest setup is Unity's PlayerInputManager with "Join Players When Button Is Pressed". Each controller that presses a button spawns one player. Because OkonaPads are real Gamepads, this is identical to how you'd support local multiplayer in any Unity game — and it runs unchanged in the Editor with whatever pads you have on your desk. Players leaving works differently on Okona than on a desktop, though — see Players Coming and Going.
Setup
- Create an Input Actions asset with a control scheme that requires
<Gamepad>, and bind your actions to<Gamepad>/…paths. - Make a Player prefab with a
PlayerInputcomponent pointing at that asset (set its default scheme + default map). - Add a
PlayerInputManagerto a scene object; set Join Behavior to Join Players When Button Is Pressed and point Player Prefab at your prefab. - If you subscribe to join/leave in C#, set the manager's Notification Behavior to Invoke C# Events (the
onPlayerJoined/onPlayerLeftevents only fire in that mode).
using UnityEngine;
using UnityEngine.InputSystem;
using OkonaPad;
// One of these is spawned per joined controller.
[RequireComponent(typeof(PlayerInput))]
public class PlayerController : MonoBehaviour
{
PlayerInput _input;
void Awake()
{
_input = GetComponent<PlayerInput>();
// The Gamepad this player owns (an OkonaPadDevice on Okona).
var pad = _input.GetDevice<Gamepad>();
// Optional: the 0-based Okona slot (P1 = 0 … P6 = 5).
if (pad is OkonaPadDevice okona)
Debug.Log($"Joined as player slot {okona.okonaSlot}");
}
// Bound to a "Fire" action in the Input Actions asset.
public void OnFire(InputAction.CallbackContext ctx)
{
if (ctx.performed) Fire();
}
void Rumble()
{
// Standard Gamepad rumble — forwarded to the physical pad.
(_input.GetDevice<Gamepad>())?.SetMotorSpeeds(0.4f, 0.8f);
}
}
onDeviceLost is not how players leave on Okona. In a desktop game, unplugging a pad loses the device and PlayerInput.onDeviceLost fires. On Okona a controller that goes away keeps its device on purpose, so that callback stays silent — it fires only in the rare slot-reclaim case. Use it if you like as a belt-and-braces cleanup, but if your game needs to notice people leaving, read Players Coming and Going first.// Fires only when a slot is genuinely reclaimed — not on an ordinary walk-away.
_input.onDeviceLost += pi => Destroy(pi.gameObject);
Players Coming and Going
Okona games run in public spaces, and people treat them accordingly: they wander up halfway through a round, put a phone down, let it lock itself, pick it back up two minutes later — or simply walk away. If your game supports more than one player — and especially if it runs unattended on a sign — how you handle that is worth deciding on purpose rather than inheriting by accident.
This section covers what the platform guarantees, what it deliberately leaves to you, and three patterns that cover most games.
The slot model
Okona has six player slots. Every controller that connects takes the lowest free one and keeps it for the whole session. Nobody is ever renumbered: if P2's pad sleeps and P4's phone joins afterwards, P2 is still P2 when they come back.
| What happens | What your game sees |
|---|---|
| A controller connects | A new OkonaPadDevice is added — InputDeviceChange.Added, PlayerInputManager join, the usual |
| Its controller goes away | Nothing is removed. The device stays, its input reads neutral, and okonaParked becomes true |
| It comes back | Same slot, same device instance, okonaParked back to false. No join event — they never technically left |
| The Home menu opens | Every pad reads neutral, and nobody reports parked |
| A slot is reclaimed (see below) | The device really is removed — InputDeviceChange.Removed, onDeviceLost |
The important row is the second one. Holding the device is what makes returning seamless — it's the fix for “my controller came back and I was suddenly player 2” — but it means the standard Input System will never tell you somebody walked away. That's what okonaParked is for.
Noticing someone leave
Poll okonaParked wherever you already track your players. There is no event, and there deliberately isn't one — the device you'd hang it off never goes away.
var dev = player.Pad as OkonaPad.OkonaPadDevice;
if (dev != null && dev.okonaParked)
DropPlayer(player); // they've left — clear their character, free their colour
okonaParked == false for everybody, or opening the menu would eject the whole room. Read the flag; never infer departure from silence.When they come back
A returning controller reclaims its own slot and its own device instance. Your reference stays valid the whole time — there is nothing to re-acquire, and input simply resumes.
If you dropped the player while they were parked, watch for the flag going the other way and seat them again:
// Poll once per frame; act on the edges, not the level.
bool parked = dev.okonaParked;
if (parked && !_wasParked) OnPlayerLeft(player);
if (!parked && _wasParked) OnPlayerReturned(player); // same slot, same device
_wasParked = parked;
How long a controller can stay parked is unbounded — a pad can nap for an entire session and still come back as itself. Okona will only take the seat away in one case, below.
The one case where a device really goes away
Parked slots are reclaimed lazily: if all six slots are taken and a new controller wants in, the slot that has been parked the longest is freed for it. That player's device is removed, so you get the standard InputDeviceChange.Removed and onDeviceLost — the only situation in which you do.
A game that handles both signals is robust in every case: okonaParked for the ordinary walk-away, device removal for the reclaim.
Choosing a pattern
None of the following is enforced — the platform reports the facts and stays out of the way. Pick the one that matches how your game is played.
1. Drop immediately — unattended screens, drop-in party games
The moment someone parks, remove them. Nobody is standing at a digital sign to clean up after a player who wandered off, so anything left behind is left behind forever.
if (dev.okonaParked) RemovePlayer(player);
Pair it with mid-game joining (a join code in your own UI) and the display looks after itself: people arrive, play, leave, and the game keeps running.
2. Grace period, then drop — couch multiplayer
Bluetooth blips, a phone locking its screen, someone getting a drink. A few seconds of tolerance avoids ejecting a player who never actually left, at the cost of a brief ghost.
if (dev.okonaParked)
{
player.ParkedFor += Time.deltaTime;
if (player.ParkedFor > 10f) RemovePlayer(player);
}
else player.ParkedFor = 0f;
3. Hold the seat — co-op, turn-based, anything with per-player progress
Keep the player, but stop simulating them: freeze the character, skip their turn, show “waiting for P3”. When they return you carry on where you left off — which is exactly what the platform is set up for, since their device never went anywhere.
player.Character.SetActive(!dev.okonaParked); // or pause their turn timer
Players
static class OkonaPad.Players
Show each player's character on their phone controller. When a phone pairs it says “Player 3”; once your game knows who they are, tell their phone — a name, an icon, an accent colour — and the controller in their hand becomes their controller:
// P1 (slot 0) picked the blue fish:
Players.SetIdentity(0, "Splash", "icons/bluefish.png", "#3b82f6");
// or an emoji instead of an image:
Players.SetIdentity(0, "Splash", "🐟", "#3b82f6");
Members
| Member | Type | Description |
|---|---|---|
SetIdentity(slot, name, icon, colorHex) | void | Show this character on the player's phone. slot is the 0-based player slot (P1 = 0, same as rumble); icon and colorHex are optional |
ClearIdentity(slot) | void | Back to the plain “Player N” pill |
Field rules
Out-of-spec values are truncated or dropped — never an error:
| Field | Rules |
|---|---|
name | Shown next to the icon; truncated past 24 characters |
icon | Either an emoji (any short string), or a relative path to an image shipped in your build's StreamingAssets (png/jpg/webp/gif/svg) — e.g. "icons/bluefish.png"; see Shipping icon images below. External URLs are not supported |
colorHex | Optional #rrggbb; tints the identity chip on the phone. Anything else is ignored |
Shipping icon images
An image icon travels with your game build — there is no separate icon upload and no URL to host: the icon is published game content, served from your game's own hosted build. Two steps, one in Unity and one on the portal.
1. In Unity — put the images in your project's Assets/StreamingAssets/ folder (create the folder if it doesn't exist; the name is special to Unity). Keep them small — the pad shows them at chip size, so something like a 128×128 PNG is plenty:
Assets/StreamingAssets/icons/bluefish.png
Assets/StreamingAssets/icons/octopus.png
Unity copies that folder verbatim into every WebGL build's output as a StreamingAssets/ folder next to Build/. In code, pass the path relative to StreamingAssets:
Players.SetIdentity(0, "Splash", "icons/bluefish.png", "#3b82f6");
2. On the developer portal — when you upload the build on your game's Build tab, also drop the build output's StreamingAssets folder into the StreamingAssets zone beneath the four build files (the same zone Unity Addressables content uses). Okona hosts the folder alongside your build, and each player's phone loads its icon from there. If you skip this step the game still works — pads that can't load an icon simply fall back to the name.
SetIdentity (or ClearIdentity) yourself when you seat them.Testing it
In the dev harness, disconnecting a controller shows a PARKED badge on its card. On a live run, close the phone's controller page. Both take the genuine path — a controller leaving navigator.getGamepads() — which is the one that matters: it's easy to fake a departure in a way that never happens in practice and conclude your handling works when it doesn't.
API Reference
Everything lives in the OkonaPad namespace. For most games you never touch these types directly — you read input through the standard Gamepad / PlayerInput API. They're here for when you want the Okona slot, manual access, or deterministic tests.
OkonaPadDevice
OkonaPad.OkonaPadDevice : UnityEngine.InputSystem.Gamepad
One connected Okona controller, surfaced as a standard Unity Gamepad. Every normal Gamepad control and binding works out of the box. The only addition is the player slot.
Members
| Member | Type | Description |
|---|---|---|
okonaSlot | int | 0-based player slot (P1 = 0 … P6 = 5); -1 until assigned |
okonaKind | OkonaPadState.PadKind | What this player is holding: Gamepad, Phone, or Keyboard. Input is identical either way — see Phone Pairing In Your Game |
okonaParked | bool | This player's controller has gone away, but their slot is held so they get the same player back if they return. The device stays alive on purpose — polling this is the only way to notice somebody leaving; see Players Coming and Going |
SetMotorSpeeds(low, high) | void | Standard Gamepad rumble; forwarded to the physical pad |
PauseHaptics() / ResetHaptics() | void | Stop rumble (sends 0,0) |
Inherited from Gamepad: buttonSouth/East/West/North, leftShoulder, rightShoulder, leftTrigger, rightTrigger, select, start, leftStick, rightStick, leftStickButton, rightStickButton, dpad, plus Gamepad.all and Gamepad.current.
OkonaInputBridge
static class OkonaPad.OkonaInputBridge
The static driver that pulls the input snapshot from the web layer each frame and adds/removes OkonaPadDevices as players connect and disconnect. It bootstraps itself at load — you do not create or reference it for normal use.
Members
// The connected device for a 0-based slot, or null.
public static OkonaPadDevice GetDevice(int slot);
// Pull the current snapshot and push it to the Input System. Runs every
// frame automatically (InputSystem.onBeforeUpdate); also callable directly
// for deterministic tests.
public static void Pump();
// Editor/test only: inject a snapshot as if it came from the web layer.
#if UNITY_EDITOR
public static void SimulateSnapshot(byte[] snapshot);
#endif
window.__okonaPad), so no OkonaPadDevices are created and Pump() no-ops. You test with native Unity gamepads, which work because your bindings target <Gamepad>. In a WebGL build the bridge manufactures OkonaPadDevices from the live snapshot.Controls & Bindings
OkonaPad maps the standard controller to the standard Unity Gamepad controls. Bind to the <Gamepad>/ paths below.
| Control | Binding path | Notes |
|---|---|---|
| A (bottom) | <Gamepad>/buttonSouth | |
| B (right) | <Gamepad>/buttonEast | |
| X (left) | <Gamepad>/buttonWest | |
| Y (top) | <Gamepad>/buttonNorth | |
| Left bumper | <Gamepad>/leftShoulder | |
| Right bumper | <Gamepad>/rightShoulder | |
| Left trigger | <Gamepad>/leftTrigger | Analog 0–1 |
| Right trigger | <Gamepad>/rightTrigger | Analog 0–1 |
| Select / View | <Gamepad>/select | |
| Start / Menu | <Gamepad>/start | |
| Left stick | <Gamepad>/leftStick | Vector2 |
| Right stick | <Gamepad>/rightStick | Vector2 |
| L3 (stick press) | <Gamepad>/leftStickPress | |
| R3 (stick press) | <Gamepad>/rightStickPress | |
| D-pad | <Gamepad>/dpad | /dpad/up etc. |
<Gamepad>/leftTrigger and /rightTrigger to actions typed Value / Axis (with Initial State Check on), then read the live 0–1 pull with ReadValue<float>() and apply your own threshold. If you type the action as a Button, ReadValue<float>() stays 0 until the pull crosses the press point (default 0.5) — so light presses do nothing and some pads (e.g. an Xbox controller over Bluetooth) may never register at all. A face button mapped to the same action still fires, which hides the bug. If you reuse a trigger Value action as a menu “confirm,” subscribe to started (one edge), not performed — performed re-fires every frame the trigger is held.Input Snapshot advanced
You never read this directly — it's how input physically reaches your game, documented for the curious. The shell keeps a fixed-layout binary snapshot of every player's current state in window.__okonaPad. Each frame the SDK copies it straight out of WebAssembly memory (HEAPU8) into a reused buffer and decodes it — no SendMessage, no JSON, no Base64, zero per-frame allocation.
Layout is little-endian, 106 bytes (a 4-byte header + 6 × 17-byte pad records). It is defined identically in bridge/okona-padstate.js (web) and OkonaPad/OkonaPadState.cs (Unity).
Header (4 bytes)
[0] magic 0x4F ('O')
[1] version 1
[2] maxPads 6
[3] reserved 0
Per pad (17 bytes), one per slot 0..5
[0] flags bit0 = connected
bit1..2 = pad kind (0 gamepad, 1 phone, 2 keyboard)
[1..4] buttons uint32 LE — bit i = button i pressed
[5..6] leftX int16 LE (-32767..32767 => -1..1)
[7..8] leftY int16 LE
[9..10] rightX int16 LE
[11..12] rightY int16 LE
[13..14] leftTrigger uint16 LE (0..65535 => 0..1)
[15..16] rightTrigger uint16 LE
The buttons bit positions are the standard Web Gamepad indices:
| Bit | Button |
|---|---|
| 0–3 | A, B, X, Y |
| 4–5 | LB, RB |
| 6–7 | LT, RT (digital) |
| 8–9 | Select, Start |
| 10–11 | L3, R3 |
| 12–15 | D-pad Up, Down, Left, Right |
| 16 | Guide (reserved) |
Custom Analytics Events web interop
window.okonaAnalyticsTrack(name, props)
Okona records impressions, sessions, and controller joins automatically. To count things only your game knows — rounds completed, high scores, a promo screen reached — report a custom event and it shows up aggregated in your analytics dashboard.
Call it from Unity with a one-line jslib plugin (Assets/Plugins/OkonaAnalytics.jslib):
mergeInto(LibraryManager.library, {
OkonaTrack: function (namePtr) {
if (window.okonaAnalyticsTrack)
window.okonaAnalyticsTrack(UTF8ToString(namePtr));
}
});
// C# side
[DllImport("__Internal")] static extern void OkonaTrack(string name);
OkonaTrack("round_completed");
round_completed, not per-player values.Phone Pairing In Your Game
Players use a phone as a controller by scanning a code — on a digital sign, and in a browser playing a shared link. Okona shows that code on its own “Turn on a controller to play” screen, but that screen disappears the moment the first player joins. If you want people to be able to join during play — a lobby that fills up, a party game between rounds, an unattended display at an event — ask Okona for the code and draw it yourself.
using OkonaPad;
using UnityEngine;
using UnityEngine.UI;
public class JoinPanel : MonoBehaviour
{
public GameObject panel;
public RawImage qrImage;
public Text captionLabel;
void Start()
{
// We're showing the code ourselves — don't show Okona's too.
SystemUI.HideJoinScreen();
Refresh();
Pairing.OnChanged += OnPairingChanged;
}
void OnDestroy() => Pairing.OnChanged -= OnPairingChanged;
void OnPairingChanged(string url) => Refresh();
void Refresh()
{
panel.SetActive(Pairing.IsAvailable);
qrImage.texture = Pairing.GetQrTexture();
captionLabel.text = Pairing.Caption;
}
}
Pairing.OnChanged. A game that reads Pairing.Url only in Start() looks fine in testing and then shows a dead code an hour into an unattended run. (GetQrTexture() hands back the same Texture2D every time and reloads it in place, so if you only ever assign that texture you're already covered — but the caption can change with it.)Pairing
static class OkonaPad.Pairing
The current phone-pairing code, if there is one.
Members
| Member | Type | Description |
|---|---|---|
IsAvailable | bool | Whether there's a code to show right now |
Url | string | The address a phone opens; "" when unavailable |
Caption | string | How the phone reaches this session, ready to print under the code — “On the same Wi‑Fi” or “Uses your phone's internet” |
OnChanged | event Action<string> | Raised on the main thread whenever the code changes, appears, or goes away. Argument is the new Url |
GetQrTexture() | Texture2D | The code as a 512×512 texture, ready for a RawImage; null when unavailable |
IsAvailable == false. There's a moment at startup on the web before the code exists, it can be permanently unavailable if the player's browser blocks it, and a session may not be offering phone pairing at all. Hide your join panel rather than showing an empty frame, and make sure a controller can still start the game.Destroy it. Decoding happens once per new code, not per frame. It's drawn black-on-white with the required quiet zone and point filtering, so it scans reliably even blown up on a large screen. Don't tint it, and keep something light behind it.GetQrTexture() returns a QR-shaped image that is deliberately not scannable and Url is a stub. It's there so you can build and preview your join screen. Test the real thing in the sandbox (Play In Sandbox on your project) with an actual phone.SystemUI
static class OkonaPad.SystemUI
Control over the parts of Okona that overlay your game.
Members
| Member | Type | Description |
|---|---|---|
HideJoinScreen() | void | Suppress Okona's “Turn on a controller to play” screen — you're handling joining |
ShowJoinScreen() | void | Hand joining back to Okona |
SetJoinScreenVisible(bool) | void | Either of the above; safe to call at any time, repeatedly |
JoinScreenVisible | bool | Whether Okona's join screen is currently allowed to appear |
okonaonline.com/games/?id=YOUR_ID runs in digital signage mode by default: no Okona UI ever appears — not the join screen, not the Home menu, no connect sounds, no error banners — and if the game fails to load, the page quietly retries on its own. Your game owns the whole screen, so draw your own join code with Pairing. The Home button, which normally opens Okona's menu, is delivered to your game like any other button. The dashboard's Copy link button copies this URL for you. Add &play=1 for the interactive browser variant instead (Game Menu, keyboard-as-gamepad — what Play in Browser in the editor opens); &joinui=off works there when you only want the waiting screen gone while keeping the Home menu and its pairing code.Knowing who joined
Each player's device tells you what they're holding, so your lobby can say “Phone joined as P3” or show the right glyph. Input is identical whichever it is.
var dev = OkonaInputBridge.GetDevice(slot);
if (dev != null && dev.okonaKind == OkonaPadState.PadKind.Phone)
label.text = "Phone";
PadKind is Gamepad, Phone, or Keyboard (a browser player on a shared link). Anything Okona can't identify reports as Gamepad.
How Input Flows
A phone pairs by scanning the on-screen code (a desktop browser can also use USB/Bluetooth pads or the keyboard); the game shell tracks each controller in one of six player slots and writes its state into the snapshot; the SDK reads the snapshot from WebAssembly memory and drives a Gamepad for it.
Slots & reconnection
The shell manages six slots. A pad is adopted into the lowest free slot the moment a button is pressed on it (a single pad attached at load is adopted automatically). If a pad disconnects — including a brief Bluetooth blip — its slot is held for 30 seconds and the same player resumes when it returns, so a momentary drop never turns into a new join. After the reserve expires the slot is freed and the OkonaPadDevice is removed.
Rumble
Call SetMotorSpeeds(low, high) on the player's Gamepad. The SDK forwards it to the web layer, which vibrates the physical pad (best-effort — silently ignored on pads that don't support haptics). No Okona-specific API to call.
Reference Project
The SDK ships with a Unity 6 reference project that demonstrates the recommended setup end-to-end:
- Input Tester — a
PlayerInputManagerscene that spawns one on-screen controller widget per joined pad and lights up every input (face buttons, bumpers, sticks, L3/R3, D-pad, analog triggers). Drop in up to six pads and watch them light independently. - Player prefab + Input Actions — a bare
PlayerInputprefab and an actions asset with a<Gamepad>control scheme you can copy straight into your game. OkonaJoinPanel.cs— a copy-paste “scan to join” panel: wire up your own image and label, and it shows the pairing code, refreshes it when it's reissued, and hides Okona's own join screen. See Phone Pairing In Your Game.
It's the fastest way to confirm the SDK works with your controllers — open the Input Tester scene, press Play, and press a button on each pad.
Testing Your Game for Okona
The Okona Dev Harness runs your WebGL build on your own machine exactly as the Okona runtime serves it — the same game shell, the same controller input path, the same in-game menu — so you can validate your game before you publish it. Download the harness — it's a small tool that needs only Python 3.7+, nothing to install.
Run it
- Build your game for WebGL with Brotli compression (see Requirements).
- Download the harness and unzip it anywhere.
- From the unzipped
okona-harness/folder, point it at your build — the folder Unity produced, containingindex.htmland aBuild/subfolder:python devtools/okona-test/okona-serve.py <path-to-your-WebGL-build> - A browser opens with your game running. Plug in a USB or Bluetooth controller and play. Press ` (backtick) to toggle the diagnostics overlay.
The serve script sets the HTTP headers Unity WebGL requires — the reason a plain static server usually fails to load a Brotli build.
Confirm your controls
The strip along the bottom shows one card per controller slot (P1–P6), decoded live from the input snapshot the SDK reads: pressed buttons, stick positions, trigger pull. Use it to confirm every control maps the way you expect, and that multiple pads each drive their own player.
Check performance against real screen hardware
Digital signage players are modest ARM devices — think Android media players and Raspberry-Pi-class boards, not gaming PCs. A game that hits 60 fps on your development machine can run far slower on a sign, so the harness does not rely on desktop framerate. Instead it grades your game's GPU workload — which predicts on-device behaviour no matter how fast your machine is — against budgets measured on representative Pi-5-class hardware:
| Metric (per frame) | Budget | Why it matters |
|---|---|---|
| Draw calls | ≤ 80 | Each has fixed CPU cost; the Pi's CPU is the bottleneck |
| Buffer uploads | ≤ 2 MB | Per-frame dynamic vertex/index data stalls the GPU |
| Vertices | ≤ 100K | Raw geometry throughput |
| Render-target switches | ≤ 8 | Tiler GPUs pay heavily per pass (shadows, post stacks) |
Green is comfortable, amber is at budget, red is over. If you're in the red, reduce draw calls (batch/atlas), cut per-frame mesh rebuilds, and avoid realtime shadows and full-screen post-processing.
Pre-publish checklist
- Game loads and is playable in the harness (Brotli build).
- Every control reads correctly in the input visualizer.
- Multiple controllers each drive their own player (P1–P6).
- If players can leave mid-game: disconnecting one shows PARKED on its card, and your game does what you intended — see Players Coming and Going.
- Home opens the Game Menu; Continue resumes, Exit leaves.
- Workload metrics are green or amber — not red.
- Rumble fires on supported pads (if your game uses it).
Troubleshooting
No controller in the Editor
- In the Editor the OkonaPad bridge is dormant — you test with native Unity gamepads, so plug one in and press a button.
- Confirm Active Input Handling is Input System (New) (or Both) in Player settings.
- Check the pad shows up:
Window > Analysis > Input Debuggershould list a Gamepad.
Input doesn't respond in the published game
- Make sure your actions bind to
<Gamepad>/paths (not a custom device). AnOkonaPadDeviceis aGamepad. - If you use
PlayerInputManagerwith C# join/leave events, set its Notification Behavior to Invoke C# Events — the events don't fire otherwise. - Make sure your control scheme requires
<Gamepad>, not<OkonaPad>.
A pad controls two players (or joins twice)
- This is the native-gamepad-double-up problem. The SDK disables Unity's browser-gamepad backend in WebGL automatically; don't re-enable it or join players from a backend you've re-added.
- Never build join logic off
InputSystem.devicesthat you've manually re-enabled on WebGL — letPlayerInputManagerhandle joins.
Rumble not working
- Call
SetMotorSpeeds(low, high)on the player'sGamepad(values 0–1). - Not every physical pad supports haptics; the request is silently ignored on those.
WebGL build issues
- Ensure Input System (New) is enabled in Project Settings.
- Don't include server-side / networking code — WebGL can't run sockets, and the SDK needs none.
Analytics API paid add-on
Everything the analytics dashboard shows — impressions, sessions, controllers, playtime, and your custom events — is available programmatically, so a signage CMS, BI tool, or client report can combine Okona engagement with its own metrics. Included with Okona Pro (subscribe under Account → Billing); one key covers everything the organization builds. Create keys under Account → API Keys in the Developer Portal.
Authentication
Send your key as a bearer token. Keys are organization-scoped, shown once at creation, and revocable at any time.
curl -H "Authorization: Bearer ok_live_..." "https://okonaonline.com/api/v1/projects"
Endpoints
| Endpoint | Returns |
|---|---|
GET /api/v1/projects | Your organization's projects (projectId, title, live) |
GET /api/v1/projects/{id}/daily?from=&to= | Per-day engagement rows for the range (default: last 30 days; max 92) |
GET /api/v1/projects/{id}/summary?from=&to= | Totals over the range |
Dates are UTC days (YYYY-MM-DD). Each daily row carries impressions (every time the game loaded on a screen), sessions (someone actually played), uniqueScreens, controllerConnects, totalDurationSeconds, totalActiveSeconds, avgSessionSeconds, maxControllers, and customEvents (name → count — whatever your game reports via custom analytics events). Today's row is included live and flagged "partial": true; history comes from finalized daily rollups.
Example
curl -H "Authorization: Bearer ok_live_..." "https://okonaonline.com/api/v1/projects/YOUR_PROJECT_ID/summary?from=2026-08-01&to=2026-08-31"
{
"projectId": "YOUR_PROJECT_ID",
"title": "Fish Tank",
"from": "2026-08-01", "to": "2026-08-31",
"totals": {
"impressions": 14210, "sessions": 327,
"controllerConnects": 512, "totalDurationSeconds": 45360,
"avgSessionSeconds": 138, "peakUniqueScreens": 46,
"customEvents": { "round_completed": 171 }
},
"daysIncluded": 31
}
error.code/error.message body on failure — 401 unknown or revoked key, 403 add-on not active, 404 project not in your organization, 429 rate limited. A revoked key stops working within a minute.Use with AI Assistants
If you build with an AI coding assistant, connect it to the Okona docs MCP server and it can read this documentation directly — the full SDK reference, the testing guide, and the dev-harness manual — instead of guessing at the OkonaPad API. It's a public, read-only Model Context Protocol endpoint; no account or API key needed.
https://okonaonline.com/mcp
Claude Code
claude mcp add --transport http okona-docs https://okonaonline.com/mcp
Cursor
Add to .cursor/mcp.json in your project (or the global one):
{
"mcpServers": {
"okona-docs": { "url": "https://okonaonline.com/mcp" }
}
}
Other clients
Any MCP client that supports the Streamable HTTP transport works — point it at the URL above with no authentication. The server exposes three tools:
list_doc_sections— the documentation catalogread_doc_section— one section (or everything) as markdownsearch_docs— keyword search with snippets
Ready to build?
Download the SDK, bind to the standard Gamepad, and ship it anywhere a browser runs.