Cómo entrené robots pixel art con una red neuronal en JavaScript
Una historia de assets pixeleados, evolution strategies, y mucho café.
Durante estos últimos días me he encontrado trabajando en el frontend de Artisanal Brew como parte del desarrollo de mi portafolio personal. Pensé en implementar algunas de las últimas propuestas que se han venido desarrollando en Ethereum, como lo son los ERC-4337, ERC-8004, ERC-7683 y ERC-8183.
| Estándar | Qué habilita | Por qué importa aquí |
|---|---|---|
| ERC-4337 | Cuentas inteligentes: wallets con lógica propia en vez de una clave simple | Permite que un agente firme transacciones de forma automática sin aprobación humana en cada una |
| ERC-8004 | Registro de identidad y reputación para agentes en la blockchain | Permite verificar qué agente ejecutó una compra o staking y consultar su historial |
| ERC-7683 | Órdenes entre redes (cross-chain): defines el resultado deseado, no la ruta | El checkout encuentra automáticamente la red con menor comisión o mejor liquidez |
| ERC-8183 | Depósito de garantía seguro (escrow): retiene los fondos hasta verificar la entrega | Asegura que el pago automático solo se libere cuando la compra o pedido se complete con éxito |
Por qué pagos agénticos, y por qué robots
Estos protocolos tienen algo en común: habilitan pagos agénticos, es decir, transacciones que la propia wallet firma sin que un humano intervenga en cada una, y que además patrocinan (sponsorship) los fees de gas. Eso me hizo preguntarme de qué otra forma podía representar ese tipo de interacción en el home actual.
Al inicio el proyecto era simplemente un storefront: comprabas artículos con Sepolia Tokens, recibías tu voucher por correo, y también podías hacer staking de tokens que viven en esa misma testnet. Pero conforme fui avanzando e implementando más funcionalidad, empecé a querer algo que se adaptara mejor a lo que el proyecto se estaba convirtiendo.
De cabeza de monitor a un crew con más carácter
La versión 1 del crew ya existía, pero no se parecía en nada a la de ahora: la cabeza era prácticamente un monitor CRT (pantalla verde fosforescente y cuerpo color crema), más cercano a una vieja computadora de escritorio con patas que a un personaje con personalidad. Funcionaba para probar las animaciones básicas en loop, pero se sentía plano y le faltaba esa vibra retro-futurista que buscaba para la experiencia del hero.
Para transformar esa versión inicial, le pedí un concepto 3D pixelado al modelo GPT 5.6 Sol. El resultado superó mis expectativas: una cabeza esférica pulida, proporciones metálicas mejor definidas y una expresión simpática. Usé este diseño como plano maestro de referencia para rediseñar desde cero todo el crew en 2D, logrando que los robots tuvieran identidad propia y lucieran vivos en la interfaz.
¿Y si los hiciera recogerlas?
Originalmente los cuatro robots se ubicaban en las cuatro esquinas de la pantalla y flotaban ahí, estáticos, para siempre. Había monedas repartidas por el fondo, y me pregunté: ¿y si hiciera que las recogieran? Lo primero que se me ocurrió fue programar cinco escenarios distintos, cada uno con las monedas repartidas de forma diferente, y rotar el escenario en cada refresh. No me convenció: seguía siendo una coreografía fija, no una decisión. Fue ahí cuando le pregunté a mi mejor amigo Claudio (mi alias para la familia de modelos de lenguaje de Anthropic) qué método de entrenamiento le convenía más a este proyecto:
El entrenamiento
| Hiperparámetro | Valor | Para qué sirve |
|---|---|---|
| Población | 64 | Prueba 32 pares de variaciones opuestas |
| Sigma | 0.1 | Define cuánto cambian los pesos en cada prueba |
| Learning rate | 0.03 | Controla el tamaño de cada actualización |
| Evaluación | 3 episodios | Promedia resultados para reducir el factor suerte |
| Generaciones | 300 | Guarda checkpoints desde la generación 20 |
El cerebro de cada robot es una red pequeña de 13 → 16 → 2 con tanh en la capa oculta y en la salida. Una entrada es simplemente un número que describe el estado del robot en ese frame; la red no recibe imágenes. Sus trece entradas resumen la posición y distancia de la moneda que persigue, su velocidad, la distancia a cada una de las cuatro paredes, la taza más cercana y la cafeína que todavía le queda. Esos valores llegan a dieciséis neuronas y cada conexión tiene un peso que el entrenamiento va ajustando. Al final salen sólo dos números: la aceleración horizontal y la vertical. En total son 258 parámetros, así que evaluarla cuatro veces por frame cuesta muy poco. Incluso los pesos entrenados caben en unos 7 KB y viajan como números dentro de un módulo ES, sin necesidad de cargar un archivo de modelo.
Ver los 240 pesos entrenadosGeneración 300
- w001
-0.34363 - w002
0.12427 - w003
0.17716 - w004
-0.11511 - w005
0.20866 - w006
0.28381 - w007
-0.04388 - w008
-0.37095 - w009
-0.22562 - w010
-0.06634 - w011
0.00079 - w012
0.04024 - w013
-0.08385 - w014
0.45111 - w015
-0.39193 - w016
0.00223 - w017
0.02903 - w018
0.22770 - w019
0.25482 - w020
-0.19702 - w021
-0.00663 - w022
0.20344 - w023
0.27935 - w024
0.36372 - w025
0.28182 - w026
0.07945 - w027
-0.10643 - w028
-0.09121 - w029
-0.20843 - w030
0.01261 - w031
-0.05974 - w032
-0.29965 - w033
0.20760 - w034
0.18564 - w035
-0.16519 - w036
-0.00191 - w037
-0.02618 - w038
-0.22984 - w039
-0.18386 - w040
-0.59259 - w041
0.13166 - w042
0.65156 - w043
0.24601 - w044
-0.09902 - w045
-0.10780 - w046
0.21004 - w047
-0.14724 - w048
0.15766 - w049
0.03987 - w050
0.20154 - w051
-0.34675 - w052
0.09292 - w053
0.48580 - w054
-0.33263 - w055
0.27199 - w056
-0.28440 - w057
0.11379 - w058
0.09009 - w059
-0.63781 - w060
-0.03512 - w061
-0.08104 - w062
0.23852 - w063
-0.13590 - w064
0.11244 - w065
-0.16232 - w066
-0.21761 - w067
0.25988 - w068
-0.28877 - w069
0.08090 - w070
0.19998 - w071
0.14599 - w072
0.20820 - w073
-0.00019 - w074
-0.23121 - w075
0.01884 - w076
0.06580 - w077
0.13400 - w078
-0.01112 - w079
-0.08607 - w080
0.35028 - w081
-0.06165 - w082
-0.06076 - w083
-0.33414 - w084
-0.27047 - w085
0.08036 - w086
0.17994 - w087
-0.11257 - w088
0.09819 - w089
0.24334 - w090
0.25934 - w091
0.19198 - w092
-0.29125 - w093
0.36299 - w094
-0.04625 - w095
0.09974 - w096
-0.30832 - w097
-0.03754 - w098
0.18922 - w099
0.00141 - w100
-0.34535 - w101
-0.10391 - w102
0.18342 - w103
0.00820 - w104
0.02984 - w105
0.15634 - w106
0.32104 - w107
0.13830 - w108
0.17402 - w109
-0.32680 - w110
-0.34576 - w111
-0.21899 - w112
0.16848 - w113
-0.08664 - w114
0.28496 - w115
0.09361 - w116
0.09016 - w117
-0.01124 - w118
0.73382 - w119
0.51342 - w120
0.35035 - w121
-0.22379 - w122
-0.34121 - w123
-0.08773 - w124
-0.32811 - w125
-0.00700 - w126
-0.02542 - w127
0.14382 - w128
-0.04052 - w129
0.24993 - w130
-0.02811 - w131
-0.40466 - w132
0.12995 - w133
0.04000 - w134
-0.02513 - w135
-0.00075 - w136
0.09870 - w137
0.04864 - w138
0.09763 - w139
-0.11719 - w140
-0.08639 - w141
-0.01265 - w142
0.26865 - w143
-0.02132 - w144
0.43155 - w145
0.47430 - w146
-0.37561 - w147
-0.33735 - w148
-0.07882 - w149
-0.27028 - w150
-0.22051 - w151
0.20179 - w152
-0.02259 - w153
0.65128 - w154
-0.02798 - w155
0.03352 - w156
-0.05122 - w157
-0.24476 - w158
-0.98826 - w159
0.15726 - w160
0.14040 - w161
0.25237 - w162
-0.15079 - w163
0.36849 - w164
-0.25686 - w165
0.07699 - w166
0.00997 - w167
-0.34416 - w168
-0.13579 - w169
0.12159 - w170
-0.77060 - w171
0.40363 - w172
0.01637 - w173
0.37731 - w174
-0.18021 - w175
0.02463 - w176
0.29099 - w177
-0.04895 - w178
-0.17493 - w179
-0.21884 - w180
0.43405 - w181
0.02249 - w182
0.00126 - w183
-0.36078 - w184
0.23873 - w185
0.04880 - w186
0.17783 - w187
0.06637 - w188
0.10483 - w189
-0.00976 - w190
0.00428 - w191
0.13081 - w192
-0.09345 - w193
-0.25127 - w194
0.06072 - w195
-0.10501 - w196
-0.09362 - w197
-0.35899 - w198
-0.11254 - w199
0.43683 - w200
0.02253 - w201
-0.12836 - w202
-0.20470 - w203
0.16189 - w204
-0.25541 - w205
0.10080 - w206
-0.34116 - w207
0.16417 - w208
-0.16243
- w209
0.11276 - w210
0.35634 - w211
0.04607 - w212
-0.32797 - w213
0.22558 - w214
0.05664 - w215
-0.09341 - w216
-0.12398 - w217
0.28348 - w218
0.52466 - w219
-0.20907 - w220
0.15624 - w221
-0.14603 - w222
-0.28987 - w223
-0.25674 - w224
-0.24285 - w225
-0.00312 - w226
-0.19850 - w227
0.04562 - w228
0.05406 - w229
-0.18747 - w230
0.15570 - w231
0.16955 - w232
0.04013 - w233
0.06498 - w234
0.19296 - w235
-0.11466 - w236
0.16265 - w237
-0.72297 - w238
0.33103 - w239
-0.08316 - w240
0.03866
Cómo implementé Evolution Strategies
Evolution Strategies cabe en una frase: en cada generación pruebo variaciones aleatorias de los pesos, puntúo cada una y muevo los pesos originales hacia las que salieron mejor. Lo que me gustó fue lo poco que hace falta para implementarlo.
El trainer es un script de Node sin dependencias de machine learning. Los 258 parámetros viven en un Float64Array llamado theta, el simulador es una función que recibe ese arreglo y devuelve un número, y el entrenamiento completo es un for de 300 generaciones alrededor de este ciclo:
Traducido a código, ese ciclo son ocho líneas:
for (let k = 0; k < population / 2; k++) {
const eps = gaussianNoise(parameterCount);
returns[k] = evaluate(theta + sigma * eps);
returns[k + population / 2] =
evaluate(theta - sigma * eps);
}
const shaped = rankNormalise(returns);
theta += learningRate * estimateGradient(shaped);Con una población de 64, el bucle sólo da 32 vueltas: cada una genera un vector de ruido gaussiano eps del mismo tamaño que theta y lo usa dos veces, en ambas direcciones. Eso es el muestreo espejeado, y en código me salió gratis, un solo gaussianNoise() por par, escalado por un sigma de 0.1, que es cuánto se le permite moverse a cada peso en la prueba.
Comparar cada versión contra su opuesta es lo que me deja distinguir si el cambio ayudó de verdad o si sólo tuvo suerte. Del otro lado, evaluate() es toda la interfaz con el simulador: le paso los pesos, corre tres episodios completos y me devuelve el promedio. El trainer no sabe nada de monedas, tazas ni colisiones.
Las dos últimas líneas son la actualización. rankNormalise tira las magnitudes y se queda sólo con el orden: reemplaza los 64 retornos por su posición dentro del grupo, repartida de forma pareja entre -0.5 y 0.5.
Sin ese paso, una sola partida afortunada se adueña de la actualización y arrastra los pesos hacia un ruido que no tenía nada de especial. Con él, lo único que importa es qué versiones quedaron arriba de las demás.
La actualización final
Después estimateGradient multiplica cada ruido por la puntuación ya normalizada, suma los 64 vectores y divide entre población por sigma. El resultado tiene la misma forma que theta, se escala por el learning rate de 0.03 y se suma. No hay gradientes reales ni tensores en ningún punto: son sumas y multiplicaciones sobre arreglos planos.
Dos detalles de implementación
- El tablero cambia en cada generación. Vuelvo a sortear la posición de los objetos, así que ningún individuo se evalúa sobre el mismo tablero que el anterior. La red no puede memorizar un acomodo específico de monedas y tiene que aprender una estrategia que funcione en escenarios distintos.
- Trainer y navegador importan el mismo pixelCrewSim.js. La física, las observaciones, las recompensas y el cálculo de la red están definidos en un solo lugar. Así evito que ambos entornos se comporten de manera ligeramente distinta, un error difícil de notar porque los pesos cargan y los robots se mueven, pero no tan bien como durante las pruebas.
La economía de las tazas
Las monedas siempre están en el campo; las tazas no. Esa asimetría es lo que convierte al café en una decisión y no en otro objeto que recoger: una taza sólo vale la pena si el desvío cuesta menos de lo que devuelve el boost, y puede expirar antes de que el robot llegue. La primera versión pagaba 0.4 por taza y las dejaba visibles 7–12 segundos, y el crew las ignoraba olímpicamente: 46% de captura.
Mi primera reacción fue que la recompensa era muy baja. Estaba a medias: el problema de fondo era geométrico. El crew cruza la pantalla completa en unos 10.6 segundos, así que una taza que aparecía del otro lado no se podía alcanzar dentro de una ventana de 7 segundos. La política tenía razón en ignorarla (la recompensa era inalcanzable, no estaba subvaluada). Corrí un barrido de 120 generaciones por configuración:
| Recompensa | Ventana | Monedas · tazas · captura |
|---|---|---|
| 0.4 | 7–20 s | 28.5 · 5.3 de 10.8 · 48% |
| 0.9 | 7–12 s | 27.6 · 6.3 de 10.6 · 59% |
| 0.9 | 12–20 s | 26.5 · 5.9 de 9.5 · 62% |
| 1.5 | 12–20 s | 26.7 · 6.4 de 9.9 · 65% |
Qué aprendió el crew
El entrenamiento completo tardó 275 segundos en un núcleo de CPU. La mayor parte de la competencia apareció entre las generaciones 20 y 30; el resto compró una mejora menor y ruidosa. Con la política reentrenada, la captura de tazas subió de 46% a 67% sin sacrificar de forma material la recolección de monedas.
export const SHAPE = [13, 16, 2];
export const GENERATIONS = 300;
export const WEIGHTS = {
untrained: [-0.0314, 0.0821, -0.0105, 0.0442, ...], // Baseline azaroso
early: [0.1284, -0.4501, 0.3892, -0.1042, ...], // Gen 20
trained: [0.4812, -0.8910, 0.7321, -0.3129, ...] # Gen 300 final
};La composición del Hero
La página separa la experiencia en tres planos. Esa decisión evita el problema habitual de los fondos animados: que una partícula o un personaje pase por encima del título y lo vuelva ilegible.
01 Fondo ambiental
GlobalScene conserva ambos planos con data-permanent durante la navegación mejorada. El cielo no se reinicia, la paleta aleatoria no cambia al entrar a staking y las animaciones no vuelven al fotograma cero. Lo único que cambia es la capa visible, seleccionada mediante data-scene-route en el elemento <html>.
El background y sus cinco cielos
La base visual no es una imagen gigante. Parte de un negro café #100D0B y combina dos gradientes radiales muy tenues. Al iniciar una visita se elige una de cinco variantes; cada una cambia los brillos, la formación inicial del crew y la distribución de las monedas ambientales.









.gs-scene {
--hero-glow-a: rgba(200, 155, 106, 0.11);
--hero-glow-b: rgba(143, 185, 155, 0.08);
background:
radial-gradient(ellipse 62% 52% at 18% 14%,
var(--hero-glow-a), transparent 70%),
radial-gradient(ellipse 55% 48% at 82% 72%,
var(--hero-glow-b), transparent 70%);
}
.ph-star {
width: 11px;
height: 3px;
background: var(--star-color);
}
.ph-star::after {
width: 3px;
height: 11px;
content: "";
}Las dieciséis estrellas son cruces hechas con CSS y un pseudo-elemento; parpadean con duraciones y retrasos diferentes. Andrómeda queda al fondo con baja opacidad y un desplazamiento de 74 segundos. Los planetas y los badges de Ethereum, Solana y BNB usan movimiento escalonado y image-rendering: pixelated para conservar bordes duros.
De coreografía a simulación
La primera versión movía cada robot con un keyframe de 90 segundos. Su moneda estaba colocada exactamente en el destino de esa animación y desaparecía cuando el reloj decía que el robot había llegado. Parecía una recolección, pero ningún objeto percibía al otro.
Ese truco se rompía al arrastrar un robot: había que mover también su moneda para que la coreografía siguiera coincidiendo. La simulación eliminó ese acoplamiento. Ahora soltar un robot es simplemente cambiar su posición; la política vuelve a observar y continúa desde ahí.
La ruta y el momento de desaparecer ya están escritos.
Un mundo normalizado para desktop y móvil
La física vive en un cuadrado normalizado de 0 a 1. El runtime transforma esas coordenadas a píxeles al renderizar. Por eso la misma política funciona en un teléfono y en un monitor ancho sin entrenar dos modelos.
| Parte | Tamaño | Qué representa |
|---|---|---|
| Observación | 13 valores | Objetivo, velocidad, paredes, taza cercana y cafeína restante |
| Acción | 2 valores | Aceleración horizontal y vertical, limitadas con tanh |
| Episodio | 1,800 pasos | Treinta segundos simulados a 60 Hz |
| Dinámica | Continua | Aceleración, drag, velocidad máxima y paredes absorbentes |
export const OBS_SIZE = 13;
export const ACT_SIZE = 2;
export const EPISODE_STEPS = 1800;
export const PARAMS = {
accel: 0.6,
drag: 0.7,
maxSpeed: 0.12,
respawnSeconds: 5.5,
bagBoost: 1.4,
bagBoostSeconds: 6,
bagReward: 0.9,
};Cada robot reclama su propia moneda. La asignación se mantiene hasta que esa moneda desaparece, evitando que cuatro agentes se amontonen sobre el mismo objetivo. Las tazas aparecen durante ventanas aleatorias de 12 a 20 segundos y dan un aumento de 40% a la velocidad máxima durante seis segundos. La política debe decidir si el desvío compensa.
Cómo aparecen las monedas
El mundo inicia con siete monedas activas en puntos aleatorios. spawnPoint rechaza cualquier coordenada dentro del rectángulo ocupado por el copy, así que una moneda puede cruzar detrás del título cuando la lleva un robot, pero nunca aparece estacionada sobre el texto.
Al tocarla, la moneda deja de estar viva, suma una unidad a la recompensa y recibe .is-collected. Su imagen sube 14 píxeles, se reduce y desaparece durante 360 ms. Permanece oculta 5.5 segundos; después reaparece en una nueva posición válida y el runtime retira la clase.
if (distance(robot, coin) < PARAMS.pickupRadius) {
coin.alive = false;
coin.respawnAt = world.time + 5.5;
world.collected++;
reward += 1;
}
if (!coin.alive && world.time >= coin.respawnAt) {
const point = spawnPoint(world);
coin.x = point.x;
coin.y = point.y;
coin.alive = true;
}.ph-coinbit.is-collected img {
animation: coin-pop 360ms ease-out 1 forwards;
}
@keyframes coin-pop {
from {
opacity: 1;
transform: translate3d(0, 0, 0) scale(1);
}
to {
opacity: 0;
transform: translate3d(0, -14px, 0) scale(0.3);
}
}Cómo funcionan las tazas de café
Las monedas son el trabajo; las tazas son la capa de dificultad. La diferencia es que las seis tazas no existen permanentemente. Cada slot arranca dormido con un tiempo de aparición distinto (escalonado a propósito, para que no aparezcan todas juntas en el primer segundo de la página). Cuando ese reloj vence, el simulador elige una posición nueva fuera del copy, activa la taza y le da una vida aleatoria de 12 a 20 segundos. Si nadie llega, expira y vuelve a dormir entre 6 y 18 segundos.
Nada de ese calendario está atado al crew, y ahí está justo la dificultad: un robot no puede planear alrededor de la taza, sólo puede reaccionar. Cuando alcanza una, gana un 40% de velocidad máxima durante seis segundos. El detalle que más me gustó al implementarlo es que el boost sube el techo, no el empuje: un robot cafeinado conserva la misma aceleración ingrávida y simplemente alcanza a planear más rápido. Así el café se lee como inercia y no como un tirón.
if (mug.alive && world.time >= mug.expiresAt) {
mug.alive = false;
mug.appearAt = world.time + randomBetween(6, 18);
}
if (!mug.alive && world.time >= mug.appearAt) {
const point = spawnPoint(world);
mug.x = point.x;
mug.y = point.y;
mug.alive = true;
mug.expiresAt = world.time + randomBetween(12, 20);
}El runtime traduce alive a la clase .is-live y CSS hace un fade de 220 ms. Si un robot la alcanza, añade .is-caught durante 900 ms, cambia al sprite de beber y activa .is-boosted durante seis segundos. Los zapatos sónicos muestran visualmente el aumento de 40% en la velocidad máxima.
.ph-scene--sim .ph-bag {
opacity: 0;
transition: opacity 220ms ease;
}
.ph-scene--sim .ph-bag.is-live {
opacity: 0.92;
}
.ph-scene--sim .ph-bag.is-caught {
opacity: 0;
}Los tenis de Sonic
Un boost invisible es un boost que nadie cree. Necesitaba que se notara que ese robot va más rápido porque se tomó un café, así que le puse tenis: unos sneakers pixelados que el robot usa exactamente mientras dura la cafeína. Y lo bonito es que no tienen temporizador propio. El runtime ya mantiene .is-boosted durante toda la ventana de seis segundos, así que los tenis se cuelgan directo de esa clase: no hay dos relojes que se puedan desincronizar, hay uno solo y CSS lo obedece.
Los dos detalles que no eran obvios hasta que lo vi roto en pantalla: el clip-path y el sprite espejeado. Sin el recorte, los pies originales del robot se asoman por debajo de los tenis y quedan dos siluetas encimadas; con él, el sprite reemplaza limpiamente las últimas dos filas de pierna. Y como las puntas de los tenis son largas y apuntan hacia adelante, un robot que viaja a la izquierda necesita la variante -flip o parece que camina de reversa. Mientras nadie tiene café, los tenis están en display: none y no cuestan nada.

.ph-scene__shoes {
display: none;
background: url("images/pl-sonic-shoes.png")
center bottom / contain no-repeat;
image-rendering: pixelated;
}
.ph-scene__roam.is-boosted .ph-scene__shoes {
animation: ph-sonic-shoes-dash 0.5s steps(2, end) infinite;
display: block;
}
/* Sin este recorte, los pies originales del robot
asoman por debajo y las dos siluetas chocan. */
.ph-scene__roam.is-boosted .ph-scene__actor {
clip-path: inset(0 0 12.5% 0);
}Inventario completo de assets
Este es el inventario del primer viewport y de la simulación del crew. La segunda pantalla de descubrimiento de producto es un componente independiente y no forma parte de esta lista.
Del modelo al navegador
El trainer escribe tres juegos de pesos (sin entrenar, temprano y final) en pixelCrewPolicy.js. El runtime importa ese archivo y la simulación compartida. En cada frame observa el mundo, ejecuta la red, integra la física y escribe posiciones con translate3d.
Deploy
Me faltaba la última pieza, y es la que más me interesaba resolver bien: qué pasa cuando todo esto se sube. Un hero con una red neuronal adentro tiene una forma nueva de romperse, los pesos pueden truncarse, alguien (yo) puede tocar la física y olvidar reentrenar, y nada de eso falla ruidosamente. Los robots se siguen moviendo; simplemente son peores de lo que dicen los números del post.
El modelo viaja dentro de la imagen
El hero no descarga un modelo de ningún lado. Cuando el entrenamiento termina, train_pixel_crew.mjs escribe los 258 pesos de cada checkpoint como literales en pixelCrewPolicy.js, y ese archivo entra al repositorio con un commit: el modelo entrenado es código fuente, se revisa en el pull request y se compara con diff como cualquier otro cambio. No hay modelo.bin, ni bucket de artefactos, ni servidor de inferencia. Después docker build lo copia dentro de la imagen del Web igual que copia los .css y los .png, y ahí está la propiedad que me importa: los bytes que el gate de CI verificó son exactamente los que el navegador termina descargando.
Así que lo que se ve al abrir la portada no es una grabación del entrenamiento ni una coreografía que lo imita. El navegador importa esos mismos pesos, el runtime ejecuta la red en cada frame sobre el mismo pixelCrewSim.js que usó el gate, y los robots que recogen monedas son los 258 números multiplicándose en vivo, en la máquina de quien visita. Si CI aprobó la política, la política aprobada es (literalmente, byte a byte) la que está moviendo al crew en pantalla.
Entrenamiento y deploy forman un solo contrato
La política no se entrena durante el deploy. Se entrena offline y sus pesos se versionan como código. A partir de ahí CI trata ese archivo como cualquier otro artefacto de producción: comprueba su forma, sus valores y su desempeño antes de construir la imagen.
- Un push a
mainejecuta restore, build, tests .NET ynode tools/verify_pixel_crew.mjs. - El gate evalúa 100 seeds no vistas, exige al menos 300 generaciones, valida los 258 pesos de cada checkpoint y prueba la política a 20, 60 y 120 FPS.
- GitHub Actions autentica con Azure mediante OIDC, sin credenciales estáticas, y construye la imagen Docker del Web en el runner.
- La imagen se publica en Azure Container Registry con el SHA del commit; después se aplican migraciones EF Core usando un secreto leído de Key Vault.
- Azure Container Apps actualiza el Web con una a dos réplicas y espera hasta 150 segundos por salud y por el contrato visual del Hero.
- Si falla, el workflow restaura la imagen anterior. La etiqueta
latestsólo se mueve después de que Web y Worker están sanos.
- name: Verify trained homepage policy
run: node tools/verify_pixel_crew.mjs
- name: Wait for Web app health
run: |
curl --fail "https://$WEB_FQDN/health/ready"
HOME_HTML=$(curl --fail --silent "https://$WEB_FQDN/")
grep -q 'class="ph-hero' <<< "$HOME_HTML"
grep -q 'id="ph-scene-root"' <<< "$HOME_HTML"Comprobar sólo /health/ready demostraría que ASP.NET arrancó, no que la portada correcta fue desplegada. El segundo check busca el Hero y el contenedor de la escena en el HTML real. Es una verificación pequeña, pero conecta el release con la experiencia que el usuario debe recibir.










