OKONAONLINE
Developer Docs
Developer Portal →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.
| Path | What you build | You need | Start |
|---|---|---|---|
| HTML5, any engine | A 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 |
| Unity | A 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 assistant | An 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.
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 withnpx; 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
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.
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,updateanddraw, plus theonJoin/onLeave/onPark/onResumehooks. 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
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.
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.| Field | Type | Meaning |
|---|---|---|
slot / index | 1–6 / 0–5 | The player's seat. Seats are held while a controller is away — nobody is renumbered. |
connected | bool | A controller holds this seat. |
kind | 'phone' | 'gamepad' | 'keyboard' | null | What the player is holding. Input is identical either way. |
parked | bool | The 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. |
buttons | object of bools | a b x y lb rb lt rt select start l3 r3 up down left right home |
pressed(name) / justPressed(name) | function | Held now / went down since your last pads() call. |
axes | {lx, ly, rx, ry} −1..1 | Sticks. +Y is down, like the Web Gamepad API and screen space. |
triggers | {lt, rt} 0..1 | Analog triggers. |
mask | uint32 | The 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.modeis'live'(in the Okona app),'sandbox'or'standalone'(opened outside Okona).Okona.ready(fn)runs immediately if already ready. Until it fires,modereads'hosted'inside a frame — wait forreadybefore branching on it, and start your frame loop there.join/leave— a seat is taken / permanently freed. Set the player's identity onjoin.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.menuOpenholds 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.isHostedistrue,Okona.buildUrlis your build's base URL, andOkona.watermarkistrueonly 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 throughOkona.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,promptand 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 intry/catchand 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, andfetch/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.snapshotis 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.
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
- Install the Input System package via Package Manager.
Edit > Project Settings > Player > Other Settings→ set Active Input Handling to Input System (New) (or Both).Edit > Project Settings > Player > WebGL > Publishing Settings→ set Compression Format to Brotli. Okona takes Unity builds compressed with Brotli only; the dev harness andokona checkrefuse a gzip or uncompressed build and say why.- 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
Gamepaddevices - Up to 6 players, each in their own slot
- Full controller surface: face buttons, bumpers, both sticks (incl. L3/R3), D-pad, triggers
- Zero-allocation input via WebAssembly memory (no GC hitches)
- Standard rumble through
Gamepad.SetMotorSpeeds() - Self-bootstrapping — no GameObject, no component, no scene wiring
- Works in the Editor with any pad; live with players' phones
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.
| Area | v1 (retired) | v2 |
|---|---|---|
| Device type | Custom OkonaPadControllerDevice (not a Gamepad) | OkonaPadDevice : Gamepad |
| Binding paths | <OkonaPad>/buttonA | <Gamepad>/buttonSouth |
| Transport | SendMessage + JSON + Base64 per frame | Binary snapshot read from HEAPU8 (zero alloc) |
| Setup | Add an OkonaInputBridge GameObject named exactly | None — the bridge self-bootstraps |
| Controls | A/B/X/Y, Start/Select, D-pad, left stick | Everything a Gamepad has, both sticks, analog triggers |
| Rumble | Custom static event | Standard Gamepad.SetMotorSpeeds() |
<Gamepad>/ bindings for Editor testing, but on Okona every controller arrives as an OkonaPadDevice — which is a Gamepad, so your bindings are unaffected.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.
PlayerPrefsdon'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
.jslibplugins keep working as long as they stay inside your game:localStorage, IndexedDB, cookies and anything onwindow.parentthrow, so wrap any use intry/catch. Calls towindow.okonaAnalyticsTrack(see Custom analytics events) are forwarded for you. - Dialogs and popups are blocked:
alert,confirm,promptand 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.
Import the SDK
Download OkonaPad.unitypackage and import it (Assets > Import Package > Custom Package…). It drops a single Assets/OkonaPad/ folder into your project. There is no component to add and no GameObject to create — the bridge self-bootstraps via [RuntimeInitializeOnLoadMethod].
Bind to the standard Gamepad
In your Input Actions asset, bind to <Gamepad>/ paths exactly as you would for any Unity game — <Gamepad>/buttonSouth, <Gamepad>/leftStick, <Gamepad>/rightTrigger, and so on. An OkonaPadDevice is a Gamepad, so these match on Okona and in the Editor with any pad you plug in.
<Gamepad>, not <OkonaPad>. A control scheme that requires <OkonaPad> will reject native pads in the Editor, so a joined player is silently dropped. Use <Gamepad> and everything works in both places.Read input
Use the Input System exactly as normal — PlayerInput / PlayerInputManager (recommended, see below), an InputActions asset, or direct polling:
using UnityEngine;
using UnityEngine.InputSystem;
public class Player : MonoBehaviour
{
void Update()
{
var pad = Gamepad.current; // an OkonaPadDevice on Okona
if (pad == null) return;
if (pad.buttonSouth.wasPressedThisFrame) Jump();
Vector2 move = pad.leftStick.ReadValue();
float gas = pad.rightTrigger.ReadValue();
}
}
Build, test, submit
File > Build Settings > WebGL→ Build, with Compression Format set to Brotli (see Unity requirements).- Run the build in the dev harness to confirm your controls and check the performance budgets, then pair a real phone in the sandbox.
- Upload the build on your game's Build tab in the Developer Portal — or
npx okona-cli pushthe folder Unity produced — and hit Submit for review; see Submit to the Okona catalog.
Recommended pattern: PlayerInputManager
For both single- and multi-player, the cleanest setup is Unity's PlayerInputManager with "Join Players When Button Is Pressed". Each controller that presses a button spawns one player. Because OkonaPads are real Gamepads, this is identical to how you'd support local multiplayer in any Unity game — and it runs unchanged in the Editor with whatever pads you have on your desk. Players leaving works differently on Okona than on a desktop, though — see Players Coming and Going.
Setup
- Create an Input Actions asset with a control scheme that requires
<Gamepad>, and bind your actions to<Gamepad>/…paths. - Make a Player prefab with a
PlayerInputcomponent pointing at that asset (set its default scheme + default map). - Add a
PlayerInputManagerto a scene object; set Join Behavior to Join Players When Button Is Pressed and point Player Prefab at your prefab. - If you subscribe to join/leave in C#, set the manager's Notification Behavior to Invoke C# Events (the
onPlayerJoined/onPlayerLeftevents only fire in that mode).
using UnityEngine;
using UnityEngine.InputSystem;
using OkonaPad;
// One of these is spawned per joined controller.
[RequireComponent(typeof(PlayerInput))]
public class PlayerController : MonoBehaviour
{
PlayerInput _input;
void Awake()
{
_input = GetComponent<PlayerInput>();
// The Gamepad this player owns (an OkonaPadDevice on Okona).
var pad = _input.GetDevice<Gamepad>();
// Optional: the 0-based Okona slot (P1 = 0 … P6 = 5).
if (pad is OkonaPadDevice okona)
Debug.Log($"Joined as player slot {okona.okonaSlot}");
}
// Bound to a "Fire" action in the Input Actions asset.
public void OnFire(InputAction.CallbackContext ctx)
{
if (ctx.performed) Fire();
}
void Rumble()
{
// Standard Gamepad rumble — forwarded to the physical pad.
(_input.GetDevice<Gamepad>())?.SetMotorSpeeds(0.4f, 0.8f);
}
}
onDeviceLost is not how players leave on Okona. In a desktop game, unplugging a pad loses the device and PlayerInput.onDeviceLost fires. On Okona a controller that goes away keeps its device on purpose, so that callback stays silent — it fires only in the rare slot-reclaim case. Use it if you like as a belt-and-braces cleanup, but if your game needs to notice people leaving, read Players Coming and Going first.// Fires only when a slot is genuinely reclaimed — not on an ordinary walk-away.
_input.onDeviceLost += pi => Destroy(pi.gameObject);
Players coming and going
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 happens | What your game sees |
|---|---|
| A controller connects | A new OkonaPadDevice is added — InputDeviceChange.Added, PlayerInputManager join, the usual |
| Its controller goes away | Nothing is removed. The device stays, its input reads neutral, and okonaParked becomes true |
| It comes back | Same slot, same device instance, okonaParked back to false. No join event — they never technically left |
| The 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
okonaParked == false for everybody, or opening the menu would eject the whole room. Read the flag; never infer departure from silence.When they come back
A returning controller reclaims its own slot and its own device instance. Your reference stays valid the whole time — there is nothing to re-acquire, and input simply resumes.
If you dropped the player while they were parked, watch for the flag going the other way and seat them again:
// Poll once per frame; act on the edges, not the level.
bool parked = dev.okonaParked;
if (parked && !_wasParked) OnPlayerLeft(player);
if (!parked && _wasParked) OnPlayerReturned(player); // same slot, same device
_wasParked = parked;
How long a controller can stay parked is unbounded — a pad can nap for an entire session and still come back as itself. Okona will only take the seat away in one case, below.
The one case where a device really goes away
Parked slots are reclaimed lazily: if all six slots are taken and a new controller wants in, the slot that has been parked the longest is freed for it. That player's device is removed, so you get the standard InputDeviceChange.Removed and onDeviceLost — the only situation in which you do.
A game that handles both signals is robust in every case: okonaParked for the ordinary walk-away, device removal for the reclaim.
Choosing a pattern
None of the following is enforced — the platform reports the facts and stays out of the way. Pick the one that matches how your game is played.
1. Drop immediately — 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
Players
static class OkonaPad.Players
Show each player's character on their phone controller. When a phone pairs it says “Player 3”; once your game knows who they are, tell their phone — a name, an icon, an accent colour — and the controller in their hand becomes their controller:
// P1 (slot 0) picked the blue fish:
Players.SetIdentity(0, "Splash", "icons/bluefish.png", "#3b82f6");
// or an emoji instead of an image:
Players.SetIdentity(0, "Splash", "🐟", "#3b82f6");
Members
| Member | Type | Description |
|---|---|---|
SetIdentity(slot, name, icon, colorHex) | void | Show this character on the player's phone. slot is the 0-based player slot (P1 = 0, same as rumble); icon and colorHex are optional |
ClearIdentity(slot) | void | Back to the plain “Player N” pill |
Field rules
Out-of-spec values are truncated or dropped — never an error:
| Field | Rules |
|---|---|
name | Shown next to the icon; truncated past 24 characters |
icon | Either an emoji (any short string), or a relative path to an image shipped in your build's StreamingAssets (png/jpg/webp/gif/svg) — e.g. "icons/bluefish.png"; see Shipping icon images below. External URLs are not supported |
colorHex | Optional #rrggbb; tints the identity chip on the phone. Anything else is ignored |
Shipping icon images
An image icon travels with your game build — there is no separate icon upload and no URL to host: the icon is published game content, served from your game's own hosted build. Two steps, one in Unity and one on the portal.
1. In Unity — put the images in your project's Assets/StreamingAssets/ folder (create the folder if it doesn't exist; the name is special to Unity). Keep them small — the pad shows them at chip size, so something like a 128×128 PNG is plenty:
Assets/StreamingAssets/icons/bluefish.png
Assets/StreamingAssets/icons/octopus.png
Unity copies that folder verbatim into every WebGL build's output as a StreamingAssets/ folder next to Build/. In code, pass the path relative to StreamingAssets:
Players.SetIdentity(0, "Splash", "icons/bluefish.png", "#3b82f6");
2. On the developer portal — when you upload the build on your game's Build tab (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.
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
| Member | Type | Description |
|---|---|---|
okonaSlot | int | 0-based player slot (P1 = 0 … P6 = 5); -1 until assigned |
okonaKind | OkonaPadState.PadKind | What this player is holding: Gamepad, Phone, or Keyboard. Input is identical either way — see Phone Pairing In Your Game |
okonaParked | bool | This player's controller has gone away, but their slot is held so they get the same player back if they return. The device stays alive on purpose — polling this is the only way to notice somebody leaving; see Players Coming and Going |
SetMotorSpeeds(low, high) | void | Standard Gamepad rumble; forwarded to the physical pad |
PauseHaptics() / ResetHaptics() | void | Stop rumble (sends 0,0) |
Inherited from Gamepad: buttonSouth/East/West/North, leftShoulder, rightShoulder, leftTrigger, rightTrigger, select, start, leftStick, rightStick, leftStickButton, rightStickButton, dpad, plus Gamepad.all and Gamepad.current.
OkonaInputBridge
static class OkonaPad.OkonaInputBridge
The static driver that pulls the input snapshot from the web layer each frame and adds/removes OkonaPadDevices as players connect and disconnect. It bootstraps itself at load — you do not create or reference it for normal use.
Members
// The connected device for a 0-based slot, or null.
public static OkonaPadDevice GetDevice(int slot);
// Pull the current snapshot and push it to the Input System. Runs every
// frame automatically (InputSystem.onBeforeUpdate); also callable directly
// for deterministic tests.
public static void Pump();
// Editor/test only: inject a snapshot as if it came from the web layer.
#if UNITY_EDITOR
public static void SimulateSnapshot(byte[] snapshot);
#endif
window.__okonaPad), so no OkonaPadDevices are created and Pump() no-ops. You test with native Unity gamepads, which work because your bindings target <Gamepad>. In a WebGL build the bridge manufactures OkonaPadDevices from the live snapshot.Controls & bindings
OkonaPad maps the standard controller to the standard Unity Gamepad controls. Bind to the <Gamepad>/ paths below.
| Control | Binding path | Notes |
|---|---|---|
| A (bottom) | <Gamepad>/buttonSouth | |
| B (right) | <Gamepad>/buttonEast | |
| X (left) | <Gamepad>/buttonWest | |
| Y (top) | <Gamepad>/buttonNorth | |
| Left bumper | <Gamepad>/leftShoulder | |
| Right bumper | <Gamepad>/rightShoulder | |
| Left trigger | <Gamepad>/leftTrigger | Analog 0–1 |
| Right trigger | <Gamepad>/rightTrigger | Analog 0–1 |
| Select / View | <Gamepad>/select | |
| Start / Menu | <Gamepad>/start | |
| Left stick | <Gamepad>/leftStick | Vector2 |
| Right stick | <Gamepad>/rightStick | Vector2 |
| L3 (stick press) | <Gamepad>/leftStickPress | |
| R3 (stick press) | <Gamepad>/rightStickPress | |
| D-pad | <Gamepad>/dpad | /dpad/up etc. |
menu event.<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:
| Bit | Button |
|---|---|
| 0–3 | A, B, X, Y |
| 4–5 | LB, RB |
| 6–7 | LT, RT (digital) |
| 8–9 | Select, Start |
| 10–11 | L3, R3 |
| 12–15 | D-pad Up, Down, Left, Right |
| 16 | Guide (reserved) |
Custom analytics events 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");
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;
}
}
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
| Member | Type | Description |
|---|---|---|
IsAvailable | bool | Whether there's a code to show right now |
Url | string | The address a phone opens; "" when unavailable |
Caption | string | How the phone reaches this session, ready to print under the code — “On the same Wi‑Fi” or “Uses your phone's internet” |
OnChanged | event Action<string> | Raised on the main thread whenever the code changes, appears, or goes away. Argument is the new Url |
GetQrTexture() | Texture2D | The code as a 512×512 texture, ready for a RawImage; null when unavailable |
IsAvailable == false. There's a moment at startup on the web before the code exists, it can be permanently unavailable if the player's browser blocks it, and a session may not be offering phone pairing at all. 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.Destroy it. Decoding happens once per new code, not per frame. It's drawn black-on-white with the required quiet zone and point filtering, so it scans reliably even blown up on a large screen. Don't tint it, and keep something light behind it.GetQrTexture() returns a QR-shaped image that is deliberately not scannable and Url is a stub. It's there so you can build and preview your join screen. Test the real thing in the sandbox 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.
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.
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
PlayerInputManagerscene that spawns one on-screen controller widget per joined pad and lights up every input (face buttons, bumpers, sticks, L3/R3, D-pad, analog triggers). Drop in up to six pads and watch them light independently. - Player prefab + Input Actions — a bare
PlayerInputprefab and an actions asset with a<Gamepad>control scheme you can copy straight into your game. OkonaJoinPanel.cs— a copy-paste “scan to join” panel: wire up your own image and label, and it shows the pairing code 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
| Piece | Where | What it gives the assistant |
|---|---|---|
| Docs MCP server | https://okonaonline.com/mcp | This 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 skill | okona-skill.zip · github.com/earthcrosser/okona-game-skill | An 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 CLI | npm create okona-game · npx okona-cli | What the skill scaffolds with and ships with. Node 18.17+; see Set up your development environment. |
| llms.txt | okonaonline.com/llms.txt | A 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.
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 flexgap, 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
- Build your game. Unity: a WebGL build with Brotli compression (see Unity requirements). HTML5: the folder with
index.htmlat its root — the starter works as-is. - Download the harness and unzip it anywhere. It needs Python 3.7+ and nothing else.
- From the unzipped
okona-harness/folder, point it at your build — the folder Unity produced (containingindex.htmland aBuild/subfolder) or your HTML5 folder; the script detects which:
Options:python devtools/okona-test/okona-serve.py <path-to-your-build>--port 8080(the default),--no-opento skip launching a browser,--threadsonly for a multithreaded Unity build. - 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) | Budget | Why it matters |
|---|---|---|
| Draw calls | ≤ 80 | Each has fixed CPU cost; a low-end device's CPU is the bottleneck |
| Buffer uploads | ≤ 2 MB | Per-frame dynamic vertex/index data stalls the GPU |
| Vertices | ≤ 100K | Raw geometry throughput |
| Render-target switches | ≤ 8 | Tiler GPUs pay heavily per pass (shadows, post stacks) |
Green is comfortable, amber is at budget, red is over. If you're in the red, reduce draw calls (batch/atlas), cut per-frame mesh rebuilds, and avoid realtime shadows and full-screen post-processing.
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
- New Game on the Games page. Give it a title, a description and an icon — all three are required to submit.
- 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'sStreamingAssets/folder. HTML5: drop the folder that containsindex.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. - Save draft, then Play In Sandbox to try the upload with real phones.
- 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.
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.
| Endpoint | What it does |
|---|---|
GET /games | The 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/commit | Same 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}/publish | Submit 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}/sandbox | Remove 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 Debuggershould list a Gamepad.
Unity: input doesn't respond in the published game
- Make sure your actions bind to
<Gamepad>/paths (not a custom device). AnOkonaPadDeviceis aGamepad. - If you use
PlayerInputManagerwith C# join/leave events, set its Notification Behavior to Invoke C# Events — the events don't fire otherwise. - Make sure your control scheme requires
<Gamepad>, not<OkonaPad>.
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.devicesthat you've manually re-enabled on WebGL — letPlayerInputManagerhandle joins.
Rumble not working
- Call
SetMotorSpeeds(low, high)on the player'sGamepad(values 0–1). - Not every physical pad supports haptics; the request is silently ignored on those. 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 checktell 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.jsresolves 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 insideOkona.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.htmlat the root” — push the folder that containsindex.htmldirectly, not its parent (adist/insidemy-game/means pushingmy-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.lyis +Y down, the Web Gamepad and screen-space convention. Unity'sleftStickis +Y up; if you port code between them, flip it once.alert,confirmandpromptare 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
insetand flexgap, 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.