Skip to content

Getting started ​

This page builds the smallest useful gamekit program: one entity, two components and one system, stepped by the runtime's fixed tick. Then it moves the same idea into a window with a scene, input and a drawn square.

You need thicket and a copy of the gamekit folder (gamekit-tks) with the willowui folder (willowui-tks) beside it. gamekit finds willowui through its own packages, so the two folders sit side by side.

1. Make a project ​

text
mygame/
  thicket.json5
  app.tks
gamekit-tks/
willowui-tks/

thicket.json5:

json5
{
    entry: "app.tks",
    systems: ["willow", "bitmap"],
    packages: { gamekit: "../gamekit-tks" },
}

There is no window section yet, so this is a console program. willow works without a window, which is what lets gamekit code run in a terminal and in headless tests.

2. Components are plain classes ​

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

class Position {
    x: float;
    y: float;
    constructor(x: float, y: float) {
        this.x = x;
        this.y = y;
    }
}

class Velocity {
    dx: float;
    dy: float;
    constructor(dx: float, dy: float) {
        this.dx = dx;
        this.dy = dy;
    }
}

const POSITION: ComponentKey<Position> = new ComponentKey<Position>("position");
const VELOCITY: ComponentKey<Velocity> = new ComponentKey<Velocity>("velocity");

A component is any class. It needs no base class. A ComponentKey<T> names a kind of component and carries its type, so reading a component back gives you a Position, not an any. The string label is for debugging only; two keys with the same label are still different keys.

3. A world holds the entities ​

ts
const world: World = new World();
world.registerStore(POSITION);
world.registerStore(VELOCITY);

const ball: Entity = world.createEntity();
world.put(ball, POSITION, new Position(0.0, 0.0));
world.put(ball, VELOCITY, new Velocity(60.0, 30.0));

registerStore gives the world one store per key. createEntity makes an entity the world owns, and put files a component in that key's store, where queries can find it.

4. A system is a function ​

ts
const schedule: PhaseSchedule = new PhaseSchedule();
schedule.addPhase("simulate", [], true);
schedule.addSystem("simulate", (w: World, dt: float, tick: int): void => {
    w.query2(POSITION, VELOCITY).each((entity: Entity, p: Position, v: Velocity): void => {
        p.x += v.dx * dt;
        p.y += v.dy * dt;
    });
});

A PhaseSchedule runs systems in named phases, in the order you add them. addPhase(name, after, flushAfter) takes the phases it must come after, and flushAfter: true applies any deferred entity changes when the phase ends. query2 visits every entity that has both components.

5. Let the runtime tick it ​

ts
App.tps = 10.0;
App.onTick((): void => {
    schedule.run(world, App.tickDelta, App.tick);
    const p: Position = world.getRequired(ball, POSITION);
    console.log("tick " + App.tick + ": ball at " + p.x + ", " + p.y);
    if (App.tick >= 4) {
        App.quit();
    }
});

App.onTick runs once per fixed logic tick, App.tps times a second, and a console program with a tick handler keeps running until App.quit(). Put the five snippets in app.tks in order and run it:

sh
thicket run
text
tick 0: ball at 6, 3
tick 1: ball at 12, 6
tick 2: ball at 18, 9
tick 3: ball at 24, 12
tick 4: ball at 30, 15

6. Put it in a window ​

A window game adds a window section:

json5
{
    entry: "game.tks",
    systems: ["willow", "bitmap"],
    packages: { gamekit: "../gamekit-tks" },
    window: { title: "Drift", width: 640, height: 480 },
}

This version moves a square left and right with the arrow keys or A and D. It adds three gamekit parts:

  • A scene (AppScene) that owns the world and a willow node. A SceneStack starts it.
  • A WorldTicker, which steps the world on the scene node's tick. Pause or dispose the node and the world stops with it.
  • An InputPolicy, which turns the core InputMap into an ActionFrame of named actions, and a WillowPresentationAdapter, which draws each Body as a willow Quad in the present phase.
ts
import { Node, Quad } from "willow";
import { AppScene, ComponentKey, Entity, InputPolicy, PhaseSchedule, SceneStack, World, WorldTicker } from "gamekit";
import { WillowPresentationAdapter, WillowPresentationState } from "gamekit/src/willow";

enum Action { LEFT, RIGHT, MOVE_X }

class Body {
    x: float;
    y: float;
    speed: float = 0.0;
    constructor(x: float, y: float) {
        this.x = x;
        this.y = y;
    }
}

const BODY: ComponentKey<Body> = new ComponentKey<Body>("body");

class PlayScene extends AppScene {
    private world: World = new World();
    private schedule: PhaseSchedule = new PhaseSchedule();
    private input: InputPolicy = new InputPolicy();
    private adapter: WillowPresentationAdapter<Body>;
    private ticker: WorldTicker | null = null;
    private root: Node = new Node("play");

    constructor() {
        super("play");
        this.world.registerStore(BODY);
        InputMap.bind(Action.LEFT as int, [Key.LEFT, Key.A]);
        InputMap.bind(Action.RIGHT as int, [Key.RIGHT, Key.D]);
        InputMap.axis(Action.MOVE_X as int, Action.LEFT as int, Action.RIGHT as int);
        this.input.watch("move_x", Action.MOVE_X as int);

        // Presentation: one Quad per Body, synced in the present phase.
        this.adapter = new WillowPresentationAdapter<Body>(
            (body: Body): Node => new Quad(32.0, 32.0, Color{ value: 0x40C080FF }),
            (body: Body): WillowPresentationState => {
                const state: WillowPresentationState = new WillowPresentationState(this.root);
                state.x = body.x;
                state.y = body.y;
                return state;
            });

        this.schedule.addPhase("input", [], true);
        this.schedule.addPhase("simulate", ["input"], true);
        this.schedule.addPhase("present", ["simulate"]);
        this.schedule.addSystem("input", (w: World, dt: float, tick: int): void => {
            const axis: float = this.input.frame().axis("move_x");
            w.query(BODY).each((e: Entity, b: Body): void => { b.speed = axis * 200.0; });
        });
        this.schedule.addSystem("simulate", (w: World, dt: float, tick: int): void => {
            w.query(BODY).each((e: Entity, b: Body): void => { b.x += b.speed * dt; });
        });
        this.schedule.addSystem("present", (w: World, dt: float, tick: int): void => {
            this.adapter.present();
        });

        engine.root2d.add(this.root);
        this.attachNode(this.root);
    }

    override async enter(): Promise<void> {
        const player: Entity = this.world.createEntity();
        const body: Body = new Body(304.0, 224.0);
        this.world.put(player, BODY, body);
        this.adapter.bind(body);
        // The runtime's fixed tick steps the world while this scene's node runs.
        this.ticker = new WorldTicker(this.root, this.world, this.schedule, App.tickDelta);
    }

    override async exit(): Promise<void> {
        if (this.ticker != null) { this.ticker.dispose(); }
        this.adapter.dispose();
        this.world.dispose();
    }
}

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

The simulation never touches a willow node. Systems change Body records, and only the present phase copies them onto the screen. That split is what lets the same simulation run headless in a test.

Next ​