You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

31 KiB

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:472584
  • The saveHeroRequest struct accepts HP, MaxHP, Attack, Defense, Speed, Strength, Constitution, Agility, Luck, State, Gold, XP, Level, WeaponID, ArmorID — every progression-critical field.
  • Lines 526573 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:256261 sends the full client-computed hero state every 10 seconds, plus a sendBeacon on page unload (line 279285).

GAP-2: Combat runs entirely on the client

  • File: frontend/src/game/engine.ts:464551 (_simulateFighting)
  • The client computes hero attack damage (line 490496), enemy attack damage (line 518524), HP changes, crit rolls, stun checks — all locally.
  • The backend's Engine.StartCombat() (backend/internal/game/engine.go:5798) 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:259300) 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:624671 (_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:5759
  • r.Use(handler.TelegramAuthMiddleware(deps.BotToken)) is commented out.
  • Identity falls back to ?telegramId= query parameter (game.go:4661), 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:1215). 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:331334) that would call engine.applyServerState() never fires.

GAP-6: WS heroID hardcoded to 1

  • File: backend/internal/handler/ws.go:128129
  • 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:649655 generates a trivial LootDrop with itemType: 'gold', rarity: Common.
  • Server: GetLoot (game.go:587614) 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:1720 — 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:

// 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)

// 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)

// 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 157166), instead of discarding all messages, parse them:

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:

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:

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

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)

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:

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:

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)

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 SaveHerointernal/handler/game.go

Delete the current SaveHero handler entirely (lines 471585). Replace with a minimal preferences-only endpoint:

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:

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:

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 NewWSMessage 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 NewgenerateLoot
internal/handler/game.go Delete SaveHero (lines 471585), 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/regenProcessDebuffDamage 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

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

// 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 _simulateFightingsrc/game/engine.ts

Remove the entire fighting simulation. The _simulateTick method should only handle the walking phase visually:

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 464551)
  • _onEnemyDefeated (lines 624671)
  • _spawnEnemy (lines 553572)
  • _requestEncounter (lines 575595) — 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:

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 6162)
  • 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

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:

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:

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

const handleRevive = useCallback(() => {
  ws.send('request_revive', {});
}, []);

Remove engine.reviveHero() from engine.ts.

9. Fix buff activation

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 24 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 15 (backend) and 67 (frontend) can be parallelized once the WS contract (envelope format + event types) is agreed upon — which this document defines.