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
CombatEventstruct (type/heroId/damage/source/isCrit/debuffApplied/heroHp/enemyHp/timestamp) maps directly into the newattackenvelope payload. Migration: wrap in{"type":"attack","payload":{...}}. - Current
readPumphandles raw"ping"string. Change to parse JSON envelope; keep raw"ping"support during transition. maxMessageSizemust increase from 512 to 4096 to accommodatehero_stateandroute_assignedpayloads.
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:
- Load hero from DB
- If hero has no movement state, initialize: place at nearest town, pick destination, assign route
- Create
HeroMovementin engine - Send
hero_state(full snapshot) - Send
route_assigned(current route) - If hero was mid-combat (state == fighting), resume or cancel combat
On disconnect:
- Persist hero state to DB (position, HP, gold, etc.)
- Remove
HeroMovementfrom engine - Hero becomes eligible for offline simulator ticks
2.8 Combat Integration with Movement
When Engine decides to start combat (from movement tick):
- Stop hero movement (state = fighting)
- Call existing
Engine.StartCombat(hero, enemy) - Send
combat_startvia Hub
When combat ends (enemy dies in handleEnemyDeath):
- Apply rewards (existing
onEnemyDeathcallback) - Set state back to "walking"
- Send
combat_endwith rewards summary - Resume movement from where hero stopped
When hero dies:
- Send
hero_died - Set state to "dead"
- Stop movement
- Wait for client
revivemessage (or auto-revive after 1 hour, same as offline)
2.9 Server-Side Potion Use
When client sends use_potion:
- Validate: hero is in combat, hero.Potions > 0, hero.HP > 0
- Apply: hero.HP += hero.MaxHP * 30 / 100, clamp to MaxHP, hero.Potions--
- Emit
attackevent with source "potion" (or a newpotion_usedtype) - 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
requestAnimationFramerender 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 loadPOST /api/v1/hero-- create heroPOST /api/v1/hero/name-- set nameGET /api/v1/hero/adventure-log-- historyGET /api/v1/map-- map data
MOVE TO WS (Phase 2-3, then remove REST):
POST /api/v1/hero/buff/{buffType}-> client WSactivate_buffPOST /api/v1/hero/revive-> client WSrevivePOST /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:
- Define
WSEnvelopetype inmodel/orhandler/ - Refactor
Hub.broadcastfromchan model.CombatEventtochan WSEnvelope - Refactor
Client.sendfromchan model.CombatEventtochan WSEnvelope - Add
Hub.SendToHero(heroID, type, payload)method - Wrap existing
emitEventcalls in envelope:WSEnvelope{Type: evt.Type, Payload: evt} - Parse incoming client messages as JSON envelopes in
readPump - Add
Hub.incomingchannel + dispatch logic - Increase
maxMessageSizeto 4096 - Run migration
000007_hero_movement_state.sql - Run migration
000008_roads.sql+ seed waypoints - Implement
RoadGraphloader (read from DB at startup) - Implement
HeroMovementstruct and movement tick in engine - Add
hero_move(2 Hz) andposition_sync(10s) sends - Send
hero_stateon WS connect - Send
route_assignedwhen hero starts a new road - Send
town_enter/town_exiton arrival/departure - Implement hero lifecycle on connect/disconnect (section 2.7)
Frontend tasks:
- Create
ws-handler.tswith typed envelope parsing - Implement
hero_movehandler with lerp interpolation - Implement
position_synchandler with drift snap - Implement
route_assignedhandler (optional: draw road on map) - Implement
town_enter/town_exitUI - Remove client-side walking/movement logic from engine.ts
- Remove
requestEncountercalls - Remove
reportVictorycalls - Keep combat UI rendering but drive it from WS
attackevents
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:
- Engine auto-starts combat when movement tick triggers encounter
combat_startenvelope includes full enemy stateattackenvelopes replace client-simulated damagecombat_endenvelope includes full reward summary- Wire
use_potionclient message to potion logic - Wire
activate_buffclient message to buff logic (move from REST handler) - Wire
reviveclient message to revive logic (move from REST handler) hero_diedsent on death; hero waits forreviveor auto-revive timer
Frontend tasks:
- Remove all local combat simulation code
- Display combat from server
attackevents (animate swing, show damage number) - Wire "Use Potion" button to
use_potionWS message - Wire buff activation buttons to
activate_buffWS message - Wire revive button to
reviveWS message - Display
combat_endreward summary
Phase 3: Remove Deprecated Endpoints (est. 1 day)
Backend:
- Remove
RequestEncounterhandler - Remove
ReportVictoryhandler - Remove REST buff/revive handlers (if all clients migrated)
- Add 410 Gone responses for removed endpoints
- Remove save endpoint if no longer needed (state persisted on disconnect)
Frontend:
- Remove all REST calls that were migrated to WS
- 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 selectionbackend/internal/game/road_graph.go-- RoadGraph struct, DB loader, waypoint generationbackend/internal/handler/ws_envelope.go-- WSEnvelope, ClientMessage types, dispatchbackend/migrations/000007_hero_movement_state.sqlbackend/migrations/000008_roads.sqlfrontend/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 channelbackend/internal/game/engine.go-- add movement map, multi-ticker Run loop, movement/sync ticksbackend/internal/model/combat.go-- add StateResting, StateInTownbackend/internal/model/hero.go-- add movement fields (CurrentTownID, etc.)frontend/src/game/engine.ts-- gut movement/combat logic, receive from WS only