OKONAONLINE

Developer Docs

Developer Portal →
Unity SDK · v2

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.

OkonaPad is just a Gamepad. 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 Gamepad devices
  • 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.

Areav1 (retired)v2
Device typeCustom OkonaPadControllerDevice (not a Gamepad)OkonaPadDevice : Gamepad
Binding paths<OkonaPad>/buttonA<Gamepad>/buttonSouth
TransportSendMessage + JSON + Base64 per frameBinary snapshot read from HEAPU8 (zero alloc)
SetupAdd an OkonaInputBridge GameObject named exactlyNone — the bridge self-bootstraps
ControlsA/B/X/Y, Start/Select, D-pad, left stickEverything a Gamepad has, both sticks, analog triggers
RumbleCustom static eventStandard Gamepad.SetMotorSpeeds()
Don't mix native gamepads into your join logic on WebGL. In a WebGL build the SDK disables Unity's own browser-gamepad backend so a physical pad is never seen twice (once natively, once as an OkonaPad). You keep <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

  1. Install the Input System package via Package Manager.
  2. Edit > Project Settings > Player > Other Settings → set Active Input Handling to Input System (New) (or Both).
  3. 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

1

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].

2

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.

Bind <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.
3

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();
    }
}
4

Build for WebGL & publish

  1. File > Build Settings > WebGL → Build.
  2. Upload the build through the Developer Portal and hit Publish.
  3. Once published, your game has one link — open it in any browser and Okona feeds your game the connected controllers.

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 happensWhat your game sees
A controller connectsA new OkonaPadDevice is added — InputDeviceChange.Added, PlayerInputManager join, the usual
Its controller goes awayNothing is removed. The device stays, its input reads neutral, and okonaParked becomes true
It comes backSame slot, same device instance, okonaParked back to false. No join event — they never technically left
The Home menu opensEvery 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
If players can join mid‑game, handle this. A game that lets people drop in but never notices them dropping out accumulates abandoned players — and on an unattended screen, nobody is there to clear them. One departed player can leave a phantom that every subsequent round faithfully re-spawns.
Parked is not the same as idle. A parked player sends no input — but so does one who's standing still, and so does everyone while the Home menu is open. That last case deliberately reports 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.

Okona tracks controllers, not people. A returning phone is the same device; the platform can't know whether the same human picked it up. If that distinction matters to your game — scores, teams, saved progress — that's yours to decide, and a short “welcome back / new player?” prompt is usually the honest answer.

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
Whichever you pick, don't let a parked player block progress. If your game waits on “all players ready”, a parked one must not stall the round forever — count only unparked players, or let the others start without them.

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

MemberTypeDescription
SetIdentity(slot, name, icon, colorHex)voidShow 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)voidBack to the plain “Player N” pill

Field rules

Out-of-spec values are truncated or dropped — never an error:

FieldRules
nameShown next to the icon; truncated past 24 characters
iconEither 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
colorHexOptional #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.

Icons version with your build. Changing an icon means uploading a new build and publishing the update, exactly like any other art in your game. A path that doesn't resolve (a typo, or a file missing from the upload) is never a broken image on the phone — the pad quietly shows the name (and emoji, if you passed one) instead.
Fire-and-forget, phones only. There's no readback, and the call is a harmless no-op for players on physical gamepads or keyboards (they have no screen), in the Editor, and on shells that predate the feature. Identity reaches the phone within about a second and survives phone reloads and reconnects on its own.
It's identity, not a ticker. Updates are delivered at most once a second per phone — set it when a player picks or changes character, not every frame. When a player is permanently removed their identity is cleared automatically; if your game recycles a slot for a new player mid-session, call 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

MemberTypeDescription
okonaSlotint0-based player slot (P1 = 0 … P6 = 5); -1 until assigned
okonaKindOkonaPadState.PadKindWhat this player is holding: Gamepad, Phone, or Keyboard. Input is identical either way — see Phone Pairing In Your Game
okonaParkedboolThis 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)voidStandard Gamepad rumble; forwarded to the physical pad
PauseHaptics() / ResetHaptics()voidStop 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
In the Editor the bridge is dormant. There's no web layer (no 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.

ControlBinding pathNotes
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>/leftTriggerAnalog 0–1
Right trigger<Gamepad>/rightTriggerAnalog 0–1
Select / View<Gamepad>/select
Start / Menu<Gamepad>/start
Left stick<Gamepad>/leftStickVector2
Right stick<Gamepad>/rightStickVector2
L3 (stick press)<Gamepad>/leftStickPress
R3 (stick press)<Gamepad>/rightStickPress
D-pad<Gamepad>/dpad/dpad/up etc.
The Home / Guide button is special. In standard play it opens the in-game Okona menu (Continue / Exit) — intercepted by the shell and never delivered to your game. In signage mode there is no Okona menu, so Home is delivered to your game like any other button. Don't rely on it for anything essential in standard play.
Analog triggers are Values, not Buttons. Bind <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 performedperformed 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:

BitButton
0–3A, B, X, Y
4–5LB, RB
6–7LT, RT (digital)
8–9Select, Start
10–11L3, R3
12–15D-pad Up, Down, Left, Right
16Guide (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");
Budgets: event names are capped at 64 characters, optional props (JSON) at 1 KB, and 500 custom events per page session — beyond that, calls are dropped silently. Counts aggregate per name per day; use names like 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;
    }
}
Don't read the code once and cache it. Pairing codes expire and are reissued while your game runs — roughly hourly. Subscribe to 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

MemberTypeDescription
IsAvailableboolWhether there's a code to show right now
UrlstringThe address a phone opens; "" when unavailable
CaptionstringHow the phone reaches this session, ready to print under the code — “On the same Wi‑Fi” or “Uses your phone's internet”
OnChangedevent Action<string>Raised on the main thread whenever the code changes, appears, or goes away. Argument is the new Url
GetQrTexture()Texture2DThe code as a 512×512 texture, ready for a RawImage; null when unavailable
Always handle 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.
The texture is managed for you. The same instance is reused for the life of the game, so assign it and leave it — don't 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.
In the Editor you get a placeholder, not a real code. There's no Okona session in Play Mode, so 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

MemberTypeDescription
HideJoinScreen()voidSuppress Okona's “Turn on a controller to play” screen — you're handling joining
ShowJoinScreen()voidHand joining back to Okona
SetJoinScreenVisible(bool)voidEither of the above; safe to call at any time, repeatedly
JoinScreenVisibleboolWhether Okona's join screen is currently allowed to appear
The Home menu keeps its code. Hiding the join screen only affects the “no controllers” screen. Pressing Home still opens the Okona menu with a pairing code in it — that's the safety net for anyone already holding a controller, so a mistake in your join screen can never leave players with no way in.
The bare project URL is your game link. 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.
Free vs Pro. On the free tier the link plays anywhere, any time, and carries a small Powered by Okona watermark (on the game, and as a chip on the phone controller). Okona Pro (one subscription per organization, from Account → Billing) removes both on every game you have live — same link, no watermark. If Pro lapses the link keeps working and the watermark returns.

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.

Knowing who left is a separate problem. A player who closes their controller page doesn't disappear — Okona holds their seat so they can come back. If people can join your game mid-session, they can leave mid-session too: see Players Coming and Going.

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.

Phone / USB / BT Pad
Game Shell
window.__okonaPad
OkonaInputBridge (HEAPU8)
Gamepad in your game

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 PlayerInputManager scene 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 PlayerInput prefab 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

  1. Build your game for WebGL with Brotli compression (see Requirements).
  2. Download the harness and unzip it anywhere.
  3. From the unzipped okona-harness/ folder, point it at your build — the folder Unity produced, containing index.html and a Build/ subfolder:
    python devtools/okona-test/okona-serve.py <path-to-your-WebGL-build>
  4. 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)BudgetWhy it matters
Draw calls≤ 80Each has fixed CPU cost; the Pi's CPU is the bottleneck
Buffer uploads≤ 2 MBPer-frame dynamic vertex/index data stalls the GPU
Vertices≤ 100KRaw geometry throughput
Render-target switches≤ 8Tiler 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.

The framerate shown is your desktop's, not the screen's. The workload budgets are the real signal in the harness — but the final word on performance is always running your published game on the actual screen hardware it will run on.

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 Debugger should list a Gamepad.

Input doesn't respond in the published game

  • Make sure your actions bind to <Gamepad>/ paths (not a custom device). An OkonaPadDevice is a Gamepad.
  • If you use PlayerInputManager with 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.devices that you've manually re-enabled on WebGL — let PlayerInputManager handle joins.

Rumble not working

  • Call SetMotorSpeeds(low, high) on the player's Gamepad (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

EndpointReturns
GET /api/v1/projectsYour 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
}
Limits and errors. 60 requests/minute per key; responses are JSON with an 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 catalog
  • read_doc_section — one section (or everything) as markdown
  • search_docs — keyword search with snippets
Always current. The server reads this page live, so your assistant sees the same documentation you do — including updates published after you configured it.

Ready to build?

Download the SDK, bind to the standard Gamepad, and ship it anywhere a browser runs.

Back to Okona