Skip to content

Crafting machine

A kitchen that turns bread and cheese into sandwiches: start button with status-driven feedback, a live progress bar, and an optional auto-restart policy.

State & wiring

ts
import { createEngine } from "@idlekitjs/core";
import { Renderer, bindText, bindDisabled } from "@idlekitjs/dom";
import { createRafScheduler } from "@idlekitjs/browser/raf-scheduler";
import { crafting, type CraftingJob, type ResourceBag } from "@idlekitjs/mechanics/crafting";

interface State {
  resources: ResourceBag;
  crafting: { jobs: CraftingJob[] };
}

const renderer = new Renderer();
const engine = createEngine<State>({
  initialState: {
    resources: { bread: 10, cheese: 5 },
    crafting: { jobs: [] },
  },
  renderer,
  scheduler: createRafScheduler(),
});

const kitchen = crafting<State>({
  recipes: [
    {
      id: "sandwich",
      inputs: { bread: 2, cheese: 1 },
      outputs: { sandwich: 1 },
      duration: 10,
      machineType: "kitchen",
    },
  ],
  machines: [{ id: "kitchen-1", type: "kitchen" }],
  getResources: (s) => s.resources,
  setResources: (s, resources) => {
    s.resources = resources;
  },
  getJobs: (s) => s.crafting.jobs,
  setJobs: (s, jobs) => {
    s.crafting.jobs = jobs;
  },
});

engine.use(kitchen);

Status-driven button

status returns exactly what the UI should say — switch on it instead of reimplementing the checks:

ts
function craftLabel(): string {
  const status = kitchen.status(engine.state, "sandwich", "kitchen-1");
  switch (status.kind) {
    case "ready":
      return "Make sandwich (2 bread, 1 cheese)";
    case "crafting":
      return `Crafting… ${Math.round(status.progress * 100)}%`;
    case "missing-inputs":
      return `Missing: ${Object.entries(status.missing)
        .map(([id, n]) => `${n} ${id}`)
        .join(", ")}`;
    case "machine-busy":
      return "Kitchen is busy";
    default:
      return "Unavailable";
  }
}

const craftEl = document.querySelector<HTMLButtonElement>("#craft")!;
renderer.add(bindDisabled(craftEl, () => !kitchen.canStart(engine.state, "sandwich", "kitchen-1")));
craftEl.addEventListener("click", () => kitchen.start(engine.state, "sandwich", "kitchen-1"));

The label reads job progress (mutated in place each tick), so bind it per frame; the stock line is a normal reactive binding:

ts
const stockEl = document.querySelector<HTMLElement>("#stock")!;
renderer.addFrame(bindText(craftEl, craftLabel));
renderer.add(
  bindText(stockEl, () => {
    const r = engine.state.resources;
    return `bread ${r.bread ?? 0} · cheese ${r.cheese ?? 0} · sandwiches ${r.sandwich ?? 0}`;
  }),
);

engine.start();

Progress bar

ts
const barEl = document.querySelector<HTMLElement>("#bar")!;
renderer.addFrame({
  update() {
    barEl.style.width = `${kitchen.progressFraction(engine.state, "kitchen-1") * 100}%`;
  },
});

Auto-restart (a game policy, not a mechanic feature)

Crafting deliberately doesn't queue or loop. If your design wants the kitchen to keep going while inputs last, that's three lines of game code:

ts
const kitchen = crafting<State>({
  // ...same as above, plus:
  onComplete: (job) => {
    // requeue the same recipe if it can start again
    queueMicrotask(() => kitchen.start(engine.state, job.recipeId, job.machineId));
  },
});

(The microtask defers the restart out of the completion pass; the start simply no-ops with a missing-inputs status when the bread runs out.)

What you get for free

  • Saving: jobs are plain data in state.crafting.jobs — the save setup persists mid-craft jobs untouched.
  • Offline: a big catch-up dt completes every due job (with auto-restart above, it chains as far as inputs allow — one job per pass).
  • Patches: on load, jobs for removed recipes/machines are dropped and durations re-derived — see Crafting.