Skip to content

Instantly share code, notes, and snippets.

@thevtm
Last active July 12, 2026 10:28
Show Gist options
  • Select an option

  • Save thevtm/dee220828c1c0e19394f61da76a38a84 to your computer and use it in GitHub Desktop.

Select an option

Save thevtm/dee220828c1c0e19394f61da76a38a84 to your computer and use it in GitHub Desktop.
A JavaScript bot for the Gear Factory idle game
/**
* Gear Factory Bot
* ================
*
* A JavaScript bot that automates the idle game "Gear Factory".
*
* What it does:
* - Buy Gear: repeatedly clicks the "Buy Gear" button on the Build tab.
* - Sort: repeatedly clicks the "Sort" button.
* - Merge: merge gears very quickly.
*
* How to use:
* 1. Open the game and make sure the game's iframe is selected as the
* console's execution context (the iframe dropdown at the top of the
* DevTools Console). The bot reads the game's React state, so it must
* run inside the game frame — not the top-level page.
* 2. Paste this entire script into the console and press Enter.
* 3. Run `bot.start()` to start every bot, or start them individually:
* bot.startBuyGear();
* bot.startSort();
* bot.startMerge();
* 4. Run `bot.stop()` to stop everything (or bot.stopBuyGear(),
* bot.stopSort(), bot.stopMerge() to stop them individually).
*/
if (window.bot != null) {
console.info("[bot] bot already defined, stopping previous instance");
window.bot.stop();
window.bot = null;
}
window.bot = {
start() {
this.preflightChecks();
this.startBuyGear();
this.startBuyBuildUpgrades();
this.startSort();
this.startMerge();
},
stop() {
this.stopBuyGear();
this.stopBuyBuildUpgrades();
this.stopSort();
this.stopMerge();
},
preflightChecks() {
this.ensureGameStateHookHasBeenFound();
},
assert(condition, message) {
if (!condition) {
throw new Error(message);
}
return condition;
},
// State
stateFiber: null,
recorderState: "idle",
recorderLog: [],
ensureGameStateHookHasBeenFound() {
if (this.stateFiber != null) {
return;
}
this.stateFiber = this.findStateFiber(this.findRootFiber());
this.assert(this.stateFiber != null, "[bot] [state] could not find game state fiber");
},
getGameState() {
this.ensureGameStateHookHasBeenFound();
const stateHook = this.findStateHook(this.stateFiber, 0);
this.assert(stateHook != null, "[bot] [state] state hook is not set");
return stateHook.memoizedState;
},
dispatchActionToGameState(action) {
this.ensureGameStateHookHasBeenFound();
const stateHook = this.findStateHook(this.stateFiber, 0);
this.assert(stateHook != null, "[bot] [state] state hook is not set");
this.assert(stateHook.queue != null, "[bot] [state] state hook does not have a queue");
this.assert(
typeof stateHook.queue.dispatch === "function",
"[bot] [state] state hook does not have a dispatch function",
);
return stateHook.queue.dispatch(action);
},
findRootFiber() {
const rootEl = document.querySelector("#root") || document.body.firstElementChild;
const rootKey = Object.keys(rootEl).find((k) => k.startsWith("__reactContainer$"));
const rootFiber = rootEl[rootKey];
this.assert(rootFiber, "[bot] React root fiber not found on #root");
return rootFiber;
},
findStateFiber(rootFiber, name = "Re") {
let target = null;
const walk = (fiber) => {
while (fiber && !target) {
const t = fiber.type;
if (typeof t === "function" || (typeof t === "object" && t !== null)) {
const n = t.displayName || t.name || t.render?.name || t.type?.name || "";
if (n === name) {
target = fiber;
return;
}
}
if (fiber.child) walk(fiber.child);
fiber = fiber.sibling;
}
};
walk(rootFiber.current ?? rootFiber);
this.assert(target, `[bot] No component named "${name}" found in the fiber tree`);
return target;
},
findStateHook(state_fiber, hookIndex = 0) {
let hook = state_fiber.memoizedState;
for (let i = 0; i < hookIndex && hook; i++) hook = hook.next;
this.assert(hook != null, `[bot] [state] hook at index ${hookIndex} not found`);
return hook;
},
startRecordingGameStateActions() {
this.ensureGameStateHookHasBeenFound();
if (this.recorderState === "recording") {
console.warn("[bot] [state] [recorder] already recording");
return;
}
queue = this.findStateHook(this.stateFiber).queue;
this.assert(queue != null, "[bot] [state] [recorder] state hook does not have a queue");
this.recorderLog = [];
this.recorderState = "recording";
const real = queue.dispatch;
queue.__real = real;
queue.dispatch = (...args) => {
this.recorderLog.push(args.length === 1 ? args[0] : args);
console.log("%c[action]", "color:#f60", ...args);
return real(...args);
};
queue.__spied = true;
console.log("[bot] [state] [recorder] Recording. Interact with the app, then read `.log`.");
return this;
},
stopRecordingGameStateActions() {
if (this.recorderState !== "recording") {
console.warn("[bot] [state] [recorder] not recording");
return;
}
const queue = this.findStateHook(this.stateFiber).queue;
this.assert(queue != null, "[bot] [state] [recorder] state hook does not have a queue");
this.assert(queue.__spied != null, "[bot] [state] [recorder] queue.__spied is not set");
this.assert(queue.__real != null, "[bot] [state] [recorder] queue.__real is not set");
this.recorderState = "idle";
queue.dispatch = queue.__real;
delete queue.__real;
delete queue.__spied;
},
// Buy Gear Bot
intervalIdBuyGear: null,
startBuyGear(delayMs = 1000) {
if (this.intervalIdBuyGear) {
console.warn("[bot] [buy gear] bot already running, call bot.stop() first");
return;
}
this.intervalIdBuyGear = setInterval(() => this.runBuyGear(), delayMs);
console.log("[bot] [buy gear] bot started");
},
stopBuyGear() {
if (this.intervalIdBuyGear) {
clearInterval(this.intervalIdBuyGear);
this.intervalIdBuyGear = null;
console.log("[bot] [buy gear] bot stopped");
} else {
console.log("[bot] [buy gear] bot is not running");
}
},
runBuyGear() {
this.dispatchActionToGameState({
type: "BUY_MULT_GEAR",
});
this.dispatchActionToGameState({
type: "BUY_MAX_GEAR",
});
},
// Buy Build Upgrades Bot
intervalIdBuyBuildUpgrades: null,
startBuyBuildUpgrades(delayMs = 1000) {
if (this.intervalIdBuyBuildUpgrades) {
console.warn("[bot] [buy build upgrades] bot already running, call bot.stop() first");
return;
}
this.intervalIdBuyBuildUpgrades = setInterval(() => this.runBuyBuildUpgrades(), delayMs);
console.log("[bot] [buy build upgrades] bot started");
},
stopBuyBuildUpgrades() {
if (this.intervalIdBuyBuildUpgrades) {
clearInterval(this.intervalIdBuyBuildUpgrades);
this.intervalIdBuyBuildUpgrades = null;
console.log("[bot] [buy build upgrades] bot stopped");
} else {
console.log("[bot] [buy build upgrades] bot is not running");
}
},
runBuyBuildUpgrades() {
this.dispatchActionToGameState({
type: "BUY_MAX_UPG",
id: "speed",
max: 100,
baseCost: 50,
scale: 1.8,
});
this.dispatchActionToGameState({
type: "BUY_MAX_UPG",
id: "multi",
max: 100,
baseCost: 100,
scale: 2,
});
},
// Sort Bot
intervalIdSort: null,
startSort(delayMs = 500) {
if (this.intervalIdSort) {
console.warn("[bot] [sort] bot already running, call bot.stop() first");
return;
}
this.intervalIdSort = setInterval(() => this.runSort(), delayMs);
console.log("[bot] [sort] bot started");
},
stopSort() {
if (this.intervalIdSort) {
clearInterval(this.intervalIdSort);
this.intervalIdSort = null;
console.log("[bot] [sort] bot stopped");
} else {
console.log("[bot] [sort] bot is not running");
}
},
runSort() {
this.dispatchActionToGameState({
type: "SORT_GRID",
});
},
// Merge Bot
intervalIdMerge: null,
getGearNodes() {
return document.querySelectorAll(`[data-cell]`);
},
startMerge(delayMs = 100) {
if (!this.preflightChecksMerge()) {
console.error("[bot] [merge] preflight checks failed, aborting");
return;
}
if (this.intervalIdMerge) {
console.warn("[bot] [merge] bot already running, call bot.stop() first");
return;
}
this.intervalIdMerge = setInterval(() => this.runMerge(), delayMs);
console.log("[bot] [merge] merge bot started");
},
stopMerge() {
if (this.intervalIdMerge) {
clearInterval(this.intervalIdMerge);
this.intervalIdMerge = null;
console.log("[bot] [merge] merge bot stopped");
} else {
console.log("[bot] [merge] merge bot is not running");
}
},
preflightChecksMerge() {
if (this.getGearNodes().length === 0) {
console.error("[bot] [merge] no gear nodes found");
return false;
}
return true;
},
getGearValue(gear_state_cell) {
if (gear_state_cell == null) {
return "EMPTY";
}
if (gear_state_cell === "CENTER") {
return "CENTER";
}
if (gear_state_cell?.type === "SQUARE") {
return "SQUARE";
}
// Multiplier
const is_multiplier = gear_state_cell?.type === "MULT";
const is_permanent_multiplier = is_multiplier && gear_state_cell?.perm;
if (is_permanent_multiplier) {
return "PERMANENT_MULTIPLIER"; // skip permanent multipliers
}
if (is_multiplier) {
return "MULTIPLIER";
}
// Normal gear
if (gear_state_cell?.tier == null) {
console.warn(`[bot] [merge] unknown gear cell at index ${i}, skipping`, gear_state_cell);
return "UNKNOWN";
}
return `NORMAL_GEAR:${gear_state_cell.tier}`;
},
canMergeMultGears() {
return this.getGameState().pUpgrades.moveMultGear && this.getGameState().pUpgrades.mergeMultGear;
},
runMerge() {
// const gear_nodes = [...this.getGearNodes()];
const gear_prev_by_value = new Map();
const gear_grid = this.getGameState().grid;
for (const [gear_index, gear_cell] of gear_grid.entries()) {
// const gear = gear_nodes[i];
const gear_value = this.getGearValue(gear_cell);
if (
gear_value === "EMPTY" ||
gear_value === "CENTER" ||
gear_value === "PERMANENT_MULTIPLIER" ||
gear_value === "SQUARE"
) {
continue;
}
if (this.canMergeMultGears() === false && gear_value === "MULTIPLIER") {
continue;
}
const prev_gear_index = gear_prev_by_value.get(gear_value);
if (prev_gear_index != null) {
// this.simulateReactDrag(prev_gear_index, gear);
this.dispatchActionToGameState({
type: "MERGE",
from: prev_gear_index,
to: gear_index,
});
gear_prev_by_value.delete(gear_value);
// return; // only merge one pair per run
} else {
gear_prev_by_value.set(gear_value, gear_index);
}
}
},
sleep(ms = 0) {
return new Promise((r) => setTimeout(r, ms));
},
async simulateReactDrag(sourceContainer, targetContainer) {
// dig out the draggable child from each container
const sourceEl = sourceContainer.querySelector("[draggable]") || sourceContainer;
const targetEl = targetContainer.querySelector("[draggable]") || targetContainer;
const dt = new DataTransfer(); // only feeds setDragImage
const at = (el) => {
const r = el.getBoundingClientRect();
return { x: r.x + r.width / 2, y: r.y + r.height / 2 };
};
const fire = (type, el, p) =>
el.dispatchEvent(
new DragEvent(type, {
bubbles: true,
cancelable: true,
composed: true,
clientX: p.x,
clientY: p.y,
dataTransfer: dt,
}),
);
const s = at(sourceEl),
t = at(targetEl);
fire("dragstart", sourceEl, s); // source's draggable → sets dragged item in state
await this.sleep(); // ← load-bearing: let React commit that state
fire("dragenter", targetEl, t);
fire("dragover", targetEl, t);
await this.sleep();
fire("drop", targetEl, t); // target's draggable → reads state, drops in
fire("dragend", sourceEl, s);
},
};
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment