Skip to content

Scenario tests ​

A scenario is a headless test of the real game. It builds a fresh app from src/main.ts, runs Startup, lets you set things up and step exact frames, and disposes the app afterwards. Scenarios run under bun test; kiln test runs every file in scenarios/.

ts
import { expect, scenario } from 'kiln/test'
import { Transform } from 'kiln'
import { Player } from '../src/prefabs.ts'

scenario('player walks right', ({ app }) => {      // fresh app, Startup already run
  const p = app.spawn(Player, { at: { x: 0, y: 0, z: 0 } })
  app.input.script([{ frame: 1, hold: 'right', frames: 60 }])
  app.step(60)                                     // exactly 60 fixed steps, no renderer
  expect(app.get(p, Transform)!.x).toBeCloseTo(6, 3)
}, { seed: 3 })

Before the body runs, async plugins (physics) are ready and asset loads started in Startup have finished, so every run is the same.

Options ​

scenario(name, body, options):

Option
seedOverrides config.seed
configOther config overrides (fixedHz, mode, ...)
startupfalse skips Startup
appA factory to build the app instead of the game's entry
timeoutTest timeout in ms

scenario.only and scenario.skip work like test.only and test.skip. kiln/test re-exports expect and describe from bun:test.

The app in tests ​

Method
step(n)Run n frames of exactly one fixed step each
tick(dt)Run one frame with a real delta, like the browser
spawn(...), despawn(e)Immediate, not queued
get(e, T), set(e, T, {...}), has(e, T)Trait values
query(...)Matching entities now
res(R)A resource, writable
insert(R, value)Replace a resource
emit(E, data)Emit an event from outside a system
events(E)Everything E emitted during the last step() call
inputScripted input: script([...]), press, release, tap, setAxis, reset
snapshot(opts), diff(a, b), restore(snap)See Snapshots
describe()The schedule as data

Writing good scenarios ​

Keep each one about one thing. Put entities where you need them with app.set(e, Transform, {...}), and despawn what would interfere:

ts
app.query(IsEnemy).forEach((e) => app.despawn(e))

Mind the level. Long input holds run into level geometry (arena walls, the ground), so start the player where the run fits.

Count events in chunks. app.events(E) only holds what the last step() call emitted. To check "exactly once" over a long run, step in chunks and add up the lengths:

ts
let hits = 0
for (let i = 0; i < 10; i++) {
  app.step(60)
  hits += app.events(Hit).length
}
expect(hits).toBe(1)

Test the HUD as data. app.res(Ui).get('score')?.text, and app.res(Ui).click('menu.start') to press a button. See HUD and UI.

Building apps yourself ​

scenarioApp(options) builds an app the same way and returns it, for tests that manage their own lifecycle (two apps sharing save storage, say). Dispose it when done:

ts
const app = await scenarioApp({ seed: 7 })
try {
  app.step(10)
} finally {
  app.dispose()
}

Koota allows 16 worlds at once, so an app that is never disposed eventually fails with too-many-apps.

Regression tests from recordings ​

A recorded play session becomes a test with replay(...). See Recordings and replay.