Making a game

Designing a kind

A thing with rules, and things acting on each other.

A kind is one sort of thing with rules, such as a coin, a door, a score or a monster. Its rule is <kind>.sim.ts and its drawing <kind>.client.ts, both in games/<game>/.

Where each value goes

The valueGoes in
Changes while the game runsA field of the row, starting from the kind's prefab, prefabs.<kind> in the scene file.
Fixed, the kind's own (speed, size, colour, reach, worth)Its definition, the kind's entry in assets/<game>.json.
Fixed, the scene's ownThe scene's world.
Large and fixed (a table, a level)A data file of its own in assets/, listed in the scene's catalogue and named by a field of the definition.
Used only by the page (volume, sound on or off)A key of the store.

A thing whose fixed numbers differ from another's is another kind with its own definition. No number is written in the rule or the drawing.

Making the kind

  1. Decide whose value it is. What a seat owns, its health, ammo, lives or score, is a field of its player row (Designing a seat). A value no seat owns, such as a team's total or a match clock, is a kind of its own. A new system is a new kind, never a field added to an existing kind.
  2. Name it with a lower-case identifier that is not the game's name or a plugin's name.
  3. Write its definition in assets/<game>.json.
  4. Write its prefab in the scene file with every changing field at its starting value, or {}. A thing that stands somewhere has pos.
  5. Write its rule, games/<game>/<kind>.sim.ts, exporting <kind>: Kind.
  6. Import the rule in <scene>.sim.ts and list it in sim, including kinds that are only spawned.
  7. Place its rows: one actors entry for a single thing in the scene, an entry per thing placed, or rows made by w.spawn while the game runs.
  8. Write its drawing, <kind>.client.ts, if it is drawn.

The editor's Kinds panel, New kind, writes a blank kind or a copy of another kind in one undoable change.

The rule

  1. A rule runs in before, tick and after, each (me, dt, w), and in its acts. Multiply rates by dt, the seconds the step covers.
  2. Each step answers the seats' messages, then runs every row's before, then every tick, then every after. A rule that reads what another row did this step reads it in after.
  3. A seat's message runs an act only on the row it possesses, or an act listed in the kind's open. Check arguments that came from a seat.
  4. Anything a kind remembers between steps is a field on its row. Write array fields whole (me.data.pos = [x, y]) and spawn with copies of another row's arrays.

The drawing

<kind>.client.ts exports setup(row, plugins), tick(row, plugins) and remove(plugins). It draws through the scene's renderer, reads changing values off row.data and numbers off the definition, and sets its variables in setup. Files such as pictures come through the loader. It is set up for every row of its kind, so a drawing that wants the seat's own row finds it with possessed(plugins.wire.rows).

Kinds acting on each other

  • One kind never imports another's file, and never reads another kind's definition.
  • Find rows with w.find(kind), w.all(kind) or w.get(id). Use w.all(kind) for a row that may have ended.
  • Change another row by calling its act, w.call(row, act, seat, ...args), so each field is written in one file. A hook passes -1 as the seat.
  • An act on the player row that other kinds call returns at once when a seat sent it, if (seat >= 0) return, so a seat cannot send it to itself.
  • Sounds and effects the page plays once are raised on audio's or a particle plugin's ledger, never as a field set for one step.

A coin with definition { "reach": 24, "worth": 5, "size": 16, "colour": "#f2a93b" } that gives its worth to the seat that touches it and counts itself on a tally:

// coin.sim.ts
export const coin: Kind = {
  after(me, _dt, w) {
    const { reach, worth } = def(w, me.kind)
    const [x, y] = me.data.pos
    const by = w.all('player').find(p => math.hypot(p.data.pos[0] - x, p.data.pos[1] - y) <= reach)
    if (!by) return
    w.call(by, 'award', -1, worth)
    w.call(w.find('tally'), 'add', -1, 1)
    w.end(me)
  },
}

// player.sim.ts, among its acts
award(me, _w, seat, worth: number) {
  if (seat >= 0) return
  me.data.score += worth
},

Shared code

Code several kinds share, or a system of the game's such as its level or HUD, is a plugin of the game's own in games/<game>/plugins/<name>/. Its <name>.sim.ts holds functions a rule imports; its <name>.plugin.ts draws every row of the kinds that share a drawing and is loaded by shipped. It reads numbers off the definition of each row it is handed. See Building a plugin.

Random numbers and maths

w.die() rolls from 0 up to 1 from the scene's world.seed, so a replay rolls the same numbers. A rule never calls Math.random. For sin, cos, pow, hypot and the other functions whose results differ between JavaScript engines, a rule uses math; sqrt, floor, min, max and the like stay on Math.

Checking it

  1. Write <kind>.sim.test.ts beside the rule, booting the game's manager, joining a seat and reading what the kind wrote off the rows. Run it with yarn vitest run games/<game>/<kind>.sim.test.ts.
  2. Run yarn editor <game> and open localhost:5173/games/<game>/index.html. The thing is drawn where its row says, acts on what it should, and leaves nothing drawn when it ends.