← Back to portfolio

PROJECT · NETWORKING / AUTOMATION

Automation bot

Goodgame Empire — WebSocket + SmartFox protocol

Multi-account bot built in Node.js that connects directly to the Goodgame Empire server over WebSocket. It speaks the game's SmartFoxServer protocol (XML messages for the handshake and %xt% frames with JSON payloads for gameplay), enabling automation of repetitive tasks. The architecture uses Worker Threads to run each account in an independent thread and a modular plugin system covering everything from automated attacks to Discord integration. It is a personal, educational and non-commercial project built to learn networking, protocols and concurrency.

Tech stack

JavaScriptNode.jsWebSocketSmartFoxServer ProtocolWorker ThreadsExpressSQLiteDiscord.js

How it works · 01

Overview

The project is an Express server with a web panel that manages multiple game accounts. Each account runs as an independent Worker Thread maintaining its own WebSocket connection to the game server. A plugin system lets users enable and configure features per account. Persistence is handled with Node.js native SQLite and the entire web frontend is served as static files.

  • Express server with a web panel for managing accounts and plugins.
  • Worker Threads: each game account runs in its own thread with an independent WebSocket connection.
  • SQLite for persistence of users, accounts and plugin configuration.

How it works · 02

SmartFox protocol — handshake and communication

The game uses SmartFoxServer. The handshake is XML: first verChk, then login with the server zone, and finally autoJoin. Once inside, all communication uses frames with the format %xt%zone%cmd%room%jsonPayload%. The bot parses each frame, extracts the command and emits events through an EventEmitter so plugins can react.

  • XML handshake: verChk → login (zone + credentials) → autoJoin.
  • %xt% frames: each game message is a delimited string with command + JSON.
  • Rate limiter of 5 messages/second to avoid detection by the server.
ggeBot.js — handshake + frame parsing
// ── Conexión WebSocket al servidor del juego ──
const webSocket = new WebSocket(
  `wss://${botConfig.gameURL}/`
)

webSocket.onopen = () => {
  // 1. Version check (XML)
  webSocket.send(
    '<msg t="sys"><body action="verChk" r="0">'
    + '<ver v="166"/></body></msg>'
  )
}

webSocket.onmessage = ({ data: message }) => {
  // ── SmartFox %xt% frames ──
  if (message.charAt(0) === "%") {
    let [,,cmd,, r, obj] = message.split("%")
    const result = Number(r)
    obj = JSON.parse(obj)

    // "gbd" = game bulk data → emitir cada clave
    if (cmd === "gbd")
      for (const [key, value] of Object.entries(obj))
        xtHandler.emit(key, value, 0)
    else
      xtHandler.emit(cmd, obj, result)
  }
  // ── XML handshake responses ──
  else if (message.includes("apiOK")) {
    webSocket.send(`<msg t="sys"><body action="login"
      r="0"><login z="${botConfig.gameServer}">
      <nick><![CDATA[]]></nick>
      <pword><![CDATA[undefined%en%0]]></pword>
      </login></body></msg>`)
  }
}

How it works · 03

sendXT + waitForResult — sending and awaiting responses

sendXT builds a %xt% frame and sends it respecting the rate limiter. waitForResult registers a listener on the EventEmitter and returns a Promise that resolves when the server responds with the expected command, or rejects on timeout. This pattern lets each plugin await its actions sequentially.

ggeBot.js — sendXT + waitForResult
// ── Rate limiter: 5 mensajes/seg ──
const limiter = new RateLimiter({
  tokensPerInterval: 5, interval: "sec"
})

async function sendXT(cmdName, paramObj) {
  await limiter.removeTokens(1)
  webSocket.send(
    `%xt%${botConfig.gameServer}%${cmdName}%1%${paramObj}%`
  )
}

// ── Espera la respuesta del servidor ──
const waitForResult = (key, timeout, func) =>
  new Promise((resolve, reject) => {
    func ??= _ => true
    let timer = setTimeout(() => {
      xtHandler.removeListener(key, handler)
      reject("TIMED_OUT")
    }, timeout * (ggeConfig.timeoutMultiplier ?? 1) * 5)

    const handler = (data, result) => {
      if (!func(Object(data), Number(result))) return
      xtHandler.removeListener(key, handler)
      clearInterval(timer)
      resolve([Object(data), Number(result)])
    }
    xtHandler.addListener(key, handler)
  })

How it works · 04

Worker Threads — one account, one thread

The main server (main.js) creates a Worker Thread for each active account by instantiating ggeBot.js with configuration data. Each worker is independent: it has its own WebSocket connection, loaded plugins and state. If a worker crashes, main.js detects it and restarts it automatically after a configurable delay. Communication between main and workers uses parentPort.postMessage with numeric ActionTypes.

  • Each account = new Worker('./ggeBot.js', { workerData: config }).
  • Auto-restart: if the worker exits and the account is still active, it restarts after N seconds.
  • Bidirectional main↔worker communication via ActionType enums.
main.js — createBot (simplificado)
async function createBot(uuid, user) {
  // Asignar plugins activos y servidor
  plugins.forEach(plugin => {
    data.plugins[plugin.key] ??= {}
    if (plugin.force)
      data.plugins[plugin.key].state = true
    if (data.plugins[plugin.key]?.state)
      data.plugins[plugin.key].filename = plugin.filename
  })
  const instance = instances.find(
    e => Number(e.gameID) == data.server
  )
  data.gameURL ??= instance.gameURL
  data.gameServer ??= instance.gameServer

  // Crear worker con config completa
  const worker = new Worker('./ggeBot.js', {
    workerData: { ...data, discordData }
  })
  botMap.set(user.id, worker)

  // Auto-restart on exit
  worker.on('exit', () => {
    if (getSpecificUser(uuid, user).state == true) {
      setTimeout(() => createBot(uuid, user),
        1000 * (ggeConfig.secondsTillRestartBot ?? 10))
    }
  })
}

How it works · 05

Modular plugin system

Plugins are auto-discovered by reading the plugins/ directory at startup. Each plugin exports metadata (description, configurable options, whether it is mandatory) and, when running as a worker, listens to xtHandler events to react to game messages. There are attack plugins (nomads, fortresses, barons, khan, samurai…), resource management, troop deployment, Discord integration and more.

  • Auto-discovery: plugins/ is scanned and each .js found is loaded.
  • Dual pattern: on the main thread it exports metadata, on the worker it runs the logic.
  • Attack plugins with unit calculations, waves and siege tool computations.
plugins/index.js + attack/attack.js
// ── Auto-discovery de plugins ──
const dir = fs.readdirSync(__dirname, { recursive: true })
dir.forEach(file => {
  if (path.extname(file) != ".js") return
  plugins.push([
    `plugins/${file.slice(0, -3)}`,
    require(path.join(__dirname, file))
  ])
})

// ── Plugin de ataque: metadata + cálculos ──
// En el hilo principal → exportar opciones
if (require('worker_threads').isMainThread) {
  return module.exports = {
    force: true,
    pluginOptions: [
      { type: "Text", key: "attackDelaySeconds",
        default: "4.5" },
      { type: "Text", key: "attackDelayRandomizationSeconds",
        default: "2.5" },
      { type: "Text", key: "attackLimit" }
    ]
  }
}

// En el worker → lógica de cálculo
const getMaxAttackers = targetLevel =>
  targetLevel <= 69
    ? Math.min(260, 5 * targetLevel + 8)
    : 320

How it works · 06

Discord integration

The bot includes full Discord.js v14 integration: slash commands, incoming and outgoing attack notifications, fortress status and in-game chat forwarded to Discord channels. OAuth2 authentication links the web panel user with their Discord account. Available channels are auto-detected based on the bot's permissions in the guild.

  • Full OAuth2 flow to link web account ↔ Discord.
  • Real-time notifications: incoming attacks with preview, in-game chat, island status.
  • Slash commands to check status from Discord without opening the panel.