Fiz uma IA de 19 neurônios em JS puro que zerou o Snake. Depois testei de verdade e ela nunca mais zerou.
Sempre tive curiosidade de entender como uma IA aprende de verdade, por dentro, sem ser só chamando uma API. A inspiração pra este projeto veio do canal Universo Programado, que eu admiro muito: ele coloca IA pra aprender a jogar Dino, Flappy Bird, Snake... (Quem curte o tema provavelmente conhece também o Code Bullet, que faz a versão gringa disso.)
Aí resolvi fazer a minha. Uma IA que aprende a jogar Snake sozinha, sem nenhuma biblioteca de IA, só JavaScript, treinando em 4 VPS ao mesmo tempo.
Ela saiu de "bate na parede" pra zerar o jogo: 397 maçãs, tabuleiro 20×20 completamente cheio. Só que o resultado mais interessante foi o que aconteceu quando eu fui conferir esse zeramento. E no caminho eu caí em umas boas ciladas, que deixei todas numa seção no final.

A partida inteira em 18 segundos, com câmera lenta nas últimas maçãs. A barra embaixo é o progresso até 397, e fica branca quando zera.
Dá pra ver ela jogando direto no navegador, com o cérebro acendendo a cada jogada: im-fernando.github.io/snake-neuroevolucao. A página abre com a partida que zerou, e no botão "Treinar do zero aqui" você vê uma população aprendendo desde a geração 1, no seu próprio navegador.
A ideia: neuroevolução
Nada de backpropagation, gradiente ou PyTorch. A técnica é neuroevolução, que no fundo é seleção natural aplicada em rede neural:
- Cria 500 cobras, cada uma com uma rede neural de pesos aleatórios
- Todas jogam a mesma partida
- Quem come mais maçã ganha mais "aptidão"
- As melhores viram pais da próxima geração. Os filhos misturam os pesos dos pais (cruzamento) e ganham pequenas alterações aleatórias (mutação)
- Repete. Milhares de vezes
Ninguém programa como jogar. O comportamento surge da seleção, e essa foi a parte que mais me pegou no projeto.
O cérebro: 19 neurônios
A rede é minúscula:
17 entradas → 16 neurônios ocultos (tanh) → 3 saídas
As saídas são seguir reto, virar à esquerda e virar à direita, e a maior nota decide a jogada. São 339 pesos contando os bias, então o modelo inteiro cabe em 1,4 KB (sim, KB haha).

Cada bolinha é um neurônio, azul é peso positivo e laranja é negativo. Na página isso acende ao vivo a cada jogada, e dá pra ver quais sensores ela está usando pra decidir.
O forward é só isso:
function forward(g, input, bufs) {
bufs[0].set(input);
let off = 0;
const last = SIZES.length - 2;
for (let l = 0; l <= last; l++) {
const a = bufs[l], b = bufs[l + 1], ni = SIZES[l], no = SIZES[l + 1];
for (let j = 0; j < no; j++) {
let s = g[off + ni]; // bias
for (let i = 0; i < ni; i++) s += g[off + i] * a[i];
off += ni + 1;
b[j] = l < last ? Math.tanh(s) : s;
}
}
// argmax das 3 saídas = jogada
}
O "genoma" de cada cobra é só um Float32Array com os 339 pesos. Mutação é somar um ruído gaussiano em alguns deles.
Uma coisa que eu só entendi de verdade fazendo isso: um modelo de IA é literalmente um arquivo de matrizes. Os 339 números são uma matriz 16×17, uma 3×16 e dois vetores de bias. Um LLM é a mesma natureza de coisa, só que com bilhões de números em vez de 339 e um treino completamente diferente.
O que ela enxerga
A versão final tem 17 entradas, todas relativas à direção da cabeça:
- 5 raios (frente, esquerda, direita e as duas diagonais da frente) dizendo a distância até o obstáculo mais próximo e se tem maçã naquela linha
- Onde está a maçã em relação à cabeça
- Onde está a cauda em relação à cabeça
- Quanto espaço livre ela ainda alcança se for pra frente, pra esquerda ou pra direita (um flood fill pra cada direção)
Essas três últimas não existiam no começo, e a história de por que elas entraram é a primeira cilada lá embaixo.
A aptidão
const fitness = g => g.steps + 1000 * g.score * g.score;
Maçã ao quadrado domina. Os passos só servem pra diferenciar as cobras das primeiras gerações, quando ninguém come nada.
Rodando em 4 VPS: modelo de ilhas
No navegador o treino usa um núcleo só. Então separei a simulação num sim.js que roda tanto no browser (pro replay) quanto no Node (pro treino), e subi como serviço systemd em 4 VPS que eu já tinha.
O jeito clássico de distribuir algoritmo genético é o modelo de ilhas. Cada máquina evolui a própria população e, de tempos em tempos, as melhores cobras migram entre elas. Isso mantém a diversidade (cada ilha vai por um caminho) e ao mesmo tempo espalha as descobertas boas.
- Cada ilha avalia a população com
worker_threads(3 workers por máquina) - De 90 em 90 segundos manda as 10 melhores pra uma central e recebe as das outras
- As migrantes entram no lugar dos piores filhos, e as elites ficam intactas
- Checkpoint a cada minuto (grava num
.tmpe fazrename, pra nunca ficar um arquivo pela metade): se a VPS reiniciar, continua de onde parou
Algumas dessas VPS rodam outros sistemas, então o serviço usa CPUSchedulingPolicy=idle. Ele só pega CPU que está sobrando, e qualquer outro processo passa na frente.
A comunicação entre as ilhas é por SSH, com uma chave restrita no authorized_keys da central:
restrict,command="/opt/snake-ia/hub.sh" ssh-ed25519 AAAA... ilha-b
Essa chave só consegue fazer a troca de cobras. Se alguém pegar ela e tentar abrir um shell, recebe "comando inválido". E nenhuma porta fica aberta na internet.
O experimento: treinar o fim de jogo de propósito
Com a cobra passando de 250 maçãs, a dificuldade virou outra. Com mais de 70% do tabuleiro ocupado, sobreviver exige planejar muito bem. Só que quase todo o tempo de treino era gasto no começo das partidas, que é a parte fácil.
Então, numa das 4 ilhas, mudei a avaliação (sem recomeçar, a população continuou a mesma):
- 3 partidas por cobra, com a aptidão sendo a média. Cobra mediana com sorte não passa mais na frente
- Uma partida extra que já começa com a cobra grande, a partir de posições tiradas dos replays dos campeões. Nessa partida só conta o que ela conquista dali pra frente. É tipo estudar só final no xadrez
- Passo quase não vale mais nada (0,1 em vez de 1), pra não premiar enrolação, e um bônus enorme por zerar
- Limite de fome maior pra cobra grande (
100 + 5 × tamanho), pra ela conseguir dar a volta esperando a cauda liberar o caminho
As outras três ficaram como grupo de controle.

A linha laranja é a ilha do experimento. Ela faz umas 4 vezes menos gerações por minuto (cada cobra joga 4 partidas), por isso a linha é mais curta. Mas repara que, na mesma geração, ela fica acima das outras, tanto no melhor de cada geração (as linhas de cima) quanto na média da população (as de baixo).
E por tempo também: no mesmo momento, a média da população dela estava em uns 260 maçãs, contra 180 a 220 das outras. Só que isso não é um experimento limpo. As máquinas são diferentes, e como as ilhas trocam cobras entre si, as melhores do experimento migram pras outras e vice-versa. Então é um bom indício, não uma prova.
Ela zerou
Na geração 5.718 de uma das ilhas, uma cobra comeu 397 maçãs em 24.788 passos e encheu o tabuleiro inteiro. Peguei os pesos dela, rejoguei a partida com a mesma semente e conferi: zerou mesmo. Depois disso, mais duas ilhas também zeraram, uma delas a do experimento.
...mas aí eu testei de verdade
Coloquei as cobras que zeraram, e as melhores recentes de cada ilha, pra jogar 100 partidas novas cada:
| Cobra | Zerou | Média | Mediana | Pior |
|---|---|---|---|---|
| A primeira que zerou (g5718) | 0/100 | 254 | 312 | 1 |
| Outra que zerou (g6032) | 0/100 | 275 | 323 | 4 |
| A que zerou no experimento (g1629) | 0/100 | 145 | 56 | 2 |
| Melhor recente do experimento (g1721) | 0/100 | 286 | 348 | 0 |
| Melhor recente da primeira ilha (g7010) | 0/100 | 283 | 335 | 1 |

Nenhuma das cobras que zeraram zerou de novo em 100 partidas kkkkkkk
Detalhe importante: 0 em 100 não quer dizer que a chance é zero. Pela "regra do três" da estatística, zero sucessos em 100 tentativas só permite afirmar que a chance real fica abaixo de uns 3%. Pra medir uma chance de 0,1%, por exemplo, ia precisar de milhares de partidas.
O gráfico mostra bem: a que zerou fica quase sempre entre 300 e 367, e às vezes morre logo no começo. A do experimento concentra bem mais perto do fim, com quase metade das partidas entre 350 e 375. Mas nenhuma das duas encosta no 397.
E a da ilha do experimento que zerou é a mais engraçada: mediana de 56 maçãs. Era um filho com uma mutação que, por acaso, pegou uma partida perfeita.
O nível real das melhores é uma mediana de 300 a 348 maçãs. Ainda é muito bom pra 19 neurônios, mas é bem diferente de "sabe zerar".
As ciladas em que eu caí
Essa é a parte que eu mais queria ter lido antes de começar.
1. Achei que os sensores bastavam
A primeira versão só tinha os raios e a posição da maçã e da cauda. Funcionou bem até 116 maçãs, e aí travou. Não adiantava esperar mais geração, a curva ficava ali.
O problema é que, com o corpo grande, ela entrava em bolsão fechado pelo próprio corpo. Os raios dizem "tem parede a 3 casas", mas não dizem "se você entrar ali, nunca mais sai".
Adicionei as 3 entradas de espaço livre, com os mesmos 16 neurônios. Passou de 200 maçãs em 10 minutos.
Na prática, isso virou um experimento sem querer: a mesma rede, com e sem a informação de espaço livre. Sem ela, teto de 116. Com ela, 397. A forma como os dados chegam na rede pesou mais que qualquer outra coisa no projeto.
O instinto era aumentar a rede. O que resolveu foi dar a informação certa pra ela. Rede maior sem a informação certa só decora melhor uma estratégia cega.
2. Achei que população maior ajudava
"Mais cobra = mais chance de achar uma boa", certo? Testei várias configurações com o mesmo tempo pra todas, 3 minutos cada, em paralelo:

10.000 cobras perderam feio pra 500. Evolução precisa de geração, e população grande só deixa cada geração mais lenta. Cheguei a pensar em 200 mil cobras kkkkk, ainda bem que testei antes.
3. Achei que rede maior ajudava
Mesma lógica: testei 32 neurônios e duas camadas de 16, e os dois perderam pros 16 no curto prazo. Quanto mais peso, maior o espaço de busca, e mais difícil a mutação aleatória acertar justamente o peso que importa.
Rede maior até pode ter um teto mais alto, mas cobra o preço em tempo. Só vale aumentar quando a curva achata e a informação certa já está lá.
4. A cobra que aprende a enrolar
Sobreviver dá ponto, e ela descobre rapidinho que ficar girando em círculo é ótimo pra sobreviver. A IA sempre otimiza exatamente o que você mede, não o que você quis dizer.
Por isso existe um limite de fome: passou 100 + 3 × tamanho passos sem comer, morre.
5. O Uint32Array que travou as 4 VPS de madrugada
Acordei e as 4 ilhas estavam paradas fazia horas. Serviço "ativo", uma thread a 100% de CPU, nenhuma geração nova.
O culpado era o flood fill. Pra não ter que zerar o array de casas visitadas a cada chamada, usei a técnica do carimbo: cada flood fill ganha um número novo, e a casa conta como visitada se o carimbo dela for igual ao atual.
const FF_MARK = new Uint32Array(CELLS);
let ffStamp = 0;
// ...
const st = ++ffStamp;
O ffStamp é um número do JS, mas o FF_MARK é Uint32Array. Depois de 4.294.967.295 flood fills, o carimbo gravado no array dá a volta e nunca mais fica igual ao ffStamp. Toda casa parece não visitada, a fila nunca esvazia, loop infinito.
E a conta bate: são 3 flood fills por passo, vezes milhões de passos por geração. Cada ilha chegou nesse número entre 4 e 6 horas depois de começar, exatamente quando cada uma travou.
A correção:
if (++ffStamp >= 0xFFFFFFFF) { FF_MARK.fill(0); ffStamp = 1; }
De bônus coloquei um limite de 2 milhões de passos por partida, pra nenhum bug futuro travar uma ilha inteira de novo.
6. Confiei que "serviço ativo" queria dizer "funcionando"
Essa veio junto com a anterior e é a que mais me incomodou. O systemd dizia active, o processo estava vivo, o painel mostrava números bonitos... e nada andava fazia 4 horas. O painel mostrava os últimos dados que recebeu como se fossem atuais.
Processo vivo não é processo trabalhando. Se eu tivesse um alerta simples do tipo "essa ilha não manda atualização faz 5 minutos", tinha visto na hora.
7. Acreditei no recorde
Essa é a do título. O recorde é o máximo de milhões de partidas, e máximo de muita coisa aleatória sempre sobe, mesmo sem a cobra melhorar. Uma cobra boa uma hora pega uma partida perfeita.
O que diz se ela é boa de verdade é a taxa de vitória em partidas novas, que ela nunca viu. Se eu tivesse olhado só o recorde, ia sair por aí dizendo que a IA "sabe zerar".
E isso também explica por que avaliar cada cobra com 3 partidas funcionou: a seleção para de premiar quem teve sorte uma vez.
8. Comparei ilhas por geração em vez de tempo
A ilha do experimento faz 4 vezes menos gerações por minuto. Se eu comparasse "geração 1.000 contra geração 1.000", ela parece incrível. Se comparasse "1 hora contra 1 hora", ela parece lenta. As duas comparações respondem perguntas diferentes, e eu quase misturei as duas.
9. Mudar a regra do jogo quebra o replay
O replay funciona porque o jogo é determinístico: com a mesma semente (uso um gerador mulberry32) e os mesmos pesos, a partida se repete idêntica no navegador e no servidor. Mas quando o experimento mudou o limite de fome, as cobras daquela ilha passaram a jogar com uma regra diferente. Replay com a regra velha dava outra partida.
Tive que guardar a regra junto com a semente em cada campeã. Qualquer coisa que muda o resultado do jogo precisa ir junto com ele.
10. Ilhas com cérebros diferentes não conversam
Pensei em colocar uma ilha com 32 neurônios pra comparar. Só que aí ela não consegue trocar cobras com as outras, porque os genomas têm tamanhos diferentes. Ou seja, comparar arquitetura isola a ilha. Pra testar mudança de regra (como o experimento) tudo bem, pra testar mudança de cérebro não.
11. Tomei ban de SSH do meu próprio servidor
De tanto conectar pra conferir status, uma das VPS passou a recusar SSH vindo do meu IP. O treino continuou normal (a ilha fala com a central pela chave dela), mas eu fiquei de fora. Provavelmente proteção contra força bruta fazendo o trabalho dela. Monitorar com muitas conexões curtas por SSH tem preço.
12. Achei que precisava de GPU
Tenho uma RX 570 parada e cheguei a pensar em usar. Pra isso aqui ela não ajuda: a rede é minúscula e o trabalho pesado é a lógica do jogo (flood fill, colisão, "se bateu, morreu"), que é sequencial e cheia de decisão. CPU ganha. GPU brilha multiplicando matrizes enormes, que não é o caso.
Tudo que eu aprendi
Sobre IA (além das ciladas):
- Neuroevolução não precisa de gradiente. Mutação e seleção acham bons pesos sozinhas, pelo menos em rede pequena. Pra rede grande o gradiente ganha de longe, porque ele calcula pra que lado mexer cada peso em vez de chutar
- A IA otimiza o que você mede, então a aptidão define o comportamento. Premiar sobrevivência criou cobra que enrola
- Treinar a parte difícil de propósito funciona. Começar partidas já com a cobra grande deixou a ilha do experimento à frente na mesma geração
- Modelo é matriz. 339 números num
Float32Array, e o "programa" é só como você multiplica eles
Sobre engenharia:
- Simulação determinística (semente + pesos + regras) permite rejogar qualquer partida em qualquer máquina e conferir resultado
- Modelo de ilhas é um jeito simples de distribuir algoritmo genético: cada máquina independente, trocando só o melhor
CPUSchedulingPolicy=idledeixa usar CPU sobrando de servidor sem atrapalhar ninguém- Chave SSH com
command=erestrictdá pra máquina exatamente uma permissão e nada mais - Checkpoint com
.tmp+renamenunca deixa arquivo corrompido se o processo morrer no meio - Não precisa de GPU nem de biblioteca pra brincar com isso. A IA inteira cabe num arquivo de umas 180 linhas
Como reproduzir
- Máquinas: 4 VPS (1 ARM e 3 x86, de 4 a 6 núcleos), 3 workers por máquina, com prioridade idle
- Treino: umas 20 mil gerações somando as ilhas, perto de 12 milhões de partidas, em cerca de 36 horas de relógio (descontando as horas travadas)
- Teste das 100 partidas: a semente da partida
ié0xC0FFEE + i × 104729, comide 0 a 99. Com ocampeao.jsone a funçãoplayGenomedosim.js, dá pra refazer o teste e conferir partida por partida - Intervalo de confiança: 0 em 100 dá um intervalo de 95% entre 0% e 3,6% pra chance real de zerar
- O que eu não fiz: treinos independentes, do zero, com sementes diferentes. Então não sei se outro treino chegaria no mesmo lugar, e essa é a maior limitação do experimento
Código e próximos passos
Está tudo aberto: github.com/im-fernando/snake-neuroevolucao
O sim.js tem o jogo, os sensores, a rede e o algoritmo genético. O train.js tem o treino em ilhas. E o campeao.json tem os pesos da cobra que zerou, se alguém quiser brincar com ela.
O que eu quero testar agora:
- Levar a configuração do experimento pras 4 ilhas
- Adicionar uma entrada "a cauda é alcançável?" sem recomeçar. Dá pra inserir a entrada nova com peso zero: a cobra continua jogando igualzinho, e a evolução descobre sozinha como usar
- Trocar o recorde pela taxa de vitória como métrica principal, e colocar um alerta de ilha parada
- Depois, fazer o mesmo com xadrez (minimax com alfa-beta e a evolução ajustando a função de avaliação)
Fica a pergunta pra quem já mexeu com isso: vocês acham que dá pra uma rede desse tamanho zerar com frequência, ou o teto é a arquitetura? Minha aposta é que, sem enxergar o tabuleiro inteiro, ela não passa de "zera de vez em quando", mas é aposta: eu não tenho evidência pra afirmar isso. Pra saber de verdade, teria que comparar arquiteturas diferentes com o mesmo orçamento de processamento. Mas eu já errei previsão nesse projeto (achava que zerar em 48h tinha menos de 20% de chance haha).