Socket Server
zombsSockets.js — a 305-line WebSocket server (port 8100) that pools WASM anti-bot solver instances so a browser full of Alts doesn't have to load a copy each.
Every connection a Banshee client opens to zombs.io — your main player, every Alt,
every headless Session — has to pass the same anti-bot "blend field" challenge
(opcodes 5 and 10) using the same zombs_wasm.wasm module the official client embeds. Loading and
running that module once per character would be expensive; loading it a dozen times in one browser tab for a
wall of alts would be worse. The Socket Server's entire reason to exist is to be the place that loads it
once per logical bot, in a separate process, and answers challenges on request over a tiny text
protocol.
Connecting — clientId, password, and reattachment
A new socket is provisionally registered the instant it connects (ws.clientId = ++clientId),
and immediately told its id. It has 15 seconds to send a matching clientId <id> <password>
reply or the server closes it — a short authentication window, not an open relay:
wss.on("connection", (ws) => {
ws.clientId = ++clientId;
ws.actualId = ws.clientId;
if (!clients[ws.clientId]) {
clients[ws.clientId] = {};
setTimeout(() => {
if (!ws.verified) {
ws.close();
}
}, 15000);
}
if (!clients[ws.clientId].modules) {
clients[ws.clientId].modules = {};
}
ws.sendMessage(`clientId ${ws.clientId}`);
ws.on("message", (m) => {
const msg = decode(m);
switch (msg.split(" ")[0].replaceAll(" ", "")) {
case "clientId":
const clientId = msg.split(" ")[1];
const password = msg.split(" ")[2];
if (password === socketServerPassword) {
if (ws.clientId != clientId && clients[clientId]) {
ws.actualId = clientId;
if (!clients[ws.actualId].isActive) {
clients[ws.actualId].isActive = true;
}
}
ws.verified = true;
}
break;
// … createModule / decodeOpcode5 / decodeOpcode10 / altClosed / changePassword
}
});
});
The ws.actualId branch is the interesting bit: a verified connection can claim a different,
already-known clientId as its real identity — letting a page reload or reconnect "rejoin" its
previous module pool instead of starting from scratch with an empty one. This is the same identity-recovery
pattern ban.connect relies on when a tab refreshes mid-session: the WASM modules already loaded
for that tab's alts don't have to be rebuilt from nothing.
The control protocol — five commands
Past the handshake, the entire surface area of the server is five short text commands — each one a single
line, each mapping onto the lifecycle of a pooled wasmmodule() instance keyed by the
Alt's random wid:
case "createModule":
if (!ws.verified) return;
const moduleId = msg.split(" ")[1];
clients[ws.actualId].modules[moduleId] = wasmmodule();
break;
case "decodeOpcode5":
const opcode5id = msg.split(" ")[1];
const opcode5data = `[${msg.split(" ")[2]}]`;
const hostname = msg.split(" ")[3];
if (clients[ws.actualId].modules[opcode5id]) {
clients[ws.actualId].modules[opcode5id].onDecodeOpcode5(new Uint8Array(JSON.parse(opcode5data)), hostname, decodedopcode5 => {
ws.sendMessage(`opcode4 ${opcode5id} ${new Uint8Array(decodedopcode5[5])} ${decodedopcode5[6]}`);
});
}
break;
case "decodeOpcode10":
const opcode10id = msg.split(" ")[1];
const opcode10data = `[${msg.split(" ")[2]}]`;
if (clients[ws.actualId].modules[opcode10id]) {
const module = clients[ws.actualId].modules[opcode10id].finalizeOpcode10(new Uint8Array(JSON.parse(opcode10data)));
ws.sendMessage(`opcode10 ${opcode10id} ${module}`);
}
break;
case "altClosed":
const altId = msg.split(" ")[1];
if (clients[ws.actualId].modules[altId]) {
delete clients[ws.actualId].modules[altId];
}
gc();
break;
This is the exact other half of the exchange seen in Alt.onMessage on the
multibox page — the client sends
decodeOpcode5 <wid> <bytes> <hostname> <name>, and gets back
opcode4 <wid> <bytes> <bytes>, which the Alt forwards straight into the game
socket as its world-entry response. Notice the wire format itself: rather than a binary frame, the bytes are
serialized by template-literal-stringifying a Uint8Array (${new Uint8Array(...)}
→ a comma-separated decimal string like "5,12,200,7"), then re-parsed on the other end with
JSON.parse(`[${...}]`) — a slightly unusual choice, but one that lets the whole protocol stay
plain text and trivially loggable, which matters when the entire point of this server is debugging a
finicky, version-sensitive anti-bot handshake.
Cleanup — grace windows on both ends
altClosed is the clean-shutdown path (an Alt tells the server it's done, the module
is freed, gc() runs immediately — recall setFlagsFromString("--expose_gc") at the
top of the file exists specifically to make that call available). But browser tabs close ungracefully too,
so onclose implements a second, coarser cleanup with its own 15-second grace window before it
tears down an entire client's module pool:
ws.onclose = () => {
clients[ws.actualId].isActive = false;
setTimeout(() => {
if (!clients[ws.clientId].isActive) {
for (let i in clients[ws.clientId].modules) {
delete clients[ws.clientId].modules[i];
}
delete clients[ws.clientId];
gc();
}
if (clients[ws.actualId] && !clients[ws.actualId].isActive) {
for (let i in clients[ws.actualId].modules) {
delete clients[ws.actualId].modules[i];
}
delete clients[ws.actualId];
gc();
}
}, 15000);
}
The isActive flag is what makes this a grace window rather than an immediate teardown:
closing flips it to false, but the actual deletion is deferred 15 seconds and re-checks the flag
first. If the same logical client (by actualId) reconnects and re-verifies within that window,
isActive flips back to true and the scheduled cleanup becomes a no-op — so a quick
page refresh doesn't force every alt to redo the expensive WASM instantiation and challenge-solving dance
from zero. Both clientId and actualId pools get checked and swept
independently, since a reattached connection can leave the original numeric id's pool orphaned.
Why pool at all — what wasmmodule() actually costs
Each pooled instance is a full WebAssembly.instantiate of zombs_wasm.wasm (read
once into wasmbuffers at startup and reused as the instantiation source for every module),
wired up with the same browser-faking import shim documented in depth on the
architecture page — cstr() answering the handful of
fixed strings the WASM module probes for (Game.currentGame.network.connected,
document.getElementById("hud").children.length, and so on) with hand-picked constants that
satisfy the module without a real window or document ever existing:
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++) ? 1 : 0);
if (str.startsWith('document.getElementById("hud").children.length')) return 24;
}
Standing this up — reading the 1-2MB .wasm binary, instantiating it, running its init exports,
and warming its internal heap — isn't free, and a serious multibox session might run a dozen or more alts
simultaneously. clients[id].modules is therefore a small per-connection registry mapping each
alt's wid to its own live Module, instantiated once on createModule
and reused for every subsequent decodeOpcode5/decodeOpcode10 call that alt needs —
which is the entire performance argument for running this as a separate Node process rather than inline in
the page: one process, one loaded .wasm buffer, many cheap module instances, and a a clean place
to gc() them away the moment an alt disconnects.
Changing the password
The last command, changePassword, mirrors the same pattern seen on
Session Saver's changehasaccess — a verified client can rotate
the shared secret at runtime (capped at 50 characters), with the new value logged server-side rather than
persisted anywhere a casual reader of the source would find it:
case "changePassword":
if (!ws.verified) return;
const pswd = msg.split(" ")[1];
if (pswd && pswd.length <= 50 && socketServerPassword !== pswd) {
socketServerPassword = pswd;
console.log(`New socket server password: ${socketServerPassword}`);
}
break;