Dejé un juego de Space Invaders corriendo en AWS y lo que más me costó no fue el juego. Fue que un líder de ranking con el servidor confiando en el score que manda el cliente se llena de tramposos en cinco minutos. Así que terminé construyendo un backend que recibe eventos de kills, no puntajes, y un anti-cheat de 8 reglas que recalcula el score del lado del servidor. Y después descubrí que, para desplegarlo, ECS Express Mode me evitaba escribir treinta recursos de Terraform.
El problema
Un leaderboard global tiene un solo bug inevitable: el cliente manda { nickname, score: 999999 } y el servidor lo guarda. Funciona, hasta que alguien con devtools abiertas lo nota.
La solución ingenua es mandar el score cifrado del cliente. La mejor defensa es no confiar en el score del cliente en absoluto: que el cliente mande los eventos crudos y que el servidor sea el que suma. Eso es una decisión, no una feature. Me llevó a dos preguntas: ¿qué eventos importan y qué hace al score verificable?
Además estaba el despliegue. Un servicio de juego en ECS clásico son VPC, subnets, security groups, ALB, target groups, listeners, certificado ACM, auto-scaling, políticas de IAM. Todo eso lo podés escribir mal de veinte formas. Quería ver cuánto de eso realmente necesitaba escribir a mano.
La solución
Dos mitades simétricas.
En src/scores.mjs vive un módulo puro, sin I/O, que implementa el anti-cheat. El cliente no manda el puntaje final: manda un array de eventos de kill { type, level, t }. El servidor recalcula el score con una tabla oficial (small=30, medium=20, large=10, ufo=150) y valida ocho reglas en orden estricto. Si el score declarado no coincide con el recalculado, rechaza. No hay discusión posible.
En src/db.mjs vive el único módulo que toca AWS. Encapsula el repositorio DynamoDB, genera scoreId y playedAt del lado del servidor (jamás acepta los del cliente), y traduce los errores del SDK a tipos tipados que el HTTP convierte en 503.
Y para correr todo eso en la nube, ECS Express Mode. En infra/terraform/main.tf el stack entero son 5 recursos: un ECR, una tabla DynamoDB y 3 roles de IAM. El resto —ALB, certificado, target group, DNS— lo aprovisiona AWS.
Cómo funciona
El anti-cheat es una función pura
Toda la defensa cabe en una función sin I/O. El corazón del sistema entero es validateScoreSubmission en src/scores.mjs: va regla por regla en orden estricto y el primer fallo gana. La tengo completa acá porque es la pieza que define todo lo demás:
// src/scores.mjs
export function validateScoreSubmission(body) {
// 1. malformed_request — body no es un objeto plano.
if (!isPlainObject(body)) {
return { ok: false, reason: 'malformed_request' };
}
const { nickname, score, level, duration, events } = body;
if (
typeof nickname !== 'string' ||
typeof score !== 'number' ||
!Number.isFinite(score) ||
typeof level !== 'number' ||
!Number.isFinite(level) ||
typeof duration !== 'number' ||
!Number.isFinite(duration) ||
!Array.isArray(events)
) {
return { ok: false, reason: 'missing_fields' };
}
if (!NICKNAME_REGEX.test(nickname)) {
return { ok: false, reason: 'invalid_nickname' };
}
const scoreRecalculado = recalculateScore(events);
if (score !== scoreRecalculado) {
return {
ok: false,
reason: 'score_mismatch',
expected: scoreRecalculado,
};
}
return {
ok: true,
scoreRecalculado,
nickname,
level,
playedAt: new Date().toISOString(),
};
}
Fijate en el final: cuando el validador pasa, devuelve scoreRecalculado y playedAt como valores autoritativos. El handler de POST /api/scores persiste eso, nunca req.body.score. La regla score_mismatch cierra el círculo: compara el score declarado con el recalculado y, si no coinciden, rechaza con expected para que el cliente sepa cuánto le faltó.
Límites físicos contra el tramposo
Además del score_mismatch, el pipeline incluye reglas que el cliente no puede simular sin tronar. En medio de validateScoreSubmission corren límites que son físicos, no heurísticos: no podés jugar menos de 8 segundos (duration_too_short), matar más de 55 invasores en un nivel (too_many_kills_per_level), ni sostener más de 10 kills por segundo (kill_rate_too_high). El tramposo que acelera un cliente también revive contra la tabla oficial KILL_POINTS (small=30, medium=20, large=10, ufo=150), la única fuente de verdad para recalcucar.
El servidor norma todo
El handler POST /api/scores corre el schema de Fastify primero (shape básica), después esta función (reglas semánticas), y solo al final persiste. El schema mantiene additionalProperties: true a propósito: el cliente puede mandar un scoreId espurio, y el server lo descarta porque la autoridad sobre scoreId y score es siempre del servidor.
La observabilidad de todo eso cae en src/metrics.mjs, un módulo que implementa el formato Prometheus exposition v0.0.4 a mano, sin librerías. si_requests_total se incrementa en un hook global onRequest, así que cuenta hasta los 400 y 404; si_cheats_rejected_total es mi señal favorita. Cuando el worker de Prometheus scrapea, render() arma el payload:
// src/metrics.mjs
export function render() {
const lines = [];
for (const name of Object.keys(counters)) {
lines.push(`# HELP ${name} ${HELP[name]}`);
lines.push(`# TYPE ${name} counter`);
lines.push(`${name} ${counters[name]}`);
}
return lines.join('\n') + '\n';
}
Cómo lo usás: el costo real de ECS Express Mode
Localmente es un docker compose up --build: el docker-compose.yml levanta la app y una instancia de DynamoDB Local con volumen persistente. La app espera DYNAMODB_ENDPOINT para apuntar al Local en vez de al servicio gestionado, y db.ensureTableExists() crea la tabla con su GSI si no está, con backoff exponencial contra ECONNREFUSED porque DynamoDB Local tarda unos segundos en aceptar conexiones.
En AWS, el deploy es un push a main. El workflow .github/workflows/deploy.yml:
- resuelve el tag de imagen como el SHA corto del commit (inmutable, trazable),
- build multi-arquitectura
linux/amd64,linux/arm64(Graviton para producción, amd64 para validar), - push a ECR,
- y llama a
aws-actions/amazon-ecs-deploy-express-service@v1conhealth-check-path: /health.
La primera vez AWS aprovisiona el servicio Express (ALB + cert ACM + target groups), las siguientes hace canary sin downtime. El stack base lo crea Terraform una única vez. Para rollback, Actions → deploy → Run workflow → image_tag: <sha-anterior>.
Qué aprendí
Que el anti-cheat server-side es más un problema de diseño de confianza que de criptografía. La regla que más vale no es una heurística sofisticada: es sencillamente no aceptar el score del cliente. Todo lo demás — las 8 reglas, los límites de kills por nivel, el score_mismatch con el expected — son capas sobre esa decisión base.
También aprendí que escribir el formato Prometheus a mano rinde más de lo que parece. Un módulo de 30 líneas sin dependencias es más enseñable y más portátil que la librería. Cuando el estado es apenas tres contadores, la abstracción sobra.
Y la lección que más me quedó grabada de ECS Express Mode: el error clásico al armarlo es confundir el infrastructure role (lo asume el servicio ECS, ecs.amazonaws.com) con el task role (lo asumen las tasks, ecs-tasks.amazonaws.com). Los nombres suenan iguales y la trust policy vive en infra/terraform/main.tf. Cuando el servicio se queda en DRAINING infinito, el 90% de las veces es eso o el task role sin acceso a DynamoDB.
Y otra pregunta
El anti-cheat asume que quien corre el juego pierde algo con que le rechacen un score. Pero si el sticker de "número uno del mundo" no tiene valor real, ¿no podés simplemente no esforzarte tanto y confiar en que el tramposo quede arriba solo? ¿Cuánta defensa anti-cheat vale la pena cuando el premio es solo la gloria?
De dónde salió esto
Generado con deepseek/deepseek-chat leyendo roxsross/devops-space-invaders.
Archivos efectivamente leídos (11):
-
src/metrics.mjs·1044b0940263 -
src/scores.mjs·092b18ccb9ac -
src/server.mjs·6e6064ab5cfe -
src/db.mjs·7d0328ea7146 -
package.json·82b5250dc138 -
Dockerfile·a3a09ebba7e2 -
docker-compose.yml·7067d8bf4065 -
infra/terraform/main.tf·d3263ece33de -
docs/CI-CD.md·93ebfe458ab9 -
README.md·3ade325ce66b -
.github/workflows/deploy.yml·4e7c7b48f4fb
Rechazos de la compuerta antes de pasar: 2.
Huella de la evidencia: d4a1bf8f278bec8f.
Consumo: 43 turnos · in 1,105,330 · out 14,324 · caché 98% · ~USD 0.0430.
Revisión automática: promedio 3.00, mínimo 2.
Aprobado por ROSS.
Top comments (0)