add hot Chunk reload
All checks were successful
Dynamic Branch Deploy / build-and-deploy (push) Successful in 2m27s
All checks were successful
Dynamic Branch Deploy / build-and-deploy (push) Successful in 2m27s
This commit is contained in:
156
README.md
156
README.md
@@ -1,11 +1,12 @@
|
|||||||
# **🏃 Escape the Teacher**
|
# **🏃 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**
|
## **📖 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.
|
"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 **anti-cheat architecture** usually found in multiplayer RTS or FPS games. The browser does not decide if you survived; the server does.
|
|
||||||
|
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**
|
## **✨ Features**
|
||||||
|
|
||||||
@@ -18,129 +19,114 @@ Unlike typical browser games, this project implements **anti-cheat architecture*
|
|||||||
* **Power-Ups:**
|
* **Power-Ups:**
|
||||||
* 🛡️ **Godmode:** Survives 3 hits.
|
* 🛡️ **Godmode:** Survives 3 hits.
|
||||||
* ⚾ **Baseball Bat:** Eliminates the next teacher obstacle.
|
* ⚾ **Baseball Bat:** Eliminates the next teacher obstacle.
|
||||||
* 👟 **Jumpboots:** Grants higher jumping power for a limited time.
|
* 👟 **Jumpboots:** Grants higher jumping power.
|
||||||
* 💰 **Coins:** Bonus points (visual score only, does not affect game speed).
|
* 💰 **Coins:** Bonus points.
|
||||||
* **Dynamic Backgrounds:** Environment changes as you progress.
|
* **Level Editor:** Custom chunks (platforms, enemies) can be designed in a secure Admin UI and are streamed into the game live.
|
||||||
* **Boss Phases:** Every 1500 ticks, special boss enemies spawn.
|
* **Juice:** Particle effects and retro sound system.
|
||||||
|
|
||||||
### **📱 Mobile First**
|
|
||||||
|
|
||||||
* Fully responsive Canvas rendering (Letterboxing).
|
|
||||||
* Touch gestures (Swipe to crouch).
|
|
||||||
* "Rotate Device" enforcement for optimal gameplay.
|
|
||||||
|
|
||||||
### **🛡️ Security & Admin**
|
### **🛡️ Security & Admin**
|
||||||
|
|
||||||
* **Admin Panel:** Password-protected (/admin) interface.
|
* **Server-Authoritative:** Physics runs on the server. Cheating (speedhack, godmode) is impossible.
|
||||||
* **Moderation:** Review and delete leaderboard entries.
|
* **Admin Panel:** Create levels, manage badwords, and moderate the leaderboard.
|
||||||
* **Badword Filter:** Automatic blocking of inappropriate names using Redis Sets.
|
* **Proof System:** Players receive an 8-character "Claim Code" to prove their high score offline.
|
||||||
* **Proof System:** Players receive an 8-character "Claim Code" to prove their high score to the teacher.
|
|
||||||
|
|
||||||
## **🏗️ Technical Architecture**
|
## **🏗️ 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.
|
1. **Backend (Go):**
|
||||||
2. **Backend (Go):** Validates the inputs by re-simulating the game physics tick-by-tick.
|
* Runs the physics simulation at a fixed **20 TPS (Ticks Per Second)**.
|
||||||
3. **Database (Redis):** Stores active sessions, RNG states, and leaderboards (Sorted Sets).
|
* 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**
|
### **Tech Stack**
|
||||||
|
|
||||||
* **Backend:** Go (Golang) 1.21+
|
* **Backend:** Go (Golang) 1.22+ (`gorilla/websocket`)
|
||||||
* **Frontend:** Vanilla JavaScript (Canvas API), CSS3
|
* **Frontend:** Vanilla JavaScript (Canvas API)
|
||||||
* **Database:** Redis
|
* **Database:** Redis
|
||||||
* **Containerization:** Docker (Multi-Stage Build: Node Minifier \-\> Go Builder \-\> Alpine Runner)
|
* **Containerization:** Docker (Multi-Stage Build)
|
||||||
* **Orchestration:** Kubernetes (Deployment, Service, Ingress)
|
* **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).
|
### **3. Low Tick-Rate & Interpolation**
|
||||||
* **Solution:** We implemented a custom **Linear Congruential Generator (LCG)** in both languages.
|
* **Problem:** To save server CPU, we run physics at only **20 TPS**. This usually looks choppy (like a slideshow).
|
||||||
* **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.
|
* **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.
|
||||||
* **Fix:** We forced JavaScript to use BigInt to simulate 32-bit integer overflows exactly like Go's uint32.
|
|
||||||
|
|
||||||
### **2\. Floating Point Drift & Spawning**
|
### **4. "Instant" Death**
|
||||||
|
* **Problem:** Waiting for the server to confirm "You died" feels laggy.
|
||||||
* **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:** **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.
|
||||||
* **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**
|
## **🚀 Getting Started**
|
||||||
|
|
||||||
### **Using Docker (Recommended)**
|
### **Using Docker (Recommended)**
|
||||||
|
|
||||||
This project includes a production-ready Dockerfile and docker-compose.yml.
|
|
||||||
|
|
||||||
1. **Clone the repository:**
|
1. **Clone the repository:**
|
||||||
git clone \[https://git.zb-server.de/ZB-Server/it232Abschied.git\](https://git.zb-server.de/ZB-Server/it232Abschied.git)
|
```bash
|
||||||
|
git clone [https://git.zb-server.de/ZB-Server/it232Abschied.git](https://git.zb-server.de/ZB-Server/it232Abschied.git)
|
||||||
cd it232Abschied
|
cd it232Abschied
|
||||||
|
```
|
||||||
|
|
||||||
2. Configure Environment (Optional):
|
2. **Run:**
|
||||||
Edit docker-compose.yml to set your admin credentials:
|
```bash
|
||||||
environment:
|
docker-compose up --build -d
|
||||||
\- ADMIN\_USER=teacher
|
```
|
||||||
\- ADMIN\_PASS=secret123
|
|
||||||
|
|
||||||
3. **Run:**
|
3. **Play:** Open `http://localhost:8080`
|
||||||
docker-compose up \--build \-d
|
4. **Admin:** Open `http://localhost:8080/admin` (User: `lehrer`, Pass: `geheim123`)
|
||||||
|
|
||||||
4. **Play:** Open http://localhost:8080
|
### **Local Development**
|
||||||
|
|
||||||
### **Local Development (Go & Redis)**
|
|
||||||
|
|
||||||
1. Start Redis:
|
1. Start Redis:
|
||||||
docker run \-d \-p 6379:6379 redis:alpine
|
```bash
|
||||||
|
docker run -d -p 6379:6379 redis:alpine
|
||||||
|
```
|
||||||
2. Start the Server:
|
2. Start the Server:
|
||||||
|
```bash
|
||||||
go run .
|
go run .
|
||||||
|
```
|
||||||
*(Note: Use go run . to include all files, not just main.go)*
|
|
||||||
|
|
||||||
## **📂 Project Structure**
|
## **📂 Project Structure**
|
||||||
|
|
||||||
|
```
|
||||||
.
|
.
|
||||||
├── k8s/ \# Kubernetes manifests
|
├── k8s/ \# Kubernetes manifests
|
||||||
├── static/ \# Frontend files
|
├── static/ \# Frontend files
|
||||||
│ ├── assets/ \# Images & Sprites
|
│ ├── assets/ \# Images & Audio
|
||||||
│ ├── fonts/ \# Local GDPR-compliant fonts
|
│ ├── js/ \# Game Engine
|
||||||
│ ├── js/ \# Modular Game Engine
|
│ │ ├── audio.js \# Sound Manager
|
||||||
│ │ ├── config.js \# Constants
|
│ │ ├── logic.js \# Physics & Buffer Logic
|
||||||
│ │ ├── logic.js \# Physics & Collision
|
│ │ ├── render.js \# Drawing & Interpolation
|
||||||
│ │ ├── network.js \# Server-Sync
|
│ │ ├── network.js \# WebSocket & RTT Sync
|
||||||
│ │ └── ...
|
│ │ └── ...
|
||||||
│ ├── index.html \# Entry Point
|
│ ├── index.html \# Entry Point
|
||||||
│ └── style.css \# Retro Design
|
│ └── style.css \# Styling
|
||||||
├── secure/ \# Protected Admin Files
|
├── secure/ \# Protected Admin Files (Editor)
|
||||||
├── main.go \# Go Server Entrypoint
|
├── main.go \# HTTP Routes & Setup
|
||||||
├── simulation.go \# Server-side Physics Engine
|
├── websocket.go \# Game Loop & Streaming Logic
|
||||||
├── rng.go \# Deterministic RNG
|
├── simulation.go \# Physics Core
|
||||||
├── types.go \# Data Structures
|
├── types.go \# Data Structures
|
||||||
├── Dockerfile \# Multi-Stage Build
|
└── Dockerfile \# Multi-Stage Build
|
||||||
└── ...
|
```
|
||||||
|
|
||||||
## **📜 Legal**
|
## **📜 Legal**
|
||||||
|
|
||||||
This is a non-commercial educational project.
|
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.
|
**Run for your grade! 🏃💨**
|
||||||
* **Assets:** Font "Press Start 2P" is hosted locally.
|
|
||||||
|
|
||||||
**Good luck escaping\! 🏃💨**
|
|
||||||
|
|||||||
36
config.go
36
config.go
@@ -63,25 +63,37 @@ func initGameConfig() {
|
|||||||
}
|
}
|
||||||
log.Println("✅ Config mit Powerups geladen")
|
log.Println("✅ Config mit Powerups geladen")
|
||||||
|
|
||||||
loadChunksFromRedis()
|
defaultConfig.Chunks = loadChunksFromRedis()
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadChunksFromRedis() {
|
func loadChunksFromRedis() []ChunkDef {
|
||||||
// Gleiche Logik wie im Handler, aber speichert es in die globale Variable
|
|
||||||
if rdb == nil {
|
if rdb == nil {
|
||||||
return
|
return []ChunkDef{}
|
||||||
} // Falls Redis noch nicht da ist
|
}
|
||||||
|
|
||||||
ids, _ := rdb.SMembers(ctx, "config:chunks:list").Result()
|
ids, err := rdb.SMembers(ctx, "config:chunks:list").Result()
|
||||||
sort.Strings(ids) // WICHTIG
|
if err != nil {
|
||||||
|
log.Println("Redis: Keine Chunks geladen")
|
||||||
|
return []ChunkDef{}
|
||||||
|
}
|
||||||
|
sort.Strings(ids)
|
||||||
|
|
||||||
|
var loadedChunks []ChunkDef
|
||||||
|
|
||||||
var chunks []ChunkDef
|
|
||||||
for _, id := range ids {
|
for _, id := range ids {
|
||||||
val, _ := rdb.Get(ctx, "config:chunks:data:"+id).Result()
|
val, err := rdb.Get(ctx, "config:chunks:data:"+id).Result()
|
||||||
|
if err == nil {
|
||||||
var c ChunkDef
|
var c ChunkDef
|
||||||
json.Unmarshal([]byte(val), &c)
|
json.Unmarshal([]byte(val), &c)
|
||||||
chunks = append(chunks, c)
|
c.ID = id
|
||||||
|
loadedChunks = append(loadedChunks, c)
|
||||||
}
|
}
|
||||||
defaultConfig.Chunks = chunks
|
}
|
||||||
log.Printf("📦 %d Custom Chunks geladen", len(chunks))
|
|
||||||
|
// 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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ type SimState struct {
|
|||||||
// Anti-Cheat
|
// Anti-Cheat
|
||||||
LastJumpDist float64
|
LastJumpDist float64
|
||||||
SuspicionScore int
|
SuspicionScore int
|
||||||
|
|
||||||
|
Chunks []ChunkDef
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ func handleWebSocket(w http.ResponseWriter, r *http.Request) {
|
|||||||
Ticks: 0,
|
Ticks: 0,
|
||||||
PosY: PlayerYBase,
|
PosY: PlayerYBase,
|
||||||
NextSpawnTick: 0,
|
NextSpawnTick: 0,
|
||||||
|
Chunks: loadChunksFromRedis(),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Channel größer machen, damit bei Lag nichts blockiert
|
// 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 {
|
if tick >= s.NextSpawnTick {
|
||||||
spawnX := SpawnXStart
|
spawnX := SpawnXStart
|
||||||
|
|
||||||
chunkCount := len(defaultConfig.Chunks)
|
chunkCount := len(s.Chunks)
|
||||||
if chunkCount > 0 && s.RNG.NextFloat() > 0.8 {
|
if chunkCount > 0 && s.RNG.NextFloat() > 0.8 {
|
||||||
idx := int(s.RNG.NextRange(0, float64(chunkCount)))
|
idx := int(s.RNG.NextRange(0, float64(chunkCount)))
|
||||||
chunk := defaultConfig.Chunks[idx]
|
chunk := s.Chunks[idx]
|
||||||
|
|
||||||
for _, p := range chunk.Platforms {
|
for _, p := range chunk.Platforms {
|
||||||
createdPlats = append(createdPlats, ActivePlatform{X: spawnX + p.X, Y: p.Y, Width: p.Width, Height: p.Height})
|
createdPlats = append(createdPlats, ActivePlatform{X: spawnX + p.X, Y: p.Y, Width: p.Width, Height: p.Height})
|
||||||
|
|||||||
Reference in New Issue
Block a user