What this is: a guided tour of how one piece of trusted per-player data — what a player owns (characters, skills, consumables), how much they paid, their rank, whatever your game needs — travels through the Klotho engine, told through the reference sample code.
Companion: LobbyIntegrationGuide.md §9 is the how-to (which hooks to implement, how to wire them). This doc is the how-it-works — follow one blob from birth to disposal.
The engine treats an entitlement as an opaque byte[]. It never looks inside. Your game decides what the bytes mean and how to pack them; the engine only carries, stores, propagates, and hands them back.
Two rules make the whole thing trustworthy:
- The source is never the client. The bytes come from a lobby you trust (SD) or from a lobby signature every peer can verify (P2P). A player can't invent their own entitlement.
- Once handed over, the bytes are frozen. Whoever produced the
byte[]must not touch it again — the engine and its caches share the same array by reference, and that's only safe because nobody mutates it.
[origin] born during join validation
SD : lobby redeemTicket response ─┐
P2P: lobby-signed ticket payload ─┤→ IdentityValidationOutcome.Accept(account, name, ENTITLEMENT)
│ (opaque byte[], frozen from here on)
[store] kept per-player, on the authority/peer side ◄┘ never on the roster wire
│
[preserve] rides the player's lifetime — survives disconnect, survives the GameStart
│ roster rebuild, released only when the player is evicted
│
[propagate] SD : the tick-0 seed effect ships in the Initial FullState; the raw bytes are
│ also sent to clients so they can READ them (UI) — no re-verification
│ P2P: the signed ticket is sent to every peer → each re-verifies the signature
│ and re-derives byte-identical bytes for itself
│
[read] engine.GetPlayerEntitlement(playerId) → three consumers:
│ ② tick-0 loadout seed · config guard (clamp) · ③ in-match gate (SD)
│
[dispose] player evicted → per-player state gone → the byte[] is garbage-collected
The rest of this doc walks these six stages with the actual sample code. The samples pack the owned-set as a small bitmask struct — that's the running example throughout:
// Samples/IdentityP2pRef/Runtime/DemoEntitlementData.cs
// A [KlothoSerializableStruct] must be unmanaged → fixed-width bitmasks. 12 bytes total.
[KlothoSerializableStruct]
public partial struct DemoEntitlementData
{
public int OwnedClassMask; // character class 0..3 → bit
public int OwnedSkillMask; // (classIdx*2 + slot), 8 bits → bit
public int OwnedConsumableMask; // (consumableId - 100) → bit
}The entitlement is minted from the verified account, not from anything the client says. In the samples that's a pure function ForAccount(account):
// Samples/IdentitySdRef/Runtime/DemoEntitlement.cs
public static byte[] ForAccount(string account)
{
if (IsRestricted(account)) // a "guest" account owns less
return Encode(new DemoEntitlementData {
OwnedClassMask = AllClassesMask, // all 4 classes...
OwnedSkillMask = 0b01010101, // ...but only skill slot 0 of each
OwnedConsumableMask = 0, // ...and no consumable
});
return Encode(new DemoEntitlementData { // everyone else: the full loadout
OwnedClassMask = AllClassesMask,
OwnedSkillMask = FullSkillMask,
OwnedConsumableMask = 1,
});
}Why it's a pure function of the account: a reconnect, a late-join, or (in P2P) each peer re-running this must produce the exact same bytes. Purity guarantees that. (If it depended on wall-clock, RNG, or dictionary order, peers would disagree and the match would desync — see the rules at the end.)
Now the bytes enter the engine. Both modes funnel through the same door — IdentityValidationOutcome.Accept(...):
| SD (dedicated server) | P2P (host / guest) | |
|---|---|---|
| Where they come from | the lobby's redeemTicket reply |
the lobby signs them into the ticket |
| How they enter | the validator returns Accept(account, name, entitlement) |
the validator extracts them after verifying the signature; each guest re-extracts from the propagated ticket |
| Why we trust them | the server is the trust anchor — an online lobby lookup is fine | only the lobby signature is trusted (not the host relay) — same signed ticket ⇒ same bytes on every peer |
// SD: the redeem reply carries the bytes; the validator hands them to the core.
// Samples/IdentitySdRef/Runtime/SdRedeemIdentityValidator.cs
handle.SetResult(IdentityValidationOutcome.Accept(r.Account, r.DisplayName, r.Entitlement));// P2P: the lobby signs the SAME bytes into the ticket payload, so one signature
// covers both identity and entitlement. Samples/.../BrawlerDevIdentity.Server.cs
var ticket = new LobbyTicket(account, displayName, sessionId, nowMs, expiresAt, nonce,
entitlement: DemoEntitlement.ForAccount(account));The frozen-bytes contract. After you pass a
byte[]toAccept(...), don't mutate or reuse it. Unlike thestringfields next to it, abyte[]can be changed — so this is a promise you make, not something the language enforces. The engine shares the reference downstream; the promise is what keeps that safe.
The bytes are held per player, on the authority/peer side, and deliberately kept off the wire:
// The ONLY way to read an entitlement. It is NOT a field on IPlayerInfo,
// so it is never serialized into RosterEntry and never leaks to other clients.
byte[] GetPlayerEntitlement(int playerId); // com.xpturn.klotho/Runtime/Core/ILockstepEngine.csWho stores what:
- SD server — the bytes from the redeem reply (or
nullif no validator ran). - P2P host — the bytes extracted from the joining peer's verified ticket.
- P2P guest — the bytes it re-derived itself from the propagated ticket. A guest keeps the derived entitlement, not the ticket — it re-verifies each time rather than hoarding the credential.
Keeping it off IPlayerInfo is the whole point: a large or private owned-set never rides the roster to other players.
There's no separate bookkeeping. The entitlement is attached to the player and shares the player's fate:
- Across a disconnect — kept, so a reconnecting player gets the same data back. Released only when the player is evicted.
- Across the GameStart roster rebuild — when the match starts, the roster is rebuilt from the player-id set, which doesn't itself carry entitlements. The engine deliberately retains them across that rebuild (by reference — safe because they're frozen). Skip this and a late-join after GameStart would carry an empty entitlement → a missing seed → desync.
- P2P host's own entitlement — the host derives it through the same signature-only path the guests use, not the full enforcing validator. If it used the enforcing path (which can bail out early on expiry/nonce), only the host's bytes would be empty and its tick-0 seed would disagree with everyone else's → desync.
- Reconnect — nothing special: the entitlement is preserved on the player just like
Account/DisplayName. On SD the seed is restored by full-state resync; on P2P the ticket is re-bundled in the reconnect accept so the peer re-derives it.
| SD (dedicated) | P2P (host) | |
|---|---|---|
| What travels | the tick-0 seed effect (baked into the Initial FullState) plus the verified bytes themselves (so clients can read them, e.g. for UI) | the original signed ticket (not the bytes) goes to every peer |
| How | seed effect via the Initial FullState; the raw bytes ride the identity channel per-player | rides the identity channel — roster sync, join/late-join notifications, late-join/reconnect accepts |
| Verification on arrival | none — it's server-trusted | each peer runs IPropagatedTicketVerifier.ReverifyPropagatedTicket — signature only (expiry/nonce/session were already checked at join) |
| When it's on | always (server authority) | only if an entitlement hook is configured — otherwise nothing changes (no regression) |
Why P2P works this way: peers don't trust the host to relay honest data, so trusted data must survive lobby signature + delivery to every peer + independent per-peer re-verification. Forgery can't get through. A genuine omission (a missing seed) shows up as a tick-0 desync — which safely aborts that match instead of letting peers silently diverge.
Everyone reads through the same call, then decodes. The decoder is lenient on purpose:
// null / empty / corrupt bytes → "all owned" → no gating.
// This is what makes the whole feature opt-in: no lobby wired ⇒ entitlement is null ⇒ nothing is gated.
var ent = DemoEntitlement.Decode(engine.GetPlayerEntitlement(playerId));Who gets what back from GetPlayerEntitlement:
| Caller | Returns | Note |
|---|---|---|
| SD server | the stored redeem bytes | the authoritative copy |
| SD client | the propagated bytes — read-only (for UI) | null until they arrive, or if no validator ran. The client must never re-seed simulation from these — the seed is authored server-side (below) |
| P2P peer | the bytes it re-derived | every peer verified and derived its own |
There are exactly three places entitlements have an effect. Here's each, with its real code.
Runs inside OnInitializeWorld (before SaveSnapshot(0)), pushing each player's owned-set into the deterministic simulation state as a LoadoutSeedComponent:
// Samples/Brawler/Assets/Brawler/Scripts/ECS/BrawlerSimSetup.cs
public static void SeedOneLoadout(ref Frame frame, IKlothoEngine engine, int pid)
{
if (HasLoadoutSeed(ref frame, pid)) return; // idempotent → rollback-safe
var ent = DemoEntitlement.Decode(engine.GetPlayerEntitlement(pid)); // null → all owned
var seed = frame.CreateEntity();
frame.Add(seed, new LoadoutSeedComponent {
PlayerId = pid,
OwnedSkillMask = ent.OwnedSkillMask,
OwnedConsumableMask = ent.OwnedConsumableMask,
});
}Two details that keep it deterministic and reusable:
- It iterates the authoritative
SessionParticipantComponentslots — not a localmaxPlayersguess — so every peer seeds the exact same set of entities. - The same
SeedOneLoadouthandles a late-joiner viaOnPlayerJoinedWorld. TheHasLoadoutSeedguard makes re-entry harmless.
On SD this runs on the server, and the result ships to clients inside the Initial FullState — OnInitializeWorld doesn't run on an SD client at all, so a client never re-seeds.
Cross-checks a client's character pick against the owned classes. Own it → keep it; don't → clamp to a server-decided default:
// Samples/Brawler/Assets/Brawler/Scripts/ECS/BrawlerPlayerConfigEntitlementGuard.cs
public PlayerConfigVerdict Check(int playerId, byte[] entitlement, PlayerConfigBase selection)
{
if (!(selection is BrawlerPlayerConfig cfg)) return PlayerConfigVerdict.Pass();
int classMask = DemoEntitlement.Decode(entitlement).OwnedClassMask;
int cls = cfg.SelectedCharacterClass;
if ((uint)cls < 32u && (classMask & (1 << cls)) != 0)
return PlayerConfigVerdict.Pass(); // owned → keep the pick
if (classMask == 0) return PlayerConfigVerdict.Pass(); // owns nothing → nothing to clamp to
return PlayerConfigVerdict.Clamp( // unowned → lowest owned bit,
new BrawlerPlayerConfig { SelectedCharacterClass = LowestSetBit(classMask) }); // deterministic
}"Lowest owned bit" is a deterministic first owned class — so SD's single server decision and P2P's per-peer decisions all land on the same answer.
Drops an unowned command before it reaches an authoritative tick. The sample only gates consumable use; everything else passes:
// Samples/Brawler/Assets/Brawler/Scripts/ECS/BrawlerReliableCommandEntitlementGate.cs
public ReliableCommandVerdict Check(int playerId, byte[] entitlement, ICommand command)
{
if (!(command is UseConsumableCommand use)) return ReliableCommandVerdict.Accept(); // e.g. spawn
int mask = DemoEntitlement.Decode(entitlement).OwnedConsumableMask;
int bit = use.ConsumableId - DemoEntitlement.ConsumableId; // id 100 → bit 0
return ((uint)bit < 32u && (mask & (1 << bit)) != 0)
? ReliableCommandVerdict.Accept()
: ReliableCommandVerdict.Drop(); // unowned → the use simply never happens
}The player just sees "nothing happened." On the client side, SendUseConsumableCommand issues on the reliable channel and resolves its handle once the effect shows up in the frame — if the server dropped it, that never comes.
Why P2P has no gate: it doesn't need one. The owned-set is already in the simulation state (from the seed), so an unowned action no-ops identically on every peer without anyone having to police commands.
When a player is evicted, its per-player state is dropped and the byte[] is garbage-collected — no explicit clear. Because the bytes were frozen, any shared reference (like the seed retained across the GameStart rebuild) stays valid right up until then.
- Never on the roster wire. The entitlement (and, in P2P, the ticket) live outside
IPlayerInfo, so they never end up inRosterEntry.GetPlayerEntitlementis the only door. - Frozen once published. The
byte[]you hand the core is immutable thereafter. That's what makes caches and the seed path safe to share it by reference. - P2P must be bit-identical everywhere. The seed, the clamp, and the in-match no-op all have to produce the same result on every peer. No RNG, no floats, no culture-sensitive or dictionary-order logic — any of those desyncs tick-0.
- Fully opt-in. No hook configured →
GetPlayerEntitlementreturnsnull→ guard/gate skipped, P2P propagation off,PlayerConfigbehaves exactly as before. - SD client bytes are read-only. A client holds the propagated bytes for display (UI), but the seed is authored server-side and delivered in the Initial FullState. Client code must treat
GetPlayerEntitlementas read-only and never re-seed simulation from it.
The core never parses the payload — three reasons:
- The entitlement is lobby-authored and may come from a non-Unity backend, so tying it to C# code generation would be wrong.
- In P2P the signature is verified over the original bytes (verify-over-wire), so the core must not re-serialize them.
- A byte-identical blob makes cross-peer determinism automatic.
The game picks the payload format. The samples use the fixed-width DemoEntitlementData bitmask because it's compact (12 bytes, fits the P2P pre-auth budget) and trivially deterministic. The codec lives entirely in the game assembly:
// Samples/IdentityP2pRef/Runtime/DemoEntitlement.cs — the byte[] ↔ struct boundary.
public static byte[] Encode(in DemoEntitlementData d) { /* generated Serialize */ }
public static DemoEntitlementData Decode(byte[] entitlement)
{
if (entitlement == null || entitlement.Length == 0) return Full; // "all owned"
try { /* generated Deserialize */ }
catch { return Full; } // lenient: a corrupt blob does not gate
}The core boundary stays byte[] no matter what you choose — everything in stages 1–6 is unchanged by the encoding.
Three projects, one blob:
① The lobby produces it — Samples/DevLobbyServer. A plain console process (not a game server). On a redeem request it calls DemoEntitlement.ForAccount(account) and packs the bytes into the reply:
// Samples/IdentitySdRef/Runtime/DevLobbyCore.cs
var result = RedeemResult.Accept(p.Account, p.DisplayName, DemoEntitlement.ForAccount(p.Account));② The dedicated server stores it and registers the consumers — Samples/Brawler/Server/Program.cs:
roomManagerConfig.PlayerConfigEntitlementGuard = new BrawlerPlayerConfigEntitlementGuard();
roomManagerConfig.ReliableCommandEntitlementGate = new BrawlerReliableCommandEntitlementGate();
if (lobbyEnabled) // --lobby host:port
roomManagerConfig.IdentityValidator = SdDevIdentity.CreateValidator(/* ..., redeemClient, ... */);The guard and gate are always registered but stay inert until a lobby fills in the bytes — with no --lobby, GetPlayerEntitlement is null and nothing gates.
③ The simulation reads it — Samples/Brawler/Assets/Brawler, the three consumers shown in §5.
One last connection: the in-editor P2P host path (BrawlerDevIdentity.Server.cs) signs those same ForAccount bytes into the ticket. One entitlement format, both modes.
Want to see it running? For a step-by-step play test — start the dev lobby, run the dedicated server with
--lobby, enable the lobby on the client, and watch a"guest"account get gated — see LobbyIntegrationGuide §9.8.