# AutoHero Server-Authority Blueprint ## Part 1: Architecture Review — Authority Gap Analysis ### Severity P0 (Critical — Exploitable Now) **GAP-1: `SaveHero` is an unrestricted cheat API** - **File:** `backend/internal/handler/game.go:472–584` - The `saveHeroRequest` struct accepts `HP`, `MaxHP`, `Attack`, `Defense`, `Speed`, `Strength`, `Constitution`, `Agility`, `Luck`, `State`, `Gold`, `XP`, `Level`, `WeaponID`, `ArmorID` — every progression-critical field. - Lines 526–573 apply each field with zero validation: no bounds checks, no delta verification, no comparison against known server state. - A `POST /api/v1/hero/save` with body `{"gold":999999,"level":100,"xp":0}` is accepted and persisted to PostgreSQL. - The auto-save in `App.tsx:256–261` sends the full client-computed hero state every 10 seconds, plus a `sendBeacon` on page unload (line 279–285). **GAP-2: Combat runs entirely on the client** - **File:** `frontend/src/game/engine.ts:464–551` (`_simulateFighting`) - The client computes hero attack damage (line 490–496), enemy attack damage (line 518–524), HP changes, crit rolls, stun checks — all locally. - The backend's `Engine.StartCombat()` (`backend/internal/game/engine.go:57–98`) is never called from any handler. `GameHandler` holds `*game.Engine` but none of its methods invoke `engine.StartCombat`, `engine.StopCombat`, or `engine.GetCombat`. - The `RequestEncounter` handler (`game.go:259–300`) returns enemy stats as JSON but does not register the combat. The enemy ID is `time.Now().UnixNano()` — ephemeral, untracked. **GAP-3: XP, gold, and level-up are client-dictated** - **File:** `frontend/src/game/engine.ts:624–671` (`_onEnemyDefeated`) - Client awards `xpGain = template.xp * (1 + hero.level * 0.1)`, adds gold, runs the `while(xp >= xpToNext)` level-up loop including stat increases. - These values are then sent to the server via `SaveHero`, overwriting server data. **GAP-4: Auth middleware is disabled** - **File:** `backend/internal/router/router.go:57–59` - `r.Use(handler.TelegramAuthMiddleware(deps.BotToken))` is commented out. - Identity falls back to `?telegramId=` query parameter (`game.go:46–61`), which anyone can spoof. ### Severity P1 (High — Broken Plumbing) **GAP-5: WebSocket protocol mismatch — server events never reach the client** - **Server sends:** flat `model.CombatEvent` JSON (`{"type":"attack","heroId":1,...}`) via `WriteJSON` (`ws.go:184`). - **Client expects:** `{type: string, payload: unknown}` envelope (`websocket.ts:12–15`). The client's `dispatch(msg)` calls `handlers.get(msg.type)` where `msg.type` comes from the envelope — but the server's flat JSON has `type` at root, not inside `payload`. Even if `type` matched, `msg.payload` would be `undefined`, and the `App.tsx` handlers destructure `msg.payload`. - **Heartbeat mismatch:** Server sends WS `PingMessage` (binary control frame, `ws.go:190`). Client sends text `"ping"` (`websocket.ts:191`) and expects text `"pong"` (`websocket.ts:109`). Server's `readPump` discards all incoming messages (`ws.go:158`), never responds with `"pong"`. Client times out after `WS_HEARTBEAT_TIMEOUT_MS` and disconnects with code 4000. - Result: the `_serverAuthoritative` flag in `engine.ts` is never set to `true` during normal gameplay. The WS `game_state` handler (`App.tsx:331–334`) that would call `engine.applyServerState()` never fires. **GAP-6: WS heroID hardcoded to 1** - **File:** `backend/internal/handler/ws.go:128–129` - Every WS client is assigned `heroID = 1`. In a multi-user scenario, all clients receive events for hero 1 only, and no other hero's combat events are routed. **GAP-7: Loot is client-generated or stubbed** - **Client:** `engine.ts:649–655` generates a trivial `LootDrop` with `itemType: 'gold'`, `rarity: Common`. - **Server:** `GetLoot` (`game.go:587–614`) returns an in-memory cache or empty list. No loot generation exists on the server side tied to enemy death. ### Severity P2 (Medium — Design Debt) **GAP-8: Buffs/debuffs not persisted across save** - `HeroStore.Save` writes hero stats but does not write to `hero_active_buffs` or `hero_active_debuffs` tables. - `ActivateBuff` handler mutates the in-memory hero, saves via `store.Save`, but buffs are lost on next DB load. **GAP-9: Engine death handler doesn't persist** - `handleEnemyDeath` awards XP/gold and runs `hero.LevelUp()` on the in-memory `*model.Hero`, but never calls `store.Save`. If the server restarts, all in-flight combat progress is lost. **GAP-10: `CheckOrigin: return true` on WebSocket** - `ws.go:17–20` — any origin can upgrade. Combined with no auth, this enables cross-site WebSocket hijacking. **GAP-11: Redis connected but unused** - `cmd/server/main.go` creates a Redis client that is never passed to any service. No session store, no rate limiting, no pub/sub for WS scaling. --- ## Part 2: Backend Engineer Prompt (Go) ### Objective Make combat, progression, and economy **server-authoritative**. The client becomes a thin renderer that sends commands and receives state updates over WebSocket. No gameplay-critical computation on the client. ### New WebSocket Message Envelope All server→client and client→server messages use this envelope: ```go // internal/model/ws_message.go type WSMessage struct { Type string `json:"type"` Payload json.RawMessage `json:"payload"` } ``` Server always sends `WSMessage`. Client always sends `WSMessage`. The `readPump` must parse incoming text into `WSMessage` and route by `Type`. ### New WS Event Types (server→client) ```json // combat_start {"type":"combat_start","payload":{"enemy":{"id":123,"name":"Forest Wolf","hp":30,"maxHp":30,"attack":8,"defense":2,"speed":1.8,"enemyType":"wolf","isElite":false},"heroHp":100,"heroMaxHp":100}} // attack (hero hits enemy) {"type":"attack","payload":{"source":"hero","damage":15,"isCrit":true,"heroHp":100,"enemyHp":15,"debuffApplied":null,"timestamp":"..."}} // attack (enemy hits hero) {"type":"attack","payload":{"source":"enemy","damage":8,"isCrit":false,"heroHp":92,"enemyHp":15,"debuffApplied":"poison","timestamp":"..."}} // hero_died {"type":"hero_died","payload":{"heroHp":0,"enemyHp":30,"killedBy":"Fire Demon"}} // combat_end (enemy defeated) {"type":"combat_end","payload":{"xpGained":50,"goldGained":30,"newXp":1250,"newGold":500,"newLevel":5,"leveledUp":true,"loot":[{"itemType":"gold","rarity":"common","goldAmount":30}]}} // level_up (emitted inside combat_end or standalone) {"type":"level_up","payload":{"newLevel":5,"hp":120,"maxHp":120,"attack":22,"defense":15,"speed":1.45,"strength":6,"constitution":6,"agility":6,"luck":6}} // buff_applied {"type":"buff_applied","payload":{"buffType":"rage","magnitude":100,"durationMs":10000,"expiresAt":"..."}} // debuff_applied {"type":"debuff_applied","payload":{"debuffType":"poison","magnitude":5,"durationMs":5000,"expiresAt":"..."}} // hero_state (full sync on connect or after revive) {"type":"hero_state","payload":{...full hero JSON...}} ``` ### New WS Command Types (client→server) ```json // Client requests next encounter (replaces REST POST /hero/encounter) {"type":"request_encounter","payload":{}} // Client requests revive (replaces REST POST /hero/revive) {"type":"request_revive","payload":{}} // Client requests buff activation (replaces REST POST /hero/buff/{buffType}) {"type":"activate_buff","payload":{"buffType":"rage"}} ``` ### Step-by-Step Changes #### 1. Fix WS protocol — `internal/handler/ws.go` **a) Respond to text "ping" with text "pong"** In `readPump` (currently lines 157–166), instead of discarding all messages, parse them: ```go func (c *Client) readPump() { defer func() { c.hub.unregister <- c c.conn.Close() }() c.conn.SetReadLimit(maxMessageSize) c.conn.SetReadDeadline(time.Now().Add(pongWait)) c.conn.SetPongHandler(func(string) error { c.conn.SetReadDeadline(time.Now().Add(pongWait)) return nil }) for { _, raw, err := c.conn.ReadMessage() if err != nil { if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) { c.hub.logger.Warn("websocket read error", "error", err) } break } text := string(raw) if text == "ping" { c.conn.SetWriteDeadline(time.Now().Add(writeWait)) c.conn.WriteMessage(websocket.TextMessage, []byte("pong")) continue } var msg model.WSMessage if err := json.Unmarshal(raw, &msg); err != nil { c.hub.logger.Warn("invalid ws message", "error", err) continue } c.handleCommand(msg) } } ``` **b) Wrap outbound events in envelope** Change `Client.send` channel type from `model.CombatEvent` to `model.WSMessage`. In `writePump`, `WriteJSON(msg)` where `msg` is already `WSMessage`. Change `Hub.broadcast` channel and `BroadcastEvent` to accept `WSMessage`. Create a helper: ```go func WrapEvent(eventType string, payload any) model.WSMessage { data, _ := json.Marshal(payload) return model.WSMessage{Type: eventType, Payload: data} } ``` **c) Extract heroID from auth** In `HandleWS`, parse `initData` query param, validate Telegram HMAC, extract user ID, load hero from DB: ```go func (h *WSHandler) HandleWS(w http.ResponseWriter, r *http.Request) { initData := r.URL.Query().Get("initData") telegramID, err := h.auth.ValidateAndExtractUserID(initData) if err != nil { http.Error(w, "unauthorized", http.StatusUnauthorized) return } hero, err := h.store.GetByTelegramID(r.Context(), telegramID) if err != nil || hero == nil { http.Error(w, "hero not found", http.StatusNotFound) return } conn, err := upgrader.Upgrade(w, r, nil) if err != nil { return } client := &Client{ hub: h.hub, conn: conn, send: make(chan model.WSMessage, sendBufSize), heroID: hero.ID, hero: hero, engine: h.engine, store: h.store, } h.hub.register <- client go client.writePump() go client.readPump() // Send initial hero state client.sendMessage(WrapEvent("hero_state", hero)) } ``` **d) Add `WSHandler` dependencies** `WSHandler` needs: `*Hub`, `*game.Engine`, `*storage.HeroStore`, `*handler.AuthValidator`, `*slog.Logger`. **e) Route commands in `handleCommand`** ```go func (c *Client) handleCommand(msg model.WSMessage) { switch msg.Type { case "request_encounter": c.handleRequestEncounter() case "request_revive": c.handleRequestRevive() case "activate_buff": c.handleActivateBuff(msg.Payload) } } ``` #### 2. Wire `StartCombat` into encounter flow — `internal/handler/ws.go` (new method on `Client`) ```go func (c *Client) handleRequestEncounter() { hero := c.hero if hero.State == model.StateDead || hero.HP <= 0 { return } if _, ok := c.engine.GetCombat(hero.ID); ok { return // already in combat } enemy := pickEnemyForLevel(hero.Level) enemy.ID = generateEnemyID() hero.State = model.StateFighting c.engine.StartCombat(hero, &enemy) c.sendMessage(WrapEvent("combat_start", map[string]any{ "enemy": enemy, "heroHp": hero.HP, "heroMaxHp": hero.MaxHP, })) } ``` Now the engine's tick loop will drive the combat, emit `CombatEvent` via `eventCh`, which flows through the bridge goroutine to `Hub.BroadcastEvent`, to matching clients. #### 3. Enrich engine events with envelope — `internal/game/engine.go` Change `emitEvent` to emit `WSMessage` instead of `CombatEvent`: ```go type Engine struct { // ... eventCh chan model.WSMessage // changed from CombatEvent } func (e *Engine) emitEvent(eventType string, payload any) { msg := model.WrapEvent(eventType, payload) select { case e.eventCh <- msg: default: e.logger.Warn("event channel full, dropping", "type", eventType) } } ``` Update all `emitEvent` call sites in `processHeroAttack`, `processEnemyAttack`, `handleEnemyDeath`, `StartCombat`, and the debuff/death checks. #### 4. Server-side rewards on enemy death — `internal/game/engine.go` `handleEnemyDeath` already awards XP/gold and calls `hero.LevelUp()`. Add: a) Persist to DB after rewards: ```go func (e *Engine) handleEnemyDeath(cs *model.CombatState, now time.Time) { hero := cs.Hero enemy := &cs.Enemy oldLevel := hero.Level hero.XP += enemy.XPReward hero.Gold += enemy.GoldReward leveledUp := false for hero.LevelUp() { leveledUp = true } hero.State = model.StateWalking // Persist if e.store != nil { ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() if err := e.store.Save(ctx, hero); err != nil { e.logger.Error("failed to persist after enemy death", "error", err) } } // Emit combat_end with rewards e.emitEvent("combat_end", map[string]any{ "xpGained": enemy.XPReward, "goldGained": enemy.GoldReward, "newXp": hero.XP, "newGold": hero.Gold, "newLevel": hero.Level, "leveledUp": leveledUp, "loot": generateLoot(enemy, hero), }) if leveledUp { e.emitEvent("level_up", map[string]any{ "newLevel": hero.Level, "hp": hero.HP, "maxHp": hero.MaxHP, "attack": hero.Attack, "defense": hero.Defense, "speed": hero.Speed, "strength": hero.Strength, "constitution": hero.Constitution, "agility": hero.Agility, "luck": hero.Luck, }) } delete(e.combats, cs.HeroID) } ``` b) Add `store *storage.HeroStore` to `Engine` struct, pass it from `main.go`. #### 5. Implement server-side loot generation — `internal/game/loot.go` (new file) ```go package game import "math/rand" func generateLoot(enemy *model.Enemy, hero *model.Hero) []LootDrop { roll := rand.Float64() var rarity string switch { case roll < 0.001: rarity = "legendary" case roll < 0.01: rarity = "epic" case roll < 0.05: rarity = "rare" case roll < 0.25: rarity = "uncommon" default: rarity = "common" } return []LootDrop{{ ItemType: "gold", Rarity: rarity, GoldAmount: enemy.GoldReward, }} } type LootDrop struct { ItemType string `json:"itemType"` Rarity string `json:"rarity"` GoldAmount int64 `json:"goldAmount"` } ``` #### 6. Replace `SaveHero` — `internal/handler/game.go` **Delete** the current `SaveHero` handler entirely (lines 471–585). Replace with a minimal preferences-only endpoint: ```go type savePreferencesRequest struct { // Only non-gameplay settings } func (h *GameHandler) SavePreferences(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) } ``` Update the route in `router.go`: replace `r.Post("/hero/save", gameH.SaveHero)` with `r.Post("/hero/preferences", gameH.SavePreferences)` (or remove entirely for now). Keep `POST /hero/encounter`, `POST /hero/revive`, and `POST /hero/buff/{buffType}` as REST fallbacks, but the primary flow should be WS commands. The REST encounter handler should also call `engine.StartCombat`: ```go func (h *GameHandler) RequestEncounter(w http.ResponseWriter, r *http.Request) { // ... existing hero lookup ... enemy := pickEnemyForLevel(hero.Level) enemy.ID = time.Now().UnixNano() h.engine.StartCombat(hero, &enemy) // NEW: register combat writeJSON(w, http.StatusOK, encounterEnemyResponse{...}) } ``` #### 7. Persist buffs/debuffs — `internal/storage/hero_store.go` Add methods to write/read `hero_active_buffs` and `hero_active_debuffs` tables. Update `Save` to transactionally persist hero + buffs + debuffs. Update `GetByTelegramID` to join and hydrate them. #### 8. Enable auth middleware — `internal/router/router.go` Uncomment line 59: ```go r.Use(handler.TelegramAuthMiddleware(deps.BotToken)) ``` Fix `validateInitData` to reject empty `botToken` instead of skipping HMAC. Restrict CORS origin to the Telegram Mini App domain instead of `*`. Set `CheckOrigin` in ws.go to validate against allowed origins. #### 9. Hero state synchronization on WS connect When a client connects, send `hero_state` with the full current hero. If the hero is in an active combat, also send `combat_start` with the current enemy and HP values so the client can resume rendering mid-fight. #### 10. Auto-save on disconnect In `readPump`'s defer (when client disconnects), if hero is in combat, call `engine.StopCombat(heroID)` and persist the hero state to DB. ### Files to Modify | File | Action | |------|--------| | `internal/model/ws_message.go` | **New** — `WSMessage` struct + `WrapEvent` helper | | `internal/handler/ws.go` | Major rewrite — envelope, text ping/pong, command routing, auth, encounter/revive/buff handlers | | `internal/game/engine.go` | Change `eventCh` to `chan WSMessage`, add `store`, enrich `handleEnemyDeath` with rewards+persist | | `internal/game/loot.go` | **New** — `generateLoot` | | `internal/handler/game.go` | Delete `SaveHero` (lines 471–585), wire `StartCombat` into `RequestEncounter` | | `internal/router/router.go` | Uncomment auth, remove `/hero/save` route, pass engine to WSHandler | | `internal/storage/hero_store.go` | Add buff/debuff persistence in `Save` and `GetByTelegramID` | | `internal/handler/auth.go` | Reject empty `botToken`, export `ValidateAndExtractUserID` for WS | | `cmd/server/main.go` | Pass `store` to engine, pass `engine` + `store` + `auth` to WSHandler | ### Risks 1. **Engine holds `*model.Hero` in memory** — if the REST handler also loads the same hero from DB and modifies it, state diverges. Mitigation: during active combat, REST reads should load from engine's `CombatState`, not DB. Or: make the engine the single writer for heroes in combat. 2. **Tick-based DoT/regen** — `ProcessDebuffDamage` and `ProcessEnemyRegen` are called in `processTick` but may need careful timing. Verify they run at 10Hz and produce sane damage values. 3. **Event channel backpressure** — if a slow client falls behind, events are dropped (non-blocking send). Consider per-client buffering with disconnect on overflow (already partly implemented via `sendBufSize`). --- ## Part 3: Frontend Engineer Prompt (React/TS) ### Objective Transform the frontend from a "simulate locally, sync periodically" model to a **"render what the server tells you"** model. The client sends commands over WebSocket, receives authoritative state updates, and renders them. Client-side combat simulation is removed. ### What the Frontend Keeps Ownership Of - **Rendering:** PixiJS canvas, isometric projection, sprites, draw calls - **Camera:** pan, zoom, shake effects - **Animation:** walking cycle, attack animations, death animations, visual transitions - **UI overlays:** HUD, HP bars, buff bars, death screen, loot popups, floating damage numbers - **Walking movement:** client drives the visual walking animation (diagonal drift) between encounters. The walking is cosmetic — the server decides when an encounter starts, not the client. - **Input:** touch/click events for buff activation, revive button - **Sound/haptics:** triggered by incoming server events ### What the Frontend Stops Doing - Computing damage (hero or enemy) - Rolling crits, applying debuffs - Deciding when enemies die - Awarding XP, gold, levels - Generating loot - Running `_simulateFighting` / `_onEnemyDefeated` / `_spawnEnemy` - Auto-saving hero stats to `/hero/save` - Sending `sendBeacon` with hero stats on unload - Level-up stat calculations ### New WS Message Types to Consume ```typescript // src/network/types.ts (new file or add to existing types) interface CombatStartPayload { enemy: { id: number; name: string; hp: number; maxHp: number; attack: number; defense: number; speed: number; enemyType: string; isElite: boolean; }; heroHp: number; heroMaxHp: number; } interface AttackPayload { source: 'hero' | 'enemy'; damage: number; isCrit: boolean; heroHp: number; enemyHp: number; debuffApplied: string | null; timestamp: string; } interface HeroDiedPayload { heroHp: number; enemyHp: number; killedBy: string; } interface CombatEndPayload { xpGained: number; goldGained: number; newXp: number; newGold: number; newLevel: number; leveledUp: boolean; loot: LootDrop[]; } interface LevelUpPayload { newLevel: number; hp: number; maxHp: number; attack: number; defense: number; speed: number; strength: number; constitution: number; agility: number; luck: number; } interface HeroStatePayload { id: number; hp: number; maxHp: number; attack: number; defense: number; speed: number; // ...all fields } interface BuffAppliedPayload { buffType: string; magnitude: number; durationMs: number; expiresAt: string; } interface DebuffAppliedPayload { debuffType: string; magnitude: number; durationMs: number; expiresAt: string; } ``` ### New WS Commands to Send ```typescript // Request next encounter (triggers server combat) ws.send('request_encounter', {}); // Request revive ws.send('request_revive', {}); // Activate buff ws.send('activate_buff', { buffType: 'rage' }); ``` ### Step-by-Step Changes #### 1. Fix WebSocket client — `src/network/websocket.ts` The current implementation already expects `{type, payload}` envelope and handles text `"pong"` — this matches the new server protocol. **No changes needed** to the message parsing. #### 2. Gut `_simulateFighting` — `src/game/engine.ts` Remove the entire fighting simulation. The `_simulateTick` method should only handle the walking phase visually: ```typescript private _simulateTick(dtMs: number): void { if (!this._gameState.hero) return; if (this._gameState.phase === GamePhase.Walking) { this._simulateWalking(dtMs); } // Fighting, death, loot — all driven by server events now } ``` **Remove entirely:** - `_simulateFighting` (lines 464–551) - `_onEnemyDefeated` (lines 624–671) - `_spawnEnemy` (lines 553–572) - `_requestEncounter` (lines 575–595) — encounters are now requested via WS - `_effectiveHeroDamage`, `_effectiveHeroAttackSpeed`, `_incomingDamageMultiplier` — server computes these - `_isBuffActive`, `_isHeroStunned` — server handles stun/buff logic - Attack timer fields: `_nextHeroAttackMs`, `_nextEnemyAttackMs` **Keep** `_simulateWalking` but strip it down to visual movement only — remove the encounter spawning logic: ```typescript private _simulateWalking(dtMs: number): void { const hero = this._gameState.hero!; const moveSpeed = 0.002; // tiles per ms hero.position.x += moveSpeed * dtMs * 0.7071; hero.position.y += moveSpeed * dtMs * 0.7071; } ``` #### 3. Remove `_serverAuthoritative` flag — `src/game/engine.ts` This flag is no longer needed. The engine is always server-authoritative. Remove: - The `_serverAuthoritative` field (line 61–62) - The `if (!this._serverAuthoritative)` check in `_update` (line 334) - The flag-setting in `applyServerState` (line 185) - The `if (this._serverAuthoritative) return` guard in `reviveHero` (line 674) #### 4. Add server event handlers to the engine — `src/game/engine.ts` ```typescript handleCombatStart(payload: CombatStartPayload): void { const enemy: EnemyState = { id: payload.enemy.id, name: payload.enemy.name, hp: payload.enemy.hp, maxHp: payload.enemy.maxHp, position: { x: this._gameState.hero!.position.x + 1.5, y: this._gameState.hero!.position.y, }, attackSpeed: payload.enemy.speed, damage: payload.enemy.attack, defense: payload.enemy.defense, enemyType: payload.enemy.enemyType as EnemyType, }; this._gameState = { ...this._gameState, phase: GamePhase.Fighting, enemy, hero: { ...this._gameState.hero!, hp: payload.heroHp, maxHp: payload.heroMaxHp }, }; this._onStateChange?.(this._gameState); } handleAttack(payload: AttackPayload): void { const hero = { ...this._gameState.hero! }; const enemy = this._gameState.enemy ? { ...this._gameState.enemy } : null; hero.hp = payload.heroHp; if (enemy) enemy.hp = payload.enemyHp; this._gameState = { ...this._gameState, hero, enemy }; this._onStateChange?.(this._gameState); } handleHeroDied(payload: HeroDiedPayload): void { this._gameState = { ...this._gameState, phase: GamePhase.Dead, hero: { ...this._gameState.hero!, hp: 0 }, }; this._onStateChange?.(this._gameState); } handleCombatEnd(payload: CombatEndPayload): void { const hero = { ...this._gameState.hero! }; hero.xp = payload.newXp; hero.gold = payload.newGold; hero.level = payload.newLevel; hero.xpToNext = Math.round(100 * Math.pow(1.1, payload.newLevel)); this._gameState = { ...this._gameState, phase: GamePhase.Walking, hero, enemy: null, loot: payload.loot.length > 0 ? payload.loot[0] : null, }; this._onStateChange?.(this._gameState); } handleLevelUp(payload: LevelUpPayload): void { const hero = { ...this._gameState.hero! }; hero.level = payload.newLevel; hero.hp = payload.hp; hero.maxHp = payload.maxHp; hero.damage = payload.attack; hero.defense = payload.defense; hero.attackSpeed = payload.speed; hero.strength = payload.strength; hero.constitution = payload.constitution; hero.agility = payload.agility; hero.luck = payload.luck; hero.xpToNext = Math.round(100 * Math.pow(1.1, payload.newLevel)); this._gameState = { ...this._gameState, hero }; this._onStateChange?.(this._gameState); } handleHeroState(payload: HeroStatePayload): void { const heroState = heroResponseToState(payload); this._gameState = { ...this._gameState, hero: heroState, }; this._onStateChange?.(this._gameState); } ``` #### 5. Wire WS events in App.tsx — `src/App.tsx` Replace existing WS handlers with: ```typescript ws.on('hero_state', (msg) => { engine.handleHeroState(msg.payload as HeroStatePayload); }); ws.on('combat_start', (msg) => { engine.handleCombatStart(msg.payload as CombatStartPayload); }); ws.on('attack', (msg) => { const p = msg.payload as AttackPayload; engine.handleAttack(p); hapticImpact(p.isCrit ? 'heavy' : 'light'); engine.camera.shake(p.isCrit ? 8 : 4, p.isCrit ? 250 : 150); }); ws.on('hero_died', (msg) => { engine.handleHeroDied(msg.payload as HeroDiedPayload); hapticNotification('error'); }); ws.on('combat_end', (msg) => { engine.handleCombatEnd(msg.payload as CombatEndPayload); hapticNotification('success'); }); ws.on('level_up', (msg) => { engine.handleLevelUp(msg.payload as LevelUpPayload); hapticNotification('success'); }); ``` #### 6. Replace encounter trigger Remove `engine.setEncounterProvider(...)`. Instead, after walking for X seconds, send a WS command: ```typescript // In _simulateWalking: private _walkTimerMs = 0; private static readonly ENCOUNTER_REQUEST_INTERVAL_MS = 3000; private _simulateWalking(dtMs: number): void { // ... visual movement ... this._walkTimerMs += dtMs; if (this._walkTimerMs >= GameEngine.ENCOUNTER_REQUEST_INTERVAL_MS) { this._walkTimerMs = 0; this._onRequestEncounter?.(); } } // In App.tsx: engine.onRequestEncounter(() => { ws.send('request_encounter', {}); }); ``` #### 7. Remove auto-save - Remove `engine.onSave(...)` registration - Remove `_triggerSave`, `_saveTimerMs`, `SAVE_INTERVAL_MS` from engine.ts - Remove `handleBeforeUnload` / `sendBeacon` - Remove `heroStateToSaveRequest` function - Remove the import of `saveHero` from `network/api.ts` #### 8. Fix revive flow ```typescript const handleRevive = useCallback(() => { ws.send('request_revive', {}); }, []); ``` Remove `engine.reviveHero()` from engine.ts. #### 9. Fix buff activation ```typescript const handleBuffActivate = useCallback((type: BuffType) => { ws.send('activate_buff', { buffType: type }); hapticImpact('medium'); }, []); ``` Keep REST `activateBuff` as fallback if WS disconnected. #### 10. Handle WS disconnect gracefully Show "Reconnecting..." overlay. On reconnect, server sends `hero_state` automatically. Remove `initDemoState` fallback (or gate behind explicit `DEV_OFFLINE` flag). ### Files to Modify | File | Action | |------|--------| | `src/game/engine.ts` | Major rewrite — remove fighting sim, add server event handlers, remove save logic | | `src/App.tsx` | Replace WS handlers, remove auto-save, encounter provider, fix revive/buff to use WS | | `src/network/websocket.ts` | No changes needed (already correct format) | | `src/network/api.ts` | Remove `saveHero`. Keep `initHero`, `requestRevive`, `activateBuff` as REST fallbacks | | `src/network/types.ts` | **New** — WS payload interfaces | | `src/game/types.ts` | Remove types only used by client-side combat (if any) | | `src/ui/LootPopup.tsx` | Wire to `combat_end` loot payload instead of client-generated loot | ### Risks 1. **Latency perception** — At 10Hz server tick (100ms), attacks feel delayed compared to client-side instant feedback. Mitigation: client can play "optimistic" attack animations immediately and snap HP values when the `attack` event arrives. 100ms is fast enough for idle game feel. 2. **Walking encounter timing** — If the client requests encounters every 3s but WS latency varies, encounters may feel irregular. Mitigation: server enforces minimum walk cooldown (e.g. 2s) and responds immediately when valid. 3. **Disconnect during combat** — If the client disconnects mid-fight, the server keeps the combat running. Hero might die while offline. On reconnect, server sends current state (possibly dead). Client must handle resuming into any phase. 4. **Demo mode removal** — Removing `initDemoState` means the game won't work without a backend. Keep a `DEV_OFFLINE` env flag for development. --- ## Implementation Priority | Order | Task | Owner | Blocks | |-------|------|-------|--------| | 1 | Enable auth middleware + fix `validateInitData` | Backend | Everything | | 2 | WS envelope + text ping/pong + heroID from auth | Backend | Frontend WS integration | | 3 | Wire `StartCombat` into `RequestEncounter` handler | Backend | Server-driven combat | | 4 | Enrich engine events with rewards, persist on death | Backend | Frontend can render combat | | 5 | Delete `SaveHero`, add `SavePreferences` | Backend | Frontend must stop auto-save | | 6 | Frontend: remove client combat sim, add server event handlers | Frontend | Needs steps 2–4 done | | 7 | Frontend: switch encounter/revive/buff to WS commands | Frontend | Needs step 2 done | | 8 | Buff/debuff DB persistence | Backend | Nice-to-have for MVP | | 9 | Server-side loot generation | Backend | Nice-to-have for MVP | | 10 | Restrict CORS + WS CheckOrigin | Backend | Pre-production hardening | Steps 1–5 (backend) and 6–7 (frontend) can be parallelized once the WS contract (envelope format + event types) is agreed upon — which this document defines.