iceranto.dev / log

Integrando EmulatorJS em um site Astro SSR

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.