Appearance
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:
HurtInHazardstakes named queries. It runs once per step and gets a view per query. OnlyHealthis written, so it is the only trait wrapped inMut(); writingplayerPos.xwould be a type error.DespawnDeadis per entity: it runs once for every entity withHealth. Despawning goes throughcommands, which apply at the end of the phase.HealthHudis per entity too, and writes a resource withres.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 stepsGameplay 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 describephase 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 UiHad 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 testThe 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 docsThe generated section now lists Health, Hazard, Damaged and the new systems with their JSDoc and their place in the schedule.