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.
autohero/docs/spec-server-authoritative.md

23 KiB

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:

{"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:

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:

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

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

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.

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

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:

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:

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:

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

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

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

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

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