Using Steam and EOS lobbies with Discovery
Take a party that already exists in a Steam or Epic Online Services lobby into a matchmade or reserved session with one ticket, without a backend of your own.
This page is for a studio engineer who already has a lobby working in Steamworks or Epic Online Services and has read Matchmaking Integration. It covers only the hand-off from the platform lobby to a Discovery session; forming the party, invites, and chat stay in the platform lobby.
Why Discovery has no lobby primitive
Steam and EOS already hold the party: membership, invites, voice, and who the leader is. Discovery does not duplicate that. What it offers is a single ticket or a single reservation with a partySize, and the lobby's own data channel is how the result reaches the rest of the party. The page assumes the party is already formed in the platform lobby.
The flow in one paragraph
The lobby leader submits ONE ticket with partySize equal to the lobby's member count (up to sessionSize, which is capped at 1000; from a player token up to 8 by default). Only the leader talks to Discovery. The leader polls its ticket; when it is matched, it writes ip and port into lobby data; every member reads them and connects. Nothing else in the party ever calls Discovery.
Tokens: who calls Discovery
Two options.
Without a backend, the leader uses a player token: an anonymous one from POST /v1/apps/{publicId}/player-tokens if the app allows it, or a studio-signed one if your login service mints them (setup for both is on Choosing How Players Connect). The leader's token owns the ticket: only the leader can poll or cancel it, which is what you want. The player floors apply. The ticket cannot ask for a session smaller than 2 and its relaxAfterSeconds is at least 10, and the party must be strictly smaller than both sessionSize and minSessionSize (400 party_fills_session_for_player otherwise). A player-token ticket can never be the whole session, so a full lobby that IS the session (a 4-player lobby wanting a 4-seat private match) is the second option.
With a backend, the backend submits the ticket with an allocate-scope dsc_ token on the leader's behalf and is then the poller. None of the player floors apply to it.
Use the first option unless you need full-party sessions or your own gate in front of the queue. The party already trusts the leader's client to run the lobby, and one more call from that client changes nothing about that trust.
Steam: carrying the state in lobby data
The leader writes three keys with ISteamMatchmaking::SetLobbyData; members read them with GetLobbyData when the LobbyDataUpdate_t callback fires.
pc_ticket: theticketId, written as soon as the ticket is submitted so a member can show "finding a match".pc_status:queued,matched, orfailed.pc_endpoint:ip:port, written once, and only whenpc_statusismatched.
Write pc_endpoint LAST so no member connects before pc_status says matched. Members react to pc_endpoint appearing, not to pc_status alone.
The ticket body carries queue: "casual", sessionSize: 8, partySize equal to the member count, minSessionSize: 4, relaxAfterSeconds: 30, and the members' SteamID64 strings in context, one per member, so the server sees the party's Steam ids on its roster entry's context. With a player token the party must stay strictly below both sizes, so minSessionSize: 4 serves lobbies of up to three members; set it one above your largest lobby, or submit larger lobbies from a backend. The ticket contract has no roster field of its own: the roster the server receives has one entry per ticket, and the playerId on that entry is the leader's token sub, not a Steam id. Everything in context is echoed, never verified, so the server treats the ids as a hint and authenticates each connecting client through Steam itself.
Leader (C#, Steamworks.NET)
// Steamworks.NET. The lobby leader submits one ticket for the whole lobby,
// polls it, and publishes the result through lobby data.
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Steamworks;
public sealed class LobbyMatchmaker
{
private static readonly HttpClient Http = new HttpClient();
private const string TicketsUrl = "https://discovery.pingcore.io/v1/apps/<publicId>/tickets";
// playerToken: the leader's anonymous or studio-signed player token.
public async Task RunAsync(CSteamID lobbyId, string playerToken, CancellationToken ct)
{
var memberCount = SteamMatchmaking.GetNumLobbyMembers(lobbyId);
var members = new List<string>(memberCount);
for (var i = 0; i < memberCount; i++)
{
members.Add(SteamMatchmaking.GetLobbyMemberByIndex(lobbyId, i).m_SteamID.ToString());
}
var body = JsonSerializer.Serialize(new
{
queue = "casual",
sessionSize = 8,
partySize = memberCount, // must stay below sessionSize AND minSessionSize for a player token
minSessionSize = 4, // so lobbies of up to 3 work here; size it above your largest lobby
relaxAfterSeconds = 30,
context = new { members }, // SteamID64 per member; echoed to the server, never verified
});
using var submit = new HttpRequestMessage(HttpMethod.Post, TicketsUrl);
submit.Headers.Authorization = new AuthenticationHeaderValue("Bearer", playerToken);
submit.Content = new StringContent(body, Encoding.UTF8, "application/json");
using var submitted = await Http.SendAsync(submit, ct);
var submitJson = await submitted.Content.ReadAsStringAsync();
if (!submitted.IsSuccessStatusCode)
{
// 409 too_many_tickets: cancel the previous ticket first.
// 400 party_fills_session_for_player: the lobby is the whole session; use a backend.
SteamMatchmaking.SetLobbyData(lobbyId, "pc_status", "failed");
return;
}
var ticketId = JsonDocument.Parse(submitJson).RootElement.GetProperty("ticketId").GetString();
SteamMatchmaking.SetLobbyData(lobbyId, "pc_ticket", ticketId);
SteamMatchmaking.SetLobbyData(lobbyId, "pc_status", "queued");
while (!ct.IsCancellationRequested)
{
await Task.Delay(TimeSpan.FromSeconds(2), ct);
using var poll = new HttpRequestMessage(HttpMethod.Get, $"{TicketsUrl}/{ticketId}");
poll.Headers.Authorization = new AuthenticationHeaderValue("Bearer", playerToken);
using var response = await Http.SendAsync(poll, ct);
if ((int)response.StatusCode == 404)
{
SteamMatchmaking.SetLobbyData(lobbyId, "pc_status", "failed"); // expired: resubmit with a new id
return;
}
if (!response.IsSuccessStatusCode) continue;
var ticket = JsonDocument.Parse(await response.Content.ReadAsStringAsync()).RootElement;
if (ticket.GetProperty("status").GetString() != "matched") continue;
var endpoint = $"{ticket.GetProperty("ip").GetString()}:{ticket.GetProperty("port").GetInt32()}";
SteamMatchmaking.SetLobbyData(lobbyId, "pc_status", "matched");
SteamMatchmaking.SetLobbyData(lobbyId, "pc_endpoint", endpoint); // written LAST
return;
}
}
// Call on the way out of the lobby while the ticket is still queued (see Failure handling).
public async Task CancelAsync(string ticketId, string playerToken)
{
using var cancel = new HttpRequestMessage(HttpMethod.Delete, $"{TicketsUrl}/{ticketId}");
cancel.Headers.Authorization = new AuthenticationHeaderValue("Bearer", playerToken);
using var _ = await Http.SendAsync(cancel);
}
}Member (C#, Steamworks.NET)
// Every member (the leader included) reacts to lobby data. It connects only
// when pc_endpoint appears, which the leader writes after pc_status = matched.
using Steamworks;
public sealed class LobbyMemberJoin
{
private readonly Callback<LobbyDataUpdate_t> _lobbyDataUpdate;
private readonly CSteamID _lobbyId;
private bool _connected;
public LobbyMemberJoin(CSteamID lobbyId)
{
_lobbyId = lobbyId;
_lobbyDataUpdate = Callback<LobbyDataUpdate_t>.Create(OnLobbyDataUpdate);
}
private void OnLobbyDataUpdate(LobbyDataUpdate_t update)
{
if (update.m_ulSteamIDLobby != _lobbyId.m_SteamID || _connected) return;
var status = SteamMatchmaking.GetLobbyData(_lobbyId, "pc_status");
if (status == "queued")
{
ShowSearching(SteamMatchmaking.GetLobbyData(_lobbyId, "pc_ticket"));
return;
}
if (status == "failed")
{
ShowFailed(); // the leader resubmits; nothing for a member to do
return;
}
var endpoint = SteamMatchmaking.GetLobbyData(_lobbyId, "pc_endpoint");
if (status == "matched" && !string.IsNullOrEmpty(endpoint))
{
_connected = true;
var parts = endpoint.Split(':');
ConnectToServer(parts[0], int.Parse(parts[1])); // present your SteamID in the handshake
}
}
private void ShowSearching(string ticketId) { /* UI */ }
private void ShowFailed() { /* UI */ }
private void ConnectToServer(string ip, int port) { /* your netcode */ }
}Epic Online Services: the same three keys as lobby attributes
The leader writes the same three keys as lobby attributes with EOS_Lobby_UpdateLobbyModification plus EOS_LobbyModification_AddAttribute, with visibility EOS_LAT_PUBLIC so members can read them. Members read them with EOS_Lobby_CopyLobbyDetailsHandle plus EOS_LobbyDetails_CopyAttributeByKey, driven by EOS_Lobby_AddNotifyLobbyUpdateReceived. Same key names pc_ticket, pc_status, pc_endpoint, same write order. EOS ProductUserId strings go into the ticket's context exactly as Steam ids do.
Leader (C++, EOS SDK)
// EOS SDK. Writes one string attribute on the lobby; call it for pc_ticket, pc_status
// and finally pc_endpoint. HttpPostJson / HttpGet are your own HTTP helpers.
static void SetLobbyKey(EOS_HLobby LobbyHandle, EOS_ProductUserId LocalUserId,
const char* LobbyId, const char* Key, const char* Value)
{
EOS_Lobby_UpdateLobbyModificationOptions ModOptions = {};
ModOptions.ApiVersion = EOS_LOBBY_UPDATELOBBYMODIFICATION_API_LATEST;
ModOptions.LocalUserId = LocalUserId;
ModOptions.LobbyId = LobbyId;
EOS_HLobbyModification ModHandle = nullptr;
if (EOS_Lobby_UpdateLobbyModification(LobbyHandle, &ModOptions, &ModHandle) != EOS_EResult::EOS_Success) return;
EOS_Lobby_AttributeData Attribute = {};
Attribute.ApiVersion = EOS_LOBBY_ATTRIBUTEDATA_API_LATEST;
Attribute.Key = Key;
Attribute.ValueType = EOS_ELobbyAttributeType::EOS_AT_STRING;
Attribute.Value.AsUtf8 = Value;
EOS_LobbyModification_AddAttributeOptions AddOptions = {};
AddOptions.ApiVersion = EOS_LOBBYMODIFICATION_ADDATTRIBUTE_API_LATEST;
AddOptions.Attribute = &Attribute;
AddOptions.Visibility = EOS_ELobbyAttributeVisibility::EOS_LAT_PUBLIC; // members must be able to read it
EOS_LobbyModification_AddAttribute(ModHandle, &AddOptions);
EOS_Lobby_UpdateLobbyOptions UpdateOptions = {};
UpdateOptions.ApiVersion = EOS_LOBBY_UPDATELOBBY_API_LATEST;
UpdateOptions.LobbyModificationHandle = ModHandle;
EOS_Lobby_UpdateLobby(LobbyHandle, &UpdateOptions, nullptr,
[](const EOS_Lobby_UpdateLobbyCallbackInfo*) {});
EOS_LobbyModification_Release(ModHandle);
}
// The leader: one ticket for the whole lobby. Members is the list of
// ProductUserId strings (EOS_ProductUserId_ToString) in the order you want on the server.
void SubmitLobbyTicket(EOS_HLobby LobbyHandle, EOS_ProductUserId LocalUserId, const char* LobbyId,
const std::vector<std::string>& Members, const std::string& PlayerToken)
{
const std::string Url = "https://discovery.pingcore.io/v1/apps/<publicId>/tickets";
const std::string Body =
"{\"queue\":\"casual\",\"sessionSize\":8,\"partySize\":" + std::to_string(Members.size()) +
",\"minSessionSize\":4,\"relaxAfterSeconds\":30,\"context\":{\"members\":" + ToJsonArray(Members) + "}}";
HttpResponse Submitted = HttpPostJson(Url, Body, PlayerToken);
if (Submitted.Status != 200)
{
// 409 too_many_tickets: cancel the previous ticket. 400 party_fills_session_for_player: use a backend.
SetLobbyKey(LobbyHandle, LocalUserId, LobbyId, "pc_status", "failed");
return;
}
const std::string TicketId = JsonString(Submitted.Body, "ticketId");
SetLobbyKey(LobbyHandle, LocalUserId, LobbyId, "pc_ticket", TicketId.c_str());
SetLobbyKey(LobbyHandle, LocalUserId, LobbyId, "pc_status", "queued");
for (;;)
{
SleepSeconds(2); // the loop cadence
HttpResponse Poll = HttpGet(Url + "/" + TicketId, PlayerToken);
if (Poll.Status == 404) { SetLobbyKey(LobbyHandle, LocalUserId, LobbyId, "pc_status", "failed"); return; }
if (Poll.Status != 200) continue;
if (JsonString(Poll.Body, "status") != "matched") continue;
const std::string Endpoint = JsonString(Poll.Body, "ip") + ":" + std::to_string(JsonInt(Poll.Body, "port"));
SetLobbyKey(LobbyHandle, LocalUserId, LobbyId, "pc_status", "matched");
SetLobbyKey(LobbyHandle, LocalUserId, LobbyId, "pc_endpoint", Endpoint.c_str()); // written LAST
return;
}
}Member (C++, EOS SDK)
// Every member subscribes to lobby updates and reads the three keys. It connects
// only when pc_endpoint is present, which the leader writes after pc_status = matched.
static std::string ReadLobbyKey(EOS_HLobby LobbyHandle, EOS_ProductUserId LocalUserId,
const char* LobbyId, const char* Key)
{
EOS_Lobby_CopyLobbyDetailsHandleOptions CopyOptions = {};
CopyOptions.ApiVersion = EOS_LOBBY_COPYLOBBYDETAILSHANDLE_API_LATEST;
CopyOptions.LobbyId = LobbyId;
CopyOptions.LocalUserId = LocalUserId;
EOS_HLobbyDetails Details = nullptr;
if (EOS_Lobby_CopyLobbyDetailsHandle(LobbyHandle, &CopyOptions, &Details) != EOS_EResult::EOS_Success) return "";
EOS_LobbyDetails_CopyAttributeByKeyOptions AttrOptions = {};
AttrOptions.ApiVersion = EOS_LOBBYDETAILS_COPYATTRIBUTEBYKEY_API_LATEST;
AttrOptions.AttrKey = Key;
std::string Value;
EOS_Lobby_Attribute* Attribute = nullptr;
if (EOS_LobbyDetails_CopyAttributeByKey(Details, &AttrOptions, &Attribute) == EOS_EResult::EOS_Success)
{
if (Attribute->Data->ValueType == EOS_ELobbyAttributeType::EOS_AT_STRING) Value = Attribute->Data->Value.AsUtf8;
EOS_Lobby_Attribute_Release(Attribute);
}
EOS_LobbyDetails_Release(Details);
return Value;
}
void SubscribeToLobbyResult(EOS_HLobby LobbyHandle, EOS_ProductUserId LocalUserId)
{
EOS_Lobby_AddNotifyLobbyUpdateReceivedOptions Options = {};
Options.ApiVersion = EOS_LOBBY_ADDNOTIFYLOBBYUPDATERECEIVED_API_LATEST;
EOS_Lobby_AddNotifyLobbyUpdateReceived(LobbyHandle, &Options, LocalUserId,
[](const EOS_Lobby_LobbyUpdateReceivedCallbackInfo* Data)
{
EOS_ProductUserId Me = static_cast<EOS_ProductUserId>(Data->ClientData);
const std::string Status = ReadLobbyKey(GLobbyHandle, Me, Data->LobbyId, "pc_status");
if (Status == "queued") { ShowSearching(ReadLobbyKey(GLobbyHandle, Me, Data->LobbyId, "pc_ticket")); return; }
if (Status == "failed") { ShowFailed(); return; }
const std::string Endpoint = ReadLobbyKey(GLobbyHandle, Me, Data->LobbyId, "pc_endpoint");
if (Status == "matched" && !Endpoint.empty() && !GConnected)
{
GConnected = true;
ConnectToServer(Endpoint); // present your ProductUserId in the handshake
}
});
}Server browsers: the reservation variant
When the party picks a server from the list instead of matchmaking, the leader calls POST .../servers/{serverId}/reservations (or quick-join) with seats equal to the member count and playerIds for every member. It writes pc_endpoint and one extra key, pc_reservation (the reservationId), into lobby data. Members read both and connect. The game server verifies the reservation ONCE for the whole party: GET /v1/reservations/verify/{reservationId} answers seats and playerIds, so the server admits each member whose id appears there. The reservation rules (seat caps, TTLs, the verify response) are on Reservations and Quick-Join and are not repeated here.
Measured latency for a party
Each member measures the beacons as described on Latency and Location Targeting and writes its map into lobby data under pc_latency_<memberIndex> (or sends it to the leader over the lobby's P2P channel). The leader merges the maps and submits the ticket with latency set to the per-location MAXIMUM across members, so maxLatencyMs protects the worst-connected member, which is exactly how the matchmaker itself ranks (minimax). If a member has no measurement for a location, omit that location from the merged map: under maxLatencyMs that excludes it, and without a ceiling it marks the location unmeasured.
Failure handling
The leader leaves the lobby. The new leader cannot cancel the old ticket: a player token owns it, and only that token can see it. The OLD client cancels on the way out (
DELETE .../tickets/{ticketId}), or the ticket lapses at its TTL (300 seconds by default). The new leader submits a fresh ticket.A member leaves while queued. Cancel and resubmit with the smaller
partySize. A matched ticket has already claimed the seats; the game handles a no-show like any disconnect.The ticket is
failedor expired. Clearpc_statusback toqueuedand resubmit. Do not reuse theticketIdfrom a player token: if the old record is still alive, the resubmit answers 409ticket_id_taken.A member connects before
pc_statusismatched. The write-order rule prevents it:pc_endpointis written last, and members connect onpc_endpoint, not onpc_status.
What Discovery never learns
Lobby membership, invites, chat, voice, and who the leader is. Discovery sees one ticket (or one reservation) with a partySize and whatever the leader chose to put in context or playerIds. It has no lobby id and no callback into Steam or EOS. A platform lobby outage does not touch matchmaking, and a Discovery outage leaves the lobby intact.
Checkpoints
You should see:
After the leader submits:
pc_ticketis set on the lobby andGET .../tickets/{ticketId}answersstatus: "queued"withpartySizeequal to the member count.After a match:
pc_statusismatched,pc_endpointisip:port, and the game server's delivered context carries one roster entry for the lobby whosepartySizeequals the member count and whosecontext.memberslists the ids in the order the leader sent them.In the workspace Matchmaking card the ticket row shows the party's
partySizeand, for a player-token ticket, a 12-character player id hash rather than a Steam or Epic id.For the reservation variant:
GET /v1/reservations/verify/{reservationId}from the game server answersseatsequal to the party size and theplayerIdsthe leader sent.
Troubleshooting
Members connect and are rejected as unknown. The ticket was submitted without the member ids in
context, or the server authorises from that list but a member's id was not in it. Send every member's id; the server should still authenticate through the platform.too_many_ticketswhen the leader resubmits. The previous ticket is still alive (a player holds at most 1 queued ticket by default). Cancel it first or wait for the TTL.ticket_id_taken. A player token reused aticketIdthat another live ticket holds. Generate a new id per submit, or omitticketId.session_too_small_for_player. A player-token ticket asked forsessionSizeorminSessionSizebelow 2. A solo lobby that wants a private server goes through a backend and adsc_token (see Matchmaking Integration).party_too_large_for_player. A lobby larger than 8 members (the default) cannot be one player-token ticket. Submit from a backend with adsc_token (apartySizeup to thesessionSize) or split the party.party_fills_session_for_player. The lobby's member count equalssessionSizeorminSessionSize, so the party would be the whole session. A player token cannot commit a server on its own; raisesessionSizeandminSessionSizeso at least one more player is needed, or submit the full-party session from a backend with adsc_token.no_location_within_ceiling. The merged latency map (per-location maximum) has no location undermaxLatencyMs: the worst-connected member is above the ceiling everywhere. Raise the ceiling, have members measure more beacons, or omitmaxLatencyMsso the map becomes a preference.Members see
pc_endpointbut connect to a full server. The write-order rule was not followed, or the server was picked from the list without a party-sized reservation. Reserveseatsfor the whole party.The new leader cannot cancel the old ticket. Expected: the old token owns it. The old client cancels on exit, or the ticket expires.