Skip to main content
5 minutos de lectura

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ándarQué habilitaPor qué importa aquí
ERC-4337Cuentas inteligentes: wallets con lógica propia en vez de una clave simplePermite que un agente firme transacciones de forma automática sin aprobación humana en cada una
ERC-8004Registro de identidad y reputación para agentes en la blockchainPermite 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 rutaEl checkout encuentra automáticamente la red con menor comisión o mejor liquidez
ERC-8183Depósito de garantía seguro (escrow): retiene los fondos hasta verificar la entregaAsegura 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.

pl-robot-scout.png (versión 1)
Versión 1 del robot scout, con cabeza de monitor CRT

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.

Concepto 3D (GPT 5.6 Sol)
Concepto 3D pixelado del robot, generado con GPT 5.6 Sol, usado como referencia para el rediseño

¿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:

Conversación con Claude
Conversación con Claude sobre cómo entrenar a los robots para recolectar monedas

El entrenamiento

HiperparámetroValorPara qué sirve
Población64Prueba 32 pares de variaciones opuestas
Sigma0.1Define cuánto cambian los pesos en cada prueba
Learning rate0.03Controla el tamaño de cada actualización
Evaluación3 episodiosPromedia resultados para reducir el factor suerte
Generaciones300Guarda 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.

Red neuronal de trece entradas, dieciséis neuronas y dos salidasLas trece observaciones del robot se conectan con una capa de dieciséis neuronas tanh. La red produce dos valores: aceleración horizontal y vertical.
Ver los 240 pesos entrenadosGeneración 300
Entrada → capa oculta208 pesos
  1. w001-0.34363
  2. w0020.12427
  3. w0030.17716
  4. w004-0.11511
  5. w0050.20866
  6. w0060.28381
  7. w007-0.04388
  8. w008-0.37095
  9. w009-0.22562
  10. w010-0.06634
  11. w0110.00079
  12. w0120.04024
  13. w013-0.08385
  14. w0140.45111
  15. w015-0.39193
  16. w0160.00223
  17. w0170.02903
  18. w0180.22770
  19. w0190.25482
  20. w020-0.19702
  21. w021-0.00663
  22. w0220.20344
  23. w0230.27935
  24. w0240.36372
  25. w0250.28182
  26. w0260.07945
  27. w027-0.10643
  28. w028-0.09121
  29. w029-0.20843
  30. w0300.01261
  31. w031-0.05974
  32. w032-0.29965
  33. w0330.20760
  34. w0340.18564
  35. w035-0.16519
  36. w036-0.00191
  37. w037-0.02618
  38. w038-0.22984
  39. w039-0.18386
  40. w040-0.59259
  41. w0410.13166
  42. w0420.65156
  43. w0430.24601
  44. w044-0.09902
  45. w045-0.10780
  46. w0460.21004
  47. w047-0.14724
  48. w0480.15766
  49. w0490.03987
  50. w0500.20154
  51. w051-0.34675
  52. w0520.09292
  53. w0530.48580
  54. w054-0.33263
  55. w0550.27199
  56. w056-0.28440
  57. w0570.11379
  58. w0580.09009
  59. w059-0.63781
  60. w060-0.03512
  61. w061-0.08104
  62. w0620.23852
  63. w063-0.13590
  64. w0640.11244
  65. w065-0.16232
  66. w066-0.21761
  67. w0670.25988
  68. w068-0.28877
  69. w0690.08090
  70. w0700.19998
  71. w0710.14599
  72. w0720.20820
  73. w073-0.00019
  74. w074-0.23121
  75. w0750.01884
  76. w0760.06580
  77. w0770.13400
  78. w078-0.01112
  79. w079-0.08607
  80. w0800.35028
  81. w081-0.06165
  82. w082-0.06076
  83. w083-0.33414
  84. w084-0.27047
  85. w0850.08036
  86. w0860.17994
  87. w087-0.11257
  88. w0880.09819
  89. w0890.24334
  90. w0900.25934
  91. w0910.19198
  92. w092-0.29125
  93. w0930.36299
  94. w094-0.04625
  95. w0950.09974
  96. w096-0.30832
  97. w097-0.03754
  98. w0980.18922
  99. w0990.00141
  100. w100-0.34535
  101. w101-0.10391
  102. w1020.18342
  103. w1030.00820
  104. w1040.02984
  105. w1050.15634
  106. w1060.32104
  107. w1070.13830
  108. w1080.17402
  109. w109-0.32680
  110. w110-0.34576
  111. w111-0.21899
  112. w1120.16848
  113. w113-0.08664
  114. w1140.28496
  115. w1150.09361
  116. w1160.09016
  117. w117-0.01124
  118. w1180.73382
  119. w1190.51342
  120. w1200.35035
  121. w121-0.22379
  122. w122-0.34121
  123. w123-0.08773
  124. w124-0.32811
  125. w125-0.00700
  126. w126-0.02542
  127. w1270.14382
  128. w128-0.04052
  129. w1290.24993
  130. w130-0.02811
  131. w131-0.40466
  132. w1320.12995
  133. w1330.04000
  134. w134-0.02513
  135. w135-0.00075
  136. w1360.09870
  137. w1370.04864
  138. w1380.09763
  139. w139-0.11719
  140. w140-0.08639
  141. w141-0.01265
  142. w1420.26865
  143. w143-0.02132
  144. w1440.43155
  145. w1450.47430
  146. w146-0.37561
  147. w147-0.33735
  148. w148-0.07882
  149. w149-0.27028
  150. w150-0.22051
  151. w1510.20179
  152. w152-0.02259
  153. w1530.65128
  154. w154-0.02798
  155. w1550.03352
  156. w156-0.05122
  157. w157-0.24476
  158. w158-0.98826
  159. w1590.15726
  160. w1600.14040
  161. w1610.25237
  162. w162-0.15079
  163. w1630.36849
  164. w164-0.25686
  165. w1650.07699
  166. w1660.00997
  167. w167-0.34416
  168. w168-0.13579
  169. w1690.12159
  170. w170-0.77060
  171. w1710.40363
  172. w1720.01637
  173. w1730.37731
  174. w174-0.18021
  175. w1750.02463
  176. w1760.29099
  177. w177-0.04895
  178. w178-0.17493
  179. w179-0.21884
  180. w1800.43405
  181. w1810.02249
  182. w1820.00126
  183. w183-0.36078
  184. w1840.23873
  185. w1850.04880
  186. w1860.17783
  187. w1870.06637
  188. w1880.10483
  189. w189-0.00976
  190. w1900.00428
  191. w1910.13081
  192. w192-0.09345
  193. w193-0.25127
  194. w1940.06072
  195. w195-0.10501
  196. w196-0.09362
  197. w197-0.35899
  198. w198-0.11254
  199. w1990.43683
  200. w2000.02253
  201. w201-0.12836
  202. w202-0.20470
  203. w2030.16189
  204. w204-0.25541
  205. w2050.10080
  206. w206-0.34116
  207. w2070.16417
  208. w208-0.16243
Capa oculta → salida32 pesos
  1. w2090.11276
  2. w2100.35634
  3. w2110.04607
  4. w212-0.32797
  5. w2130.22558
  6. w2140.05664
  7. w215-0.09341
  8. w216-0.12398
  9. w2170.28348
  10. w2180.52466
  11. w219-0.20907
  12. w2200.15624
  13. w221-0.14603
  14. w222-0.28987
  15. w223-0.25674
  16. w224-0.24285
  17. w225-0.00312
  18. w226-0.19850
  19. w2270.04562
  20. w2280.05406
  21. w229-0.18747
  22. w2300.15570
  23. w2310.16955
  24. w2320.04013
  25. w2330.06498
  26. w2340.19296
  27. w235-0.11466
  28. w2360.16265
  29. w237-0.72297
  30. w2380.33103
  31. w239-0.08316
  32. w2400.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:

Una generación de Evolution StrategiesLos pesos theta se combinan con treinta y dos vectores de ruido gaussiano, cada uno aplicado en las dos direcciones para formar sesenta y cuatro individuos. Cada individuo se evalúa en tres episodios; los sesenta y cuatro retornos se convierten en posiciones dentro del grupo, se combinan con sus ruidos para estimar un gradiente y el resultado se suma a theta. El ciclo se repite trescientas veces.theta258 pesosgaussianNoise()32 vectores εθ + σε32 individuosθ − σε32 individuosevaluate()3 episodios · promedioreturns[64]un retorno por individuorankNormalise()posición → [-0.5, 0.5]estimateGradient()Σ ε · rango ÷ (n · σ)
Una generación completa de train_pixel_crew.mjs — 32 ruidos, 64 individuos, una sola actualización de theta

Traducido a código, ese ciclo son ocho líneas:

train_pixel_crew.mjs — muestreo espejeado
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.

Retornos crudos convertidos en posiciones dentro de la generaciónOcho de los sesenta y cuatro individuos. A la izquierda, el retorno promedio de cada uno: siete quedan entre 22 y 32 puntos, y el último se hunde en 4.3. A la derecha, el peso que cada uno recibe tras rankNormalise: valores repartidos de forma pareja entre más 0.5 y menos 0.5, así que el peor individuo pierde un puesto y no dieciocho puntos.
8 de los 64 individuos — a la izquierda lo que puntuaron, a la derecha lo que pesan en la actualización

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:

RecompensaVentanaMonedas · tazas · captura
0.47–20 s28.5 · 5.3 de 10.8 · 48%
0.97–12 s27.6 · 6.3 de 10.6 · 59%
0.912–20 s26.5 · 5.9 de 9.5 · 62%
1.512–20 s26.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.

pixelCrewPolicy.js — los 258 pesos exportados como literales (~7 KB)
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
};
Suite de validación ciega en 100 semillas y 3 tasas de refresco (verify_pixel_crew.mjs)
node tools/verify_pixel_crew.mjs
untrained mean=1.82 min=0 max=4 zero_runs=14
early mean=25.24 min=18 max=31 zero_runs=0
trained mean=27.38 min=21 max=33 zero_runs=0
frame rates 20fps=27.12 60fps=27.38 120fps=27.44
✔ release gate passed (258 parameters, generation 300)

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.

GlobalScene — el background recompuesto con sus assets reales
GlobalScene.razor.css — profundidad sin un wallpaper
.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í.

Coreografía 0Ruta predefinida90 s

La ruta y el momento de desaparecer ya están escritos.

SimulaciónObserva y reaccionaen vivo
La diferencia visible: antes el reloj decidía el resultado; ahora la posición soltada cambia la siguiente decisión.

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.

Un mundo, dos pantallasLa política siempre observa coordenadas de 0 a 113 → 2
Desktop · 1440 × 900x: 0 → 1y: 0 → 1robot (0.24, 0.68)moneda (0.76, 0.28)
Móvil · 390 × 844x: 0 → 1y: 0 → 1
Cambian los píxeles de salida, no el estado que recibe la red: el robot y la moneda conservan exactamente las mismas coordenadas.
ParteTamañoQué representa
Observación13 valoresObjetivo, velocidad, paredes, taza cercana y cafeína restante
Acción2 valoresAceleración horizontal y vertical, limitadas con tanh
Episodio1,800 pasosTreinta segundos simulados a 60 Hz
DinámicaContinuaAceleración, drag, velocidad máxima y paredes absorbentes
pixelCrewSim.js — la interfaz del mundo
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.

Ciclo de una monedaLa colisión, no el reloj de una animación, inicia el ciclo5.5 s
01
Colisión realdistance < pickupRadius
02
+1
Pop visual−14 px · 360 ms
03
Nueva posiciónspawnPoint después de 5.5 s
El objeto cambia de estado: activa, recompensa, espera y reaparece en otra coordenada válida.
pixelCrewSim.js — recoger, esperar y reaparecer
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;
}
GlobalScene.razor.css — el pop visual de la moneda
.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.

pixelCrewSim.js — ciclo independiente de cada taza
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.

La taza tiene su propio relojPuede aparecer, expirar o ser atrapada sin depender del crew12–20 s
Dormida6–18 s
Disponible12–20 s
Catch900 ms
Boost6 s
normal · maxSpeed 0.12
× 1.4
cafeinado · maxSpeed 0.168
El empuje sigue siendo 0.6; sólo sube 40% el límite de velocidad. Los tenis hacen visible exactamente esa ventana de seis segundos.
GlobalScene.razor.css — de dormida a catchable
.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.

Sprite de los tenis sónicos apuntando a la derechaSprite espejeado de los tenis sónicos
Sanic Easter Egg
GlobalScene.razor.css — los tenis viven de .is-boosted
.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.

Del entrenamiento offline al hero desplegadoEl trainer corre fuera de línea y escribe los pesos como un módulo de JavaScript que entra al repositorio con un commit. Un push a main ejecuta el gate de CI, que evalúa la política en cien semillas no vistas y a tres tasas de refresco. Si pasa, se construye la imagen Docker del Web, se publica en Azure Container Registry con el SHA del commit y Azure Container Apps actualiza el servicio. El navegador recibe el mismo archivo verificado, como un asset estático más de la imagen.train_pixel_crew.mjsoffline · 300 generacionespixelCrewPolicy.js258 pesos · ~7 KB · commitCI gate100 seeds · 20/60/120 FPSdocker buildimagen del Web · OIDCContainer Registrytag = SHA del commitContainer Apps1–2 réplicas · migraciones EFNavegadorhero con la política verificada
El recorrido completo: el archivo que verifica CI es el mismo que la imagen sirve al navegador

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.

  1. Un push a main ejecuta restore, build, tests .NET y node tools/verify_pixel_crew.mjs.
  2. 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.
  3. GitHub Actions autentica con Azure mediante OIDC, sin credenciales estáticas, y construye la imagen Docker del Web en el runner.
  4. 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.
  5. 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.
  6. Si falla, el workflow restaura la imagen anterior. La etiqueta latest sólo se mueve después de que Web y Worker están sanos.
ci.yml — el Hero también tiene un release contract
- 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.

El contrato de salud que decide si latest avanzaTras actualizar Container Apps, el workflow espera hasta ciento cincuenta segundos por el endpoint de salud y por el contrato visual del hero en el HTML de la portada. Si la revisión está sana, la etiqueta latest se mueve al nuevo SHA; si falla, se restaura la imagen anterior.nueva revisiónContainer Appsespera ≤ 150 s/health/ready + HTML del heromueve latestWeb y Worker sanosrollbackimagen anterior
La decisión del workflow después de actualizar Container Apps — latest sólo avanza si el hero responde