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
The gamehoster-player/ folder inside a game: the two schemas, the lifecycle scripts, and the optional view.

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.

NameTypeDescription
gamehoster-player-join-schema.jsonfileThe fixed data a player supplies on connect.
gamehoster-player-schema.jsonfileThe player's per-instance state schema.
gamehoster-player-join.jsfileOn connect: route the player into an instance and set them up.
gamehoster-player-leave.jsfileClean up when a player leaves.
gamehoster-player-view.jsfileOptional: which players and entities are relevant to a viewer.
gamehoster-player-update-backend.jsfileThe player's own per-tick update, backend only.
gamehoster-player-update-frontend.jsfileThe 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"
  }
]
pong · gamehoster-player/gamehoster-player-join-schema.json: one required field, no default
KeyTypeMeaning
gamehoster-player-join-schema-namestringThe field's name.
gamehoster-player-join-schema-typestringIts type.
gamehoster-player-join-schema-defaultoptionalA 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
  }
]
pong · gamehoster-player/gamehoster-player-schema.json
FieldTypesyncaudiencesmoothWhat it is
namestringsentallDisplay name, copied from the player's join data when they enter.
sidestringsentallWhich paddle they hold: "left" or "right", assigned to whichever side is free.
readyboolsentallWhether they've readied up; starts false.
scoreintsentallPoints this player has scored; starts 0.
ynumbersharedalltruePaddle 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:

syncHow the client gets it
serverComputed and kept on the backend; never sent. (audience is ignored.)
sentComputed 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.
sharedComputed 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)
pong · gamehoster-player/gamehoster-player-join.js: route the player into an instance, then fill their state
FunctionWhat it does
Gamehoster.Context.playerId · Gamehoster.Context.instanceIdThe 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")
pong · gamehoster-player/gamehoster-player-leave.js: pausing the match when an opponent drops
FunctionWhat it does
Gamehoster.Context.playerId · Gamehoster.Context.instanceIdThe 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)
gamehoster-player-view.js: mark the players and entities relevant to this viewer (illustrative; pong has no view)
FunctionWhat it does
Gamehoster.Context.playerId · Gamehoster.Context.instanceIdThe 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:

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.

FunctionWhat it does
Gamehoster.Context.playerId · Gamehoster.Context.instanceIdThe 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.

FunctionWhat it does
Gamehoster.Context.playerId · Gamehoster.Context.instanceIdThe 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.