Skip to content

Recipes ​

Each recipe is a complete program. The console ones were run and their output is shown; the window ones were type-checked.

Spawn from a definition during a phase ​

Inside a phase the world refuses direct structural changes, so a spawner defers them. The spawn phase has flushAfter: true, so new enemies are visible to count in the same tick.

ts
import { ComponentKey, DefinitionRegistry, Entity, PhaseSchedule, ResolvedDefinition, World } from "gamekit";

class Enemy {
    kind: string;
    hp: int;
    constructor(kind: string, hp: int) {
        this.kind = kind;
        this.hp = hp;
    }
}
const ENEMY: ComponentKey<Enemy> = new ComponentKey<Enemy>("enemy");

const defs: DefinitionRegistry = new DefinitionRegistry();
defs.define("slime", { hp: 3 });
defs.define("big_slime", { hp: 9 }, ["slime"]);

const world: World = new World();
world.registerStore(ENEMY);

const schedule: PhaseSchedule = new PhaseSchedule();
schedule.addPhase("spawn", [], true);        // flushAfter: deferred work lands at the end of the phase
schedule.addPhase("count", ["spawn"]);
schedule.addSystem("spawn", (w: World, dt: float, tick: int): void => {
    // Inside a phase the world rejects direct structural changes. Defer them instead.
    const e: Entity = w.deferCreateEntity();
    const enemy: Enemy = defs.instantiate("big_slime",
        (def: ResolvedDefinition, context: any): Enemy => new Enemy(def.id, def.data.hp as int)) as Enemy;
    w.deferPutComponent(e, ENEMY, enemy);
});
schedule.addSystem("count", (w: World, dt: float, tick: int): void => {
    let total: int = 0;
    w.query(ENEMY).each((e: Entity, enemy: Enemy): void => { total += enemy.hp; });
    console.log("tick " + tick + ": " + w.entityCount + " enemies, " + total + " hp");
});

for (let tick: int = 0; tick < 3; tick++) {
    schedule.run(world, 0.1, tick);
}
text
tick 0: 1 enemies, 9 hp
tick 1: 2 enemies, 18 hp
tick 2: 3 enemies, 27 hp

More: World, queries and systems, Definitions and prefabs.

A cooldown in a system ​

A Gauge per turret fills with the tick's dt; the system fires when it reports full.

ts
import { ComponentKey, Entity, Gauge, PhaseSchedule, World } from "gamekit";

class Turret {
    reload: Gauge = new Gauge(1.0, 2.0);   // full after 0.5 s of ticks
    shots: int = 0;
}
const TURRET: ComponentKey<Turret> = new ComponentKey<Turret>("turret");

const world: World = new World();
world.registerStore(TURRET);
world.put(world.createEntity(), TURRET, new Turret());

const schedule: PhaseSchedule = new PhaseSchedule();
schedule.addPhase("simulate", [], true);
schedule.addSystem("simulate", (w: World, dt: float, tick: int): void => {
    w.query(TURRET).each((e: Entity, t: Turret): void => {
        if (t.reload.advance(dt)) {
            t.reload.consume(1.0);
            t.shots += 1;
        }
    });
});

for (let tick: int = 0; tick < 30; tick++) {
    schedule.run(world, 0.1, tick);      // 3 seconds at 10 ticks per second
}
world.query(TURRET).each((e: Entity, t: Turret): void => {
    console.log("shots in 3 s: " + t.shots);
});
text
shots in 3 s: 6

More: Gauges, picks and spatial helpers.

Pause the world with a pause scene ​

Pushing a scene pauses the node of the scene below. A WorldTicker on that node stops with it, and starts again when the pause scene is popped.

ts
import { Node } from "willow";
import { AppScene, PhaseSchedule, SceneStack, World, WorldTicker } from "gamekit";

class PlayScene extends AppScene {
    world: World = new World();
    schedule: PhaseSchedule = new PhaseSchedule();
    ticker: WorldTicker;

    constructor() {
        super("play");
        const root: Node = new Node("play");
        engine.root2d.add(root);
        this.attachNode(root);
        this.schedule.addPhase("simulate", [], true);
        this.ticker = new WorldTicker(root, this.world, this.schedule, App.tickDelta);
    }
}

class PauseScene extends AppScene {
    constructor() {
        super("pause");
        const root: Node = new Node("pause");
        engine.root2d.add(root);
        this.attachNode(root);
    }
}

const scenes: SceneStack = new SceneStack();
const play: PlayScene = new PlayScene();
await scenes.start(play);

App.onFrame((dt: float): void => {
    if (!Keyboard.isPressed(Key.ESCAPE) || scenes.transitioning) { return; }
    if (scenes.top === play) {
        scenes.push(new PauseScene());   // pauses play's node, so its world stops ticking
    } else {
        scenes.pop();                    // play's node resumes, and so does its world
    }
});

More: Scenes, WorldTicker.

Replay recorded input ​

Record each tick's ActionFrame, then feed the recording back through the same step function. The same frames give the same result.

ts
import { ActionFrame, ActionState, InputRecorder, InputReplay } from "gamekit";
import { SimulationClock } from "gamekit/src/testing";

class Runner {
    x: float = 0.0;
}

// The same step function for live play and for replay.
function step(runner: Runner, frame: ActionFrame, dt: float): void {
    runner.x += frame.axis("move_x") * 100.0 * dt;
}

// 1. A "live" run records each tick's frame.
const recorder: InputRecorder = new InputRecorder();
const live: Runner = new Runner();
const liveClock: SimulationClock = new SimulationClock(0.1, 8, (dt: float, tick: int): void => {
    const axis: float = tick < 5 ? 1.0 : -0.5;
    const frame: ActionFrame = new ActionFrame([new ActionState("move_x", true, false, false, false, axis)]);
    recorder.record(frame);
    step(live, frame, dt);
});
liveClock.advance(1.0);

// 2. A replay feeds the recording back, one frame per tick.
const replay: InputReplay = new InputReplay(recorder.recording());
const again: Runner = new Runner();
const replayClock: SimulationClock = new SimulationClock(0.1, 8, (dt: float, tick: int): void => {
    if (!replay.finished) {
        step(again, replay.next(), dt);
    }
});
replayClock.advance(1.0);
console.log("live " + live.x + ", replay " + again.x + ", same: " + (live.x == again.x));
text
live 35, replay 35, same: true

More: Input actions, Testing and debugging.

Save a world and restore it ​

Save a component's data to a slot, then build a fresh world from it.

ts
import { Codec, Codecs, ComponentKey, Entity, MemoryByteStorage, RecordField, SaveResult, SaveSchema, SaveSlotManager, SaveSlotMetadata, World } from "gamekit";

class Hero {
    level: int;
    gold: int;
    constructor(level: int, gold: int) {
        this.level = level;
        this.gold = gold;
    }
}
const HERO: ComponentKey<Hero> = new ComponentKey<Hero>("hero");

const heroCodec: Codec<Hero> = Codecs.record<Hero>(
    [new RecordField("level", Codecs.int()), new RecordField("gold", Codecs.int())],
    (f: any): Hero => new Hero(f.level as int, f.gold as int));
const slots: SaveSlotManager<Hero> = new SaveSlotManager<Hero>(
    new MemoryByteStorage(), new SaveSchema<Hero>("hero", 1, heroCodec));

async function main(): Promise<void> {
    // Play a little, then save the hero component.
    const world: World = new World();
    world.registerStore(HERO);
    const hero: Entity = world.createEntity();
    world.put(hero, HERO, new Hero(4, 120));
    await slots.saveSlot("main", world.getRequired(hero, HERO), new SaveSlotMetadata("main", "hero", 1));
    world.dispose();

    // Later: a fresh world, filled from the save.
    const loaded: SaveResult<Hero> = await slots.loadSlot("main");
    if (!loaded.isOk) {
        console.log("could not load: " + loaded.kind);
        return;
    }
    const fresh: World = new World();
    fresh.registerStore(HERO);
    fresh.put(fresh.createEntity(), HERO, loaded.value as Hero);
    fresh.query(HERO).each((e: Entity, h: Hero): void => {
        console.log("restored level " + h.level + " with " + h.gold + " gold");
    });
}

await main();
text
restored level 4 with 120 gold

More: Saves and settings.

A purchase that cannot half-happen ​

A condition refuses the purchase when gold is short; the staged mutation either commits or rolls back.

ts
import { Action, ActionContext, ActionRequest, ActionResult, Condition, ConditionResult } from "gamekit";

class Wallet {
    gold: int = 30;
}

class Price {
    cost: int;
    constructor(cost: int) { this.cost = cost; }
}

// Conditions explain themselves when they fail.
const canAfford: Condition<ActionContext<Price>> = new Condition<ActionContext<Price>>(
    "can-afford", "not enough gold",
    (ctx: ActionContext<Price>): boolean => (ctx.actor as Wallet).gold >= ctx.payload.cost);

// An action validates, stages mutations, then commits them all or none.
const buy: Action<Price, any> = new Action<Price, any>("buy", "buys an item");
buy.addCondition(canAfford);
buy.validate((ctx: ActionContext<Price>): void => {
    const wallet: Wallet = ctx.actor as Wallet;
    const cost: int = ctx.payload.cost;
    ctx.stageMutation("pay",
        (): void => { wallet.gold -= cost; },
        (): void => { wallet.gold += cost; });
});

const wallet: Wallet = new Wallet();
const first: ActionResult<any> = buy.execute(new ActionRequest<Price>("buy", wallet, null, new Price(20)));
const second: ActionResult<any> = buy.execute(new ActionRequest<Price>("buy", wallet, null, new Price(20)));
console.log("first: " + first.status + ", second: " + second.status + " (" + (second.reason ?? "") + ")");
console.log("gold left: " + wallet.gold);

// Conditions compose.
const positive: Condition<int> = new Condition<int>("positive", "must be above zero", (n: int): boolean => n > 0);
const even: Condition<int> = new Condition<int>("even", "must be even", (n: int): boolean => n % 2 == 0);
const both: ConditionResult = Condition.all<int>([positive, even]).evaluate(3);
console.log("3 passes: " + both.passed + ", reason: " + both.reason);
text
first: committed, second: denied (not enough gold)
gold left: 10
3 passes: false, reason: must be even

More: Conditions, actions and interactions.