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 → getSyncNeedsapplyVerifyData) is documented in full on the architecture page; this page is about what's actually running on the other end of that connection.

class Bot — zombsSessions.js:734 WebSocket server · port 8090 ~2,500 lines

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:

zombsSessions.js — connection gate
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):

zombsSessions.js — relay dispatch by prefix byte
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:

zombsSessions.js — Bot constructor (admission control & state init)
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:

zombsSessions.js — distance-based weapon switching (autoweaponswitch)
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:

zombsSessions.js — onEnterWorldHandler (anti-detection: a fake client Metrics report)
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 buttonWire commandServer-side effect
Enable/Disable Auto Break Ineabi,  ;<sid>,  ;<sessionName>,  ;<name>,  ;<psk> / dabiSets server.autoBreakIn + stashes the session name/display name/party share key the spawned bot should use
Enable/Disable Auto Refilleresrf,  ;<sid> / dsrfSets server.filler — keeps spawning fresh, party-less bots whenever the server isn't full
Enable/Disable Reconnectingearc,  ;<sid> / darcSets server.autoReconnect; disabling also calls clearReconnectState(sid), wiping any queued reconnects for that server
Enable/Disable Auto Farmeafr,  ;<sid> / dafrSets server.autoFarm — gates whether this.hasFarmed gets set immediately on world-entry or only after a real farming pass
Enable/Disable Party Refillereafp,  ;<sid> / dafpSets server.partyFiller — refill bots auto-join a configured party once it's missing members
Add/Delete Party Refiller Keyaddafpsk,  ;<psk>,  ;<sid> / removeafpskRegisters 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):

zombsSessions.js — Auto Break-In sweep
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:

zombsSessions.js — refiller & party-filler checks (inside the tick loop)
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:

zombsSessions.js — reconnect constants & the draining 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:

zombsSessions.js — command relay (autobuild shown in full; the rest follow the same enable/disable shape)
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:

zombsSessions.js — getSyncNeeds (abridged)
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.