Account & Persistence Layer (server/db.mjs)
- SQLite via node:sqlite DatabaseSync with WAL mode, foreign keys, and
synchronous=NORMAL for microsecond response times
- Users table: UUID primary key, unique lowercase username, scrypt-hashed
password, optional OIDC sub/issuer, crystal balance, timestamps
- Sessions table: 64-byte random hex token, FK to users, configurable TTL
with automatic expiry cleanup every 10 minutes
- User upgrades table: composite PK (user_id, upgrade_id), level tracking,
ON CONFLICT DO UPDATE for idempotent merges
- User stats table: games played/won, total kills/score, highest wave
- Password hashing: crypto.scrypt with 16-byte salt and 64-byte derived
key, constant-time comparison via crypto.timingSafeEqual
- Guest merge: caps crystals at 100,000, caps levels to defined maxLevels,
uses MAX(level, new) to preserve higher account levels, ignores unknown
upgrade IDs, transactional with BEGIN IMMEDIATE/COMMIT/ROLLBACK
- Atomic crystal purchasing: subtracts cost only if balance sufficient,
upgrades level within transaction, returns full user object on success
Meta-Progression Definitions (shared/meta-upgrades.mjs)
- Single source of truth shared between server (authoritative validation)
and client (talent tree UI)
- 3 branches: Economy (start_gold, wave_bonus, obstacle_discount),
Defense (bonus_lives, shockwave, fortress_shield),
Towers (tower_range, tower_speed, dot_potency)
- 9 upgrades with 1–5 levels each, progressive cost curves
- calcCrystalsEarned(wave, score, win): base 2.5 per wave, +40 for victory,
+floor(score/250), minimum 1 crystal per game
REST API (server/server.mjs)
- /api/auth/register: username 3–16 chars a-z0-9_-; password min 8 chars;
case-insensitive uniqueness; auto-creates user_stats row; returns
session cookie (HttpOnly, SameSite=Lax, Secure when HTTPS)
- /api/auth/login: constant-time username lookup via scrypt verify;
rate-limited 20 auth attempts/IP/minute
- /api/auth/logout: deletes session server-side, clears cookie
- /api/auth/me: returns publicUser (id, username, crystals, upgrades,
stats, oidc flag) or null
- /api/auth/oidc/login: PKCE Authorization Code flow with SHA-256 S256
challenge/verifier; discovers .well-known/openid-configuration;
verifies RS256 id_token signature via JWKS public key; validates
issuer, audience, and expiry; finds or creates user by OIDC sub/issuer
- /api/auth/oidc/callback: exchanges code for tokens, verifies id_token,
issues session cookie, redirects to /?auth=ok or /?auth=error
- /api/upgrades/buy: validates upgrade ID against META_UPGRADES,
checks current level < maxLevel, deducts cost from crystals
- /api/game/finish: server-authoritative crystal calculation;
bounds-checks inputs (wave ≤ 9999, score ≤ 10M, kills ≤ 1M);
updates user_stats (games_played, games_won, total_kills, total_score,
highest_wave via MAX)
- /api/auth/merge-guest: one-time guest-to-account crystal and upgrade
migration with level caps
- /api/config: public endpoint exposing OIDC enabled state and button label
- Security: CSP header on all responses, X-Content-Type-Options: nosniff,
X-Frame-Options: DENY, Referrer-Policy: no-referrer, cache-control
no-store on API responses, path-traversal protection on static serving
Multiplayer Fairness
- Meta-upgrades (start_gold, bonus_lives, tower_range, tower_speed,
dot_potency, wave_bonus, obstacle_discount, shockwave, fortress_shield)
applied only in solo campaign mode
- Co-op and Duel multiplayer sessions reset all meta buffs to zero,
preserving lockstep determinism and competitive balance
- Multiplayer results set crystalsEarned: 0 to prevent duplicate rewards
Frontend (Vue 3 + TypeScript)
- AuthModal.vue: username/password login and registration form with
validation, OIDC single sign-on button (shown when configured),
guest-to-account upgrade on first login
- UserProfileBar.vue: top-bar indicator showing crystal count (💎),
user display name, research and login/logout buttons, reactive
auth state via auth controller
- ResearchTree.vue: interactive talent tree modal with 3 branches,
per-upgrade cost/level display, purchase confirmation, disabled
state for unaffordable/maxed upgrades, branch icons and descriptions
- auth.ts: reactive controller managing login, registration, OIDC
redirect detection (?auth=ok/?auth=error), guest profile migration
on first login, upgrade purchasing, and game result reporting
- meta.ts: frontend helpers for branch definitions, upgrade costs,
and guest profile persistence in localStorage
- engine.ts: solo meta bonus application (start_gold, bonus_lives,
tower_range, tower_speed, dot_potency, wave_bonus, obstacle_discount,
shockwave, fortress_shield); crystal rewards in finish() path
- mpgame.ts: meta buff reset in multiplayer sessions; crystalsEarned: 0
- sound.ts: shield sound synthesis for fortress_shield absorption
- store.ts: auth state, research tree toggle, upgrade snapshot
- types.ts: SfxName extended with "shield" sound
Docker Configuration
- Multi-stage build: node:22-alpine build → node:22-alpine runtime
(production deps only: ws)
- VOLUME /app/data for persistent SQLite database
- HEALTHCHECK on /health endpoint
- docker-compose.yml: port 3001, persistent ./data volume,
commented OIDC environment variables (OIDC_ENABLED, OIDC_ISSUER,
OIDC_CLIENT_ID, OIDC_CLIENT_SECRET, OIDC_REDIRECT_URI, OIDC_BUTTON_LABEL)
- .gitignore: data/, *.db, *.db-journal, *.db-wal, *.db-shm
Automated Tests (npm test)
- scripts/test-db.mjs (17 unit tests): isolated SQLite persistence –
scrypt hash/verify roundtrip, timing-safe constant-time comparison,
user creation with lowercase enforcement, UNIQUE constraint, session
create/get/delete lifecycle, expired session invalidation, crystal
addition, 5-level upgrade cost progression with max-level guard,
guest merge (crystal cap 100k, level cap, MAX() semantics, unknown
upgrade rejection), game result stats accumulation, calcCrystalsEarned
formula verification
- scripts/test-auth.mjs (31 integration tests): spawns real server with
isolated DATA_DIR, exercises full REST flow – register validation
(username too short, password too short, duplicate, case-insensitive),
login (wrong password, correct), session cookie attributes (HttpOnly,
SameSite=Lax, Path=/), /me endpoint, upgrade purchase (insufficient
crystals, unknown ID, successful purchase), game finish rewards
(victory, defeat, negative values clamped to 1 crystal minimum),
guest merge (crystals, level caps, unknown upgrades), logout,
OIDC-disabled endpoints (400), unauthenticated guards (401), 404
routing, CSP header on static files, rate limiting (429 after 20+
auth attempts per minute), and persistence across server restart
(kill + respawn with same DATA_DIR preserves all state)
|
||
|---|---|---|
| scripts | ||
| server | ||
| shared | ||
| src | ||
| .dockerignore | ||
| .gitignore | ||
| docker-compose.yml | ||
| Dockerfile | ||
| index.html | ||
| package-lock.json | ||
| package.json | ||
| README.md | ||
| tsconfig.json | ||
| vite.config.ts | ||
TRXTD – Tower Defense im Browser
Ein vollständiges Tower-Defense-Spiel, gebaut mit Vue 3 + TypeScript + Vite. Die Spiellogik läuft in reinem TypeScript auf einem HTML-Canvas (60 FPS), Vue kümmert sich um HUD, Menüs und Panels.
Enthält Solo-Kampagne, 2-Spieler-Coop und 1v1-Duell (Online/Lokal per WebSocket).
Schnellstart
-
Abhängigkeiten installieren:
npm install -
Frontend (Vite) und Multiplayer-Server starten:
# Terminal 1: Dev-Server npm run dev # → http://localhost:5173 # Terminal 2: Multiplayer-Relay (nur für Coop / 1v1 nötig) npm run server # → ws://localhost:3001 -
Browser öffnen unter
http://localhost:5173.
🐳 Docker
Das Spiel gibt es als All-in-One-Container: Frontend (statisch) und Multiplayer-Server teilen sich einen Port – ideal für Self-Hosting.
# Image bauen (Multi-Stage-Build: Build → schlankes Runtime-Image)
docker build -t trxtd:latest .
# Container starten
docker run -d --name trxtd -p 3001:3001 trxtd:latest
# Oder mit Docker Compose (empfohlen)
docker compose up -d
Danach läuft das komplette Spiel inkl. Online-Multiplayer unter http://localhost:3001.
Enthaltene Härtung:
- Multi-Stage-Build (kein Build-Tooling im Runtime-Image)
- Läuft als unprivilegierter Nutzer (
node) HEALTHCHECKüber/health/assets/mit immutablen Cache-Headern, SPA-Fallback für alle anderen Routen- WebSocket-Sicherheit: Payload-Limit (4 KB), Rate-Limiting (30 Aktionen/s), Schema-Validierung, Ping/Pong-Heartbeat
Nützliche Umgebungsvariablen:
| Variable | Default | Bedeutung |
|---|---|---|
PORT |
3001 |
HTTP- und WebSocket-Port |
DIST_DIR |
./dist |
Ordner mit den statischen Dateien |
ALLOWED_ORIGINS |
– | Zusätzlich erlaubte WebSocket-Origins (Komma-Liste, z. B. https://spiel.example.com) |
Öffentliches Hosting hinter Reverse Proxy (Nginx Proxy Manager / NPMplus):
- Container nur im internen Netz erreichbar machen (Port 3001 nicht am Router freigeben!), z. B.:
# docker-compose.yml — gemeinsames Netzwerk mit dem Proxy services: trxtd: build: . restart: unless-stopped expose: ["3001"] # intern, kein ports:-Mapping nach außen # networks: proxy_net ... - In NPM/NPMplus einen Proxy Host anlegen:
- Domain:
spiel.example.com(DNS-A-Record auf den Server) - Scheme:
http· Forward Hostname: Containername (gemeinsames Docker-Netz) oder Server-LAN-IP · Port:3001 - Websockets Support: ✔ aktivieren (zwingend erforderlich für Multiplayer!)
- Block Common Exploits: ✔ · Cache Assets: ✘ (der Server setzt eigene Cache-Header)
- SSL-Tab: Let's-Encrypt-Zertifikat anfordern, Force SSL + HTTP/2 aktivieren
- Domain:
- Fertig – das Spiel (inkl. Coop/1v1 über
wss://) läuft unterhttps://spiel.example.com.
Serverseitige Härtung (aktiv): CSP-/Frame-/Referrer-Header · Origin-Check gegen Cross-Site-WebSocket-Hijacking · X-Forwarded-For-Auswertung nur aus privaten Proxy-Netzen · max. 500 WebSocket-Verbindungen total / 20 pro IP · max. 300 Räume · Payload-Limit 4 KB · Rate-Limit 30 Aktionen/s · Schema-Validierung · Ping/Pong-Heartbeat · Raum-Timeout 30 min.
Manuell ohne Docker im Produktionsmodus starten:
npm run build
npm run server # serves dist/ + ws auf :3001
Spielmodi
1. Solo-Kampagne
Verteidige deine Basis gegen 20 handgetunte Wellen (Leicht: 30 Leben, Normal: 20 Leben, Schwer: 12 Leben). Nach Welle 20 geht es auf Wunsch im Endlos-Modus weiter.
2. 🤝 2-Spieler-Coop
- Gemeinsame Basis & gemeinsames Gold – Sprecht euch ab!
- Eigene Türme: Deine Türme haben einen blauen Ring, die deines Partners einen orangenen Ring (jeder kann nur seine eigenen Türme upgraden oder verkaufen).
- Gegner haben 70 % mehr Leben, um der doppelten Feuerkraft standzuhalten.
- Wellen starten automatisch nach Countdown oder per Klick auf „Nächste Welle" (beide können klicken).
3. ⚔ 1v1-Duell (Tower-Battles-Prinzip)
- Jeder Spieler verteidigt sein eigenes Spielfeld.
- Über ein Live-Miniaturfenster (PiP) siehst du in Echtzeit das Spielfeld deines Gegners mit allen Türmen und Gegnern.
- Rush-Mechanik: Schicke gegen Gold (
60 🪙 + 8 🪙/Welle) eine Welle schneller Läufer auf die Basis des Gegners! - Siegbedingungen:
- Basis des Gegners fällt auf 0 Leben → Sofortsieg!
- Nach 20 Wellen gewinnt, wer mehr Leben übrig hat (bei Gleichstand entscheiden die Punkte).
Multiplayer-Raum-System
- Klicke auf dem Startbildschirm auf „Coop-Raum erstellen" oder „1v1-Raum erstellen".
- Gib deinem Mitspieler den 4-stelligen Raum-Code (z. B.
5SK7). - Der Mitspieler tippt seinen Namen + den Code ein und klickt auf „Beitreten".
- Der Host startet das Spiel, sobald beide in der Lobby sind.
- Funktioniert im lokalen Netzwerk (LAN / WLAN) – für Freunde über das Internet einfach Port 3001 freigeben oder über ngrok/Tailscale leiten.
🌟 9-Level-Evolutionssystem
Jeder Turm hat 9 Ausbaustufen, aufgeteilt in 3 Evolutions-Stufen:
| Tier | Level | Farbe | Bedeutung |
|---|---|---|---|
| Basis | 1–3 | 🟡 Gelb | Höherer Grundschaden, Reichweite & Feuerrate |
| Evolution I | 4–6 | 🟠 Orange | Neue Spezialfähigkeit schaltet sich frei |
| Evolution II | 7–9 | 🔵 Blau | Finale Evolutionsstufe mit verheerenden Effekten |
Die Evolutionen im Detail
| Turm | 🟡 Basis (L1–3) | 🟠 Evolution I (L4–6) | 🔵 Evolution II (L7–9) |
|---|---|---|---|
| 🏹 Bogenturm | Schneller Einzelschuss | Mehrfachschuss: Feuert 2–3 Pfeile gleichzeitig auf verschiedene Ziele | Giftpfeile + 4-Fach: Bis zu 4 Pfeile + starker Gift-DoT (28 Schaden/s) |
| 🧨 Kanone | Flächenschaden | Brandgeschosse: Hinterlässt brennenden DoT an allen getroffenen Gegnern | Flak-Geschütz: Trifft ab L7 auch Flugeinheiten mit voller Explosionskraft! |
| ❄️ Eisturm | Verlangsamender Puls | Permafrost: Jeder 3. Puls friert alle Gegner komplett ein (Bewegung = 0) | Zerbrechlichkeit: Eingefrorene Ziele nehmen +50 % Schaden aus ALLEN Quellen! |
| ⚡ Teslaturm | Kettenblitz | Betäubungs-Schock: Kettenblitze betäuben getroffene Ziele kurzzeitig | Gewittersturm: Jeder 4. Schuss schlägt bei ALLEN Gegnern in Reichweite gleichzeitig ein! |
| 💫 Laserturm | Soforttreffer | Prisma-Brechung: Der Laserstrahl bricht und springt auf 2–3 Nebenziele über | Durchschlag: Der Strahl durchdringt ALLES in einer Linie bis zum Rand der Reichweite |
Türme ab Level 4 erhalten im Spiel eine pulsierende Evolutions-Aura (orange ab L4, blau ab L7) und die Sterne im Turm-Panel zeigen die jeweilige Tier-Farbe.
Gegner
- 🟢 Kriecher: Standard-Einheit, gut für frühes Gold
- 🟡 Läufer: Sehr schnell – Bogentürme oder Eisturm nötig
- ⬜ Panzer: Zäh, kostet 2 Leben bei Durchbruch
- 🟣 Flieger: Ignorieren den Weg und fliegen auf halber Höhe geradeaus (nur Bogenturm, Eisturm, Tesla, Laser!)
- 🔴 Boss: Riesige Lebensleiste, kostet 5 Leben (Wellen 10, 15, 20 …)
Hindernisse
Bäume (60 🪙) und Felsen (40 🪙) blockieren strategisch wichtige Bauplätze.
- Klick auf das Hindernis → „⛏ Entfernen" öffnet sich oben rechts.
- Nach dem Entfernen wird das Feld sofort bebaubar.
Steuerung
- 1–5 – Turm zum Bauen auswählen, Klick auf freies Feld platziert ihn
- Klick auf Turm – Details, Upgrade, Verkauf, Zielmodus
- Klick auf Hindernis – Entfernen gegen Gold
- Leertaste – nächste Welle früh starten (Frühstart-Bonus!) / Pause (nur Solo)
- U Upgrade · V Verkaufen · T Zielmodus wechseln
- P Pause (nur Solo) · M Ton an/aus · Esc Abbrechen
- Rechte Maustaste – Bauauswahl aufheben
Technische Details
- Client-Side Simulation: Determinister Lockstep-Tick-Loop (30 Hz) – beide Clients berechnen den exakten Spielverlauf parallel; der Server (
server/server.mjs) vermittelt nur die Räume und leitet Aktionen weiter (minimaler Traffic, kein Lag bei der Bewegung). - Renderer: Offscreen-gepufferter HTML-Canvas mit dynamischer Vollbild-Skalierung, Retina-DPR-Support, Partikelsystem und Mündungsfeuer.
- Sound: Web Audio API mit synthetisierten Klängen (keine externen Audio-Dateien).
- Automatisierte Tests:
npm run sim– Headless-Bot spielt die Solo-Kampagne zur Balancing-Prüfung durchnpx tsx scripts/test-mp.mts– Lockstep-Determinismus-Test (3600 Ticks, byte-identischer Zustand auf beiden Seiten)npx tsx scripts/test-obstacle.mts– Feature-Test: Hindernis-Entfernung