Prozedurale Steinmauern, Tore und Brücken in Godot
12 Min. Lesezeit

In Kalecik beginnt der Burgbau mit einer einzigen Geste. Sie ziehen den Finger über den Bildschirm, und dahinter wächst eine Steinmauer, Stein für Stein. Bei einer scharfen Kurve setzt sich ein Turm in die Ecke. Führen Sie einen gepflasterten Weg durch die Mauer, öffnet sich ein Torbogen; führen Sie ihn über einen Teich, wird daraus eine Brücke. Kritzeln Sie eine Fläche, entsteht darauf der Grundriss eines Hauses. Kalecik ist ein ruhiges Burg- und Dorfbauspiel, das ich für iPhone und iPad entwickle.
Dieser Beitrag zeigt Schritt für Schritt, wie aus einem Fingerstrich Mauern, Tore, Brücken und Häuser werden. Vorweg eine ehrliche Anmerkung: Den Code des Spiels habe nicht ich geschrieben. Das haben Claude-Code-Sitzungen erledigt, also KI-Agenten, die parallel arbeiteten. Ich habe entschieden, was das Spiel können soll, jeden Build auf meinem iPhone getestet und zurückgemeldet, was mir aufgefallen ist. Wie das Projekt insgesamt ablief, beschreibe ich im Beitrag über den Weg vom leeren Ordner aufs iPhone. Die Codeauszüge unten stammen direkt aus dem Quellcode des Spiels (Godot 4, GDScript); ich habe sie gekürzt und kommentiert.
Der Ablauf lässt sich knapp zusammenfassen: Der Strich wird zu einer sauberen Kurve, die Kurve zu einem in Metern messbaren Pfad und der Pfad zu Steinen. Danach folgen Türme, der Radierer und die Regeln für Tore, Brücken und Häuser.
Vom Strich zum Pfad: Glättung und WallPath
Vom zittrigen Strich zur sauberen Kurve
Touch-Eingaben sind unregelmäßig. Wischen Sie schnell, kommen die Abtastpunkte spärlich; wischen Sie langsam, ballen sie sich. Außerdem zittert die Hand ein wenig. Eine Mauer, die direkt auf diesen Punkten stünde, würde jedes Zittern in Stein meißeln. Die Lösung des Agenten besteht aus drei Zeilen in main.gd (_shape_stroke):
pts = PathTools.resample(pts, 0.9) # gleichmäßig, alle 0,9 m ein Punkt
pts = PathTools.chaikin(pts, 3, closed) # drei Runden Ecken abschneiden
pts = PathTools.resample(pts, 0.25) # dicht und gleichmäßig für alles WeitereDie erste Zeile tastet den Strich neu ab, mit einem Punkt alle 0,9 Meter. Der Abstand hängt damit nicht mehr von der Wischgeschwindigkeit ab, und kleines Zittern fällt einfach zwischen die Punkte. Die zweite Zeile wendet den Eckenschneide-Algorithmus von Chaikin dreimal an. Die dritte tastet das Ergebnis mit 25 Zentimetern Abstand ab; alle weiteren Berechnungen arbeiten auf diesem Array.
Chaikin ist denkbar einfach: Von jeder Kante behält man die Punkte bei einem Viertel und bei drei Vierteln und verwirft die ursprüngliche Ecke. Jede Runde rundet die Ecken weiter ab, und nach drei Runden sieht ein handgezeichneter Strich wie ein bewusster Bogen aus:
## Chaikin-Eckenschnitt. Offene Kurven behalten ihre Endpunkte.
static func chaikin(src: PackedVector2Array, iterations: int, closed: bool) -> PackedVector2Array:
var p := src
for it in iterations:
var n := p.size()
if n < 3:
return p
var q := PackedVector2Array()
if not closed:
q.append(p[0])
var count := n if closed else n - 1
for i in count:
var a := p[i]
var b := p[(i + 1) % n]
q.append(a.lerp(b, 0.25)) # ein Viertel der Kante
q.append(a.lerp(b, 0.75)) # und drei Viertel
if not closed:
q.append(p[n - 1])
p = q
return pVor dem Glätten fallen noch zwei Entscheidungen. Endet der Strich in der Nähe eines vorhandenen Turms oder einer Mauer, rastet das Ende dort ein, damit zwei Mauern wirklich verbunden sind. Ist der Strich länger als 8 Meter und liegen Anfang und Ende weniger als 2,5 Meter auseinander, gilt er als geschlossener Ring. Niemand muss den Finger exakt am Startpunkt anheben.
Ein Pfad in Metern: WallPath
Die geglätteten Punkte sind immer noch nur ein Array. Um Steine zu setzen, braucht man eine schnelle Antwort auf die Frage: „Wo bin ich und wohin schaue ich, s Meter nach dem Anfang?“ wall_path.gd beantwortet sie mit einer Parametrisierung nach Bogenlänge. Die aufsummierte Länge bis zu jedem Punkt (cum) wird einmal berechnet; für ein beliebiges s sucht eine binäre Suche das passende Segment, und dazwischen wird interpoliert:
func pos(s: float) -> Vector2:
if closed and length > 0.0:
s = fposmod(s, length) # im Ring wieder von vorn
else:
s = clampf(s, 0.0, length)
var lo := 0
var hi := pts.size() - 1
while hi - lo > 1: # binäre Suche: In welchem Segment liegt s?
var mid := (lo + hi) >> 1
if cum[mid] <= s:
lo = mid
else:
hi = mid
var seg := cum[hi] - cum[lo]
if seg <= 0.00001:
return pts[lo]
return pts[lo].lerp(pts[hi], (s - cum[lo]) / seg)Die Normale, also die Richtung, in die die Mauer schaut, stammt nicht aus einem einzelnen Segment, sondern aus der Differenz der Punkte 30 Zentimeter vor und 30 Zentimeter hinter s. Diese zentrale Differenz verhindert, dass die Normale an Knickpunkten springt; die Steine laufen weich um die Ecke.
Ab hier spricht alles in Metern: Ein Tor liegt „bei Meter 12 der Mauer“, ein vom Radierer übrig gelassenes Stück reicht „von 3 bis 7,5 Meter“.
Stein für Stein: ein Seed pro Lage, ein Seed pro Stein
In Kalecik gibt es keine importierten 3D-Modelle; jeder Stein ist ein kleines, im Code erzeugtes Gitter. masonry.gd mauert Lage für Lage. Jede Lage bekommt eine zufällige Höhe zwischen 30 und 42 Zentimetern (die unterste ist dicker) und wird in Steine von 50 Zentimetern bis 1 Meter Länge aufgeteilt. Öffnungen wie Tore werden als „Löcher“ übergeben, dort kommen keine Steine hin.

Stein für Stein gesetzte Mauern; jede Steinform stammt aus ihrem eigenen Seed.
Der eigentliche Kniff liegt darin, wie der Zufall geseedet wird. Während Sie ziehen, wächst die Mauer und wird bei jeder Aktualisierung neu berechnet. Mit einem einzigen Zufallsgenerator würden bei jeder Verlängerung alle Steine verrutschen, und die Mauer würde vor Ihren Augen brodeln. Stattdessen hat jede Lage und jeder Stein einen eigenen Seed:
while y < y_top - 0.08:
var h := course_rng.randf_range(0.30, 0.42) # Höhe der Lage
rng.seed = sd * 31 + row * 7919 + 17 # Steinlängen dieser Lage
var spans := _row_spans(a, b, row_holes, 0.7 if row == 0 else 0.5, 1.25 if row == 0 else 1.0, rng)
for k in range(0, spans.size(), 2):
var sm := (spans[k] + spans[k + 1]) * 0.5
if not _in_range(path, sm, s_from, s_to):
continue # diesen Stein baut ein anderer Chunk
# Seed pro Stein: Der Stein sieht gleich aus, egal welcher Chunk ihn baut
stone_rng.seed = sd * 131 + row * 104729 + k
_stone(buf, path, side, off, spans[k] + GAP * 0.5, spans[k + 1] - GAP * 0.5, y + GAP * 0.5, y + h - GAP * 0.5, stone_rng)
y += h
row += 1Weil jede Lage vom Anfang des Pfads nach vorn aufgeteilt wird, ändert eine längere Mauer nur die Steine ganz am Ende. Alles davor bleibt identisch: gleicher Seed, gleiche Reihenfolge, gleiche Längen. Die Beschleunigung im nächsten Abschnitt beruht vollständig auf dieser Eigenschaft.
Ein einzelner Stein ist ein „Kissen“: ein Gitter, dessen äußerer Ring bis auf die Fuge zurückweicht, während die Vorderseite 8,5 Zentimeter vorsteht, leicht zufällig geneigt, in einem von mehreren Steintönen mit kleiner Abweichung. Zwischen Nachbarn bleibt eine Fuge von 3,5 Zentimetern. Mit echten Schatten reicht das, damit es wie handgesetztes Mauerwerk wirkt.
Beim Zeichnen nur das Ende neu bauen
Die erste Version baute bei jeder Bewegung die ganze Mauer neu. Bei kurzen Mauern ging das gut, bei einer 50-Meter-Mauer dauerte eine einzige Aktualisierung auf einem M1-Mac aber 80 ms, viel zu langsam für flüssiges Zeichnen. Die Lösung war, Mauern in Abschnitte von 4 Metern zu teilen (castle_view.gd):
const CHUNK := 4.0
const TAIL := 4.0 # beim Zeichnen kann sich nur dieses letzte Stück noch ändern
func preview_update(path: WallPath, sd: int, opts: Dictionary) -> void:
var n := maxi(1, ceili(path.length / CHUNK))
var jobs := []
for k in range(_preview_stable, n): # fertige Chunks überspringen
jobs.append(_chunk_job(path, sd, k * CHUNK, minf(path.length, (k + 1) * CHUNK), "", opts))
var built := _run_jobs(jobs)
# ... die neuen Meshes ersetzen die alten Chunks in der Szene
_preview_stable = clampi(floori((path.length - TAIL) / CHUNK), 0, n)Während der Finger weiterzieht, gelten Chunks, die mehr als 4 Meter zurückliegen, als stabil und werden nie wieder gebaut; jede Aktualisierung mauert nur das Ende neu. Die Seeds pro Stein machen das sicher: Ein stabiler Chunk sieht genau so aus, als wäre die ganze Mauer neu gebaut worden. Auf demselben Rechner sank die Live-Aktualisierung auf durchschnittlich 8 ms, im schlechtesten Fall 11 ms. Alle diese Werte stammen von einem M1-Mac; Messungen auf dem Telefon habe ich nicht.
Fertige Mauern folgen derselben Idee. Jedes Chunk-Mesh wird unter einem Schlüssel zwischengespeichert: Basispfad, Chunk-Nummer, Bereich und die Signatur der Mauer (Art, Höhe, Tore, Treppen, alles, was die Steine beeinflusst). Eine Änderung baut nur die Chunks neu, deren Signatur sich geändert hat. Bis zu 150 ungenutzte Chunk-Meshes bleiben für Rückgängig und Wiederholen im Cache; beim Rückgängigmachen wird die Mauer also nicht neu gemauert, sondern kommt direkt aus dem Speicher. Wie große Neubauten auf Worker-Threads verteilt werden und warum das Telefon heiß wurde, beschreibe ich im Beitrag über Godot-Performance auf dem Handy.
Türme und Radierer

Der Radierer teilt die Mauer; an den offenen Enden stehen Steinpfeiler.
Wohin die Türme kommen
Wenn Sie eine Mauer fertig gezeichnet haben, erscheinen Türme von selbst: an beiden Enden einer offenen Mauer, dort, wo sich zwei Steinmauern kreuzen, und an scharfen Ecken. Um Ecken zu finden, geht der Agent den Pfad in Schritten von einem halben Meter ab und vergleicht die Richtung 2 Meter davor mit der Richtung 2 Meter danach. Knicke über 60 Grad werden Kandidaten, die Kandidaten nach Schärfe sortiert, und von allen, die näher als 5,5 Meter beieinanderliegen, bleibt nur der schärfste:
## Fast gerade Strecken bekommen keine Türme, echte Ecken schon.
func _corner_spots(path: WallPath) -> Array:
var cand := []
var s := 0.0 if path.closed else 3.0 # offene Mauern bekommen eigene Endtürme
var s_end := path.length if path.closed else path.length - 3.0
while s < s_end:
var a := path.pos(s - 2.0)
var b := path.pos(s)
var c := path.pos(s + 2.0)
var ang := absf((b - a).angle_to(c - b))
if ang > deg_to_rad(60.0):
cand.append([s, ang])
s += 0.5
cand.sort_custom(func(x, y): return x[1] > y[1]) # schärfste Ecke zuerst
var out := []
for cd in cand:
var ok := true
for o in out:
var d := absf(float(o) - float(cd[0]))
if path.closed:
d = minf(d, path.length - d)
if d < 5.5: # zu nah an einem bereits gesetzten Turm
ok = false
break
if ok:
out.append(cd[0])
return outDie Regel ist bewusst vorsichtig. Als ich die erste Fassung auf dem Telefon ausprobierte, wurde aus einem schnell gekritzelten Strich ein Haufen Türme; meine Rückmeldung lautete sinngemäß „unsinnige Auto-Türme“. Die Winkelschwelle, die Abstandsregel und der Schalter „Auto-Türme“ in den Einstellungen kamen danach. Höhe, Radius und Dach eines Turms wählt der Seed; per Antippen lässt sich alles später ändern. Es gibt vier Dachformen: einen Schindelkegel, eine hölzerne Galerie, eine Zinnenkrone und einen quadratischen Turm mit Pyramidendach.
Der Radierer: Mauern teilen, ohne einen Stein zu bewegen
Beim Radieren tun sich prozedurale Bauwerke am schwersten. Löschen Sie die Mitte einer Mauer, sollen die beiden Reststücke nicht neu berechnet werden und ihre Steine nicht verrutschen. Es soll wie eine absichtlich geöffnete Lücke aussehen, nicht wie eine eingestürzte Burg. castle.gd löst das über das Datenmodell. Das Dorf wird als reine Daten gespeichert, Meshes entstehen immer daraus. Eine Mauer ist der Abschnitt [s0, s1] eines Basispfads:
var bases := {} # id -> {pts, closed, seed, kind, height, gates: Array}
var walls: Array = [] # {id, base, s0, s1}Der Radierer fasst den Basispfad nie an. Er tastet das Mauerstück alle 20 Zentimeter ab, markiert die Punkte unter dem Pinsel und behält jede zusammenhängende Folge unberührter Punkte als neues Stück; Krümel unter 60 Zentimetern fallen weg. Bei einem geschlossenen Ring werden die Stücke beiderseits des Startpunkts wieder zu einem vereint. Da die Steine aus Basispfad und Seed abgeleitet sind, bleibt jeder übrige Stein genau an seinem Platz. Auch Tore sind in Metern auf dem Basispfad gespeichert und überstehen das Teilen unverändert.
Ein angenehmer Nebeneffekt: Rückgängig machen und Speichern sind nichts weiter als Kopien einiger Arrays. Den Rest erledigt der Mesh-Cache.
Weg durch die Mauer wird Tor, Weg über Wasser wird Brücke
Das ist meine liebste Regel in Kalecik: Ein Bauwerk passt sich an das an, was hindurchführt. Um Kreuzungen zweier Linien zu finden, wendet PathTools.crossings den klassischen Schnitttest zweier Strecken auf jedes Segmentpaar an und liefert jeden Treffer als Entfernung entlang der Mauer. Beim Zeichnen einer Mauer:

Ein über den Teich gezogener Weg wurde zur Steinbrücke mit Treppen.
# eine Steinmauer quer über einem Weg oder Haus bekommt einen Torbogen
if kind in Castle.STONE_KINDS:
for bid in _bases_of_kind("path"):
for s in PathTools.crossings(castle.bases[bid].pts, shaped.pts, shaped.closed):
castle.add_gate(w.base, s)Beim Zeichnen eines Wegs läuft derselbe Test in umgekehrter Richtung. add_gate lehnt manche Tore ab: keines näher als 1,6 Meter am Ende einer offenen Mauer, und zwischen zwei Toren mindestens 3 Meter. Der Mauerwerkscode übergibt die 2 Meter breite Toröffnung dann bis zur Torhöhe jeder Lage als Loch und setzt den Bogen darüber.
Bei Brücken lautet die Frage nicht „Wo kreuzt es?“, sondern „Wo ist es nass?“. _split_at_water tastet den Weg alle 25 Zentimeter ab und markiert Punkte, an denen das Gelände unter dem Wasserspiegel liegt. Nasse Abschnitte werden dann an beiden Ufern um 1,5 Meter verlängert, damit die Brücke auf festem Boden beginnt und nicht an der Wasserkante. Trockene Abschnitte bleiben Pflasterweg, nasse werden Brücke. Das Deck wölbt sich sanft (bridge.gd):
## Das Deck steigt zur Mitte leicht an: Platz für die Bögen, und es liest sich als Brücke.
static func hump(L: float) -> float:
return clampf(L * 0.09, 0.3, 1.5)
static func deck_at(base: float, s: float, L: float) -> float:
return base + hump(L) * sin(clampf(s / L, 0.0, 1.0) * PI)Die Wölbung beträgt 9 Prozent der Brückenlänge, aber nie weniger als 30 Zentimeter und nie mehr als 1,5 Meter. Etwa alle 3,6 Meter steht ein Pfeiler mit Bögen dazwischen, an beiden Rändern läuft ein Geländer aus Stein oder Holz, und endet das Deck über dem Boden, führt eine Treppe hinunter.
Die Treppen waren lehrreich. Anfangs wurde ihre Richtung aus der Normalen des Pfads berechnet. Welche Seite eine Normale zeigt, sagt aber nichts darüber, in welche Richtung das Deck verläuft, und so drehte sich jede Treppe unter die Brücke zurück. Die Korrektur: die Richtung aus der Linie selbst nehmen, aus der Differenz zwischen Endpunkt und einem Punkt 30 Zentimeter weiter innen. Die Bogensteine, die aussahen „wie fallende Blöcke“, waren ein ähnlicher Geometriefehler; sie wurden an Tangente und Normale der Ellipse ausgerichtet.
Vom Gekritzel zum Haus: das kleinste umschließende Rechteck
Das Haus-Werkzeug verwendet nicht die gezeichnete Form selbst, sondern das engste Rechteck darum. PathTools.min_rect bildet zuerst die konvexe Hülle der Punkte. Dann behandelt es jede Kante der Hülle der Reihe nach als Achse, projiziert alle Punkte auf diese Achse und ihre Senkrechte und behält die Box mit der kleinsten Fläche:

Ein Haus, das in die gezeichnete Fläche passt; antippen öffnet Griffe und Bearbeitungsmenü.
static func min_rect(src: PackedVector2Array) -> Dictionary:
var h := hull(src) # konvexe Hülle
var best := {}
var best_area := INF
for i in h.size():
var e := h[(i + 1) % h.size()] - h[i]
if e.length_squared() < 1e-8:
continue
var u := e.normalized() # diese Kante ist eine Achse
var v := Vector2(-u.y, u.x) # die Senkrechte die andere
var lo := Vector2(INF, INF)
var hi := Vector2(-INF, -INF)
for p in h:
var q := Vector2(p.dot(u), p.dot(v)) # Projektion auf beide Achsen
lo = Vector2(minf(lo.x, q.x), minf(lo.y, q.y))
hi = Vector2(maxf(hi.x, q.x), maxf(hi.y, q.y))
var size := hi - lo
if size.x * size.y < best_area:
best_area = size.x * size.y
var mid := (lo + hi) * 0.5
best = {center = u * mid.x + v * mid.y, angle = u.angle(), w = size.x, d = size.y}
return bestDas funktioniert, weil eine Seite des flächenkleinsten Rechtecks immer auf einer Kante der konvexen Hülle liegt. Statt jeden Winkel durchzuprobieren, genügen die Kanten der Hülle.
Die Maße werden auf 25 Zentimeter gerundet und auf den erlaubten Bereich begrenzt. Ein einfaches Antippen ergibt ein Standard-Häuschen. Die Tür liegt immer auf der Seite, die zur Kamera zeigt. Ein Haus, das in gleicher Tiefe geradeaus am Ende eines anderen gezeichnet wird, verschmilzt mit ihm und verlängert es; eines im rechten Winkel dockt als L- oder T-Flügel an. Und ein Haus, das quer über eine Steinmauer gezeichnet wird, öffnet einen Torbogen in der Mauer und wird zum Torhaus.
Wer die Regeln festlegte und wie sie getestet wurden
Die ersten Dateien (wall_path.gd, path_tools.gd, masonry.gd) entstanden vierzehn Minuten nach meiner ersten Nachricht. Nach einer halben Stunde lief auf dem Mac eine Demo: Strich ziehen, Steinmauer und automatischer Turm erscheinen. Häuser und Wege, die Torbögen öffnen, kamen noch in derselben Nacht; Brücken und ihre Verbindungen folgten in späteren Runden. Mein Anteil bestand darin zu sagen, was passieren soll, und das Ergebnis auf dem Telefon zu prüfen. Die Rückmeldungen zu unsinnigen Auto-Türmen, zu Brückenenden und zu Brücken, die sich verbinden sollen, stammten von mir, ebenso eine deutliche Nachricht, die übersetzt etwa lautete: „Beim prozeduralen Modellieren fehlt noch gewaltig viel.“ Die Lösungen kamen von den Agenten.
Die Agenten haben jede Regel an einen szenariobasierten Test gekoppelt, der ins Spiel eingebaut ist. Er zeichnet mit simulierten Berührungen Mauern, Wege und Häuser und prüft dann Punkte wie „Weg durch die Mauer hat ein Tor geöffnet“, „Weg über den Teich wurde zur Brücke“ oder „Radierer hat den Hof aufgeschnitten, der Ring ist offen“. Bricht eine Änderung eine Regel, fällt das auf, bevor das Spiel überhaupt auf einem Telefon landet.
Kalecik entsteht für iPhone und iPad. Version 1.0 wird für die Prüfung im App Store vorbereitet, eine Android-Version für Google Play ist unterwegs. Das Spiel wird einmalig gekauft, kommt ohne Werbung aus und funktioniert komplett offline. Wenn Sie diese Mauern selbst mit dem Finger ziehen möchten: Die Store-Links stehen auf der Kalecik-Seite, sobald sie live sind. Auch der Klang des Spiels wurde vollständig im Code erzeugt, ohne eine einzige Aufnahme; darum geht es im Beitrag über Spielmusik und Sounds aus Code.
Häufige Fragen
Was ist der Chaikin-Algorithmus, und warum keine Bézierkurven?
Der Chaikin-Algorithmus glättet einen Linienzug, indem er von jeder Kante die Punkte bei einem Viertel und drei Vierteln behält und die Ecken abschneidet. Bézierkurven brauchen eigens gewählte Kontrollpunkte; Chaikin arbeitet direkt mit den vorhandenen Punkten und passt in wenige Zeilen. Um einen handgezeichneten Strich zu säubern, reicht das.
Verwendet Kalecik fertige 3D-Modelle?
Nein. Die gesamte Geometrie, also Steine, Türme, Brücken und Häuser, wird im Code erzeugt. Im Spiel gibt es keine fertigen oder KI-generierten 3D-Modelldateien.
Warum verrutschen die Steine nicht, während die Mauer wächst?
Der Zufall jedes Steins stammt aus einem eigenen Seed: dem Seed der Mauer, der Nummer der Lage und der Position des Steins in dieser Lage. Wächst die Mauer, ändern nur die letzten Steine ihre Länge; alles davor bleibt gleich. Deshalb genügt es, beim Zeichnen nur die letzten 4 Meter neu zu bauen.
Wann erscheint Kalecik, und auf welchen Plattformen?
Kalecik ist für iPhone und iPad gedacht. Die App-Store-Version kommt bald, eine Google-Play-Version ist in Arbeit. Das Spiel wird einmalig gekauft, ohne Werbung, Abo oder In-App-Käufe, und läuft offline. Den aktuellen Stand finden Sie auf der Kalecik-Seite.
Verwandte Artikel
Lizenzfreie Spielmusik: jeden Sound im Code erzeugen
Kalecik nutzt keine Aufnahmen: Steine, Vögel, Schafe und 37 Musikstücke entstanden in Python. Keine Loops, ein Musik-Wächter und LUFS statt Ohren.
Ein Mobile-Spiel mit KI-Agenten entwickeln: Kalecik
Vom leeren Ordner aufs iPhone: wie ich Kalecik mit fünf parallelen Claude-Code-Sitzungen gebaut habe, was ich entschied und wo es hakte.
Wenn das Spiel das iPhone aufheizt: Godot mobil optimieren
Mein Godot-Spiel hat ein iPhone 15 Pro Max aufgeheizt. Die Ursache waren 120 fps; gelöst mit Framerate-Regler, Schatten-Proxys, Gras-Chunks und Threads.