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 @@ -