Mit PixelRAM programmierst du schnelle Computergrafik direkt in C. Es gibt keine Befehle für Linien, Kreise oder Figuren: Dein Programm bestimmt die Farbe jedes einzelnen Bildpunkts selbst. Daraus entstehen in diesem Tutorial bewegte Wellenmuster und ein Spiel für zwei an einer Tastatur.
Bei Pixelflow Canvas zeichnest du mit einfachen Befehlen in Ruby. PixelRAM geht eine Ebene tiefer: Der Bildschirm ist ein Stück Speicher, in dem für jeden Bildpunkt eine Zahl steht, und dein Programm schreibt diese Zahlen. Das ist der Bildschirm, wie ihn die ersten PCs hatten, und alles andere baust du dir selbst: Muster, Animationen, Spiele, Fraktale, eigene 3D-Grafik.
Dafür verwenden wir C. Die Sprache ist nah an der Arbeitsweise des Computers und schnell genug, um für jedes Bild zehntausende Pixel einzeln auszurechnen, im Browser, 60-mal pro Sekunde.
Für diese Anleitung brauchst du die PixelRAM-Vorlage:
Stelle zuerst sicher, dass du keinen Ordner geöffnet hast. Um sicherzugehen, drücke einfach den Shortcut für »Ordner schließen«: StrgK und dann F. Dein Workspace sollte jetzt ungefähr so aussehen:

Klicke auf »Clone Repository« und füge die Adresse der Vorlage ein:
https://github.com/specht/pixelram-starter.git

Bestätige mit Enter. Danach wählst du das Verzeichnis, in dem das Repository gespeichert werden soll: den Ordner /workspace.

Das Repository wird jetzt in einen Unterordner von /workspace geklont, der so heißt wie das Repository. Beantworte die Frage, ob es jetzt geöffnet werden soll, mit »Open«:

Öffne die Datei main.c und gib im Terminal ein:
make
Beim ersten Mal lädt make die neueste Version der Bibliothek PixelRAM herunter und legt sie im versteckten Ordner .pixelram ab; danach nimmt es immer diese Kopie. Mit make pixelram-update holst du später die neueste Version. Dann übersetzt es main.c in die Datei main.html: Dein C-Programm läuft als WebAssembly im Browser.


Klicke unten rechts auf »Go Live«. Im neuen Tab siehst du die Dateien des Projekts; klicke dort auf main.html. Du solltest einen kleinen grünen Punkt in der Mitte sehen:

make pixelram-update die neueste Fassung von PixelRAM.
make, dann die Vorschau neu laden, falls sie es nicht von selbst tut. Findet make einen Fehler, nennt es die Zeile in deiner Datei, und main.html bleibt, wie sie war. Lies die erste Fehlermeldung zuerst, die übrigen sind oft nur Folgefehler.
So sieht main.c aus:
#include "pixelram.h" int main(void) { if (!screen_open(320, 180, pixel_indexed8, "PixelRAM")) return 1; set_pixel(160, 90, 10); return 0; }
screen_open(320, 180, pixel_indexed8, "PixelRAM") öffnet einen Bildschirm mit 320 × 180 Pixeln. set_pixel(160, 90, 10) setzt genau einen davon: an der Stelle x = 160, y = 90, also in der Mitte, auf die Farbe 10. Der Punkt (0, 0) liegt links oben, y wächst nach unten.
Im Modus pixel_indexed8 ist jedes Pixel ein Byte, eine Zahl von 0 bis 255. Welche Farbe zu einer Zahl gehört, legt eine Palette fest: In der Standardpalette ist 10 ein helles Grün und 0 Schwarz.
Der Bildschirm hat 320 × 180 = 57.600 Pixel. Für jedes einen eigenen set_pixel-Befehl zu schreiben, wäre unsinnig. Stattdessen laufen zwei verschachtelte Schleifen über alle Zeilen und darin über alle Spalten, und eine Funktion color sagt für jedes Pixel, welche Farbe es bekommt. Ersetze den Inhalt von main.c durch:
#include "pixelram.h" #define W 320 #define H 180 int color(int x, int y) { return (x / 20 + y / 20) % 16; } int main(void) { screen_open(W, H, pixel_indexed8, "PixelRAM"); use_palette("sweetie_16"); for (int y = 0; y < H; y++) for (int x = 0; x < W; x++) set_pixel(x, y, color(x, y)); return 0; }
#define W 320 gibt der Breite einen Namen, damit die Zahl nur an einer Stelle steht. use_palette("sweetie_16") wählt eine Palette mit 16 Farben, die Nummern 0 bis 15. Deshalb rechnet color am Ende % 16: Der Rest beim Teilen durch 16 liegt immer zwischen 0 und 15.

x / 20 ist eine Ganzzahldivision: Für x = 0 bis 19 kommt 0 heraus, für 20 bis 39 kommt 1 heraus, und so weiter. So entstehen Felder von 20 × 20 Pixeln. Dein Programm zeichnet keine Quadrate. Es beantwortet für jedes Pixel einzeln die Frage: Welche Farbe hast du? Darum geht es auf dieser ganzen Seite.
Welche Farbnummer gibt color für das Pixel x = 250, y = 175 zurück?
Rechne x / 20 und y / 20 als ganze Zahlen, ohne Rest, zähle sie zusammen und nimm davon den Rest beim Teilen durch 16.
Ändere nur die Zeile mit return in color. Jede dieser Formeln ergibt ein ganz anderes Bild:
return (x * y / 64) % 16;
return (x * x + y * y) / 256 % 16;
return (x & y) == 0 ? 12 : 0;
return (x ^ y) / 8 % 16;Die dritte Formel zeichnet das Sierpiński-Dreieck, ein Fraktal. x & y verknüpft die beiden Zahlen Bit für Bit: Ein Bit des Ergebnisses ist 1, wenn es in x und in y 1 ist. Das Pixel wird weiß (Farbe 12), wenn x und y keine einzige 1 an derselben Stelle haben. ? und : sind ein Wenn-dann-sonst in einer Zeile. x ^ y in der vierten Formel ist das bitweise exklusive Oder.
x durch x - 160 und y durch y - 90 ersetzt? Wie bekommst du senkrechte Streifen, wie ein Schachbrett? Achte darauf, dass das Ergebnis zwischen 0 und 15 liegt.
Dieselben Zahlen sehen mit einer anderen Palette ganz anders aus. Ersetze "sweetie_16" zum Beispiel durch "pico_8" (16 Farben), "endesga_32" (32 Farben) oder "aap_64" (64 Farben). Hat eine Palette mehr Farben, kannst du statt % 16 auch % 32 oder % 64 rechnen.
set_palette(nummer, rot, grün, blau) stellst du dir eine einzelne Farbe selbst ein, jeweils mit Werten von 0 bis 255.
Jetzt wird die Formel für color eine Rechnung mit Abständen und Winkelfunktionen, und am Ende bewegt sich alles.
Wie weit ist ein Pixel von der Mitte des Bildschirms entfernt? Der Satz des Pythagoras sagt es: Mit dx und dy, dem Abstand nach rechts und nach unten, ist der Abstand sqrt(dx * dx + dy * dy). Für sqrt brauchst du die Mathematik-Bibliothek: Schreib #include <math.h> unter die erste Zeile.
Dafür rechnet color mit Kommazahlen, double: int color(double x, double y). Die Schleife in main gibt weiter ganze Zahlen hinein, aber color kann so auch die Farbe zwischen zwei Pixeln ausrechnen; das braucht das Programm gleich. Die Mitte ist (W / 2.0, H / 2.0), denn mit 2.0 statt 2 rechnet C mit Kommazahlen. Am Ende schneidet (int) die Nachkommastellen ab, weil % nur mit ganzen Zahlen geht.
color neu: dx und dy sind der Abstand des Punkts (x, y) zur Mitte, distance ist der Abstand nach Pythagoras, und alle 6 Pixel Abstand kommt die nächste Farbe: (int)(distance / 6.0) % 16.
Rechne für den Punkt (190, 130) von Hand: Wie weit liegt er rechts und unterhalb der Mitte (160, 90), wie weit ist er insgesamt von ihr entfernt, und welche Farbe bekommt er?
Ein Gerüst:
int color(double x, double y) { double dx = …; double dy = …; double distance = …; return (int)(distance / 6.0) % 16; }
int color(double x, double y) { double dx = x - W / 2.0; double dy = y - H / 2.0; double distance = sqrt(dx * dx + dy * dy); return (int)(distance / 6.0) % 16; }

Pixel mit ungefähr demselben Abstand bekommen dieselbe Farbe. So entstehen Kreise, obwohl das Programm keinen einzigen Kreis zeichnet. Probiere aus, was passiert, wenn du 6.0 größer oder kleiner machst.
Welche Farbnummer bekommt das Pixel x = 220, y = 10 mit der neuen
Funktion color?
Die Mitte ist (160, 90). Also dx = 220 - 160 und dy = 10 - 90; dann Pythagoras, durch 6, abschneiden und % 16.
Schreib color mit dem Abstand zur Mitte in main.c und speichere. Der
Workspace fragt die Farbe an ein paar Stellen ab.
Die Ringe haben harte Kanten. Weicher wird es mit der Sinusfunktion: sin liefert Werte, die gleichmäßig zwischen −1 und 1 hin- und herpendeln. sin(distance * 0.12) wird damit zu einer Welle um die Mitte, mit einem Berg alle gut 52 Pixel.
color durch zwei: double wave = sin(distance * 0.12); und ein return, das aus wave eine Farbe von 0 bis 15 macht, 0 im Wellental, 15 auf dem Berg.
Welche Farbe soll ein Pixel bekommen, wenn wave −1 ist, welche bei 1 und welche bei 0? Wie machst du aus den Zahlen von −1 bis 1 erst Zahlen von 0 bis 2 und daraus welche von 0 bis 15?
Ein Gerüst:
double wave = sin(distance * 0.12); return (int)(…); // aus −1 bis 1 wird 0 bis 15
double distance = sqrt(dx * dx + dy * dy); double wave = sin(distance * 0.12); return (int)((wave + 1.0) * 7.5);
wave + 1.0 liegt zwischen 0 und 2, mal 7,5 ergibt das 0 bis 15: genau die 16 Farben der Palette.
distance * 0.20 statt 0.12 rückt die Wellen enger zusammen.

Mach in color aus wave eine Farbe von 0 bis 15 und speichere. Der
Workspace sieht sich die Farben aller Pixel an.
main unter use_palette die Zeile for (int i = 0; i < 16; i++) set_palette(i, i * 17, 0, 255 - i * 17);. Jetzt geht Farbe 0 (Blau) in 16 gleichen Schritten zu Farbe 15 (Rot), und aus den bunten Ringen werden weiche Wellenberge. Wie sieht ein Verlauf von Schwarz über Rot und Gelb bis Weiß aus, wie im Feuer? Dort steht, wie man ihn aus mehreren Abschnitten baut.
Sieh dir die Ränder der Ringe genau an: Treppenstufen. Jedes Pixel bekommt genau eine der 16 Farben, also verläuft jede Grenze zwischen zwei Farben in Stufen. Gleich bewegen sich die Ringe, dann springen die Stufen bei jedem Bild von Pixel zu Pixel, und entlang jeder Kante krabbelt und flimmert es.
Auf den Bildschirmen der 80er- und 90er-Jahre fiel das kaum auf. Ein Röhrenbildschirm (CRT, Cathode Ray Tube) hat keine Pixel mit scharfen Rändern: Ein Elektronenstrahl fährt Zeile für Zeile über die Leuchtschicht hinter dem Glas, und wo er sie trifft, glüht sie auf, in einem runden Fleck mit weichem Rand. Der Strahl springt auch nicht von einer Farbe zur nächsten, er wird stärker oder schwächer, und dafür braucht er ein Stück Weg. So fließt jedes Pixel ein wenig in seine Nachbarn über, vor allem nach links und rechts, und zwischen den Zeilen bleibt ein dunkler Streifen, die Scanline. Treppenstufen wurden auf der Röhre von selbst weich, und wer damals Grafik zeichnete, rechnete damit: Zwei Farben im Schachbrettmuster sahen aus wie eine Mischfarbe. Ein wenig Glättung gab es umsonst. Der CRT-Filter von PixelRAM (das Röhrensymbol, F6) ahmt diesen Bildschirm nach; schalte ihn ein und sieh dir die Kanten noch einmal an.
Ohne Röhre muss das Programm selbst glätten. Ein Pixel ist ein kleines Quadrat, und bisher fragt das Programm nur an einem einzigen Punkt nach der Farbe: an seiner linken oberen Ecke (x, y). Läuft eine Grenze zwischen zwei Ringen mitten durch das Quadrat, bekommt es trotzdem ganz die Farbe dieser einen Ecke. Besser wäre eine Farbe dazwischen, so wie das Pixel zum Teil im einen und zum Teil im anderen Ring liegt. Dafür fragt das Programm an mehreren Punkten im Pixel und mischt die Farben. Mehrere Messpunkte für ein Pixel heißen Supersampling.
Viele Messpunkte für jedes Pixel wären viel Arbeit, und das schafft der Browser nicht 60-mal pro Sekunde, sobald sich etwas bewegt. Nötig sind sie aber nur an den Kanten, und die findet das Programm billig:
(x, y) aus, nur eine Spalte und eine Zeile weiter, und merkt sie sich im Array corner. Das sind die vier Ecken jedes Pixels; jede Ecke gehört zu vier Pixeln und wird nur einmal gerechnet.Die Funktion square ruft sich dafür selbst auf: Sie ist rekursiv. Damit das ein Ende hat, zählt levels mit, wie oft sie noch teilen darf. Bei 0 nimmt square einfach die Farbe der linken oberen Ecke, so wie bisher. LEVELS 0 ist also genau das Bild von eben, LEVELS 1 teilt ein Pixel an einer Kante einmal (bis zu 4 Messpunkte), LEVELS 2 zweimal (bis zu 16), LEVELS 3 dreimal (bis zu 64). Genauer hinsehen nur dort, wo es etwas bringt: Das heißt adaptives Supersampling.
An color ändert sich nichts. Ersetze main durch:
return (int)((wave + 1.0) * 7.5);}// wie oft ein Pixel an einer Kante höchstens geteilt wird#define LEVELS 1// die Farben an den Ecken der Pixel: eine Zeile und eine Spalte mehrint corner[H + 1][W + 1];// Die Farbe eines Quadrats mit der linken oberen Ecke (x, y) und der// Seitenlänge size; c00, c10, c01 und c11 sind die Farben seiner Ecken.void square(double x, double y, double size, int c00, int c10, int c01, int c11, int levels, int rgb[3]){ if (levels == 0 || (c00 == c10 && c00 == c01 && c00 == c11)) { // alle Ecken gleich (oder nicht weiter teilen): eine Farbe uint8_t r, g, b; get_palette(c00, &r, &g, &b); rgb[0] = r; rgb[1] = g; rgb[2] = b; return; } // eine Kante läuft durch: in vier kleinere Quadrate teilen double half = size / 2; int top = color(x + half, y); int left = color(x, y + half); int middle = color(x + half, y + half); int right = color(x + size, y + half); int bottom = color(x + half, y + size); int upper_left[3], upper_right[3], lower_left[3], lower_right[3]; square(x, y, half, c00, top, left, middle, levels - 1, upper_left); square(x + half, y, half, top, c10, middle, right, levels - 1, upper_right); square(x, y + half, half, left, middle, c01, bottom, levels - 1, lower_left); square(x + half, y + half, half, middle, right, bottom, c11, levels - 1, lower_right); // und ihre Farben mischen for (int i = 0; i < 3; i++) rgb[i] = (upper_left[i] + upper_right[i] + lower_left[i] + lower_right[i]) / 4;}// zeichnet das ganze Bild mit color, die Kanten geglättetvoid draw(void){ for (int y = 0; y <= H; y++) for (int x = 0; x <= W; x++) corner[y][x] = color(x, y); for (int y = 0; y < H; y++) for (int x = 0; x < W; x++) { int rgb[3]; square(x, y, 1.0, corner[y][x], corner[y][x + 1], corner[y + 1][x], corner[y + 1][x + 1], LEVELS, rgb); set_pixel_rgb(x, y, rgb[0], rgb[1], rgb[2]); }}int main(void){ screen_open(W, H, pixel_rgb24, "PixelRAM"); use_palette("sweetie_16"); draw(); return 0;}
pixel_rgb24, und jedes Pixel bekommt mit set_pixel_rgb seinen Rot-, Grün- und Blauanteil, jeweils von 0 bis 255.get_palette(nummer, &r, &g, &b) schreibt die drei Anteile einer Paletten-Farbe in r, g und b. Das & gibt der Funktion die Adressen der Variablen, damit sie hineinschreiben kann. Jeder Messpunkt ist also eine der 16 Farben; nur Pixel auf einer Kante bekommen eine Mischung.square gibt drei Zahlen zurück, Rot, Grün und Blau. Eine Funktion in C kann nur einen Wert zurückgeben, deshalb bekommt square das Array rgb und schreibt hinein: Ein Array als Parameter ist dasselbe Array wie beim Aufruf, kein neues.corner steht außerhalb von draw: Mit 181 × 321 Zahlen ist das Array zu groß für den Platz, den eine Funktion im Browser für ihre Variablen hat.So sieht ein Ausschnitt aus der Nähe aus, mit LEVELS von 0 bis 3:
LEVELS 0: ein Messpunkt
LEVELS 1: bis zu 4 Messpunkte
LEVELS 2: bis zu 16 Messpunkte
LEVELS 3: bis zu 64 MesspunkteDie Pixel innen in den Ringen sind bei jeder Stufe genau wie vorher, das Bild wird nicht unscharf. Nur an den Grenzen sitzen Pixel in Zwischentönen: Je mehr von einem Pixel im einen Ring liegt, desto näher ist seine Farbe an dessen Farbe. Den größten Schritt macht LEVELS 1, danach werden die Unterschiede klein, die Arbeit aber nicht: Bei den beiden Wellen weiter unten braucht ein Bild mit LEVELS 1 etwa zweieinhalbmal so lange wie ohne Glätten, mit LEVELS 2 gut fünfmal und mit LEVELS 3 elfmal. Für alles, was sich bewegt, ist LEVELS 1 die richtige Wahl; für ein einzelnes Bild darf es mehr sein.
LEVELS auf 2 und 3 und vergleiche. Wo sieht das Programm genauer hin? Schreib in square ganz unten, nach der Schleife, die das Mischen erledigt, rgb[0] = 255; rgb[1] = 0; rgb[2] = 0;: Jetzt ist jedes Pixel rot, das das Programm geteilt hat. Und schalte den CRT-Filter ein und aus: Wie viel macht er bei geglätteten Kanten noch aus?
Bisher rechnet das Programm ein einziges Bild und ist dann fertig. Für eine Animation braucht es eine Schleife, die immer wieder ein neues Bild berechnet, bis jemand die Seite schließt:
while (!should_close()) { // ein Bild berechnen present(); // und zeigen }
present() zeigt das fertige Bild und wartet bis zum nächsten, höchstens 60-mal pro Sekunde. seconds() sagt, wie viele Sekunden seit dem Start vergangen sind. Steckt diese Zeit in der Sinusfunktion, wandern die Wellen nach außen.
Die Zeit braucht color, gesetzt wird sie in main: Sie steht deshalb als Variable t außerhalb von beiden, wo jede Funktion sie lesen kann, über color:
#define W 320#define H 180// Sekunden seit dem Start, für jedes Bild neudouble t;int color(double x, double y)
In color werden die drei Zeilen für den Abstand zu einer, und in sin kommt - t * 2.0 dazu:
int color(double x, double y){ double distance = hypot(x - W / 2.0, y - H / 2.0); double wave = sin(distance * 0.12 - t * 2.0); return (int)((wave + 1.0) * 7.5);}
hypot(dx, dy) rechnet dasselbe wie sqrt(dx * dx + dy * dy), nur kürzer. draw bleibt, wie es ist. In main kommt die Schleife um draw():
int main(void){ screen_open(W, H, pixel_rgb24, "PixelRAM"); use_palette("sweetie_16"); while (!should_close()) { t = seconds(); draw(); present(); } screen_close(); return 0;}
Kein Kreis wird verschoben: Das Programm rechnet jedes Bild komplett neu, und weil t wächst, liefert sin jedes Mal ein wenig andere Werte. Die Kanten gleiten dabei ruhig nach außen, weil draw sie glättet.
Wie schnell die Wellen wandern, hängt dabei nur an der Uhr, nicht daran, wie viele Bilder pro Sekunde der Rechner schafft: Nach einer Sekunde ist t um 1 größer, ob in dieser Sekunde 144 Bilder gezeigt wurden, 60 oder nur 20. Auf einem langsamen Rechner springen die Wellen von Bild zu Bild ein größeres Stück, kommen aber in einer Sekunde genauso weit. Würde das Programm dagegen in jedem Bild ein festes Stück weiterrechnen, liefen die Wellen auf jedem Bildschirm anders schnell.
Bau die Schleife mit should_close, seconds und present in main.c
ein, nimm t in die Sinusfunktion, übersetze mit make und sieh dir
die Wellen in der Vorschau an.
Was passiert, wenn du in color - t * 2.0 durch + t * 2.0 ersetzt?
Überleg erst, dann probier es aus.
set_target_fps(10); unter use_palette("sweetie_16");, übersetze mit make und lade die Vorschau neu. Jetzt zeigt PixelRAM höchstens 10 Bilder pro Sekunde statt 60. Achte darauf, wie weit ein Ring in einer Sekunde kommt, und wie gleichmäßig er dabei wandert. Nimm die Zeile danach wieder heraus.
Die Quelle der Wellen muss nicht in der Mitte bleiben. Mit cos und sin der Zeit wandert ein Punkt in Schleifen über den Bildschirm. Nimm zwei solche Quellen, rechne für jedes Pixel beide Wellen aus und zähle sie zusammen. Die beiden Quellen stehen wie t außerhalb der Funktionen, color misst den Abstand zu beiden:
// Sekunden seit dem Start, für jedes Bild neudouble t;// zwei Wellenquellen, die durchs Bild wanderndouble ax, ay, bx, by;int color(double x, double y){ double d1 = hypot(x - ax, y - ay); double d2 = hypot(x - bx, y - by); double wave = sin(d1 * 0.12 - t * 1.0) + sin(d2 * 0.12 + t * 1.15); return (int)((wave + 2) * 3.75);}
main bewegt die Quellen bei jedem Bild ein Stück weiter:
while (!should_close()) { t = seconds(); ax = W / 2.0 + cos(t * 0.4) * 70; ay = H / 2.0 + sin(t * 0.55) * 40; bx = W / 2.0 + cos(t * 0.65) * 90; by = H / 2.0 + sin(t * 0.35) * 50; draw(); present(); }
An einem Pixel ist die eine Welle gerade ganz oben (sin ergibt 1) und
die andere ganz unten (−1). Welche Farbe bekommt das Pixel? Überleg
erst, dann übersetze und sieh dir das Bild an.
Hier geht es weiter, sobald du deine Antwort oben abgegeben hast.
Wo beide Wellen gleichzeitig oben sind, verstärken sie sich, wo die eine oben und die andere unten ist, löschen sie sich aus. Das nennt man Interferenz. Die Summe liegt zwischen −2 und 2, deshalb rechnet die letzte Zeile von color (wave + 2) * 3.75.

Für jedes Bild rechnet color an 58.000 Ecken zwei Abstände und zwei Sinuswerte aus, und weil die Ringe hier dicht liegen, an vielen Kanten noch einmal: zusammen rund 200.000-mal pro Bild, bei 60 Bildern pro Sekunde rund 12 Millionen-mal pro Sekunde. Für solche Rechnungen ist C gemacht.
d1 * 0.18, schnellere Wellen mit t * 2.0, weitere Wege mit cos(t * 0.4) * 110. Füge eine dritte Quelle hinzu: Wie musst du dann die letzte Zeile von color ändern, damit die Farben wieder zwischen 0 und 15 liegen? Und wie sieht es mit "aap_64" und 64 Farben aus?
Zwei Spuren fahren über das Spielfeld und ziehen eine Linie hinter sich her. Wer in eine Linie oder an die Wand fährt, verliert die Runde. Gespielt wird zu zweit an einer Tastatur, mit WASD und mit den Pfeiltasten.
Das Spiel ist älter als die ersten Heimcomputer, die man fertig kaufen konnte, etwa den Apple II von 1977. 1976 stellte die Firma Gremlin den Spielautomaten Blockade in die Spielhallen: Zwei Spieler steuern je einen Pfeil, der eine Mauer aus Blöcken hinter sich herzieht, und wer gegen eine Mauer fährt, verliert. Berühmt wurde die Idee 1982 durch den Film Tron. Darin leben Programme in einem Computer und fahren auf leuchtenden Motorrädern, den Light Cycles, gegeneinander; wer die Lichtspur des anderen berührt, ist ausgelöscht. Und aus Blockade wurde Snake, die Schlange, die auf Handys von Nokia ab 1998 Millionen Menschen gespielt haben.
Das Spiel bekommt eine eigene Datei. Lege lichtspur.c neben main.c an und übersetze sie mit:
make PROGRAM=lichtspur
Das ergibt lichtspur.html, die du in der Vorschau öffnest. So kann jedes Programm im Ordner seine eigene Datei haben.
#include "pixelram.h" #define W 160 #define H 90 #define STEP (1.0 / 30) // ein Schritt alle 1/30 Sekunde int main(void) { screen_open(W, H, pixel_indexed8, "Lichtspur"); use_palette("sweetie_16"); int x = 20, y = H / 2; // Position int dx = 1, dy = 0; // Richtung: nach rechts double last = seconds(); double lag = 0; // Zeit, für die noch Schritte fehlen while (!should_close()) { if (key_pressed(pixel_key_up)) { dx = 0; dy = -1; } if (key_pressed(pixel_key_down)) { dx = 0; dy = 1; } if (key_pressed(pixel_key_left)) { dx = -1; dy = 0; } if (key_pressed(pixel_key_right)) { dx = 1; dy = 0; } double now = seconds(); lag += now - last; last = now; if (lag > 0.25) // nach einer Pause nicht davonrasen lag = 0.25; while (lag >= STEP) { x += dx; y += dy; set_pixel(x, y, 11); lag -= STEP; } present(); } screen_close(); return 0; }
Die Spur fährt nach rechts. Du drückst einmal kurz ↑ und lässt die Taste wieder los. Was macht die Spur? Überleg erst, dann probier es aus.
Hier geht es weiter, sobald du deine Antwort oben abgegeben hast.
Klicke in die Vorschau, damit sie die Tasten bekommt, und steuere mit den Pfeiltasten. Dreierlei ist neu:
key_pressed ist in genau dem Bild wahr, in dem eine Taste heruntergedrückt wurde. dx und dy merken sich die Richtung, und bei jedem Schritt geht die Spur einen Pixel weiter.
Die Spur fährt einen Pixel nach dem anderen, sie kann nicht wie die Wellen einfach ein krummes Stück weiterrücken. Würde sie in jedem Bild einen Schritt machen, wäre sie auf jedem Bildschirm anders schnell: Viele zeigen 60 Bilder pro Sekunde, manche 120 oder 144, ein langsamer Rechner schafft vielleicht nur 20, und ein Tab im Hintergrund zeigt gar keine. Deshalb fährt die Spur in einem festen Takt, nach der Uhr: alle STEP Sekunden, also 30-mal pro Sekunde, einen Schritt.
lag sammelt die Zeit, die seit dem letzten Bild vergangen ist. Die innere while-Schleife macht für jede volle Dreißigstelsekunde darin einen Schritt und zieht sie von lag ab; der Rest wartet auf das nächste Bild. Bei 60 Bildern pro Sekunde fährt die Spur so etwa in jedem zweiten Bild einen Schritt, bei 10 Bildern pro Sekunde drei Schritte pro Bild. Gelenkt wird dagegen in jedem Bild, denn key_pressed ist nur in einem einzigen Bild wahr, und kein Tastendruck soll verloren gehen. War das Programm eine Weile angehalten, zum Beispiel weil der Tab im Hintergrund lag, holt es höchstens eine Viertelsekunde nach, statt Hunderte Schritte auf einmal zu fahren.
set_target_fps(10); unter use_palette("sweetie_16");, übersetze mit make PROGRAM=lichtspur und lade die Vorschau neu. Jetzt zeigt PixelRAM höchstens 10 Bilder pro Sekunde. Achte darauf, wie schnell die Spur über das Feld kommt und in welchen Sprüngen sie wächst. Ändere danach STEP in (1.0 / 60). Schreib zuletzt clear(0); als erste Zeile in die innere while-Schleife: Was bleibt von der Spur übrig, und warum? Nimm alles danach wieder heraus.
Game Programming Patterns, Robert Nystrom, 2014. Das Kapitel »Game Loop« macht genau hier weiter: Es vergleicht Spielschleifen, die in jedem Bild einen Schritt machen, mit solchen, die mit der vergangenen Zeit rechnen, und landet beim festen Takt mit derselben Variable lag. Das ganze Buch ist frei online zu lesen.
Noch kannst du deine eigene Spur kreuzen und aus dem Bild fahren. Das ändert sich jetzt.
Was auf dem Bildschirm steht, ist Speicher, und Speicher kann man lesen: get_pixel(x, y) gibt die Farbnummer eines Pixels zurück. Ist das Feld, auf das die Spur fahren will, nicht mehr 0 (der Hintergrund), dann steht dort eine Wand oder eine Spur, und es kracht. Du brauchst keine Liste der Linien und keine Rechnung mit Rechtecken: Der Bildschirm selbst ist das Spielfeld.
Zeichne vor der Schleife einen Rahmen in Farbe 14:
for (int x = 0; x < W; x++) { set_pixel(x, 0, 14); set_pixel(x, H - 1, 14); } for (int y = 0; y < H; y++) { set_pixel(0, y, 14); set_pixel(W - 1, y, 14); }
Bei jedem Schritt sieht das Programm nach, bevor es das neue Pixel setzt. Den Zusammenstoß merkt sich eine Variable bool crashed = false; vor der äußeren Schleife, und die Unfallstelle wird rot, Farbe 2. Für die Wahl zwischen zwei Farben hat C eine kurze Form: crashed ? 2 : 11 ist 2, wenn crashed wahr ist, und sonst 11.
bool crashed = false; vor die äußere Schleife und ändere die innere while-Schleife: Nach dem Schritt fragt sie mit get_pixel, ob auf dem neuen Feld schon etwas steht, und setzt dann crashed auf true. Danach setzt sie das Pixel, rot nach dem Zusammenstoß, sonst wie bisher in Farbe 11. Ist die Spur verunglückt, fährt sie nicht weiter, auch wenn in diesem Bild noch Schritte übrig wären: Das gehört in die Bedingung der Schleife.
Die Spur fährt nach rechts und steht bei (157, 45); der Rahmen rechts liegt bei x = 159. Was gibt get_pixel beim nächsten Schritt zurück, was beim übernächsten? Wann wird crashed wahr, und was soll die Schleife danach tun?
In der Schleife kommt zuerst der Schritt, dann ein if mit get_pixel(x, y), dann set_pixel mit crashed ? 2 : 11. Die Bedingung der Schleife bekommt einen zweiten Teil, der falsch wird, sobald es gekracht hat.
Ein Gerüst:
while (… && lag >= STEP) // hier: nicht weiter nach dem Zusammenstoß { x += dx; y += dy; if (…) // steht auf dem neuen Feld schon etwas? crashed = true; set_pixel(x, y, …); lag -= STEP; }
while (!crashed && lag >= STEP) { x += dx; y += dy; if (get_pixel(x, y) != 0) crashed = true; set_pixel(x, y, crashed ? 2 : 11); lag -= STEP; }
!crashed in der Bedingung hält die Spur nach dem Zusammenstoß an, auch mitten in einem Bild, in dem sie noch mehrere Schritte vor sich hätte.
Zeichne in lichtspur.c den Rahmen und prüfe mit get_pixel, ob die
Spur zusammenstößt. Übersetze mit make PROGRAM=lichtspur und fahr in
die Wand.
Was passiert, wenn du die Zeile mit set_pixel vor das if mit
get_pixel schiebst, die Spur also erst zeichnest und dann nachsiehst?
Überleg erst, dann probier es aus.
Für zwei Spuren bräuchtest du alle Variablen doppelt: x1, y1, dx1, x2, … Übersichtlicher ist ein struct, das alles zusammenfasst, was zu einer Spur gehört. Funktionen wie drive bekommen dann einen Zeiger auf eine Spur, Player *p, und erreichen ihre Teile mit p->x. So arbeitet derselbe Code für beide. Hier das ganze Spiel, bis auf zwei Funktionen, die du kennst:
#include "pixelram.h" #define W 160 #define H 90 #define WALL 14 #define CRASH 12 #define STEP (1.0 / 30) // ein Schritt alle 1/30 Sekunde typedef struct { int x, y; // Position int dx, dy; // Richtung int color; bool crashed; int wins; } Player; void draw_field(Player *a, Player *b) { clear(0); for (int x = 0; x < W; x++) { set_pixel(x, 0, WALL); set_pixel(x, H - 1, WALL); } for (int y = 0; y < H; y++) { set_pixel(0, y, WALL); set_pixel(W - 1, y, WALL); } // Siege als Punkte in der oberen Wand: links a, rechts b for (int i = 0; i < a->wins; i++) set_pixel(2 + 2 * i, 0, a->color); for (int i = 0; i < b->wins; i++) set_pixel(W - 3 - 2 * i, 0, b->color); } void start(Player *p, int x, int dx) { p->x = x; p->y = H / 2; p->dx = dx; p->dy = 0; p->crashed = false; } void steer(Player *p, pixel_key up, pixel_key down, pixel_key left, pixel_key right) { // hier: lenken wie bisher, aber Umdrehen geht nicht } void drive(Player *p) { // hier: einen Schritt fahren und merken, ob es kracht } int main(void) { screen_open(W, H, pixel_indexed8, "Lichtspur"); use_palette("sweetie_16"); Player a = { .color = 3 }; // Orange, fährt mit WASD Player b = { .color = 11 }; // Türkis, fährt mit den Pfeiltasten bool running = false; // Die Leertaste startet jede Runde. draw_field(&a, &b); double last = seconds(); double lag = 0; // Zeit, für die noch Schritte fehlen while (!should_close()) { double now = seconds(); lag += now - last; last = now; if (lag > 0.25) // nach einer Pause nicht davonrasen lag = 0.25; if (running) { steer(&a, pixel_key_w, pixel_key_s, pixel_key_a, pixel_key_d); steer(&b, pixel_key_up, pixel_key_down, pixel_key_left, pixel_key_right); } else if (key_pressed(pixel_key_space)) { draw_field(&a, &b); start(&a, 20, 1); start(&b, W - 21, -1); running = true; lag = 0; // die Runde beginnt ohne Rückstand } // beide Spuren fahren im selben Takt: immer beide einen Schritt while (running && lag >= STEP) { drive(&a); drive(&b); if (a.x == b.x && a.y == b.y) // frontal ineinander a.crashed = b.crashed = true; set_pixel(a.x, a.y, a.crashed ? CRASH : a.color); set_pixel(b.x, b.y, b.crashed ? CRASH : b.color); if (a.crashed || b.crashed) { running = false; if (!a.crashed) a.wins++; if (!b.crashed) b.wins++; } lag -= STEP; } present(); } screen_close(); return 0; }
drive: Die Spur p fährt einen Schritt in ihre Richtung, und steht auf dem neuen Feld schon etwas, ist sie verunglückt. Das hast du vorhin schon geschrieben, mit x statt p->x.
Welche Zeilen der inneren Schleife von vorhin machen genau das? Was wird aus x, dx und crashed, wenn sie jetzt zur Spur p gehören?
Ein Gerüst:
void drive(Player *p) { p->x += …; p->y += …; if (…) // steht auf dem neuen Feld schon etwas? p->crashed = true; }
void drive(Player *p){ p->x += p->dx; p->y += p->dy; if (get_pixel(p->x, p->y) != 0) p->crashed = true;}
Schreib drive in lichtspur.c und speichere. Der Workspace lässt eine
Spur einen Schritt fahren, einmal auf ein freies Feld und einmal auf
ein Feld, auf dem schon etwas steht.
steer: Mit den vier Tasten, die es bekommt, lenkt es die Spur p wie vorhin mit den Pfeiltasten. Nur umdrehen darf sie nicht mehr, sonst führe sie sofort in ihre eigene Spur: Wer nach rechts fährt, kann nach oben oder unten abbiegen, aber nicht nach links.
Die Spur fährt nach rechts, also dx = 1, dy = 0. Welche der vier Tasten darf jetzt die Richtung ändern, welche nicht? Und wenn sie nach oben fährt? Was haben die erlaubten Fälle gemeinsam, wenn du auf dx und dy schaust?
Nach oben oder unten darf die Spur nur abbiegen, wenn sie gerade waagrecht fährt, also p->dy == 0 ist. Eine Zeile als Muster:
if (key_pressed(up) && p->dy == 0) { p->dx = 0; p->dy = -1; }
void steer(Player *p, pixel_key up, pixel_key down, pixel_key left, pixel_key right){ // Umdrehen geht nicht: nur abbiegen if (key_pressed(up) && p->dy == 0) { p->dx = 0; p->dy = -1; } if (key_pressed(down) && p->dy == 0) { p->dx = 0; p->dy = 1; } if (key_pressed(left) && p->dx == 0) { p->dx = -1; p->dy = 0; } if (key_pressed(right) && p->dx == 0) { p->dx = 1; p->dy = 0; }}
Schreib steer in lichtspur.c und speichere. Der Workspace drückt
eine Taste, während die Spur in eine Richtung fährt, und sieht nach,
wohin sie danach fährt.

Mit der Leertaste beginnt eine Runde. Die Punkte stehen als Pixel in der oberen Wand.
Beide Spuren fahren im selben Schritt gegen eine Wand. Wer bekommt den
Punkt? Lies dazu die Schleife in main.
Hier geht es weiter, sobald du deine Antwort oben abgegeben hast.
Gelenkt wird in jedem Bild, gefahren im festen Takt: Bei jedem Schritt fahren beide Spuren, und erst danach wird gezeichnet. Deshalb ist es gerecht, wenn beide gleichzeitig verunglücken: Dann gewinnt niemand.
STEP eine Variable, die mit jedem Schritt ein wenig kleiner wird: Je länger eine Runde dauert, desto schneller fahren beide Spuren.draw_field ein paar Blöcke in Wandfarbe ins Feld, mit rand() aus <stdlib.h> jedes Mal woanders.get_pixel(b.x + b.dx, b.y + b.dy) ein Feld voraus und biegt ab, wenn dort etwas ist. Wie kann sie auch weiter vorausschauen?Die Maus fragst du mit mouse_position(&x, &y) und mouse_button_down(pixel_mouse_left) ab, alle weiteren Tasten heißen wie pixel_key_a oder pixel_key_enter. Die ganze Liste steht in der Dokumentation.
Ein Extra für Schnelle. Wer es auslässt, springt zu Zeigen und veröffentlichen.
Im Ordner beispiele der Vorlage liegen fertige Programme. Jedes übersetzt du mit make PROGRAM=… und öffnest die passende HTML-Datei:
make PROGRAM=beispiele/tunnel
beispiele/tunnel: Abstand und Winkel jedes Pixels zur Mitte ergeben einen Flug durch einen Tunnel.
beispiele/julia: Für jedes Pixel immer wieder dieselbe kleine Rechnung, ein Fraktal, das sich langsam verwandelt; die Kanten geglättet wie in »Glatte Kanten«.
beispiele/magie: Die Form wird nur einmal berechnet. Bewegung bringt allein die Palette, die sich in der Schleife dreht; jedes Bild mischt ihre Farben an den Kanten neu.Dazu kommen beispiele/feuer, ein Feuer, in dem jedes Pixel die Hitze seiner Nachbarn darunter aufnimmt, und beispiele/sterne, ein Flug durch 400 Sterne in 3D. Kopiere ein Beispiel neben main.c und verändere es: Das ist oft der schnellste Weg zu einer eigenen Idee.
beispiele/magie heißt Color Cycling. Der Künstler Mark Ferrari hat damit ganze Landschaften mit Regen, Schnee und Wasser zum Leben erweckt, ohne ein einziges Pixel zu verändern. Hier kannst du seine Bilder ansehen.
PixelRAM ist klein, aber kein Spielzeug. Dieselben wenigen Befehle, die du kennst, tragen auch Spiele aus den Neunzigerjahren, die damals ganz ähnlich programmiert wurden: Ein Programm schreibt Pixel in den Speicher. Sogar DOOM läuft auf PixelRAM. Crystal Caves (1991) ist ein Jump-and-Run, dessen erste Episode frei verteilt werden darf. Probier es aus:
make crystal
Das lädt PixelRAM mit seinen Spiele-Anpassungen und den Quelltext von OpenCrystalCaves, einer freien Neuprogrammierung des Spiels, und übersetzt alles. Das dauert ein, zwei Minuten. Dann öffnest du spiele/crystal.html in der Vorschau. Du läufst mit den Pfeiltasten, springst mit Z und schießt mit X.

Die Grafik des Spiels kommt am Ende als Pixel in genau dem Speicher an, den du mit set_pixel beschreibst, und die Tasten kommen über key_down und poll_key_event herein. Wie das Spiel an PixelRAM angeschlossen ist, steht in ports/crystal im Repository von PixelRAM. Wer die Originaldateien besitzt, kann auf dieselbe Weise auch Prince of Persia und Descent übersetzen.
Solange dein Workspace läuft, kannst du anderen zeigen, was du gebaut hast. Starte die Vorschau, öffne dein Profil und teile unter »Shared Live Apps« die laufende Web-App; die Vorschau mit »Go Live« läuft auf Port 5500. Alle, die im Workspace angemeldet sind, finden sie dann in ihrem Profil unter »Von anderen geteilt«, deine Gruppe ganz oben. Erreichbar ist sie, solange dein Workspace und die Vorschau laufen.
Dauerhaft und für alle, auch ohne Anmeldung, wird dein Projekt mit einer eigenen Hackschule-Subdomain, zum Beispiel meinprojekt.hackschule.de. Dorthin kopierst du die fertigen Dateien deines Projekts. Das geht mit allem, was ganz im Browser läuft, ohne ein Programm auf dem Server. Wie du die Subdomain bekommst und die Dateien hochlädst, steht in der Anleitung zum eigenen Webspace.
Auf dieser Seite hast du keine einzige fertige Form gezeichnet. Aus einer Formel wurde ein Bild, aus einer Welle eine Animation und aus einem gelesenen Pixel ein Spiel. Dieselbe Frage trägt noch viel weiter, bis zu eigener 3D-Grafik: Welche Farbe soll dieser Pixel haben?
Die ganze Bibliothek mit allen Befehlen ist unter specht.github.io/pixelram beschrieben.