Salt & Steel: Networking Model
Document type: Technical Architecture — Canonical
Status: Canonical
Last updated: 2026-04
See also: Server Architecture | Client Architecture | Instance Management | Security and Integrity
Overview
Salt & Steel's networking model inherits Path of Exile's server-authoritative foundation and its dual-mode (Lockstep / Predictive) synchronization design, then extends both to cover a challenge PoE never faced: a single session can encompass two simultaneous combat contexts at different spatial scales — personal ARPG combat at character scale and naval combat at ship scale — and the client must handle both, sometimes concurrently, without visible seams.
The design goal is not just "low latency" — it is correct latency handling. The GURPS 3d6 bell curve creates a specific timing relationship between player input and visible outcomes. Active defense timing (Dodge, Parry, Block) is the most latency-sensitive element in the personal combat system: a player who presses the dodge key must receive believable visual feedback immediately, while the server resolves whether the dodge succeeds. Naval steering must feel responsive despite the ship's inherent momentum model. Both domains require careful attention to where prediction is safe and where server authority must be respected without compromise.
Transport Protocol
Salt & Steel uses TCP as its primary transport, following PoE's established approach. The rationale is identical:
- Reliable ordered delivery is required. Combat state updates must arrive in order; a position update arriving after a death notification would produce incorrect visual state. Implementing reliable ordered delivery over UDP adds complexity equivalent to reimplementing TCP — without a compelling latency advantage at Salt & Steel's tick rates.
- ARPG tick rates tolerate TCP. At 30 Hz land tick rate and 20 Hz sea tick rate, the game's update cadence is orders of magnitude slower than the scenarios (64-128 Hz FPS games) where UDP's lower head-of-line blocking risk provides meaningful benefit. TCP retransmission artifacts — a delayed packet causing a brief stall — are less perceptible at 30 Hz than at 128 Hz.
- Congestion control is beneficial. Many players connect over asymmetric consumer internet links with upstream congestion. TCP's built-in congestion window management adapts to these conditions automatically.
TCP tuning: The connection uses TCP_NODELAY (disabling Nagle's algorithm) to prevent the OS from buffering small packets for up to 200ms before sending. At typical Salt & Steel packet sizes (50-500 bytes per update), Nagle's algorithm would introduce unacceptable artificial latency. Each tick's state update is transmitted immediately as its own segment.
Connection keepalive: A 15-second keepalive probe detects dead connections. Stale connections (no client response to keepalive probe within 30 seconds) are terminated and the player is returned to their last safe checkpoint. This prevents ghost connections from consuming server resources.
Dual Networking Modes
Following PoE's architecture, Salt & Steel supports both Lockstep and Predictive modes, selectable per-player based on their network conditions.
Lockstep Mode
The client does not advance any game state locally. Every player input is sent to the server; the client waits for server confirmation before rendering the result. The server's state is the only state.
Mechanism:
- Player presses attack key → client sends
INPUT_ATTACKpacket with target hint, timestamp, and current client tick counter - Client begins anticipatory animation (wind-up phase) — this is cosmetic, not game state advancement
- Server receives input, resolves it in the next simulation tick, returns
STATE_UPDATEwith attack resolution - Client receives result and completes animation accordingly (hit → impact; miss → whiff)
- Visual lag: one full RTT from input to confirmed visual result
When to use Lockstep: Recommended for players with RTT below 80ms to their selected server region. Above 80ms, the input latency becomes perceptible as sluggishness, especially during reactive defense timing. At 50ms RTT, the lag is 50ms — comparable to a 20 Hz monitor's frame latency. Below perception threshold for most players.
Lockstep gate: The client maintains a frame hold buffer. If the server acknowledgment has not arrived when the client's render frame is due, the client holds the rendered frame (renders the same frame twice) rather than advancing the animation. This is visible as a micro-stutter if packets are delayed. The stutter is honest — it correctly represents the uncertain game state — and is preferable to the alternative (advancing state incorrectly and then rubber-banding).
Predictive Mode
The client predicts the result of player actions and displays them immediately. The server resolves actions authoritatively and sends corrections when its result differs from the client's prediction.
Mechanism:
- Player presses attack key → client immediately begins full attack animation based on predicted hit
- Client sends
INPUT_ATTACKpacket to server simultaneously - Server resolves attack in next tick, returns
STATE_UPDATE - If server result matches prediction: client continues animation cleanly
- If server result differs: client applies correction — typically a brief animation state snap (hit to miss or miss to hit) with a short reconciliation blend
Prediction accuracy targets:
- Character movement: 95%+ accuracy with position error < 0.5m at 100ms RTT. Movement prediction is simply extrapolating current velocity — very reliable.
- Basic attacks: 80%+ accuracy. Attack hit/miss prediction is based on client-side skill level and effective modifiers. The prediction is directionally correct most of the time; corrections are rare.
- GURPS 3d6 roll outcomes: never predicted for display. The outcome of a die roll (hit/miss, defense success/failure, critical) is not shown until server confirmation arrives. The attack and defense animations are predicted; the resolution animation (hit impact, defense success flash) waits for server confirmation. This distinction preserves the integrity of the GURPS resolution without sacrificing input feel.
When to use Predictive: Mandatory recommendation for players above 100ms RTT. At high latency, Predictive provides a responsive feel that Lockstep cannot match. The trade-off (occasional visible correction) is acceptable given the alternative (obvious input lag).
Switching Between Modes
The networking mode can be changed in settings and takes effect at the next instance transition. It cannot be changed mid-instance (this would require a full client-server state reconciliation that is not implemented). The client displays the current mode and current RTT in the connection status panel.
During sea-to-land transitions (and vice versa), the networking mode is preserved — the player does not re-select mode on each transition.
Land Combat Synchronization
Tick Architecture
The land instance simulation server runs at 30-40 Hz (tick interval: 25-33ms). Each tick:
- Receive all pending input packets from connected clients
- Process NPC AI and pathfinding updates
- Resolve all queued combat interactions:
- For each pending attack: look up attacker's effective skill, generate 3d6 roll, determine hit/miss/critical
- For each pending defense attempt: look up defender's effective defense value, generate 3d6 roll, determine success/failure
- Apply damage pipeline: raw damage → DR subtraction → wound modifier → HP deduction → status effect application
- Apply FP costs for all ability uses
- Advance projectile positions (arrows, thrown weapons, spell bolts)
- Advance status effect timers (bleeding tick, poison progression, conditions)
- Advance monster AI state machines
- Generate state delta: all entities whose state changed this tick
- Transmit state delta to all connected clients
The state delta is per-client — each client only receives updates for entities within their area of interest (the player's visible area plus a 10m buffer zone). An entity at 300m range does not generate updates for this player.
GURPS 3d6 Roll Synchronization
The 3d6 roll is the most critical data element in Salt & Steel's combat. It must be:
- Generated server-side (not inferable from any client-visible data)
- Transmitted to the client efficiently
- Never cached on the client for future use
Each combat resolution packet contains:
struct CombatResolutionPacket {
uint32 attacker_entity_id;
uint32 defender_entity_id;
uint8 attack_roll_result; // 3-18 (the die roll sum)
uint8 effective_skill; // effective skill target number
bool is_hit;
bool is_critical;
uint8 hit_location; // body location enum
uint8 defense_type; // DODGE / PARRY / BLOCK / NONE
uint8 defense_roll_result; // 3-18 (defense roll sum, if attempted)
uint8 effective_defense; // defense target number
bool defense_succeeded;
uint16 raw_damage;
uint8 damage_type; // cutting / impaling / crushing / etc.
uint16 penetrating_damage; // after DR
uint16 final_damage; // after wound modifier
uint8 status_effects_applied; // bitmask
}
This packet is 20 bytes — compact enough to include in the standard tick delta without special framing. The client displays the roll result in the combat log (Hit! [Skill 14 vs. Roll 11]) and plays the appropriate animation.
Active Defense Latency Handling
Active defense is the most latency-sensitive interaction in land combat. A player sees an attack animation, presses the dodge/parry/block key, and expects the defense to engage. The timing window for active defense in Salt & Steel corresponds to approximately 300ms from the start of an attack animation (the game-design specification; see combat design documents for rationale). Within this window, a defense input is valid.
Defense window implementation:
- The server tracks the attack wind-up start time and the defense window close time for each attack entity
- Client defense inputs timestamped with client-side clock, adjusted by measured RTT (client adjusts its clock based on round-trip measurement samples taken every 30 seconds)
- Server validates:
defense_received_time ≤ attack_window_close + max_allowed_latency_compensation - Maximum latency compensation: 100ms (half the target maximum RTT for Lockstep mode). This means a player at 200ms RTT can still successfully input a defense within the window, but the server treats a defense input arriving 100ms after the window as valid only if the client's timestamped send time was within the window.
- Players above 200ms RTT will experience defense window failures that are genuinely caused by network conditions. The UI displays a "Defense window missed — check network connection" message when this is detected.
This approach prioritizes fairness for low-latency players while extending reasonable accommodation to moderate-latency players, rather than compensating all latency equally (which would benefit cheaters using artificial latency to extend defense windows).
Naval Combat Synchronization
Naval combat operates at ship scale — ships are the primary entities, not individual characters (except during boarding). The synchronization challenge is distinct from land combat: ships have significant momentum and turn slowly, making dead-reckoning prediction more reliable than character movement prediction, but also making corrections more visually jarring when they occur.
Ship State Synchronization
Each ship's state packet (sent from server to clients at 20 Hz):
struct ShipStatePacket {
uint16 ship_entity_id;
float32 world_x, world_y; // position in sea instance
float16 heading; // 0-360 degrees, quantized
float16 speed; // knots, quantized
float16 heel_angle; // roll from wind pressure
uint8 sail_state; // enum: FULL / REEFED / FURLED + angle
uint8 hull_integrity[4]; // bow/stern/port/starboard, 0-100
bool is_flooding;
uint8 mast_state[3]; // per-mast: INTACT / DAMAGED / GONE
uint8 port_broadside_ready; // reload %
uint8 starboard_broadside_ready;
uint8 crew_count; // surviving crew, 0-255
uint8 crew_morale; // 0-100
}
At 20 Hz with up to 16 ships in an instance, the raw ship state bandwidth is:
- 16 ships × 32 bytes × 20 updates/second = 10,240 bytes/second (10 KB/s) for ship state alone
This is comfortably within typical consumer internet upload/download capacity. Ship state is not delta-compressed (ships are almost always moving and the state changes each tick), but it is quantized: position is stored to 0.1m precision, heading to 0.35° precision — sufficient for visual rendering without bit-wasteful full 32-bit floats.
Ship Position Interpolation
Client-side ship rendering uses interpolation between the two most recently received state packets to smooth ship movement. At 20 Hz updates (50ms interval), naively snapping to each received position would produce visible jitter. Instead:
- The client buffers the last two received ship state packets (100ms of history)
- The current render frame interpolates linearly (for position) and spherically (for rotation) between these states
- The interpolation playback point is held 100ms behind the current server time — this introduces 100ms of display lag but ensures smooth movement by always having "future" state to interpolate toward
For the player's own ship, interpolation is replaced by prediction during Predictive mode: the client advances the ship state forward based on the current steering inputs, then corrects to server state on each update. The steering of a ship is deterministic enough (physics model is simple — acceleration/deceleration curves based on wind and sail state) that client-side prediction is 90%+ accurate, keeping corrections minor.
Cannon Projectile Synchronization
Cannon projectiles (cannonballs, chain shot, grape shot) are simulated on the server. The server computes the trajectory including wind effects and sends the projectile's initial state to all clients:
struct ProjectileLaunchPacket {
uint16 source_ship_id;
uint8 cannon_type; // CANNON / CHAIN / GRAPE / BAR
float32 origin_x, origin_y, origin_z; // world position
float16 direction_h, direction_v; // horizontal/vertical angles
float16 muzzle_velocity;
uint16 flight_time_ms; // server-computed total flight time
bool is_impact_predicted; // server has already computed landing
float32 impact_x, impact_y; // landing point (if predictable)
}
The client renders the projectile's flight as a visual element interpolated along the server-computed trajectory. The projectile's visual is purely cosmetic — it does not affect game state. The actual damage is applied server-side at impact time. The client receives a damage application packet simultaneously with the impact visuals.
For high-velocity cannonballs (fast enough that flight time is under one server tick), the server sends a combined launch-plus-impact packet. The client plays the launch flash, flight, and impact as a single animated sequence without waiting for separate packets.
Grape shot (short-range crew-suppressing fire) generates many small projectiles. Rather than sending 50 individual projectile packets, the server sends a single GRAPE_SHOT_VOLLEY packet with a spread pattern descriptor (angle, density, range). The client generates the visual particles from this descriptor on its own GPU — the display is approximated, but the actual crew casualties are server-computed and sent as damage packets.
Boarding Transition Handshake
Boarding is the most complex networking event in Salt & Steel: it transitions combat from ship-scale (20 Hz tick) to personal-scale (30 Hz sub-tick) while keeping both scales active simultaneously.
Boarding initiation sequence:
- Player inputs boarding command (within boarding range, crew morale sufficient)
- Server validates boarding conditions: range ≤ boarding threshold, relative velocity ≤ maximum boarding velocity, both ships have sufficient crew
- Server sends
BOARDING_INITIATEDpacket to all clients in the sea instance — this packet includes the attacker ship ID, defender ship ID, boarding side (port/starboard), and a boarding engagement ID - Both ships' clients receive the packet and play the grappling hook animation (cosmetic)
- Server spawns a boarding combat sub-context within the sea instance: a 30 Hz GURPS simulation running on the deck geometry of the defender's ship
- Crew members from both ships are assigned to the boarding context by the server; their prior ship roles are suspended
- The player's client receives a
BOARDING_CONTEXT_JOINpacket with their spawn position on the defender's deck - The client camera transitions from naval view to deck view over 2 seconds (during which the boarding animation plays)
- Both the boarding sub-context and the main sea-scale simulation continue running simultaneously — remaining crew members not in the boarding action continue firing cannons, and the two ships continue taking damage from each other's gunners
Simultaneous scale operation: The server maintains two update streams for a player in a boarding action:
- The sea-scale update (20 Hz): ship hull status, enemy ship's remaining broadside, weather changes
- The boarding sub-context update (30 Hz): GURPS combat state for boarding participants
The client receives and processes both streams. HUD shows a split-context indicator: the main HUD shows the player's personal combat state (HP, FP, active defenses), and a smaller overlay shows the player's ship's hull status and broadside reload.
Boarding end conditions:
- All attackers killed or driven off → boarding fails, context dissolved, crew returned to ship roles
- All defenders killed or surrendered → boarding succeeds,
BOARDING_SUCCEEDEDpacket triggers ship capture sequence - Both ships begin sinking →
BOARDING_ABORTEDpacket, all surviving crew must swim to safety or their ship
Weather State Synchronization
Weather state is owned by the Weather Service (running on the server infrastructure). It is injected into sea instance ticks as a parameter set — clients receive weather state as part of the periodic tick delta.
Weather parameters transmitted per-tick (when changed):
struct WeatherStatePacket {
uint8 wind_direction; // 0-255 mapped to 0-360°
uint8 wind_speed; // 0-255 mapped to 0-100 knots
uint8 wave_height; // 0-255 mapped to 0-20m
uint8 wave_period; // seconds, affects Tessendorf FFT input
uint8 visibility; // 0-255 mapped to 0-4km visibility
uint8 rain_intensity; // 0 = none, 255 = torrential
uint8 fog_intensity; // 0 = none, 255 = zero-vis fog
uint8 storm_intensity; // 0 = calm, 255 = hurricane
bool lightning_active;
uint32 lightning_seed; // drives client-side lightning position generation
}
Weather changes smoothly: the Weather Service advances weather state on a real-time clock, and the parameters transmitted each tick are interpolated toward the target state. Clients render the current weather parameters without further interpolation (the 20 Hz update is frequent enough that per-client weather interpolation is unnecessary at nautical scales).
Area of Interest Filtering
Not all entities in a sea instance are relevant to all players. Area of interest filtering reduces bandwidth and prevents information leakage (seeing enemy ship positions that shouldn't be visible through fog).
Land Instance AoI
- Radius: 120m from player character (larger than the visible screen area)
- Entities within AoI: receive full state updates each tick
- Entities outside AoI: no updates sent
- AoI edge: entities transitioning in/out of AoI receive spawn/despawn packets
Sea Instance AoI — Ship Scale
The sea instance AoI operates on a two-tier model:
- Visibility tier (0-4km, modulated by weather visibility parameter): Ships within visibility range receive full position and state updates
- Detection tier (4-8km, passive awareness): Ships outside visibility but within detection range receive coarse positional pings (position rounded to nearest 500m, 2 Hz update rate). This represents lookout awareness — you know roughly where a ship is but cannot see it precisely. Detection range is modified by the player's Lookout crew skill.
- Beyond detection: No updates. These ships do not exist to this client's sea-scale AoI.
This two-tier model is both a performance optimization and a gameplay design element: fog reduces the visibility tier boundary (potentially to 0 in zero-visibility supernatural fog), making navigation and ambush genuinely possible at the simulation level.
AoI Transitions and Information Security
When a ship crosses from beyond-AoI into detection range, the client receives a spawn packet with the ship's general class and faction (enemy / neutral / unknown) but not detailed ship statistics. Detailed state packets begin arriving when the ship enters visibility range.
This prevents information exploitation: a client cannot determine enemy ship hull integrity, cannon loadout, or crew state beyond what would be observable in-world. The server enforces AoI — clients cannot request information for entities outside their AoI.
Latency Handling — Summary Table
| Combat Element | Server Authority | Client Prediction | Latency Compensation |
|---|---|---|---|
| Character position | Final arbiter | Extrapolate current velocity | Server corrects via rubber-band (≤200ms visible) |
| Attack hit/miss | Server 3d6 roll | Animation predicted, result awaited | 300ms attack window; 100ms defense compensation |
| Active defense success | Server 3d6 roll | Animation predicted, result awaited | 100ms timestamp compensation for defense window |
| GURPS damage value | Server-only | Never predicted | Damage applied visually on server confirmation |
| FP / HP values | Server-only | Never predicted | HUD always shows confirmed server values |
| Ship position | Server-authoritative | Extrapolate physics model | Smooth correction via blend (20 Hz updates) |
| Cannon projectile | Server-simulated | Visual trajectory is approximated | Impact plays on server-confirmed time |
| Weather state | Server-owned | Client renders server parameters | 20 Hz updates; visual changes are gradual |
| Boarding initiation | Server-validated | None | 2-second animation covers round-trip |
| Crew combat in boarding | Server GURPS sim | Animation predicted, result awaited | Same as character combat above |
Packet Budget Analysis
Per-player per-second bandwidth at steady state:
| Data Type | Direction | Bytes/sec (estimated) |
|---|---|---|
| Land instance tick updates (30 Hz) | Server → Client | 3,000-15,000 |
| Player input packets | Client → Server | 200-500 |
| Party member state (5 others at 30 Hz) | Server → Client | 2,000-6,000 |
| AoI entity state (50-200 entities) | Server → Client | 5,000-25,000 |
| Total land instance | Bidirectional | 10-46 KB/s |
| Sea instance tick (20 Hz, 8 ships) | Server → Client | 2,000-8,000 |
| Player ship input | Client → Server | 100-200 |
| Projectile packets (active combat) | Server → Client | 1,000-5,000 |
| AoI ship updates (16 ships) | Server → Client | 5,000-10,000 |
| Total sea instance | Bidirectional | 8-23 KB/s |
These estimates are well within typical consumer broadband capabilities (2+ Mbps upload, 10+ Mbps download). Mobile network play is feasible under good signal conditions but not guaranteed — the game does not optimize for mobile data saving.
Party Synchronization
In a 6-player land party, each client receives the state of all other party members in addition to all NPC entities. Network load scales sub-linearly because area-of-interest filtering reduces updates for party members in different rooms.
Party join mid-instance: When a player joins a running instance via a ship anchor point or portal:
- Server generates a full world state snapshot of the current instance — all alive entities, their positions and current state, ground items, boss health, boss phase
- Snapshot is compressed (zlib) and transmitted — typical snapshot size for a mid-instance land area: 50-200 KB
- Client receives snapshot and initializes the scene in background while the loading screen is still visible
- Once snapshot is processed, the player is placed in the instance and visible to other clients
- Other clients receive a
PLAYER_JOINEDentity spawn packet and begin receiving that player's position updates
Protocol Security Considerations
All game protocol packets include:
- A session token (encrypted, server-validated) confirming the player's authenticated identity
- A sequence number for ordering and replay detection
- A timestamp for defense window validation
Packets without valid session tokens are dropped at the gateway tier, before reaching simulation servers. The simulation server never processes unauthenticated inputs. See security-and-integrity.md for full security architecture.
Cross-references: design/10-technical-architecture/server-architecture.md, design/10-technical-architecture/client-architecture.md, design/10-technical-architecture/instance-management.md, design/10-technical-architecture/security-and-integrity.md