Im Februar 2026 geriet ich auf Reddit in eine Diskussion. Jemand behauptete, die neuesten Coding-Modelle würden besseren Code schreiben als jeder menschliche Entwickler, und führte die neuesten Releases von OpenAI und Anthropic als Beweis an. Ich war anderer Meinung. Die Modelle sind sehr gut bei Landing Pages, CRUD-Backends und der Art von Demo, von der es tausend Kopien auf GitHub gibt. Ich hatte Zweifel daran, was passiert, wenn die Aufgabe räumliches Denken, sorgfältige Gleitkomma-Arithmetik und eine Meinung dazu erfordert, was sich gut spielt.
Also suchte ich mir ein Projekt aus, das im Internet kaum existiert, und baute es selbst: ein Pseudo-3D-Rennspiel im Stil von OutRun, das auf einem ESP32-S3 mit einem ILI9341-Display läuft. Unterwegs bat ich die Modelle, dasselbe zu bauen. Dieser Artikel erklärt, wie das Spiel funktioniert — ausführlicher als die kürzere Beschreibung, die ich auf Medium veröffentlicht habe — und endet damit, was die Modelle falsch gemacht haben. Der vollständige Quellcode liegt unter github.com/davidmonterocrespo24/esp32s3-arcade-3d.

Was das Spiel macht
Das Ganze sind etwa 2.500 Zeilen C++ im Arduino-Framework, gezeichnet über die TFT_eSPI-Bibliothek. Es bietet:
- Eine Straße aus Segmenten mit Kurven, Hügeln und Senken, bei jedem Start zufällig generiert.
- Ein texturiertes 3D-Spielerauto, geladen aus einem OBJ-Mesh: 428 Vertices, 312 Dreiecke, eine 128 mal 128 große Textur.
- Gebäude auf beiden Seiten der Straße, einen langen Tunnel mit Deckenlichtern sowie Bäume, Büsche, Felsen und Laternenpfähle in den Lücken.
- Sechs Verkehrsautos mit eigener Geschwindigkeit und Spur.
- Einen Tag-, Sonnenuntergangs- und Nachtzyklus mit exponentiellem Nebel zum Horizont hin.
- Physik mit Beschleunigungsrampen, Reibung, Hügelgravitation und seitlichem Drift in Kurven sowie Kollisionen mit Verkehr, Wänden und Objekten am Straßenrand.
- Ein HUD mit rundem Tacho, Rundenzähler sowie aktueller und bester Rundenzeit.
Die Hardware ist bewusst gewöhnlich:
| Komponente | Details |
|---|---|
| SoC | ESP32-S3, 240 MHz Dual-Core, mit PSRAM |
| Display | ILI9341 TFT, 320 mal 240 Pixel, 16-Bit RGB565, über SPI |
| Display-Pins | SCK 12, MOSI 11, MISO 13, CS 10, DC 9, RST 8 |
| Hintergrundbeleuchtung | GPIO 39 |
| Tasten | GPIO 17 (links) und GPIO 16 (rechts), mit internen Pull-ups |

Warum es keinen Z-Buffer gibt
Die erste Antwort, die mir sowohl ChatGPT als auch Claude gaben, war, dass das Projekt auf dieser Hardware nicht machbar sei, weil ein 3D-Renderer einen Tiefenpuffer braucht und der Mikrocontroller keinen Platz dafür hat. Die Rechnung lohnt sich, denn sie zeigt, wo die eigentliche Einschränkung liegt.
Ein Frame von 320 mal 240 in RGB565 sind 153.600 Bytes. Ein 16-Bit-Tiefenpuffer derselben Größe sind weitere 153.600 Bytes. Der ESP32-S3 hat 512 KB internen SRAM, von dem ein guter Teil vom Arduino-Core, dem Display-Treiber und dem Heap belegt wird, sodass zwei Vollbild-Puffer nicht bequem in den internen Speicher passen. Mit PSRAM passen sie hinein. Das Speicherargument ist schwach.
Das stärkere Argument sind die Kosten pro Pixel. Ein Tiefenpuffer bedeutet pro Pixel jedes Dreiecks ein Lesen, ein Vergleichen und ein bedingtes Schreiben, in Software, auf einem Kern, der außerdem 76.800 Pixel pro Frame füllen und über SPI ausgeben muss. Bei 240 MHz ist dieses Budget knapp.
Arcade-Racer der 1980er hatten weder den Speicher noch die Füllrate und lösten es mit Reihenfolge statt Tiefentest: Zeichne die fernen Dinge zuerst und lass die nahen darüber malen. Das ist der Painter’s Algorithm, und er funktioniert, weil eine Straße eine sehr wohlerzogene Szene ist. Die Segmente sind bereits nach Entfernung sortiert, und alles andere (Bäume, Gebäude, Verkehr) hängt an einem Segment. Das einzige Objekt, das echtes 3D braucht, ist das Auto des Spielers, und es sitzt in fester Entfernung vor der Kamera, kann also für sich allein sortiert werden.
Die Straße: Segmente und Projektion
Die Straße ist ein Array aus 200 Segmenten, jedes 200 Welteinheiten lang. Ein Segment speichert seine Krümmung, seine Höhe, ein optionales Sprite am Straßenrand, ein Tunnel-Flag und die Höhe und Farbe eines Gebäudes auf jeder Seite:
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
};
Der Weltmaßstab ergibt sich aus der Straßenbreite: ROAD_W ist 2000 Einheiten für die halbe Straße, was ich mit etwa 10,5 m ansetze, sodass eine Einheit etwa 5,25 mm entspricht. Jede andere Konstante in config.h ist in diesen Einheiten ausgedrückt.
Die Kerntechnik folgt Jake Gordons
JavaScript-Racer-Artikeln,
die die klarste Erklärung von Pseudo-3D-Straßen sind, die ich kenne. Die Kamera sitzt CAM_HEIGHT Einheiten über der Straße. Das Sichtfeld definiert eine Kameratiefe:
float fovRad = FOV_DEG * PI / 180.0;
cameraDepth = 1.0 / tanf(fovRad / 2.0);
playerZdist = CAM_HEIGHT * cameraDepth;
Eine Segmentkante zu projizieren ist dann eine Division und zwei Multiplikationen. camZ1 ist die Entfernung von der Kamera zur nahen Kante von Segment n, und sc1 ist der Maßstab bei dieser Entfernung:
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);
Kurven sind der klassische Trick: Die curve eines Segments ist kein Winkel, sondern eine Änderung des horizontalen Versatzes pro Segment. Während die Schleife sich von der Kamera entfernt, akkumuliert sie curveDX += seg.curve und curveX += curveDX, sodass der Versatz quadratisch mit der Entfernung wächst und die Straße sich sanft biegt. Hügel sind noch einfacher: Jedes Segment hat sein eigenes y, und die Projektion nutzt die Höhendifferenz zur Kamera, sodass eine ansteigende Straße den Bildschirm hinaufklettert und eine Senke unter dem Horizont verschwindet.
Das interessante Detail ist, dass drawRoad() zwei Durchläufe über die Segmente macht, in entgegengesetzten Richtungen.
- Von vorne nach hinten, projizieren. Die erste Schleife läuft vom nächsten Segment nach außen, projiziert jede Kante in
rCache[n]und merkt sich inrClip[n]die niedrigste Bildschirmzeile, die in dieser Entfernung noch sichtbar ist. Während die Schleife voranschreitet, bewegt sichmaxynur nach oben: Ein Segment hinter einer Hügelkuppe projiziert unter die Kuppe und wird weggeschnitten. So verdecken Hügel das, was hinter ihnen liegt, ohne jeden Tiefenvergleich. - Von hinten nach vorne, zeichnen. Die zweite Schleife läuft vom fernen Ende zur Kamera und zeichnet Gras, Randstreifen, Straße und Fahrbahnmarkierungen jedes Segments als horizontale Bänder zwischen
rCache[n]undrCache[n-1], beschnitten durchrClip. Nahe der Kamera kann ein Segment viele Zeilen hoch sein, also wird es in bis zu drei Bänder unterteilt, wobei die Breite zwischen den beiden Kanten interpoliert wird, was verhindert, dass Kurven wie gestapelte Trapeze aussehen.
Gebäude und Tunnel teilen sich die Schleife
Eine frühe Version zeichnete die Straße in einer Schleife und die Gebäude in einer anderen. Auf flachem Boden sah es richtig aus und auf Hügeln zerfiel es: Ein Gebäude, das hinter einer Kuppe verborgen sein sollte, wurde darüber gemalt, weil die zweite Schleife keine Ahnung hatte, was die erste bereits gezeichnet hatte.
Die Lösung war, alles, was zu einem Segment gehört, innerhalb derselben Von-hinten-nach-vorne-Schleife zu zeichnen. Für jedes Segment, in dieser Reihenfolge: die Tunnelwände und die Decke, wenn das Segment im Tunnel liegt, die Gebäude, wenn es außerhalb liegt, dann die Straßenoberfläche. Ein Gebäude ist ein Paar von Quads (Vorder- und Seitenfläche), gebaut aus den projizierten Straßenkanten des Segments und des vorherigen:
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);
}
Der Tunnel ist dieselbe Idee mit nach innen gedrehten Quads: ein Decken-Quad in fester Höhe über der Straße, zwei Wand-Quads, ein gelbes Licht alle vier Segmente und ein graues Portal, das auf dem ersten Segment gezeichnet wird. Weil alles in Painter-Reihenfolge ausgegeben wird, sieht ein Tunneleingang auf halber Höhe eines Hügels ohne Sonderfälle korrekt aus.
Sprites am Straßenrand und die Verkehrsautos kommen in eine dritte, kurze Schleife, nachdem die Straße fertig ist. Sie werden als flache Formen gezeichnet, skaliert mit dem Projektionsmaßstab des Segments und beschnitten auf rClip, sodass ein Baum hinter einem Hügel genau an der Kuppe abgeschnitten wird, genau wie die Straße.

Das Spielerauto ist echtes 3D
Ich wollte kein Sprite für den Spieler. Das Auto ist ein OBJ-Mesh, das offline von einem kleinen Python-Skript in einen C-Header umgewandelt wird, sodass zur Laufzeit nichts geparst wird:
python assets/obj_to_header.py assets/Car2.obj > car2_mesh.h
python assets/png_to_rgb565.py assets/car2.png > car2_texture.h
Der Header enthält 428 Vertices mit Position und Texturkoordinaten sowie 312 Dreiecke als Indexliste. Die 128 mal 128 große Textur sind 32 KB RGB565 im Flash, gelesen mit pgm_read_word(), sodass sie nie den RAM berührt.
Jeden Frame durchläuft das Mesh eine kleine feste Pipeline in
render_player.cpp:
- Transformieren. Jeder Vertex wird um die vertikale Achse gedreht, um einen Winkel, der aus der seitlichen Position des Autos abgeleitet wird (das Auto dreht sich sichtbar in die Kurve hinein), dann um die Straßensteigung geneigt, gemittelt über die nächsten sechs Segmente und über die Zeit geglättet, damit das Auto auf Hügeln kippt, ohne zu zittern.
- Projizieren.
x = centerX + rx * fov / z, mit fester Kameraentfernung und einer Brennweite von 130 Pixeln. Vertices hinter der Kamera werden markiert und ihre Dreiecke übersprungen. - Sortieren. Die durchschnittliche Tiefe jedes Dreiecks wird berechnet und die 312 Dreiecke werden von fern nach nah sortiert. Ein Insertion Sort reicht bei dieser Größe aus und ist nahezu kostenlos, wenn sich die Reihenfolge zwischen Frames kaum ändert.
- Culling und Beleuchtung. Abgewandte Dreiecke werden anhand des Vorzeichens des 2D-Kreuzprodukts verworfen. Die Flächennormale wird im Objektraum berechnet und mit einem festen Licht von oben und leicht von vorne skalar multipliziert, was eine Helligkeit zwischen 0,35 und 1,0 ergibt.
- Rasterisieren. Jedes Dreieck wird von einem Scanline-Rasterizer mit affinem Texture-Mapping gezeichnet.
Der Rasterizer ist der Teil, an dem ich am meisten Zeit verbracht habe. Er sortiert die drei Vertices nach Bildschirm-Y, läuft durch die Zeilen zwischen oben und unten und interpoliert für jede Zeile das linke und rechte X sowie die Texturkoordinaten entlang der beiden aktiven Kanten. Dann läuft er über die Zeile, tastet die Textur ab, wendet die Beleuchtung an und schreibt ein Pixel:
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);
}
Affines Mapping interpoliert u und v linear im Bildschirmraum, was nicht perspektivisch korrekt ist. Auf einer großen Wand erzeugt es das Wackeln, für das PlayStation-Spiele berühmt waren. Auf einem Auto, das etwa ein Fünftel des Bildschirms einnimmt, immer in derselben Entfernung, liegt der Fehler unter einem Pixel, und die Division pro Pixel, die ein korrektes Mapping bräuchte, ist es nicht wert. Der Renderer lehnt außerdem jede Scanline ab, die breiter als 160 Pixel ist, und jedes Dreieck mit einem Vertex, das mehr als 20 Pixel außerhalb des Bildschirms liegt. Beide Schutzmaßnahmen existieren wegen echter Bugs: Ein einzelner falsch sortierter oder beschnittener Vertex wird zu einem Streifen über den ganzen Frame, und es ist weit billiger, das Dreieck zu verwerfen, als es ordentlich zu beschneiden.
Unter dem Auto liegt eine dunkle Ellipse, gezeichnet als Stapel horizontaler Linien. Sie verschiebt sich mit der seitlichen Position des Autos und ist das billigste Element im ganzen Projekt mit der größten Wirkung darauf, wie geerdet das Auto aussieht.

Himmel, Parallaxe und Tageszeit
Der Himmel wird nicht jeden Frame gezeichnet. Beim Start baut initBackground() ein 640 mal 120 großes Sprite im PSRAM (doppelte Bildschirmbreite, halbe Bildschirmhöhe) mit einem vertikalen Farbverlauf, einer Sonne und zwei Schichten einer prozeduralen Skyline mit beleuchteten Fenstern. Jeden Frame scrollt die Hauptschleife es um einen Betrag, der proportional zur aktuellen Kurve und Geschwindigkeit ist, und blittet es zweimal, damit der Umbruch nahtlos ist:
int bgX = (int)skyOffset % (SCR_W * 2);
bgSpr.pushToSprite(&spr, -bgX, 0);
bgSpr.pushToSprite(&spr, (SCR_W * 2) - bgX, 0);
Das ist der Effekt, den Horizon Chase verwendet: Der Horizont gleitet seitwärts, während man lenkt, was die Kurve weit mehr verkauft als die Straßengeometrie allein.
Die Palette liegt in colors.cpp als drei Sätze von RGB565-Farben für Tag, Sonnenuntergang und Nacht: Himmel, helles und dunkles Gras, helle und dunkle Straße, Randstreifen, Fahrbahnmarkierungen und Nebel. Das Spiel wechselt alle 180.000 Welteinheiten Fahrt zum nächsten Satz, was bei Höchstgeschwindigkeit etwa vierzehn Sekunden sind. Der Nebel ist exponentiell in der Entfernung und wird pro Segment gemischt:
float expFog(float d, float density) {
return 1.0f - clampF(1.0f / expf(d * d * density), 0, 1);
}
lerpCol() mischt zwei RGB565-Werte Kanal für Kanal, was ausreicht, um Straße und Gras in die Nebelfarbe übergehen zu lassen, ohne je nach 24-Bit zu konvertieren.
Physik: Was sich wie ein Auto anfühlt
Grafik lässt einen Racer auffallen. Die Physik entscheidet, ob ihn jemand länger als eine Minute spielt. Die gesamte Abstimmung liegt in 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
Das Update in physics.cpp macht vier Dinge der Reihe nach. Die Steigung wird aus der Höhendifferenz zwischen dem aktuellen und dem vorherigen Segment gelesen und in eine Beschleunigung umgewandelt, sodass Anstiege Geschwindigkeit kosten und Gefälle sie hinzufügen. Die Reibung wird als multiplikativer Abfall angewendet. Die Beschleunigung, die über die Zeit ansteigt statt auf ihren Zielwert zu springen, wird in die Geschwindigkeit integriert. Dann drückt die Kurve das Auto seitwärts:
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;
Die seitliche Geschwindigkeit ist es, die den Drift erzeugt. Ein Auto, das in eine Kurve einfährt, rutscht nicht sofort; die Kraft sammelt sich an, das Rutschen baut sich auf, und wenn die Kurve endet, bringt die Dämpfung das Auto zurück. Der Winkel des Drifts wird dann aus dem Verhältnis von seitlicher zu vorwärts gerichteter Geschwindigkeit berechnet und nur zur Anzeige verwendet.
Kollisionen werden über die Entfernung entlang der Strecke plus Überlappung in der seitlichen Achse behandelt. Ein Auffahrunfall auf ein langsameres Verkehrsauto von hinten reduziert dich auf siebzig Prozent von dessen Geschwindigkeit; das Berühren einer Tunnelwand, eines Baums oder das Verlassen der Straße kostet mehr Geschwindigkeit, und alles davon über einem Schwellenwert löst den zwei Sekunden dauernden Crash-Zustand aus.
Der Build im Repository läuft als Demo: Ein Autopilot liest die kommende Kurve und lenkt gegen sie, und das Gas ist immer durchgedrückt. Die beiden Tasten werden in setup() deklariert und mit Pull-ups versehen, und der PC-Emulator bildet die Pfeiltasten darauf ab, sodass manuelles Lenken nur ein paar Zeilen in handleInput() sind.
Keine dieser Konstanten stammt aus einer Formel. Ich habe die Perspektive auf Papier skizziert, die Geschwindigkeitskurven in einer Tabellenkalkulation gebaut und dann eine Zahl nach der anderen geändert, bis sich das Auto richtig anfühlte. Diese Schleife funktionierte nur, weil jede Iteration Sekunden dauerte, was mich zum Emulator bringt.
Entwicklung auf einem PC mit Raylib
Das Flashen eines ESP32-S3 dauert lange genug, dass das Abstimmen einer Konstante nach Gefühl schmerzhaft ist. Also kompiliert derselbe Quellcode unter Windows gegen
Raylib, wobei die Arduino- und TFT_eSPI-Aufrufe durch einen dünnen Shim im Ordner emulator/ ersetzt werden:
car_game_wrapper.cppmacht einfach#include "../car_game.ino", sodass der Sketch ohne Änderungen zu einer gewöhnlichen C++-Übersetzungseinheit wird.Arduino.hundArduino.cppstellenmillis(),delay(),random(), einSerial, das nach stdout schreibt, und eindigitalRead()bereit, das den Zustand der Pfeiltasten für die beiden Tasten-Pins zurückgibt.TFT_eSPI.cppimplementiertTFT_eSpriteauf Basis einer Raylib-Render-Textur.fillRect,fillTriangle,drawPixelund der Rest werden zu Raylib-Zeichenaufrufen;pushSprite()blittet die Textur ins Fenster.
Ein Beispiel dafür, was der Shim richtig hinbekommen muss:
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();
}
TFT_eSPI kümmert sich nicht um die Wicklungsreihenfolge, Raylib schon, und der Straßencode verspricht keine. Jedes Dreieck zweimal zu zeichnen kostet auf einer GPU nichts und machte den Emulator pixelgenau zum Board.
Damit war die Schleife Bearbeiten, make, Ausführen in wenigen Sekunden. Die Screenshots in diesem Artikel stammen aus dem Emulator; die Animation oben ist das Board.
Speicher und Frame-Budget auf dem Board
Wohin die Bytes gehen:
| Puffer | Größe | Wo |
|---|---|---|
| Frame-Sprite, 320 mal 240 mal 16-Bit | 153.600 B | PSRAM |
| Himmel- und Skyline-Sprite, 640 mal 120 mal 16-Bit | 153.600 B | PSRAM |
| Auto-Textur, 128 mal 128 mal 16-Bit | 32.768 B | Flash |
| Auto-Mesh, 428 Vertices und 312 Dreiecke | etwa 10 KB | Flash |
| Strecke, 200 Segmente | etwa 6 KB | Interner SRAM |
| Projektions-Caches und Per-Frame-Arrays | ein paar KB | Interner SRAM |
Die beiden Sprites werden mit spr.setAttribute(PSRAM_ENABLE, true) vor createSprite() erstellt. Diese eine Zeile ist der Unterschied zwischen einem Spiel, das läuft, und einem, das nicht allokieren kann. Die 8 MB PSRAM auf dem Modul bleiben größtenteils ungenutzt; entscheidend ist, dass die beiden vollbreiten Puffer nicht mit dem Arduino-Core um internen Speicher konkurrieren.
Der Frame wird vollständig im Sprite zusammengesetzt und einmal pro Schleife mit spr.pushSprite(0, 0) an das Panel geschickt. Das ist es, was Tearing beseitigt, und es ist zugleich die Obergrenze der Bildrate: 153.600 Bytes über SPI bei 40 MHz dauern allein etwa 31 ms, bevor überhaupt gezeichnet wird. Auf dem Board pendelt sich das Spiel bei etwa 30 Bildern pro Sekunde ein, was genau das ist, was diese Rechnung vorhersagt. Den SPI-Takt in TFT_eSPIs User_Setup.h zu erhöhen, ist die erste Stelle, an der man suchen sollte, wenn man mehr will.

Wo die KI-Modelle versagten
Parallel zur handgeschriebenen Version bat ich die im Februar 2026 verfügbaren Coding-Modelle, dasselbe Spiel aus einer Beschreibung zu bauen. Das ist passiert, so fair ich es formulieren kann:
- ChatGPT produzierte Code, der nicht kompilierte, und nachdem er von Hand repariert war, lagen seine Perspektivberechnungen weit daneben. Sein durchgängiger Rat war, einen Z-Buffer hinzuzufügen.
- Claude produzierte die beste Dateistruktur und den lesbarsten Code und brach dann die Rendering-Pipeline. Es hielt die Von-hinten-nach-vorne-Reihenfolge über Straße, Tunnel und Gebäude hinweg nicht ein und führte subtile Bugs in der Gleitkomma-Mathematik ein.
- Gemini 3 Pro kam der Projektionsmathematik am nächsten und hatte dann überhaupt keine Meinung zum Rest. Die Farben sahen falsch aus, die Physik fühlte sich schwebend an, und als seine eigene Ausgabe Rendering-Artefakte hatte, konnte es sie nicht finden.
Ich halte nichts davon für überraschend. Die drei Dinge, die das Projekt am meisten brauchte, sind die drei Dinge, von denen ein Sprachmodell am wenigsten hat:
- Ordnung im Raum. Zu wissen, was vor was liegt, auf einem Hügel, in einem Tunnel, ist eine räumliche Tatsache, keine textuelle.
- Numerische Sorgfalt. Die OBJ-Datei ist Y-up und der Bildschirm ist Y-down, die V-Achse der Textur läuft von unten nach oben, und der Kurvenversatz wird jeden Frame über vierzig Segmente in Gleitkomma akkumuliert. Jedes davon ist ein Vorzeichen oder eine Rundung davon entfernt, dass die Straße driftet oder das Auto auf links gedreht gezeichnet wird.
- Geschmack. Ob 0,18 oder 0,25 die richtige Zentrifugalkonstante ist, lässt sich nicht ableiten. Man muss es fahren.
Ich habe bei diesem Projekt KI eingesetzt, und sie war genau für die Dinge nützlich, in denen sie gut ist: das Gerüst der Modulaufteilung, das Schreiben der OBJ- und PNG-Konverter und das Refactoring repetitiver Zeichenroutinen, sobald das Design stand. Jede Konstante, jede Projektion und jede Reihenfolgeentscheidung wurde von Hand getroffen. Das Modell war ein Werkzeug, nicht der Architekt. Das galt im Februar 2026 und muss nicht für immer gelten; wenn du ein Modell dazu bringst, allein aus der Beschreibung eine spielbare Version davon zu erzeugen, würde ich das wirklich gern sehen.
Bau es selbst
Das Repository ist github.com/davidmonterocrespo24/esp32s3-arcade-3d.
Auf dem Board:
- Verdrahte ein ILI9341 mit dem ESP32-S3 an den Pins aus der Hardware-Tabelle oben sowie die beiden Taster zwischen GPIO 17, GPIO 16 und Masse.
- Installiere die TFT_eSPI-Bibliothek und setze dieselben Pins und den SPI-Takt in ihrer
User_Setup.h. - Öffne
car_game.inoin der Arduino IDE, wähle ESP32S3 Dev Module, aktiviere PSRAM in den Board-Optionen und lade hoch.
Auf einem PC (Windows, MinGW und Raylib installiert):
cd emulator/
make
./car_game_emu.exe
Dieselbe Verdrahtung ist in der diagram.json des Repositories beschrieben, und sowohl das ESP32-S3 DevKit als auch das ILI9341 existieren im Teilekatalog von Velxio, wenn du die Schaltung ohne Lötkolben auslegen möchtest. Ich habe noch nicht versucht, das vollständige Spiel im emulierten Board laufen zu lassen; TFT_eSPIs Pin-Setup und das PSRAM-Sprite machen es zu einem interessanteren Test als ein Blink-Sketch, und es steht auf meiner Liste.
Wenn du es baust, den Streckengenerator änderst oder es schaffst, ein Modell dazu zu bringen, es zu schreiben, öffne ein Issue im Repository. Ich lese alle.