Table of Contents

Recipes

Short, focused examples for common tasks.

Optional Steam initialization with retry

private SteamNetworkClient client = new SteamNetworkClient();
private bool multiplayerAvailable;
private float nextRetryAt;

public override void OnUpdate()
{
    if (!multiplayerAvailable && Time.realtimeSinceStartup >= nextRetryAt)
    {
        if (client.TryInitialize(out var error))
        {
            multiplayerAvailable = true;
            MelonLogger.Msg("Steam networking ready.");
        }
        else
        {
            nextRetryAt = Time.realtimeSinceStartup + 2f;
            MelonLogger.Warning($"Steam networking not ready: {error?.Message}");
        }
    }

    if (multiplayerAvailable)
    {
        client.ProcessIncomingMessages();
    }
}

Guard every networking path with multiplayerAvailable. Your local/single-player logic should still run when Steamworks is unavailable.

Find host and remote members

if (client.TryGetHostMember(out var host))
{
    MelonLogger.Msg($"Host is {host.DisplayName} ({host.SteamId64})");
}

foreach (var member in client.GetRemoteMembers())
{
    MelonLogger.Msg($"Remote member {member.DisplayName}: {member.SteamIdString}");
}

Send a typed transaction message

public class TransactionPayload
{
    public string TransactionId { get; set; } = string.Empty;
    public string ItemId { get; set; } = string.Empty;
    public int Quantity { get; set; }
    public decimal UnitPrice { get; set; }
}

public class TransactionMessage : TypedP2PMessage<TransactionPayload>
{
    public override string MessageType => "MYMOD_TRANSACTION";

    public TransactionMessage()
    {
    }

    public TransactionMessage(TransactionPayload payload)
        : base(payload)
    {
    }
}

client.RegisterMessageHandler<TransactionMessage>((message, sender) =>
{
    MelonLogger.Msg($"Received {message.Payload.TransactionId} from {sender.m_SteamID}");
});

await client.SendMessageToPlayerAsync(hostId, new TransactionMessage(new TransactionPayload
{
    TransactionId = Guid.NewGuid().ToString("N"),
    ItemId = "pseudo",
    Quantity = 10,
    UnitPrice = 25m
}));

Host-approved client request

Use this shape when the client wants to do something that affects shared state, but the host must validate it first.

public class CheckoutRequest : TypedP2PMessage<CheckoutRequestPayload>
{
    public override string MessageType => "MYMOD_CHECKOUT_REQUEST";

    public CheckoutRequest()
    {
    }

    public CheckoutRequest(CheckoutRequestPayload payload)
        : base(payload)
    {
    }
}

public class CheckoutRequestPayload
{
    public string RequestId { get; set; } = string.Empty;
    public string ItemId { get; set; } = string.Empty;
    public int Quantity { get; set; }
}

// Host-side handler.
client.RegisterMessageHandler<CheckoutRequest>((message, sender) =>
{
    if (!client.IsHost)
    {
        return;
    }

    var request = message.Payload;
    int approved = Math.Min(request.Quantity, GetAvailableStock(request.ItemId));
    ApplyHostApprovedCheckout(sender, request.ItemId, approved);
});

// Client-side request.
await client.SendMessageToPlayerAsync(hostId, new CheckoutRequest(new CheckoutRequestPayload
{
    RequestId = Guid.NewGuid().ToString("N"),
    ItemId = "pseudo",
    Quantity = 10
}));

The client sends intent only. The host owns final state and publishes the result through a host snapshot, SyncVar, or explicit response message.

Host-authoritative sync var

var roundNumber = client.CreateHostSyncVar("Round", 1);

// Subscribe to changes
roundNumber.OnValueChanged += (oldVal, newVal) =>
{
    MelonLogger.Msg($"Round {oldVal} -> {newVal}");
};

// Host sets new value (clients only read)
roundNumber.Value = 2;

Per-client sync var

var isReady = client.CreateClientSyncVar("Ready", false);

// Subscribe to any player's changes
isReady.OnValueChanged += (playerId, oldVal, newVal) =>
{
    MelonLogger.Msg($"Player {playerId}: ready={newVal}");
};

// Set my own ready status
isReady.Value = true;

// Check if everyone is ready
var allReady = isReady.GetAllValues().Values.All(r => r);

Broadcast a mod configuration to everyone

var cfg = new DataSyncMessage { Key = "mod_config", Value = JsonConvert.SerializeObject(config) };
await client.BroadcastMessageAsync(cfg);

Host-owned state snapshot

public class LabelSnapshot
{
    public Dictionary<string, string> Labels { get; set; } = new Dictionary<string, string>();
    public long Revision { get; set; }
}

var options = new NetworkSyncOptions { KeyPrefix = "MyLabels_" };
var labels = client.CreateHostSyncVar("Labels", new LabelSnapshot(), options);

labels.OnValueChanged += (oldValue, newValue) =>
{
    ApplyLabels(newValue.Labels);
};

// Apply the value that may have loaded before the handler was attached.
ApplyLabels(labels.Value.Labels);

if (client.IsHost)
{
    labels.Value = new LabelSnapshot
    {
        Labels = BuildCurrentLabels(),
        Revision = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()
    };
}

Use snapshots for late joiners and resyncs. Use smaller P2P messages or SyncVars for frequent edits.

Poll member data when callbacks are unreliable

private ClientSyncVar<string> actionRequests;
private readonly HashSet<string> handledRequests = new HashSet<string>();
private float nextPollAt;

public override void OnUpdate()
{
    client.ProcessIncomingMessages();

    if (!client.IsHost || Time.realtimeSinceStartup < nextPollAt)
    {
        return;
    }

    nextPollAt = Time.realtimeSinceStartup + 2f;
    actionRequests.Refresh();

    foreach (var pair in actionRequests.GetAllValues())
    {
        if (string.IsNullOrWhiteSpace(pair.Value) || !handledRequests.Add(pair.Value))
        {
            continue;
        }

        HandleClientActionRequest(pair.Key, pair.Value);
    }
}

Keep this poll slow. It is a reliability fallback for important state, not a per-frame sync loop.

RPC-like event to a single player

var evt = new EventMessage
{
    EventType = "game",
    EventName = "give_item",
    EventData = "soil"
};
await client.SendMessageToPlayerAsync(targetId, evt);

Send a screenshot file to the host

var bytes = File.ReadAllBytes("screenshot.png");
await client.SendLargeDataToPlayerAsync(hostId, "screenshot.png", bytes, channel: 1);

For manual chunking, keep each complete serialized packet under the reliable send limit. The payload chunk is smaller than the packet because SteamNetworkLib adds message headers.

var bytes = File.ReadAllBytes("screenshot.png");
int chunk = 64 * 1024;
int total = (int)Math.Ceiling((double)bytes.Length / chunk);

for (int i = 0; i < total; i++)
{
    var slice = bytes.Skip(i * chunk).Take(chunk).ToArray();
    var msg = new FileTransferMessage
    {
        FileName = "screenshot.png",
        FileSize = bytes.Length,
        ChunkIndex = i,
        TotalChunks = total,
        IsFileData = true,
        ChunkData = slice
    };
    await client.SendMessageToPlayerAsync(hostId, msg);
}

Invite friends via Steam overlay

client.OpenInviteDialog();

Check mod version compatibility

client.SetMyData("mod_version", MyMod.Version);
client.SyncModDataWithAllPlayers("mod_version", MyMod.Version);
if (!client.IsModDataCompatible("mod_version"))
{
    MelonLogger.Warning("Players have mismatched mod versions");
}