# Server-Authoritative Architecture: Implementation Specification Status: APPROVED for implementation Date: 2026-03-27 This spec is the contract between the backend and frontend agents. Each phase is independently deployable. Phases must ship in order. --- ## 1. WebSocket Message Protocol All messages use a typed envelope: ```json {"type": "", "payload": { ... }} ``` The `type` field is the discriminant. `payload` is always an object (never null). ### 1.1 Server -> Client Messages | type | payload | when sent | |---|---|---| | `hero_state` | Full `Hero` JSON (same shape as GET /hero) | On WS connect; after level-up; after revive; after equipment change | | `hero_move` | `{x, y, targetX, targetY, speed, heading}` | 2 Hz while hero is walking (every 500ms) | | `position_sync` | `{x, y, waypointIndex, waypointFraction, state}` | Every 10s as drift correction | | `route_assigned` | `{roadId, waypoints: [{x,y}], destinationTownId, speed}` | When hero starts walking a new road segment | | `combat_start` | `{enemy: {name, type, hp, maxHp, attack, defense, speed, isElite}}` | Server decides encounter | | `attack` | `{source: "hero"\|"enemy", damage, isCrit, heroHp, enemyHp, debuffApplied}` | Each swing during combat | | `combat_end` | `{xpGained, goldGained, loot: [{itemType, name, rarity}], leveledUp, newLevel}` | Hero wins fight | | `hero_died` | `{killedBy: ""}` | Hero HP reaches 0 | | `hero_revived` | `{hp}` | After revive (client-requested or auto) | | `buff_applied` | `{buffType, duration, magnitude}` | Buff activated | | `town_enter` | `{townId, townName, biome, npcs: [{id, name, type}], restDurationMs}` | Hero arrives at town | | `town_exit` | `{}` | Rest complete, hero leaves town | | `npc_encounter` | `{npcId, npcName, role, dialogue, cost}` | Wandering merchant on road | | `level_up` | `{newLevel, statChanges: {hp, attack, defense, strength, constitution, agility, luck}}` | On level-up | | `equipment_change` | `{slot: "weapon"\|"armor", item}` | Auto-equip happened | | `potion_collected` | `{count}` | Potion dropped from loot | | `quest_available` | `{questId, title, description, npcName}` | NPC offers quest in town | | `quest_progress` | `{questId, current, target}` | Kill/collect progress update | | `quest_complete` | `{questId, title, rewards: {xp, gold}}` | Quest objectives met | | `error` | `{code, message}` | Invalid client action | ### 1.2 Client -> Server Messages | type | payload | effect | |---|---|---| | `activate_buff` | `{buffType: string}` | Activate a buff (server validates charges/gold) | | `use_potion` | `{}` | Use healing potion (server validates potions > 0, in combat) | | `accept_quest` | `{questId: int}` | Accept quest from NPC (must be in town with that NPC) | | `claim_quest` | `{questId: int}` | Claim completed quest reward | | `npc_interact` | `{npcId: int}` | Interact with NPC (must be in same town) | | `npc_alms_accept` | `{}` | Accept wandering merchant offer | | `npc_alms_decline` | `{}` | Decline wandering merchant offer | | `revive` | `{}` | Request revive (when dead) | | `ping` | `{}` | Keepalive (server replies with `{"type":"pong","payload":{}}`) | ### 1.3 Backward Compatibility Notes - Current `CombatEvent` struct (type/heroId/damage/source/isCrit/debuffApplied/heroHp/enemyHp/timestamp) maps directly into the new `attack` envelope payload. Migration: wrap in `{"type":"attack","payload":{...}}`. - Current `readPump` handles raw `"ping"` string. Change to parse JSON envelope; keep raw `"ping"` support during transition. - `maxMessageSize` must increase from 512 to 4096 to accommodate `hero_state` and `route_assigned` payloads. --- ## 2. Backend Changes ### 2.1 New GameState Values Add to `model/combat.go`: ```go const ( StateWalking GameState = "walking" StateFighting GameState = "fighting" StateDead GameState = "dead" StateResting GameState = "resting" // NEW: in town, resting StateInTown GameState = "in_town" // NEW: in town, interacting with NPCs ) ``` ### 2.2 Hero Model Additions Add fields to `model.Hero`: ```go // Movement state (persisted to DB for reconnect recovery) CurrentTownID *int64 `json:"currentTownId,omitempty"` DestinationTownID *int64 `json:"destinationTownId,omitempty"` RoadID *int64 `json:"roadId,omitempty"` WaypointIndex int `json:"waypointIndex"` WaypointFraction float64 `json:"waypointFraction"` // 0.0-1.0 between waypoints ``` Migration: `000007_hero_movement_state.sql` ```sql ALTER TABLE heroes ADD COLUMN current_town_id BIGINT REFERENCES towns(id), ADD COLUMN destination_town_id BIGINT REFERENCES towns(id), ADD COLUMN road_id BIGINT, ADD COLUMN waypoint_index INT NOT NULL DEFAULT 0, ADD COLUMN waypoint_fraction DOUBLE PRECISION NOT NULL DEFAULT 0; ``` ### 2.3 Road Graph (DB + in-memory) Migration: `000008_roads.sql` ```sql CREATE TABLE roads ( id BIGSERIAL PRIMARY KEY, from_town_id BIGINT NOT NULL REFERENCES towns(id), to_town_id BIGINT NOT NULL REFERENCES towns(id), distance DOUBLE PRECISION NOT NULL, UNIQUE(from_town_id, to_town_id) ); CREATE TABLE road_waypoints ( id BIGSERIAL PRIMARY KEY, road_id BIGINT NOT NULL REFERENCES roads(id), seq INT NOT NULL, x DOUBLE PRECISION NOT NULL, y DOUBLE PRECISION NOT NULL, UNIQUE(road_id, seq) ); -- Linear chain: Willowdale(1) -> Thornwatch(2) -> ... -> Starfall(7) -- Waypoints generated at insert time with +-2 tile jitter every 20 tiles ``` In-memory: `game.RoadGraph` struct loaded at startup. Contains all roads + waypoints. Immutable after load. ```go type RoadGraph struct { Roads map[int64]*Road // road ID -> road TownRoads map[int64][]*Road // town ID -> outgoing roads Towns map[int64]*model.Town // town ID -> town TownOrder []int64 // ordered town IDs for sequential traversal } type Road struct { ID int64 FromTownID int64 ToTownID int64 Waypoints []Point // ordered list Distance float64 } type Point struct { X, Y float64 } ``` ### 2.4 Movement System New file: `backend/internal/game/movement.go` ```go type HeroMovement struct { HeroID int64 Hero *model.Hero // live reference CurrentX float64 CurrentY float64 TargetX float64 // next waypoint TargetY float64 Speed float64 // units/sec, base 2.0 State model.GameState DestinationTownID int64 Road *Road WaypointIndex int WaypointFraction float64 LastEncounterAt time.Time // cooldown tracking RestUntil time.Time // when resting in town } const ( BaseMoveSpeed = 2.0 // units per second MovementTickRate = 500 * time.Millisecond // 2 Hz PositionSyncRate = 10 * time.Second EncounterCooldownBase = 15 * time.Second // min time between encounters EncounterChancePerTick = 0.04 // ~4-5 per road segment TownRestDuration = 7 * time.Second // 5-10s, randomized per visit ) ``` **Movement tick logic** (called from `Engine.processTick`): ``` for each online hero with active movement: if state == "resting" and now > restUntil: set state = "walking" pick next destination town assign route send town_exit + route_assigned continue if state != "walking": continue // Advance position along waypoints distanceThisTick = speed * tickDelta.Seconds() advance hero along road waypoints by distanceThisTick update waypointIndex, waypointFraction, currentX, currentY // Check for random encounter (if cooldown passed) if now - lastEncounterAt > encounterCooldown: if rand < encounterChancePerTick: spawn enemy for hero level start combat (engine.StartCombat) send combat_start lastEncounterAt = now // Check if reached destination town if waypointIndex >= len(road.Waypoints) - 1 and fraction >= 1.0: set state = "resting" set restUntil = now + randomDuration(5s, 10s) set currentTownID = destinationTownID send town_enter // Send position update (2 Hz) send hero_move {x, y, targetX, targetY, speed, heading} ``` **Destination selection:** ``` func pickNextTown(hero, roadGraph) townID: if hero has no currentTownID: return nearest town by Euclidean distance // Walk the town chain: find current position, go to next currentIdx = indexOf(hero.currentTownID, roadGraph.TownOrder) nextIdx = currentIdx + 1 if nextIdx >= len(TownOrder): nextIdx = currentIdx - 1 // reverse at chain end return TownOrder[nextIdx] ``` Heroes bounce back and forth along the 7-town chain. ### 2.5 Engine Integration `Engine` gains: ```go type Engine struct { // ... existing fields ... movements map[int64]*HeroMovement // keyed by hero ID roadGraph *RoadGraph moveTicker *time.Ticker // 2 Hz syncTicker *time.Ticker // 0.1 Hz } ``` `Engine.Run` changes: ```go func (e *Engine) Run(ctx context.Context) error { combatTicker := time.NewTicker(e.tickRate) // existing: 100ms moveTicker := time.NewTicker(MovementTickRate) // new: 500ms syncTicker := time.NewTicker(PositionSyncRate) // new: 10s for { select { case <-ctx.Done(): ... case now := <-combatTicker.C: e.processCombatTick(now) // renamed from processTick case now := <-moveTicker.C: e.processMovementTick(now) case now := <-syncTicker.C: e.processPositionSync(now) } } } ``` ### 2.6 Hub Refactor The `Hub` currently only broadcasts `model.CombatEvent`. Change to broadcast generic envelopes: ```go // WSEnvelope is the wire format for all WS messages. type WSEnvelope struct { Type string `json:"type"` Payload interface{} `json:"payload"` } type Client struct { hub *Hub conn *websocket.Conn send chan WSEnvelope // changed from model.CombatEvent heroID int64 } type Hub struct { // ... existing fields ... broadcast chan WSEnvelope // changed incoming chan ClientMessage // NEW: messages from clients } type ClientMessage struct { HeroID int64 Type string Payload json.RawMessage } ``` `readPump` change: parse incoming JSON as `WSEnvelope`, route to `Hub.incoming` channel. The engine reads from `Hub.incoming` and dispatches. `writePump` change: serialize `WSEnvelope` via `WriteJSON`. `BroadcastEvent` still exists but now wraps `CombatEvent` in an envelope before sending. New method: ```go // SendToHero sends a message to all connections for a specific hero. func (h *Hub) SendToHero(heroID int64, msgType string, payload interface{}) { env := WSEnvelope{Type: msgType, Payload: payload} h.mu.RLock() for client := range h.clients { if client.heroID == heroID { select { case client.send <- env: default: go func(c *Client) { h.unregister <- c }(client) } } } h.mu.RUnlock() } ``` ### 2.7 Hero Lifecycle on WS Connect/Disconnect **On connect:** 1. Load hero from DB 2. If hero has no movement state, initialize: place at nearest town, pick destination, assign route 3. Create `HeroMovement` in engine 4. Send `hero_state` (full snapshot) 5. Send `route_assigned` (current route) 6. If hero was mid-combat (state == fighting), resume or cancel combat **On disconnect:** 1. Persist hero state to DB (position, HP, gold, etc.) 2. Remove `HeroMovement` from engine 3. Hero becomes eligible for offline simulator ticks ### 2.8 Combat Integration with Movement When `Engine` decides to start combat (from movement tick): 1. Stop hero movement (state = fighting) 2. Call existing `Engine.StartCombat(hero, enemy)` 3. Send `combat_start` via Hub When combat ends (enemy dies in `handleEnemyDeath`): 1. Apply rewards (existing `onEnemyDeath` callback) 2. Set state back to "walking" 3. Send `combat_end` with rewards summary 4. Resume movement from where hero stopped When hero dies: 1. Send `hero_died` 2. Set state to "dead" 3. Stop movement 4. Wait for client `revive` message (or auto-revive after 1 hour, same as offline) ### 2.9 Server-Side Potion Use When client sends `use_potion`: 1. Validate: hero is in combat, hero.Potions > 0, hero.HP > 0 2. Apply: hero.HP += hero.MaxHP * 30 / 100, clamp to MaxHP, hero.Potions-- 3. Emit `attack` event with source "potion" (or a new `potion_used` type) 4. Error envelope if validation fails --- ## 3. Frontend Changes ### 3.1 What to REMOVE from `src/game/engine.ts` - All movement/walking logic (position calculation, direction, speed) - Combat simulation (damage calculation, attack timing, HP tracking) - Encounter triggering (requestEncounter calls) - Victory reporting (reportVictory calls) - Save calls (/hero/save) - Local state mutation for HP, XP, gold, equipment (all comes from server) ### 3.2 What to KEEP in `src/game/engine.ts` - `requestAnimationFrame` render loop - Camera follow (track hero position) - Sprite rendering, animations - UI overlay rendering (HP bar, XP bar, buff icons) ### 3.3 New: WS Message Handler ```typescript // src/game/ws-handler.ts interface WSEnvelope { type: string; payload: Record; } class GameWSHandler { private ws: WebSocket; private gameState: GameState; // local render state onMessage(env: WSEnvelope) { switch (env.type) { case 'hero_state': this.handleHeroState(env.payload); break; case 'hero_move': this.handleHeroMove(env.payload); break; case 'position_sync': this.handlePositionSync(env.payload); break; case 'route_assigned': this.handleRouteAssigned(env.payload); break; case 'combat_start': this.handleCombatStart(env.payload); break; case 'attack': this.handleAttack(env.payload); break; case 'combat_end': this.handleCombatEnd(env.payload); break; case 'hero_died': this.handleHeroDied(env.payload); break; case 'hero_revived': this.handleHeroRevived(env.payload); break; case 'town_enter': this.handleTownEnter(env.payload); break; case 'town_exit': this.handleTownExit(env.payload); break; case 'level_up': this.handleLevelUp(env.payload); break; case 'equipment_change': this.handleEquipmentChange(env.payload); break; // ... etc } } // Client -> Server sendActivateBuff(buffType: string) { this.send({ type: 'activate_buff', payload: { buffType } }); } sendUsePotion() { this.send({ type: 'use_potion', payload: {} }); } sendRevive() { this.send({ type: 'revive', payload: {} }); } } ``` ### 3.4 Position Interpolation Frontend must interpolate between `hero_move` updates (received at 2 Hz, rendered at 60 fps): ```typescript // On receiving hero_move: this.prevPosition = this.currentPosition; this.targetPosition = { x: payload.x, y: payload.y }; this.moveTarget = { x: payload.targetX, y: payload.targetY }; this.heroSpeed = payload.speed; this.lastUpdateTime = performance.now(); // In render loop: const elapsed = (performance.now() - this.lastUpdateTime) / 1000; const t = Math.min(elapsed / 0.5, 1.0); // 500ms between updates this.renderX = lerp(this.prevPosition.x, this.targetPosition.x, t); this.renderY = lerp(this.prevPosition.y, this.targetPosition.y, t); // On position_sync: snap to server position if drift > 2 tiles const drift = distance(render, sync); if (drift > 2.0) { this.renderX = sync.x; this.renderY = sync.y; } ``` ### 3.5 REST Endpoints: What Stays, What Goes **KEEP (Phase 1-2):** - `GET /api/v1/hero` -- initial hero load - `POST /api/v1/hero` -- create hero - `POST /api/v1/hero/name` -- set name - `GET /api/v1/hero/adventure-log` -- history - `GET /api/v1/map` -- map data **MOVE TO WS (Phase 2-3, then remove REST):** - `POST /api/v1/hero/buff/{buffType}` -> client WS `activate_buff` - `POST /api/v1/hero/revive` -> client WS `revive` - `POST /api/v1/hero/encounter` -> removed entirely (server decides) - `POST /api/v1/hero/victory` -> removed entirely (server resolves) **Phase 3: deprecation.** These endpoints return 410 Gone with `{"error":"use websocket"}`. --- ## 4. Phase Plan ### Phase 1: WS Protocol + Server Movement (est. 3-5 days) **Backend tasks:** 1. Define `WSEnvelope` type in `model/` or `handler/` 2. Refactor `Hub.broadcast` from `chan model.CombatEvent` to `chan WSEnvelope` 3. Refactor `Client.send` from `chan model.CombatEvent` to `chan WSEnvelope` 4. Add `Hub.SendToHero(heroID, type, payload)` method 5. Wrap existing `emitEvent` calls in envelope: `WSEnvelope{Type: evt.Type, Payload: evt}` 6. Parse incoming client messages as JSON envelopes in `readPump` 7. Add `Hub.incoming` channel + dispatch logic 8. Increase `maxMessageSize` to 4096 9. Run migration `000007_hero_movement_state.sql` 10. Run migration `000008_roads.sql` + seed waypoints 11. Implement `RoadGraph` loader (read from DB at startup) 12. Implement `HeroMovement` struct and movement tick in engine 13. Add `hero_move` (2 Hz) and `position_sync` (10s) sends 14. Send `hero_state` on WS connect 15. Send `route_assigned` when hero starts a new road 16. Send `town_enter` / `town_exit` on arrival/departure 17. Implement hero lifecycle on connect/disconnect (section 2.7) **Frontend tasks:** 1. Create `ws-handler.ts` with typed envelope parsing 2. Implement `hero_move` handler with lerp interpolation 3. Implement `position_sync` handler with drift snap 4. Implement `route_assigned` handler (optional: draw road on map) 5. Implement `town_enter`/`town_exit` UI 6. Remove client-side walking/movement logic from engine.ts 7. Remove `requestEncounter` calls 8. Remove `reportVictory` calls 9. Keep combat UI rendering but drive it from WS `attack` events **Integration test:** Connect via WS, verify hero starts walking, receives `hero_move` at 2 Hz, enters town, exits town, receives `combat_start` from server. ### Phase 2: Server-Authoritative Combat (est. 2-3 days) **Backend tasks:** 1. Engine auto-starts combat when movement tick triggers encounter 2. `combat_start` envelope includes full enemy state 3. `attack` envelopes replace client-simulated damage 4. `combat_end` envelope includes full reward summary 5. Wire `use_potion` client message to potion logic 6. Wire `activate_buff` client message to buff logic (move from REST handler) 7. Wire `revive` client message to revive logic (move from REST handler) 8. `hero_died` sent on death; hero waits for `revive` or auto-revive timer **Frontend tasks:** 1. Remove all local combat simulation code 2. Display combat from server `attack` events (animate swing, show damage number) 3. Wire "Use Potion" button to `use_potion` WS message 4. Wire buff activation buttons to `activate_buff` WS message 5. Wire revive button to `revive` WS message 6. Display `combat_end` reward summary ### Phase 3: Remove Deprecated Endpoints (est. 1 day) **Backend:** 1. Remove `RequestEncounter` handler 2. Remove `ReportVictory` handler 3. Remove REST buff/revive handlers (if all clients migrated) 4. Add 410 Gone responses for removed endpoints 5. Remove save endpoint if no longer needed (state persisted on disconnect) **Frontend:** 1. Remove all REST calls that were migrated to WS 2. Remove any polling/interval-based state refresh (WS pushes everything) --- ## 5. Data Flow Diagrams ### 5.1 Walking + Encounter ``` Engine (2Hz tick) | |-- advance hero position along road waypoints |-- roll encounter check (4% per tick, with cooldown) | |-- [no encounter] --> Hub.SendToHero("hero_move", {x,y,...}) | |-- [encounter!] --> Engine.StartCombat(hero, enemy) | | | +--> Hub.SendToHero("combat_start", {enemy}) | +--> movement paused | |-- [reach town] --> Hub.SendToHero("town_enter", {townId,...}) | +--> set state = resting, restUntil = now+7s ``` ### 5.2 Combat Resolution ``` Engine (100ms combat tick) | |-- process attack queue (existing heap) |-- hero attacks --> Hub.SendToHero("attack", {source:"hero",...}) |-- enemy attacks --> Hub.SendToHero("attack", {source:"enemy",...}) | |-- [enemy HP <= 0] --> onEnemyDeath callback (loot, xp, gold) | Hub.SendToHero("combat_end", {rewards}) | resume movement | |-- [hero HP <= 0] --> Hub.SendToHero("hero_died", {killedBy}) | stop movement, wait for revive ``` ### 5.3 Client Reconnect ``` Client connects via WS | +--> Server loads hero from DB +--> Server sends hero_state (full snapshot) +--> Server sends route_assigned (current or new route) +--> Server registers HeroMovement in engine +--> If mid-combat: send combat_start to resume UI +--> Normal 2Hz ticks begin ``` --- ## 6. Risks and Mitigations | Risk | Impact | Mitigation | |---|---|---| | WS disconnect during combat | Hero stuck in "fighting" state | On disconnect: persist state. On reconnect: if combat stale (>60s), auto-resolve (hero takes proportional damage, combat ends). | | High tick rate CPU load | Engine CPU spike with many heroes | Movement tick is O(n) over connected heroes only. 2 Hz * 100 heroes = 200 ops/s -- trivial. Monitor via engine status endpoint. | | Client interpolation jitter | Visual glitches | Lerp with 500ms window. Snap on drift > 2 tiles. Frontend can smooth with exponential decay. | | Backward compat during rollout | Old clients break | Phase 1 wraps existing events in envelopes. Old client sees `type` field on CombatEvent already. Add version handshake later if needed. | | Offline simulator conflict | Offline sim moves hero, then WS reconnect loads stale position | Offline sim does NOT touch position/movement fields. It only simulates combat/rewards. Movement state is only live when hero is online. | --- ## 7. Constants Reference ```go // Movement BaseMoveSpeed = 2.0 // units/sec MovementTickRate = 500ms // 2 Hz position updates PositionSyncRate = 10s // drift correction WaypointJitter = 2.0 // +/- tiles for road waypoints WaypointSpacing = 20.0 // tiles between jitter points TownRestMin = 5 * time.Second TownRestMax = 10 * time.Second // Encounters EncounterCooldown = 15s // min gap between fights EncounterChancePerTick = 0.04 // per 500ms tick = ~4.8 per 60s of walking // WS MaxMessageSize = 4096 // up from 512 SendBufSize = 64 // unchanged ``` ## 8. File Manifest New files to create: - `backend/internal/game/movement.go` -- HeroMovement, movement tick, destination selection - `backend/internal/game/road_graph.go` -- RoadGraph struct, DB loader, waypoint generation - `backend/internal/handler/ws_envelope.go` -- WSEnvelope, ClientMessage types, dispatch - `backend/migrations/000007_hero_movement_state.sql` - `backend/migrations/000008_roads.sql` - `frontend/src/game/ws-handler.ts` -- typed WS message handler Files to modify: - `backend/internal/handler/ws.go` -- Hub/Client refactor to WSEnvelope, add SendToHero, incoming channel - `backend/internal/game/engine.go` -- add movement map, multi-ticker Run loop, movement/sync ticks - `backend/internal/model/combat.go` -- add StateResting, StateInTown - `backend/internal/model/hero.go` -- add movement fields (CurrentTownID, etc.) - `frontend/src/game/engine.ts` -- gut movement/combat logic, receive from WS only