El Blog de AfterPack
guidesecuritygames

Cómo proteger el código fuente de un juego en JavaScript: caso práctico multijugador

por Nikita Savchenko16 min de lectura

Para proteger el código fuente de un juego en JavaScript, ofusca el build del cliente, deja fuera del ofuscador los motores de terceros y las tablas de strings, y mantén en el servidor los secretos y cualquier comprobación que pueda hacer un servidor. AfterPack (afterpack.dev), el ofuscador de JavaScript, reescribe un juego ya compilado como un programa estructuralmente distinto en cada release. Su motor Free funciona en local con npx afterpack o con un plugin de Vite, webpack o Rollup. BIGBOARD.GAMES, un juego multijugador peer-to-peer, se publica con él a los fps que permite el navegador.

Lancé BIGBOARD.GAMES el 6 de octubre. Es una colección de juegos gratuitos para el navegador pensados para 2 a 4 móviles y tablets juntos sobre una mesa: cada pantalla pasa a formar parte de un mismo tablero, y en Hockey de mesa (Seam Hockey) el disco se desliza de un dispositivo al siguiente cruzando la unión entre pantallas. Salió con Hockey de mesa, Desborde (Spillover), Aplasta al topo (Whack-a-Mole) e Islas (Islands). Las partidas van de dispositivo a dispositivo por WebRTC, sin servidor de juego de por medio; en mi blog conté cómo está hecho el juego. El primer commit del repositorio ya tenía AfterPack en vite.config.ts, así que el juego nunca se ha publicado sin protección. Este post cuenta lo que eso le ha costado en frames, bytes y tiempo de build, con cada cifra sacada del build real.

En los juegos es donde más temen los desarrolladores que la ofuscación se coma los fps, y también donde antes se lee el código publicado, porque las reglas, la física y el protocolo de red se ejecutan en el dispositivo del jugador. La IA abarató esa lectura: un agente devolvió las propias demos de dos ofuscadores populares a código fuente limpio en 10 y 20 minutos. Las mismas herramientas que me permitieron construir BIGBOARD.GAMES en unos diez días le permiten a cualquier otro leerlo igual de rápido.

¿Qué expone el código de un juego en JavaScript?

Todo. Las reglas, la física, la puntuación y el protocolo de red que tendría que hablar un programa de trampas están en el bundle, y la minificación solo acorta los nombres locales: los strings, las constantes y la estructura siguen legibles, como muestra línea a línea este recorrido por un bundle minificado.

En un juego peer-to-peer, el cliente además hace de árbitro. En Hockey de mesa, el dispositivo en el que está el disco lo simula y envía su posición a los demás, y el control pasa al siguiente dispositivo al llegar a una unión. Desborde y Cardumen (Shoal) funcionan en lockstep determinista: cada dispositivo ejecuta la misma simulación con las mismas entradas y tiene que llegar al mismo estado. Un Cloudflare Worker con dos Durable Objects solo prepara la mesa, reserva los asientos y registra los resultados.

Lo que llega a cada jugadorLo que revela leerlo
La física del disco y el traspaso del control en las unionesDónde se decide un gol, y en qué dispositivo
Las simulaciones en lockstep y su trigonometría deterministaEl estado exacto en el que tienen que coincidir todos los dispositivos
El códec de mensajes de los canales de datos de WebRTCEl protocolo que tendría que hablar un cliente modificado
La calibración de pantalla en milímetros reales (perfiles de dispositivo, o una tarjeta bancaria apoyada en el cristal)La parte que más necesitaría un clon, y la más difícil de rehacer

¿La ofuscación de JavaScript ralentiza un juego?

En BIGBOARD.GAMES, no de forma visible. Con el preset light, el que AfterPack usa por defecto, el bucle de frames de Hockey de mesa va al ritmo que el navegador le dé a requestAnimationFrame: entre 60 y 144 fps en una OnePlus Pad 3, y 60 fps en los iPad, donde todos los navegadores son WebKit y WebKit mantiene las páginas cerca de 60 fps salvo que el jugador desactive el feature flag «Prefer Page Rendering Updates near 60fps» de Safari. Los diagnósticos del juego leen ese ritmo en el build ofuscado que reciben los jugadores.

Los fps son una medida poco precisa, así que también cronometré el propio código de simulación: las mismas funciones que publican los juegos, empaquetadas con esbuild, sin ofuscar y ofuscadas con light, tanto con el motor con el que se lanzó BIGBOARD (0.2.1) como con el actual (0.2.3), en V8 y en JavaScriptCore.

Código del juego (unidad)RuntimeSin ofuscarMotor 0.2.1Motor 0.2.3
sinCos determinista (ns por llamada)V844,2104,2 (2,36x)44,1 (1,00x)
sinCos determinista (ns por llamada)JavaScriptCore9,557,4 (6,04x)20,6 (2,17x)
Simulación de Cardumen (µs por tick)V826,558,2 (2,20x)49,7 (1,88x)
Simulación de Cardumen (µs por tick)JavaScriptCore21,736,9 (1,71x)26,7 (1,23x)
Física del disco de Hockey de mesa (µs por paso)V80,521,01 (1,94x)0,78 (1,50x)
Física del disco de Hockey de mesa (µs por paso)JavaScriptCore0,390,66 (1,69x)0,64 (1,63x)

Medido el 2026-10-08 en un Apple M2 Max con Node.js 24.21.0 (V8) y el WebKit 26.6 de Playwright (JavaScriptCore): la mediana de 60 a 120 ejecuciones cronometradas, repartidas en 4 a 8 procesos nuevos por variante, con la semilla que usó el build publicado. En V8, uno de los ocho procesos de Cardumen con el motor 0.2.3 se estabilizó en unos 67 µs en lugar de 50, según cómo decidiera V8 optimizar esa ejecución.

Así que el código ofuscado del juego sí va más lento, entre 1,0x y 2,2x lo que tarda sin ofuscar con el motor actual, y aun así los fps no se mueven, porque la simulación ocupa una fracción mínima del frame. El caso más lento de la tabla, Cardumen en V8 con el motor 0.2.1, tarda 58,2 µs por tick: el 0,35 % de un frame de 16,7 ms a 60 Hz y el 0,84 % de uno de 6,9 ms a 144 Hz. Una tablet es más lenta que un M2 Max, pero haría falta una CPU más de 100 veces más lenta para que ese tick llenara un frame a 144 Hz.

light renombra los identificadores, pasa los literales de string por un decodificador en tiempo de ejecución y reescribe la sintaxis, sin añadir capas estructurales. Desde el motor 0.2.3 además deja los bucles como bucles: los motores anteriores los reescribían como funciones auxiliares con callbacks, lo que costaba 2-5x en los bucles críticos (registro de cambios). Los nombres de propiedades y de globales siguen pasando por constantes codificadas, lo que tiene cierto coste en código muy caliente. Los presets por encima, de medium a extreme, añaden trabajo estructural en cada token. Un juego que los quiera debería aplicarlos al código que más vale, como el netcode, con una directiva de Pro, y dejar el bucle de frames en light.

¿La ofuscación rompe el multijugador determinista?

No debe, y en BIGBOARD.GAMES no lo hace: los juegos en lockstep necesitan un estado idéntico bit a bit en todos los dispositivos, entre el JavaScriptCore de Safari y el V8 de Chrome, y lo consiguen con el build ofuscado.

Ese listón está más alto que «el juego sigue funcionando». No está garantizado que Math.sin devuelva los mismos bits en todos los motores de JavaScript (la especificación dice que su resultado es una aproximación que depende de la implementación), así que Desborde combina un build determinista del motor de física Rapier con su propio sinCos, que no se aleja más de 5e-14 de Math.sin. Ese sinCos y las simulaciones que lo llaman se ofuscan igual que el resto del código del juego. En cada ejecución cronometrada de arriba calculé el hash del estado completo de la simulación, cada float por sus bits exactos, y todas las variantes ofuscadas coincidieron con la original tanto en V8 como en JavaScriptCore. El estado de Cardumen y las salidas de sinCos también coincidieron entre los dos motores, que es la propiedad de la que depende el lockstep. Cada noche, la suite multidispositivo de Playwright juega partidas contra staging, que sirve el mismo build ofuscado que reciben los jugadores, entre ellas una de Cardumen con tres dispositivos que saca a un jugador a mitad de partida y comprueba que los otros dos no se desincronizan.

Lo que la salida ofuscada mantiene igual, y las pocas diferencias deliberadas, se explica en la página del contrato semántico.

Cómo ofuscar el build de un juego con Vite

Instala @afterpack/vite, añade afterpackVite() a plugins y excluye los chunks que no son tuyos. vite dev no cambia; vite build genera chunks ofuscados.

npm install -D @afterpack/vite

La configuración de abajo es la de BIGBOARD, recortada a dos exclusiones. La compilé en un proyecto limpio el 2026-10-08 con Vite 8.3.3, @afterpack/vite 0.2.2 (motor 0.2.3), @dimforge/rapier2d-deterministic-compat 0.21.0 de Rapier y Node.js 24.21.0. El recibo marcó los chunks de Rapier y de i18n como intactos y el chunk del juego como ofuscado, y dos builds del mismo commit salieron idénticos byte a byte.

1// vite.config.ts (Vite 8)
2import { afterpackVite } from "@afterpack/vite";
3import { defineConfig } from "vite";
4
5export default defineConfig({
6 plugins: [
7 afterpackVite({
8 // One program per commit; the same commit and input give the same bytes.
9 seed: "git",
10 // Output files to leave as they are.
11 paths: { exclude: ["**/rapier-*.js", "**/i18n-*.js"] },
12 }),
13 ],
14 build: {
15 rolldownOptions: {
16 output: {
17 codeSplitting: {
18 groups: [
19 // Give each excluded part its own chunk, or Vite merges it into one of yours.
20 { name: "rapier", test: /[\\/]node_modules[\\/]@dimforge[\\/]/ },
21 { name: "i18n", test: /[\\/]locales[\\/]/ },
22 ],
23 },
24 },
25 },
26 },
27});

Los globs de paths.exclude se comparan con los nombres de los archivos de salida, así que cómo se reparten los chunks importa tanto como los propios globs. Si lo dejas a su aire, Vite puede meter una librería en un chunk que también contiene tu código, y ese chunk se ofusca entero. Vite 8 divide los chunks con codeSplitting de Rolldown; en Vite 7 y anteriores, la misma división se hace con manualChunks de Rollup.

Estos son los chunks que BIGBOARD.GAMES deja fuera, 21 de sus 140 archivos JavaScript, con los motivos tal como se midieron con el motor 0.2.1:

ChunkQué esPor qué queda fuera
rapier-*.jsLa física de Rapier, build deterministaDe terceros: 3,4 MB de código de enlace con WebAssembly sin nada nuestro dentro
analytics-posthog-*.js, openapi-fetch-*.js, qr-*.jsLa analítica de PostHog, el cliente REST openapi-fetch, el codificador de QRDe terceros. Ofuscado, el codificador de QR por sí solo triplicaba su tamaño, hasta unos 25 KB con gzip
i18n-*.jsLos textos en inglés, ucraniano y españolSon strings sin más: al ofuscarlos crecían unas 10 veces, y ninguno es secreto
brand-marks-*.js, brand-glyphs-*.jsContornos del logo y marcado SVGLos datos de los trazados se comportan como strings: ofuscados, multiplicaban por diez el chunk de entrada

paths.exclude es una funcionalidad de Free. Saltarse una sola función dentro de un archivo, en lugar del archivo entero, requiere una directiva de Pro; en un build Free, una directiva es un error.

¿Cuánto aumenta la ofuscación el tamaño de un juego?

Alrededor de 1,65x con gzip en la primera carga con el motor actual, y 2,2x con el que se lanzó BIGBOARD. /play, la página que abren los jugadores, carga 20 chunks:

Servido, con gzipSin ofuscarMotor 0.2.1Motor 0.2.3
Primera carga de /play107,9 KB239,3 KB (2,22x)178,5 KB (1,65x)
Chunk de Hockey de mesa20,5 KB36,8 KB (1,80x)29,5 KB (1,44x)
Chunk de Desborde34,0 KB59,6 KB (1,75x)47,7 KB (1,40x)

Medido el 2026-10-08 con light; las columnas ofuscadas son la mediana de 7 builds. La actualización no costó más que subir la versión: la misma configuración, las mismas exclusiones, y el gate de verificación, afterpack verify y la prueba end-to-end multidispositivo pasaron con ella. Ese es el coste honesto de la ofuscación: añade bytes, y un juego que cuenta los kilobytes tiene que reservarles presupuesto. El tiempo de build es la parte más barata: el build completo, con la app, la landing y el panel de administración, tarda 1,5 s sin ofuscar y 3,6 s ofuscado en el M2 Max, con cualquiera de los dos motores. La página de rendimiento de AfterPack recoge las mediciones del propio motor.

Del presupuesto de tamaño sale una lección que vale la pena copiar. El build de BIGBOARD graba la hora del build en su chunk de entrada, así que cada build renombra la entrada y todos los chunks que la importan, y cualquier chunk que cambie sale de AfterPack completamente reordenado. La primera carga variaba entre 176 y 182 KB entre builds del mismo commit, y un presupuesto sobre los bytes servidos fallaba al azar. Por eso BIGBOARD fija el presupuesto sobre los tamaños medidos antes de ofuscar, con un techo holgado de 256 KB para lo que se sirve. Tomar la marca de tiempo del commit (git log -1 --format=%cI) arreglaría la causa.

¿Por qué publicar un programa distinto en cada release?

Para que un programa de trampas, un parche o un hook escritos contra un build dejen de encajar con el siguiente. Con seed: "git", cada commit produce una salida estructuralmente distinta, y la misma semilla con la misma entrada da los mismos bytes. Con la marca de tiempo del build fijada, dos builds de BIGBOARD del mismo commit coincidieron en los 140 archivos. Así, un pipeline que vuelve a compilar para producción puede publicar exactamente los bytes que se probaron en staging.

El modelo de amenazas de AfterPack describe el efecto con el preset hard: un autor de trampas «tiene que rehacer un análisis real en cada release en vez de parchear un offset conocido una sola vez». Con light, los nombres, los offsets y los decodificadores de strings también cambian con cada semilla; solo que cada ronda de análisis sale más barata. En cualquier caso, un juego que publica a menudo obliga al autor de trampas a pagar de nuevo en cada release. Un script que necesite una forma nueva más a menudo de lo que tú despliegas puede recibir una en cada petición desde un Cloudflare Worker.

La salida distinta en cada build tiene una pega que conviene conocer. AfterPack se ejecuta después de que Vite haya puesto nombre a los chunks, así que un chunk puede cambiar sus bytes sin cambiar su nombre de archivo. Una caché de service worker compartida entre builds podría entonces servir un chunk viejo junto a uno nuevo. El service worker de BIGBOARD nombra su caché según el build, y solo borra una caché antigua cuando ninguna página abierta ejecuta ya ese build.

¿Cómo comprobar que cada archivo publicado está ofuscado?

Ejecuta npx afterpack verify dist como último paso antes del despliegue. Vuelve a calcular el hash de cada archivo que nombra el recibo de protección del build, y falla si un archivo cambió, si falta el recibo o si es de otro build.

BIGBOARD lo ejecuta en cada pull request y otra vez dentro de su script de despliegue, justo después del build, como recomienda la guía de CI. El recibo también registra lo que se dejó fuera a propósito:

1{
2 "tool": "afterpack-vite",
3 "engine": "local",
4 "engineVersion": "0.2.3",
5 "seedOrigin": "git",
6 "files": [
7 { "path": "assets/field-hockey-CK8_TNd4.js", "sha256": "0e5bf72c…", "transformed": true },
8 { "path": "assets/rapier-B0bbuRDd.js", "sha256": "0f45e3d1…", "transformed": false }
9 ]
10}

¿Qué no frena la ofuscación en un juego multijugador?

No frena a un tramposo decidido. Hace más lento leer y parchear el cliente, y obliga a empezar ese trabajo de cero en cada release; no decide quién marcó. En un juego peer-to-peer no hay servidor que compruebe nada, así que un cliente modificado por alguien con tiempo suficiente puede seguir mintiendo sobre dónde está el disco.

En BIGBOARD.GAMES hay poco en juego, y el antitrampas es social: el otro jugador está sentado al otro lado de la mesa, mirando el mismo disco. Un juego con rankings o dinero de por medio necesita, detrás del cliente, comprobaciones con autoridad del servidor de impactos, movimientos y resultados, como también dice el modelo de amenazas de AfterPack. La ofuscación encarece leer el código que esas comprobaciones no pueden cubrir, y nada protege un secreto enviado al navegador: una clave de API en el bundle tiene que estar en el servidor.

Pruébalo en tu juego

Añade el plugin a tu build, o ejecuta npx afterpack@latest dist/ sobre la salida de cualquier bundler (inicio rápido), y abre el resultado junto al original en DevTools. El escáner de seguridad gratuito muestra lo que expone hoy tu juego en producción. BIGBOARD.GAMES es gratis, y donde mejor se juega es con dos tablets y un amigo.

Preguntas frecuentes

¿Puedo ofuscar un juego hecho con Phaser, PixiJS o Three.js?

Sí. AfterPack trabaja sobre el JavaScript ya compilado, sea cual sea el motor o el framework que lo generó, Phaser, PixiJS y Three.js incluidos. Trata la librería del motor como BIGBOARD trata a Rapier: ponla en su propio chunk, exclúyela y ofusca el código de tu juego.

¿AfterPack ofusca WebAssembly?

Todavía no. Hoy AfterPack transforma JavaScript, y nuestra intención es llevarlo también a WebAssembly. La física de BIGBOARD se ejecuta en el WebAssembly de Rapier, que es de terceros y se publica tal cual; la lógica del juego que lo rodea es JavaScript, y eso es lo que se ofusca.

¿La ofuscación funciona con una PWA y un service worker?

Sí. BIGBOARD.GAMES se instala como aplicación web progresiva y se abre sin conexión. Nombra la caché del service worker según el build, porque los chunks ofuscados pueden cambiar sus bytes sin cambiar su nombre de archivo.

¿AfterPack es gratis para desarrolladores de juegos?

El motor Free se ejecuta en local sin cuenta y sin límite, con todos los presets, y BIGBOARD.GAMES se compila con él. Pro añade directivas por región, como un preset más fuerte solo para tu netcode, y dos transformaciones de endurecimiento, desde 49 $ al mes o con recargas puntuales (precios).

¿Qué preset usar en un juego?

Empieza con light para todo el bundle, el valor por defecto, que es lo que publica BIGBOARD.GAMES. Todavía no he medido medium dentro de un bucle de frames. Inclínalo (Tilt Run), el próximo juego, lee el giroscopio en cada frame en hasta cuatro dispositivos a la vez, así que será la prueba.

Mantente al día

Sigue a AfterPack para notas de versión y artículos.