How Banshee Is Built

Three Node.js processes and a heavily modified game client, talking over WebSockets.

Banshee isn't a single userscript — it's a small stack. A self-hosted copy of the zombs.io client (served by a tiny Express app) is paired with two purpose-built backend servers that exist purely to keep bots alive. Understanding how these four pieces connect makes the rest of the documentation much easier to follow.

The four processes

ProcessFileRoleListens on
Localhost server zombsLocalhost.js Express app. Serves the modded client (client.html, script.js, app.js, …), proxies the live zombs.io leaderboard, and serves the texture manifest + WASM module. :80 (HTTP)
Modded client client.html / public/script.js The actual game client plus Banshee's overlay (window.ban = new Script()): settings menu, automation toggles, chat-command interpreter, and the in-browser multibox (Alt) system. — (runs in your browser)
Session Saver zombsSessions.js Hosts fully headless, persistent bots ("Sessions"). Each is a Bot instance that connects directly to a zombs.io game server, runs its own copy of the automation tick loop, and can be "attached to" and remote-controlled from the browser client. :8090 (WebSocket)
Socket Server zombsSockets.js A small pool of WebAssembly modules that solve the game's anti-bot "blend field" challenge (opcodes 5/10) on behalf of in-browser Alts, which can't run the WASM solver themselves. :8100 (WebSocket)

Both backend servers load the same zombs_wasm.wasm module and carry an essentially identical copy of the WASM glue code (wasmmodule(), decodeBlendInternal, onDecodeOpcode5, finalizeOpcode10) — Banshee evidently extracted the anti-bot solver from the official client bundle once and reused it in both places.

1. The localhost server — what gets served

zombsLocalhost.js is a deliberately small Express app. It serves the client bundle from ./public, fronts the official leaderboard so the client doesn't have to deal with CORS, and hands back the WASM binary and a generated asset manifest:

zombsLocalhost.js (excerpt)
app.get("/manifest.json", (req, res) => {
    res.set("Cache-Control", "no-store");
    res.json(getPictureAssets());     // recursively lists every .svg/.png/.ico under public/images
});

app.get("/zombs-leaderboard", async (req, res) => {
    const category = leaderboardCategories.has(req.query.category) ? req.query.category : "wave";
    const time = leaderboardTimes.has(req.query.time) ? req.query.time : "24h";
    const cacheKey = `${category}:${time}`;
    const cached = leaderboardCache.get(cacheKey);
    res.set("Cache-Control", "no-store");
    if (cached && Date.now() - cached.createdAt < 60000) {
        res.json(cached.data);
        return;
    }
    // … fetches https://zombs.io/leaderboard/data?category=…&time=… with a 5s abort timeout,
    //    caches the JSON response for 60 seconds, and proxies it back …
});

app.get("/zombs_wasm.wasm", (req, res) => res.sendFile("zombs_wasm.wasm", { root: __dirname }));
app.get("/", (req, res) => res.sendFile("client.html", { root: __dirname }));

app.listen(80);

Two things worth noting: the leaderboard proxy exists purely so the modded client (running on localhost) can pull live zombs.io leaderboard data without hitting a cross-origin wall — with a 60-second in-memory cache to avoid hammering the upstream API — and the manifest endpoint is what lets script.js preload all ~1,000 custom textures up front (window.gameTexturePreloadPromise in client.html).

Heads up: the shipped zombs-leaderboard handler hard-codes a slur as the outbound User-Agent string. That's a property of Banshee's source, not this documentation — it's called out here (and redacted in the excerpt on the Localhost Server page) so nobody copies it forward by accident.

See Localhost Server for the full file.

2. The wire protocol — opcodes, RPCs, and inputs

Every WebSocket connection in this stack — the real game connection, a Session's connection, a Socket's connection, even the browser's connection back to the Session Saver — speaks the same binary packet format the live game uses. Banshee's own copy of the codec (BinCodec in zombsSessions.js) names the opcodes it cares about:

zombsSessions.js — packet opcodes
const packetIds = {
    0: "PACKET_ENTITY_UPDATE",
    1: "PACKET_PLAYER_COUNTER_UPDATE",
    2: "PACKET_SET_WORLD_DIMENSIONS",
    3: "PACKET_INPUT",
    4: "PACKET_ENTER_WORLD",
    5: "PACKET_PRE_ENTER_WORLD",
    6: "PACKET_ENTER_WORLD2",
    7: "PACKET_PING",
    9: "PACKET_RPC",
    // …and the name → id reverse mapping
};

Everything a bot (or the real client) does reduces to one of three things, mirroring the pattern described in zombscontext/03-userscripts.md:

  • RPC (opcode 9) — a named action: MakeBuilding, UpgradeBuilding, BuyItem, SendChatMessage, JoinPartyByShareKey, …
  • Input (opcode 3) — movement / aim / attack: { up: 1 }, { mouseDown: yaw }, { space: 1 }, …
  • Entity update (opcode 0) — the server pushing the world state to the client at ~20 ticks/second; this is the heartbeat every bot's automation loop hangs off of.

A Bot (in the Session Saver) and an Alt (in the browser) each keep their own instance of this codec and decode/encode packets exactly like the real client does — see this.codec = new BinCodec() in Bot's constructor and the analogous codec field on Alt.

3. The anti-bot handshake — opcodes 5 and 10, and the WASM solver

zombs.io gates new connections behind a "blend field" challenge: the server sends an opcode-5 packet, the client must run it through a WASM routine to produce a response, and a follow-up opcode-10 round trip finalizes the handshake. zombscontext/04-localhost-client.md calls this the MBF/WASM anti-bot solver (_MakeBlendField) — Banshee extracted that exact routine from the shipped client and ships its own copy as zombs_wasm.wasm, wrapped by an identical wasmmodule() helper in both backend servers:

zombsSessions.js / zombsSockets.js — solving the challenge
Module.onDecodeOpcode5 = (blended, hostname, callback) => {
    Module.blended = blended;
    Module.hostname = hostname;
    if (!Module.ready) {
        return (Module.opcode5Callback = callback);
    }
    Module.asm.j(255, 140);
    const decoded = Module.decodeBlendInternal(blended);
    const mcs = Module.asm.j(187, 22);
    const opcode6Data = [6];
    for (let i = 0; i < 16; i++) {
        opcode6Data.push(Module.HEAPU8[mcs + i]);
    }
    callback({ 5: decoded, 6: new Uint8Array(opcode6Data) });
}

Module.finalizeOpcode10 = (blended) => {
    const decoded = Module.decodeBlendInternal(blended);
    const list = new Uint8Array(decoded);
    const data = [10];
    for (let i = 0; i < decoded.byteLength; i++) data.push(list[i]);
    return new Uint8Array(data);
}

The cstr() shim inside wasmmodule() is the interesting part — it's how the WASM module's calls into "browser" globals get faked inside Node.js, by pattern-matching the strings the module asks for and returning canned answers:

zombsSessions.js — faking a browser environment for the WASM module
const cstr = (str) => {
    if (str.startsWith('typeof window === "undefined" ? 1 : 0')) return 0;
    if (str.startsWith("typeof process !== 'undefined' ? 1 : 0")) return 0;
    if (str.startsWith("Game.currentGame.network.connected ? 1 : 0")) return 1;
    if (str.startsWith("Game.currentGame.network.connectionOptions.ipAddress")) return Module.hostname;
    if (str.startsWith("Game.currentGame.world.myUid === null ? 0 : Game.currentGame.world.myUid")) return ((uid++) ? 0 : 1);
    if (str.startsWith('document.getElementById("hud").children.length')) return 24;
}

In other words: the WASM solver was written assuming it runs inside the real browser game (it literally probes for window, Game.currentGame, and DOM nodes), and Banshee's wrapper answers those probes with hard-coded values so the same binary will instantiate and run happily inside a headless Node process.

This handshake plays out slightly differently for the two kinds of extra characters:

  • A Session (Bot in the Session Saver) runs the WASM module itself — it lazily creates one with this.Module = wasmmodule() the first time it sees an opcode-5 packet.
  • A Socket / Alt running in the browser can't instantiate the module locally (browser sandboxing / bundle size), so it forwards the raw challenge bytes to the Socket Server, which keeps a small pool of pre-instantiated WASM modules — one per active alt — and relays the solved response back over its own connection. See Socket Server for that relay.

4. How a "session" gets from the server to your screen

The most architecturally interesting trick in Banshee is how a fully headless Bot running on a remote machine can be "opened" in your browser and look — and be controllable — exactly like a normal game session. The flow is:

  1. The browser's User class (in client.html) opens a WebSocket to the Session Saver and authenticates with a shared salt (salt,  ;<password>).
  2. It asks to verify a session ID. The server marks that connection as belonging to that session and immediately ships down a full state snapshotsession.getSyncNeeds() — covering buildings, entities, inventory, party info, day/night cycle, the codec's internal lookup tables, everything needed to "resume" mid-game.
  3. The client's applyVerifyData(data) handler replays that snapshot through the normal game-message pipeline (onMessage2) as if the server had just sent it, swaps game.network.sendPacket to route through the relay instead of a real socket, and the player is suddenly "in" that session's world.
  4. From then on, packets are tunnelled: input/RPC packets the player sends get prefixed with a routing byte (1 = forward to the Bot, 2 = raw buffer) and relayed to the Bot's real connection; entity/RPC updates from the Bot are mirrored straight back to every attached browser.
client.html — replaying a session snapshot into the live game UI
applyVerifyData(data) {
    const codec = codecJSON;
    for (let i in codec) game.network.codec[i] = codec[i];
    // … restore sortedUidsByType / removedEntitiesObj / flags …

    game.network.socket = { readyState: 1 };
    game.network.socket.send = (e) => this.sendBuffer(new Uint8Array(e));
    game.network.sendPacket = (e, t) => {
        if (e === 4 || e === 5 || e === 6 || e === 7) return;   // skip handshake-only opcodes
        this.sendPacket(e, t);
    }
    game.options.serverId = data.serverId;
    game.network.connectionOptions = serverObj[data.serverId];

    for (let i = 0; i < data.syncNeeds.length; i++) onMessage2(data.syncNeeds[i]);
    // … replay chat history, inventory, the entity snapshot, local buildings …

    document.title = `BAN_SESSION #${this.connectedToId} - ${ban.players}, ${Object.keys(window.socketsByUid).length}`;
}

And the corresponding server-side relay — every packet from a verified browser connection is either forwarded straight to the Bot's live socket (opcode 1) or sent as a raw buffer (opcode 2), with a few safety checks (item-buy de-duplication, chat/party-name length limits) applied inline:

zombsSessions.js — relaying browser → Bot
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 de-dup guard 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;
}

Everything past the handshake and relay layer is just two parallel copies of the same automation logic — one running headless server-side as Bot.onEntitiesUpdateHandler, the other running in-browser as Alt.onEntityUpdate. See Session Saver and Multibox (Sockets / Alts) respectively.

Connection map

┌─────────────────────┐        HTTP :80         ┌──────────────────────────┐
│   Your Browser      │ ──────────────────────▶ │   zombsLocalhost.js      │
│  (client.html +     │   serves client.html,   │   (Express, port 80)     │
│   script.js,        │   script.js, app.js,    │   • static file serving  │
│   window.ban)       │   manifest, .wasm       │   • leaderboard proxy    │
└─────────┬───────────┘                         └──────────────────────────┘
          │
          │ wss:// (real game protocol)                ┌─────────────────────────┐
          ├───────────────────────────────────────────▶│   zombs.io game servers │
          │                                             └─────────────────────────┘
          │ ws:// localhost:8100  (per in-browser Alt)
          ├───────────────────────────────────────────▶┌──────────────────────────┐
          │            opcode 5 / 10 challenge bytes   │  zombsSockets.js         │
          │◀───────────────────────────────────────────│  (WS, port 8100)         │
          │            solved response                 │  pool of WASM modules    │
          │                                             └──────────────────────────┘
          │ ws:// host:8090  (User → Session relay)
          └───────────────────────────────────────────▶┌──────────────────────────┐
                       attach / verify / relay         │  zombsSessions.js        │
                  ◀──────────────────────────────────  │  (WS, port 8090)         │
                    full state snapshot, live mirror   │  • class Bot (1 per      │
                                                        │    headless session)     │
                                                        │  • own WASM solver copy  │
                                                        │  • talks directly to     │
                                                        │    zombs.io game servers │
                                                        └──────────────────────────┘