Skip to content

A complete feature ​

This walks through one feature end to end in the starter game: hazards that cost the player health, a health bar, and despawning at zero. Every snippet here runs as written on top of kiln new.

1. Data ​

Traits and events go in src/data.ts, each with a one-line JSDoc:

ts
import { event, trait, type Entity } from 'kiln'

/** Hit points. */
// A struct trait: these numbers are the defaults, overridable per entity with Health({ hp: 5 }).
export const Health = trait({ hp: 3, max: 3 })

/** Hurts the player while they stand inside `radius`. */
export const Hazard = trait({ radius: 1, damage: 1 })

/** Something took damage. */
// An event: systems emit it and any system can read it. The type is its payload.
export const Damaged = event<{ entity: Entity; amount: number }>()

Give the player health in its prefab (src/prefabs.ts):

ts
export const Player = prefab((p: { at: Vec3 }) => [
  Transform({ x: p.at.x, y: 0.5, z: p.at.z }),
  Velocity,
  IsPlayer,
  Health({ hp: 3, max: 3 }),
  MeshRef(new THREE.Mesh(new THREE.BoxGeometry(), new THREE.MeshStandardMaterial({ color: 0x4d96ff }))),
])

2. Behaviour ​

Systems go in src/systems.ts:

ts
/** Damages the player once per second while it stands in a hazard. */
export const HurtInHazards = system(
  // Two named queries. Each becomes a view the function can loop over:
  //   players: entities with Health, Transform and the IsPlayer tag. Mut(Health) means this
  //            system may change Health; Transform is read-only here.
  //   hazards: entities with Hazard and Transform, both read-only.
  { players: [Mut(Health), Transform, IsPlayer], hazards: [Hazard, Transform] },

  // The function runs once per fixed step (60 times a second by default).
  ({ players, hazards }, { events, time }) => {
    // Only deal damage once per second: skip every step that isn't a multiple of
    // "steps in one second". time.tick counts fixed steps since the game started.
    if (time.tick % stepsFor(1, time.step) !== 0) return

    // .each gives a row per entity, holding its traits in query order (tags like IsPlayer
    // add no entry), plus the entity itself.
    players.each(([health, playerPos], player) => {
      hazards.each(([hazard, hazardPos]) => {
        // Inside the hazard's radius? Comparing squared distances avoids a square root.
        const inside = distSq(playerPos, hazardPos) <= hazard.radius * hazard.radius
        if (!inside) return

        health.hp -= hazard.damage                                        // allowed: Mut(Health)
        events.emit(Damaged, { entity: player, amount: hazard.damage })   // tell anyone listening
      })
    })
  },
)

/** Despawns anything whose health reached zero. */
// A per-entity system: the function runs once for every entity that has Health.
export const DespawnDead = system([Health], ([health], { commands, entity }) => {
  // Despawning is queued through commands and happens at the end of the phase,
  // so other systems this step still see a consistent world.
  if (health.hp <= 0) commands.despawn(entity)
})

/** Shows the player's health as a bar. */
// Runs once for the player. res.mut(Ui) is the HUD; setting the same bar every frame is free.
export const HealthHud = system([Health, IsPlayer], ([health], { res }) => {
  res.mut(Ui).bar('hp', health.hp / health.max, { anchor: 'bottom-left', label: 'HP', color: '#f55' })
})

Three system shapes appear here:

  • HurtInHazards takes named queries. It runs once per step and gets a view per query. Only Health is written, so it is the only trait wrapped in Mut(); writing playerPos.x would be a type error.
  • DespawnDead is per entity: it runs once for every entity with Health. Despawning goes through commands, which apply at the end of the phase.
  • HealthHud is per entity too, and writes a resource with res.mut(Ui).

Counting steps (time.tick) instead of adding up time.step keeps the timing exact. For anything longer-lived, the built-in Timer does the counting for you.

3. Schedule ​

One change in src/main.ts:

ts
export default () =>
  createApp(config)
    .use(CorePlugins)
    .init(Damaged)                       // register the new event
    .add(Startup, SpawnLevel)
    // chain() runs these in order: move first, then take damage where the player ended up,
    // then despawn anything that hit zero.
    .add(Fixed, chain(PlayerMove, Movement, HurtInHazards, DespawnDead))
    .add(Update, HealthHud)              // once per frame, after the fixed steps

Gameplay goes in Fixed, in a chain so it runs in a known order. The HUD is cosmetic, so it goes in Update.

4. Check the schedule ​

bash
kiln describe
phase Fixed
  ...
  3. PlayerMove | writes Velocity | reads IsPlayer | res-reads Input
  4. Movement | writes Transform | reads Velocity | after PlayerMove
  5. HurtInHazards | writes Health | reads Hazard,IsPlayer,Transform | after Movement
  6. DespawnDead | reads Health | after HurtInHazards
phase Update
  1. SpawnModels | reads MeshRef,Model
  2. HealthHud | reads Health,IsPlayer | res-writes Ui

Had HurtInHazards and another system both taken Mut(Health) with no order between them, this command would stop with an error naming both and the fix.

5. Test it ​

scenarios/hazard.test.ts:

ts
import { expect, scenario } from 'kiln/test'
import { Transform, Ui } from 'kiln'
import { Damaged, Hazard, Health, IsPlayer } from '../src/data.ts'

scenario('standing in a hazard costs 1 hp per second, then the player despawns', ({ app }) => {
  // Startup already ran, so the player exists. Put a hazard right on top of it.
  const player = app.query(IsPlayer)[0]!
  app.spawn(Transform({ x: 0, z: 0 }), Hazard({ radius: 2, damage: 1 }))

  // One second (60 fixed steps): one hit, 3 hp -> 2.
  app.step(60)
  expect(app.get(player, Health)!.hp).toBe(2)
  expect(app.events(Damaged)).toEqual([{ entity: player, amount: 1 }])   // events from this step() call
  expect(app.res(Ui).get('hp')?.value).toBeCloseTo(2 / 3)               // the HUD bar, as data

  // Two more seconds: two more hits, hp reaches 0 and DespawnDead removes the player.
  app.step(120)
  expect(app.has(player, Health)).toBe(false)
})
bash
kiln test

The test spawns the hazard directly with app.spawn, steps exact frames, and reads the result: trait values, the events the last step() call emitted, and the HUD as data. No browser is involved, and the run is the same every time.

6. Update AGENTS.md ​

bash
kiln docs

The generated section now lists Health, Hazard, Damaged and the new systems with their JSDoc and their place in the schedule.