Synchronized Variables (SyncVars)
SteamNetworkLib provides a high-level API for synchronized variables that automatically keep values in sync across all lobby members with minimal boilerplate.
For a broader guide to deciding what belongs in host-owned state, client-owned state, P2P messages, or local-only state, see Making Mods Multiplayer Compatible.
Quick Start
// Host-authoritative: only host can modify, all can read
var roundNumber = client.CreateHostSyncVar("Round", 1);
// Client-owned: each client owns their value, all can read everyone's
var isReady = client.CreateClientSyncVar("Ready", false);
HostSyncVar - Host-Authoritative Data
Use HostSyncVar<T> when you need a single shared value that only the lobby host can modify:
// Create
var gameSettings = client.CreateHostSyncVar("Settings", new GameSettings());
var maxScore = client.CreateHostSyncVar("MaxScore", 100);
// Subscribe to changes
gameSettings.OnValueChanged += (oldVal, newVal) =>
{
MelonLogger.Msg($"Settings changed!");
};
// Modify (only works for host - silently ignored otherwise)
maxScore.Value = 200;
// Read (works for everyone)
int current = maxScore.Value;
Key Points
- Only the lobby host can set the value
- Non-host writes are silently ignored (no exceptions)
- Enable
WarnOnIgnoredWritesin options for debugging - Uses Steam lobby data under the hood
ClientSyncVar - Per-Client Data
Use ClientSyncVar<T> when each client needs their own synced value:
// Create
var isReady = client.CreateClientSyncVar("Ready", false);
var playerLoadout = client.CreateClientSyncVar("Loadout", "default");
// Subscribe to any client's changes
isReady.OnValueChanged += (playerId, oldVal, newVal) =>
{
MelonLogger.Msg($"Player {playerId} ready: {newVal}");
};
// Subscribe to only my changes
isReady.OnMyValueChanged += (oldVal, newVal) =>
{
MelonLogger.Msg($"I am now ready: {newVal}");
};
// Set my own value
isReady.Value = true;
// Read my value
bool myReady = isReady.Value;
// Read another client's value
bool player2Ready = isReady.GetValue(player2Id);
// Get all clients' values
Dictionary<CSteamID, bool> allReady = isReady.GetAllValues();
bool everyoneReady = allReady.Values.All(r => r);
Key Points
- Each client can only modify their own value
- All clients can read all other clients' values
- Missing values return the default value
- Uses Steam lobby member data under the hood
Supported Types
The default JSON serializer supports:
| Category | Types |
|---|---|
| Primitives | int, long, float, double, bool, string, byte, short, uint, ulong, decimal |
| Enums | Any enum type (serialized as integer) |
| Collections | List<T>, T[], Dictionary<string, T> |
| Custom Types | Classes/structs with parameterless constructor and public properties |
Custom Type Example
public class GameSettings
{
public int MaxPlayers { get; set; } = 4;
public string MapName { get; set; } = "default";
public bool FriendlyFire { get; set; } = false;
public List<string> EnabledMods { get; set; } = new();
}
// Usage
var settings = client.CreateHostSyncVar("Settings", new GameSettings());
Requirements for custom types:
- Must have a public parameterless constructor
- Properties must be public with both getter and setter
- Property types must themselves be serializable
- No circular references
Configuration Options
Customize behavior with NetworkSyncOptions:
var options = new NetworkSyncOptions
{
// Log warnings when non-host tries to write (debugging)
WarnOnIgnoredWrites = true,
// Add prefix to avoid key collisions with other mods (IMPORTANT!)
KeyPrefix = "MyMod_",
// Disable auto-sync for manual batching (see below)
AutoSync = false,
// Rate limit syncs (e.g., 10 per second for position updates)
MaxSyncsPerSecond = 10,
// Throw exceptions on validation errors (default: false)
ThrowOnValidationError = false,
// Use custom serializer (optional)
Serializer = new MyCustomSerializer()
};
var score = client.CreateHostSyncVar("Score", 0, options);
KeyPrefix - Avoid Collisions
Always use a unique prefix for published mods to prevent key collisions with other mods.
// Good: Use your mod name as prefix
var options = new NetworkSyncOptions { KeyPrefix = "MyMod_" };
var score = client.CreateHostSyncVar("Score", 0, options);
// Actual Steam key: "MyMod_Score"
var teamName = client.CreateClientSyncVar("TeamName", "Alpha", options);
// Actual Steam key: "MyMod_TeamName"
When using raw lobby/member data, apply prefixes manually:
const string PREFIX = "MyMod_";
client.SetLobbyData($"{PREFIX}version", "1.0.0");
client.SetMyData($"{PREFIX}loadout", "1911");
Value Validation
Add validation constraints to ensure values meet requirements before syncing:
// Range validation (built-in)
var scoreValidator = new RangeValidator<int>(0, 9999);
var score = client.CreateHostSyncVar("Score", 0, null, scoreValidator);
// Predicate validation (simple custom logic)
var teamNameValidator = new PredicateValidator<string>(
value => value.Length >= 3 && value.Length <= 15,
"Team name must be 3-15 characters"
);
var teamName = client.CreateClientSyncVar("Team", "Alpha", null, teamNameValidator);
// Composite validation (combine multiple rules)
var usernameValidator = new CompositeValidator<string>(
new PredicateValidator<string>(
v => v.Length >= 3 && v.Length <= 20,
"Username must be 3-20 characters"
),
new PredicateValidator<string>(
v => char.IsLetter(v[0]),
"Username must start with a letter"
)
);
Validation behavior:
- Invalid values are rejected before syncing
- By default, validation errors are logged and trigger
OnSyncError - Set
ThrowOnValidationError = trueto throw exceptions instead - Failed writes do not change the current value
Rate Limiting
Limit how frequently a SyncVar can sync to prevent network spam:
var positionOptions = new NetworkSyncOptions
{
MaxSyncsPerSecond = 10 // Max 10 position updates per second
};
var positionX = client.CreateClientSyncVar("PosX", 0f, positionOptions);
// Rapid updates - automatically throttled to 10/sec
for (int i = 0; i < 100; i++)
{
positionX.Value = i * 0.1f; // Only ~10 of these will actually sync
}
// Check if there's a pending value waiting to sync
if (positionX.IsDirty)
{
// Force immediate sync, bypassing rate limit
positionX.FlushPending();
}
Batch Syncing / Manual Sync
Disable AutoSync to make multiple changes before syncing:
var batchOptions = new NetworkSyncOptions { AutoSync = false };
var gamePhase = client.CreateHostSyncVar("Phase", "Lobby", batchOptions);
var roundNumber = client.CreateHostSyncVar("Round", 0, batchOptions);
// Make multiple changes locally - nothing syncs yet
gamePhase.Value = "InGame";
roundNumber.Value = 1;
// Check which vars have unsaved changes
if (gamePhase.IsDirty || roundNumber.IsDirty)
{
// Sync all changes at once
gamePhase.FlushPending();
roundNumber.FlushPending();
}
Use cases for manual syncing:
- Atomic multi-variable updates
- Reducing network traffic when changing multiple values
- Deferring sync until a specific game event
Advanced Example
For a comprehensive example demonstrating validation, rate limiting, and batch syncing, see:
Examples/AdvancedSyncVarExample.cs
This example includes:
- Range validation with error handling
- Rate-limited position updates
- Batch syncing for state transitions
- Custom validators with complex rules
- Interactive hotkeys to test each feature
Custom Serialization
Implement ISyncSerializer for custom serialization:
public class MySerializer : ISyncSerializer
{
public string Serialize<T>(T value)
{
// Your serialization logic
}
public T Deserialize<T>(string data)
{
// Your deserialization logic
}
public bool CanSerialize(Type type)
{
// Return true if type is supported
}
}
// Usage
var options = new NetworkSyncOptions { Serializer = new MySerializer() };
var data = client.CreateHostSyncVar("Data", myValue, options);
Raw string payloads
Use RawStringSyncSerializer when the value is already encoded and non-empty values must be stored exactly as-is. This is useful for compatibility with existing lobby-data formats, pipe-delimited protocol strings, or a mod that already serializes state outside SteamNetworkLib.
var rawOptions = new NetworkSyncOptions
{
KeyPrefix = "MyMod_",
Serializer = new RawStringSyncSerializer()
};
var hostState = client.CreateHostSyncVar("HostState", "", rawOptions);
// Stored as stock|pseudo|12|420, not as a JSON string with quotes.
hostState.Value = "stock|pseudo|12|420";
Prefer the default JsonSyncSerializer for normal typed values. RawStringSyncSerializer only supports string; it intentionally rejects other types so accidental typed state does not get converted through ToString().
RawStringSyncSerializer does not add framing, escaping, or sentinel values. Null values are normalized to an empty string. Steam lobby and member data also return an empty string for missing keys, so SyncVars cannot distinguish an empty raw value from absent data on another peer. Use an empty default value when empty should mean reset or clear; use an explicit non-empty protocol token when empty and missing must remain distinct.
Complete Example
For a comprehensive, production-ready example demonstrating all SyncVar features, see:
This example includes:
- Host-authoritative game state (round tracking, settings, timer)
- Client-owned state (ready system, teams, loadouts)
- Custom serializable types (classes and enums)
- Event handling and error management
- Interactive test hotkeys (F1-F7)
- Ready-check system with real game logic
Run the example to see SyncVars in action with live synchronization across multiple clients.
Lifecycle Management
SyncVars are automatically cleaned up when:
- You leave a lobby (
OnLobbyLeft) - The
SteamNetworkClientis disposed
No manual disposal required! Just create them and forget about cleanup.
// Create sync vars
var score = client.CreateHostSyncVar("Score", 0);
var ready = client.CreateClientSyncVar("Ready", false);
// Use them...
score.Value = 100;
ready.Value = true;
// When you leave the lobby or dispose the client,
// all sync vars are automatically disposed - no cleanup code needed!
Error Handling
var score = client.CreateHostSyncVar("Score", 0);
// Subscribe to sync errors
score.OnSyncError += (exception) =>
{
MelonLogger.Error($"Sync error: {exception.Message}");
};
// For debugging non-host writes
score.OnWriteIgnored += (attemptedValue) =>
{
MelonLogger.Warning($"Write ignored: {attemptedValue}");
};
When to Use Which
| Use Case | SyncVar Type |
|---|---|
| Game settings | HostSyncVar |
| Round/match state | HostSyncVar |
| Shared timer | HostSyncVar |
| Player ready status | ClientSyncVar |
| Player loadout/class | ClientSyncVar |
| Player preferences | ClientSyncVar |
| Per-player scores | ClientSyncVar |
Host-owned values should be written only from host-authoritative code paths. If a client needs to affect a host-owned value, send a typed request message to the host, validate it there, then let the host publish the accepted result.