Sonic PHP: cómo funciona un juego PHP en WebAssembly
Tabla de contenidos
Alguien ha reescrito el Sonic de la Master System en PHP. Entero. Unas 21.000 líneas de PHP que se ejecutan a 60 fps en tu navegador, en el móvil, y con partidas online de hasta seis jugadores.
Y no, no hay un servidor PHP detrás pintando los fotogramas. El intérprete de PHP viaja a tu navegador compilado a WebAssembly y ejecuta el juego ahí mismo, en la pestaña.
El proyecto se llama Sonic PHP y es obra de David Nurdin. Lo mejor es que ha publicado todo el código, con una documentación técnica que ya quisieran muchos proyectos de empresa. He pasado un buen rato leyendo sus cuatro repositorios y aquí te cuento cómo está montado por dentro.
En este artículo vas a encontrar:
- Cómo se ejecuta PHP 8.3 dentro de un navegador con Emscripten y una capa de C mínima
- El truco para domar un bucle bloqueante de ReactPHP con
requestAnimationFrame - Las optimizaciones que llevaron el juego a 60 fps, con números reales medidos en Chrome
- Cómo funciona el multijugador online con WebRTC y un relay escrito en PHP
- jumPhp, el framework que ha salido de todo esto para que hagas tu propio juego
¿Qué es Sonic PHP? ¶
Sonic PHP es un remake fan del Sonic the Hedgehog de Master System escrito por completo en PHP, que funciona igual en escritorio (PHP nativo + SDL2) y en el navegador (PHP compilado a WebAssembly). No es un emulador: es el juego reescrito, con su física, sus zonas y su música.

Según el README del repositorio, esto es lo que trae:
- Modo un jugador con las zonas del juego original, mejores tiempos y un fantasma de tu récord contra el que competir.
- Multijugador online de hasta 6 jugadores con cuatro modos: carrera, bandera, anillos y deathmatch. Las seis zonas, sus actos 1 y 2 y los ocho niveles bonus.
- Salas públicas o privadas con un código de cuatro letras. Los huecos vacíos se rellenan con bots.
- Repeticiones de cada partida, filtro CRT y velocidad PAL o NTSC a elegir.
- Controles táctiles en el móvil, que por debajo se convierten en eventos de teclado.
¿Por qué alguien haría esto? El propio autor lo explica en su artículo de dev.to: “Quería ver hasta dónde podía llegar PHP en WebAssembly”.
Es la misma razón que hay detrás de casi todos los side projects que acaban enseñando algo.
El código está repartido en cuatro piezas que encajan entre sí:
| Repositorio | Qué hace |
|---|---|
| sonicGamePhp | El juego en PHP. El mismo código para escritorio y navegador |
| php-wasm-sdl | El motor: PHP 8.3 + extensiones SDL2 compiladas a WebAssembly |
| php-wasm-network | La red: WebSocket y WebRTC con la misma API PHP en navegador y en nativo, más el servidor relay |
| jumPhp | Un framework para crear tus propios juegos retro 2D con la misma técnica |
Vamos por partes, empezando por la pregunta que te estás haciendo.
Tu propio side project, con un agente de IA trabajando a tu lado
Buena parte de los commits de Sonic PHP llevan la firma de Claude. Aquí arrancas un flujo con agentes que repites en cualquier proyecto, y en la lección 3 escribes su AGENTS.md regla a regla.
Entra en el curso →¿Cómo se ejecuta PHP dentro de un navegador? ¶
PHP se ejecuta en el navegador porque el intérprete entero se compila a WebAssembly con Emscripten. El navegador descarga un php-web.wasm (el motor de PHP con sus extensiones), un php-web.data (los ficheros del juego empaquetados) y un módulo JavaScript que lo arranca todo.
Llevar un runtime entero al navegador con WebAssembly ya no es raro: lo hace, por ejemplo, fx, un harness de agente que también compila a wasm y tiene que pedirle a la página el almacenamiento y la red. Con PHP tampoco es una idea nueva. El motor de Sonic PHP viene de una cadena de forks: oraoto/pib → seanmorris/php-wasm → soyuka/php-wasm → php-wasm-sdl. Lo que añade David es lo que hace falta para un juego: las extensiones de SDL2 y una forma de mantener PHP vivo frame tras frame.
¿Qué lleva dentro el motor? ¶
El Dockerfile de php-wasm-sdl parte de la imagen emscripten/emsdk:3.1.35, clona php-src en la rama PHP-8.3.0 y le añade las extensiones de SDL (php-sdl, php-sdl-image, php-sdl-mixer, php-sdl-ttf), OpenGL y OpenAL.
PHP se configura con --enable-embed=static --disable-all. Traducido: nada de CLI ni CGI, PHP pasa a ser una biblioteca que alguien llama desde C. Y solo se activan las extensiones que el juego necesita:
| Extensión | Para qué la usa el juego |
|---|---|
sdl, sdl_image, sdl_mixer, sdl_ttf |
Ventana, texturas, sonido y fuentes |
opengl, openal |
OpenGL se traduce a WebGL 1 y OpenAL a Web Audio |
gd + png |
Trocear imágenes demasiado anchas para una textura |
zlib |
Comprimir los fantasmas de los récords |
mbstring, ctype, tokenizer, bcmath |
Lo que piden los componentes de Symfony |
Hay un detalle muy fino en cómo encuentra SDL. Los config.m4 originales de esas extensiones buscan SDL con pkg-config y prueban un enlazado nativo, cosa que falla bajo Emscripten. Los ficheros de configuración del repo hacen trampa con elegancia: encuentran las cabeceras en los paquetes de Debian (libsdl2-dev) para contentar al configure, pero añaden -sUSE_SDL=2 a los flags. Al compilar y enlazar, lo que se usa de verdad son los ports de SDL que trae Emscripten.
⚠️ Compilar el motor desde cero lleva tiempo. La documentación de compilación mide unos 25 minutos en frío con 4 núcleos: 4 de clonar php-src, 6 de
configure, 6 demakey 4,5 del enlazado final con LTO.
¿Qué hace la capa de C? ¶
Entre el JavaScript de la página y PHP hay un fichero pequeño, phpw.c, que expone una única función al navegador: phpw_run(code). Hace cuatro cosas:
- Arranca PHP con
php_embed_init(la SAPI embed, sin CLI). - Ejecuta con
zend_eval_stringel código PHP de la página, que haceincludedeapp.php. Ese fichero monta el contenedor de Symfony, inicializa SDL, carga texturas y sonidos y registra los timers. - Recupera de la tabla de símbolos de PHP la función
sdl_frame()y el objeto$app. - Engancha un solo bucle del navegador con
emscripten_set_main_loop, que llama asdl_frame($app)en cadarequestAnimationFrame.
El paso 4 tiene trampa: php_embed_shutdown() no se llama nunca.
En el PHP de toda la vida, cada petición nace y muere. Aquí PHP arranca una vez y se queda vivo mientras la pestaña esté abierta. Los objetos, el estado de la partida y los recursos de SDL sobreviven de un frame al siguiente. Si vienes del mundo de FrankenPHP o los workers persistentes, la idea te sonará: es el mismo principio llevado al extremo.
Desde el lado de JavaScript, la llamada ccall('phpw_run') termina con una excepción "unwind". Parece un error, pero es el caso normal: es la forma que tiene Emscripten de devolver el control al navegador sin salir de la función. Si phpw_run vuelve sin excepción, es que algo ha fallado al arrancar y el loader muestra una pantalla de “GAME OVER”.
¿Cómo encaja un bucle de juego bloqueante en un navegador? ¶
El problema es este: el juego está escrito para escritorio, donde el bucle de eventos de ReactPHP se queda girando para siempre con Loop::run(). En un navegador eso es imposible. Si bloqueas el hilo principal, la página deja de pintarse y la pestaña se congela.
La solución es tan simple que da rabia no haberla pensado. Antes de cargar el juego, la página mete en la cola de ReactPHP una tarea que para el bucle en cuanto da la primera vuelta:
// Se ejecuta en el primer tick y detiene el bucle: así Loop::run() devuelve el control
\React\EventLoop\Loop::get()->futureTick(fn() => \React\EventLoop\Loop::get()->stop());
$app = include '/src/src/app.php';
Así, Loop::run() arranca, ejecuta ese tick, se detiene y devuelve el control. A partir de ahí, cada vez que el navegador pide un frame, la capa de C llama a sdl_frame(), que hace gameLoop->start(1): una sola vuelta del bucle de ReactPHP, que ejecuta los timers que han vencido.
Lo bonito es que el mismo código del juego funciona en escritorio. En nativo, el bucle se lanza normal y la vsync marca el ritmo. En el navegador, requestAnimationFrame hace de metrónomo.
🔑 Si solo te quedas con una idea de este proyecto: no adaptó el juego al navegador, adaptó el navegador al juego. El código de Sonic es el mismo en las dos plataformas, y no hay ni un solo parche específico para wasm.
En escritorio el timer de frame vale 1/120 s. En el navegador la página define SONIC_FRAME_INTERVAL = 0.01: un frame de juego por cada requestAnimationFrame, con un máximo de 100 por segundo. En una pantalla de 120 Hz, el juego procesa un frame de cada dos en lugar de seguir un ritmo irregular.
¿Por qué solo un bucle? ¶
Al principio había dos bucles: el de requestAnimationFrame y otro con setTimeout(0) que se ejecutaba unas 250 veces por segundo para procesar eventos. Resultado: el juego llegaba a calcular hasta dos frames completos (lógica y render) por cada frame mostrado. El hilo principal iba saturado y el teclado, el táctil y el audio llegaban tarde.
Se eliminó el segundo bucle. Es la lección que el propio autor destaca en su artículo: usa un solo bucle del navegador.
¿Cómo pinta y suena PHP en el navegador? ¶
El juego llama a SDL2 desde PHP y el port de SDL2 para Emscripten traduce esas llamadas a WebGL para el dibujo y a Web Audio para el sonido. El teclado, el ratón y el táctil se leen del DOM. Para el código PHP no hay diferencia con el escritorio.
Pero hay trampas. La documentación del motor recoge varias y todas son de las que te comen una tarde:
- El canvas de 3×3 píxeles. Con
SDL_WINDOW_RESIZABLE, SDL toma el tamaño CSS del canvas como tamaño de ventana. La página antigua usabazoom: 3sin tamaño CSS explícito, SDL leía un tamaño falso y el canvas acababa en 3×3 px. Se arregló dándole al canvas un tamaño CSS conaspect-ratio: 840 / 426. - Textos borrosos en el móvil. Con
devicePixelRatiode 2 o 3, el navegador escalaba el canvas en bilineal. La ventana se crea ahora conSDL_WINDOW_ALLOW_HIGHDPIy el canvas tiene la resolución real de la pantalla. - La línea negra del filtro CRT. Usar
image-rendering: pixelatedparecía buena idea, pero el tamaño CSS del canvas no es un número entero de píxeles físicos (596,4 px, por ejemplo). Al escalar al vecino más próximo, el navegador duplicaba una fila en medio de la imagen.
Las partidas guardadas van a IndexedDB a través de IDBFS, el sistema de ficheros de Emscripten. PHP escribe con file_put_contents como siempre en su carpeta var/, y el JavaScript sincroniza con IndexedDB cada dos segundos y al cerrar o esconder la pestaña:
// Monta IndexedDB en la carpeta donde PHP guarda las partidas
FS.mkdirTree('/src/src/var');
FS.mount(FS.filesystems.IDBFS, {}, '/src/src/var');
await syncfs(true); // IndexedDB -> FS, ANTES de arrancar PHP
setInterval(window.saveUserFiles, 2000); // FS -> IndexedDB (solo lo modificado)
addEventListener('pagehide', window.saveUserFiles);
PHP no sabe nada de IndexedDB. Escribe en disco y punto. Así da gusto trabajar con una abstracción.
El loader también tiene su historia. Mientras PHP arranca, el hilo principal se bloquea varios segundos. Si la barra de progreso se anima con width o background-position, se congela a medias. Por eso todas las animaciones del loader usan solo transform: el compositor del navegador las mueve sin necesitar el hilo principal.
Proyectos como este son los que nos encanta destripar. Cada domingo, en la newsletter, compartimos lo que estamos aprendiendo al programar con IA y 12 recursos seleccionados para +7.200 developers. Gratis, desde 2018.
Suscríbete gratis →¿Qué diferencias tiene PHP compilado a WebAssembly frente al nativo? ¶
La diferencia más gorda es que los enteros son de 32 bits. El build es wasm32, así que PHP_INT_MAX vale 2.147.483.647 y PHP_INT_SIZE vale 4. Los float siguen siendo de 64 bits. Y eso rompe cosas que en tu servidor nunca fallarían.
El ejemplo que documenta el repo es precioso. Para animar los ciclos de paleta (el agua que brilla, las flores que se mueven) el juego hacía (int)floor(microtime(true) * 60). Ese número ronda 1,07·10¹¹, que no cabe en 32 bits. El resultado se desbordaba, salían índices negativos, no encontraba el tile tileset1-cycle-1 y acababa en un TypeError dentro de SDL_RenderCopyEx. La solución: fmod(microtime(true), 86400.0) antes de convertir a entero.
Otro caso: la máscara alfa 0xFF000000 del filtro CRT tampoco cabe en un int de 32 bits. Se escribe como -0x1000000, que da el mismo valor en nativo y en wasm cuando la extensión lo convierte a Uint32.
El resto de limitaciones, según la documentación del motor:
| Característica | PHP nativo | PHP en wasm |
|---|---|---|
| Tamaño de entero | 64 bits | 32 bits |
| Opcache y JIT | Disponibles | No hay: PHP se interpreta |
Hilos, procesos, exec, sockets |
Sí | No |
Una llamada bloqueante (sleep, bucle infinito) |
Bloquea el proceso | Congela la pestaña |
echo |
Salida estándar | Consola del navegador |
| Memoria | La del sistema | Empieza en 256 MB, crece hasta 2 GB |
Ojo también con PHP_OS: vale Linux también en wasm, así que no sirve para detectar en qué plataforma estás.
¿Cuánto más lento es PHP en WebAssembly? ¶
Depende de cómo compiles. La documentación recoge un tropiezo que te puede pasar con cualquier proyecto C: cuando le pasas CFLAGS al configure de PHP, PHP deja de añadir su -O2 por defecto.
El núcleo Zend y las extensiones se estaban compilando sin optimizar. Solo wasm-opt optimizaba en el enlazado final, sin poder recuperar lo perdido. Un microbenchmark de bucles, arrays y llamadas a métodos daba el wasm entre 7 y 12 veces más lento que PHP nativo. Añadir -O2 explícito en CFLAGS lo resolvió.
💡 Si compilas cualquier proyecto C con Emscripten y le pasas tus propios
CFLAGS, revisa que no hayas desactivado sin querer la optimización por defecto del proyecto.
¿Cómo consiguió un juego en PHP llegar a 60 fps en el navegador? ¶
Midiendo. El autor usó el profiler de Chrome (vía CDP, Profiler.start) e instrumentación temporal con microtime() para medir lógica, escena, overlay y presentación de cada frame. Luego atacó los problemas uno a uno.
La tabla completa está en el repo. Estas son las mejoras que más me han llamado la atención:
| Problema | Efecto | Solución |
|---|---|---|
glCheckFramebufferStatus en cada SDL_SetRenderTarget |
65 % del tiempo de CPU: una llamada WebGL bloqueante que obligaba a esperar a la GPU | La página sustituye checkFramebufferStatus por una función que siempre responde FRAMEBUFFER_COMPLETE |
| ~400 bloques redibujados en cada frame | ~5 ms de PHP por frame | Los bloques alrededor de la vista se cachean en una textura y solo se redibujan si cambian |
| Textos del menú con contorno: 37 copias por letra | ~6 ms por frame en el menú | Caché de hasta 48 textos ya dibujados |
| Los tiles de 35 niveles cargados al arrancar (282 PNG, ~200 MB de texturas) | ~20 s de arranque y GPU saturada en móvil | Carga perezosa: solo las texturas del nivel en juego |
Un closure que se replanificaba con use (&$tick) en cada frame |
Un ciclo de memoria por frame (~25 KB/s) y una recolección larga después | Un callback creado una sola vez |
| Emscripten activaba todas las extensiones WebGL al crear el contexto | 1 a 2 s de arranque | Solo se ofrecen las que Emscripten usa |
| Contenedor de Symfony compilado al arrancar | ~0,5 s | Precompilado al empaquetar |
Fíjate en la primera fila. Una sola llamada de WebGL se comía dos tercios de la CPU. No era PHP, no era el juego. Era una comprobación que SDL hacía por seguridad y que en este caso nunca podía fallar, porque todos los render targets son texturas RGBA completas.
El caso del closure te interesa aunque nunca hagas un juego, si escribes PHP de larga duración. Un closure que se captura a sí mismo por referencia crea un ciclo de memoria que el contador de referencias no libera. En una petición web normal te da igual, porque el proceso muere en milisegundos. En un proceso que vive horas, la memoria crece hasta que el recolector de ciclos se despierta y te pega un tirón.
🛡️ Esta lección sirve fuera de los juegos. Si ejecutas PHP en workers persistentes (FrankenPHP, RoadRunner, Swoole, colas de Laravel), cuidado con los closures que se referencian a sí mismos en código que se repite.
¿Cómo gestiona las versiones y la caché? ¶
Un juego que pesa megas no puede descargarse entero en cada visita. El script package.sh genera un version.json con la huella SHA-256 (12 caracteres) de php-web.mjs, php-web.wasm y php-web.data. La página lo lee sin caché y luego:
- El
.wasmse guarda en Cache Storage y las versiones antiguas se borran. - El
.datalo guarda en IndexedDB el propiofile_packagerde Emscripten con--use-preload-cache. - Todo lleva un
?v=<huella>para no recibir un fichero viejo de la caché HTTP.
Resultado: una versión nueva se coge en la siguiente carga y lo que no ha cambiado no se vuelve a descargar.
¿Cómo funciona el multijugador online de un juego en PHP? ¶
El multijugador funciona sin servidor de juego. Uno de los jugadores, el anfitrión, hace de árbitro: su partida aplica las reglas (contactos, bandera, anillos, meta, cronómetro, marcadores) y mueve a los bots. El resto de jugadores conduce su propio Sonic en local, así que no notan retardo en los controles aunque tengan un ping alto.

La red vive en su propia biblioteca, php-wasm-network, con la misma API PHP en el navegador y en nativo:
use PhpWasmNetwork\{Network, Event};
$net = Network::connect('wss://net.example.org/ws', ['transport' => 'webrtc']);
// En cada frame: leer eventos sin bloquear
foreach ($net->poll() as $event) {
match ($event->type) {
Event::OPEN => $net->createRoom(4, ['mode' => 'race']),
Event::MESSAGE => handle($event->from, $event->data),
Event::HOST => null, // el anfitrión se ha ido: $event->from le sustituye
default => null,
};
}
$net->send($state, null, false); // no fiable: puede perderse, pero nunca llega tarde
¿Por qué no usar los sockets POSIX que emula Emscripten? El README de php-wasm-network lo explica: serían bloqueantes dentro del bucle de juego, solo van por TCP (nada de WebRTC) y piden opciones de compilación frágiles. Así que el motor wasm solo lleva una capa mínima (la extensión wasmnet y un library_wasmnet.js) y toda la lógica de red del navegador vive en un fichero JavaScript que se carga con la página. Se puede corregir sin recompilar el motor.
¿Qué viaja por la red y cada cuánto? ¶
Estos son los números del protocolo, según la explicación del multijugador:
- Cada jugador envía su estado (posición, velocidad, frame de animación, anillos, escudo…) 30 veces por segundo al anfitrión, como mensaje no fiable.
- El anfitrión emite una instantánea de la partida 20 veces por segundo, a unos 30 bytes por Sonic.
- Los eventos importantes (un golpe, un monitor roto, el final de la partida) van como mensajes fiables en JSON.
- Los otros Sonic se dibujan 120 ms en el pasado con interpolación, así se ven fluidos aunque solo lleguen 20 posiciones por segundo.
- Para los contactos, el anfitrión hace compensación de latencia: coloca a cada jugador remoto donde está en su propia pantalla, avanzando su último estado según su velocidad y el ping.
Cada pareja de navegadores tiene dos DataChannels de WebRTC: r, fiable y ordenado, y u, no fiable y sin orden, que se comporta como UDP. Los navegadores hablan entre ellos en directo. Un relay escrito en PHP sin dependencias gestiona las salas y la señalización, y un coturn hace de servidor TURN para las redes con NAT estricto.
La versión de escritorio también puede entrar en la misma sala. Como PHP nativo no tiene WebRTC, siempre pasa por el relay. Los navegadores lo detectan y le hablan por WebSocket a través del servidor, mientras entre ellos siguen en directo.
¿Cómo evita las trampas? ¶
El anfitrión no se cree todo lo que le cuentan. Rechaza velocidades de más de 1.800 px/s (salvo tras una muerte, un golpe o la salida), solo acepta anillos que el jugador haya cogido de verdad y no deja activar escudo ni invencibilidad sin haber roto antes ese monitor.
La documentación es honesta con el límite: el anfitrión es un jugador. Si el tramposo es el anfitrión, nadie le controla. Un servidor con autoridad tendría que ejecutar la partida sin pantalla.
¿Y por qué no lockstep, la técnica clásica de los juegos de estrategia? Porque la física usa float y pasos de tiempo variables, así que no es determinista entre máquinas. Y menos entre wasm de 32 bits y PHP nativo de 64.
¿De dónde salen los gráficos y la música? ¶
De la ROM original de Master System. La carpeta tools/ del juego tiene una colección de scripts en Python que decodifican los datos del cartucho: el arte de los niveles, los ciclos de paleta, los sprites de los objetos, los jefes, el agua de Labyrinth…
El de la música es una pequeña joya. El script soundrip.py no se limita a extraer ficheros. Ejecuta el driver de sonido original del Z80 dentro de un emulador, captura cada escritura al puerto del chip de sonido PSG ($7F) frame a frame y se la pasa a un emulador del SN76489, el chip de sonido de la consola. Los bucles de cada canción se detectan cuando el estado de la RAM del driver y del PSG se repite.
Hasta las zapatillas de velocidad están contempladas: el script renderiza también una versión rápida de cada canción y un timing.json con la duración de cada parte, para que el juego cambie entre ambas versiones en el mismo punto de la melodía.
Para los bots de colores, otro script (soniccolors.py) genera hojas de sprites recoloreadas. ¿Por qué no tintar el sprite en tiempo real? Porque la extensión de SDL para PHP no tiene SDL_SetTextureColorMod. Cuando la herramienta no da para más, preparas los assets por adelantado.
⚠️ Es un proyecto fan sin ánimo de lucro. Sonic, sus personajes, gráficos y música son propiedad de SEGA, y el proyecto lo deja claro en su aviso legal.
¿Qué hay alrededor del juego? ¶
Alrededor del juego hay una web completa hecha también en PHP: Symfony 8.1 sobre FrankenPHP, según el artículo del autor. Tiene salas en directo, estadísticas, repeticiones fieles de cada partida y editores de niveles, tiles (dibujo y colisiones), sprites, sonidos y música. Un nivel publicado se puede jugar dentro del juego al momento.

Hay un detalle simpático: los visitantes aparecen en la web como pequeños Sonic que corren por la página, gracias a eventos de suscripción de Mercure.
¿Y cómo habla el juego (PHP dentro de wasm) con la web (JavaScript)? La documentación lo cuenta sin rodeos: el juego no puede llamar a JavaScript. Así que escribe sus peticiones (compartir una sala, volver a la web) en un fichero, /tmp/sonic-bridge, que la página lee. Un fichero como canal de comunicación. Funciona.
¿Qué ofrece la página para desarrolladores? ¶
La página de desarrolladores documenta una API pública de solo lectura sin cuenta ni clave. Puedes leer en JSON los niveles (bloque a bloque), los tiles y sus colisiones, los sprites, las músicas, los jugadores, las partidas jugadas y el historial de cada creación:
# Los 5 niveles públicos más recientes
curl -H "Accept: application/json" \
"https://sonic.mimichat.fr/api/public/levels?order=created&itemsPerPage=5"
Viene con su OpenAPI y tres interfaces para explorarla (Swagger UI, ReDoc y Scalar). Las reglas están claras: JSON-LD/Hydra por defecto o JSON plano, 30 resultados por página (100 como máximo), errores en formato Problem Details, CORS abierto y un límite por IP de 600 peticiones de golpe y luego una por segundo.
Y para escribir, hay un servidor MCP en https://sonic.mimichat.fr/mcp con 24 herramientas. Con una clave de API, una IA puede crear un mundo de juego completo en tu cuenta: tiles, un nivel, sprites, música y efectos. Puede mirar lo que ha hecho (una imagen del nivel), probarlo con un bot que juega una partida falsa y dice dónde se atasca, arreglarlo y publicarlo.
Con Claude Code se conecta así:
claude mcp add --transport http sonic https://sonic.mimichat.fr/mcp \
--header "Authorization: Bearer sk_sonic_..."
Si nunca has montado uno, tienes un tutorial para crear tu primer servidor MCP. Este es un buen ejemplo para inspirarte: la IA trabaja como tú en el editor, crea, mira el resultado, lo prueba y lo corrige.
Una API pública y un servidor MCP en un side project fan dicen mucho de hacia dónde va el desarrollo. Cada domingo contamos cómo vivimos ese cambio con la IA, con lo que aportan los +7.200 developers suscritos.
Quiero esa dinamita 🧨¿Qué es jumPhp y cómo puedes hacer tu propio juego? ¶
jumPhp es el framework que ha salido de Sonic PHP: todo lo aprendido, empaquetado para crear juegos retro 2D con SDL2 que funcionan en escritorio y en el navegador con el mismo código. Trae tiles y colisiones al píxel (pendientes, plataformas, mapas de Tiled), entidades y animaciones, cámara, sonido, menús, guardado, traducciones, teclado, mando y táctil, y multijugador online.
Todo pasa por Docker. Tu máquina no necesita PHP, Emscripten ni Node:
composer create-project jumphp/jumphp mi-juego
Ese comando lanza un asistente que te pregunta paso a paso por las herramientas, el nombre y la resolución del juego, el motor para navegador, el despliegue (FrankenPHP solo, Traefik + FrankenPHP o detrás de un Traefik que ya tengas) y el repositorio de GitHub. Si quieres probar sin crear nada, el repo trae juegos de ejemplo:
git clone https://github.com/davidnurdin/jumPhp.git && cd jumPhp
bin/jumphp setup # imágenes Docker (una vez, ~3 min)
bin/jumphp install pong # dependencias del juego
bin/jumphp run pong # jugar en escritorio (ventana + sonido)
bin/jumphp engine:build # motor WebAssembly (una vez por máquina, ~25 min)
bin/jumphp build pong # -> games/pong/dist/web
bin/jumphp serve pong # http://localhost:8080
Además de Pong, trae un breakout, un hello-world, un hello-webgl y JUMPBALL, una demo en 3D con WebGL. Puedes probarlos sin instalar nada en la demo de jumPhp o en el playground de jumphp.net.
La arquitectura replica la de Sonic. En escritorio, un timer periódico de ReactPHP y Loop::run() bloqueante. En wasm, emscripten_set_main_loop llama a Engine::frame(), que hace un tick() y una vuelta del bucle. Cada tick lee la entrada, procesa la red, actualiza el sonido y la escena y pinta en una textura a resolución virtual que luego se escala con bandas negras y píxeles nítidos.
¿Qué papel ha tenido la IA en el proyecto? ¶
Basta con mirar el historial de git. Si buscas commits con la línea Co-Authored-By: Claude, en el momento de escribir esto salen 41 de 115 commits en sonicGamePhp, 55 de 99 en php-wasm-sdl, 9 de 11 en php-wasm-network y los 26 de jumPhp.
Las fechas también dicen bastante. El juego nativo arrancó en mayo de 2025 y tuvo su gran empujón entre mayo y julio de ese año. Luego estuvo parado más de un año. En octubre de 2026 llegan 47 commits de golpe en el juego, la librería de red completa nace en dos días y jumPhp en tres.
jumPhp da por hecho que vas a trabajar así. Su repo tiene un AGENTS.md (y un CLAUDE.md que lo importa) y el último paso del asistente de creación es lanzar Claude Code con un prompt preparado. Tú describes el juego en games/<id>/BRIEF.md, el agente lo programa, ejecuta los tests automáticos y mira las capturas que genera bin/jumphp shot sin pantalla para comprobar que lo que ha hecho se ve bien.
¿Le quita eso mérito a David? Yo creo que no.
Lo que hace que el proyecto funcione son decisiones de criterio: elegir un solo bucle, medir con el profiler antes de optimizar, renunciar al lockstep porque la física no es determinista, mantener un único código para las dos plataformas. Y una documentación técnica que explica el porqué de cada decisión, con las trampas que encontró por el camino. Un agente escribe código rápido, pero esas decisiones las tomó una persona que sabía qué quería construir.
Domina la herramienta a fondo
Claude Code más allá del chat: CLAUDE.md, hooks, agentes y MCP
Verás cómo se trabaja con CLAUDE.md, hooks, agentes y servidores MCP en flujos reales, el mismo terreno que pisan jumPhp y el MCP de Sonic PHP.
Entrar a la masterclass →Masterclass en directo · 2h30
¿Qué puedes aprender tú de Sonic PHP? ¶
Aunque no vayas a hacer un juego en tu vida, este proyecto tiene lecciones que puedes aplicar el lunes:
- Mide antes de optimizar. El 65 % de la CPU se iba en una llamada de WebGL que nadie habría sospechado. Sin profiler, habría reescrito la física.
- Un solo código, dos plataformas. En lugar de mantener parches para wasm, metió las adaptaciones (enteros de 32 bits, altura de vista en móvil) en el propio juego, válidas en los dos sitios.
- No bloquees el hilo principal. Ni para la red, ni para el bucle de juego, ni para la barra de progreso. Todo lo que bloquea, congela.
- La capa intermedia, cuanto más fina, mejor. La red del navegador vive en un JavaScript que se corrige sin recompilar 25 minutos de motor.
- Documenta las trampas. El canvas de 3×3 px, el
-O2perdido, el closure con fuga. Cada una está escrita con su causa y su solución. - PHP de larga duración necesita otra cabeza. Ciclos de memoria, estado que sobrevive, recursos que no se liberan. Lo mismo que te encuentras en workers persistentes.
Y una más, que no es técnica: los side projects absurdos enseñan muchísimo. ¿Hacía falta un Sonic en PHP? No. ¿Ha salido de ahí una librería de red, un framework de juegos, una API pública y un servidor MCP? Sí.
PHP lleva años cargando con la etiqueta de lenguaje viejo. Si alguna vez te ha tocado defender PHP frente a quien dice que está muerto, ya tienes un argumento más con el que zanjar la discusión: pásale el enlace y que juegue una partida.
TL;DR ¶
- 🎮 Sonic PHP es un remake del Sonic de Master System en ~21.000 líneas de PHP que funciona a 60 fps en el navegador gracias a PHP 8.3 compilado a WebAssembly con Emscripten y SDL2.
- 🔁 El truco central: PHP no se apaga nunca entre frames y el bucle de ReactPHP avanza un tick por cada
requestAnimationFrame. - ⚡ La mayor optimización fue quitar un
glCheckFramebufferStatusque se comía el 65 % de la CPU; también un-O2perdido que hacía el wasm entre 7 y 12 veces más lento. - 🌐 El multijugador de hasta 6 jugadores usa WebRTC entre navegadores, un relay en PHP y un anfitrión que hace de árbitro, sin servidor de juego.
- 🛠️ De todo esto ha salido jumPhp, un framework para crear tus propios juegos retro en PHP, y la web ofrece API pública y servidor MCP.
Preguntas frecuentes ¶
¿Se puede ejecutar PHP en el navegador sin servidor?
Sí. Compilando el intérprete de PHP a WebAssembly con Emscripten, el navegador descarga un fichero .wasm con el motor de PHP y ejecuta el código en local. Sonic PHP usa PHP 8.3 compilado así, con extensiones SDL2 para gráficos y sonido.
¿Qué es php-wasm-sdl?
Es el motor de Sonic PHP: PHP 8.3 y las extensiones de SDL2, OpenGL y OpenAL compilados a WebAssembly. Viene de una cadena de forks (oraoto/pib, seanmorris/php-wasm, soyuka/php-wasm) y añade una capa en C que mantiene PHP vivo entre frames.
¿Cómo se ejecuta un bucle de ReactPHP en WebAssembly?
Se encola un futureTick que detiene el bucle tras su primera vuelta, para que Loop::run() devuelva el control. Después, en cada requestAnimationFrame, la capa de C llama a una función PHP que avanza una sola vuelta del bucle con gameLoop->start(1).
¿Por qué los enteros de PHP en WebAssembly son de 32 bits?
Porque el build es wasm32, así que PHP_INT_MAX vale 2.147.483.647. Los floats siguen siendo de 64 bits. Operaciones como (int)(microtime(true) * 60) se desbordan y hay que acotarlas, por ejemplo con fmod().
¿Es PHP en WebAssembly más lento que PHP nativo?
Sí, no tiene opcache ni JIT y se interpreta. La diferencia depende mucho de la compilación: sin -O2 explícito en CFLAGS, un microbenchmark daba el wasm entre 7 y 12 veces más lento que el nativo.
¿Cómo funciona el multijugador online de Sonic PHP?
Un jugador hace de anfitrión y aplica las reglas; cada jugador mueve su Sonic en local. Los navegadores se conectan por WebRTC, con un relay en PHP para salas y señalización y coturn como servidor TURN. Los estados viajan a 30 Hz y las instantáneas del anfitrión a 20 Hz.
¿Qué es php-wasm-network?
Es una biblioteca de red para PHP con la misma API en el navegador y en nativo. Usa WebSocket a través de un relay y WebRTC con canales fiables y no fiables. Incluye el servidor, escrito en PHP sin dependencias, que hace de lobby, relay y señalización.
¿Dónde guarda las partidas un juego PHP en el navegador?
En IndexedDB, mediante IDBFS de Emscripten. PHP escribe ficheros con normalidad en su carpeta var/ y el JavaScript de la página sincroniza esos ficheros con IndexedDB cada dos segundos y al cerrar la pestaña.
¿Qué es jumPhp?
Un framework PHP para crear juegos retro 2D con SDL2 que funcionan en escritorio y en el navegador con el mismo código. Todo se gestiona con Docker y su asistente de creación termina lanzando Claude Code para programar el juego a partir de una descripción.
¿Tiene Sonic PHP una API pública?
Sí, de solo lectura, sin cuenta ni clave, documentada con OpenAPI en su página de desarrolladores. Para crear contenido con una IA hay un servidor MCP con 24 herramientas que necesita una clave de API de tu cuenta.
Fuentes ¶
- Sonic PHP y su página para desarrolladores
- sonicGamePhp: el juego
- php-wasm-sdl: el motor, con su documentación de funcionamiento, compilación y multijugador
- php-wasm-network: la red
- jumPhp: el framework
- Running a 21k-line PHP game at 60 fps in the browser with WebAssembly, el artículo del autor en dev.to
🧨 Última oprtunidad para recibir la dinamita que mereces sobre programación con IA el próximo domingo: Suscríbete gratis a Web Reactiva en https://webreactiva.com/newsletter
12 recursos para developers cada domingo en tu bandeja de entrada
Además de una skill práctica bien explicada, trucos para mejorar tu futuro profesional y una pizquita de humor útil para el resto de la semana. Gratis.