Files
InfiniTower/docs/adr/0001-balancing-daten-als-rust-konstanten.md

7.5 KiB
Raw Blame History

ADR-0001: Balancing-Daten als Rust-Konstanten hinter einer Balance-Resource

  • Status: Angenommen
  • Datum: 2026-08-10
  • Betrifft: Wellenkurven, Kosten und Wirkung von In-Run-Upgrades, Workshop-Upgrade-Kurven, Gegner-Stats pro Archetyp, Card-Effekte

Kontext

Der Slice braucht einen Ort für seine Balancing-Zahlen. Die Datenmenge ist klein und bekannt: drei bis vier Gegner-Archetypen, die tragenden Turm-Stats (Damage, Attack Speed, Health, Defense, optional Crit), Workshop-Upgrades mit 20–50 Leveln, Cards mit maximal sieben Stufen, eine Ultimate.

Zwei Größen sind nicht aus dem Referenzspiel ableitbar und müssen von Hand getunt werden: die HP-/Schadens-Skalierung pro Welle und die Kostenkurven der Upgrades. Das Wiki dokumentiert beides nicht (siehe docs/WikiData.md). Wie teuer eine Tuning-Iteration ist, ist deshalb das entscheidende Kriterium.

Gemessen (dieses Repo, examples/perf_spike.rs als Stellvertreter für einen realen Gameplay-Crate, ~490 Zeilen; Bevy-Dependencies bereits kompiliert im gemeinsamen CARGO_TARGET_DIR):

Vorgang Zeit
Inkrementeller Rebuild --release (Datei angefasst) 9–10 s
Inkrementeller Rebuild dev 22–26 s
Prozessstart bis Fenster und Frames ~1,4 s
Edit-to-see-Loop (Release) ~11 s

Der Release-Build ist schneller als der Debug-Build — das Linken der Debug-Info dominiert. Die Annahme „Balancing braucht Release, also ist der Rebuild teuer" ist damit empirisch widerlegt.

Dagegen steht der Rest der Schleife: Eine Wellenkurve bewertet man, indem man von Welle 1 an spielt. Das kostet Minuten, nicht Sekunden. Die ~11 s Rebuild sind ein einstelliger Prozentsatz eines Tuning-Zyklus.

Was Bevy 0.19 mitbringt (geprüft über docs.rs und context7, nicht aus dem Gedächtnis):

  • Es gibt keinen eingebauten generischen Loader für strukturierte Daten (RON/TOML/JSON) für eigene Typen. Ein eigener AssetLoader ist Pflicht (Vorbild: Bevy-Beispiel custom_asset).
  • Hot-Reload erfordert das nicht-default Cargo-Feature file_watcher. AssetPlugin::watch_for_changes_override ist laut Doku ausdrücklich nicht der empfohlene Weg.
  • ron (0.12.2) und serde (1.0.229) liegen bereits als transitive Dependencies im Cargo.lock; sie direkt zu deklarieren kostet keine zusätzliche Compile-Zeit. file_watcher dagegen zieht notify neu ein und funktioniert nur auf Desktop.

Randbedingungen: Desktop nativ zuerst, Mobile und Web später (siehe Slice-Scope in CONTEXT.md) — auf Wasm gibt es keinen Dateisystem-Watcher, und Assets müssten asynchron über HTTP geladen werden. Kein Modding-Ziel, keine externen Autoren, ein Entwickler mit Toolchain.

Entscheidung

Die Balancing-Daten leben als Rust-Konstanten in einem eigenen Modul balance, das eine einzige Balance-Resource befüllt. Gameplay-Systeme lesen ausschließlich Res<Balance> und sehen die Konstanten nie.

Verbindlich:

  1. balance enthält nur Daten und Datenstrukturen — keine Gameplay-Logik, keine Bevy-Systeme.
  2. Balance ist eine Resource aus reinen Datenstrukturen, so geformt, dass sie ohne Umbau serialisierbar wäre (flache Felder, Zahlen und Vektoren, keine Handle, keine Entity). Ein serde-Derive wird jetzt nicht hinzugefügt.
  3. Genau eine Funktion befüllt Balance aus den Konstanten. Sie ist die Naht, an der ein späterer Asset-Ladepfad ansetzt.
  4. Kein Gameplay-Code liest eine Konstante aus balance direkt. Diese Regel ist die einzige Bedingung, unter der die Entscheidung ihren Wert behält.

Verworfene Alternativen

  • Konstanten verstreut im Gameplay-Code — gleiche Iterationskosten wie die gewählte Lösung, aber ein späterer Wechsel auf Assets würde jedes System anfassen, das eine Zahl liest.
  • Daten-Assets (RON) über den AssetServer mit Hot-Reload — der Preis (eigener AssetLoader, direkte serde-/ron-Deps, file_watcher mit notify, ein Lade-Zustand im Start-Ablauf, ausgelieferte Asset-Dateien plus asynchrones Laden auf Web und Mobile) kauft ~11 s pro Tuning-Zyklus, dessen teurer Teil das Spielen ist. Zusätzlich: Eine Wellenkurve lässt sich mitten im Run ohnehin nicht sinnvoll austauschen — die laufende Welle hat ihre Werte bereits vergeben, man startet den Run neu.
  • bevy_common_assets statt eigenem Loader (0.17.0 unterstützt bevy 0.19.0) — spart den Loader-Code, ändert aber nichts an den übrigen Kosten und tauscht sie gegen eine Dependency, deren Release-Takt an Bevy-Versionen hängt.
  • Konfiguration über Umgebungsvariablen (wie in examples/perf_spike.rs) — trägt für ein paar Spike-Knöpfe, nicht für Tabellen mit 50 Leveln.

Abweichung von der naheliegenden Wahl

Die übliche Antwort in der Spieleentwicklung lautet „Daten raus aus dem Code". Sie stammt aus Kontexten, die hier nicht gelten: mehrere Designer ohne Compiler, Balancing-Patches ohne Client-Update, Modding. Der Slice hat keinen davon. Wo die Zahl steht, ist deshalb nicht die Frage, die die Iterationszeit entscheidet — die entscheidende Frage ist, ob man die Zahl ohne Neustart ändern und die interessante Welle ohne Vorspielen erreichen kann. Beides adressiert kein Dateiformat, sondern ein Debug-Panel, das Balance im laufenden Prozess mutiert, plus einen Sprung-zu-Welle-Pfad. Beides setzt auf Res<Balance> auf, also auf genau die Naht, die diese ADR festlegt — und beides ist billiger als eine Asset-Pipeline.

Konsequenzen

Positiv

  • Keine neuen Dependencies, kein neues Cargo-Feature, kein Lade-Zustand im Start-Ablauf.
  • Keine Plattform-API im Spielkern — Mobile und Web erben keine Asset-Auslieferung.
  • Falsche Zahlen und fehlende Felder sind Compile-Fehler statt Laufzeit-Parse-Fehler.
  • Balancing-Werte sind in die Binary gebacken; ein Save kann nicht auf Werte verweisen, die es im aktuellen Build nicht mehr gibt.

Negativ

  • Jede Zahländerung kostet einen Rebuild (~11 s heute, wachsend mit dem Crate).
  • Nicht-Entwickler können nicht balancen. Für den Slice irrelevant, für ein späteres Ziel nicht.
  • Das Bevy-Asset-System bleibt in diesem Bereich ungelernt. Wenn das ein Lernziel ist, gehört es in ein eigenes Ticket mit einem echten Asset (z. B. Sprites), nicht in die Balancing-Daten.

Folgeaufgaben (nicht Teil dieser ADR)

  • Run-Telemetrie (Zeit bis Tod, Coins pro Welle, DPS-Verlauf) — ohne sie beschleunigt ein schnellerer Edit-Loop nur das Raten.
  • Debug-Panel und Sprung-zu-Welle-Pfad, beide auf Res<Balance>.

Revisions-Auslöser

Diese Entscheidung wird neu getroffen, sobald eines der folgenden Ereignisse eintritt:

  1. Rebuild-Schwelle: cargo build --release nach einer Änderung, die nur src/balance/ berührt, dauert wiederholt über 30 s (heute 9–10 s). Nachmessen mit derselben Methode wie oben: Datei anfassen, bauen, drei Läufe, Median.
  2. Zyklus-Schwelle: Eine protokollierte Tuning-Session enthält mehr als 30 Rebuild-Zyklen pro Stunde, deren Diff ausschließlich src/balance/ betrifft. Das ist der Punkt, an dem die 11 s messbar zur dominierenden Kostenstelle werden — vorher nicht.
  3. Scope-Änderung: Balancing-Werte müssen von jemandem geändert werden können, der keine Rust-Toolchain hat, oder ohne neuen Build ausgeliefert werden (Live-Balancing, Modding, externe Tester). Keines davon steht im Slice-Scope.

Auslöser 1 und 2 werden zuerst durch das Debug-Panel beantwortet, nicht durch Assets — es ist die billigere Antwort auf dieselbe Beobachtung. Erst wenn das Panel gebaut ist und die Schwelle weiterhin gerissen wird, ist der Wechsel auf Assets fällig. Auslöser 3 führt direkt zu Assets.