diff --git a/README.md b/README.md index ed10123..cb1c795 100644 --- a/README.md +++ b/README.md @@ -1,146 +1,130 @@ # **🏃 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-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 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 speed increases 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 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. -* **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. +### **🛡️ 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. -### **📱 Mobile First** - -* Fully responsive Canvas rendering (Letterboxing). -* Touch gestures (Swipe to crouch). -* "Rotate Device" enforcement for optimal gameplay. - -### **🛡️ 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. +--- ## **🏗️ Technical Architecture** -The core philosophy is **"Server-Authoritative, Client-Predicted"**. +The project utilizes a **Realtime Streaming Architecture** to handle latency and synchronization. -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):** + * 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 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 highscores, active session states, and level editor 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) +## **🔧 Engineering Challenges & Solutions** -## **🔧 Development Challenges & Solutions** +Building a lag-free, cheat-proof game on the web came with significant hurdles. Here is how we solved them: -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 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. -### **1\. The "Butterfly Effect" (RNG Desynchronization)** +### **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. -* **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. 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. -### **2\. Floating Point Drift & Spawning** +### **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. -* **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. +--- ## **🚀 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 +1. Start Redis: + ```bash + docker run -d -p 6379:6379 redis:alpine + ``` +2. Start the Server: + ```bash + go run . + ``` -### **Local Development (Go & Redis)** +--- -1. Start Redis: - docker run \-d \-p 6379:6379 redis:alpine +## **🎶 Credits** -2. Start the Server: - go run . +This project was made possible by: - *(Note: Use go run . to include all files, not just main.go)* +* **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) -## **📂 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 -└── ... +## **⚖️ License & Rights** -## **📜 Legal** +**© 2025 IT232 Final Project** -This is a non-commercial educational project. +This project is released under a proprietary, restrictive license: -* **Privacy:** No tracking cookies are used. Highscores are stored in Redis; local scores in LocalStorage. -* **Assets:** Font "Press Start 2P" is hosted locally. +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. -**Good luck escaping\! 🏃💨** \ No newline at end of file +--- + +**Run for your grade! 🏃💨** \ No newline at end of file diff --git a/config.go b/config.go index 5217b6e..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,30 +57,41 @@ 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") - 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) + } + } + + if len(defaultConfig.Chunks) == 0 { + log.Printf("📦 Lade %d Chunks aus Redis", len(loadedChunks)) + } + + return 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 0881768..53a5cbc 100644 --- a/simulation.go +++ b/simulation.go @@ -8,7 +8,6 @@ import ( "strconv" ) -// --- INTERNE STATE STRUKTUR --- type SimState struct { SessionID string Score int @@ -33,18 +32,14 @@ type SimState struct { // Anti-Cheat LastJumpDist float64 SuspicionScore int -} -// ============================================================================ -// HAUPTFUNKTION -// ============================================================================ + Chunks []ChunkDef +} 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 @@ -71,22 +66,17 @@ 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) { + jumpPower := JumpPower if s.BootTicks > 0 { jumpPower = HighJumpPower @@ -95,44 +85,55 @@ func updatePhysics(s *SimState, didJump, isCrouching bool, currentSpeed float64) 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 { - if isCrouching && !isGrounded { - s.VelY += 2.0 // Fast Fall + if !isGrounded { + s.VelY += 2.0 + } } 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 { + + playerFeetOld := oldY + PlayerHeight + playerFeetNew := newY + PlayerHeight + + 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 + + if (pRight-5.0 > p.X) && (pLeft+5.0 < p.X+p.Width) { + + if playerFeetOld <= p.Y && playerFeetNew >= p.Y { + + newY = p.Y - PlayerHeight + s.VelY = 0 + landed = true + isGrounded = true + break + } } } } - // B. Boden Landung if !landed { if newY >= PlayerYBase { newY = PlayerYBase s.VelY = 0 + isGrounded = true } } @@ -150,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) @@ -250,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, }) @@ -275,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..a232d4b 100644 --- a/static/index.html +++ b/static/index.html @@ -88,47 +88,91 @@ -