Três abordagens diferentes de Inteligência Artificial aprenderam a jogar o mesmo Dino: uma por evolução (NEAT) e duas por aprendizado por reforço (DQN e PPO). Depois, tiramos a campeã do simulador e a colocamos para jogar o jogo real do Chrome, lendo o estado do chrome://dino ao vivo pelo DevTools Protocol. Este artigo mostra o que aconteceu, os comandos usados e o que só apareceu quando testamos de verdade.
Exp #002

3 IAs no Dino do Google

NEAT, DQN e PPO — e o terminal que joga o chrome://dino de verdade

Python NEAT DQN PPO PyTorch Chrome DevTools

O que você vai ver

  • Como NEAT, DQN e PPO aprendem (e no que diferem)
  • Por que DQN e PPO travaram em ~1.400 pontos
  • O terminal que conecta a IA ao Chrome real
  • Os comandos do console do DevTools, prontos para copiar
  • Os 12 testes e os bugs que só apareceram ao vivo
Artigo + vídeo Este experimento não tem versão jogável no navegador: o terminal roda no Desktop, conectado a um Chrome real. Quer jogar contra a IA? Vá no Exp #001.

O que aconteceu no vídeo?

Três formas diferentes de aprender

As três IAs recebem o mesmo jogo, os mesmos sensores e as mesmas três ações (correr, pular ou abaixar). O que muda é como cada uma descobre o que fazer. Uma evolui como uma espécie; as outras duas ajustam pesos por gradiente, recompensa a recompensa.

Evolução

NEAT

NeuroEvolution of Augmenting Topologies

  • • Uma população de dinossauros joga ao mesmo tempo
  • • Quem corre mais tem mais fitness e vira "pai"
  • • Filhos herdam pesos com mutações — inclusive na estrutura da rede
  • • Não usa gradiente nem recompensa por quadro
Reforço

DQN

Deep Q-Network · 10 → 256 → 256 → 3

  • • Um único agente treina sozinho
  • • A rede dá uma nota Q (recompensa futura) para cada ação; vence a maior
  • • Cada tentativa vai para uma memória de experiências e é revisitada
  • • Alvo: recompensa + 0,99 × melhor Q futuro
Reforço

PPO

Proximal Policy Optimization · 10 → 128 → 128 → Ator | Crítico

  • • Uma base compartilhada e duas cabeças
  • • O Ator calcula probabilidades e escolhe a ação
  • • O Crítico estima quanto o estado vale (V(s))
  • • Clipping de 0,8× a 1,2×: a política nunca muda bruscamente
Grade com dezenas de dinossauros coloridos treinando ao mesmo tempo no modo NEAT
NEAT: uma população inteira joga em paralelo. Cada cor é um genoma diferente; os melhores se reproduzem.
Explicação do DQN: dez sinais entram na rede e saem três notas Q — correr 0,29, pular 0,86, abaixar 0,11
DQN: a rede dá uma nota Q para cada ação. A maior (Pular, 0,86) vira o comando.
Explicação do DQN: as experiências antigas voltam da memória para treinar a rede
Recompensas (+0,1 sobreviver, +1,5 ultrapassar, −100 colidir) viram experiências que treinam a rede de novo.
Explicação do PPO: o Ator mostra as probabilidades correr 12%, pular 78%, abaixar 10% e amostra Pular
PPO: o Ator produz probabilidades (pular 78%) e amostra uma ação; o Crítico avalia o estado (V(s) = +0,42).
Explicação do PPO: régua de clipping de 0,8x a 1,2x limitando a mudança da política
O clipping limita a razão entre a política nova e a antiga a 20% — aprendizado estável, sem saltos.

Resumo em uma frase

NEAT procura no espaço de redes com muitos candidatos ao mesmo tempo e seleciona pelo resultado do episódio inteiro. DQN e PPO treinam um agente só e dependem de tropeçar em boas jogadas por exploração aleatória — e é exatamente aí que a história fica interessante.

O que as IAs enxergam?

Nenhuma delas vê pixels. A engine do jogo (Jogo/dino_env.py) entrega um vetor de 10 números normalizados, no estilo do OpenAI Gym, e espera de volta uma ação discreta: 0 correr, 1 pular, 2 abaixar.

Diagrama da rede neural NEAT: entradas como distância, altura, largura e tipo do obstáculo, velocidade e posição do dino ligadas a três saídas: correr, pular, abaixar
Os sensores entram pela esquerda, atravessam os neurônios ocultos (pesos em verde/vermelho) e saem como Correr, Pular ou Abaixar.

As 10 entradas

  1. 0 · dino_y — altura do dino / 700
  2. 1 · speed — velocidade / 13,0
  3. 2 · dist1 — distância do 1º obstáculo / 1400
  4. 3 · height1 — altura do 1º obstáculo
  5. 4 · type1 — 0 cacto · 0,5 pássaro · 1 gigante
  6. 5 · width1 — largura do 1º obstáculo / 150
  7. 6 · dist2 — distância do 2º obstáculo
  8. 7 · height2 — altura do 2º obstáculo
  9. 8 · type2 — tipo do 2º obstáculo
  10. 9 · is_jumping — 1 se está no ar
Detalhe que decidiu o jogo

A reescala para DQN e PPO

Normalizar tudo pelo tamanho da tela espreme a informação útil numa fatia minúscula: a zona onde o pulo é decidido cabe em 0,029 – 0,038 da entrada dist1. Para uma rede treinada por gradiente, partindo de pesos pequenos, esse sinal é quase invisível.

Uma sonda supervisionada mostrou o tamanho do problema: com o estado cru, a rede acertava 4% dos pulos; com o estado reescalado (rl_features.py), 100%. O NEAT nunca precisou disso — a evolução seleciona pesos grandes (até ±30) que amplificam essas fatias sozinha.

Por que DQN e PPO travaram em ~1.400 pontos…

E COMO DESTRAVAMOS

Mesmo depois de corrigir rede-alvo, escala de entrada, replay e recompensa, os dois ficaram parados. A medição explicou: o momento certo de pular é uma agulha no palheiro.

A agulha

Uma heurística que pula a 8× a velocidade faz 13.466 pontos. Com 7×, cai para 2.107; com 9×, 4.160. Variar ±12% derruba o desempenho de 3 a 6 vezes.

Partida a quente

Em vez de esperar a exploração aleatória tropeçar numa sequência perfeita, demos demonstrações (ideia do DQfD / behavior cloning): o replay começa cheio de trajetórias boas e a rede é pré-treinada — 100% de acerto nos pulos em ~5 épocas.

Salvar o campeão certo

Salvar quando o episódio bate recorde não funciona: a rede continua treinando durante o episódio. O DQN reportou recorde de 9.759, mas o arquivo salvo jogava 1.360. Solução: avaliar a política de forma gulosa (argmax) antes de salvar — exatamente como a Galeria e o Torneio reproduzem.

Tudo isso vive em três arquivos pequenos do projeto: rl_features.py (reescala), rl_warmstart.py (demonstrações) e rl_avaliacao.py (avaliação gulosa). O NEAT não passa por nenhum deles.

O Terminal: a Ponte Neural

Vencer no simulador é uma coisa. A pergunta do vídeo era outra: a campeã consegue jogar o Dino de verdade, o do Chrome? Para responder sem trapaça, construímos um terminal que não recria o jogo e não simula física paralela. Ele lê o estado real do chrome://dino, converte para o vetor que a rede aprendeu, roda a inferência e envia teclas reais de volta para o Chrome.

À esquerda o jogo real do Chrome com o dino abaixado; à direita a Ponte Neural mostrando a decisão ABAIXAR, os sensores e a confirmação do Chrome
Chrome real à esquerda, terminal à direita: a rede decidiu ABAIXAR, a tecla foi enviada e o Chrome confirmou ABAIXADO. 3 segundos vivo, 1.293 px.
chrome://dino
estado real
CDP
Runtime.evaluate
Adaptador
8 entradas
Campeã
decide
Tecla real
Input.dispatchKeyEvent
Chrome confirma
tRex.jumping
Modo 1

ESPELHO

Só lê. Você joga no Chrome e o terminal mostra os sensores ao vivo, sem enviar nenhuma tecla.

Modo 2

MANUAL PELA PONTE

Você aperta as teclas no terminal e ele as repassa ao Chrome pelo CDP — prova que o caminho de comando funciona.

Modo 3

CAMPEÃO

A rede decide a ~60 Hz, no mesmo ritmo do treino. O HUD mostra decisão, comando enviado, confirmação do Chrome e o cronômetro "Tempo vivo".

Tela de escolha do campeão no terminal: NEAT, PPO e DQN, todos disponíveis
Qualquer uma das três campeãs pode ser plugada no Chrome. No vídeo, quem atravessou o portal foi a NEAT.
Terminal mostrando CHROME: GAME OVER, 3072 px percorridos e tempo vivo de 9 segundos
O cronômetro trava no instante exato em que o Chrome reporta crashed: 9 s, 3.072 px nesta partida.

Como o terminal é organizado

  • chrome_cdp_client.py — cliente do DevTools Protocol (websocket): lê o estado e envia teclas
  • observation_adapter.py — converte o estado do Chrome no vetor de 8 floats do treino
  • champion_adapter.py — carrega a campeã e expõe decide(observation) → ação
  • visual_terminal.py — a janela Pygame: HUD, fluxo, pista, dino recolorido
  • main.py — orquestra modos, teclado, thread de leitura a 60 Hz e log CSV
  • smoke_test_bridge.py — os 12 testes de ponta a ponta (abaixo)

A rede real é minúscula

Ao desenhar a topologia da campeã NEAT (geração 53, fitness 294.640), descobrimos que ela tem 8 entradas disponíveis mas só usa 2 — a velocidade e a distância do primeiro obstáculo — com 2 neurônios ocultos, 3 saídas e 5 conexões no total. Os outros 6 sensores não têm peso nenhum ligado a eles. A evolução simplesmente descobriu que não precisava do resto.

Os comandos no console do Chrome

Antes de escrever a ponte, precisávamos de uma resposta simples: dá para ler o jogo por dentro? O Dino do Chrome é o projeto open-source t-rex-runner, e a instância do jogo costumava ficar em window.Runner.instance_. Costumava. Estes são os comandos, na ordem em que foram descobertos.

1. Abrir um Chrome com depuração remota

Um perfil isolado, em uma porta local. Nada do seu Chrome pessoal é tocado.

cmd
chrome.exe ^
  --remote-debugging-port=9222 ^
  --remote-debugging-address=127.0.0.1 ^
  --remote-allow-origins=http://127.0.0.1:9222 ^
  --user-data-dir="chrome_debug_profile" ^
  --no-first-run --no-default-browser-check

2. Ver as abas abertas pelo protocolo

Se isso responder um JSON, o Chrome está ouvindo. Cada aba traz o seu webSocketDebuggerUrl.

navegador
http://127.0.0.1:9222/json/list

3. O caminho "clássico" — que não funciona mais

Na aba chrome://dino, abra o DevTools (F12 → Console). Nas versões atuais do Chrome (testado na 153), a classe do jogo não é mais exposta globalmente: só sobram enums como RunnerClasses.

Console do DevTools
window.Runner && (window.Runner.instance_ || window.Runner.getInstance?.())
← undefined

4. O truque que achou o jogo

O jogo escuta keydown no document, e o listener registrado é a própria instância do Runner (ela implementa handleEvent). getEventListeners() é uma função exclusiva do Console — basta procurar o listener que tenha tRex e horizon.

Console do DevTools
globalThis.jogoDino = getEventListeners(document).keydown
  .map(i => i.listener)
  .find(l => l && l.horizon && l.tRex)
← Runner {outerContainerEl: div#main-frame-error…, canvas: canvas.runner-canvas, canvasCtx: CanvasRenderingContext2D, …}

5. Ler o estado do jogo ao vivo

Com jogoDino na mão, tudo que a IA precisa está a um comando de distância — o dino, a velocidade, a distância e a lista de obstáculos.

Console do DevTools
// o dinossauro
({ xPos: jogoDino.tRex.xPos, yPos: jogoDino.tRex.yPos, jumping: jogoDino.tRex.jumping, ducking: jogoDino.tRex.ducking, groundYPos: jogoDino.tRex.groundYPos })
// ← {xPos: 24, yPos: 93, jumping: false, ducking: false, groundYPos: 93}

// a partida
({ velocidade: jogoDino.currentSpeed, distancia: Math.round(jogoDino.distanceRan), playing: jogoDino.playing, crashed: jogoDino.crashed })
// ← {velocidade: 6.559, distancia: 1736, playing: false, crashed: true}

// os obstáculos à frente
console.table(jogoDino.horizon.obstacles.map(o => ({ tipo: o.typeConfig.type, x: o.xPos, largura: o.width })))
// ← 'cactusSmall' x=58 largura=17 · 'cactusSmall' x=324 largura=34

// reiniciar depois do game over
jogoDino.restart()
DevTools aberto ao lado do chrome://dino mostrando os comandos: window.Runner retorna undefined, getEventListeners encontra o Runner, e as leituras de tRex, velocidade e obstáculos
A sequência inteira rodando no Chrome 153: window.Runner é undefined, mas o listener de keydown entrega a instância real.

6. A mesma coisa, mas em Python (sem colar nada)

O terminal não pede para você abrir o Console. Runtime.evaluate aceita includeCommandLineAPI: true, que libera getEventListeners fora do DevTools. As teclas vão por Input.dispatchKeyEvent, e a navegação por Page.navigate (o Chrome moderno ignora chrome://dino passado por linha de comando).

Python · chrome_cdp_client.py (trecho)
# ler o estado (a expressão JS é a mesma do console, embrulhada em JSON.stringify)
ws.send(json.dumps({"id": 1, "method": "Runtime.evaluate", "params": {
    "expression": JS_READ_STATE,
    "returnByValue": True,
    "includeCommandLineAPI": True,   # libera getEventListeners fora do Console
}}))

# pular: uma tecla real, não um evento sintético na página
ws.send(json.dumps({"id": 2, "method": "Input.dispatchKeyEvent", "params": {
    "type": "keyDown", "key": " ", "code": "Space", "windowsVirtualKeyCode": 32, "text": " "
}}))
# ... e "keyUp" só quando tRex.jumping voltar a False (teto de segurança: 0,7 s)

# abrir o jogo numa aba em branco
ws.send(json.dumps({"id": 3, "method": "Page.navigate", "params": {"url": "chrome://dino"}}))

7. Do Chrome para a rede: a tabela de conversão

O treino usa uma tela fixa de 1400×700; o canvas do Chrome tem tamanho variável. As distâncias e larguras são reescaladas pela proporção do canvas antes da fórmula original — a hipótese é que "fração da tela" é o que a rede realmente aprendeu.

# Entrada treinada Origem no Chrome Conversão
0dino_y / 700tRex.yPos, tRex.jumping, tRex.duckingProgresso do pulo (0 = chão, 1 = ápice) projetado na faixa do treino. Pico real medido: ~72 px.
1speed / 13,0currentSpeedDireta — 6,0 inicial e 13,0 máxima já eram cópias dos valores do Runner.
2dist1 / 1400obstacles[0].xPos − tRex.xPos× (1400 / largura do canvas), depois / 1400.
3type1obstacle.typeConfig.typeContém "pterodactyl" → 0,5; senão 0. O cacto gigante (1,0) não existe no Chrome.
4width1 / 150obstacle.widthMesma reescala do item 2, depois / 150.
5dist2obstacles[1]Idem 2.
6type2obstacles[1].typeConfig.typeIdem 3.
7is_jumpingtRex.jumpingDireta — a mesma flag booleana nos dois lados.

Obstáculos já ultrapassados são ignorados nos dois lados (x + largura < tRex.xPos). Tudo é lido de forma defensiva: se um campo não existir nesta versão do Chrome — como tRex.config, que veio {}, ou height, que veio undefined — ele volta null e é reportado, nunca inventado.

Como saber se funcionava?

Ler código não prova nada contra um Chrome de verdade. Por isso a ponte tem uma bateria de 12 testes de ponta a ponta que precisam do Chrome aberto em chrome://dino. Cada um valida um elo da corrente: conexão, descoberta do jogo, leitura, envio de tecla, confirmação, carga da campeã, liberação das teclas e log.

terminal · na pasta do projeto
pip install -r chrome_bridge/requirements_bridge.txt   # pygame-ce, neat-python, websocket-client
chrome_bridge\launch_chrome_debug.bat                  # abre o Chrome isolado na porta 9222
python chrome_bridge\smoke_test_bridge.py              # os 12 testes
python chrome_bridge\main.py                           # o terminal (1 espelho · 2 manual · 3 campeão)
saída real · 21/09/2026 · Chrome 153
[PASS] 1_conexao_porta_cdp        — 4 alvo(s) encontrados em 127.0.0.1:9222
[PASS] 2_localizar_alvo_dino      — chrome://dino já estava aberto (chrome://dino/)
[PASS] 3_descoberta_runner
[PASS] 4_leitura_estado           — speed=6.035 tRex={'xPos': 24, 'yPos': 93, 'jumping': False, 'ducking': False, 'groundYPos': 93, 'config': {}} obstaculos=0
[PASS] 5_modo_espelho_sem_teclas  — nenhuma tecla foi retida durante leituras somente-leitura
[PASS] 6_envio_manual_salto       — Input.dispatchKeyEvent(Space) enviado sem exceção
[PASS] 7_pulo_so_apos_confirmacao_chrome — tRex.jumping ficou True após o comando
[PASS] 8_carregamento_campeao_io  — NEAT (8 entradas) -> 2, PPO (10 entradas) -> 0, DQN (10 entradas) -> 2
[PASS] 9_inferencia_sem_alterar_checkpoint — nenhum checkpoint teve o mtime alterado
[PASS] 10_liberacao_teclas
[PASS] 11_persistencia_cor
[PASS] 12_criacao_log             — chrome_bridge\logs\session_20260921_224723.csv

PASS=12  FAIL=0  SKIP=0

Detalhe honesto: se o Chrome estiver em game over quando a bateria rodar, o teste 6 é pulado e o 7 falha (não existe pulo para confirmar). Na primeira rodada de hoje foi exatamente isso — 10 PASS, 1 FAIL, 1 SKIP. Reiniciar a partida e rodar de novo dá os 12.

O diagnóstico do pulo

Uma dúvida clássica: segurar a tecla mais tempo pula mais alto? smoke_test_bridge.py --jump-diagnostic segura Espaço por 60, 150 e 300 ms e mede altura e tempo de voo pelo tRex.yPos:

hold 60 ms
71 px
151,7 ms no ar
hold 150 ms
75 px
172,0 ms no ar
hold 300 ms
70 px
150,5 ms no ar

Conclusão medida, não achismo: o pulo do Chrome tem altura e duração praticamente fixas (~72 px, ~160 ms). O número teórico da simulação (~83 px) foi substituído pelo medido no adaptador de observação.

Bugs que só apareceram ao vivo

NENHUM ERA VISÍVEL LENDO O CÓDIGO

A campeã jogava, mas morria muito mais rápido no Chrome do que no simulador. A investigação achou causas reais — todas dentro da ponte, nenhuma exigiu retreinar.

Decidia a 30 Hz, treinou a 60 Hz

A decisão estava presa ao relógio de desenho (30 fps). A cada decisão, o obstáculo já tinha andado o dobro do esperado. A inferência saiu do loop de desenho e passou para a thread de leitura, a ~60 Hz.

Um sleep de 60 ms a cada pulo

Segurar a tecla com time.sleep() síncrono travava leitura, decisão e desenho no momento mais crítico. A liberação passou a ser agendada e resolvida em cada leitura de estado.

Soltar só quando o Chrome pousar

Em vez de um número mágico de milissegundos, a tecla é liberada quando tRex.jumping volta a false (teto de 0,7 s, nunca atingido: o pulo real dura ~160 ms).

Aba oculta = jogo congelado

Se a janela do Chrome for minimizada ou totalmente coberta, o próprio Chrome pausa o requestAnimationFrame: distância parada, tRex.jumping travado. Numa gravação lado a lado isso não acontece — mas derrubou o teste automatizado.

%ProgramFiles(x86)% quebra o .bat

Parênteses no nome da variável abortam blocos if (...) do cmd.exe — o script fechava sem mostrar erro. Corrigido copiando o valor para uma variável sem parênteses antes de qualquer bloco.

Duas surpresas do próprio Chrome

chrome://dino por linha de comando é ignorado (abre "Nova guia") — a ponte navega sozinha via Page.navigate. E Input.enable não existe no protocolo: os comandos de teclado já funcionam direto.

Mesma campeã, mesma máquinaAntesDepois
Frequência de decisão~30 Hz~50–60 Hz
Duração da partida autônoma12 s25 s (sobreviveu o teste inteiro)
Distância percorrida3.192 px10.484 px

Resultados e Aprendizados

  • Busca populacional venceu a exploração aleatória. O ponto ótimo do pulo é uma agulha estreita; o NEAT a encontra selecionando pelo episódio inteiro, enquanto DQN e PPO só destravaram com demonstrações, reescala das entradas e avaliação gulosa antes de salvar.
  • A rede campeã é mínima. 8 sensores disponíveis, 2 usados, 5 conexões. Evolução não adiciona o que não precisa.
  • Ler o jogo real é possível, mas o Chrome mudou. window.Runner não existe mais; o listener de keydown é a porta de entrada, e includeCommandLineAPI permite usar o truque pelo protocolo, sem colar nada no Console.
  • A conversão de escala é uma hipótese, não um fato do código. O treino usa 1400×700 fixos; o canvas do Chrome varia. É a razão mais provável de a campeã morrer antes no Chrome do que no simulador — e um bom próximo experimento: treinar direto no jogo real, pela ponte.
  • Teste contra o sistema real. Cadência de decisão, sleep bloqueante, aba oculta, parênteses no .bat: nenhum apareceu na leitura estática. Todos apareceram no smoke test.
Ver código no GitHub Assistir ao Vídeo Jogar o Exp #001