Auto Warz — Server Architecture Document¶
Document Type: Technical Architecture Version: 0.4 (Updated) Status: In Progress Last Updated: 2026-02-22 Vault destination:
02_Technical DocumentsAuthor: Solo Developer Parent Document:[01_Master GDD](<../01_Design Documents/01_Master GDD.md>)Related Documents:[01_Technical Architecture Document](<./01_Technical Architecture Document.md>)·[02_Scope & MVP Document](<../01_Design Documents/02_Scope & MVP Document.md>)·[09_UI-UX Design Document](<../01_Design Documents/09_UI-UX Design Document.md>)
Table of Contents¶
- #Purpose of This Document
- #Networking Model
- #Why Authoritative Host
- #Connection Architecture
- #Session Lifecycle
- #State Synchronisation
- #RPC Design Patterns
- #Player Actions & Validation
- #Genesis Synchronisation
- #Achievement System Architecture
- #Chat System
- #Host Migration
- #Headless Server Mode
- #Phase 3 — Steam Networking
- #Security Considerations
- #Network Performance Targets
- #Testing Strategy
- #Revision History
Purpose of This Document¶
This document defines the complete networking and server architecture for Auto Warz. It must be read and understood before writing any simulation code — networking decisions affect how every game system is structured from day one.
The single most important principle in this document:
The factory simulation runs on the host only. Clients render, they do not simulate.
Every other networking decision flows from this principle.
Networking Model¶
Auto Warz uses Godot 4's High Level Multiplayer API with ENet as the transport layer.
Model: Authoritative Host¶
┌─────────────────────────────────────────────────────┐
│ HOST (Server) │
│ │
│ ┌─────────────────────────────────────────────┐ │
│ │ SIMULATION (authoritative) │ │
│ │ Factory tick · Resource counts · Research │ │
│ │ Belt state · Building state · Bot state │ │
│ │ Alien state · Power grid · Save/Load │ │
│ │ Genesis task validation & results │ │
│ └─────────────────────────────────────────────┘ │
│ │ │
│ Broadcasts state updates │
│ │ │
└────────────────────────┼────────────────────────────┘
│
┌──────────────┼──────────────┐
│ │ │
┌──────▼───┐ ┌──────▼───┐ ┌──────▼───┐
│ CLIENT 1 │ │ CLIENT 2 │ │ CLIENT 3 │
│ │ │ │ │ │
│ Renders │ │ Renders │ │ Renders │
│ Genesis │ │ Genesis │ │ Genesis │
│ position │ │ position │ │ position │
│ (local) │ │ (local) │ │ (local) │
└──────────┘ └──────────┘ └──────────┘
What Runs Where¶
| System | Host | Client |
|---|---|---|
| Factory simulation tick | ✅ Yes | ❌ No |
| Resource inventory counts | ✅ Yes | ❌ No |
| Building state machine | ✅ Yes | ❌ No |
| Research tree & RP | ✅ Yes | ❌ No |
| Alien AI & pathfinding | ✅ Yes | ❌ No |
| Power grid calculation | ✅ Yes | ❌ No |
| Genesis task validation | ✅ Yes | ❌ No |
| Genesis task results | ✅ Yes (broadcasts) | ❌ No |
| Save & Load | ✅ Yes | ❌ No |
| Achievement tracking | ✅ Yes | ❌ No |
| Your own Genesis position | ❌ No | ✅ Yes (local, then broadcast via host) |
| Other players' Genesis positions | Relays to all clients | ✅ Yes (received, visually interpolated) |
| Camera position | ❌ No | ✅ Yes |
| Input handling | ❌ No | ✅ Yes |
| Visual interpolation | ❌ No | ✅ Yes |
| UI state | ❌ No | ✅ Yes |
| Audio | ❌ No | ✅ Yes |
Why Authoritative Host¶
Factory simulation desync is the hardest problem in multiplayer factory games. Even tiny floating point differences between machines accumulate over time and cause diverging game states. The authoritative host model solves this completely:
- One simulation runs on one machine — no possibility of divergence
- Clients are dumb renderers — they display what the host tells them, nothing more
- Validation is centralised — invalid actions can't corrupt the simulation
- Saves are simple — only one machine holds simulation state, so only one machine saves
The tradeoff: the host player has zero network latency (they run the simulation locally). Remote players have input latency equal to their ping. For a factory game this is acceptable — factory decisions are not twitch-reflex actions.
Connection Architecture¶
Phase 1 — Direct IP Connection (ENet)¶
Host opens port (default: 7777 UDP)
↓
Host displays session IP to players
↓
Clients enter host IP in Join screen
↓
ENet handshake → player joins lobby
↓
Host starts session → all clients load world
Port Configuration¶
- Default port: 7777 UDP
- Configurable in host session settings
- Players may need to configure port forwarding on their router
- In-game connection test shown in lobby screen before session starts
Connection Flow (Godot 4)¶
# Host
func host_session(port: int) -> void:
var peer = ENetMultiplayerPeer.new()
peer.create_server(port, MAX_PLAYERS)
multiplayer.multiplayer_peer = peer
multiplayer.peer_connected.connect(_on_player_connected)
multiplayer.peer_disconnected.connect(_on_player_disconnected)
# Client
func join_session(ip: String, port: int) -> void:
var peer = ENetMultiplayerPeer.new()
peer.create_client(ip, port)
multiplayer.multiplayer_peer = peer
multiplayer.connected_to_server.connect(_on_connected_to_host)
multiplayer.connection_failed.connect(_on_connection_failed)
Session Lifecycle¶
PRE-SESSION (Solo Start)
├── Host starts session alone — no lobby required
├── World generates from seed
├── Solo mode active — full solo achievement eligibility
└── Host can open session to friends at any time
PRE-SESSION (Multiplayer Start)
├── Host creates session (sets rules, opens port)
├── Players join lobby via IP
├── Lobby shows player list, ready status
└── Host presses Start → session begins
IN-SESSION
├── Simulation runs on host at 20 ticks/sec
├── State updates broadcast to clients
├── Host can toggle "Open to Friends" at any time during session
│ ├── Friend joins → full state sync sent to joining player
│ ├── Friend's Genesis spawned on planet
│ ├── Session mode switches to multiplayer
│ └── Solo-only achievements locked for remainder of session
│ (solo achievements earned before joining are kept)
├── If all friends disconnect → session returns to solo mode
│ └── Solo-only achievements become earnable again
└── Save/resume available (host only)
SESSION END
├── Time limit reached OR win condition met OR host ends session
├── End screen shown to all players (with achievement notifications)
├── Session saved (host)
└── All connections closed cleanly
World Seed Distribution¶
@rpc("authority", "call_remote", "reliable")
func receive_world_seed(seed: int, session_rules: Dictionary) -> void:
WorldGenerator.generate(seed, session_rules)
request_full_state_sync.rpc_id(1)
State Synchronisation¶
Type 1 — Full State Broadcast (Every 5 Seconds)¶
Complete simulation state sent from host to all clients. Catches and corrects any accumulated desync.
func broadcast_full_state() -> void:
var state = SimulationManager.serialise_full_state()
receive_full_state.rpc(state)
@rpc("authority", "call_remote", "reliable")
func receive_full_state(state: Dictionary) -> void:
SimulationManager.apply_full_state(state)
Full state includes: all building states and inventories, all belt item positions, all resource counts, research tree state, power grid state, all bot positions and tasks, alien positions and states, all Genesis positions and current tasks.
Type 2 — Delta Updates (Every Simulation Tick)¶
Only changed values sent. Much smaller payload.
func broadcast_delta(changed_entities: Array) -> void:
if changed_entities.is_empty():
return
receive_delta.rpc(changed_entities)
@rpc("authority", "call_remote", "unreliable_ordered")
func receive_delta(changes: Array) -> void:
for change in changes:
SimulationManager.apply_delta(change)
Sync Priority¶
| Entity Type | Sync Frequency | Channel |
|---|---|---|
| Genesis position (own) | Every 2–3 ticks | Unreliable |
| Genesis position (others) | Received every 2–3 ticks | Unreliable |
| Genesis task results | On completion | Reliable |
| Building status changes | On change | Reliable |
| Resource inventory changes | On change | Reliable |
| Belt item positions | Every 2 ticks | Unreliable ordered |
| Research state changes | On change | Reliable |
| Alien positions | Every 2 ticks | Unreliable |
| Power grid state | Every 5 ticks | Reliable |
| Bot positions | Every 3 ticks | Unreliable |
| Achievement unlocks | On trigger | Reliable |
RPC Design Patterns¶
Pattern 1 — Client Action Request¶
Client sends action to host. Host validates and applies. Host broadcasts result to all clients.
# CLIENT sends action request to host
func request_place_building(building_id: String, grid_pos: Vector2i) -> void:
place_building_request.rpc_id(1, building_id, grid_pos, multiplayer.get_unique_id())
# HOST receives, validates, applies, broadcasts
@rpc("any_peer", "call_local", "reliable")
func place_building_request(building_id: String, grid_pos: Vector2i, player_id: int) -> void:
if not multiplayer.is_server():
return
if BuildingValidator.is_valid(building_id, grid_pos):
SimulationManager.place_building(building_id, grid_pos, player_id)
building_placed_confirmed.rpc(building_id, grid_pos, player_id)
else:
building_placement_rejected.rpc_id(player_id, grid_pos)
# ALL CLIENTS receive confirmed placement
@rpc("authority", "call_local", "reliable")
func building_placed_confirmed(building_id: String, grid_pos: Vector2i, player_id: int) -> void:
WorldRenderer.show_building(building_id, grid_pos, player_id)
Pattern 2 — Host Authority Broadcast¶
Host pushes state to all clients without client request.
@rpc("authority", "call_remote", "reliable")
func notify_building_status_changed(building_id: int, new_status: int) -> void:
WorldRenderer.update_building_status(building_id, new_status)
Pattern 3 — Targeted Message¶
Host sends to a specific client only.
@rpc("authority", "call_remote", "reliable")
func building_placement_rejected(grid_pos: Vector2i) -> void:
UIManager.show_placement_error(grid_pos)
RPC Channel Rules¶
| Channel | Use For |
|---|---|
reliable |
Game state changes — must arrive, must be in order |
unreliable |
Positions, visual updates — loss acceptable |
unreliable_ordered |
Frequent updates where old packets should be discarded |
Player Actions & Validation¶
Every player action goes through host validation before being applied.
Actions That Require Validation¶
| Action | Validation Checks |
|---|---|
| Place building | Grid cell empty, player has resources, building unlocked |
| Delete building | Building exists, no active delivery in progress |
| Change recipe | Building exists, recipe unlocked |
| Research node | Sufficient RP, prerequisites met |
| Assign Genesis task | Target location valid, task type valid |
| Configure drone route | Both buildings exist, valid resource filter |
| Set train schedule | All stations on connected rail network |
| Install module | Building exists, slot available, module in inventory |
Simultaneous Action Conflict Resolution¶
- First action to arrive at host wins
- Second player receives rejection RPC
- Second player's ghost snaps back to cursor
- Brief "Taken" indicator shown on contested cell
Genesis Synchronisation¶
Key Design: Each Player Has Their Own Genesis¶
Each player in a multiplayer session has their own Genesis bot. Genesis is personal to each player — it is their companion, not a shared resource. All players can see every Genesis on the shared factory floor, so position must be synced.
Player 1 → their own Genesis (local movement, position broadcast to others)
Player 2 → their own Genesis (local movement, position broadcast to others)
Player 3 → their own Genesis (local movement, position broadcast to others)
What Is Local vs Shared¶
| Genesis Property | Where It Lives | Sync Required |
|---|---|---|
| Your own Genesis position | Client-side (local, responsive) | ✅ Yes — sent to host, relayed to all other clients |
| Other players' Genesis positions | Received from host | ✅ Yes — visually interpolated on receiver |
| Visual representation | Client-side | ❌ No — all clients render from received position |
| Task request | Client → Host for validation | ✅ Yes |
| Task execution result | Host → All clients | ✅ Yes (world state change) |
| Task status (HUD indicator) | Client-side | ❌ No |
How Genesis Position Sync Works¶
Genesis position uses the same pattern as player position sync in most multiplayer games:
- Your own Genesis moves locally for immediate responsiveness (no latency on your end)
- Your Genesis position is sent to the host every 2–3 ticks via unreliable channel
- Host relays each player's Genesis position to all other clients
- Other clients visually interpolate Genesis movement smoothly between received positions
- Uses unreliable channel — occasional packet loss is fine, next position update corrects it
Genesis Task Flow (Multiplayer)¶
Player clicks → "Mine this ore node"
↓
Client moves Genesis locally toward target (visual only)
↓
Client sends task request to host
↓
Host validates: is the ore node valid? does it exist?
↓
Host applies: ore node begins depleting in simulation
↓
Host broadcasts: resource count update to all clients
↓
All clients see resource appearing in storage
# Client — Genesis movement is purely local
func direct_genesis_to_mine(resource_node_id: int) -> void:
# Move Genesis visually on this client
GenesisController.move_to(ResourceNodes[resource_node_id].position)
# Send task to host for simulation effect
genesis_task_request.rpc_id(1, {
"type": "mine",
"target_id": resource_node_id,
"player_id": multiplayer.get_unique_id()
})
# Host — validates and applies to simulation
@rpc("any_peer", "call_local", "reliable")
func genesis_task_request(task: Dictionary) -> void:
if not multiplayer.is_server():
return
if GenesisValidator.is_valid(task):
SimulationManager.apply_genesis_task(task)
# Broadcast result (e.g. resource added to storage)
# This is handled by the normal delta update system
Achievement System Architecture¶
Overview¶
Achievements are tracked on the host during a session and pushed to clients when unlocked. Steam Achievements are triggered client-side when the host notifies the relevant player.
Achievement Categories¶
| Category | Solo Only | Multi Only | Both |
|---|---|---|---|
| Factory milestones | ✅ | ||
| Research milestones | ✅ | ||
| Defence milestones | ✅ | ||
| Efficiency milestones | ✅ | ||
| Solo survival | ✅ | ||
| Multiplayer co-op | ✅ | ||
| Easter eggs / hidden | ✅ |
Solo-Only Achievements (examples)¶
- "Alone but not broken" — Complete a full research tier with only Genesis (no Construction Bots)
- "Self-sufficient" — Run a factory at 100% uptime for 30 minutes in solo
- "One bot army" — Defeat 50 aliens using only turrets, no deployable units
Multiplayer-Only Achievements (examples)¶
- "Division of labour" — Have 3+ players each managing a different factory zone simultaneously
- "Supply chain" — Complete a Quantum Processor with each material produced by a different player
- "United front" — Repel an alien attack with all players active and no buildings destroyed
Both Modes Achievements (examples)¶
- Factory: "Mass production" — Produce 1,000 Metal Plates in a single session
- Research: "Scholar" — Unlock all Tier 2 research nodes
- Defence: "Exterminator" — Defeat 500 alien enemies total
- Efficiency: "Well-oiled machine" — Maintain 95%+ factory uptime for 60 minutes
- Hidden: "Old friend" — Direct Genesis to mine ore 100 times in a single session
Cosmetic Rewards¶
Achievements unlock exclusive cosmetics — never sold in DLC.
| Achievement Tier | Reward Type |
|---|---|
| Common achievements | Building colour variants (alternate accent colours) |
| Rare achievements | Full building skin (unique model variant) |
| Legendary achievements | Genesis skin (unique Genesis visual design) |
| Hidden achievements | Special effect (particle trail on Genesis, animated faction badge) |
DLC cosmetics are entirely separate — different designs, never overlapping with achievement rewards.
Achievement Tracking Architecture¶
# AchievementManager autoload — runs on host
class_name AchievementManager
extends Node
# Tracks per-player stats across the session
var player_stats: Dictionary = {} # player_id → stats dict
# Called by SimulationManager when relevant events occur
func on_resource_produced(player_id: int, resource_id: String, amount: int) -> void:
player_stats[player_id]["produced"][resource_id] += amount
_check_factory_achievements(player_id)
func on_alien_defeated(player_id: int) -> void:
player_stats[player_id]["aliens_defeated"] += 1
_check_defence_achievements(player_id)
func on_research_unlocked(research_id: String) -> void:
# Research is shared — check for all players
_check_research_achievements(research_id)
func _unlock_achievement(player_id: int, achievement_id: String) -> void:
# Notify the specific player
achievement_unlocked.rpc_id(player_id, achievement_id)
# Client — receives achievement unlock
@rpc("authority", "call_remote", "reliable")
func achievement_unlocked(achievement_id: String) -> void:
var achievement = AchievementDatabase.get(achievement_id)
UIManager.show_achievement_toast(achievement)
CosmticManager.unlock_reward(achievement.reward_id)
# Steam integration (if on Steam)
if SteamManager.is_active():
Steam.setAchievement(achievement.steam_id)
Steam.storeStats()
Session Type Detection¶
The AchievementManager tracks session mode dynamically. Solo achievements earned before a friend joins are kept — but solo achievements are locked once multiplayer begins, and re-enabled if all friends disconnect.
# AchievementManager.gd
var _had_multiplayer_this_session: bool = false
var _current_mode: String = "solo" # "solo" or "multiplayer"
# Called by NetworkManager when a peer connects
func on_peer_connected() -> void:
_current_mode = "multiplayer"
_had_multiplayer_this_session = true
# Called by NetworkManager when all peers disconnect
func on_all_peers_disconnected() -> void:
_current_mode = "solo"
# Solo achievements become earnable again
# but _had_multiplayer_this_session remains true for record-keeping
func is_solo_session() -> bool:
return _current_mode == "solo"
func _check_achievement_eligibility(achievement_id: String) -> bool:
var achievement = AchievementDatabase.get(achievement_id)
match achievement.mode:
"solo_only":
return is_solo_session() # Only earnable when currently in solo mode
"multi_only":
return not is_solo_session() # Only earnable when friends are present
"both":
return true
return false
Key rule: is_solo_session() reflects the current mode, not the session history. This means:
- Solo achievements earned before a friend joined → already triggered and kept ✅
- Solo achievements after a friend joins → blocked (multiplayer mode) ❌
- Solo achievements after all friends leave → earnable again (back to solo mode) ✅
Steam Achievement Mapping¶
Each in-game achievement maps to a Steam Achievement ID:
# AchievementDatabase entry example
{
"id": "mass_production",
"name": "Mass Production",
"description": "Produce 1,000 Metal Plates in a single session",
"steam_id": "ACH_MASS_PRODUCTION",
"mode": "both",
"reward_id": "skin_smelter_rusted",
"category": "factory",
"hidden": false
}
Chat System¶
func send_chat(message: String) -> void:
var clean_message = message.strip_edges().left(200)
if clean_message.is_empty():
return
chat_message_received.rpc(multiplayer.get_unique_id(), clean_message)
@rpc("any_peer", "call_local", "reliable")
func chat_message_received(sender_id: int, message: String) -> void:
var player_name = SessionManager.get_player_name(sender_id)
ChatPanel.add_message(player_name, message)
System messages broadcast for: join/leave, research complete, achievement unlocked (shared notification), alien breach.
Host Migration¶
Phase 1 does not support automatic host migration. If the host disconnects: - All clients receive disconnection notification - "Host disconnected. Waiting for host to reconnect..." - 60-second reconnection window opens - Reconnect within 60s → session resumes from last auto-save - No reconnect → session ends, clients return to main menu
Full host migration may be addressed in Phase 3.
Headless Server Mode¶
# Linux (recommended for servers)
./AutoWarz.x86_64 --headless --port 7777 --session-config config.json
Session Config (JSON)¶
{
"port": 7777,
"max_players": 8,
"session_rules": {
"time_limit": 0,
"galaxy_size": "medium",
"alien_aggression": "normal",
"starting_resources": "normal",
"win_condition": "none",
"pvp_enabled": false
},
"auto_save_interval": 300,
"save_path": "./saves/session_001.json"
}
Headless Behaviour¶
- No display rendered — full simulation runs normally
- Players connect via IP as with a hosted session
- Auto-saves on configured interval
- Graceful shutdown on SIGTERM (saves before closing)
- Achievement tracking runs normally in headless mode
func _ready() -> void:
if DisplayServer.get_name() == "headless":
_start_headless_server()
else:
_start_normal_game()
Phase 3 — Steam Networking¶
In Phase 3, Steam Sockets replace ENet. Steam handles NAT traversal automatically — no port forwarding required.
Architecture Compatibility¶
The authoritative host model, RPC patterns, and state sync systems defined here remain unchanged in Phase 3. Only the transport layer changes.
# Phase 1 — ENet
var peer = ENetMultiplayerPeer.new()
peer.create_server(port, max_players)
# Phase 3 — Steam (same API, different peer)
var peer = SteamMultiplayerPeer.new()
peer.create_host(max_players)
# Everything above this line stays identical
multiplayer.multiplayer_peer = peer
Security Considerations¶
Threats Mitigated¶
- All game state changes validated on host — clients cannot directly modify simulation state
- Host can kick any player at any time
- Session rules clearly communicated at join
Accepted Risks (Acceptable for Hobby Co-op Game)¶
- A malicious host has full simulation control — players must trust the session host
- Game data is not encrypted in Phase 1
- No DDoS mitigation for player-hosted servers
Network Performance Targets¶
| Metric | Target |
|---|---|
| Tick rate | 20 simulation ticks/second |
| Delta update frequency | Every simulation tick (50ms) |
| Full state sync frequency | Every 5 seconds |
| Max bandwidth per client | < 50 KB/s at peak |
| Max latency for acceptable play | < 150ms ping |
| Reconnection window (host) | 60 seconds |
| Maximum simultaneous players | TBD (likely 8) |
Testing Strategy¶
Unit Tests¶
- State serialisation/deserialisation round-trip
- RPC validation logic
- Delta update correctness
- Achievement trigger conditions (solo vs multiplayer correctly filtered)
- Genesis task validation (valid and invalid cases)
Integration Tests¶
- Host + 1 client: full session lifecycle
- Host + 3 clients: simultaneous building placement
- Genesis task sync: client requests task, host applies, all clients see result
- Achievement unlock: trigger condition met, correct player notified, cosmetic unlocked
- Desync test: artificially introduce state difference, verify full sync corrects it
- Host disconnection: graceful session end within 60 seconds
Playtest Milestone¶
Before M4 is marked complete: - 2-hour multiplayer session with 2+ external players - Zero desync events observed - Genesis tasks apply correctly for all players - At least one achievement triggered and cosmetic unlocked correctly - All players report factory state matches expectations
Open items for this document are tracked in docs/open-items.md, area networking.
Revision History¶
| Version | Date | Changes |
|---|---|---|
| 0.1 | 2026-02-22 | Initial draft |
| 0.2 | 2026-02-22 | Genesis sync corrected — each player has their own Genesis, position is client-side only, tasks validated on host. Achievement system architecture added. |
| 0.3 | 2026-02-22 | Genesis position sync corrected again — position broadcast to all clients so players can see each other's Genesis on the shared factory floor. |
| 0.4 | 2026-02-23 | Mid-session multiplayer added — solo sessions can be opened to friends at any time (Valheim model). Session lifecycle updated. Achievement session type detection updated to handle dynamic solo/multiplayer switching. Solo achievements earned before friends join are kept; locked once multiplayer begins; re-enabled when all friends disconnect. |
This document must be read before writing any simulation or networking code. The authoritative host principle is non-negotiable.