En febrero de 2026 me metí en una discusión en Reddit. Alguien afirmaba que los últimos modelos de programación escribían mejor código que cualquier desarrollador humano, y citaba los lanzamientos más recientes de OpenAI y Anthropic como prueba. No estuve de acuerdo. Los modelos son muy buenos con landing pages, backends CRUD y ese tipo de demo de la que hay mil copias en GitHub. Tenía dudas sobre qué pasa cuando la tarea requiere razonamiento espacial, aritmética de coma flotante cuidadosa y una opinión sobre qué se siente bien al jugar.
Así que elegí un proyecto que apenas existe en internet y lo construí yo mismo: un juego de carreras pseudo-3D al estilo de OutRun, corriendo en un ESP32-S3 con una pantalla ILI9341. De paso, les pedí a los modelos que construyeran lo mismo. Este artículo explica cómo funciona el juego, con más profundidad que el artículo más corto que publiqué en Medium, y termina con lo que los modelos hicieron mal. El código fuente completo está en github.com/davidmonterocrespo24/esp32s3-arcade-3d.

Qué hace el juego
Todo el conjunto son unas 2.500 líneas de C++ en el framework de Arduino, dibujando a través de la librería TFT_eSPI. Incluye:
- Una carretera hecha de segmentos con curvas, subidas y bajadas, generada aleatoriamente en cada arranque.
- Un coche 3D texturizado del jugador cargado desde una malla OBJ — 428 vértices, 312 triángulos, una textura de 128 por 128.
- Edificios a ambos lados de la carretera, un túnel largo con luces en el techo, y árboles, arbustos, rocas y farolas en los huecos.
- Seis coches de tráfico con su propia velocidad y carril.
- Un ciclo de día, atardecer y noche con niebla exponencial hacia el horizonte.
- Física con rampas de aceleración, fricción, gravedad en pendientes y deriva lateral en las curvas, además de colisiones con el tráfico, las paredes y los objetos de la cuneta.
- Un HUD con un velocímetro redondo, contador de vueltas y tiempos de vuelta actual y mejor.
El hardware es deliberadamente corriente:
| Componente | Detalles |
|---|---|
| SoC | ESP32-S3, 240 MHz de doble núcleo, con PSRAM |
| Pantalla | TFT ILI9341, 320 por 240 píxeles, RGB565 de 16 bits, por SPI |
| Pines de pantalla | SCK 12, MOSI 11, MISO 13, CS 10, DC 9, RST 8 |
| Retroiluminación | GPIO 39 |
| Botones | GPIO 17 (izquierda) y GPIO 16 (derecha), con pull-ups internas |

Por qué no hay Z-buffer
La primera respuesta que me dieron tanto ChatGPT como Claude fue que el proyecto no era viable en este hardware, porque un renderizador 3D necesita un búfer de profundidad y el microcontrolador no tiene espacio para uno. Merece la pena hacer las cuentas, porque muestran cuál es la restricción real.
Un fotograma de 320 por 240 en RGB565 son 153.600 bytes. Un búfer de profundidad de 16 bits del mismo tamaño son otros 153.600 bytes. El ESP32-S3 tiene 512 KB de SRAM interna, de la cual una buena parte la ocupan el core de Arduino, el driver de pantalla y el heap, así que dos búferes a pantalla completa no caben cómodamente en memoria interna. Con PSRAM sí caben. El argumento de la memoria es débil.
El argumento más fuerte es el coste por píxel. Un búfer de profundidad implica una lectura, una comparación y una escritura condicional por cada píxel de cada triángulo, en software, en un núcleo que además tiene que rellenar 76.800 píxeles por fotograma y enviarlos por SPI. A 240 MHz ese presupuesto es escaso.
Los juegos de carreras arcade de los años 80 no tenían ni la memoria ni la tasa de relleno, y lo resolvieron con ordenación en lugar de con test de profundidad: dibujar primero lo lejano y dejar que lo cercano lo pinte por encima. Ese es el algoritmo del pintor, y funciona porque una carretera es una escena muy bien comportada. Los segmentos ya están ordenados por distancia, y todo lo demás (árboles, edificios, tráfico) está asociado a un segmento. El único objeto que necesita 3D real es el coche del jugador, y está a una distancia fija delante de la cámara, así que se puede ordenar por su cuenta.
La carretera: segmentos y proyección
La carretera es un array de 200 segmentos, cada uno de 200 unidades de mundo de longitud. Un segmento almacena su curvatura, su elevación, un sprite opcional de cuneta, un flag de túnel y la altura y el color de un edificio a cada lado:
struct Segment {
float curve; // Segment curvature
float y; // Height (elevation)
int8_t spriteType; // Sprite type (-1 = none)
float spriteOffset; // Lateral sprite offset
bool tunnel; // true = inside tunnel
int buildL, buildR; // Left/Right building height (0 = no building)
uint16_t colorL, colorR; // Building facade color
};
La escala del mundo viene del ancho de la carretera: ROAD_W son 2000
unidades para la mitad de la carretera, que tomo como unos 10,5 m, así que
una unidad son unos 5,25 mm. Todas las demás constantes de config.h
están expresadas en esas unidades.
La técnica central sigue los
artículos del racer en JavaScript de Jake Gordon,
que son la explicación más clara de carreteras pseudo-3D que conozco. La
cámara está CAM_HEIGHT unidades por encima de la carretera. El campo de
visión define una profundidad de cámara:
float fovRad = FOV_DEG * PI / 180.0;
cameraDepth = 1.0 / tanf(fovRad / 2.0);
playerZdist = CAM_HEIGHT * cameraDepth;
Proyectar el borde de un segmento es entonces una división y dos
multiplicaciones. camZ1 es la distancia desde la cámara al borde cercano
del segmento n, y sc1 es la escala a esa distancia:
float sc1 = cameraDepth / camZ1;
float cyp1 = p1Y - camY; // height relative to the camera
float cxp1 = playerX * ROAD_W - curveX; // lateral offset incl. the curve
int16_t sy1 = SCR_CY - (int)(sc1 * cyp1 * SCR_CY);
int16_t sx1 = SCR_CX + (int)(sc1 * (-cxp1) * SCR_CX);
int16_t sw1 = (int)(sc1 * ROAD_W * SCR_CX);
Las curvas son el truco clásico: la curve de un segmento no es un
ángulo, es un cambio por segmento en el desplazamiento horizontal. A
medida que el bucle se aleja de la cámara acumula curveDX += seg.curve y
curveX += curveDX, así que el desplazamiento crece cuadráticamente con
la distancia y la carretera se curva suavemente. Las colinas son aún más
simples: cada segmento tiene su propia y, y la proyección usa la
diferencia de altura con la cámara, así que una carretera que sube trepa
por la pantalla y una bajada desaparece por debajo del horizonte.
El detalle interesante es que drawRoad() hace dos pasadas sobre los
segmentos, en direcciones opuestas.
- De delante hacia atrás, proyectando. El primer bucle va desde el
segmento más cercano hacia fuera, proyecta cada borde en
rCache[n]y registra enrClip[n]la fila de pantalla más baja que sigue siendo visible a esa distancia. A medida que avanza el bucle,maxysolo se mueve hacia arriba: un segmento detrás de la cresta de una colina se proyecta por debajo de la cresta y queda recortado. Así es como las colinas ocluyen lo que hay detrás sin ninguna comparación de profundidad. - De atrás hacia delante, dibujando. El segundo bucle va desde el
extremo lejano hacia la cámara y dibuja la hierba, las bandas
rugosas, la carretera y las líneas de carril de cada segmento como
bandas horizontales entre
rCache[n]yrCache[n-1], recortadas porrClip. Cerca de la cámara un segmento puede tener muchas filas de alto, así que se subdivide en hasta tres bandas con el ancho interpolado entre los dos bordes, lo que evita que las curvas parezcan trapecios apilados.
Los edificios y el túnel comparten el bucle
Una versión temprana dibujaba la carretera en un bucle y los edificios en otro. Se veía bien en terreno llano y se desmoronaba en las colinas: un edificio que debería estar oculto tras una cresta se pintaba por encima de ella, porque el segundo bucle no tenía ni idea de lo que ya había dibujado el primero.
La solución fue dibujar todo lo que pertenece a un segmento dentro del mismo bucle de atrás hacia delante. Para cada segmento, en orden: las paredes y el techo del túnel si el segmento está dentro del túnel, los edificios si está fuera, y luego la superficie de la carretera. Un edificio es un par de quads (cara frontal y cara lateral) construidos a partir de los bordes proyectados de la carretera del segmento y del anterior:
void drawQuad(int x1, int y1, int x2, int y2, int x3, int y3, int x4, int y4, uint16_t c) {
spr.fillTriangle(x1, y1, x2, y2, x3, y3, c);
spr.fillTriangle(x1, y1, x3, y3, x4, y4, c);
}
El túnel es la misma idea con los quads girados hacia dentro: un quad de techo a una altura fija sobre la carretera, dos quads de pared, una luz amarilla cada cuatro segmentos y un portal gris dibujado en el primer segmento. Como todo se emite en orden de pintor, la entrada de un túnel a mitad de una colina se ve correcta sin ningún caso especial.
Los sprites de cuneta y los coches de tráfico van en un tercer bucle
corto, después de que la carretera esté completa. Se dibujan como formas
planas escaladas por la escala de proyección del segmento y recortadas
contra rClip, así que un árbol detrás de una colina queda cortado en la
cresta exactamente igual que la carretera.

El coche del jugador es 3D de verdad
No quería un sprite para el jugador. El coche es una malla OBJ convertida fuera de línea en una cabecera C por un pequeño script de Python, así que no se parsea nada en tiempo de ejecución:
python assets/obj_to_header.py assets/Car2.obj > car2_mesh.h
python assets/png_to_rgb565.py assets/car2.png > car2_texture.h
La cabecera contiene 428 vértices con posición y coordenadas de textura, y
312 triángulos como lista de índices. La textura de 128 por 128 son 32 KB
de RGB565 en flash, leídos con pgm_read_word() para que nunca toquen la
RAM.
Cada fotograma la malla pasa por un pequeño pipeline fijo en
render_player.cpp:
- Transformar. Cada vértice se rota alrededor del eje vertical por un ángulo derivado de la posición lateral del coche (el coche gira visiblemente hacia la curva), y luego se inclina por la pendiente de la carretera, promediada sobre los siguientes seis segmentos y suavizada en el tiempo para que el coche se incline en las colinas sin temblores.
- Proyectar.
x = centerX + rx * fov / z, con una distancia de cámara fija y una distancia focal de 130 píxeles. Los vértices detrás de la cámara se marcan y sus triángulos se omiten. - Ordenar. Se calcula la profundidad media de cada triángulo y se ordenan los 312 triángulos de lejos a cerca. Un ordenamiento por inserción basta a este tamaño y es casi gratis cuando el orden apenas cambia entre fotogramas.
- Descartar e iluminar. Los triángulos que miran hacia otro lado se descartan usando el signo del producto vectorial 2D. La normal de la cara se calcula en espacio de objeto y se multiplica escalarmente por una luz fija desde arriba y ligeramente al frente, dando un brillo entre 0,35 y 1,0.
- Rasterizar. Cada triángulo se dibuja con un rasterizador por scanlines con mapeado de textura afín.
El rasterizador es la parte en la que pasé más tiempo. Ordena los tres vértices por Y de pantalla, recorre las filas entre la superior y la inferior, y para cada fila interpola la X izquierda y derecha y las coordenadas de textura a lo largo de los dos bordes activos. Luego avanza a lo largo de la fila, muestrea la textura, aplica la iluminación y escribe un píxel:
for (int x = x0; x <= x1; x++) {
float t = (x - xL) * dx;
float u = uL + t * (uR - uL);
float v = vL + t * (vR - vL);
int tx = (int)(u * (CAR2_TEX_W - 1));
int ty = (int)((1.0f - v) * (CAR2_TEX_H - 1)); // OBJ V runs bottom-up
tx = max(0, min(tx, CAR2_TEX_W - 1));
ty = max(0, min(ty, CAR2_TEX_H - 1));
uint16_t texel = pgm_read_word(&car2_texture[ty * CAR2_TEX_W + tx]);
if (light < 0.99f) {
uint8_t r = ((texel >> 11) & 0x1F) * light;
uint8_t g = ((texel >> 5) & 0x3F) * light;
uint8_t b = ( texel & 0x1F) * light;
texel = (r << 11) | (g << 5) | b;
}
spr.drawPixel(x, y, texel);
}
El mapeado afín interpola u y v linealmente en espacio de pantalla, lo
que no es correcto en perspectiva. En una pared grande produce el
bamboleo por el que se hicieron famosos los juegos de PlayStation. En un
coche que ocupa alrededor de un quinto de la pantalla, siempre a la misma
distancia, el error está por debajo de un píxel y la división por píxel
que necesitaría un mapeado correcto no merece la pena. El renderizador
también rechaza cualquier scanline de más de 160 píxeles de ancho y
cualquier triángulo con un vértice a más de 20 píxeles fuera de pantalla.
Ambas protecciones existen por bugs reales: un único vértice mal ordenado
o recortado se convierte en una raya que cruza todo el fotograma, y es
mucho más barato descartar el triángulo que recortarlo bien.
Bajo el coche hay una elipse oscura dibujada como una pila de líneas horizontales. Se desplaza con la posición lateral del coche y es lo más barato del proyecto con el mayor efecto sobre lo asentado que parece el coche.

Cielo, parallax y hora del día
El cielo no se dibuja cada fotograma. En el arranque, initBackground()
construye un sprite de 640 por 120 en PSRAM (el doble del ancho de la
pantalla, la mitad superior de la pantalla de alto) con un degradado
vertical, un sol y dos capas de un skyline procedural con ventanas
iluminadas. Cada fotograma el bucle principal lo desplaza una cantidad
proporcional a la curva y la velocidad actuales, y lo blitea dos veces
para que el wrap-around sea continuo:
int bgX = (int)skyOffset % (SCR_W * 2);
bgSpr.pushToSprite(&spr, -bgX, 0);
bgSpr.pushToSprite(&spr, (SCR_W * 2) - bgX, 0);
Este es el efecto que usa Horizon Chase: el horizonte se desliza lateralmente al girar, lo que vende la curva mucho más que la geometría de la carretera por sí sola.
La paleta vive en colors.cpp como tres conjuntos de colores RGB565 para
día, atardecer y noche: cielo, hierba clara y oscura, carretera clara y
oscura, bandas rugosas, marcas de carril y niebla. El juego cambia al
siguiente conjunto cada 180.000 unidades de mundo de recorrido, que a
velocidad máxima son unos catorce segundos. La niebla es exponencial en
distancia y se mezcla por segmento:
float expFog(float d, float density) {
return 1.0f - clampF(1.0f / expf(d * d * density), 0, 1);
}
lerpCol() mezcla dos valores RGB565 canal a canal, lo que basta para
fundir la carretera y la hierba con el color de la niebla sin convertir
nunca a 24 bits.
Física: lo que hace que se sienta como un coche
Los gráficos hacen que un juego de carreras llame la atención. La física
decide si alguien juega más de un minuto. Todo el ajuste vive en
config.h:
#define SPEED_MULTIPLIER 65.0f // maxSpeed = SEG_LEN * SPEED_MULTIPLIER (~246 km/h)
#define ACCEL_TARGET 0.9f // targetAccel = maxSpeed * ACCEL_TARGET
#define ACCEL_RAMP 180.0f // How fast acceleration ramps up (u/s^2)
#define FRICTION 0.996f // Friction per frame (braking ~3.3s from max)
#define GRAVITY_FACTOR 1600.0f // Effect of slopes on acceleration
#define CENTRIFUGAL 0.18f // Centrifugal force in curves
#define CURVE_FORCE 3.0f // Lateral force multiplier in curves
#define LATERAL_FRICTION 0.90f // Lateral velocity damping per frame
La actualización en physics.cpp hace cuatro cosas en orden. La pendiente
se lee de la diferencia de altura entre el segmento actual y el anterior y
se convierte en una aceleración, así que las subidas pierden velocidad y
las bajadas la ganan. La fricción se aplica como un decaimiento
multiplicativo. La aceleración, que sube con el tiempo en lugar de saltar
a su objetivo, se integra en la velocidad. Luego la curva empuja el coche
lateralmente:
float curveForce = segments[pSeg].curve * centrifugal * spPct;
velocityX += curveForce * dt * CURVE_FORCE; // lateral velocity builds up
velocityX *= LATERAL_FRICTION; // and decays every frame
playerX -= velocityX * dt;
La velocidad lateral es lo que da la deriva. Un coche que entra en una curva no se desliza inmediatamente; la fuerza se acumula, el deslizamiento crece, y cuando la curva termina el amortiguamiento devuelve el coche. El ángulo de la deriva se calcula entonces a partir de la relación entre la velocidad lateral y la longitudinal y se usa solo para mostrarlo.
Las colisiones se gestionan por distancia a lo largo de la pista más el solapamiento en el eje lateral. Golpear por detrás a un coche de tráfico más lento te baja al setenta por ciento de su velocidad; tocar una pared del túnel, un árbol o salirse por completo de la carretera resta más velocidad, y cualquiera de ellos por encima de un umbral dispara el estado de choque de dos segundos.
La compilación del repositorio funciona como demo: un piloto automático
lee la curva que viene y contravolantea hacia ella, y el acelerador está
siempre a fondo. Los dos botones se declaran y se configuran con pull-up
en setup(), y el emulador de PC mapea las teclas de flecha a ellos, así
que la dirección manual son unas pocas líneas en handleInput().
Ninguna de estas constantes salió de una fórmula. Esbocé la perspectiva en papel, construí las curvas de velocidad en una hoja de cálculo, y luego cambié un número cada vez hasta que el coche se sintió bien. Ese bucle solo funcionó porque cada iteración tardaba segundos, lo que me lleva al emulador.
Desarrollar en un PC con Raylib
Flashear un ESP32-S3 tarda lo suficiente como para que ajustar una
constante a ojo sea doloroso. Así que el mismo código fuente compila en
Windows contra Raylib, con las llamadas de
Arduino y TFT_eSPI reemplazadas por una fina capa de compatibilidad en la
carpeta emulator/:
car_game_wrapper.cppsimplemente hace#include "../car_game.ino", así que el sketch se convierte en una unidad de traducción C++ normal sin ediciones.Arduino.hyArduino.cppproporcionanmillis(),delay(),random(), unSerialque imprime a stdout, y undigitalRead()que devuelve el estado de las teclas de flecha para los dos pines de botón.TFT_eSPI.cppimplementaTFT_eSpritesobre una render texture de Raylib.fillRect,fillTriangle,drawPixely el resto se convierten en llamadas de dibujo de Raylib;pushSprite()blitea la textura a la ventana.
Un ejemplo del tipo de cosa que la capa de compatibilidad tiene que hacer bien:
void TFT_eSprite::fillTriangle(int32_t x0, int32_t y0, int32_t x1, int32_t y1,
int32_t x2, int32_t y2, uint16_t color) {
BeginTextureMode(sd->texture);
// Draw twice so both winding orders show; Raylib culls one of them
DrawTriangle({x0, y0}, {x1, y1}, {x2, y2}, r565(color));
DrawTriangle({x0, y0}, {x2, y2}, {x1, y1}, r565(color));
EndTextureMode();
}
A TFT_eSPI no le importa el orden de los vértices, a Raylib sí, y el código de la carretera nunca promete uno. Dibujar cada uno dos veces no cuesta nada en una GPU y hizo que el emulador fuera fiel píxel a píxel a la placa.
Con esto en su sitio, el bucle era editar, make, ejecutar, en unos
segundos. Las capturas de este artículo son del emulador; la animación de
arriba es la placa.
Memoria y presupuesto de fotograma en la placa
A dónde van los bytes:
| Búfer | Tamaño | Dónde |
|---|---|---|
| Sprite de fotograma, 320 por 240 por 16 bits | 153.600 B | PSRAM |
| Sprite de cielo y skyline, 640 por 120 por 16 bits | 153.600 B | PSRAM |
| Textura del coche, 128 por 128 por 16 bits | 32.768 B | Flash |
| Malla del coche, 428 vértices y 312 triángulos | unos 10 KB | Flash |
| Pista, 200 segmentos | unos 6 KB | SRAM interna |
| Cachés de proyección y arrays por fotograma | unos pocos KB | SRAM interna |
Los dos sprites se crean con spr.setAttribute(PSRAM_ENABLE, true) antes
de createSprite(). Esa única línea es la diferencia entre un juego que
funciona y uno que falla al reservar memoria. Los 8 MB de PSRAM del módulo
están casi sin usar; lo que importa es que los dos búferes a ancho
completo no compitan con el core de Arduino por la memoria interna.
El fotograma se compone enteramente en el sprite y se envía al panel una
vez por bucle con spr.pushSprite(0, 0). Eso es lo que elimina el
tearing, y también es el techo de la tasa de fotogramas: 153.600 bytes por
SPI a 40 MHz tardan unos 31 ms por sí solos, antes de dibujar nada. En la
placa el juego se asienta en torno a 30 fotogramas por segundo, que es
exactamente lo que predice esa aritmética. Subir el reloj de SPI en el
User_Setup.h de TFT_eSPI es el primer sitio donde mirar si quieres más.

En qué fallaron los modelos de IA
En paralelo a la versión escrita a mano, les pedí a los modelos de programación disponibles en febrero de 2026 que construyeran el mismo juego a partir de una descripción. Esto es lo que pasó, lo más justo que puedo contarlo:
- ChatGPT produjo código que no compilaba, y una vez arreglado a mano sus cálculos de perspectiva estaban muy desviados. Su consejo constante era añadir un Z-buffer.
- Claude produjo la mejor estructura de archivos y el código más legible, y luego rompió el pipeline de renderizado. No mantuvo el orden de atrás hacia delante entre carretera, túnel y edificios, e introdujo bugs sutiles en las matemáticas de coma flotante.
- Gemini 3 Pro fue el que más se acercó en las matemáticas de proyección, y luego no tenía ninguna opinión sobre el resto. Los colores se veían mal, la física se sentía flotante, y cuando su propia salida tenía artefactos de renderizado no era capaz de encontrarlos.
No creo que nada de esto sea sorprendente. Las tres cosas que el proyecto necesitaba más son las tres cosas de las que un modelo de lenguaje tiene menos:
- Ordenación en el espacio. Saber qué está delante de qué, en una colina, dentro de un túnel, es un hecho espacial, no textual.
- Cuidado numérico. El archivo OBJ tiene la Y hacia arriba y la pantalla tiene la Y hacia abajo, el eje V de la textura va de abajo hacia arriba, y el desplazamiento de la curva se acumula en coma flotante a lo largo de cuarenta segmentos cada fotograma. Cada uno de esos es un signo o un redondeo de distancia de una carretera que se desvía o un coche dibujado del revés.
- Gusto. Si 0,18 o 0,25 es la constante centrífuga correcta no se puede derivar. Hay que conducirlo.
Sí usé IA en este proyecto, y fue útil exactamente para las cosas en las que es buena: montar la estructura de módulos, escribir los conversores de OBJ y PNG, y refactorizar código de dibujo repetitivo una vez que el diseño estaba asentado. Cada constante, cada proyección y cada decisión de ordenación se tomaron a mano. El modelo fue una herramienta, no el arquitecto. Eso era verdad en febrero de 2026 y puede que no lo sea para siempre; si consigues que un modelo produzca una versión jugable de esto solo a partir de la descripción, de verdad que me gustaría verlo.
Constrúyelo tú mismo
El repositorio es github.com/davidmonterocrespo24/esp32s3-arcade-3d.
En la placa:
- Cablea un ILI9341 al ESP32-S3 en los pines de la tabla de hardware de arriba, y los dos pulsadores entre GPIO 17, GPIO 16 y tierra.
- Instala la librería TFT_eSPI y configura los mismos pines y el reloj de
SPI en su
User_Setup.h. - Abre
car_game.inoen el IDE de Arduino, selecciona ESP32S3 Dev Module, activa la PSRAM en las opciones de la placa, y sube el código.
En un PC (Windows, con MinGW y Raylib instalados):
cd emulator/
make
./car_game_emu.exe
El mismo cableado está descrito en el diagram.json del repositorio, y
tanto el ESP32-S3 DevKit como el ILI9341 existen en el catálogo de
componentes de Velxio si quieres montar el circuito sin soldador. Todavía
no he probado a ejecutar el juego completo dentro de la placa emulada; la
configuración de pines de TFT_eSPI y el sprite en PSRAM lo convierten en
una prueba más interesante que un blink sketch, y lo tengo en mi lista.
Si lo construyes, cambias el generador de pistas, o consigues que un modelo lo escriba, abre un issue en el repositorio. Los leo todos.