Session Saver
zombsSessions.js — a ~2,500-line WebSocket server that runs fully headless, persistent bots which keep a base alive whether or not anyone's browser is open.
A Session is a Bot instance hosted entirely inside zombsSessions.js
— its own WebSocket straight to a zombs.io server, its own binary codec, its own automation
flags, its own AHRC schedule. It runs on a remote machine (a "VPS" in the manual's words) and keeps farming,
building, and defending around the clock. The browser doesn't drive it; the browser attaches to it —
authenticates, pulls a full state snapshot, and from then on relays chat-prefixed commands to it and renders
whatever it reports back. That attach flow (salt → verify → getSyncNeeds → applyVerifyData)
is documented in full on the architecture page; this page is
about what's actually running on the other end of that connection.
Gatekeeping the control channel
Every inbound WebSocket connection starts unauthenticated (hasAccess = false). The very first
message must be a salt, ;<password> handshake matching the server's in-memory
salt string — anything else, and the socket is closed immediately. Once authenticated, the same
connection becomes the channel for everything: spawning bots, attaching to them, and forwarding
their packets:
wss.on("connection", (ws) => {
let hasAccess = false;
ws.on("message", (m) => {
let x = new Uint8Array(m);
if (!hasAccess && x.length > 0) {
const msg = decode(m);
const args = msg.split(", ;");
if (args[0] === "salt" && args[1] === salt) {
hasAccess = true;
ws.id = ++connectionCounts;
connections.set(ws.id, ws);
ws.sendMessage("accesssuccess");
ws.sendMessage(`prfpsks, ;${Object.keys(keys)}`);
return;
}
ws.close();
return;
}
if (!hasAccess) return;
// … relay/command handling continues below
});
});
Notice the password isn't fixed at startup — changehasaccess lets an already-authenticated
client rotate salt on the fly (capped at 50 characters), and the server logs the new value to
its own console. That's the same salt the homepage's changePassword() handler
writes to when you update a session's password from the browser.
The packet relay — prefix bytes 1 and 2
Once a connection is verified against a specific session, two binary prefixes turn it into a
full-duplex pass-through to that Bot's live game socket — exactly the mechanism
User.applyVerifyData wires up client-side (see the architecture page for that half):
if (x[0] === 1 && ws.isVerified && session) {
x = x.slice(1);
const opcode = x[0];
if (opcode === 9) {
const data = session.codec.decode(x);
if (data.name === "BuyItem" && data.response.tier === 1) {
if (data.response.itemName === "PetCARL" || data.response.itemName === "PetMiner") return;
if (data.response.itemName === "Pickaxe" && session.inventory.Pickaxe) return;
// … same one-per-slot guards for Spear/Bow/Bomb
}
if (data.name === "SetPartyName" && !(encode(data.response.partyName).length <= 49)) return;
if (data.name === "SendChatMessage" && !(encode(data.response.message).length <= 249)) return;
}
session.ws.send(x);
return;
}
if (x[0] === 2 && ws.isVerified) {
if (!session) return;
if (session.ws.readyState === 1) {
session.ws.send(x.slice(1));
}
return;
}
Prefix 1 is a decoded RPC/Input forward — and notably, the server doesn't just blindly
relay it. It decodes opcode-9 (RPC) packets and applies the exact same sanity checks zombs.io
itself would reject on length or duplicate-purchase grounds (one starter weapon per slot, party names ≤49
bytes, chat messages ≤249 bytes) before spending the bandwidth to forward them — guarding the
headless bot from a malformed or malicious browser-side payload corrupting its state. Prefix 2
is a raw buffer pass-through with no inspection at all, used for the lower-level binary frames the
User class forwards directly. The same two checks are duplicated in the "packet"
JSON-command path a few lines down, for browsers that build RPCs from JS objects instead of raw bytes.
The Bot constructor
Spinning up a Session means opening a second WebSocket — this time from the server straight to
zombs.io — and initializing what amounts to an entire parallel game client: its own binary codec
(new BinCodec()), its own Scripts flag bag, its own building/entity/harvester maps,
and the exact same 8-tier AHRC schedule used by the player and every Alt:
class Bot {
constructor(sessionName = null, name = "", sid = "", psk = "", pt = false) {
if (!sid || !serverMap.get(sid)) return;
if ((serversSessions[sid]?.size ?? 0) >= RECONNECT_MAX_WS) return;
if (serversSessions[sid]) {
serversSessions[sid].forEach((ws) => {
if (ws.readyState === 2 || ws.readyState === 3) {
ws.close();
serversSessions[sid].delete(ws);
}
});
}
this.ws = new WebSocket(`wss://${serverMap.get(sid).host}`, { headers: { "Origin": "", "User-Agent": "" } });
this.ws.binaryType = "arraybuffer";
this.ws.onclose = () => {
// … unregisters from serversSessions/sessions, frees the WASM Module,
// and — if this Bot was attached and the server's autoReconnect is on — enqueueReconnect(this)
}
this.serverId = sid;
this.psk = psk;
this.playerTrickPsk = pt ? psk : null;
this.codec = new BinCodec();
this.scripts = new Scripts();
this.scripts.playertrick = pt;
this.harvesterTicks = [
{ tick: 0, resetTick: 31, deposit: 0.4, tier: 1 },
{ tick: 0, resetTick: 29, deposit: 0.6, tier: 2 },
// … tiers 3-8, byte-identical to the in-browser AHRC schedule
]
}
}
Two admission checks run before anything else: a hard cap of RECONNECT_MAX_WS (9) concurrent
sockets per server id, and a sweep that force-closes any half-dead sockets
(readyState 2/3 — closing/closed) still lingering in serversSessions[sid]. Both
exist for the same reason — new Bot(...) gets called constantly (manual spawns, the reconnect
queue, the refiller, auto break-in), and without these guards a bad server connection could spiral into
dozens of zombie sockets piling up against the same zombs.io server.
The tick loop — onEntitiesUpdateHandler
This is the headless mirror of Alt.onEntityUpdate and the
main script's bot loop — the same shape, the same toggle-driven structure, scaled up with PvP-aware
targeting logic that doesn't make sense for a follower alt. A representative slice — auto weapon-switching
by distance to the nearest enemy:
if (this.scripts.autoweaponswitch && this.nearestPlayer) {
const dist = Math.hypot(this.nearestPlayer.x - this.myPlayer.position.x, this.nearestPlayer.y - this.myPlayer.position.y);
if (dist > 300 && this.myPlayer.weaponName !== "Bow" && this.inventory.Bow) {
// … equip Bow, debounced via this.bowTicks (10-tick cooldown before re-checking)
}
if (dist > 100 && dist <= 300 && this.myPlayer.weaponName !== "Bomb" && this.inventory.Bomb) {
// … equip Bomb, debounced via this.bombTicks
}
if (dist <= 100 && this.myPlayer.weaponName !== "Spear" && this.inventory.Spear) {
// … equip Spear, debounced via this.spearTicks
}
}
That's a three-tier engagement range baked directly into the bot: snipe with the Bow past 300 units, switch
to Bombs in the 100–300 range, and close to melee Spear range under 100 — each transition gated by its own
10-tick counter so the bot doesn't flicker between weapons as a target weaves in and out of a boundary
distance. The same tick also drives AHRC via byte-identical
depositAhrc/collectAhrc methods, chat-spam, aim-lock, and a small
buildingUids_1 garbage-collector that expires stale building references after 10 ticks.
Entering the world — and faking a real client
onEnterWorldHandler runs the same starter-kit bring-up as
Alt.onEnterWorld (HatHorns, PetCARL, PetMiner, join by
share key), but headless bots have one problem an in-browser alt doesn't: there's no real renderer to report
believable performance metrics to the server. So the Session fabricates one — a hand-written
Metrics RPC with plausible-looking FPS, ping, and frame-interpolation numbers baked in as
constants:
for (let i = 0; i < 26; i++) {
this.sendPacket(3, { up: 1 });
}
this.sendPacket(7, {});
this.sendPacket(9, {
name: "Metrics",
minFps: 21.74, maxFps: 70.2, currentFps: 60.34, averageFps: 59.7,
framesRendered: 7442, framesInterpolated: 7442, framesExtrapolated: 0,
allocatedNetworkEntities: 200,
currentClientLag: 203, minClientLag: 99, maxClientLag: 398,
currentPing: 101.5, minPing: 91, maxPing: 113, averagePing: 96.85,
longFrames: 1, stutters: 142, group: 0, isMobile: 0,
timeResets: 1, maxExtrapolationTime: 0, extrapolationIncidents: 0,
totalExtrapolationTime: 0, differenceInClientTime: 16.7
});
The 26 repeated { up: 1 } input packets right before it are the bot physically walking forward
off the spawn point — almost certainly to clear the protective spawn area before the server starts expecting
normal play patterns. Between the canned Metrics report and the same
wasmmodule()/onDecodeOpcode5 anti-bot solver documented on the
architecture page, a Session is built to look — at the protocol
level — indistinguishable from a real, if laggy, browser tab.
The control panel — break-in, refill, reconnect, farm
Each entry in serverMap (one per configured zombs.io server) carries its own independent set of
automation flags, toggled from the homepage's Session Saver control panel via short text commands over the
same authenticated WebSocket. The panel's button handlers in client.html map directly onto the
server-side switch:
| Panel button | Wire command | Server-side effect |
|---|---|---|
| Enable/Disable Auto Break In | eabi, ;<sid>, ;<sessionName>, ;<name>, ;<psk> / dabi | Sets server.autoBreakIn + stashes the session name/display name/party share key the spawned bot should use |
| Enable/Disable Auto Refiller | esrf, ;<sid> / dsrf | Sets server.filler — keeps spawning fresh, party-less bots whenever the server isn't full |
| Enable/Disable Reconnecting | earc, ;<sid> / darc | Sets server.autoReconnect; disabling also calls clearReconnectState(sid), wiping any queued reconnects for that server |
| Enable/Disable Auto Farm | eafr, ;<sid> / dafr | Sets server.autoFarm — gates whether this.hasFarmed gets set immediately on world-entry or only after a real farming pass |
| Enable/Disable Party Refiller | eafp, ;<sid> / dafp | Sets server.partyFiller — refill bots auto-join a configured party once it's missing members |
| Add/Delete Party Refiller Key | addafpsk, ;<psk>, ;<sid> / removeafpsk | Registers a party share key (validated to exactly 20 chars) into server.keys and the global keys table broadcast to clients as prfpsks |
Auto Break-In is driven from a simple 15-second sweep over every server entry — if the flag
is set, it just keeps spawning a fresh bot with the stashed credentials until one successfully gets inside
(at which point onEnterWorldHandler flips autoBreakIn back off for that server):
setInterval(() => {
serverMap.forEach((e) => {
if (e.autoBreakIn) {
new Bot(e.abiSessionName, e.abiName, e.id, e.abiPsk, false);
}
});
}, 15000);
Auto Refiller (filler) and Party Filler
(partyFiller) both run from inside an existing bot's own tick loop — each live Session checks
its server's flags and, if conditions are right, either spawns a brand-new helper bot or joins a configured
party itself:
const server = serverMap.get(this.serverId);
if (server.filler && this.tick > server.tick && this.players !== 40) {
server.tick = this.tick + 300;
new Bot(this.sessionName, this.name, this.serverId, "", false);
}
if (server.partyFiller && !this.scripts.playertrick && !this.scripts.reverseplayertrick
&& !this.scripts.bossreverseplayertrick && !this.scripts.tokenreverseplayertrick && !this.gs) {
if (!Object.keys(server.keys).length) return;
if (!Object.keys(server.keys).includes(this.psk)) {
this.sendPacket(9, {
name: "JoinPartyByShareKey",
partyShareKey: Object.keys(server.keys)[Math.floor(Math.random() * Object.keys(server.keys).length)]
});
}
}
The refiller throttles itself with server.tick — every spawn pushes the next allowed spawn 300
ticks (about 15 seconds at the game's 20-tick rate) into the future, and it only fires while the server is
under its 40-player cap; the manual's !af entry describes the same day/night-aware behavior for
the in-browser equivalent ("Filling during Night will only work at 35+ players… a maximum of 5 Sockets can be
sent at a time"). Party Filler picks a random key from whichever ones have been registered for that
server — spreading helper bots across multiple configured parties rather than funneling everyone into one.
Auto-reconnect — a rate-limited queue, not a retry loop
When autoReconnect is on and a verified bot's socket drops, it doesn't just retry immediately —
it gets pushed onto a per-server queue with deduplication and burst limiting, drained by a single shared
interval:
const RECONNECT_TICK_MS = 5000;
const RECONNECT_BURST_MS = 7000;
const RECONNECT_BURST_MAX = 5;
const RECONNECT_MAX_WS = 9;
setInterval(() => {
const t = performance.now();
for (const sid of [...reconnectQ.keys()]) {
const q = reconnectQ.get(sid);
if (!serverMap.get(sid)?.autoReconnect || !q?.length) continue;
let b = reconnectBurst.get(sid);
if (!b) reconnectBurst.set(sid, b = { n: 0, t0: t });
if (t - b.t0 >= RECONNECT_BURST_MS) b.n = 0, b.t0 = t;
if (b.n >= RECONNECT_BURST_MAX || (serversSessions[sid]?.size ?? 0) >= RECONNECT_MAX_WS) continue;
const item = q.shift();
// … spawns new Bot(...) with the queued credentials, re-queues on failure,
// advances the burst counter on success
}
}, RECONNECT_TICK_MS);
Reading the constants together: checks run every 5 seconds, but each server can only actually spawn up to 5
reconnects within any rolling 7-second burst window, and never more than 9 total live sockets. The
deduplication key (enqueueReconnect's ${sessionName}\0${name}\0${empty}\0${psk}
composite) stops the same logical bot from being queued twice if it drops again before its first reconnect
lands — and the drain loop re-queues an item at the front (q.unshift) if the spawn didn't
actually increase the live socket count, so a server that's currently rejecting connections doesn't silently
eat queue entries.
Server-side command relay
This is the other half of the e/d-prefixed relay documented on the
chat commands page: the browser sends a short code like
"eaa" over user.sendMessage, and this switch turns it into a flag flip on the
attached Bot's own scripts object — the headless equivalent of the player toggling
ban.scripts:
case "eab":
if (!session || !session.gs) return;
session.scripts.autobuild = true;
session.inactiveRebuilder.forEach((e, t) => session.inactiveRebuilder.delete(t));
session.rebuilder.forEach((e, t) => session.rebuilder.delete(t));
for (const b of session.buildings.values()) {
session.rebuilder.set(
(b.x - session.gs.x) / 24 + (b.y - session.gs.y) / 24 * 1000,
[(b.x - session.gs.x) / 24, (b.y - session.gs.y) / 24, b.type,
(session.entities.get(b.uid) ? session.entities.get(b.uid).targetTick.yaw : 0)]
);
}
break;
case "dab":
if (!session) return;
session.scripts.autobuild = false;
session.inactiveRebuilder.forEach((e, t) => session.inactiveRebuilder.delete(t));
session.rebuilder.forEach((e, t) => session.rebuilder.delete(t));
break;
case "eaa": session.scripts.autoaim = true; break;
case "daa": session.scripts.autoaim = false; break;
case "eapr": session.scripts.autopetrevive = true; break;
case "eape": session.scripts.autopetevolve = true; break;
case "eaph": session.scripts.autopetheal = true; break;
case "eaaz": session.scripts.autoaimzombies = true; break;
case "eaad": session.scripts.autoaimdemons = true; break;
case "ept":
session.scripts.playertrick = true;
session.playerTrickPsk = session.psk;
break;
eab/dab are the meatiest pair because enabling auto-build has to actually
compute something — it walks every building the Session currently owns, converts each into the same
gold-stash-relative grid coordinates used by !record/!arb on the
chat commands page, and seeds a fresh rebuilder map from
scratch. Most of the rest are one-line flag flips — but they all share the
if (!session) return; guard, since these commands only make sense once a browser is actually
verified against a live session.
The sync snapshot — getSyncNeeds
The piece that makes "attaching" to an already-running Session feel seamless rather than like joining mid-game:
a single method that packages the bot's entire live state — starting tick, party info, day/night
cycle, leaderboard, every known building and entity, full inventory, chat backlog, and even the codec's
internal entity-tracking tables — into one JSON blob the browser can replay through
applyVerifyData as if it had been there from the
start:
getSyncNeeds() {
const syncNeeds = [];
syncNeeds.push({ allowed: 1, uid: this.uid, startingTick: this.tick, tickRate: 20, players: 1, maxPlayers: 40, … opcode: 4 });
syncNeeds.push({ name: "PartyInfo", response: this.partyInfo, opcode: 9 });
syncNeeds.push({ name: "PartyShareKey", response: { partyShareKey: this.psk }, opcode: 9 });
syncNeeds.push({ name: "DayCycle", response: this.dayCycle, opcode: 9 });
syncNeeds.push({ name: "Leaderboard", response: this.leaderboard, opcode: 9 });
syncNeeds.push({ name: "SetPartyList", response: Object.values(this.parties), opcode: 9 });
// … collects this.buildings, this.entities (as [uid, targetTick] pairs), and the codec's
// sortedUidsByType / removedEntitiesObj / absentEntitiesFlags / updatedEntityFlags tables
return {
tick: this.tick, entities, opcode: 0, syncNeeds, localBuildings,
inventory: this.inventory, messages: this.messages, serverId: this.serverId,
petActivated: !!this.petActivated, isPaused: this.myPlayer ? this.myPlayer.isPaused : 0,
sortedUidsByType, removedEntitiesObj: this.codec.removedEntitiesObj,
absentEntitiesFlags: …, updatedEntityFlags: …
};
}
That last group — the codec's internal flag/index tables — is the detail that makes this more than a simple "send the current world state" dump: it's reconstructing the exact decoder state the live connection is in, byte-tracking-table and all, so that the very next entity-update packet the browser receives from the relay decodes correctly against a codec that's been "warmed up" to match. Get this wrong and the binary protocol's incremental entity diffs — which assume a continuous decode history — would desync instantly.