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
| Process | File | Role | Listens 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:
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:
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:
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:
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 (
Botin the Session Saver) runs the WASM module itself — it lazily creates one withthis.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:
- The browser's
Userclass (inclient.html) opens a WebSocket to the Session Saver and authenticates with a shared salt (salt, ;<password>). - It asks to
verifya session ID. The server marks that connection as belonging to that session and immediately ships down a full state snapshot —session.getSyncNeeds()— covering buildings, entities, inventory, party info, day/night cycle, the codec's internal lookup tables, everything needed to "resume" mid-game. - The client's
applyVerifyData(data)handler replays that snapshot through the normal game-message pipeline (onMessage2) as if the server had just sent it, swapsgame.network.sendPacketto route through the relay instead of a real socket, and the player is suddenly "in" that session's world. - 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.
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:
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 │
└──────────────────────────┘