Players
A player is the connected individual: the person on the other end of a session. A player
is nothing more than two pieces of state: the session-fixed join data they supply on
connect (a display name, say) and their per-instance state, the fields they carry inside
whatever instance they are in. There is one player
type, so the player files gather into a single
gamehoster-player/ folder inside the game.
The join data is settled once, on connect, and carries unchanged for the whole session. The per-instance state is created when the player is routed into an instance and stepped forward each tick. The files below are the whole of a player: the two schemas that shape that state, the four scripts that route the player in, tear them down, and update them, and one optional script that scopes what each viewer sees.
<gamehoster-config-contentRoot>/
game.gamehoster.org/
gamehoster-games/
pong/
gamehoster-player/
gamehoster-player-join-schema.json
gamehoster-player-schema.json
gamehoster-player-join.js
gamehoster-player-leave.js
gamehoster-player-view.js
gamehoster-player-update-backend.js
gamehoster-player-update-frontend.js
Every .js file below is a raw function body: no export, no
function header. It starts straight into JavaScript and reaches the world through the
Gamehoster.* library. Which library calls are in scope depends on the file, and each section
lists exactly what that one can call. Three are always present:
Gamehoster.Time() (ms), Gamehoster.Tick() (integer) and
Gamehoster.Random() ([0,1)), which is now predictable, seeded by
the tick and the running context so it returns the same value on server and client, making it safe inside
shared logic. Backend bodies additionally get Gamehoster.Entropy(), true
unpredictable randomness for authoritative decisions; front-end predict bodies do not.
A body learns the ids in scope from
Gamehoster.Context, a record whose slots are filled for that body's context:
Gamehoster.Context.playerId (the player it acts as),
Gamehoster.Context.instanceId (the instance it is in) and
Gamehoster.Context.entityId (the entity it acts as); a slot that doesn't apply is
undefined. Every
state op names its id, Gamehoster.Player.State.Get(id, "field"),
Gamehoster.Instance.State.Set(id, "field", value) and so on, so a body's first lines are
usually const me = Gamehoster.Context.playerId and
const here = Gamehoster.Context.instanceId.
Writes do not apply the instant you make them. Each body writes into a scratch
buffer (your own reads see your own writes) and, when it returns, the engine flushes
that buffer against the body's allowed-write set: a
shared body (a command handler's -frontend.js or an
-update-frontend.js) may write only an object's shared fields and its own
owner fields, and a command handler only the fields its
writes manifest lists; a backend or setup body
(-update-backend.js, a handler's -backend.js, join, leave, startup)
may write everything. Anything left over was a write this body was not allowed to make here: a
silent no-op if the field is real (the authoritative value stands), a contained
error if the field does not exist. This is why a field's sync and audience
double as an access rule.
Contents
The player folder holds two schemas, the four lifecycle and update scripts, and an optional view script.
| Name | Type | Description |
|---|---|---|
gamehoster-player-join-schema.json | file | The fixed data a player supplies on connect. |
gamehoster-player-schema.json | file | The player's per-instance state schema. |
gamehoster-player-join.js | file | On connect: route the player into an instance and set them up. |
gamehoster-player-leave.js | file | Clean up when a player leaves. |
gamehoster-player-view.js | file | Optional: which players and entities are relevant to a viewer. |
gamehoster-player-update-backend.js | file | The player's own per-tick update, backend only. |
gamehoster-player-update-frontend.js | file | The player's own per-tick update, shared on both sides. |
gamehoster-player-join-schema.json
The fixed data a player supplies on connect: the one thing about them that is settled
for the whole session, before any instance is involved. It is an array of field objects
(the names are not used as keys), each with a -name, a
-type, and an optional -default. Where a
default is omitted the player must supply the value; where it is given, that value
stands in when they don't. Join data is fixed, never state, so these entries carry
no sync or audience. Pong asks only for a name:
[
{
"gamehoster-player-join-schema-name": "name",
"gamehoster-player-join-schema-type": "string"
}
]
| Key | Type | Meaning |
|---|---|---|
gamehoster-player-join-schema-name | string | The field's name. |
gamehoster-player-join-schema-type | string | Its type. |
gamehoster-player-join-schema-default | optional | A default value; omit it to force the player to supply one. |
gamehoster-player-schema.json
The player's per-instance state: the fields they carry inside an instance. Like the
join schema it is an array of field objects (a name is never used as an
object key) each carrying a fully-qualified -name and
-type. State fields additionally declare two things
explicitly: -sync (how a client obtains the value) and
-audience (who may receive it), with an optional
-smooth for render interpolation and an optional -default. In
pong a player is their name and side, whether they're ready, their
score, and the paddle position y:
[
{
"gamehoster-player-schema-name": "name",
"gamehoster-player-schema-type": "string",
"gamehoster-player-schema-sync": "sent",
"gamehoster-player-schema-audience": "all"
},
{
"gamehoster-player-schema-name": "side",
"gamehoster-player-schema-type": "string",
"gamehoster-player-schema-sync": "sent",
"gamehoster-player-schema-audience": "all"
},
{
"gamehoster-player-schema-name": "ready",
"gamehoster-player-schema-type": "bool",
"gamehoster-player-schema-default": false,
"gamehoster-player-schema-sync": "sent",
"gamehoster-player-schema-audience": "all"
},
{
"gamehoster-player-schema-name": "score",
"gamehoster-player-schema-type": "int",
"gamehoster-player-schema-default": 0,
"gamehoster-player-schema-sync": "sent",
"gamehoster-player-schema-audience": "all"
},
{
"gamehoster-player-schema-name": "y",
"gamehoster-player-schema-type": "number",
"gamehoster-player-schema-default": 240,
"gamehoster-player-schema-sync": "shared",
"gamehoster-player-schema-audience": "all",
"gamehoster-player-schema-smooth": true
}
]
| Field | Type | sync | audience | smooth | What it is |
|---|---|---|---|---|---|
name | string | sent | all | — | Display name, copied from the player's join data when they enter. |
side | string | sent | all | — | Which paddle they hold: "left" or "right", assigned to whichever side is free. |
ready | bool | sent | all | — | Whether they've readied up; starts false. |
score | int | sent | all | — | Points this player has scored; starts 0. |
y | number | shared | all | true | Paddle position, reproduced from commands on every client and render-smoothed so it stays fluid. |
The two required axes settle a field's networking. sync says how a client
obtains the value:
sync | How the client gets it |
|---|---|
server | Computed and kept on the backend; never sent. (audience is ignored.) |
sent | Computed on the backend from data only it has, then pushed to clients that can see it. The client treats it as truth: it snaps to the pushed value / reconciles. |
shared | Computed on both sides by running the same commands; not sent as a value but reproduced from the commands, and reconciled by the occasional keyframe. |
audience says who may receive it: owner (only the player the
field belongs to) or all (everyone the object is currently relevant to).
It is ignored when sync is server. smooth is an
optional bool, render interpolation only: put true on continuous quantities (positions),
leave it off discrete ones (scores, phases, ids). It defaults to false.
The two axes are independent, so they express combinations a single flag could not: for
instance sync: "sent", audience: "all", smooth: true: an
authoritative position the server computes and streams, which the client still render-smooths between
packets rather than snapping.
The axes double as an access rule for the scripts. A front-end body reads the
sent and shared fields of the objects relevant to it, plus its own player's
owner fields; it writes only its own shared and owner fields, and
it never reads a server field. The backend body reads and writes
everything.
gamehoster-player-join.js
Runs on the server the moment a player connects, and does two jobs. First it
routes the player into an instance: it lists
the live instances, filters them (pong wants one still waiting for an opponent that has
room), and Joins that one, or Creates a fresh instance when none fits. Then it
sets the player up: it reads their fixed join
data, gives them whichever side is free, copies over their name, and
centres their paddle.
// route: join a waiting game that has room, else start a new one
const open = Gamehoster.Instance.List().find(id =>
Gamehoster.Instance.Info(id).players < Gamehoster.Instance.Info(id).capacity &&
Gamehoster.Instance.State.Get(id, "phase") === "waiting")
Gamehoster.Instance.Join(open !== undefined ? open : Gamehoster.Instance.Create())
// set up: whichever side is free. Now that we have joined, Context.playerId names
// us and Context.instanceId our court (both undefined until the Join above).
const me = Gamehoster.Context.playerId
const here = Gamehoster.Context.instanceId
const taken = Gamehoster.Player.List()
.filter(id => id !== me)
.map(id => Gamehoster.Player.State.Get(id, "side"))
Gamehoster.Player.State.Set(me, "side", taken.includes("left") ? "right" : "left")
Gamehoster.Player.State.Set(me, "name", Gamehoster.Join.Get("name"))
Gamehoster.Player.State.Set(me, "score", 0)
Gamehoster.Player.State.Set(me, "ready", false)
Gamehoster.Player.State.Set(me, "y", Gamehoster.Instance.State.Get(here, "courtH") / 2)
| Function | What it does |
|---|---|
Gamehoster.Context.playerId · Gamehoster.Context.instanceId | The ids in scope: the joining player, and their instance (which is undefined until the Instance.Join above, so read it after). |
Gamehoster.Join.Get(name) | A value the player supplied against the join schema. |
Gamehoster.Instance.List() | Ids of the live instances, to route between. |
Gamehoster.Instance.Info(id) | {players, capacity, …} for an instance, to choose between them. |
Gamehoster.Instance.State.Get(id, name) | Read an instance's state field: e.g. to filter by phase. |
Gamehoster.Instance.Join(id) | Place this player into that instance. |
Gamehoster.Instance.Create() | Make a fresh instance and return its id. |
Gamehoster.Player.List() | Ids of the players in the instance. |
Gamehoster.Player.State.Get(id, name) | Read another player's state field. |
Gamehoster.Player.State.Set(Gamehoster.Context.playerId, name, value) | Fill the joining player's per-instance state. |
Gamehoster.Time() · Gamehoster.Tick() · Gamehoster.Random() · Gamehoster.Entropy() | Clock (ms), tick number, predictable random in [0,1) (seeded by tick + context), and unpredictable Entropy() for authoritative choices: a backend body, so both are in scope. |
gamehoster-player-leave.js
Runs on the server when a player leaves, to clean up. Pong keeps no per-player
resources beyond the paddle the engine reclaims, so it only drops the instance back to
waiting if a match was in play.
const here = Gamehoster.Context.instanceId
if (Gamehoster.Instance.State.Get(here, "phase") === "playing")
Gamehoster.Instance.State.Set(here, "phase", "waiting")
| Function | What it does |
|---|---|
Gamehoster.Context.playerId · Gamehoster.Context.instanceId | The ids in scope: the leaving player and their instance. |
Gamehoster.Instance.State.Get(id, name) · .Set(id, name, value) | Read or write the instance's state field. |
Gamehoster.Player.List() | Ids of the players still in the instance. |
Gamehoster.Player.State.Get(id, name) · .Set(id, name, value) | Read or write a remaining player's state field: e.g. hand a leaver's resource to whoever is left. |
Gamehoster.Entity.List(type) | Ids of the live entities of a type. |
Gamehoster.Entity.State.Get(id, name) | Read an entity's state field. |
Gamehoster.Entity.Destroy(id) | Remove an entity: e.g. a resource this player owned. |
Gamehoster.Time() · Gamehoster.Tick() · Gamehoster.Random() · Gamehoster.Entropy() | Clock (ms), tick number, predictable random in [0,1) (seeded by tick + context), and unpredictable Entropy(): a backend body, so both are in scope. |
gamehoster-player-view.js
An optional script that decides relevance: which players and
entities a given viewer should receive at all. It is the outer
gate in front of every field's sync/audience: if an object is not relevant to
you, you receive none of its fields; inside a relevant object, each field still syncs by
its own rules. This is how partial visibility (areas of interest, fog of war) is expressed.
The script is viewer-centric: Gamehoster.Context.playerId is the
viewer, and the body runs on the backend at the send
rate, once per viewer. It calls Gamehoster.View.Include(id) for each player or entity
that should be relevant to that viewer, including ones about to become relevant, so their
enter lands just before they're needed and nothing pops
in late. If the file is absent, everything is relevant to everyone: full visibility.
Pong, with two paddles and one ball in a single court, has nothing to hide, so it omits the file.
// A viewer sees players near them, plus any about to come into range (pre-fetch).
const me = Gamehoster.Context.playerId
const [mx, my] = [Gamehoster.Player.State.Get(me, "x"), Gamehoster.Player.State.Get(me, "y")]
const near = (x, y, r) => (x - mx) ** 2 + (y - my) ** 2 < r * r
for (const id of Gamehoster.Player.List()) {
const x = Gamehoster.Player.State.Get(id, "x")
const y = Gamehoster.Player.State.Get(id, "y")
if (near(x, y, 600)) Gamehoster.View.Include(id) // visible now, or soon (pre-fetch margin)
}
for (const id of Gamehoster.Entity.List("prop"))
if (near(Gamehoster.Entity.State.Get(id, "x"), Gamehoster.Entity.State.Get(id, "y"), 600))
Gamehoster.View.Include(id)
| Function | What it does |
|---|---|
Gamehoster.Context.playerId · Gamehoster.Context.instanceId | The ids in scope: the viewer this pass is scoping the world for, and their instance. |
Gamehoster.View.Include(id) | Mark a player or entity relevant to the viewer, so its fields sync to them. |
Gamehoster.Player.List() | Ids of the players in the instance, to test against the viewer. |
Gamehoster.Player.State.Get(id, name) | Read a player's state field: e.g. their position. |
Gamehoster.Entity.List(type) | Ids of the live entities of a type. |
Gamehoster.Entity.State.Get(id, name) | Read an entity's state field. |
Gamehoster.Instance.State.Get(id, name) | Read the instance's state field: always relevant, so useful for tuning the test. |
Gamehoster.Time() · Gamehoster.Tick() · Gamehoster.Random() · Gamehoster.Entropy() | Clock (ms), tick number, predictable random, and unpredictable random: a backend body, so both randoms are in scope. |
gamehoster-player-update-frontend.js & gamehoster-player-update-backend.js
The player's own per-tick update: step 2 of the tick, where a player reads the previous state and writes only its own. It is split by trust, not by side, across up to two optional files:
gamehoster-player-update-frontend.js: the shared body. It runs on the front end and on the backend: the predict-where-possible logic. It may read thesent/sharedfields of relevant objects and its own player'sownerfields, and it writes only its ownshared/ownerfields. It may call the predictableGamehoster.Random()but neverGamehoster.Entropy().gamehoster-player-update-backend.js: the backend-only extra, the private / authoritative part. It runs only on the server, after the shared body, and may touch anything: read and writeserverstate, writesentfields, and useGamehoster.Entropy()for authoritative choices.
So the backend runs the superset (the shared body, then the backend extra) while a client
runs only the shared body. The backend does not keep its own copy of the shared logic: it
executes the same -update-frontend.js file, then layers the
-backend file on top. The shared logic therefore exists exactly once and cannot diverge between
the two sides. Both files are optional and independent (predict-only ships just the front-end file,
private-only just the backend file) and there are no empty stubs.
A pong paddle has no autonomous per-tick behaviour (it moves only in response to the
moveY command) so it ships neither file: the pong player has
no update files at all.
gamehoster-player-update-frontend.js: the shared body, run on both
sides. Set reaches only this player's own shared / owner
fields, and it reads the sent / shared fields of relevant objects but never a
server field. These are not errors: a write outside those fields is a
silent no-op (the authoritative value stands), and an object outside this viewer's
relevance simply isn't in its lists: absent, not a throw. Only a genuine fixed-schema
breach (a field that doesn't exist, a wrong type) is caught, and then as a contained error.
| Function | What it does |
|---|---|
Gamehoster.Context.playerId · Gamehoster.Context.instanceId | The ids in scope: this player and their instance. |
Gamehoster.Player.State.Get(Gamehoster.Context.playerId, name) · .Set(Gamehoster.Context.playerId, name, value) | Read and predict this player's own state: writes its own shared / owner fields only. |
Gamehoster.Instance.State.Get(id, name) | Read a sent / shared instance state field. |
Gamehoster.Player.List() | Ids of the players in the instance. |
Gamehoster.Player.State.Get(id, name) | Read a relevant player's sent / shared state field. |
Gamehoster.Entity.List(type) | Ids of the live entities of a type. |
Gamehoster.Entity.State.Get(id, name) | Read a relevant entity's sent/shared state field. |
Gamehoster.Time() · Gamehoster.Tick() · Gamehoster.Random() | Clock (ms), tick number, and the predictable random in [0,1) (seeded by tick + context, matching the backend). No Entropy(): the shared body runs on the client too and must stay deterministic. |
gamehoster-player-update-backend.js: the optional backend-only
extra, run after the shared body; it may write any of the player's own fields and
touch server / sent state.
| Function | What it does |
|---|---|
Gamehoster.Context.playerId · Gamehoster.Context.instanceId | The ids in scope: this player and their instance. |
Gamehoster.Player.State.Get(Gamehoster.Context.playerId, name) · .Set(Gamehoster.Context.playerId, name, value) | Read and write this player's own state. |
Gamehoster.Instance.State.Get(id, name) | Read the instance's state field. |
Gamehoster.Player.List() | Ids of the players in the instance. |
Gamehoster.Player.State.Get(id, name) | Read another player's state field. |
Gamehoster.Entity.List(type) | Ids of the live entities of a type. |
Gamehoster.Entity.State.Get(id, name) | Read an entity's state field. |
Gamehoster.Time() · Gamehoster.Tick() · Gamehoster.Random() · Gamehoster.Entropy() | Clock (ms), tick number, predictable random in [0,1) (seeded by tick + context, so it replays identically on the client), and unpredictable Entropy() whose outcome must land in a sent or server field: a backend body, so both are in scope. |