From a05e79f0d1304b37cdad8e3b6bfd0b01d495a8e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Untersch=C3=BCtz?= Date: Sun, 30 Nov 2025 12:33:04 +0100 Subject: [PATCH 1/5] add hot Chunk reload --- README.md | 194 +++++++++++++++++++++++--------------------------- config.go | 46 +++++++----- simulation.go | 2 + websocket.go | 5 +- 4 files changed, 124 insertions(+), 123 deletions(-) diff --git a/README.md b/README.md index ed10123..cfe9b35 100644 --- a/README.md +++ b/README.md @@ -1,146 +1,132 @@ # **🏃 Escape the Teacher** -**A server-authoritative 2D endless runner built with Go, Redis, and JavaScript.** +**A high-performance, server-authoritative 2D endless runner built with Go, Redis, and WebSockets.** ## **📖 About the Project** -"Escape the Teacher" is a high-performance web game developed as a school project. You play as a student caught cheating, running away from an angry teacher. The game features increasing difficulty, power-ups, boss phases, and a competitive global leaderboard. -Unlike typical browser games, this project implements **anti-cheat architecture** usually found in multiplayer RTS or FPS games. The browser does not decide if you survived; the server does. +"Escape the Teacher" is a web game developed as a school project. You play as a student caught cheating, running away from an angry teacher. The game features increasing difficulty, power-ups, boss phases, and a competitive global leaderboard. + +Unlike typical browser games, this project implements **competitive multiplayer architecture** usually found in games like *Agar.io* or FPS titles. The browser is just a "dumb" terminal; the server simulates the entire world. ## **✹ Features** ### **🎼 Gameplay** -* **Endless Progression:** The game speeds up over time. -* **Controls:** - * **Jump:** Space / Arrow Up / Tap / Left Click. - * **Crouch:** Arrow Down / Swipe Down (Mobile). -* **Power-Ups:** - * đŸ›Ąïž **Godmode:** Survives 3 hits. - * ⚟ **Baseball Bat:** Eliminates the next teacher obstacle. - * 👟 **Jumpboots:** Grants higher jumping power for a limited time. - * 💰 **Coins:** Bonus points (visual score only, does not affect game speed). -* **Dynamic Backgrounds:** Environment changes as you progress. -* **Boss Phases:** Every 1500 ticks, special boss enemies spawn. - -### **đŸ“± Mobile First** - -* Fully responsive Canvas rendering (Letterboxing). -* Touch gestures (Swipe to crouch). -* "Rotate Device" enforcement for optimal gameplay. +* **Endless Progression:** The game speeds up over time. +* **Controls:** + * **Jump:** Space / Arrow Up / Tap / Left Click. + * **Crouch:** Arrow Down / Swipe Down (Mobile). +* **Power-Ups:** + * đŸ›Ąïž **Godmode:** Survives 3 hits. + * ⚟ **Baseball Bat:** Eliminates the next teacher obstacle. + * 👟 **Jumpboots:** Grants higher jumping power. + * 💰 **Coins:** Bonus points. +* **Level Editor:** Custom chunks (platforms, enemies) can be designed in a secure Admin UI and are streamed into the game live. +* **Juice:** Particle effects and retro sound system. ### **đŸ›Ąïž Security & Admin** -* **Admin Panel:** Password-protected (/admin) interface. -* **Moderation:** Review and delete leaderboard entries. -* **Badword Filter:** Automatic blocking of inappropriate names using Redis Sets. -* **Proof System:** Players receive an 8-character "Claim Code" to prove their high score to the teacher. +* **Server-Authoritative:** Physics runs on the server. Cheating (speedhack, godmode) is impossible. +* **Admin Panel:** Create levels, manage badwords, and moderate the leaderboard. +* **Proof System:** Players receive an 8-character "Claim Code" to prove their high score offline. ## **đŸ—ïž Technical Architecture** -The core philosophy is **"Server-Authoritative, Client-Predicted"**. +We moved from a traditional HTTP-Request model to a **Realtime Streaming Architecture**. -1. **Frontend (JS):** Captures inputs and renders the game state immediately (Client-Side Prediction) to ensure zero input lag. It sends input logs to the server in chunks. -2. **Backend (Go):** Validates the inputs by re-simulating the game physics tick-by-tick. -3. **Database (Redis):** Stores active sessions, RNG states, and leaderboards (Sorted Sets). +1. **Backend (Go):** + * Runs the physics simulation at a fixed **20 TPS (Ticks Per Second)**. + * Generates level segments ("Chunks") 5 seconds into the future. + * Streams object positions via **WebSockets** to the client. +2. **Frontend (JS):** + * **Client-Side Prediction:** Inputs are applied immediately for zero-latency feel. + * **Buffering:** Incoming server data is buffered and played back smoothly. + * **Interpolation:** Although physics runs at 20 FPS, the game renders at 60+ FPS by interpolating positions (`lerp`). +3. **Database (Redis):** + * Stores active sessions, highscores, and custom level chunks. ### **Tech Stack** -* **Backend:** Go (Golang) 1.21+ -* **Frontend:** Vanilla JavaScript (Canvas API), CSS3 -* **Database:** Redis -* **Containerization:** Docker (Multi-Stage Build: Node Minifier \-\> Go Builder \-\> Alpine Runner) -* **Orchestration:** Kubernetes (Deployment, Service, Ingress) +* **Backend:** Go (Golang) 1.22+ (`gorilla/websocket`) +* **Frontend:** Vanilla JavaScript (Canvas API) +* **Database:** Redis +* **Containerization:** Docker (Multi-Stage Build) +* **Orchestration:** Kubernetes -## **🔧 Development Challenges & Solutions** +## **🔧 Engineering Challenges & Solutions** -Developing a cheat-proof game for the web came with significant technical hurdles. Here is how we solved them: +### **1. The "Netflix" Approach (Streaming vs. RNG)** +* **Problem:** Syncing Random Number Generators (RNG) between Client and Server caused "Butterfly Effects" where one wrong number broke the whole game state. +* **Solution:** **Streaming.** The client no longer generates anything. The server generates objects in the future and streams them into a buffer on the client. The client simply plays back what it receives. -### **1\. The "Butterfly Effect" (RNG Desynchronization)** +### **2. Lag Compensation & RTT** +* **Problem:** If the internet lags, the server's objects appear too late on the client, causing "Ghost Kills". +* **Solution:** **RTT (Round Trip Time) Measurement.** The client constantly measures the Ping. Incoming objects are visually shifted based on latency (`Latency * Speed`), so they appear exactly where they are on the server. -* **Problem:** JavaScript's Math.random() and Go's rand implementations are different. Even if seeded with the same number, they produce different sequences. This caused "Ghost Objects" (Server spawns a teacher, Client spawns a coin). -* **Solution:** We implemented a custom **Linear Congruential Generator (LCG)** in both languages. -* **The Tricky Part:** JavaScript uses 64-bit Floating Point numbers for everything, while Go uses strict types. Math operations drifted apart after a few hundred iterations. -* **Fix:** We forced JavaScript to use BigInt to simulate 32-bit integer overflows exactly like Go's uint32. +### **3. Low Tick-Rate & Interpolation** +* **Problem:** To save server CPU, we run physics at only **20 TPS**. This usually looks choppy (like a slideshow). +* **Solution:** **Linear Interpolation.** The rendering loop runs at 60/144 FPS and calculates the visual position between two physics ticks. The game looks buttery smooth despite low server load. -### **2\. Floating Point Drift & Spawning** - -* **Problem:** We initially spawned objects based on pixel positions (if x \< 800). Due to floating point precision errors, the client sometimes calculated 799.99 (Spawn\!) while the server calculated 800.01 (Wait\!). This desynchronized the RNG state immediately. -* **Solution:** **Tick-Based Spawning.** We decoupled spawning from spatial positions. The game now decides: *"The next object spawns at Tick 500"* rather than *"at Pixel 1200"*. Integers don't drift. - -### **3\. Logic-Score Coupling** - -* **Problem:** Initially, game speed and boss phases depended on the *Score*. When we added Coins (+2000 Points), the client's score jumped instantly, speeding up the game on the client side before the server acknowledged the coin pickup. This caused massive desyncs. -* **Solution:** We decoupled logic from the visual score. Game difficulty now depends strictly on **"Ticks Alive" (Time)**, which cannot be manipulated by picking up items. - -### **4\. Physics Tunneling (High Speed Collisions)** - -* **Problem:** At high speeds, the player could move 20 pixels per frame. If an obstacle was only 15 pixels wide, the player effectively "teleported" through it without triggering a collision. -* **Solution:** **Continuous Collision Detection (CCD).** We extend the hitbox of obstacles dynamically based on their current speed (width \+ speed). - -### **5\. "Ghost Hits" (The CCD Side Effect)** - -* **Problem:** The CCD fix caused a new issue where the extended hitbox would hit the player *after* they had already jumped over the obstacle (hitting them from behind). -* **Solution:** A **"Passed Check"**. Before checking collisions, we verify if the obstacle's right edge is already physically behind the player's left edge. If so, collision is ignored. +### **4. "Instant" Death** +* **Problem:** Waiting for the server to confirm "You died" feels laggy. +* **Solution:** **Optimistic Client Death.** The client detects collisions locally and stops the game visually (`Game Over`). It sends a `DEATH` signal to the server, which then validates and saves the highscore. ## **🚀 Getting Started** ### **Using Docker (Recommended)** -This project includes a production-ready Dockerfile and docker-compose.yml. +1. **Clone the repository:** + ```bash + git clone [https://git.zb-server.de/ZB-Server/it232Abschied.git](https://git.zb-server.de/ZB-Server/it232Abschied.git) + cd it232Abschied + ``` -1. **Clone the repository:** - git clone \[https://git.zb-server.de/ZB-Server/it232Abschied.git\](https://git.zb-server.de/ZB-Server/it232Abschied.git) - cd it232Abschied +2. **Run:** + ```bash + docker-compose up --build -d + ``` -2. Configure Environment (Optional): - Edit docker-compose.yml to set your admin credentials: - environment: - \- ADMIN\_USER=teacher - \- ADMIN\_PASS=secret123 +3. **Play:** Open `http://localhost:8080` +4. **Admin:** Open `http://localhost:8080/admin` (User: `lehrer`, Pass: `geheim123`) -3. **Run:** - docker-compose up \--build \-d +### **Local Development** -4. **Play:** Open http://localhost:8080 - -### **Local Development (Go & Redis)** - -1. Start Redis: - docker run \-d \-p 6379:6379 redis:alpine - -2. Start the Server: - go run . - - *(Note: Use go run . to include all files, not just main.go)* +1. Start Redis: + ```bash + docker run -d -p 6379:6379 redis:alpine + ``` +2. Start the Server: + ```bash + go run . + ``` ## **📂 Project Structure** -. -├── k8s/ \# Kubernetes manifests -├── static/ \# Frontend files -│ ├── assets/ \# Images & Sprites -│ ├── fonts/ \# Local GDPR-compliant fonts -│ ├── js/ \# Modular Game Engine -│ │ ├── config.js \# Constants -│ │ ├── logic.js \# Physics & Collision -│ │ ├── network.js \# Server-Sync -│ │ └── ... -│ ├── index.html \# Entry Point -│ └── style.css \# Retro Design -├── secure/ \# Protected Admin Files -├── main.go \# Go Server Entrypoint -├── simulation.go \# Server-side Physics Engine -├── rng.go \# Deterministic RNG -├── types.go \# Data Structures -├── Dockerfile \# Multi-Stage Build -└── ... +``` +. +├── k8s/ \# Kubernetes manifests +├── static/ \# Frontend files +│ ├── assets/ \# Images & Audio +│ ├── js/ \# Game Engine +│ │ ├── audio.js \# Sound Manager +│ │ ├── logic.js \# Physics & Buffer Logic +│ │ ├── render.js \# Drawing & Interpolation +│ │ ├── network.js \# WebSocket & RTT Sync +│ │ └── ... +│ ├── index.html \# Entry Point +│ └── style.css \# Styling +├── secure/ \# Protected Admin Files (Editor) +├── main.go \# HTTP Routes & Setup +├── websocket.go \# Game Loop & Streaming Logic +├── simulation.go \# Physics Core +├── types.go \# Data Structures +└── Dockerfile \# Multi-Stage Build +``` ## **📜 Legal** This is a non-commercial educational project. +* **Privacy:** No tracking cookies. +* **Assets:** Font "Press Start 2P" hosted locally. Sounds generated via bfxr.net. -* **Privacy:** No tracking cookies are used. Highscores are stored in Redis; local scores in LocalStorage. -* **Assets:** Font "Press Start 2P" is hosted locally. - -**Good luck escaping\! 🏃💹** \ No newline at end of file +**Run for your grade! 🏃💹** diff --git a/config.go b/config.go index 5217b6e..14a04d8 100644 --- a/config.go +++ b/config.go @@ -63,25 +63,37 @@ func initGameConfig() { } log.Println("✅ Config mit Powerups geladen") - loadChunksFromRedis() + defaultConfig.Chunks = loadChunksFromRedis() } -func loadChunksFromRedis() { - // Gleiche Logik wie im Handler, aber speichert es in die globale Variable +func loadChunksFromRedis() []ChunkDef { if rdb == nil { - return - } // Falls Redis noch nicht da ist - - ids, _ := rdb.SMembers(ctx, "config:chunks:list").Result() - sort.Strings(ids) // WICHTIG - - var chunks []ChunkDef - for _, id := range ids { - val, _ := rdb.Get(ctx, "config:chunks:data:"+id).Result() - var c ChunkDef - json.Unmarshal([]byte(val), &c) - chunks = append(chunks, c) + return []ChunkDef{} } - defaultConfig.Chunks = chunks - log.Printf("📩 %d Custom Chunks geladen", len(chunks)) + + ids, err := rdb.SMembers(ctx, "config:chunks:list").Result() + if err != nil { + log.Println("Redis: Keine Chunks geladen") + return []ChunkDef{} + } + sort.Strings(ids) + + var loadedChunks []ChunkDef + + for _, id := range ids { + val, err := rdb.Get(ctx, "config:chunks:data:"+id).Result() + if err == nil { + var c ChunkDef + json.Unmarshal([]byte(val), &c) + c.ID = id + loadedChunks = append(loadedChunks, c) + } + } + + // Log nur beim Server-Start (wenn defaultConfig leer ist), sonst spammt es + if len(defaultConfig.Chunks) == 0 { + log.Printf("📩 Lade %d Chunks aus Redis", len(loadedChunks)) + } + + return loadedChunks } diff --git a/simulation.go b/simulation.go index 0881768..53fb53b 100644 --- a/simulation.go +++ b/simulation.go @@ -33,6 +33,8 @@ type SimState struct { // Anti-Cheat LastJumpDist float64 SuspicionScore int + + Chunks []ChunkDef } // ============================================================================ diff --git a/websocket.go b/websocket.go index a422e07..5265db2 100644 --- a/websocket.go +++ b/websocket.go @@ -74,6 +74,7 @@ func handleWebSocket(w http.ResponseWriter, r *http.Request) { Ticks: 0, PosY: PlayerYBase, NextSpawnTick: 0, + Chunks: loadChunksFromRedis(), } // Channel grĂ¶ĂŸer machen, damit bei Lag nichts blockiert @@ -246,10 +247,10 @@ func generateFutureObjects(s *SimState, tick int, speed float64) ([]ActiveObstac if tick >= s.NextSpawnTick { spawnX := SpawnXStart - chunkCount := len(defaultConfig.Chunks) + chunkCount := len(s.Chunks) if chunkCount > 0 && s.RNG.NextFloat() > 0.8 { idx := int(s.RNG.NextRange(0, float64(chunkCount))) - chunk := defaultConfig.Chunks[idx] + chunk := s.Chunks[idx] for _, p := range chunk.Platforms { createdPlats = append(createdPlats, ActivePlatform{X: spawnX + p.X, Y: p.Y, Width: p.Width, Height: p.Height}) From 61e82e0dba9dc207bea4b65b5c9965ec69d07ba6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Untersch=C3=BCtz?= Date: Sun, 30 Nov 2025 18:49:00 +0100 Subject: [PATCH 2/5] fix Sync --- simulation.go | 66 ++++++++++++++++++++++++++++++++++++++------------- 1 file changed, 50 insertions(+), 16 deletions(-) diff --git a/simulation.go b/simulation.go index 53fb53b..0d626fd 100644 --- a/simulation.go +++ b/simulation.go @@ -89,55 +89,89 @@ func simulateChunk(sessionID string, inputs []Input, totalTicks int, vals map[st // ============================================================================ func updatePhysics(s *SimState, didJump, isCrouching bool, currentSpeed float64) { + // 1. Powerup Logik (Jump Boots) jumpPower := JumpPower if s.BootTicks > 0 { jumpPower = HighJumpPower s.BootTicks-- } + // 2. Sind wir am Boden? isGrounded := checkGrounded(s) - // Fehler behoben: "currentHeight declared but not used" entfernt. - // Wir brauchen es hier nicht, da checkPlatformLanding mit fixen 50.0 rechnet. - // Die Hitbox-Änderung passiert nur in checkCollisions. - - if isCrouching && !isGrounded { - s.VelY += 2.0 // Fast Fall + // 3. Ducken / Fast Fall + // (Variable 'currentHeight' entfernt, da sie hier nicht gebraucht wird) + if isCrouching { + // Wenn man in der Luft duckt, fĂ€llt man schneller ("Fast Fall") + if !isGrounded { + s.VelY += 2.0 + } } + // 4. Springen if didJump && isGrounded && !isCrouching { s.VelY = jumpPower isGrounded = false - checkJumpSuspicion(s) } + // 5. Schwerkraft anwenden s.VelY += Gravity + oldY := s.PosY newY := s.PosY + s.VelY landed := false - // A. Plattform Landung (One-Way Logic) - if s.VelY > 0 { + // ============================================================ + // PLATTFORM KOLLISION (MIT VERTICAL SWEEP) + // ============================================================ + + if s.VelY > 0 { // Nur wenn wir fallen + + // Wir nutzen hier die Standard-Höhe fĂŒr die FĂŒĂŸe. + // Auch beim Ducken bleiben die FĂŒĂŸe meist unten (oder ziehen hoch?), + // aber fĂŒr die Landung auf Plattformen ist die Standard-Box sicherer. + playerFeetOld := oldY + PlayerHeight + playerFeetNew := newY + PlayerHeight + + // Player X ist fest bei 50, Breite 30 + pLeft := 50.0 + pRight := 50.0 + 30.0 + for _, p := range s.Platforms { - hit, landY := checkPlatformLanding(p.X, p.Y, p.Width, 50.0, oldY, newY, s.VelY) - if hit { - newY = landY - s.VelY = 0 - landed = true - break + + // 1. Horizontal Check (GroßzĂŒgig!) + // Toleranz an den RĂ€ndern (-5 / +5), damit man nicht abrutscht + if (pRight-5.0 > p.X) && (pLeft+5.0 < p.X+p.Width) { + + // 2. Vertikaler Sweep (Durchsprung-Schutz) + // Check: FĂŒĂŸe waren vorher <= Plattform-Oberkante + // UND FĂŒĂŸe sind jetzt >= Plattform-Oberkante + if playerFeetOld <= p.Y && playerFeetNew >= p.Y { + + // Korrektur: Wir setzen den Spieler exakt AUF die Plattform + newY = p.Y - PlayerHeight + s.VelY = 0 + landed = true + isGrounded = true + break // Landung erfolgreich + } } } } - // B. Boden Landung + // ============================================================ + // BODEN KOLLISION + // ============================================================ if !landed { if newY >= PlayerYBase { newY = PlayerYBase s.VelY = 0 + isGrounded = true } } + // Neue Position setzen s.PosY = newY } From 56dd8db9a387a21539089337987f82ae4f8fe57f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Untersch=C3=BCtz?= Date: Sun, 30 Nov 2025 19:00:59 +0100 Subject: [PATCH 3/5] add Sprechblasen --- types.go | 1 + websocket.go | 99 +++++++++++++++++++++++++++++++++++++++++++--------- 2 files changed, 83 insertions(+), 17 deletions(-) diff --git a/types.go b/types.go index bfdc1b5..0bedfb6 100644 --- a/types.go +++ b/types.go @@ -57,6 +57,7 @@ type ActiveObstacle struct { Y float64 `json:"y"` Width float64 `json:"w"` Height float64 `json:"h"` + Speech string `json:"speech,omitempty"` } type ActivePlatform struct { diff --git a/websocket.go b/websocket.go index 5265db2..8018243 100644 --- a/websocket.go +++ b/websocket.go @@ -235,46 +235,93 @@ func handleWebSocket(w http.ResponseWriter, r *http.Request) { } } -// ... (generateFutureObjects bleibt gleich wie vorher) ... +// Hilfsfunktion: Generiert Objekte fĂŒr EINEN Tick in der Zukunft func generateFutureObjects(s *SimState, tick int, speed float64) ([]ActiveObstacle, []ActivePlatform) { var createdObs []ActiveObstacle var createdPlats []ActivePlatform + // Initialisierung beim ersten Lauf if s.NextSpawnTick == 0 { s.NextSpawnTick = tick + 50 } + // Ist es Zeit fĂŒr etwas Neues? if tick >= s.NextSpawnTick { spawnX := SpawnXStart - chunkCount := len(s.Chunks) + // --- ENTSCHEIDUNG: CHUNK vs RANDOM --- + + // Wir nutzen die globalen Chunks (da Read-Only wĂ€hrend des Spiels, ist Zugriff sicher) + chunkCount := len(defaultConfig.Chunks) + if chunkCount > 0 && s.RNG.NextFloat() > 0.8 { + // ================================================= + // OPTION A: CHUNK SPAWNING + // ================================================= idx := int(s.RNG.NextRange(0, float64(chunkCount))) - chunk := s.Chunks[idx] + chunk := defaultConfig.Chunks[idx] + // 1. Plattformen ĂŒbernehmen for _, p := range chunk.Platforms { - createdPlats = append(createdPlats, ActivePlatform{X: spawnX + p.X, Y: p.Y, Width: p.Width, Height: p.Height}) - } - for _, o := range chunk.Obstacles { - createdObs = append(createdObs, ActiveObstacle{ID: o.ID, Type: o.Type, X: spawnX + o.X, Y: o.Y, Width: o.Width, Height: o.Height}) + createdPlats = append(createdPlats, ActivePlatform{ + X: spawnX + p.X, + Y: p.Y, + Width: p.Width, + Height: p.Height, + }) } - width := chunk.TotalWidth - if width == 0 { - width = 2000 + // 2. Hindernisse ĂŒbernehmen & Speech berechnen + for _, o := range chunk.Obstacles { + + // Speech-Logik: Wir mĂŒssen die Original-Def finden, um zu wissen, ob er sprechen kann + speech := "" + for _, def := range defaultConfig.Obstacles { + if def.ID == o.ID { + // Wenn gefunden, wĂŒrfeln wir + if def.CanTalk && len(def.SpeechLines) > 0 { + if s.RNG.NextFloat() > 0.7 { // 30% Wahrscheinlichkeit + sIdx := int(s.RNG.NextRange(0, float64(len(def.SpeechLines)))) + speech = def.SpeechLines[sIdx] + } + } + break // Def gefunden, Loop abbrechen + } + } + + createdObs = append(createdObs, ActiveObstacle{ + ID: o.ID, + Type: o.Type, + X: spawnX + o.X, + Y: o.Y, + Width: o.Width, + Height: o.Height, + Speech: speech, // <--- HIER wird der Text gesetzt + }) } - s.NextSpawnTick = tick + int(float64(width)/speed) + + // Timer setzen (LĂ€nge des Chunks) + width := float64(chunk.TotalWidth) + if width == 0 { + width = 2000.0 + } + s.NextSpawnTick = tick + int(width/speed) } else { - // Random Logic + // ================================================= + // OPTION B: RANDOM SPAWNING + // ================================================= + + // LĂŒcke berechnen gap := 400 + int(s.RNG.NextRange(0, 500)) s.NextSpawnTick = tick + int(float64(gap)/speed) + // Pool bilden (Boss Phase?) defs := defaultConfig.Obstacles if len(defs) > 0 { - // Boss Check isBoss := (tick % 1500) > 1200 var pool []ObstacleDef + for _, d := range defs { if isBoss { if d.ID == "principal" || d.ID == "trashcan" { @@ -287,18 +334,36 @@ func generateFutureObjects(s *SimState, tick int, speed float64) ([]ActiveObstac } } + // Objekt auswĂ€hlen def := s.RNG.PickDef(pool) + if def != nil { - // RNG Calls to keep sync (optional now, but good practice) - if def.CanTalk && s.RNG.NextFloat() > 0.7 { - } + // Powerup Rarity (90% Chance, dass es NICHT spawnt) if def.Type == "powerup" && s.RNG.NextFloat() > 0.1 { def = nil } if def != nil { + // Speech Logik + speech := "" + if def.CanTalk && len(def.SpeechLines) > 0 { + if s.RNG.NextFloat() > 0.7 { + sIdx := int(s.RNG.NextRange(0, float64(len(def.SpeechLines)))) + speech = def.SpeechLines[sIdx] + } + } + + // Y-Position berechnen (Boden - Höhe - Offset) + spawnY := GroundY - def.Height - def.YOffset + createdObs = append(createdObs, ActiveObstacle{ - ID: def.ID, Type: def.Type, X: spawnX, Y: GroundY - def.Height - def.YOffset, Width: def.Width, Height: def.Height, + ID: def.ID, + Type: def.Type, + X: spawnX, + Y: spawnY, + Width: def.Width, + Height: def.Height, + Speech: speech, // <--- HIER wird der Text gesetzt }) } } From 8950b703782223dc8130cfcf1862ac496598aa97 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Untersch=C3=BCtz?= Date: Sun, 30 Nov 2025 19:33:20 +0100 Subject: [PATCH 4/5] fix README, SYNC, DATENSCHUTZ --- README.md | 126 ++++++++++++++++++++--------------------- config.go | 4 +- handlers.go | 8 +-- main.go | 2 - middleware.go | 11 ---- simulation.go | 55 ++---------------- static/index.html | 94 ++++++++++++++++++++++-------- static/js/audio.js | 13 ++--- static/js/config.js | 21 +++---- static/js/input.js | 15 +---- static/js/logic.js | 63 +++++++-------------- static/js/main.js | 70 +++++++++-------------- static/js/network.js | 109 +++++++++-------------------------- static/js/particles.js | 12 +--- static/js/render.js | 70 +++++------------------ static/js/state.js | 59 ++++++++----------- static/style.css | 4 +- websocket.go | 56 +++--------------- 18 files changed, 280 insertions(+), 512 deletions(-) diff --git a/README.md b/README.md index cfe9b35..cb1c795 100644 --- a/README.md +++ b/README.md @@ -4,15 +4,18 @@ ## **📖 About the Project** -"Escape the Teacher" is a web game developed as a school project. You play as a student caught cheating, running away from an angry teacher. The game features increasing difficulty, power-ups, boss phases, and a competitive global leaderboard. +"Escape the Teacher" is a web-based game developed as a final project (IT232). You play as a student caught cheating, running away from an angry teacher. The game features increasing difficulty, power-ups, boss phases, and a competitive global leaderboard. -Unlike typical browser games, this project implements **competitive multiplayer architecture** usually found in games like *Agar.io* or FPS titles. The browser is just a "dumb" terminal; the server simulates the entire world. +Unlike typical browser games, this project implements **competitive multiplayer architecture** usually found in shooters or RTS games. The browser is treated as a "dumb" display terminal; the server simulates the entire world to prevent cheating. + +**🔗 Repository:** [https://git.zb-server.de/ZB-Server/it232Abschied](https://git.zb-server.de/ZB-Server/it232Abschied) + +--- ## **✹ Features** ### **🎼 Gameplay** - -* **Endless Progression:** The game speeds up over time. +* **Endless Progression:** The game speed increases over time. * **Controls:** * **Jump:** Space / Arrow Up / Tap / Left Click. * **Crouch:** Arrow Down / Swipe Down (Mobile). @@ -20,56 +23,54 @@ Unlike typical browser games, this project implements **competitive multiplayer * đŸ›Ąïž **Godmode:** Survives 3 hits. * ⚟ **Baseball Bat:** Eliminates the next teacher obstacle. * 👟 **Jumpboots:** Grants higher jumping power. - * 💰 **Coins:** Bonus points. -* **Level Editor:** Custom chunks (platforms, enemies) can be designed in a secure Admin UI and are streamed into the game live. -* **Juice:** Particle effects and retro sound system. + * 💰 **Coins:** Bonus points for the highscore. +* **Level Editor:** Custom level segments ("Chunks") can be designed live in the Admin Panel and are streamed directly into active game sessions. -### **đŸ›Ąïž Security & Admin** +### **đŸ›Ąïž Security & Technology** +* **Server-Authoritative:** Physics runs entirely on the server (Go). Speedhacks or Godmode cheats are impossible. +* **Admin Panel:** Password-protected interface to manage levels, badwords, and leaderboards. +* **Proof System:** Players receive an 8-character "Claim Code" to validate their high score offline. -* **Server-Authoritative:** Physics runs on the server. Cheating (speedhack, godmode) is impossible. -* **Admin Panel:** Create levels, manage badwords, and moderate the leaderboard. -* **Proof System:** Players receive an 8-character "Claim Code" to prove their high score offline. +--- ## **đŸ—ïž Technical Architecture** -We moved from a traditional HTTP-Request model to a **Realtime Streaming Architecture**. +The project utilizes a **Realtime Streaming Architecture** to handle latency and synchronization. 1. **Backend (Go):** - * Runs the physics simulation at a fixed **20 TPS (Ticks Per Second)**. - * Generates level segments ("Chunks") 5 seconds into the future. - * Streams object positions via **WebSockets** to the client. + * Simulates physics at a fixed **20 TPS (Ticks Per Second)** to save CPU. + * Generates level chunks 5 seconds into the future. + * Streams object positions and game state via **WebSockets**. 2. **Frontend (JS):** - * **Client-Side Prediction:** Inputs are applied immediately for zero-latency feel. - * **Buffering:** Incoming server data is buffered and played back smoothly. - * **Interpolation:** Although physics runs at 20 FPS, the game renders at 60+ FPS by interpolating positions (`lerp`). + * **Client-Side Prediction:** Inputs are applied immediately for a "Zero-Lag" feel. + * **Buffering & Interpolation:** Although the server calculates at 20 FPS, the client renders at 60+ FPS by interpolating positions (`Lerp`) between server ticks. + * **Lag Compensation (RTT):** The client measures Round Trip Time and visually shifts server objects to match the client's timeline. 3. **Database (Redis):** - * Stores active sessions, highscores, and custom level chunks. + * Stores highscores, active session states, and level editor chunks. -### **Tech Stack** - -* **Backend:** Go (Golang) 1.22+ (`gorilla/websocket`) -* **Frontend:** Vanilla JavaScript (Canvas API) -* **Database:** Redis -* **Containerization:** Docker (Multi-Stage Build) -* **Orchestration:** Kubernetes +--- ## **🔧 Engineering Challenges & Solutions** -### **1. The "Netflix" Approach (Streaming vs. RNG)** -* **Problem:** Syncing Random Number Generators (RNG) between Client and Server caused "Butterfly Effects" where one wrong number broke the whole game state. -* **Solution:** **Streaming.** The client no longer generates anything. The server generates objects in the future and streams them into a buffer on the client. The client simply plays back what it receives. +Building a lag-free, cheat-proof game on the web came with significant hurdles. Here is how we solved them: -### **2. Lag Compensation & RTT** -* **Problem:** If the internet lags, the server's objects appear too late on the client, causing "Ghost Kills". -* **Solution:** **RTT (Round Trip Time) Measurement.** The client constantly measures the Ping. Incoming objects are visually shifted based on latency (`Latency * Speed`), so they appear exactly where they are on the server. +### **1. The "Netflix" Approach (Streaming vs. RNG Sync)** +* **Problem:** Initially, we tried to sync the Random Number Generators (RNG) between Client and Server. However, a single floating-point deviation caused a "Butterfly Effect," desynchronizing the entire game world. +* **Solution:** **Streaming.** The client no longer generates obstacles. The server pre-calculates the future and streams objects into a buffer on the client. The client simply plays back the stream, similar to a video player. -### **3. Low Tick-Rate & Interpolation** -* **Problem:** To save server CPU, we run physics at only **20 TPS**. This usually looks choppy (like a slideshow). -* **Solution:** **Linear Interpolation.** The rendering loop runs at 60/144 FPS and calculates the visual position between two physics ticks. The game looks buttery smooth despite low server load. +### **2. Low Tick-Rate & Interpolation** +* **Problem:** To allow many concurrent players, the server physics runs at only **20 TPS**. Without smoothing, this looks choppy (like a slideshow) on a 144Hz monitor. +* **Solution:** **Linear Interpolation.** The rendering engine calculates the visual position between the current and the previous physics state (`alpha = accumulator / dt`). The result is buttery smooth motion despite the low server update rate. -### **4. "Instant" Death** -* **Problem:** Waiting for the server to confirm "You died" feels laggy. -* **Solution:** **Optimistic Client Death.** The client detects collisions locally and stops the game visually (`Game Over`). It sends a `DEATH` signal to the server, which then validates and saves the highscore. +### **3. Latency & "Ghost Kills"** +* **Problem:** If the internet lags (e.g., 100ms Ping), the server sees the player hitting an obstacle that appeared "safe" on the client screen. +* **Solution:** **RTT Compensation.** The client constantly measures the network delay. Incoming objects from the server are visually shifted towards the player (`Latency * Speed`), ensuring they appear exactly where the server expects them to be at the moment of interaction. + +### **4. Vertical Synchronization (Platforms)** +* **Problem:** Jumping onto platforms is tricky. At high speeds, the server might calculate that the player fell *through* a platform between two ticks ("Tunneling"). +* **Solution:** **Vertical Sweeping & Client Authority override.** The server checks the *path* of the player, not just the position. Additionally, if the client detects a safe landing, it forces a physics sync update to the server to prevent unfair deaths. + +--- ## **🚀 Getting Started** @@ -100,33 +101,30 @@ We moved from a traditional HTTP-Request model to a **Realtime Streaming Archite go run . ``` -## **📂 Project Structure** +--- -``` -. -├── k8s/ \# Kubernetes manifests -├── static/ \# Frontend files -│ ├── assets/ \# Images & Audio -│ ├── js/ \# Game Engine -│ │ ├── audio.js \# Sound Manager -│ │ ├── logic.js \# Physics & Buffer Logic -│ │ ├── render.js \# Drawing & Interpolation -│ │ ├── network.js \# WebSocket & RTT Sync -│ │ └── ... -│ ├── index.html \# Entry Point -│ └── style.css \# Styling -├── secure/ \# Protected Admin Files (Editor) -├── main.go \# HTTP Routes & Setup -├── websocket.go \# Game Loop & Streaming Logic -├── simulation.go \# Physics Core -├── types.go \# Data Structures -└── Dockerfile \# Multi-Stage Build -``` +## **đŸŽ¶ Credits** -## **📜 Legal** +This project was made possible by: -This is a non-commercial educational project. -* **Privacy:** No tracking cookies. -* **Assets:** Font "Press Start 2P" hosted locally. Sounds generated via bfxr.net. +* **Development & Code:** Sebastian UnterschĂŒtz +* **Music Design:** Max E. +* **Sound Effects:** Generated via [bfxr.net](https://www.bfxr.net/) +* **Graphics:** [Kenney.nl](https://kenney.nl) / Sebastian UnterschĂŒtz +* **Fonts:** "Press Start 2P" (Google Fonts / Local) -**Run for your grade! 🏃💹** +--- + +## **⚖ License & Rights** + +**© 2025 IT232 Final Project** + +This project is released under a proprietary, restrictive license: + +1. **No Commercial Use:** The source code, assets, and the game itself may not be used for commercial purposes or sold. +2. **No Modification:** You are not allowed to modify, fork, or redistribute this project in modified form without express written permission from the authors. +3. **Educational Use:** Viewing and running the code for educational purposes within the context of the school project is permitted. + +--- + +**Run for your grade! 🏃💹** \ No newline at end of file diff --git a/config.go b/config.go index 14a04d8..88eeff4 100644 --- a/config.go +++ b/config.go @@ -21,7 +21,6 @@ const ( GameWidth = 800.0 ) -// Globale Variablen var ( ctx = context.Background() rdb *redis.Client @@ -58,7 +57,7 @@ func initGameConfig() { {ID: "p_bat", Type: "powerup", Width: 30, Height: 30, Color: "red", Image: "powerup_bat1.png", YOffset: 20.0}, // SchlĂ€ger {ID: "p_boot", Type: "powerup", Width: 30, Height: 30, Color: "lime", Image: "powerup_boot1.png", YOffset: 20.0}, // Boots }, - // Mehrere HintergrĂŒnde fĂŒr Level-Wechsel + Backgrounds: []string{"school-background.jpg", "gym-background.jpg", "school2-background.jpg"}, } log.Println("✅ Config mit Powerups geladen") @@ -90,7 +89,6 @@ func loadChunksFromRedis() []ChunkDef { } } - // Log nur beim Server-Start (wenn defaultConfig leer ist), sonst spammt es if len(defaultConfig.Chunks) == 0 { log.Printf("📩 Lade %d Chunks aus Redis", len(loadedChunks)) } diff --git a/handlers.go b/handlers.go index a1f5677..ac46bd5 100644 --- a/handlers.go +++ b/handlers.go @@ -137,7 +137,6 @@ func handleSubmitName(w http.ResponseWriter, r *http.Request) { return } - // Validierung if len(req.Name) > 4 { http.Error(w, "Zu lang", 400) return @@ -297,7 +296,6 @@ func handleClaimDelete(w http.ResponseWriter, r *http.Request) { log.Printf("đŸ—‘ïž USER DELETE: Session %s deleted via code", req.SessionID) - // Aus Listen entfernen rdb.ZRem(ctx, "leaderboard:unverified", req.SessionID) rdb.ZRem(ctx, "leaderboard:public", req.SessionID) @@ -318,7 +316,6 @@ func generateClaimCode() string { func handleAdminBadwords(w http.ResponseWriter, r *http.Request) { key := "config:badwords" - // GET: Liste abrufen if r.Method == http.MethodGet { words, _ := rdb.SMembers(ctx, key).Result() w.Header().Set("Content-Type", "application/json") @@ -326,12 +323,11 @@ func handleAdminBadwords(w http.ResponseWriter, r *http.Request) { return } - // POST: HinzufĂŒgen oder Löschen if r.Method == http.MethodPost { - // Wir nutzen ein einfaches Struct fĂŒr den Request + type WordReq struct { Word string `json:"word"` - Action string `json:"action"` // "add" oder "remove" + Action string `json:"action"` } var req WordReq if err := json.NewDecoder(r.Body).Decode(&req); err != nil { diff --git a/main.go b/main.go index f60aea9..36e2e84 100644 --- a/main.go +++ b/main.go @@ -35,7 +35,6 @@ func main() { fs := http.FileServer(http.Dir("./static")) http.Handle("/", fs) - // API Routes (jetzt mit Logger!) http.HandleFunc("/api/config", Logger(handleConfig)) http.HandleFunc("/api/start", Logger(handleStart)) http.HandleFunc("/api/validate", Logger(handleValidate)) @@ -44,7 +43,6 @@ func main() { http.HandleFunc("/api/leaderboard", Logger(handleLeaderboard)) http.HandleFunc("/api/claim/delete", Logger(handleClaimDelete)) - // Admin Routes (Logger + BasicAuth kombinieren) http.HandleFunc("/admin", Logger(BasicAuth(handleAdminPage))) http.HandleFunc("/api/admin/badwords", Logger(BasicAuth(handleAdminBadwords))) http.HandleFunc("/api/admin/list", Logger(BasicAuth(handleAdminList))) diff --git a/middleware.go b/middleware.go index 85bfd0b..605eab7 100644 --- a/middleware.go +++ b/middleware.go @@ -6,40 +6,29 @@ import ( "time" ) -// Wir bauen unseren eigenen ResponseWriter, der den Status-Code "mitschreibt" type StatusRecorder struct { http.ResponseWriter Status int } -// Überschreiben der WriteHeader Methode, um den Code abzufangen func (r *StatusRecorder) WriteHeader(status int) { r.Status = status r.ResponseWriter.WriteHeader(status) } -// Die eigentliche Middleware Funktion func Logger(next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - // 1. Startzeit messen start := time.Now() - // 2. Recorder initialisieren (Standard ist 200 OK) recorder := &StatusRecorder{ ResponseWriter: w, Status: http.StatusOK, } - // 3. Den echten Handler aufrufen (mit unserem Recorder) next(recorder, r) - // 4. Dauer berechnen duration := time.Since(start) - // 5. Loggen - // Format: [METHODE] PFAD | STATUS | DAUER | IP - // Beispiel: [POST] /api/validate | 200 | 1.2ms | 127.0.0.1 - icon := "✅" if recorder.Status >= 400 { icon = "⚠" diff --git a/simulation.go b/simulation.go index 0d626fd..53a5cbc 100644 --- a/simulation.go +++ b/simulation.go @@ -8,7 +8,6 @@ import ( "strconv" ) -// --- INTERNE STATE STRUKTUR --- type SimState struct { SessionID string Score int @@ -37,16 +36,10 @@ type SimState struct { Chunks []ChunkDef } -// ============================================================================ -// HAUPTFUNKTION -// ============================================================================ - func simulateChunk(sessionID string, inputs []Input, totalTicks int, vals map[string]string) (bool, int, []ActiveObstacle, []ActivePlatform, PowerUpState, int, int, uint32) { - // 1. State laden state := loadSimState(sessionID, vals) - // 2. Bot-Check if isBotSpamming(inputs) { log.Printf("đŸ€– BOT-ALARM [%s]: Spammt SprĂŒnge", sessionID) state.IsDead = true @@ -73,42 +66,32 @@ func simulateChunk(sessionID string, inputs []Input, totalTicks int, vals map[st state.Score++ } - // 4. Anti-Cheat Heuristik if state.SuspicionScore > 15 { log.Printf("đŸ€– BOT-ALARM [%s]: Zu perfekte SprĂŒnge (Heuristik)", sessionID) state.IsDead = true } - // 5. Speichern saveSimState(&state) return packResponse(&state) } -// ============================================================================ -// LOGIK & PHYSIK FUNKTIONEN -// ============================================================================ - func updatePhysics(s *SimState, didJump, isCrouching bool, currentSpeed float64) { - // 1. Powerup Logik (Jump Boots) + jumpPower := JumpPower if s.BootTicks > 0 { jumpPower = HighJumpPower s.BootTicks-- } - // 2. Sind wir am Boden? isGrounded := checkGrounded(s) - // 3. Ducken / Fast Fall - // (Variable 'currentHeight' entfernt, da sie hier nicht gebraucht wird) if isCrouching { - // Wenn man in der Luft duckt, fĂ€llt man schneller ("Fast Fall") + if !isGrounded { s.VelY += 2.0 } } - // 4. Springen if didJump && isGrounded && !isCrouching { s.VelY = jumpPower isGrounded = false @@ -122,47 +105,30 @@ func updatePhysics(s *SimState, didJump, isCrouching bool, currentSpeed float64) landed := false - // ============================================================ - // PLATTFORM KOLLISION (MIT VERTICAL SWEEP) - // ============================================================ + if s.VelY > 0 { - if s.VelY > 0 { // Nur wenn wir fallen - - // Wir nutzen hier die Standard-Höhe fĂŒr die FĂŒĂŸe. - // Auch beim Ducken bleiben die FĂŒĂŸe meist unten (oder ziehen hoch?), - // aber fĂŒr die Landung auf Plattformen ist die Standard-Box sicherer. playerFeetOld := oldY + PlayerHeight playerFeetNew := newY + PlayerHeight - // Player X ist fest bei 50, Breite 30 pLeft := 50.0 pRight := 50.0 + 30.0 for _, p := range s.Platforms { - // 1. Horizontal Check (GroßzĂŒgig!) - // Toleranz an den RĂ€ndern (-5 / +5), damit man nicht abrutscht if (pRight-5.0 > p.X) && (pLeft+5.0 < p.X+p.Width) { - // 2. Vertikaler Sweep (Durchsprung-Schutz) - // Check: FĂŒĂŸe waren vorher <= Plattform-Oberkante - // UND FĂŒĂŸe sind jetzt >= Plattform-Oberkante if playerFeetOld <= p.Y && playerFeetNew >= p.Y { - // Korrektur: Wir setzen den Spieler exakt AUF die Plattform newY = p.Y - PlayerHeight s.VelY = 0 landed = true isGrounded = true - break // Landung erfolgreich + break } } } } - // ============================================================ - // BODEN KOLLISION - // ============================================================ if !landed { if newY >= PlayerYBase { newY = PlayerYBase @@ -171,7 +137,6 @@ func updatePhysics(s *SimState, didJump, isCrouching bool, currentSpeed float64) } } - // Neue Position setzen s.PosY = newY } @@ -186,7 +151,7 @@ func checkCollisions(s *SimState, isCrouching bool, currentSpeed float64) { activeObs := []ActiveObstacle{} for _, obs := range s.Obstacles { - // Passed Check + paddingX := 10.0 if obs.X+obs.Width-paddingX < 55.0 { activeObs = append(activeObs, obs) @@ -286,21 +251,19 @@ func handleSpawning(s *SimState, speed float64) { if s.Ticks >= s.NextSpawnTick { spawnX := GameWidth + 3200.0 - // --- OPTION A: CUSTOM CHUNK (20% Chance) --- chunkCount := len(defaultConfig.Chunks) if chunkCount > 0 && s.RNG.NextFloat() > 0.8 { idx := int(s.RNG.NextRange(0, float64(chunkCount))) chunk := defaultConfig.Chunks[idx] - // Objekte spawnen for _, p := range chunk.Platforms { s.Platforms = append(s.Platforms, ActivePlatform{ X: spawnX + p.X, Y: p.Y, Width: p.Width, Height: p.Height, }) } for _, o := range chunk.Obstacles { - // Fehler behoben: Zugriff auf o.X, o.Y jetzt möglich dank neuem Types-Struct + s.Obstacles = append(s.Obstacles, ActiveObstacle{ ID: o.ID, Type: o.Type, X: spawnX + o.X, Y: o.Y, Width: o.Width, Height: o.Height, }) @@ -311,20 +274,14 @@ func handleSpawning(s *SimState, speed float64) { width = 2000 } - // Fehler behoben: Mismatched Types (int vs float64) s.NextSpawnTick = s.Ticks + int(float64(width)/speed) } else { - // --- OPTION B: RANDOM GENERATION --- spawnRandomObstacle(s, speed, spawnX) } } } -// ============================================================================ -// HELPER -// ============================================================================ - func loadSimState(sid string, vals map[string]string) SimState { rngState, _ := strconv.ParseInt(vals["rng_state"], 10, 64) diff --git a/static/index.html b/static/index.html index 69997bb..5538fe2 100644 --- a/static/index.html +++ b/static/index.html @@ -88,47 +88,91 @@ -