Una interfaz con una red neuronal implementada
Ayer tomándome un café se me ocurrió entrenar a unos archivos .png a recolectar graficamente otros archivos .png, utilizando PPO.
Durante estos últimos días me he encontrado trabajando en el frontend de Artisanal Brew, una aplicación que corre sobre el framework .NET que simula una eShop de artículos relacionados al café, esto como parte de la búsqueda de una nueva oportunidad laboral. Para destacarme, 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. Así se veía el diseño anterior:
No es que me pareciera mal — el único problema real era que, al ser un video, a veces tardaba en cargar. 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
La política es una red multicapa 13 → 16 → 2 con tanh en ambas capas: 258 parámetros. Trece números entran (a dónde está mi moneda, qué tan lejos, a qué velocidad voy, qué tan cerca están las cuatro paredes, dónde está la taza más próxima y cuánta cafeína me queda) y salen dos: cuánto acelero en x y cuánto en y. Nada más. Es tan pequeña que evaluarla cuatro veces por frame es ruido, y que los pesos entrenados viajan como ~7 KB de números literales dentro de un módulo ES, no como un archivo de modelo.
Qué significa “entrenar” aquí
Esta es la parte que más me costó explicarle a la gente, porque el vocabulario de ML se usa muy suelto. Aquí no hay dataset, no hay etiquetas y no hay backpropagation. Hay un mundo que se corre completo, se mide cuántas monedas juntó el crew, y ese único número decide si los pesos que lo produjeron valían la pena. Las unidades son estas:
| Unidad | Cuánto es | Qué pasa dentro |
|---|---|---|
| Paso | 1/60 de segundo | Cada robot observa, la red decide y la física avanza un frame |
| Episodio | 1,800 pasos | Treinta segundos simulados con un layout de monedas nuevo |
| Evaluación | 3 episodios | Se promedian, para que un layout afortunado no decida nada |
| Generación | 64 evaluaciones | 32 pares de perturbaciones ± sobre los mismos pesos, y una actualización |
| Entrenamiento | 300 generaciones | ~57,600 episodios: veinte días de tiempo simulado en 275 segundos de reloj |
Por qué Evolution Strategies y no PPO
PPO es la respuesta refleja y habría sido la equivocada: pide autograd, una red de valor, cálculo de ventajas y unas 300 líneas que pueden estar sutilmente mal, todo para resolver un problema de dirección de 258 parámetros. La estrategia evolutiva hace lo mismo tratando al mundo como una caja negra: se perturban los pesos, se corren episodios y se conserva la dirección que produjo mejores retornos. No hay gradientes, no hay tensores y no queda ninguna dependencia de machine learning en producción.
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);Dos detalles de esas diez líneas son los que hacen que esto no se desarme. El primero es el muestreo espejeado: cada perturbación se evalúa en las dos direcciones, θ + σε y θ − σε, y el par cancela gratis buena parte de la varianza del estimador. El segundo es la normalización por rango: antes de actualizar, los retornos se reemplazan por su posición en el orden, dentro de [-0.5, 0.5]. Así un episodio con suerte y un retorno enorme no puede dominar el paso — sólo importa quién quedó arriba de quién.
| Hiperparámetro | Valor | Motivo |
|---|---|---|
| Población | 64 | 32 pares de perturbaciones positivas y negativas |
| Sigma | 0.1 | Escala del ruido aplicado a los pesos |
| Learning rate | 0.03 | Paso de actualización de la política |
| Evaluación | 3 episodios | Promedio para reducir la suerte del layout |
| Generaciones | 300 | Con checkpoint temprano en la generación 20 |
Los layouts se resiembran en cada generación, así que la política no puede memorizar un acomodo de monedas: tiene que generalizar. Pero el detalle más importante no es el algoritmo, es que trainer y navegador importan el mismo pixelCrewSim.js. Física, observaciones, recompensa y forward pass viven una sola vez. Una segunda copia del entorno —aunque esté escrita con cuidado desde las mismas notas— se desvía en silencio: los pesos siguen cargando, los robots siguen moviéndose, y el comportamiento simplemente es peor de lo que prometían los números, sin nada a qué apuntarle.
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
Trayectorias y Comportamientos por Checkpoint
Deriva sin control, se atasca contra las paredes y recoge objetos únicamente por accidente.
Apunta bien y acelera con fuerza hacia el objetivo, pero sobrepasa el punto objetivo y debe regresar.
Acelera pronto, aprovecha la inercia para deslizarse limpiamente y evalúa la conveniencia de las tazas de café.
Los tres checkpoints se envían, no sólo el bueno. El switcher CREW BRAIN del hero cambia cuál está manejando sin reconstruir el mundo: los robots y las monedas se quedan exactamente donde estaban, así que lo único que cambia es el comportamiento. Ese es el punto de enviar el checkpoint sin entrenar — la diferencia sólo es legible si nada más se mueve.
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.
| Plano | Contenido | Responsabilidad |
|---|---|---|
| z-index 0 | GlobalScene / gs-scene | Cielo, estrellas, Andrómeda, planetas, badges, tazas y monedas |
| z-index 1 | GlobalScene / gs-crew | Los cuatro robots y sus efectos visuales |
| z-index 2 | PixelHome / main | Título, texto y botones siempre legibles |
<div class="gs-scene gs-scene--bg-@_bgVariant"
id="ph-scene-root"
data-permanent
aria-hidden="true">
<img class="ph-sky__andromeda"
src="images/pl-andromeda.png" alt="" />
<div class="gs-layer gs-layer--home ph-scene">
<!-- tazas y monedas -->
</div>
</div>
<div class="gs-crew"
id="ph-scene-root-over"
data-permanent
aria-hidden="true">
<!-- scout, buyer, courier e inspector -->
</div>El atributo data-permanent hace que Blazor conserve esos nodos 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í.
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.

.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);
}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.
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.
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (!firstRender) return;
_crew = await JSRuntime.InvokeAsync<IJSObjectReference>(
"import", "./js/pixelCrewRuntime.js");
_simRunning = await _crew.InvokeAsync<bool>(
"init", "ph-scene-root", "trained");
}
public async ValueTask DisposeAsync()
{
await _crew!.InvokeVoidAsync("destroy", "ph-scene-root");
await _crew.DisposeAsync();
}La destrucción explícita evita dos loops de requestAnimationFrame después de navegar. Si JavaScript no carga, quedan los keyframes de fallback. Si el usuario prefiere movimiento reducido, el runtime ni siquiera arranca y CSS congela la composición, oculta la llama y elimina las transiciones.
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.
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.
El futuro
Quiero cerrar con la parte incómoda, porque si no la digo yo la va a pensar cualquiera que lea esto: accel = normalize(coin - pos) son cinco líneas de dirección y se ven prácticamente igual en pantalla. Ningún visitante puede distinguir una política entrenada de un steering escrito a mano nomás viendo el hero. La red no está ahí porque el movimiento la necesitara.
Está ahí porque el artefacto era el punto, y porque un artefacto así sólo vale algo si el aprendizaje se puede ver. Por eso se envían los tres checkpoints detrás del switcher en lugar de sólo el bueno: la generación 0 estrellándose contra una pared está haciendo más trabajo que la 300 barriendo el campo.
De lo que sigue, lo que más ganas tengo de construir es lo que hoy está a medias. El ERC-8004 abre la puerta a que un agente tenga identidad y reputación verificables, y eso cambia la pregunta del proyecto: ya no es “¿puede un robot pagar por mí?” sino “¿por qué debería confiarle esta compra a este agente y no a otro”. Con el ERC-7683 encima, el checkout deja de elegir una chain y empieza a declarar un resultado. Son estándares jóvenes, se están moviendo mientras escribo esto, y me parece el mejor momento para estar equivocándome en público con ellos.
Todo esto empezó como una eShop de café para destacar en una búsqueda de trabajo, y terminó siendo el proyecto donde más he aprendido en mucho tiempo: sobre Blazor, sobre pagos agénticos, sobre cuánto cuesta que una animación sea honesta. Si llegaste hasta aquí y estás construyendo algo en esa intersección —o quieres a alguien que se tome esto con este nivel de detalle— la puerta está abierta. Y si no, ojalá al menos te quedes con la idea de entrenar unos .png para que recojan otros .png. Se lo debo a un café.










