OKONAONLINE

Developer Docs

Developer Portal →
HTML5 · Unity · the Okona catalog

Build for Okona

Phone-controlled multiplayer games for the Okona catalog. Build in HTML5 with any engine, or in Unity with the OkonaPad SDK, and submit your game; once it's approved, players find it in Okona. Up to six players scan a code on the screen and their phones become gamepads. No networking code, no join screen to build, nothing to host.

Introduction

Okona is a curated catalog of phone-controlled multiplayer games, played in the Okona app on a shared screen: a TV at home, a screen in a bar, a projector at an event. You build the game, test it with real phones in your private sandbox, and submit it for review; once Okona approves it, it's in the catalog. Up to six people scan a code on the screen and their phones become gamepads. The Okona app shows that code and keeps every phone paired from one game to the next, so players are usually seated before your game even loads. Players install nothing, and there is no networking code to write and no join screen to build — the phones arrive in your game as ordinary controllers. There are no public game links: games play only inside Okona.

USB and Bluetooth gamepads plugged into the machine showing the game work too, through the same six player slots. Per-player identity on the phone and engagement analytics are built in.

Choose your path

There are two runtimes and three ways to build for them. Every path gets the same hosting, the same phones and the same review.

PathWhat you buildYou needStart
HTML5, any engineA folder with index.html at its root — Phaser, Three.js, PlayCanvas, Godot's web export, plain canvas — plus one script tag.A browser. Node 18.17+ if you use the starter or the CLI.HTML5 quick start
UnityA Unity WebGL build. Controllers appear as standard Input System Gamepad devices; your bindings just work.Unity 6, the Input System package, a Brotli WebGL build.Unity quick start
An AI coding assistantAn HTML5 game the assistant writes, tests and submits for you, with the Okona skill and these docs on hand.Claude Code, Cursor or any MCP client; a person to approve one login.Build with an AI assistant

Whichever you pick, read Set up your development environment once and Designing for the TV and the phone before you write gameplay. Testing and submitting are the same for both runtimes.

Free for developers. Developer accounts are free; building, hosting and review cost nothing. Players play the catalog with an Okona Plus membership. See Submit to the Okona catalog.

Set up your development environment

Everything on this page assumes a few things are in place: an Okona account, the tools for the path you are taking, and a phone in your pocket for the moment you want to see a real controller join. Set them up once; the quick starts skip straight to code.

For everyone

  • An Okona account. Okona is invite-only right now: sign in at the Developer Portal and request access (tell us who you are and what you're building — we reply by email). Approval creates your account’s studio — the thing that owns your games and API keys. Its name (yours to set on Account → Studio) is what players see on your games’ pages in the Okona app.
  • A phone. Real phone pairing needs a relay room, which exists only inside Okona and in the sandbox. The local tools use scripted controllers and the keyboard instead.
  • The dev harness (optional, both runtimes). okona-harness.zip runs your build under the real Okona shell on your own machine and grades its performance. Python 3.7+, standard library only — nothing to install. See Testing your game.
  • Node 18.17 or newer (node --version) — only if you use the starter or the CLI. Both are zero-dependency npm packages you run with npx; nothing needs a global install.

HTML5

No build tools are required. npm create okona-game my-game scaffolds a working game; open its index.html in a browser and play with the keyboard, or serve the folder with any static server (npx serve .). A game you already have needs one script tag — <script src="https://okonaonline.com/sdk/okona.js"></script>, always the absolute URL. Bundle the libraries you use into the folder rather than loading them from a CDN. → HTML5 quick start

Unity

Unity 6 (6000.3.x) or newer with the Input System package (1.17+) and the WebGL build module. Two Player settings matter: Active Input Handling = Input System (New) or Both, and Compression Format = Brotli — Okona takes Unity builds compressed with Brotli only. Then import OkonaPad.unitypackage. → Unity requirements

The CLI (optional, both runtimes)

The okona command creates games, uploads builds and publishes from a terminal, a CI job or an AI agent. The npm package is okona-cli, so run it as npx okona-cli … (or npm i -g okona-cli and then plain okona …). Log in once:

npx okona-cli login

It prints a URL and an 8-character code. Open the URL signed in to the developer portal, confirm the code, and a publish key is saved to ~/.okona/config.json. For CI or an unattended agent, create a publish key under Account → API Keys instead and set OKONA_API_KEY. → Publish API & CLI

With an AI coding assistant

Point the assistant at the docs MCP server, install the Okona skill, and it can build, test and publish for you. → Build with an AI coding assistant

Where the keyboard works. A keyboard drives P1 in exactly three places: a game file opened outside Okona (the SDK's standalone mode), the dev harness, and the sandbox. It never drives a player in the Okona app: players use phones and gamepads, and there the keyboard runs the app itself (Shift+Tab opens the Okona menu).

HTML5 quick start

Okona hosts any HTML5 game — Phaser, Three.js, PlayCanvas, Godot's web export, plain canvas, or a game an AI coding tool wrote for you — and gives it phones as controllers, hosting in the Okona catalog and analytics. Your build is a folder with index.html at its root; the JavaScript SDK is one script tag. This is the fastest path from an empty folder to a game your phone can join.

The same runtime. An HTML5 game runs inside the Okona shell in a frame that fills the screen. The shell keeps owning controllers, pairing and analytics; the game reads six pads and, if it likes, draws the join code. Nothing about the phone side changes.

1. Scaffold a game

npm create okona-game my-game
cd my-game

Node 18.17 or newer; npx create-okona-game my-game does the same. The starter is four files and no build step:

  • index.html — the lobby. It shows the join code until the room is full, seats up to six players and pushes each one's name and colour to their phone, then runs the frame loop. You rarely need to touch it.
  • game.js — your game: start, update and draw, plus the onJoin / onLeave / onPark / onResume hooks. It ships with a small coin-grab race for you to replace.
  • icon.png — the icon the portal shows for the game. Replace it.
  • README.md — the same play-and-publish steps, for whoever opens the folder next.

There is no package.json and nothing to install. The folder you scaffold is the folder you publish.

2. Play it

Open index.html in a browser, or serve the folder (npx serve .). Outside Okona the SDK falls back to the keyboard as P1 (WASD or arrows to move, Space = A, Shift = B) and seats USB gamepads after it, so you can play the whole thing at your desk. Edit game.js, refresh, repeat.

3. Add the SDK to a game you already have

Any HTML5 game becomes an Okona game with one script tag and a loop that reads six pads:

<script src="https://okonaonline.com/sdk/okona.js"></script>
<script>
// Six pads, always. Call once per frame.
function frame() {
  var pads = Okona.pads();
  for (var i = 0; i < pads.length; i++) {
    var p = pads[i];
    if (!p.connected) continue;
    move(i, p.axes.lx, p.axes.ly);            // -1..1, +Y down (Gamepad API convention)
    if (p.justPressed('a')) jump(i);
  }
  requestAnimationFrame(frame);
}
requestAnimationFrame(frame);

// Draw the scan-to-join code wherever your lobby wants it.
Okona.on('pairing', function () { Okona.drawQr(document.getElementById('qr')); });
</script>

Open the file in a browser and it plays with the keyboard and any USB gamepad. On Okona the same code gets phones. That is the whole integration. For an engine with its own input layer, turn off its gamepad and keyboard handling and read Okona.pads() in its update loop instead; design the scene at 1920×1080 logical pixels and let it scale to fill the frame; bundle the engine with your build rather than loading it from a CDN.

The JavaScript SDK reference has the complete pad object, the events and the join code. A complete example game of about 200 lines (Bumper Party) ships inside the Okona skill.

4. Ship it

Read Designing for the TV and the phone, try it with real phones in the sandbox, then submit it for review from the portal's Build tab or from the terminal:

npx okona-cli login
npx okona-cli create --title "My Game" --description "Six players, one screen." --runtime html5
npx okona-cli push . --game <id> --icon icon.png --sandbox     # real phones, your private sandbox
npx okona-cli push . --game <id> --icon icon.png --publish     # submit for review → Okona approves it into the catalog
Built with an AI assistant? It can do all of this for you — see Build with an AI coding assistant.

JavaScript SDK reference

https://okonaonline.com/sdk/okona.js defines one global, Okona. It gives your game six pad objects to read every frame, a handful of events for players arriving and leaving, the join code to draw, and a way to put each player's character on their phone. This is the complete reference; the quick start shows the shape of a game that uses it. ES2015, no dependencies, about 15 KB.

The pad object

Okona.pads() → array of 6  ·  Okona.pad(i) → one

Okona.pads() returns the six player slots as objects that are updated in place. justPressed() compares against the previous pads() call. Presses are latched between calls, so a tap shorter than one frame still registers.

Call pads() exactly once per frame and keep the array. A second call — or Okona.pad(i), which refreshes all six — resets justPressed() for that frame. You never need it elsewhere: Okona.rumble(i, …), Okona.players.setIdentity(i, …) and clearIdentity(i) take a plain seat index (or the pad object) and never touch input state.
FieldTypeMeaning
slot / index1–6 / 0–5The player's seat. Seats are held while a controller is away — nobody is renumbered.
connectedboolA controller holds this seat.
kind'phone' | 'gamepad' | 'keyboard' | nullWhat the player is holding. Input is identical either way.
parkedboolThe player's controller has gone away (phone page closed, pad asleep) but the seat is held for their return. The way to notice somebody leaving — see below.
buttonsobject of boolsa b x y lb rb lt rt select start l3 r3 up down left right home
pressed(name) / justPressed(name)functionHeld now / went down since your last pads() call.
axes{lx, ly, rx, ry} −1..1Sticks. +Y is down, like the Web Gamepad API and screen space.
triggers{lt, rt} 0..1Analog triggers.
maskuint32The raw button bits (standard Gamepad indices) if you prefer them.

Okona.MAX_PLAYERS is 6; Okona.BUTTONS lists the 17 button names in bit order. The phone pad starts on a layout of A/B/X/Y, one stick, bumpers, a d-pad and Select/Start, so treat the right stick and analog triggers as optional unless your game's Controller tab declares the Full layout. Don't build anything around Start or Home: in the Okona app Start opens the Okona menu and Home goes to the TV's home screen, and neither reaches your game (see the menu event below); only in the sandbox and outside Okona do they arrive like any other button. Begin a round on A, never "Press Start".

Events

Okona.on(name, fn)  ·  Okona.off(name, fn)  ·  Okona.ready(fn)

Seat events pass the pad object as the argument.

  • ready — the SDK knows where it is running: Okona.mode is 'live' (in the Okona app), 'sandbox' or 'standalone' (opened outside Okona). Okona.ready(fn) runs immediately if already ready. Until it fires, mode reads 'hosted' inside a frame — wait for ready before branching on it, and start your frame loop there.
  • join / leave — a seat is taken / permanently freed. Set the player's identity on join.
  • park / resume — the player's controller went away / came back to the same seat. Pause or fade a parked player rather than removing them: on Okona a departed phone keeps its seat so the same person gets the same player back.
  • pairing — the join code changed, or its image just became drawable. It is reissued periodically; redraw on every one.
  • menu — the Okona menu opened (true) or closed (false) over your game in the Okona app; Okona.menuOpen holds the current state. It opens on Start (any controller; the TV remote's Back), and by itself while no controller is connected. Every pad already reads neutral while it is open, so ignoring it is fine; pause your game on it if you'd rather the action stopped.

The seat model is the one Unity games see: seats are held while a controller is away, nobody is renumbered, and a seat is freed only when all six are taken and a new controller joins. The patterns for handling departures — drop immediately, grace period, hold the seat — are in Players coming and going and apply unchanged, with parked in place of okonaParked.

Pairing and the join code

Okona.pairing  ·  Okona.drawQr(canvas, opts?)

The Okona app gets players in: it shows the join code on its own screens whenever no controller is connected, so most players are seated before your game loads, and over a running game the Okona menu's Controllers shows it (the menu opens on Start), and when a game starts with nobody connected the app shows it over the game until a player starts. So drawing the code yourself is optional. It lets people join during play without the menu covering your game — a lobby that fills up, a party game between rounds — and it is the only way a phone joins in the sandbox, which has no Okona screens around your game. Okona.drawQr(canvas) paints the current code into a canvas, square and centred on a white ground, scaled to the canvas, and is safe to call every frame (it draws from a decoded image, never fetches). It returns true when a real code was drawn and false for the placeholder it paints until one exists — and always outside Okona. Options: { margin, background, placeholderColor }.

Okona.pairing carries available, url, caption (a short line such as “Uses your phone's internet” to print under the code), version (bumps on every change), qrUrl (a blob: URL for an <img>) and qrImage (the decoded image). Okona.pairing.onChange(fn) is the same as Okona.on('pairing', fn).

Two rules, the same as for Unity. The code is reissued while the game runs (roughly hourly), so redraw on every pairing event rather than drawing once. And handle available == false: there is a moment at startup before the code exists, a browser can block pairing outright, outside Okona there is never a code, and a running game can lose its code for a while: when the screen's connection drops for more than a few seconds, a pairing event arrives with available false, and another brings the fresh code once the screen is back. Hide or grey the panel rather than show a dead code (drawQr paints its placeholder meanwhile), and make sure a controller can still start the game. Show the code large while the room is empty, small once people are in, hidden at six.

Player identity on the phone

Okona.players.setIdentity(padOrIndex, { name, icon, accent }) puts the player's character on their phone's status pill: a name (≤24 characters), an icon that is either an emoji / short text or a path to an image in your build ("icons/tank.png", relative to index.html — PNG, JPG, WebP, GIF or SVG; external URLs are refused), and an accent colour (#rrggbb, the pill's dot). Okona.players.clearIdentity(pad) reverts to “Player N”; Okona.players.connected() counts seated players. Set it on join, not every frame: identity survives a park and is cleared automatically when a player is permanently removed. Out-of-spec values are truncated or dropped, never an error; the call is a no-op outside Okona.

Rumble and analytics

Okona.rumble(padOrIndex, low, high) vibrates a gamepad that supports it, with dual-motor magnitudes 0..1 for about 200 ms; phones do not vibrate. Both take a seat index or the pad object, so neither needs another pads() call. Okona.track(name, props) records a custom analytics event while the game is played in Okona — names up to 64 characters, props up to 1 KB of JSON, 500 events per page load — shown on your Analytics tab as customEvents. Counts aggregate per name per day, so use names like round_completed, not per-player values. Both return false outside Okona.

Standalone fallback (local development)

Opened outside Okona — a file in a browser, an editor's preview pane, python -m http.server — the SDK reports mode === 'standalone', pairing.available is false, and the keyboard takes P1 on its first mapped key; standard-mapping USB and Bluetooth gamepads take the next seats. The keys are the sandbox keyboard pad's: WASD left stick, IJKL right stick, arrows d-pad, Space A, Left Shift B, F X, R Y, Q / E bumpers, Z / X triggers, Tab / Enter Select / Start, C / V L3 / R3. Losing focus releases every held key. This exists so you (or an AI assistant) can test what you wrote with zero setup — never design a keyboard scheme for players.

Hosting facts

  • Inside Okona the game runs in a frame that fills the screen. Okona.isHosted is true, Okona.buildUrl is your build's base URL, and Okona.watermark is true only in the sandbox, where Okona labels the screen “Okona Sandbox”; in the Okona app nothing is drawn over your game.
  • navigator.getGamepads() is empty on purpose — every controller (phones, USB pads, the dev keyboard) arrives through Okona.pads(), so a physical pad is never seated twice. Don't use an engine's own gamepad plugin.
  • Dialogs and popups are blocked inside the frame: alert, confirm, prompt and top-level navigation do nothing. Nobody could dismiss them with a gamepad anyway.
  • The frame is an isolated origin. localStorage, sessionStorage, IndexedDB and cookies are unavailable — touching them throws, so wrap any use in try/catch and let the game run without it. The game cannot reach the page around it; it talks to Okona only through the SDK. Its own files load normally: relative URLs, and fetch/XHR work (the host sends CORS headers).
  • Assets are served relative to index.html, exactly as they are on disk — no build-time rewriting. Text assets are gzipped for you; images, audio and fonts are stored as-is. Up to 50 MB and 500 files, no dotfiles.
  • Okona.snapshot is the raw 106-byte input snapshot — the same bytes the Unity SDK reads (see Input snapshot) — for engines that would rather have bytes.
  • Old screens run old browsers. See Designing for the TV and the phone for the syntax and CSS to avoid.

Unity requirements

OkonaPad is the Unity SDK for Okona. Its whole job is to make Okona's controllers — players' phones, plus USB and Bluetooth pads on the machine showing the game — appear inside your game as ordinary Unity Gamepad devices, read through the standard Input System. It bootstraps itself; there is nothing to add to a scene.

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.

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. Edit > Project Settings > Player > WebGL > Publishing Settings → set Compression Format to Brotli. Okona takes Unity builds compressed with Brotli only; the dev harness and okona check refuse a gzip or uncompressed build and say why.
  4. That's it. The SDK registers its layout and starts pumping input automatically at load — there is nothing to add to a scene.

What the SDK does

  • 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

Upgrading from SDK v1

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.

Hosting facts

  • Inside Okona your build runs in a frame that fills the screen, an isolated origin, the same one HTML5 games get. Your game cannot reach the page around it; controllers, the join code, player identity and analytics reach it through the SDK exactly as before.
  • PlayerPrefs don't persist between page loads. The frame has no browser storage, so saves last only while the game is running. A round-based party game rarely needs them; if yours does, keep the state in memory. Unity's asset cache is off for the same reason.
  • Your own .jslib plugins keep working as long as they stay inside your game: localStorage, IndexedDB, cookies and anything on window.parent throw, so wrap any use in try/catch. Calls to window.okonaAnalyticsTrack (see Custom analytics events) are forwarded for you.
  • Dialogs and popups are blocked: alert, confirm, prompt and top-level navigation do nothing. Nobody could dismiss them with a gamepad anyway.

Unity quick start

Four steps from an empty project to a game your phone can join. Everything here is standard Unity: the SDK adds one folder and no scene wiring.

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, test, submit

  1. File > Build Settings > WebGL → Build, with Compression Format set to Brotli (see Unity requirements).
  2. Run the build in the dev harness to confirm your controls and check the performance budgets, then pair a real phone in the sandbox.
  3. Upload the build on your game's Build tab in the Developer Portal — or npx okona-cli push the folder Unity produced — and hit Submit for review; see Submit to the Okona catalog.

Players coming and going

Party games are casual, and people treat them accordingly: they join halfway through a round, put a phone down, let it lock itself, pick it back up two minutes later — or simply go home. If your game supports more than one player, 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. An HTML5 game sees the same model as pad.parked and the park / resume events (JavaScript SDK); the patterns below apply unchanged.

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. In the Okona app, every controller already connected when your game starts is seated right away, so your first frame may already have several players.

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 Okona menu opens (Home)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
If players can join mid‑game, handle this. A game that lets people drop in but never notices them dropping out accumulates abandoned players. 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 Okona 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 — drop-in party games

The moment someone parks, remove them. In a game people wander in and out of, a player who left isn't coming back this round, and anything left behind stays on screen until the game ends.

if (dev.okonaParked) RemovePlayer(player);

Pair it with mid-game joining (a join code in your own lobby) and the game 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 (Runtime: Unity WebGL), 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. To check icons before uploading, the dev harness serves the build's StreamingAssets/ folder from where Unity wrote it and shows each player's pill on their card.

Icons version with your build. Changing an icon means uploading a new build and submitting the update for review, 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.

OkonaPad 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.
Start and Home belong to Okona. In the Okona app, Start opens the Okona menu on every controller and Home goes to the TV's home screen (in a browser it opens the menu too): your game never sees either, and while the menu is open every pad reads neutral. Only in the sandbox and the dev harness do they reach your game like any other button, so don't build anything around them: no "Press Start" in a lobby; use A (or another face button) to begin and pause on the SDK's menu event.
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 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)
                        bit3    = parked (seat held, controller away)
  [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 Unity · 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. (An HTML5 game calls Okona.track(name, props) — see the JavaScript SDK; the budgets below apply to both.)

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 Unity game

Players use a phone as a controller by scanning a code. In the Okona app most of them do it before your game loads: the app shows the code on its own screens whenever no controller is connected, keeps every phone paired from one game to the next, and shows the code again in the Okona menu's Controllers (Start opens the menu over your game), and over your game when it starts with nobody connected. So drawing the code yourself is optional — but it lets people join during play without the menu covering your game (a lobby that fills up, a party game between rounds), and it is the only way a phone joins in the sandbox, where there are no Okona screens around your game. Ask Okona for the code and draw it. (An HTML5 game does the same with Okona.drawQr() — see Pairing and the join code.)

using OkonaPad;
using UnityEngine;
using UnityEngine.UI;

public class JoinPanel : MonoBehaviour
{
    public GameObject panel;
    public RawImage qrImage;
    public Text captionLabel;

    void Start()
    {
        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 a long session. (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. It can also turn false in the middle of a run: when the screen loses its connection for more than a few seconds, the code would lead nowhere, so Okona withdraws it, and a fresh one arrives (OnChanged again) once the screen is back. Hide or grey your join panel rather than showing a dead code, and make sure a controller can still start the game. Players already connected are unaffected.
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 with an actual phone.

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 (the keyboard pad in the sandbox and the dev harness — never in the Okona app). 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 join code (on the Okona app's screens, in the Okona menu, or wherever your game draws it); USB and Bluetooth pads on the machine showing the game join too (a keyboard pad exists only in the sandbox and the dev harness). In the Okona app, the app holds every controller for the whole visit and hands them to the game shell when your game starts. The game shell tracks each controller in one of six player slots and writes its state into a binary snapshot every frame. The SDK reads that snapshot — from WebAssembly memory in Unity, over a postMessage bridge in HTML5 — and drives a Gamepad for it in Unity or the pad objects from Okona.pads() in HTML5. The wire format is identical for both; see Input snapshot.

Phone / USB / BT Pad
Game Shell
window.__okonaPad
SDK (OkonaInputBridge · okona.js)
Gamepad in Unity · Okona.pads() in HTML5

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; in the Okona app, every controller already connected is seated as your game starts). If a controller goes away — a Bluetooth blip, a phone locking its screen, a player walking off — its slot is held for the whole session and flagged parked; the same player resumes in the same slot when it returns, so a drop never turns into a new join. A slot is reclaimed only when all six are taken and a new controller wants in; then the longest-parked device really is removed. The details and the patterns for handling it are in Players coming and going.

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. In HTML5 the same thing is Okona.rumble(pad, low, high). Phones do not vibrate.

Unity 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 and refreshes it when it's reissued. 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.

Build with an AI coding assistant

An AI coding assistant can build and ship an Okona game end to end: scaffold it from the starter, run it locally, check the build, push it to your sandbox and, when you say so, submit it for review. Three pieces make that reliable: the docs MCP server, so it reads this page instead of guessing; the Okona skill, which carries the SDK contract, the design rules and the workflow; and the okona CLI, which it uses to ship. None of them needs an account until the CLI logs in.

The three pieces

PieceWhereWhat it gives the assistant
Docs MCP serverhttps://okonaonline.com/mcpThis documentation, live, over the Model Context Protocol — public, read-only, no key. Three tools: list_doc_sections, read_doc_section (one section, or everything, as markdown) and search_docs. It also serves the dev-harness manual.
The Okona skillokona-skill.zip · github.com/earthcrosser/okona-game-skillAn Agent Skill: a SKILL.md with the workflow, a reference/ folder with the SDK, the TV-and-phone design rules and the publish steps, and a complete example game (Bumper Party, about 200 lines).
The starter and the CLInpm create okona-game · npx okona-cliWhat the skill scaffolds with and ships with. Node 18.17+; see Set up your development environment.
llms.txtokonaonline.com/llms.txtA machine-readable summary of everything on this page, for assistants that read a URL rather than run a tool.

Claude Code

claude mcp add --transport http okona-docs https://okonaonline.com/mcp
git clone https://github.com/earthcrosser/okona-game-skill .claude/skills/okona-game

Or unzip okona-skill.zip into .claude/skills/ — its root folder is okona-game/. Put it under ~/.claude/skills/ instead to have it in every project.

Cursor

Add to .cursor/mcp.json in your project (or the global one):

{
  "mcpServers": {
    "okona-docs": { "url": "https://okonaonline.com/mcp" }
  }
}

Unzip the skill into the project and point Cursor at its SKILL.md — add it as a rule, or reference it in your prompt.

Other clients

Any MCP client that supports the Streamable HTTP transport works — point it at the URL above with no authentication. The skill is plain markdown; any assistant that can read a file can follow it.

What to expect

With the skill installed the assistant follows the workflow this page teaches: scaffold with npm create okona-game, write the game in game.js, open it in a browser (the keyboard is P1), run npx okona-cli check, push to your sandbox with --sandbox so you can try it on your phone, and submit it for review only when you say so. Every CLI command takes --json; that is what the assistant reads.

The login handshake

The first time the assistant runs npx okona-cli login it prints a URL and an 8-character code and must stop and wait: a person opens the URL signed in to the developer portal and approves it. The publish key is then stored on that machine. For unattended use, create a publish key under Account → API Keys and set OKONA_API_KEY for the agent. Never paste a key into the game, a commit or the chat; keys can be revoked in the portal at any time. Mechanics in Publish API & CLI.

What to ask for

Describe the game in a sentence or two — “a four-player game we can play on the TV with our phones” — and the skill supplies the rest. Without the skill, the whole contract fits in one prompt: six pads from Okona.pads(), a join code from Okona.drawQr() in the lobby, identity on join, pause on park; the assistant can read the details from the MCP server or from llms.txt.

Always current. The server reads this page live, so your assistant sees the same documentation you do — including updates published after you configured it.

Designing for the TV and the phone

Whichever runtime you build in, the game is played on a screen across the room with a phone in each player's hand. A few rules follow from that, and they are worth deciding on before you write gameplay: the input surface is a pad, players come and go while a round is running, and the screen may be a TV's own browser or a small streaming device.

  • Design for the phone pad. A/B/X/Y, one stick, bumpers, a d-pad. No mouse, no keyboard, no touch on the game itself. Treat the right stick and analog triggers as optional unless you set the Controller tab's default layout to Full.
  • Up to six players, often seated before the first frame. In the Okona app players usually join before your game loads, and more join and leave mid-game. Let a late arrival in without restarting the round.
  • 16:9, large type, high contrast, no hover states — it is being read from a couch or a bar. Design at 1920×1080 logical pixels and keep what matters inside the middle 90%.
  • Short rounds. Thirty seconds to three minutes, with a results screen that leads back to the lobby by itself. People arrive, play, leave.
  • Never call alert() or open popups: the runtime blocks them, and nobody can dismiss a dialog with a gamepad anyway.
  • Old screens run old browsers. TV browsers and webviews can be pre-2020 Chromium: ES2015 syntax, no CSS inset, no flex gap, and for HTML5 no Brotli-only assets. Bundle your libraries rather than loading them from a CDN so a network that blocks one can't break the game.

Seats are held

Okona has six seats. A controller takes the lowest free one and keeps it for the whole session; when it goes away — a phone locks, a pad sleeps, a player walks off — the seat is held and flagged parked, and the same person gets the same player back when they return. A seat is reclaimed only when all six are taken and a new controller wants in. So: freeze or fade a parked player, never delete them on the spot, and never let a parked player block “all players ready”. The three patterns that cover most games — drop immediately, grace period, hold the seat — are in Players coming and going (Unity, okonaParked) and apply unchanged to HTML5 (pad.parked, the park / resume events).

The join code

The Okona app gets players in: it shows a large join code on its own screens whenever no controller is connected, over your game when it starts with nobody connected (until a player starts), and in the Okona menu's Controllers (Start opens the menu), so a game is never left with no way to join. Draw the code in your lobby too if people should be able to join mid-game without the menu covering your game; it is also the only way a phone joins in the sandbox. Show it large while the room is empty, small once people are in, hidden at six, and redraw it whenever it is reissued. When there is no code at all, hide the panel and let a controller start the game anyway. Unity: Phone pairing in your Unity game. HTML5: Pairing and the join code.

Tell the phone who they are

When a player picks a character, push its name, icon and colour to their phone so the controller in their hand becomes their controller. Do it on join and on change, not every frame. Unity: Players. HTML5: Okona.players.setIdentity in the JavaScript SDK.

Start and Home belong to Okona

In the Okona app, Start opens the Okona menu on every controller (on a TV remote, Back does) and Home goes to the TV's home screen, as in every app on the TV; in a browser Home opens the menu too. The Okona join code also comes up by itself whenever no controller is connected (the game starts with nobody, or everyone has been gone for ten seconds). Your game never sees Start or Home, every pad reads neutral while the menu is open, and HTML5 games get the menu event if they want to pause. Don't build anything around them: begin a round on A, never "Press Start", and don't build your own pause menu on Start. (In the sandbox, both reach your game like any other button.)

Performance on low-end hardware

Okona runs in a browser or webview on whatever the screen is: a TV's own browser, a streaming stick, a small ARM box — rarely a gaming PC. A game that runs at 60 fps on your machine can crawl there, so the dev harness grades your GPU workload against budgets measured on Pi-5-class hardware, a low-end reference. See the budget table in Testing your game.

Testing your game

Two tools, for two different questions. The Okona Dev Harness runs your build on your own machine exactly as the Okona runtime serves it — the same game shell, the same controller input path — and answers “does it work, and will it perform on a low-end screen?” with no upload. The sandbox is a private hosted copy with a real relay room and answers “does it work with real phones?”. Both take a Unity build or an HTML5 folder.

Run it in the harness

  1. Build your game. Unity: a WebGL build with Brotli compression (see Unity requirements). HTML5: the folder with index.html at its root — the starter works as-is.
  2. Download the harness and unzip it anywhere. It needs Python 3.7+ and nothing else.
  3. From the unzipped okona-harness/ folder, point it at your build — the folder Unity produced (containing index.html and a Build/ subfolder) or your HTML5 folder; the script detects which:
    python devtools/okona-test/okona-serve.py <path-to-your-build>
    Options: --port 8080 (the default), --no-open to skip launching a browser, --threads only for a multithreaded Unity build.
  4. A browser opens with your game running. Plug in a USB or Bluetooth controller and play; the keyboard is P1 too. Press ` (backtick) to toggle the diagnostics overlay.

The serve script sets the HTTP headers a Brotli Unity build requires — the reason a plain static server usually fails to load one.

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.

Phone players and per-player identity

Real phones can't scan into the harness (there's no relay room on your desktop), so the JOIN AS card at the left of the strip seats a scripted controller of any kind — Phone, Gamepad or Keyboard — with no hardware. It takes the genuine path (the pad appears in navigator.getGamepads() just as a relay phone does), so your game sees a phone player. Its card gains A (tap the join button), Park / Resume (the controller goes away and comes back — the real walk-away path, see Players Coming and Going) and Remove (permanent).

When your game calls Players.SetIdentity (Unity) or Okona.players.setIdentity (HTML5), the card shows a replica of the phone's status pill — the 16 px round icon or inline emoji, the name (truncated at 24 characters), the accent tinting the dot — plus a log of when the identity was set or cleared relative to the slot's last join, park or resume. Identity should survive a park and be cleared only on removal; a game that clears it on park shows cleared … after parked right there. Icons load from your build — a Unity build's StreamingAssets/ folder (Unity writes it beside Build/; the harness serves it where hosting will), or a path relative to an HTML5 build's index.html — so a build whose icons show here shows them on phones once uploaded. Note that on a live run the phone's pill refreshes on a roughly one-second poll, not the same frame — the harness pill follows that cadence by default. The buttons wrap a small HarnessPads console API (addPhone(), tap(), park(), resume(), remove(), identity()) for scripted checks; the harness README has the details.

Check performance against low-end hardware

Many of the screens Okona runs on are modest ARM devices — TVs' own browsers, streaming sticks, Android boxes — not gaming PCs. A game that hits 60 fps on your development machine can run far slower there, 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; a low-end device'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 game on a real low-end screen — a TV's browser running your sandbox, say. For an HTML5 game the workload rows read n/a — the harness cannot see inside the game frame yet — so use the input strip here and the real screen for the verdict.

Harness or sandbox?

The harness has no relay room, so real phones can't join it: Pairing.IsAvailable is false and Okona.drawQr() paints its placeholder, which your game must handle anyway. It is the fastest loop — edit, rebuild, refresh — and the only place you get the performance verdict and scripted players.

The sandbox is your game hosted privately with a real relay room. Deploy the build you have uploaded with Play In Sandbox on your game's page in the portal, or from the terminal with npx okona-cli push <dir> --game <id> --sandbox (or npx okona-cli sandbox <id> for a build already uploaded). Open the returned link signed in to the portal as the game's owner — the code on screen scans for real, so this is where you check the join flow, the identity pill and a full room of phones before anyone else sees the game. Harness for iteration and the performance verdict; sandbox for real pairing and another pair of hands.

Before you submit

  • Game loads and is playable in the harness (Unity: a 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.
  • If you set per-player identity: join as Phone, the pill reads right (icon loads, name fits), and Park doesn't clear it — only Remove should.
  • If your game draws a join code: it hides when none is available and refreshes when the code changes.
  • A real phone pairs in the sandbox and plays. The sandbox has no Okona screens, so the phone scans the code your game draws.
  • Workload metrics are green or amber — not red (Unity).
  • Rumble fires on supported pads (if your game uses it).
  • The Controller tab's layout gives the phone every control your game reads (Full if it uses the sticks or triggers).

Submit to the Okona catalog

Games reach players through the Okona catalog, and every game is reviewed before it goes in. It works the same for both runtimes: upload the build on the game's Build tab (or push it with the CLI), try it in the sandbox, press Submit for review, and Okona approves it into the catalog or sends it back with exactly what to change. This section covers the review, the Controller tab, and how to update or remove a game.

From the portal

  1. New Game on the Games page. Give it a title, a description and an icon — all three are required to submit.
  2. On the Build tab pick the runtime. Unity WebGL: drop the four build files (loader.js, data.br, framework.js.br, wasm.br — the portal matches them from Unity's names) and, if your game uses Addressables or ships player-icon images, the build's StreamingAssets/ folder. HTML5: drop the folder that contains index.html (up to 50 MB and 500 files, no dotfiles). Text assets are gzipped for you; images, audio and fonts are stored as-is; your relative asset paths are untouched.
  3. Save draft, then Play In Sandbox to try the upload with real phones.
  4. Submit for review. The game shows In review.

The review

Submitting freezes the build: what Okona reviews is exactly what goes into the catalog, and a game in review can't be edited (withdraw it from the game page's … menu if you need to change something). You get an email either way:

  • Approved — the game is in the Okona catalog; its status reads In the Okona app.
  • Changes requested — the reason is in the email and on the game's page. Edit the game and submit it again.

There are no public game links: players find and play your game in Okona. Once it's in the catalog, the game's page in the portal links to its page in the Okona app, where players see it “by” your studio name. In the app it plays the way it does for every player — with Okona Plus — while the sandbox stays free for testing every version.

From a terminal

The same submit, without the portal: npx okona-cli push <dir> --game <id> --icon icon.png --publish (--publish submits for review). See Publish API & CLI.

The Controller tab

Every game has a Controller tab on its page in the portal, and every game picks its phone-pad layout there — the phone shows exactly the controls your game uses, and players cannot switch away from it. The setting applies to the next phone that connects, with no review needed:

  • Basic — D-pad, four face buttons, Select/Start. What a game gets until you choose otherwise.
  • Full — adds both sticks, triggers and shoulders. Pick it if your game reads the sticks or triggers; on Basic those controls don't exist on the phone.
  • XL — an oversized D-pad and A/B only, easiest for first-time players. Pick it if that's all your game needs.

Test it before submitting with Play In Sandbox: the sandbox's phones get the same layout. From a terminal or an agent, okona controller <id> <basic|full|xl> or controller on the Publish API sets the same thing.

The same tab says how your game is best played. Leave it on Phones work great, or pick Best with a gamepad for a fast or precise game that is hard to play well on a touchscreen: the Okona app then says so on your game's page, and shows players a short note when the game starts with only phones connected. It is a recommendation, not a requirement — phones always still work — and like the layout it applies at once, with no review.

Updating a game in the catalog

Edit game on its page opens it for editing; nothing changes for players until your update is approved. Saving marks it In the app — updating. Submit for review sends the update to Okona while players keep the version they have; approval swaps it in. Discard update throws the edits away (also after an update is sent back).

Remove from the Okona app takes the game out of the catalog; to bring it back, submit it again. A draft that has never been in the catalog can be deleted.

Publish API & CLI

Ship a game without opening the portal: create it, upload the build and submit it for review from a terminal, a CI job, or an AI coding assistant. The okona CLI wraps the API; both use a publish key from Account → API Keys.

The npm package is okona-cli; the command it installs is okona. npx okona-cli … runs it without installing anything; after npm i -g okona-cli the same commands are plain okona …. Node 18.17+, zero dependencies.

The CLI

npx okona-cli login                                    # approve a code in the portal → a publish key lands on this machine
npx okona-cli create --title "Bumper Party" --description "Six cars, one arena." --runtime html5
npx okona-cli controller <id> full                      # the phone pad's layout (basic | full | xl) — no republish
npx okona-cli check ./dist                             # build shape, size caps, SDK tag, dialogs
npx okona-cli push ./dist --game <id> --icon icon.png --sandbox    # real phones in your private sandbox first
npx okona-cli push ./dist --game <id> --icon icon.png --publish    # submit for review
npx okona-cli update <id> --keywords "party, cars" --screenshots ./shots --publish   # everything the portal's editor sets
npx okona-cli game <id>                                # what the portal has: status (and Okona's reason), fields, icon, screenshots, build

push takes an HTML5 folder (index.html at its root) or the folder Unity produced (Brotli build). Files go straight to storage over signed URLs, in parallel, so a 50 MB build takes seconds; then --publish submits it for review exactly like the portal's button, or --sandbox deploys to your private sandbox first. If the sandbox step fails after the upload, the build is already saved: run okona sandbox <id> to retry it without uploading again. (CLI versions before 0.4.0 print “Published”; the server submits for review either way.) Add --json to any command for machine-readable output — that is what an AI agent reads. OKONA_API_KEY in the environment overrides the saved key for CI.

update is the portal's editor from the terminal: --title, --description, --genre, --keywords "a, b", --youtube URL, --players 2-6, --play-time under-5, --icon icon.png, --screenshots <folder | a.png,b.png> (up to six; a folder's images go in name order) and --clear-screenshots. The screenshot list is declarative: what you send replaces what is there, in that order — re-send to reorder. Changing a game that's in the catalog starts an update, as in the portal; add --publish to submit it in the same command. --json here and on game returns the full record, media URLs included.

okona.json — the catalog next to the build

{
  "game": "<id>",                       // written by `okona create`
  "title": "Bumper Party",
  "description": "Six cars, one arena.",
  "genre": "multiplayer-party",
  "keywords": ["party", "cars"],
  "youtubeUrl": "",
  "controller": "full",                   // basic | full | xl
  "runtime": "html5",                    // html5 | unity: what `okona create` makes (default html5)
  "playersMin": 2, "playersMax": 6,       // optional, 1–6
  "playTime": "under-5",                // optional: under-5 | 5-15 | 15-30 | 30-plus
  "icon": "icon.png",                    // paths are relative to this file
  "screenshots": "shots",                 // a folder (images in name order) or ["shots/1.png", …]
  "build": "."                            // the build folder (default: this one)
}

With an okona.json in the folder, okona create reads it and writes the new id back, and okona push . needs no --game: it syncs the fields that changed, the icon and the screenshots, then the build — one command ships the whole game. okona update with no id does the same without a build upload. Flags always win over the file; okona check validates it. The manifest, the icon and the screenshots are never uploaded as part of the build. npm create okona-game scaffolds one.

Logging in from an agent. okona login prints a URL and an 8-character code; a person opens the URL — the approval page is /developers/cli.html — signed in to the developer portal, and approves it. The key is then stored in ~/.okona/config.json. For unattended use, create a publish key in the portal once and set OKONA_API_KEY.

The API

Base URL https://okonaonline.com/api/v1, header Authorization: Bearer ok_live_… (a publish key), JSON in and out. A key acts for its studio; a game outside it is a 404. 60 requests per minute per key.

EndpointWhat it does
GET /gamesThe studio's games: id, title, runtime, status (draft | in-review | changes-requested | live | updating), rejectionReason (while changes are requested), link (the game's page in the Okona app once it's in the catalog, else null), build.
POST /games{ title, description, runtime: "html5" | "unity", genre?, keywords?, youtubeUrl?, controller?, playersMin?, playersMax?, playTime? } → a draft. Title and description are required to submit.
GET /games/{id} · PATCH /games/{id}Read the full record (keywords as a list, youtubeUrl, icon and screenshots as { name, url }, controller, build); update title, description, genre, keywords (a list or a comma-separated string, ≤20), youtubeUrl (a YouTube link or ""), runtime, and the optional party facts playersMin / playersMax (whole numbers 1–6, the fewest and most players; null clears) and playTime (under-5 | 5-15 | 15-30 | 30-plus minutes, or ""). Patching a game that's in the catalog starts an update (players keep the approved version). controller — the phone pad's layout and the gamepad recommendation, "full" or { layout: "basic" | "full" | "xl", gamepadRecommended: true | false } (either key alone is fine) — is the Controller tab over the API: presentation fields that apply at once and never start an update. Read them back as game.controller.layout (a game that never chose reads basic) and game.controller.gamepadRecommended.
POST /games/{id}/build{ runtime, files: [{ path, size }], icon?: { name, size } } → one signed PUT URL per file (20-minute expiry) plus the headers to send. Each size is the file's exact size in bytes: storage refuses an upload bigger than it. HTML5: the tree, index.html at the root, ≤50 MB, ≤500 files. Unity: exactly loader.js, data.br, framework.js.br, wasm.br (rename from Unity's base name) plus StreamingAssets/…; each file ≤512 MB, the build ≤1 GB and ≤1000 files. Icon: png/jpg/webp/gif ≤2 MB. The previous staged build is cleared first.
POST /games/{id}/build/commitSame body once every file is uploaded → the game records the build (sizes are read from storage, not trusted from the request; a file larger than declared is removed and the commit refused).
POST /games/{id}/media{ icon?: { name, size }, screenshots?: [{ name, size }] } → a signed PUT URL per image (20-minute expiry) with its headers and storagePath. Icon: png/jpg/webp/gif ≤2 MB, replaces the current one. Screenshots: up to 6, each ≤5 MB; the list is declarative — it becomes the game's list in that order, [] clears, absent leaves it alone.
POST /games/{id}/media/commit{ icon?: { name, storagePath }, screenshots?: [{ name, storagePath }] } using the paths the plan returned, once every image is uploaded → the game records them and replaced images are removed. On a live game this starts an update, like a catalog patch.
POST /games/{id}/publishSubmit for review (the build is frozen; Okona approves it into the catalog or sends it back with a reason). Same rules as the portal: title, description, complete build, icon, the live-game ceiling; from draft or changes-requested only (409 otherwise). Returns submitted: true.
POST /games/{id}/unpublish · POST /games/{id}/sandboxRemove the game from the Okona catalog; deploy the staged build to the key owner's sandbox (open the returned URL signed in to the portal).

Errors are { "error": { "code", "message" } } with the matching HTTP status: 400 for a refused body or a submit precondition (the message says which), 401 for a bad key, 404 for a game outside your studio, 409 for a game that can't be submitted right now (already in review, say), 429 past the rate limit or the live-game ceiling.

Upload, step by step

# 1. plan
curl -s -X POST https://okonaonline.com/api/v1/games/$ID/build \
  -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{"runtime":"html5","files":[{"path":"index.html","size":6900}],"icon":{"name":"icon.png","size":4100}}'
# 2. PUT each file to its url with BOTH returned headers (they are signed into the URL)
curl -s -X PUT "$UPLOAD_URL" -H "Content-Type: text/html; charset=utf-8"   -H "x-goog-content-length-range: 0,6900" --data-binary @index.html
# 3. commit with the same body, then submit for review
curl -s -X POST https://okonaonline.com/api/v1/games/$ID/build/commit -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" -d @plan.json
curl -s -X POST https://okonaonline.com/api/v1/games/$ID/publish -H "Authorization: Bearer $KEY"

Screenshots follow the same three steps against /media and /media/commit. Games created this way appear in your dashboard like any other and can be edited there afterwards — and anything the portal's editor sets can be set here.

Troubleshooting

The problems developers hit most, for each runtime. If yours isn't here, the dev harness overlay and the input strip usually show where a control or a player is going astray.

Unity: 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.

Unity: 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>.

Unity: 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. Phones never vibrate.
  • HTML5: Okona.rumble(pad, low, high) with magnitudes 0–1.

Unity: WebGL build issues

  • Ensure Input System (New) is enabled in Project Settings.
  • Compression Format must be Brotli — the dev harness and okona check tell you if it isn't.
  • Don't include server-side / networking code — WebGL can't run sockets, and the SDK needs none.

HTML5: Okona is undefined, or the SDK script 404s

  • Load the SDK from its absolute URL: <script src="https://okonaonline.com/sdk/okona.js"></script>. A relative /sdk/okona.js resolves against wherever your build happens to be served from and breaks off-platform.
  • Put the tag before the script that uses Okona, and start reading pads inside Okona.ready(fn).

HTML5: navigator.getGamepads() is empty inside Okona

  • By design. Every controller — phones, USB pads, the dev keyboard — arrives through Okona.pads(), so a physical pad is never seated twice. Turn off your engine's own gamepad plugin and read the pad objects in its update loop.

HTML5: the game only sees the keyboard

  • You are in standalone mode — the file was opened outside Okona, so there is no relay and no phones. That is the local-development fallback working as intended. For real phones use the sandbox.
  • In the Okona app the keyboard doesn't drive a player, also by design: players use phones and gamepads.

HTML5: okona check or the Build tab refuses the folder

  • “No index.html at the root” — push the folder that contains index.html directly, not its parent (a dist/ inside my-game/ means pushing my-game/dist).
  • Over 50 MB or 500 files — drop unused assets and source maps; dotfiles and hidden folders are skipped automatically.

HTML5: sticks are inverted, or alert() does nothing

  • axes.ly is +Y down, the Web Gamepad and screen-space convention. Unity's leftStick is +Y up; if you port code between them, flip it once.
  • alert, confirm and prompt are blocked inside the game frame. Draw messages on the canvas; nobody could dismiss a dialog with a gamepad anyway.

Works on your machine, not on the TV

  • A library loaded from a CDN at runtime is the usual cause — some networks block or throttle unfamiliar hosts. Bundle every dependency into the build folder.
  • Older TV browsers and webviews (pre-2020 Chromium) reject modern syntax and CSS. Ship ES2015, avoid CSS inset and flex gap, and test on the actual screen. See Designing for the TV and the phone.

Ready to ship?

Run it in the dev harness, pair a real phone in the sandbox, and submit it to the Okona catalog.

Back to Okona