# 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": "<message_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: "<enemy_name>"}` | 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<string, unknown>;
}

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
