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:472–584 - The
saveHeroRequeststruct acceptsHP,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/savewith body{"gold":999999,"level":100,"xp":0}is accepted and persisted to PostgreSQL. - The auto-save in
App.tsx:256–261sends the full client-computed hero state every 10 seconds, plus asendBeaconon 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.GameHandlerholds*game.Enginebut none of its methods invokeengine.StartCombat,engine.StopCombat, orengine.GetCombat. - The
RequestEncounterhandler (game.go:259–300) returns enemy stats as JSON but does not register the combat. The enemy ID istime.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 thewhile(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.CombatEventJSON ({"type":"attack","heroId":1,...}) viaWriteJSON(ws.go:184). - Client expects:
{type: string, payload: unknown}envelope (websocket.ts:12–15). The client'sdispatch(msg)callshandlers.get(msg.type)wheremsg.typecomes from the envelope — but the server's flat JSON hastypeat root, not insidepayload. Even iftypematched,msg.payloadwould beundefined, and theApp.tsxhandlers destructuremsg.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'sreadPumpdiscards all incoming messages (ws.go:158), never responds with"pong". Client times out afterWS_HEARTBEAT_TIMEOUT_MSand disconnects with code 4000. - Result: the
_serverAuthoritativeflag inengine.tsis never set totrueduring normal gameplay. The WSgame_statehandler (App.tsx:331–334) that would callengine.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–655generates a trivialLootDropwithitemType: '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.Savewrites hero stats but does not write tohero_active_buffsorhero_active_debuffstables.ActivateBuffhandler mutates the in-memory hero, saves viastore.Save, but buffs are lost on next DB load.
GAP-9: Engine death handler doesn't persist
handleEnemyDeathawards XP/gold and runshero.LevelUp()on the in-memory*model.Hero, but never callsstore.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.gocreates 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 157–166), 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 SaveHero — internal/handler/game.go
Delete the current SaveHero handler entirely (lines 471–585). 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 |
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
- Engine holds
*model.Heroin 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'sCombatState, not DB. Or: make the engine the single writer for heroes in combat. - Tick-based DoT/regen —
ProcessDebuffDamageandProcessEnemyRegenare called inprocessTickbut may need careful timing. Verify they run at 10Hz and produce sane damage values. - 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
sendBeaconwith 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 _simulateFighting — src/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 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:
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
_serverAuthoritativefield (line 61–62) - The
if (!this._serverAuthoritative)check in_update(line 334) - The flag-setting in
applyServerState(line 185) - The
if (this._serverAuthoritative) returnguard inreviveHero(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_MSfrom engine.ts - Remove
handleBeforeUnload/sendBeacon - Remove
heroStateToSaveRequestfunction - Remove the import of
saveHerofromnetwork/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
- 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
attackevent arrives. 100ms is fast enough for idle game feel. - 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.
- 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.
- Demo mode removal — Removing
initDemoStatemeans the game won't work without a backend. Keep aDEV_OFFLINEenv 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.