Build an AI agent to run your kitchen. Top scores earn automatic admission to Hack the 6ix 2026.
Five chefs move on a grid kitchen. You send them to stations with KitchenAPI.command(chefId, stationId). Orders appear at reception stands; each order lists the exact ingredients and preparation state required (raw, chopped, or cooked).
Chefs take ingredients from bins → chop at cutting boards → cook on stoves → drop ingredients onto a plating area → pick the plate back up → deliver it to the reception stand that holds that order.
Plating areas are infinite. Drop any ingredient on one and it accumulates; picking up from an area with items gives the chef a held plate containing those items. There is no plate inventory, no dish rack, and no sink — once a customer finishes eating they simply leave.
The game ends after three expired orders. Score = delivery points + endurance-time bonus after 10 minutes. Difficulty ramps aggressively: the first minute is approachable manually; after that, automation is essentially required.
| Phase | Approx. Time | What's new |
|---|---|---|
| Tutorial | 0:00 – 1:00 | Salad & Steak only; generous timers |
| Ramp | 1:00 – 2:30 | Burger unlocks; spawn rate tightens |
| Automation | 2:30 – 10:00 | Pizza → Deluxe Burger → Feast → Supreme Pizza in stages; timers shrink on a curve |
| Endurance | 10:00+ | Full recipe mix, tightest pacing, bonus score tick per second |
KitchenAPI.run(fn).KitchenAPI.start().KitchenAPI.run(fn) — it survives start() and restarts, and re-pasting replaces the old agent. Raw onTick listeners are cleared on every start(); run() avoids that.<script> tag — anything that runs where window.KitchenAPI exists.Type: string. Current value: "2.0.0".
Recommended entry point. Registers fn as your agent. It runs once per game frame and survives start() and restarts. Calling run() again replaces the previous agent, so re-pasting your script is always safe (no double-registration).
Signature: fn(state, api, tick), where state is a fresh getState() snapshot, api is KitchenAPI, and tick is { dt, time }. Exceptions thrown inside fn are caught and logged as "Agent error:" — the run continues.
Returns: undefined.
KitchenAPI.run((s, api) => {
if (!s.running || s.paused) return;
// your logic here
});
KitchenAPI.start();
Unregisters the agent set by run(). Does not stop the game.
Returns: undefined.
Canonical LLM entry point. Registers an async planner that runs on a cadence (default every 1000ms, clamped 100–60000ms) independent of the game's tick loop. The planner's return value is stored as the current policy and exposed on state.policy and via KitchenAPI.getPolicy(). Re-entrancy is guarded — if a planner call takes longer than the interval, subsequent ticks are skipped rather than queued.
Signature: fn(state, api) → Promise<policy>. Exceptions are caught and logged; the last good policy is preserved on error.
Use plan() together with run() for the two-tier pattern: a slow async planner sets policy, a fast sync executor dispatches commands against it. This is the recommended shape for any agent whose decision step takes longer than a frame (LLMs, MILP solvers, search-based planners).
KitchenAPI.plan(async (s) => {
// Slow side: call an LLM, solve an LP, whatever takes >16ms.
// Return any object — it becomes state.policy.
const urgent = [...s.orders].sort((a, b) => a.timeLeft - b.timeLeft)[0];
return { focusDish: urgent?.dish || null };
}, { everyMs: 2000 });
KitchenAPI.run((s, api) => {
// Fast side: dispatch against the current policy.
if (!s.running || s.paused || !s.policy) return;
// ...act on s.policy.focusDish with the same per-tick guards as before.
});
Unregisters the planner. Clears the current policy. Does not affect the run() agent.
Returns the latest value the planner returned (or null if no planner is registered or it hasn't resolved yet).
Removes all event listeners registered via on() / onTick() / etc. The run() agent is re-attached automatically. Useful to reset raw listeners without restarting.
Returns: undefined.
Starts or restarts the game. Resets stations, chefs, orders, and score. Clears raw event listeners (those registered with onTick, on, etc.), so re-register them after each start(), or use KitchenAPI.run() instead, which survives restarts automatically.
Returns: undefined.
Toggles the pause state. Syncs with the in-game Pause button.
Returns: undefined.
Signature: (chefId: number, stationId: string) → { success: boolean, error?: string }
Queues pathfinding to a walkable tile adjacent to the target station, then triggers the same interaction as a mouse click. Fails if: game not running or paused, invalid chef id, chef is busy, unknown station id, no adjacent walkable tile, or A* finds no path.
Chef IDs: 0–4. Station IDs: strings like 'bin_3', 'stove_0', 'reception_2', see Section 4.
const r = KitchenAPI.command(0, 'plating_0');
if (!r.success) console.error(r.error);
Returns: { success: boolean, error?: string }. Applies a 3.5 s movement speed boost with a 12 s cooldown. Same as pressing B in-game.
Visual selection only (highlights chef in UI). Does not move the chef.
Returns: undefined.
Returns a plain snapshot of the entire game state. Ingredient objects are { ingredient, state }. Plates are { type: 'plate', items: [...] }. The run ends when failedOrders >= maxFailedOrders (currently 3).
Reception order when present: { id, dish, timeLeft, components }. Stove ready / burnt mirror the UI progress thresholds.
Returns: Array<{ name, icon, difficulty, components }>, static recipe metadata without internal step strings. icon is a short string code (e.g. "STK" for Steak).
Each method returns an unsubscribe function: const off = KitchenAPI.onTick(fn); off();
| Method | Alias / event name | Payload |
|---|---|---|
KitchenAPI.on(eventName, fn) | generic | varies |
KitchenAPI.onTick(fn) | 'tick' | { dt, time } |
KitchenAPI.onOrderSpawned(fn) | 'orderSpawned' | { id, dish, timeLeft, standId, components } |
KitchenAPI.onOrderExpired(fn) | 'orderExpired' | { id, dish, standId } |
KitchenAPI.onOrderDelivered(fn) | 'orderDelivered' | { id, dish, score, streak } |
KitchenAPI.onOrderFailed(fn) | 'orderFailed' | { dish } |
KitchenAPI.onPhaseChanged(fn) | 'phaseChanged' | { phase } |
KitchenAPI.onRushBurst(fn) | 'rushBurst' | { count } — fires when rush activates and spawns a burst of orders simultaneously |
KitchenAPI.onGameOver(fn) | 'gameOver' | { score, time, bestStreak } |
| Station | ID pattern | Count | Notes |
|---|---|---|---|
| Ingredient bin | bin_0 … bin_5 | 6 | bin_0 tomato · bin_1 lettuce · bin_2 onion · bin_3 meat · bin_4 dough · bin_5 cheese |
| Stove | stove_0 … stove_2 | 3 | Cooks raw meat or dough; pick up at ~80% progress; burns if left too long |
| Cutting board | cutting_0, cutting_1 | 2 | Chops raw ingredients (not dough) |
| Plating area | plating_0 … plating_3 | 4 | Infinite. Drop ingredients onto it; empty-handed pickup wraps them into a plate. Holding a plate + interact merges its items back onto the area. |
| Trash | trash_0 | 1 | Destroys the chef's held item permanently |
| Counter | counter_0 … counter_N | ~15 | Temporary storage; check getState().stations.counters for exact IDs |
| Reception stand | reception_0 … reception_4 | 5 | Deliver matching plate. After eating, the customer leaves and the stand becomes available again. |
| Dish | Components (ingredient · state) | Quick steps |
|---|---|---|
| 🥗 Salad | lettuce · chopped, tomato · chopped | Chop both → plate → deliver |
| 🥩 Steak | meat · cooked | Cook meat → plate → deliver |
| 🍔 Burger | meat · cooked, dough · raw | Cook meat, bun (dough) on plate → deliver |
| 🍕 Pizza | dough · cooked, tomato · chopped, cheese · raw | Cook dough, chop tomato, cheese on plate → deliver |
| 🍔+ Deluxe Burger | meat · cooked, dough · raw, onion · chopped | Cook meat, chop onion, dough on plate → deliver |
| 🍱 Feast Platter | meat · cooked, lettuce · chopped, tomato · chopped, cheese · raw | Cook meat, chop veg, cheese on plate → deliver |
| 🍕+ Supreme Pizza | dough · cooked, tomato · chopped, onion · chopped, cheese · raw | Cook dough, chop tomato & onion, cheese on plate → deliver |
!chef.busy && !chef.hasPath before issuing the next command for that chef.busy = true. Don't stack commands on a busy chef.(ingredient, state) pair must match; extras cause a failed delivery.state.rush.active to react.onRushBurst or watch for back-to-back onOrderSpawned events to react in time.state.upcomingOrders previews the next 3 dishes before they go live, each with an estimated etaSeconds. The dishes are locked in (no re-roll on consumption), but ETAs are estimates and will shorten dramatically when rush triggers.command() while the chef still has ≥3 tiles of path to a different target) costs a 1.5s stall. Idempotent re-issues (same target) and commands from idle are free. Check chef.stall before issuing new commands. Applies equally to human clicks and agent commands.KitchenAPI.plan(asyncFn, { everyMs: 1000 }). The planner runs independently of the tick loop; its return value is exposed on state.policy for a fast run() executor to dispatch against. This is how to integrate an LLM without starving the game loop.order.timeLeft.plating_0..3 are independent buffers. Use one per recipe-in-progress to avoid mixing ingredients from different orders.command() with running && !paused && !chef.busy && !chef.hasPath to avoid error spam.onOrderSpawned rather than polling each tick to cut latency.stove.burnt and clear it promptly.upcomingOrders — if a Pizza is 8 seconds out, start cooking dough now. Reactive agents that only react to onOrderSpawned waste the cook/chop window.onRushBurst to trigger your batch path.KitchenAPI.plan() for the decision step (set roles, assign chefs to orders, batch upcoming pizzas) and KitchenAPI.run() for the dispatch step (cheap per-tick guards + command()). Don't await inside run() — the tick loop will block.These are teaching examples, not optimal solutions. They omit multi-recipe routing, full bussing, boost timing, and rush handling.
Bin → stove → plating area → reception. Plating areas are infinite, so picking up from one with cooked meat on it produces a held plate containing that meat.
KitchenAPI.run((s, api) => {
if (!s.running || s.paused) return;
const c = s.chefs[0];
if (c.busy || c.hasPath) return;
const steak = s.orders.find(o => o.dish === 'Steak');
if (!steak) return;
const h = c.holding;
const platItems = s.stations.platingAreas[0].items || [];
const meatReady = platItems.some(i => i.ingredient === 'meat' && i.state === 'cooked');
if (!h) { api.command(0, meatReady ? 'plating_0' : 'bin_3'); return; }
if (h.ingredient === 'meat' && h.state === 'raw') { api.command(0, 'stove_0'); return; }
if (h.ingredient === 'meat' && h.state === 'cooked') { api.command(0, 'plating_0'); return; }
if (h.type === 'plate') { api.command(0, steak.standId); }
});
Chef 0 cooks meat; Chef 1 chops lettuce for Salad; Chef 2 plates and delivers the most urgent available order.
KitchenAPI.run((s, api) => {
if (!s.running || s.paused) return;
const go = (id, station) => {
const c = s.chefs[id];
if (c && !c.busy && !c.hasPath) api.command(id, station);
};
// Chef 0: cycle meat through stove → plating_0
const c0 = s.chefs[0];
if (!c0.busy && !c0.hasPath) {
const h = c0.holding;
if (!h) go(0, 'bin_3');
else if (h.ingredient === 'meat' && h.state === 'raw') go(0, 'stove_0');
else if (h.ingredient === 'meat' && h.state === 'cooked') go(0, 'plating_0');
}
// Chef 1: chop lettuce whenever a Salad order exists
const c1 = s.chefs[1];
if (!c1.busy && !c1.hasPath && s.orders.some(o => o.dish === 'Salad')) {
const h = c1.holding;
if (!h) go(1, 'bin_1');
else if (h.ingredient === 'lettuce' && h.state === 'raw') go(1, 'cutting_0');
else if (h.ingredient === 'lettuce' && h.state === 'chopped') go(1, 'plating_1');
}
// Chef 2: pick up the prepared items from plating_1 → deliver most urgent order
const c2 = s.chefs[2];
if (!c2 || c2.busy || c2.hasPath) return;
const urgent = [...s.orders].sort((a, b) => a.timeLeft - b.timeLeft)[0];
if (!urgent) return;
const h2 = c2.holding;
if (!h2) { go(2, 'plating_1'); return; }
if (h2.type === 'plate' && h2.items?.length > 0) { go(2, urgent.standId); return; }
});
Uses upcomingOrders to start cooking before the order is live. When a Pizza is forecast within 10 seconds, Chef 0 picks up dough and stoves it now — so the dough is already cooked by the time the order spawns. The same pattern generalizes to any cook-bound recipe.
KitchenAPI.run((s, api) => {
if (!s.running || s.paused) return;
const c = s.chefs[0];
if (c.busy || c.hasPath) return;
// Live work wins over speculative work.
const livePizza = s.orders.find(o => o.dish === 'Pizza' || o.dish === 'Supreme Pizza');
const soonPizza = s.upcomingOrders.find(u =>
(u.dish === 'Pizza' || u.dish === 'Supreme Pizza') && u.etaSeconds < 10
);
const want = livePizza || soonPizza;
if (!want) return;
const h = c.holding;
const platItems = s.stations.platingAreas[0].items || [];
const doughReady = platItems.some(i => i.ingredient === 'dough' && i.state === 'cooked');
if (!h) { api.command(0, doughReady ? 'plating_0' : 'bin_4'); return; }
if (h.ingredient === 'dough' && h.state === 'raw') { api.command(0, 'stove_0'); return; }
if (h.ingredient === 'dough' && h.state === 'cooked') { api.command(0, 'plating_0'); return; }
});
onRushBurst to switch into a batch-parallel mode the instant rush hits, and you'll consistently outscore role-split FSMs in endurance.The canonical shape for any agent whose decision step is slower than a frame. The planner runs every 2 seconds (swap in fetch() to your model of choice); the executor runs every tick and just dispatches against the latest policy. The game loop never blocks on the LLM.
// Replace this with a real model call to Anthropic / OpenAI / your backend.
async function callLLM(state) {
// ... await fetch('/api/plan', { method: 'POST', body: JSON.stringify(state) })
// For this skeleton, just pick the most urgent dish.
const urgent = [...state.orders].sort((a, b) => a.timeLeft - b.timeLeft)[0];
return { assignments: { 0: urgent?.dish || null } };
}
KitchenAPI.plan(async (s) => await callLLM(s), { everyMs: 2000 });
KitchenAPI.run((s, api) => {
if (!s.running || s.paused || !s.policy) return;
const dish = s.policy.assignments?.[0];
if (!dish) return;
const order = s.orders.find(o => o.dish === dish);
if (!order) return;
const c = s.chefs[0];
if (c.busy || c.hasPath || c.stall > 0) return;
// ... your per-recipe dispatch logic here, e.g. for Steak:
if (dish === 'Steak') {
const h = c.holding;
if (!h) api.command(0, 'bin_3');
else if (h.ingredient === 'meat' && h.state === 'raw') api.command(0, 'stove_0');
else if (h.ingredient === 'meat' && h.state === 'cooked') api.command(0, 'plating_0');
else if (h.type === 'plate') api.command(0, order.standId);
}
});
await inside run(). The executor runs every frame and must return in <16ms. All slow work belongs in plan().state.policy is populated quickly. Subsequent updates happen on the everyMs cadence; if a planner call exceeds the interval, the next tick is skipped, not queued — so you can't accidentally pile up in-flight LLM calls.