PatrickSchaeferling/spatial-timber-arcade
SpatialTimber Arcade
Browser-Arcade-Spiel auf einem trainierten FEM-Surrogate. Der Spieler platziert Wandzellen auf einem mehrgeschossigen Holz-Tragwerk; nach jeder Aktion liefert das Surrogate-Netz innerhalb von ~50 ms die vorhergesagten Spannungen und Verformungen, die als Heatmaps gerendert werden. Ziel: möglichst hoher Score durch geschicktes Setzen aussteifender Wände bei minimalem Materialeinsatz.
1. Schnellstart
1.1 Voraussetzungen prüfen
Pfade werden zentral in `backend/config.py` aufgelöst. Wenn du etwas verschiebst, dort anpassen.
1.2 Erster Lauf (einmalig)
cd C:\Users\Patrick\source\repos\SpatialTimber_DesignExplorer\spatial_timber_arcade
# Python-Pakete installieren
C:\Users\Patrick\miniforge3\envs\torch_env\python.exe -m pip install -r requirements.txt
# Level-Geometrien aus den Trainings-Shards extrahieren
# (erzeugt levels/<id>/footprint.npy + supports.npy in-distribution)
C:\Users\Patrick\miniforge3\envs\torch_env\python.exe scripts\extract_levels.pyextract_levels.py schreibt auch levels/_preview.png als visuelle Sanity-Check-Vorschau aller Levels nebeneinander.
1.3 Server starten
C:\Users\Patrick\miniforge3\envs\torch_env\python.exe -m uvicorn backend.main:app --host 127.0.0.1 --port 8765 --reloadBrowser öffnen: <http://127.0.0.1:8765>
--reload lässt uvicorn auf Backend-Änderungen reagieren. Frontend-Dateien (frontend/*.html|css|js) werden vom Static-Mount serviert; Änderungen dort brauchen nur Strg+F5 im Browser (Cache umgehen).
1.4 Server stoppen
Im Terminal mit Strg+C. Falls der Prozess hängt:
Get-NetTCPConnection -LocalPort 8765 | Select OwningProcess
Stop-Process -Id <PID> -Force1.5 Tests / Werkzeuge
C:\Users\Patrick\miniforge3\envs\torch_env\python.exe tests\test_inference_roundtrip.py
C:\Users\Patrick\miniforge3\envs\torch_env\python.exe tests\test_scoring.py
C:\Users\Patrick\miniforge3\envs\torch_env\python.exe scripts\calibrate_score.py
C:\Users\Patrick\miniforge3\envs\torch_env\python.exe scripts\inspect_channels.py2. Spielanleitung
2.1 Hauptbildschirm-Layout
┌────────────────────────────────────────────────────────────────┐
│ HEADER: Level · Mode · Status · WALLS/UNDO/JOKER-Badges │
├──────────┬───────────┬───────────────────┬─────────────────────┤
│ AUSN. │ AUSN. │ BAUEN │ VERFORMUNG DECKEN │
│ DECKEN │ WÄNDE │ (große Iso) │ (Mini-Stack) │
│ (Mini) │ (Mini, │ │ │
│ │ klickbar │ │ │
│ │ zum │ │ │
│ │ Geschoss-│ │ │
│ │ wählen) │ │ │
├──────────┼───────────┴───────────────────┼─────────────────────┤
│ TOOLS │ UTIL-SCORE · BONUS · DISP │ GAME OPTIONS │
│ (WALLS, │ (3 Karten) + TOTAL-SCORE │ FINISH · RETRY │
│ REMOVE, │ (gelb hervorgehoben) │ QUIT · DEBUG │
│ JOKER) │ │ │
└──────────┴───────────────────────────────┴─────────────────────┘- AUSN. WÄNDE (Mini-Stack 2. Spalte): zeigt alle Geschosse als isometrische Stapel. Klick wählt das aktive Geschoss aus.
- BAUEN (große Iso): zeigt das aktive Geschoss vergrößert. Hier wird per Maus gezeichnet. Wandkanten erscheinen orange beim Hover, blau wenn gesetzt, blau-fett wenn vom Joker vorgeschlagen.
- AUSN. DECKEN und VERFORMUNG DECKEN (Mini-Stacks links/rechts): Heatmaps pro Decke. Aktualisieren live nach jeder Aktion (außer Hölle-Mode).
- Score-Karten: UTILIZATION =
1 / max_stress, DISPLACEMENT =δ_lim / max_disp, BONUS = Belohnung für Restbudget (nur aktiv wenn beide Sub-Scores > 1). TOTAL =Util · Disp · Bonus · Difficulty.
2.2 Wände setzen — Edge-Click + Drag
- Linksklick auf eine Kante zwischen zwei Footprint-Zellen: setzt eine Wand. Rechtsklick entfernt sie und kostet 1 Undo-Token.
- Klick + Halten + Bewegen zeichnet eine Wandlinie. Beim ersten gesetzten Edge wird die Achse (X oder Y) und Reihe/Spalte gesperrt — du ziehst eine saubere gerade Linie, ohne dass die Maus exakt auf einer Achse bleiben muss.
- Während des Drags wird kein Predict ausgelöst. Erst beim Loslassen geht ein Request an
/api/predictmit dem kompletten neuen Wandsatz.
2.3 Hotkeys
2.4 Joker-Mechanik
Joker-Knopf (Tools-Spalte) oder J schickt den aktuellen Bauzustand an /api/joker. Backend macht einen differenzierbaren Forward Pass mit allen Wand-Kanälen als requires_grad=True, leitet score.backward() durch und liefert das argmax über den Gradienten als nächste empfohlene Wand-Zelle (Geschoss, Orientierung, Cell). Frontend springt aufs richtige Geschoss und hebt die vorgeschlagene Kante visuell hervor. Du musst die vorgeschlagene Wand selbst setzen — der Joker entscheidet nur den Tipp, nicht die Aktion.
Joker sind nur im Normal-Mode verfügbar.
2.5 Schwierigkeitsmodi
Im Hölle-Mode bleibt die Heatmap statisch auf "???". Der /api/predict-Call wird während der Platzierung nicht ausgelöst — erst Finish triggert einen einzigen Predict mit voller Reveal-Animation und Score-Aufschlüsselung.
3. Score-Formel
Die Bewertung ist multiplikativ:
score_stress = STRESS_LIMIT / max(max_stress, eps) # 1.0 bei genau Limit
score_disp = disp_ref / max(max_disp, eps) # 1.0 bei genau Limit
bonus = walls_unused·10 + jokers_left·50 # nur wenn beide > 1
total = round(score_stress · score_disp · bonus)
final = total · difficulty_multiplierCharakteristik:
- Sub-Scores ohne Hard-Cap: ein Tragwerk mit
max_stress = 0.5bekommtscore_stress = 2.0— das wird mitscore_dispmultipliziert, also lohnt sich Ausnutzung unter dem Limit überproportional. - Bonus aktiviert nur bei Erfolg auf beiden Achsen (
stress > 1unddisp > 1). Wenn eines der Limits gerissen ist →bonus = 0→total = 0. Das ergibt einen klaren Failure-Mode: ein knappes Versagen ist sofort sichtbar (Score springt auf 0). - Difficulty-Multiplier: Normal 1.0, Alptraum 1.5, Hölle 2.5.
disp_ref wird pro Level dynamisch gesetzt: bbox_height_in_cells · 0.625 / 300 (in Metern; entspricht L/300 als typisches SLS-Verformungslimit für die y-Spannweite). STRESS_LIMIT = 1.0 und alle Score-Konstanten in `backend/scoring.py`.
4. Architektur
spatial_timber_arcade/
├─ backend/
│ ├─ config.py Pfade (Modell, Scaler, Shards) + Konstanten
│ ├─ inference.py Modell-Wrapper: predict() (no_grad) + joker_gradient()
│ ├─ scoring.py compute() (numpy) + compute_torch() (differenzierbar)
│ ├─ levels.py LevelDef-Katalog + load_geometry() + Footprint-Zentrierung
│ ├─ schemas.py Pydantic Request/Response-Modelle
│ ├─ db.py SQLite-Scoreboard (CRUD)
│ └─ main.py FastAPI-App + Endpunkte + Static-Mount
├─ frontend/
│ ├─ index.html SPA-Shell (Header, 4-Spalten-Grid, End/Start/Scoreboard-Panels)
│ ├─ style.css 80er-CRT-Look (Neon, Pixel-Font, Scanlines, Score-Cards)
│ └─ game.js Canvas-Renderer, State, API-Client, FX-System
├─ levels/
│ └─ <id>/footprint.npy + supports.npy (32×96 binär, in-distribution)
├─ scripts/
│ ├─ extract_levels.py Generiert Level-NPYs aus den Trainings-Shards
│ ├─ inspect_channels.py 17-Kanal-Visualisierung eines Shard-Samples
│ └─ calibrate_score.py Score-Verteilung über echte Samples (Histogramm)
├─ tests/
│ ├─ test_inference_roundtrip.py Wrapper bit-identisch zur Original-Pipeline
│ └─ test_scoring.py Unit-Tests für Score-Formel
├─ scores.db SQLite-Datei (lokal, .gitignore)
├─ requirements.txt
└─ README.md (diese Datei)4.1 Datenfluss pro User-Aktion (Live-Mode)
- Mausdrag im BAUEN-Canvas →
applyDragAt(edge)→placeWall(...)mitskipPredict=true. State (state.walls) wird aktualisiert,updateHudundredrawAllsofort lokal — kein Backend-Call. - Mouseup →
endDrag()setztstate.pendingFx = {x, y}(für Sparks/Glow) und ruftcallPredict(). - `POST /api/predict` — Body enthält
level_id,walls,difficulty. - Backend:
inference.build_input_tensorbaut den 17-Kanal-Tensor (Decken- Kanäle aus Level + Wand-Kanäle aus Player-Walls), hängt GPS und Proximity an → 20 Kanäle.predict()Forward Pass imno_grad, Output-Inverse-Transform mit dem geladenen Y-Scaler._split_outputstrennt 22 Output-Kanäle inwall_stress(16),deck_stress(n+1),deck_displacement(n+1).scoring.computeberechnet das ScoreBreakdown.- Response → Frontend:
state.lastResp = resptriggerScoreFx(resp.score)spawnt Sparks (pro Score-Delta) und Glow (gelb/rot/cyan je nach Total-Delta) an der eingefrorenen Klick-Position.updateHud()startet Tween-Animationen für die 4 Score-Zahlen.redrawAll()zeichnet alle 4 Iso-Canvases neu.
4.2 Backend-Endpunkte
5. Modell-Eingabe (Kanal-Layout)
20 Eingabe-Kanäle = 17 Roh + 2 GPS + 1 Proximity-Map. Die 17 Roh-Kanäle (verifiziert via inspect_channels.py):
Wichtig: Wände existieren pro Geschoss × Orientierung in eigenen Kanälen — das ist nicht eine einzige binäre Maske, sondern 10 Wand-Kanäle (5 Geschosse × 2 Orientierungen). Die Game-UX (Floor-Switcher + X/Y-Edge-Pick) ist direkt diese Modell-Struktur.
GPS- und Proximity-Map-Berechnung übernimmt 1:1 den Code aus jenga_dataset.py:266-314 aus dem Trainings-Repo, damit Player-Inputs exakt in derselben Distribution liegen wie die Trainingsdaten.
Output: 22 Kanäle — Ch 0–15 sind Spannungen der 16 Bauteile (10 Wände
- 6 Decken), Ch 16–21 sind Verformungen der 6 Decken. Räumlich 32×96 (Cell-Convention; eine Zelle = 0.625 m).
6. Levels
6.1 Aktueller Katalog
Definiert in `backend/levels.py` (CATALOG-Liste). Geometrien (Footprint + Auflager) liegen pro ID als NPY-Dateien unter levels/<id>/.
6.2 Eigenes Level anlegen
- Geometrie erzeugen (in einer Python-Session mit der
torch_env):
import numpy as np, os
d = r"spatial_timber_arcade\levels\06_meinlevel"
os.makedirs(d, exist_ok=True)
fp = np.zeros((32, 96), dtype=np.float32)
sp = np.zeros((32, 96), dtype=np.float32)
fp[1:7, 1:7] = 1.0 # 6x6 Footprint
for r, c in [(1,1),(1,7),(7,1),(7,7)]: # Auflager an den Ecken
sp[r, c] = 1.0
np.save(os.path.join(d, "footprint.npy"), fp)
np.save(os.path.join(d, "supports.npy"), sp)Konventionen:
- Footprint:
1.0für Zellen, die zum Geschoss-Footprint gehören (cell-convention, Cell(r,c)belegt das Quadrat[r,r+1]×[c,c+1]). - Supports:
1.0für Auflager-Punkte (vertex-convention, Punkt(r,c)ist die Ecke). Typischerweise an den 4 Außenecken des Footprint-Bbox:(rmin, cmin),(rmin, cmax+1),(rmax+1, cmin),(rmax+1, cmax+1). - Backend zentriert Footprint+Supports automatisch im 32×96 Grid (
levels._center_in_grid), damit das Modell in-distribution arbeitet (es wurde auf zentriert platzierten Bauten trainiert).
- `LevelDef` in `backend/levels.py` ergänzen:
LevelDef(
id="06_meinlevel",
name="Meinlevel",
description="6x6 Sandbox.",
n_active_floors=3, # 1..5
cell_budget=100,
undo_budget=100,
joker_count=20,
target_score=500,
),- Backend neu starten (uvicorn
--reloadreicht für reine Code- Änderungen; neu angelegte NPY-Dateien werden beim ersten/api/levels- Call gelesen und sind dann vialru_cachepersistent).
6.3 Tipps für sinnvolle Level
- In-distribution bleiben: Footprints sollten kompakt + zusammenhängend sein (das Trainings-Set besteht aus rechteckigen / wenig fragmentierten Grundrissen). Frei zerstreute Inseln liefern unzuverlässige Modell-Vorhersagen.
- Auflager an Ecken oder mit Symmetrie: zentral platzierte Auflager (Pinwheel-Pattern, Eckpattern) sind häufig in den Trainingsdaten.
- Footprint < 25×60: bleibt komfortabel im 32×96-Grid auch nach Zentrierung, ohne die Ränder zu touchieren.
7. Frontend-FX (Sparks · Glow · Tweens)
Alle Animationen laufen über einen einzigen requestAnimationFrame-Loop (fxStep) auf dem Vollbild-Canvas #fx-overlay. Die Loop startet selbständig wenn etwas zu animieren ist und beendet sich, sobald alle Listen leer sind (fx.sparks, fx.glows, fx.tweens).
7.1 Sparks
Bei jedem Predict-Resolve fliegen Funken von der eingefrorenen Klick-Position zu den vier Score-Karten (final, util, disp, bonus). Pro Score wird ein eigener Spawn ausgelöst:
- Anzahl skaliert mit dem Betrag des Score-Deltas (siehe
sparkCountFor— Total-Score/5, Util/Disp×30, Bonus/5, Cap 100 pro Ziel). - Farbe = gelb (
#fff060) bei positivem Delta, rot (#ff3a3a) bei negativem. - Trajektorie in zwei Phasen: (1) radialer Burst nach außen zu einem Kontrollpunkt, (2) Anflug Richtung Score-Karte. Smoothstep blendet zwischen beiden Phasen, damit kein sichtbarer Knick entsteht.
- Burst-Reichweite wird per
radiusScalemit|Δfinal|/200skaliert — bei großem Delta fliegen die Funken weiter raus, bevor sie zum Score abdrehen.
7.2 Glow
Pro Predict-Resolve einmal — radialer Farbverlauf, der von der Klick-Position nach außen expandiert und ausklingt:
- Farbe & Intensität koppeln an
Δfinal: gelb wenn Score steigt, rot wenn er fällt, cyan-Baseline wenn unverändert (damit jede Aktion visuell quittiert wird). - Radius linear an Δ: bei
Δ=200ist der Peak-Radius doppelt so groß wie der Standard (260 → 520 px). Kein Hard-Cap. - Lebenszeit wächst leicht mit (600–1200 ms), damit größere Explosionen Zeit zum Ausklingen haben.
- Alpha ist auf 1 geklemmt, damit nichts überstrahlt.
7.3 Score-Tweens
tweenNumber(elemId, from, to, fmt, dur) animiert die Anzeige der vier Score-Werte (#sb-score-num, #ss-util-value, #ss-disp-value, #ss-bonus-value) über 700 ms. Wichtig:
- Jeder Aufruf entfernt vorab einen evtl. noch laufenden Tween für dieselbe ID — sonst überschreibt der alte rAF-Frame das frische
textContent. - Wenn
|to - from| < 0.001wird ohne Tween direkt gesetzt. - Quelle ist
fx.prevScore(alter Stand) →sc(neuer Stand).prevScorewird nach den Tween-Aufrufen aktualisiert, damit die Deltas auch intriggerScoreFxkorrekt berechnet werden.
8. Troubleshooting
9. Deployment auf Hugging Face Spaces
Das Repo enthaelt alles fuer ein Docker-basiertes HF Space:
Dockerfile(CPU-only Torch, Port 7860).gitattributes(Git LFS fuermodels/*.pth+models/*.pkl)- YAML-Frontmatter in dieser README (
sdk: docker, app_port: 7860) backend/vendor/— vendoredmodel.py+scaler.pyaus dem FEM_surrogate-Repo, damit der Container keinen externen Repo-Pfad brauchtbackend/config.py— Pfade ueber Env-VarsMODEL_PATH/SCALER_PATHueberschreibbar (Dockerfile setzt sie auf/app/models/*)
9.1 Lokal das Image testen
cd C:\Users\Patrick\source\repos\SpatialTimber_DesignExplorer\spatial_timber_arcade
docker build -t spatial-timber .
docker run -p 7860:7860 spatial-timber
# Browser: http://localhost:7860Wenn das laeuft, ist HF nur noch ein git push.
9.2 Auf HF pushen
- Space anlegen: huggingface.co → New Space → SDK: Docker → Name z.B.
spatial-timber-arcade. - LFS lokal aktivieren (einmalig):
git lfs install- Remote hinzufuegen + pushen:
git init # falls noch nicht
git add .gitattributes # WICHTIG: vor den LFS-Dateien adden
git add .
git commit -m "Initial HF deploy"
git remote add hf https://huggingface.co/spaces/<dein-user>/spatial-timber-arcade
git push hf main- Beim ersten Push fragt's nach HF-Token (huggingface.co/settings/tokens, Scope: "write").
HF baut das Image automatisch (~3-5 min beim Erststart) und startet den Container. Logs sind im Space-UI unter "Logs".
9.3 Hardware-Auswahl
Im Space-UI unter Settings → Hardware:
9.4 Limitationen Free Tier
- `scores.db` ist fluechtig — verschwindet bei Container-Restart. Optionen: Persistent Storage zubuchen (~$5/Monat) oder auf externe DB (z.B. Supabase) auslagern.
- Sleep nach ~48 h Idle — Cold-Start beim naechsten Aufruf ~30 s.
- Space ist public — privater Space kostet.
10. Was als nächstes ansteht
- Polish + Score-Konstanten nach 2. Spieltest tunen (
backend/scoring.py:BONUS_PER_WALL,BONUS_PER_JOKER, evtl.STRESS_LIMIT-Skalierung). - Optional: Sound-Effekte, Mobile-Touch-Support, Multi-Language.
Out-of-scope (ausdrücklich nicht geplant): Authentifizierung, Account-System, Live-Multiplayer, In-Game-Level-Editor.
