Playing
Once you are in a match, the library streams the game to you and sends the player's input back. You
subscribe to two things on the instance (its
frames to draw and its state to react to), send input and
commands through one call, and never touch the wire yourself. Every call takes the instance (or its
id) as its first argument.
Rendering frames
Instance.Frame is the render path. It returns a subscription handle; set
its onChange, and the library calls it on your own free-running frame loop,
as fast as the display allows: there is no fixed render rate. Between the server's sparse packets the
client itself runs the game's own *-update-frontend.js shared bodies at the
tick rate (the same code the backend runs), so the logical state keeps simulating and stays live between
snaps, and each callback hands you a ready-to-draw, already-predicted
frame view interpolated from it. You never invoke
those bodies yourself; you just draw what you're handed:
const frame = Gamehoster.Instance.Frame(instance)
frame.onChange = (view) => {
// view.players — each { id, x, y }
// view.entities — each { id, type, x, y }
draw(view) // already interpolated — just render it
}
frame.onError = (error) => showRenderError(error)
// later: frame.cancel()
The frame view is only the interpolated positions of everything on screen: the
players and the
entities (each with their type), and only the ones
relevant to you. The backend streams each player just what they are allowed to see, so
others appear and vanish from the view as they enter and leave your relevance. Purely
cosmetic touches (grass, smoke, particles, any decorative randomness) live in this render loop too,
drawn from the view but never synced anywhere.
The instance state
Instance.State is the logic path. It's a subscription too, but where Frame runs on your own
free-running frame loop, this arrives at the server's send rate (the authoritative
word, snapping to whatever the server last confirmed) and surfaces every logical change: a goal
is scored, a player readies up, the phase advances. Use it for scoreboards, phase screens,
and the player list; use Frame for drawing:
const state = Gamehoster.Instance.State(instance)
state.onChange = (state) => {
// state.phase · state.players · plus the instance's own fields
updateScoreboard(state)
}
state.onError = (error) => showConnectionLost(error)
// later: state.cancel()
The instance state carries the instance's
phase, so following the flow (waiting, playing, over, round again) is just reading
state.phase as it changes. Its core fields, plus whatever the instance's schema declares
(score, turn, and so on):
| Field | Type | Description |
|---|---|---|
id | string | The instance id. |
phase | string | The instance's phase: waiting, playing, over, and the like. |
players | array | The players currently relevant to you:
each an individual, with an id, their fixed join info, and the per-instance
fields the game's player schema declares (like score
and y). A game with per-player relevance shows you only those you may see. |
| … | — | Plus every field the instance's schema declares. |
An instance starts in its waiting phase and moves to playing; this one
subscription tracks the state throughout. While the phase is waiting, its state is
the lobby (the player list and their ready flags), so you show your gathering UI straight from
state.players. There is no separate lobby subscription.
Sending input and commands
Input flows the other way through Instance.Send(instance, { type, …params }).
It's fire-and-forget (it returns nothing), and one call carries both high-frequency input and one-off
commands. You propose; the server disposes:
Gamehoster.Instance.Send(instance, { type: "move", y }) // input
Gamehoster.Instance.Send(instance, { type: "ready" }) // lobby command
Gamehoster.Instance.Send(instance, { type: "start" }) // command
Every command is handled server-side by the game's own command body, which decides for itself whether to act. An illegal command (not your turn, wrong phase) is one the handler ignores with an early return, so it changes nothing. You can run the same handler logic on the client to decide whether to offer the control. A command that the handler ignores simply never shows up in the instance state or the frame view: that's where every command's effect appears, never as a return value from the call.
Leaving
Instance.Leave disconnects from the room and ends its subscriptions. It's an operation
handle, so you can confirm you're out:
const left = Gamehoster.Instance.Leave(instance)
left.onSuccess = () => returnToMenu()
left.onError = (error) => showError(error)