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.
-
Accept a WebSocket on port 9010, path
/link. -
Answer
hellowith a complete state snapshot — every key you publish, not a patch. -
Send a
statepatch whenever something changes, to every connected client. -
Accept
cmdstrings 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.
ping → pong. 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 |
|---|---|
|
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. |
|
Shows a banner, and keeps the controls live. |
|
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… |
|---|---|
|
…Start/Stop is dead, or is pressable through the seconds a connect takes. |
|
…Pause is dead. |
|
…Pause and the canned-phrase button stay live when there is no conversation to act on. |
|
…the out-of-service toggle is dead and nothing ever fogs. |
|
…the admin controls and the language picker are dead. |
|
…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.