Skip to main content
17 min read

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á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. Así se veía el diseño anterior:

El hero anterior de ArtisanalBrew — el video de fondo, corriendo en loop

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.

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 sobre cómo entrenar a los robots para recolectar monedas
La pregunta que terminó reemplazando cinco escenarios cableados a mano por Evolution Strategies

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:

UnidadCuánto esQué pasa dentro
Paso1/60 de segundoCada robot observa, la red decide y la física avanza un frame
Episodio1,800 pasosTreinta segundos simulados con un layout de monedas nuevo
Evaluación3 episodiosSe promedian, para que un layout afortunado no decida nada
Generación64 evaluaciones32 pares de perturbaciones ± sobre los mismos pesos, y una actualización
Entrenamiento300 generaciones~57,600 episodios: veinte días de tiempo simulado en 275 segundos de reloj
Salida real del script de entrenamiento (tools/train_pixel_crew.mjs)
node tools/train_pixel_crew.mjs --gens 300 --pop 64
training 258 parameters, shape 13→16→2, pop 64, 300 generations
gen 0 reward 0.12 coins 1.8 (untrained baseline)
gen 1 reward 0.85 coins 4.2
gen 10 reward 4.80 coins 14.5
gen 20 reward 8.95 coins 25.2 (checkpoint early guardado)
gen 50 reward 9.82 coins 26.1
gen 100 reward 10.12 coins 26.8
gen 300 reward 10.45 coins 27.0 (checkpoint trained final)
held-out coins per episode (evaluación ciega):
untrained 1.8 coins/ep
early 25.2 coins/ep
trained 27.0 coins/ep
trained in 275.4s
wrote src/ThisCafeteria.Web/wwwroot/js/pixelCrewPolicy.js
wrote scratch/pixel-crew-training.csv

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.

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);

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ámetroValorMotivo
Población6432 pares de perturbaciones positivas y negativas
Sigma0.1Escala del ruido aplicado a los pesos
Learning rate0.03Paso de actualización de la política
Evaluación3 episodiosPromedio para reducir la suerte del layout
Generaciones300Con 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:

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

Trayectorias y Comportamientos por Checkpoint

Generación 0Baseline Sin Entrenar
1.8 mon/ep
⚠ Deriva sin control

Deriva sin control, se atasca contra las paredes y recoge objetos únicamente por accidente.

Generación 20Aceleración Temprana
25.2 mon/ep
⚡ Sobrepasa objetivo

Apunta bien y acelera con fuerza hacia el objetivo, pero sobrepasa el punto objetivo y debe regresar.

Generación 300Política Optimizada
27.0 mon/ep
★ Deslizamiento inercial óptimo

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.

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)
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
};

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.

PlanoContenidoResponsabilidad
z-index 0GlobalScene / gs-sceneCielo, estrellas, Andrómeda, planetas, badges, tazas y monedas
z-index 1GlobalScene / gs-crewLos cuatro robots y sus efectos visuales
z-index 2PixelHome / mainTítulo, texto y botones siempre legibles
GlobalScene.razor — la escena debajo y el crew encima
<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.

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í.

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.

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.

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.

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.

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);
}

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.

PixelHome.razor — montaje y teardown del runtime
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.

  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 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é.