Cliente TypeScript headless para bonk.io — conecta direto via Socket.IO, sem browser. Crie e gerencie salas, entre em salas existentes, escute o roster e os eventos de partida em tempo real, e rode bots 24h com reconexão automática.
Por que headless? Automatizar o bonk.io com Puppeteer/Playwright exige um browser completo (~300 MB de RAM, headless instável, tela virtual no Linux). bonktools fala o protocolo Socket.IO do jogo diretamente — menos de 15 MB de RAM por sala, sem Chromium, sem Xvfb, sem depender de seletores de UI que quebram a cada atualização do client.
- Arquitetura em camadas
- Instalação
- Autenticação
- Criando uma sala —
createRoom() - Entrando em uma sala —
joinRoom() BonkRoom— eventos e métodos- Reconexão automática
BonkSession— pool de salas- Travar times
- Placar e vencedor
- Anti-AFK
- Tratamento de erros
- Registro público de IS blobs
- Decisões técnicas
- Notas de segurança
- Disclaimer
- Contribuindo
- Licença
BonkSession
└── BonkRoom (1 por sala)
└── BonkTransport (socket.io-client v2)
└── bonk.io WS
| Camada | Responsabilidade |
|---|---|
BonkTransport |
Conexão Socket.IO v2 (EIO=3), TLS Sectigo, timesync |
BonkRoom |
Ciclo de vida da sala, roster, eventos tipados, reconexão |
BonkSession |
Pool de salas, auth compartilhado, throttle, reconcile 60s |
npm install bonktools
# ou
pnpm add bonktools
# ou
yarn add bonktoolsRequisitos: Node.js >= 20.18.1. O pacote usa ESM ("type": "module"), com build CJS disponível para require().
A lib suporta dois modos:
import type { AuthOptions } from 'bonktools';
// Conta registrada (recomendado para bots de longa duração)
const auth: AuthOptions = {
type: 'registered',
username: 'meu_usuario',
password: 'minha_senha',
};
// Convidado (sem HTTP de auth)
const auth: AuthOptions = {
type: 'guest',
guestName: 'BonkBot',
};Com type: 'registered', a lib faz uma chamada HTTP para o endpoint de login do bonk.io e obtém um token de sessão. O token é reusado em todas as salas da mesma BonkSession.
A função mais direta. Retorna um BonkRoom já conectado e ativo (aguarda o packet SHARE_LINK do servidor antes de resolver).
import { createRoom } from 'bonktools';
const room = await createRoom({
auth: { type: 'registered', username: '...', password: '...' },
desiredState: {
roomName: 'Minha Sala',
password: '', // string vazia = sem senha
maxPlayers: 6,
mode: 'b', // 'b'=classic, 'ar'=arrows, 'ard'=arrows death, 'sp'=grapple, 'v'=vtol, 'f'=football
rounds: 3,
},
hidden: false, // aparece na lista pública
timeoutMs: 10_000, // rejeita com RoomCreationTimeoutError se demorar mais
});
console.log('Link da sala:', room.shareLink);
// => https://bonk.io/123456abcde| Campo | Tipo | Default | Descrição |
|---|---|---|---|
auth |
AuthOptions |
— | Estratégia de autenticação (obrigatório) |
desiredState |
DesiredRoomState |
— | Configuração da sala (obrigatório) |
hidden |
boolean |
false |
Sala oculta na lista pública |
minLevel |
number |
0 |
Nível mínimo para entrar |
maxLevel |
number |
999 |
Nível máximo para entrar |
timeoutMs |
number |
10000 |
Timeout em ms para receber SHARE_LINK |
protocolVersion |
number |
49 |
Versão do protocolo bonk.io |
reconnectPolicy |
ReconnectPolicyOptions |
defaults | Política de backoff |
interface DesiredRoomState {
roomName: string;
password: string; // '' = sem senha
maxPlayers: number; // 1–8
mode: string | number; // 'b', 'ar', 'ard', 'sp', 'v', 'f'...
engine?: string; // 'b', 'f'...
rounds: number;
map?: string | null; // blob LZ-String do mapa
}import { joinRoom } from 'bonktools';
// Via URL pública
const room = await joinRoom('https://bonk.io/123456abcde', {
auth: { type: 'registered', username: '...', password: '...' },
role: 'host', // 'host' (time=1) ou 'spectator' (time=0, apenas o time inicial requisitado)
password: '', // senha da sala, se houver
});A lib parseia a URL, resolve o servidor e depois conecta. Resolve após o packet ROOM_JOIN, ou rejeita com RoomJoinTimeoutError.
Também aceita um ResolvedRoomAddress já pronto (sem chamada HTTP):
const room = await joinRoom(
{ server: 'b2seattle1', joinId: '...', bypass: 'abcde' },
{ auth, role: 'spectator' },
);Nota:
role: 'spectator'define apenas o time inicial requisitado noJOIN_ROOM(mesmo comportamento do botão "Spectate" do client oficial) — o servidor ainda trata a conexão como um jogador normal daí em diante. Qualquer lógica de sala que decide quem joga (pick systems, etc.) precisa lidar com isso explicitamente.
BonkRoom estende EventEmitter3<BonkRoomEvents>. Todos os eventos são tipados.
const state = room.state;
// state.myId — ID numérico do bot nesta sala (null antes de entrar)
// state.hostId — ID numérico do host atual
// state.players — Map<id, PlayerData>
// state.inGame — boolean (partida em andamento)
// state.teamsLocked — boolean
room.currentMap; // blob LZ-String do mapa ativo, ou null (mapa padrão)// Jogador entrou
room.on('player-join', (packet) => {
console.log(packet.userName, packet.id, packet.level);
});
// Jogador saiu
room.on('player-leave', (packet) => {
console.log('saiu id:', packet.id);
});
// Mensagem de chat (filtra echo do próprio bot automaticamente)
room.on('chat-message', (packet) => {
const nome = room.state.players.get(packet.id)?.userName;
console.log(`[${nome}]: ${packet.message}`);
});
// Link da sala disponível
room.on('share-link', (packet) => {
console.log(`https://bonk.io/${packet.roomId}${packet.bypass}`);
});
// Sala morreu (socket caiu, ban, sala cheia, retries esgotados)
room.on('room-dead', (reason) => {
// reason.kind: 'socket-disconnect' | 'status-banned' | 'status-room_full' | 'max-retries-exceeded'
});
// Sala foi recriada após reconexão
room.on('room-rebuilt', (shareLink) => {
console.log('nova URL:', shareLink);
});
// Todos os packets brutos (antes dos reducers)
room.on('raw-packet', (packet) => {
if (packet.type === 'UNKNOWN') { /* packet não mapeado */ }
});| Evento | Payload | Descrição |
|---|---|---|
room-join |
RoomJoinPacket |
Bot entrou na sala |
room-created |
RoomCreatedPacket |
Bot criou a sala |
player-join |
PlayerJoinPacket |
Jogador entrou |
player-leave |
PlayerLeavePacket |
Jogador saiu |
host-leave |
HostLeavePacket |
Host saiu (newHostId=-1 = sala fechada) |
team-change |
TeamChangePacket |
Jogador trocou de time |
ready-change |
ReadyChangePacket |
Jogador marcou/desmarcou pronto |
tabbed |
TabbedPacket |
Jogador tabou/voltou |
username-change |
UsernameChangePacket |
Jogador mudou de nome |
player-pings |
PlayerPingsPacket |
Ping de todos os jogadores |
game-start |
GameStartPacket |
Partida iniciada |
game-end |
GameEndPacket |
Partida encerrada |
all-ready-reset |
AllReadyResetPacket |
Reset de estado pronto |
chat-message |
ChatMessagePacket |
Mensagem de chat |
player-kick |
PlayerKickPacket |
Jogador foi kickado |
countdown |
CountdownPacket |
Countdown iniciado |
abort-countdown |
AbortCountdownPacket |
Countdown abortado |
teamlock-toggle |
TeamlockTogglePacket |
Times bloqueados/desbloqueados |
gamemode-change |
GamemodeChangePacket |
Modo de jogo alterado |
change-rounds |
ChangeRoundsPacket |
Número de rounds alterado |
map-switch |
MapSwitchPacket |
Mapa trocado |
balance-set |
BalanceSetPacket |
Balance de jogador alterado |
player-level-up |
PlayerLevelUpPacket |
Jogador subiu de nível |
room-name-update |
RoomNameUpdatePacket |
Nome da sala alterado |
room-password-update |
RoomPasswordUpdatePacket |
Senha da sala alterada |
status-message |
StatusMessagePacket |
Mensagem de status do servidor |
share-link |
ShareLinkPacket |
Link da sala disponível |
room-dead |
RoomDeadReason |
Sala morreu |
room-rebuilt |
string (shareLink) |
Sala reconectada |
raw-packet |
IncomingPacket | UnknownPacket |
Todo packet bruto |
room.setRoomName('Novo Nome');
room.setRoomPassword('nova_senha'); // '' = remover senharoom.startGame();
room.stopGame(); // volta ao lobby
room.startCountdown(3); // countdown de 3s
room.abortCountdown();
room.setMode('b', 'b'); // engine, mode
room.setRounds(5);
room.setMap(lzStringBlob); // troca o mapa ativoroom.chat('Olá!');
room.kickPlayer(id); // kick sem ban
room.banPlayer(id); // ban permanente// time: 0=spec 1=ffa 2=red 3=blue 4=green 5=yellow
room.setTeam(id, 2);
room.setTeamLock(true);
room.setTeamsEnabled(true);
room.giveHost(id);
room.setNoHostSwap(true); // desativa troca automática de hostroom.disconnect(); // idempotente, limpa timers e estadoBonkRoom reconecta automaticamente após desconexões transitórias (socket caiu, servidor reiniciou). A política de backoff é configurável:
const room = await createRoom({
auth,
desiredState: { /* ... */ },
reconnectPolicy: {
maxAttempts: 10, // default
initialDelayMs: 1000, // default: 1s
maxDelayMs: 30_000, // default: 30s
multiplier: 1.5, // default
jitter: true, // full jitter (recomendado)
},
});Causas terminais (sem retry): status-banned, status-room_full, max-retries-exceeded.
Causas transitórias (com retry): socket-disconnect.
Para rodar múltiplas salas com a mesma conta, use BonkSession. Ela compartilha o AuthClient, o token e aplica throttle de token-bucket entre criações.
import { BonkSession } from 'bonktools';
const session = new BonkSession({
auth: { type: 'registered', username: '...', password: '...' },
throttle: {
capacity: 3, // burst máximo de criações simultâneas
refillPerSec: 0.5, // 1 slot reabastecido a cada 2s
},
});
// Pré-autentica uma vez; token reusado em todas as salas
await session.getToken();
// Ouve eventos do pool
session.on('room-added', (localId) => {
const { room } = session.rooms.get(localId)!;
console.log('sala ativa:', room.shareLink);
});
session.on('room-dead-terminal', ({ localId, reason }) => {
console.error('sala terminal:', localId, reason);
});Cria todas as salas a partir de um array de configs, com stagger + jitter entre criações. Registra as configs no reconcile loop de 60s (rede de segurança para falhas silenciosas).
await session.startFromConfig({
rooms: [
{ id: 'sala-1', name: 'ATLAS', maxPlayers: 6, mode: 'b', rounds: 3 },
{ id: 'sala-2', name: 'ZEUS', maxPlayers: 8, mode: 'ar', rounds: 5 },
],
throttle: {
maxConcurrentRooms: 10,
roomCreationDelayMs: 3000, // espera mínima entre criações
roomCreationJitterMs: 2000, // + aleatório de até 2s
},
});// Adicionar sala avulsa
const localId = await session.addRoom({
id: 'sala-3',
name: 'HERMES',
password: 'segredo',
maxPlayers: 4,
mode: 'sp',
rounds: 3,
});
// Acessar a BonkRoom diretamente
const { room, status } = session.rooms.get(localId)!;
room.chat('Olá!');
// Remover sala
await session.removeRoom(localId);
// Encerrar toda a sessão (idempotente)
await session.destroy();interface RoomConfig {
id: string; // identificador único (usado no reconcile)
name: string;
password?: string; // default: ''
maxPlayers?: number; // default: 6
mode?: string; // default: 'b'
rounds?: number; // default: 3
hidden?: boolean; // default: false
map?: string; // blob LZ-String
}| Status | Significado |
|---|---|
starting |
createRoom() ainda não resolveu |
active |
sala criada e viva |
dead-transient |
morta — será recriada com throttle |
dead-terminal |
morta permanentemente (ban, sala cheia, retries esgotados) |
room.lockTeams(); // jogadores não conseguem mais trocar de time nem sair para o spec
room.unlockTeams(); // libera de novo
room.state.teamsLocked; // estado atualSó o host pode chamar (de outro cliente é ignorado com um aviso). Com os times travados só o host move jogadores (room.setTeam(id, team) continua funcionando). Quem entra depois do lock já vê a sala travada, e o lock é reaplicado sozinho se a sala for reconstruída (room-rebuilt). setTeamLock(boolean) continua disponível; lockTeams/unlockTeams são os atalhos.
O bonk.io não informa placar nem vencedor ao host por pacote nenhum. O ScoreTracker roda a física do football do próprio client do jogo (baixada de bonk.io na primeira execução, não faz parte do pacote) num worker, alimentada com o estado inicial da partida e com os inputs dos jogadores. O resultado é idêntico ao dos clientes, sem navegador e sem ocupar vaga na sala.
import { ScoreTracker } from 'bonktools';
const score = new ScoreTracker(room, { cacheDir: '.cache/bonk-client' });
score.on('score', ({ team, scores }) => room.chat(`ponto do time ${team}: ${scores[3]} x ${scores[2]}`));
score.on('match-winner', ({ team }) => console.log('venceu o time', team)); // 2 vermelho, 3 azul
score.on('error', (e) => console.error('rastreamento desativado', e));
await score.start();
// Estado inicial pelo próprio jogo (qualquer nº de jogadores e ids, sem blobs capturados):
const is = await score.buildInitialState([null, { id: 1, team: 3 }, { id: 2, team: 2 }]);
room.startGame({ is: is ?? undefined });- Só football; outros modos são ignorados. Vence quem chega a
gs.wl(oumaxScore). - Depende de trechos do código ofuscado do client: se o jogo mudar, emite
errore para. - Também exporta
decodeInitialState,encodeInitialStateeremapInitialStatePlayers(formato do IS blob documentado emBONK_PROTOCOL.md).
room.enableAntiAfk(); // 12 s sem se mexer nem falar no chat (padrão)
room.on('player-afk', (id) => room.kickPlayer(id));
room.on('player-back', (id) => room.chat(`jogador ${id} voltou`));
room.isAfk(playerId); // consulta pontualSó vigia jogadores em time durante a partida (espectadores e o bot são ignorados). Opções: enableAntiAfk({ thresholdMs, checkIntervalMs }). O movimento é detectado pelos frames de input via WebRTC (evento peer-input); o chat, pelo Socket.IO.
import { RoomCreationTimeoutError, RoomJoinTimeoutError } from 'bonktools';
try {
const room = await createRoom({ auth, desiredState: { /* ... */ } });
} catch (err) {
if (err instanceof RoomCreationTimeoutError) {
// SHARE_LINK não chegou dentro do timeout
}
}
try {
const room = await joinRoom('https://bonk.io/...', { auth });
} catch (err) {
if (err instanceof RoomJoinTimeoutError) {
// ROOM_JOIN não chegou dentro do timeout (ex: sala cheia)
}
}O IS blob (estado inicial da física) depende do mapa ativo na sala — capturar um novo exige um client real do bonk.io iniciando a partida (veja Decisões técnicas). Pra evitar que todo mundo precise capturar os mesmos blobs dos mapas padrão de cada gamemode, registry/blobs.json espelha publicamente os que este projeto já capturou:
const res = await fetch('https://cdn.jsdelivr.net/gh/brenoluizdev/bonktools@main/registry/blobs.json');
const registry = await res.json();
const blob = registry.gamemodeDefaults.vtol.blobs['2']; // vtol, mapa padrão, 1v1Os mesmos dados já vêm embutidos na lib via GAMEMODE_DEFAULT_BLOBS/FOOTBALL_DEFAULT_BLOBS — o registro é útil pra quem não usa TypeScript/o pacote npm diretamente, ou quer os dados mais recentes sem esperar um novo release. Detalhes do formato e como contribuir: registry/README.md.
Por que socket.io-client@2 e não a versão mais recente?
O servidor bonk.io fala o protocolo Engine.IO 3 (EIO=3). O cliente v4 negocia EIO=4 e não tem opção de downgrade — a conexão falha imediatamente no handshake. A versão 2.5.0 é a última da linha v2 e a única compatível.
Por que undici e não fetch nativo?
O bonk.io serve uma cadeia TLS Sectigo incompleta. O fetch nativo do Node.js não permite injetar uma CA customizada por requisição sem monkeypatch global. O undici.Agent permite configurar a CA Sectigo por cliente, sem afetar outras requisições HTTPS do processo.
Por que o CA Sectigo está bundlado?
Usar o TLS store padrão do Node.js rejeitaria a cadeia incompleta do bonk.io. Ao invés de desativar toda a verificação TLS globalmente (NODE_TLS_REJECT_UNAUTHORIZED=0), o projeto bundla a cadeia completa Sectigo e injeta apenas onde necessário.
Por que EventEmitter3 e não o EventEmitter nativo?
EventEmitter3 tem tipagem genérica por evento (EventEmitter<Events>), o que permite room.on('player-join', handler) com o tipo do handler inferido corretamente pelo TypeScript.
- Credenciais (
username,password) nunca são logadas — apenas eventos de sucesso/falha. - O token de sessão nunca aparece em logs.
- TLS usa cadeia Sectigo customizada via
undici.Agent— semNODE_TLS_REJECT_UNAUTHORIZED=0global. - Variáveis de ambiente são lidas via
process.env— nunca hardcode credenciais no código.
bonktools é um cliente não-oficial, resultado de engenharia reversa do protocolo Socket.IO público do bonk.io. Não é afiliado, endossado ou mantido pela equipe do bonk.io. Use por sua conta e risco, respeitando os termos de serviço do jogo — o projeto existe para automação, bots e ferramentas legítimas (salas 24h, moderação, integração com outras plataformas), não para lag/DDoS, spam ou qualquer forma de abuso.
Veja CONTRIBUTING.md.