Motivação
Queria adicionar jogos clássicos à página /jogos do site. O Asteroids já estava lá via iframe externo, mas queria algo emulado de verdade — um jogo rodando em hardware simulado diretamente no browser.
O EmulatorJS parecia a opção mais madura: suporta NES, SNES, GBA, PS1, N64 e outros, roda via WebAssembly, e tem um CDN público pronto para uso. A integração básica é trivial, na teoria.
A ROM: só homebrew
Antes de qualquer código: ROM de jogo comercial não vai entrar no servidor. A Nintendo derruba sites por isso, mas resolvi arriscar e adicionei o Donkey-kong. Escolhi o Flappy Bird GBA como teste, um port homebrew criado em 2014 por Jay van Hutten, escrito em C com DevKitARM. É gratuito, não é propriedade de nenhuma empresa, e o arquivo .gba é válido.
A ROM fica em public/roms/flappybird/flappybird.gba, servida como arquivo estático pelo Astro.
Integração básica
A documentação do EmulatorJS mostra isso:
<div id="game"></div>
<script>
EJS_player = "#game";
EJS_core = "gba";
EJS_gameUrl = "/roms/flappybird/flappybird.gba";
EJS_pathToData = "https://cdn.emulatorjs.org/stable/data/";
</script>
<script src="https://cdn.emulatorjs.org/stable/data/loader.js"></script>
Simples. Mas em Astro SSR, as coisas não funcionam assim.
Bug 1 — document.currentScript é null
Ao carregar a página, o console mostrou:
TypeError: Cannot read properties of null (reading 'src')
O loader.js do EmulatorJS usa document.currentScript.src para calcular onde estão os assets do emulador. Em Astro, os scripts com is:inline são processados e podem ser movidos no HTML de forma que document.currentScript retorna null em tempo de execução.
A solução é definir EJS_pathtodata (o caminho dos assets) antes de carregar o loader. Quando essa variável está definida, o loader a usa e ignora o currentScript.
Bug 2 — EJS_pathToData vs EJS_pathtodata
Aqui está o detalhe que a documentação não deixa claro: a documentação oficial escreve EJS_pathToData (camelCase), mas o código-fonte do loader.js verifica EJS_pathtodata, tudo em minúsculo (Foi complicado achar isso …).
JavaScript é case-sensitive. A variável era ignorada, o loader tentava usar document.currentScript, que era null, e tudo quebrava.
// ❌ Como está na documentação — não funciona como fallback
window.EJS_pathToData = 'https://cdn.emulatorjs.org/stable/data/';
// ✅ Como o loader.js realmente lê
window.EJS_pathtodata = 'https://cdn.emulatorjs.org/stable/data/';
Para confirmar, bastou ler o fonte do loader diretamente:
// trecho real do loader.js
let scriptPath = (typeof window.EJS_pathtodata === "string")
? window.EJS_pathtodata
: folderPath((new URL(document.currentScript.src)).pathname);
Solução final
O loader precisa ser uma tag <script> estática (não injetada via createElement) para que o document.currentScript funcione como fallback caso necessário. O mais seguro é hospedar o loader.js localmente, assim o path é sempre previsível:
# baixar o loader uma vez
curl -o public/emulatorjs/loader.js \
https://cdn.emulatorjs.org/stable/data/loader.js
E no .astro:
<script is:inline>
window.EJS_player = '#flappy-gba';
window.EJS_core = 'gba';
window.EJS_gameUrl = '/roms/flappybird/flappybird.gba';
window.EJS_pathtodata = 'https://cdn.emulatorjs.org/stable/data/';
window.EJS_color = '#3C3489';
window.EJS_startOnLoaded = true;
window.EJS_videoRotation = 1; // portrait mode — o jogo é vertical
</script>
<script is:inline src="/emulatorjs/loader.js"></script>
O is:inline garante que o Astro não processe nem mova o script. A ordem importa: as variáveis globais precisam estar definidas antes do loader ser executado.
Estrutura de template para múltiplos jogos
Para facilitar a adição de novos jogos no futuro, montei um array de dados no frontmatter do Astro que gera os cards automaticamente:
---
const gbaGames = [
{
id: 'flappy-gba',
title: 'Flappy Bird GBA',
rom: '/roms/flappybird/flappybird.gba',
rotation: 1,
height: 560,
// ...
},
];
---
{gbaGames.map((game) => (
<section>
<div id={game.id}></div>
</section>
))}
Cada jogo é inicializado via IntersectionObserver — o emulador só carrega quando o card entra na viewport, evitando que múltiplos jogos na mesma página inicializem ao mesmo tempo e travem o browser.
Integração com o CLI
O site tem um modo CLI em /cli. Adicionei o comando play para abrir jogos diretamente pelo terminal:
~/iceranto $ play
# lista os jogos disponíveis
~/iceranto $ play flappy-bird
# redireciona para /jogos
Com Tab completion funcionando: play f + Tab → play flappy-bird.
O que ficou de fora
- Salvar estado: o EmulatorJS suporta save states via
EJS_loadStateURL, mas não configurei — o Flappy Bird não precisa. - Gamepad: o EmulatorJS detecta controles automaticamente, mas não testei.
- Múltiplos emuladores simultâneos: o EmulatorJS não suporta mais de uma instância por página no mesmo contexto. Para múltiplos jogos, cada um precisaria de um iframe separado ou inicialização sequencial.
Conclusão
A integração funciona bem, mas exige atenção a dois pontos que a documentação não deixa explícito: o nome exato da variável EJS_pathtodata (tudo minúsculo) e o comportamento do document.currentScript em ambientes SSR. Hospedar o loader.js localmente elimina o problema de raiz, e por fim, aqui estou eu, às 1h30 da manhã, documentando essa experiência.