Writing your own host

A host is the thing being controlled — the software holding the truth about whether a conversation is running, audio is paused, or how loud it is. Unreal is simply the implementation we ship. Building a Unity host is supported, and this page provides the practical route to one.

The host is not a fork of the tablet app. These are distinct jobs on opposite ends of the socket, and this page covers only the first. Your Unity project implements the host; the tablet remains a web client. Run ours unchanged against your host from the day it accepts a connection. Fork apps/tablet later, or never, if you want a differently-shaped tablet — see Using the library directly for that half.

You need nothing from the Unreal implementation. The wire protocol is the entire contract. If you prefer reading code, @promethist/kiosk-tablet/protocol provides that same contract as TypeScript types with no runtime dependencies. Any tablet built on our library, including the reference tablet in apps/tablet, will drive your host once implemented.

What a host owes a client

Four things. That is genuinely all of it: there is no registration, acknowledgement, or correlation id.

  1. Accept a WebSocket on port 9010, path /link.

  2. Answer hello with a complete state snapshot — every key you publish, not a patch.

  3. Send a state patch whenever something changes, to every connected client.

  4. Accept cmd strings and act on them.

The build checklist

In the order you will meet them.

A WebSocket server. Unity has none built in, so pick a library; the protocol does not care which. Check one thing early: ensure you can send to a specific connection and enumerate open ones, because state goes to everybody. Clients connect to /link by convention. The reference host does not check the path, and yours need not either.

Marshal to the main thread. Your socket library delivers messages on a background thread, but Unity’s API is main-thread only. Queue incoming commands and drain the queue in Update(). Failing to do this is the most common mistake in a first host, causing intermittent failures — the worst kind.

Text frames are fine. Clients must accept either text or binary. The Unreal host sends binary only because the engine’s WebSocket plugin cannot do otherwise. Yours should send text, removing a whole class of encoding bug.

Authorise before anything else. Until a client sends a valid hello, answer other messages with unauthorized and close after a short grace period. Trust local connections by peer address for a zero-configuration local tablet, but demand the token for anything arriving over a network.

A state store that compares before it stores. Keep a flat map of dotted keys. On a write, compare the new value with the existing one. If unchanged, do nothing. Otherwise, stage it, and once per frame send the accumulated changes as one patch, incrementing rev by one. Two things depend on this comparison: clients use rev gaps to detect lost patches and request a resync, and re-publishing identical values triggers false edges for downstream watchers.

Publish initial values, not just changes. A client cannot know which way an unpublished toggle might move, so a toggle whose key has never been published stays disabled. This is correct, but a host writing the key only when pressed ships an unpressable control. Nothing logs it.

Clear your pending keys on start-up. If you publish somethingPending for outstanding work, clear it when the process starts. Otherwise, an interrupted job leaves the client showing a spinner for an abandoned action.

pingpong. Cheap, and the only way a client can tell "wedged main thread" from "working, nothing happening". Worth doing before you think you need it.

Rate-limit, and log what you drop. Size your budget above what your tablet’s continuous controls generate, not how fast a finger taps; a throttled slider sends steadily throughout a drag. The Unreal host allows 20/s sustained with a burst of 40. Log every dropped command: it is the one refusal a client cannot show, so your log is the only record the press occurred.

Declare your vocabulary

State is self-describing — clients read every key published in hello — but commands are not. Declare what you accept:

"server": { "app":"MyKiosk", "boot":"...", "rev":42, "proto":[2],
            "commands":[ {"cmd":"pause", "value":"bool", "reflects":"paused"},
                         {"cmd":"restart"} ] }

This is optional, buying discoverability rather than permission; an undeclared command still works. It exists because "read their README" stops being an answer once multiple host implementations exist.

Three things that catch host authors

Do not assume your command should be named after the state key it changes. Ours mostly are not, and one must not be: blackout latches only the operator’s half of the lockout, while the blackout key represents that half OR the host’s diagnostics. A client watching the wrong key would send blackout:false, see it stay true, and conclude the command failed. This is what reflects is for.

Require a value where you need one. An absent value and a false one are indistinguishable once coerced. A host treating missing as false lets one malformed frame lift a lockout. Log and ignore, rather than guessing.

Answer a bad value rather than dropping it. A silently refused command becomes a button that does nothing, indistinguishable from a broken kiosk.

Your states will not be our states, and that is fine

Your engine will have conversation states ours lacks, such as prebuffering or thinking, depending on your pipeline. None of this requires agreement, because the keys are the contract and the values are not.

connection is a free-form string. The reference tablet displays whatever arrives, special-casing exactly three values:

Value What the reference tablet does with it

Unavailable

Treats the host as unreachable: fogs the screen and greys every control. It means there is no avatar behind this socket — a command would be sent, logged, and answered by nobody. Publish it only when you mean exactly that, never for an ordinary idle.

Reconnecting

Shows a banner, and keeps the controls live.

Disconnected

Nothing on its own; it is what arms the end-of-conversation rating prompt.

Every other value — Ready, Listening, Speaking, and anything you invent — passes through untouched. The tablet deliberately avoids fogging on unrecognised values, ensuring normal conversational turns proceed uninterrupted.

The keys are a different matter, applying only to buttons you want our app to drive. Each key drives one control, and a control remains disabled until its key is published:

Key Publish it or…

sessionActive, sessionBusy

…Start/Stop is dead, or is pressable through the seconds a connect takes.

paused

…Pause is dead.

agentUnready

…Pause and the canned-phrase button stay live when there is no conversation to act on.

blackout, blackoutManual

…the out-of-service toggle is dead and nothing ever fogs.

volume, muted, locale, locales

…the admin controls and the language picker are dead.

subtitles, assistancePending

…those two buttons are dead. In our deployment the project publishes them, not the plugin.

This disabled-by-default behaviour is a feature, not a gap to work around. "I have not implemented this yet" and "this host does not do that" produce the same honest result: an unoffered control. Implement keys for the controls you want, ignore the rest, and name anything new whatever you like.

Get a test client for free

Reuse our command names. Match pause, volume, session, restart, locale, and the rest, and the reference tablet in apps/tablet will drive your host on day one, before you write any interface. Point it at your port to test your socket implementation against a known-good client.

Diverge afterwards, once that half works. Diverging first means debugging two new things against each other.

Our vocabulary is a useful worked example even if you implement none of it. See The reference tablet for what each command and key does, and Taking itself out of service for the one behaviour a kiosk host really should copy.

What you do not have to build

The Unreal host also serves the tablet’s web page over HTTP on port 8080, so the tablet needs no separate server. This convenience is not part of the protocol. Skip it and serve the page however you like, or set url in the tablet’s config.json to point at your channel.

Self-diagnostics, saved volume, and the language list are equally optional. A host implementing only the four obligations at the top of this page is valid. A tablet built on our library will connect and render whatever keys it finds.