← Use cases

Unity lobbies, matchmaking and leaderboards

Presence lobbies, “match found” and cached leaderboards with PusherClient for .NET.

Not every message in a game needs a game server. Lobbies, "match found", friends coming online, chat, leaderboards and live-ops announcements are events: small, occasional, and sent to the players who care. Hibira carries them over WebSockets with the open-source PusherClient library for .NET, so your backend publishes with one HTTP call and no socket servers of your own. Keep your gameplay networking (Netcode for GameObjects, Photon, Mirror or your own) for the state that changes every frame.

What your players get

  • A lobby that shows who is in it and who is ready, without refreshing.
  • "Match found" the moment the matchmaker decides, instead of a polling spinner.
  • Leaderboards and event countdowns that are current the moment the screen opens.

Built with

  • PusherClient (pusher-websocket-dotnet) in Unity.
  • Presence channels (presence-) for lobbies and parties.
  • Client events (client-*) for "ready" and other small signals between players.
  • Private channels (private-) for messages to one player.
  • Cache channels (cache-), which hand the latest leaderboard to every player who opens it.

Step 1: connect once, handle events on the main thread

Add the PusherClient package from NuGet (for example with NuGetForUnity), then put this component on a GameObject in your first scene:

using System;
using System.Collections.Concurrent;
using System.Net.Http.Headers;
using PusherClient;
using UnityEngine;

public class Realtime : MonoBehaviour
{
    public static Realtime Instance { get; private set; }
    public Pusher Client { get; private set; }

    // PusherClient raises events on a background thread; Unity's API only works on the main thread.
    readonly ConcurrentQueue<Action> mainThread = new ConcurrentQueue<Action>();
    public void Post(Action action) => mainThread.Enqueue(action);

    async void Awake()
    {
        Instance = this;
        DontDestroyOnLoad(gameObject);
        Client = new Pusher("YOUR_APP_KEY", new PusherOptions
        {
            Host = "ws.hibira.io",
            Encrypted = true,
            ChannelAuthorizer = new HttpChannelAuthorizer("https://api.example.com/pusher/auth")
            {
                AuthenticationHeader = new AuthenticationHeaderValue("Bearer", Session.Token),
            },
        });
        Client.Error += (sender, error) => Debug.LogWarning(error);
        await Client.ConnectAsync();
    }

    void Update()
    {
        while (mainThread.TryDequeue(out var action)) action();
    }

    async void OnApplicationQuit() => await Client.DisconnectAsync();
}

Set Host and not Cluster: setting a cluster points the library back at pusher.com. The app secret stays on your backend, which signs private and presence channels at the auth endpoint.

Step 2: a lobby with presence and "ready"

[Serializable]
public class Player { public string name; public int level; }

var rt = Realtime.Instance;
var lobby = await rt.Client.SubscribePresenceAsync<Player>("presence-lobby-" + lobbyId);

foreach (var m in lobby.GetMembers()) rt.Post(() => LobbyUI.Add(m.Key, m.Value));
lobby.MemberAdded += (sender, m) => rt.Post(() => LobbyUI.Add(m.Key, m.Value));
lobby.MemberRemoved += (sender, m) => rt.Post(() => LobbyUI.Remove(m.Key));

// "Ready" goes straight to the other players: turn on Allow client events in the app settings
lobby.Bind("client-ready", (PusherEvent e) => rt.Post(() => LobbyUI.SetReady(e.UserId, true)));
readyButton.onClick.AddListener(() => lobby.Trigger("client-ready", new { ready = true }));

Your auth endpoint decides who may join a lobby and returns each player's user_id and user_info (here the name and level), which every member receives.

Step 3: match found and leaderboards from your backend

// When the matchmaker pairs players
await pusher.trigger(players.map((p) => 'private-player-' + p.id), 'match-found', { matchId, server: address });

// The latest standings, kept for players who open the screen later
await pusher.trigger('cache-leaderboard-weekly', 'update', { top: await leaderboard.top(20) });
var rt = Realtime.Instance;

var me = await rt.Client.SubscribeAsync("private-player-" + Session.PlayerId);
me.Bind("match-found", (PusherEvent e) => rt.Post(() => Matchmaking.Join(JsonUtility.FromJson<Match>(e.Data))));

// Listen before subscribing, so the cached standings sent right after the subscription are not missed
rt.Client.BindAll((string eventName, PusherEvent e) =>
{
    if (e.ChannelName == "cache-leaderboard-weekly" && eventName == "update")
        rt.Post(() => LeaderboardUI.Render(JsonUtility.FromJson<Standings>(e.Data)));
});
await rt.Client.SubscribeAsync("cache-leaderboard-weekly");

One trigger reaches up to 100 channels, and it counts as one message per channel however many players are subscribed. The Node.js guide and .NET guide show the server side for your backend.

Tips

  • WebGL builds cannot open sockets from C#. There, load pusher-js on the page and call it through a .jslib plugin with the same host and channels.
  • Disconnect when the game is paused on mobile (OnApplicationPause) and connect again on resume; for messages while the game is closed, use push notifications.
  • Presence channels hold up to 100 members, which suits lobbies and parties. For a "players online" count across the whole game, see live audiences.
  • Client events are rate limited per connection and each one counts as a message, so send state changes, not every frame.