Appearance
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
dtcompletes 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.