Skip to content

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 Documents Author: 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

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.