Roles (Hidden/Asymmetric)
Server-assigned opaque role ids with per-viewer visibility filtering (a hidden assignment never touches the wire), per-holder private payloads, role-scoped sends, and an end-of-game reveal. IRoleVisibilityPolicy SPI; no client verbs (unforgeable).
Seams you implement 1
Crossplay calls these; your game supplies them. Each ships an inert or permissive default, so register yours before services.AddCrossplayRoles(); and it wins.
IRoleVisibilityPolicy game SPI
The game's "who may know whose role" rule — the non-spatial visibility axis this piece exists for. Consulted server-side while building each viewer's RoleRoster, so a hidden assignment never touches the wire. The viewer's OWN assignment is always visible; this policy is asked only about OTHER members' assignments.
// A GM/observer role (99) that sees everything, plus the impostor rule:
public bool CanSee(long scope, ushort viewerRole, long viewer, ushort subjectRole, long subject)
=> viewerRole == 99 || viewerRole == subjectRole; -
bool CanSee(long scopeId, ushort viewerRoleId, long viewerAccountId, ushort subjectRoleId, long subjectAccountId)May the viewer know the subject's assignment?
Services you call 1
Crossplay implements these. Resolve them from DI and call them from your own systems.
IRoleService
Server-assigned hidden/asymmetric roles per game-defined scope: opaque role ids, per-viewer visibility filtering (an assignment a viewer may not know NEVER touches the wire), an optional per-holder private payload, role-scoped sends, and an end-of-game reveal. This is the "only impostors see the impostor list" piece — deliberately Core-only: the spatial "ghosts see ghosts in the 3D world" axis stays out of v1 (it belongs to a World interest-filter seam); a game builds that itself over RoleOf + RoleChanged.
-
IReadOnlyList<long> MembersOf(long scopeId, ushort roleId)The current member account ids of a role (empty for an unknown role). The returned list is live piece state — read it synchronously, never cache or mutate it.
-
void ReleaseScope(long scopeId)Drops the whole scope (revealing first when RevealOnRelease is on); every member receives an empty roster so role UIs reset.
-
void RevealAll(long scopeId)Broadcasts every assignment, unfiltered, to all assigned members — the end-of-game "the impostors were…" reveal. Assignments stay in place (release the scope to drop them).
-
event Action<long, long, ushort> RoleChangedRaised on every assignment change: (scopeId, accountId, roleId — 0 = cleared). The server-side glue seam — bind role chat channels, spatial visibility, ability kits… all without piece-to-piece references.
-
ushort RoleOf(long scopeId, long accountId)The account's role in the scope, or 0 when unassigned.
-
void SendToRole<T>(long scopeId, ushort roleId, T message, bool reliable = true)Sends the game's own message to every online member of one role — the "only my faction hears this" channel (serialize-once fan-out).
-
RoleResult TryAssign(long scopeId, ISession session, ushort roleId, byte[] privateData = null)Assigns (or re-assigns) the session's account to roleId in the scope and re-sends every affected viewer's filtered roster.
-
RoleResult TryClear(long scopeId, long accountId)Removes the account's assignment in the scope (works for offline members too); the cleared viewer receives an empty roster so its UI resets.
Configuration 1
Every tunable lives in an options object — there are no magic numbers to hunt for.
RoleOptions
Configurable role rules, passed to AddCrossplayRoles. Defaults live here, never inline in the service (the no-hardcoded-values rule).
-
int MaxDataBytes { get; set; }Maximum size of a per-assignment opaque private payload, in bytes.
-
int MaxRolesPerScope { get; set; }Maximum distinct role ids per scope; assigns past the cap are refused.
-
float ResyncIntervalSeconds { get; set; }Seconds between periodic per-viewer roster re-sends (the reconnect replay: Core has no re-authentication hook, so the cadence is what brings a returning session back up to date — it is at most one interval away from knowing its role again). 0 disables the resync (rosters then push only on change).
-
bool RevealOnRelease { get; set; }When true, releasing a scope broadcasts the full reveal first (as if the game had called RevealAll). Off by default — a released scope just vanishes.
-
bool SameRoleVisible { get; set; }When true (the default) members of the same role see each other's assignment — the impostor rule. The viewer's own assignment is always visible regardless. A registered IRoleVisibilityPolicy replaces this rule entirely.
Wire messages 2
The protocol this piece speaks. Ids are allocated per piece so they can never collide.
RoleRoster Server → ONE viewer: the role assignments this viewer is allowed to know, filtered per viewer by the server's IRoleVisibilityPolicy before it ever touches the wire — a client can't be data-mined for hidden roles, because the hidden entries were never sent. The viewer's own assignment is always included. Role ids are the game's vocabulary; the framework never knows what an "impostor" is.
RolesRevealed Server → every assigned member of the scope: all assignments, unfiltered — the end-of-game reveal ("the impostors were…"). Sent only when the game calls IRoleService.RevealAll (or on scope release when RoleOptions.RevealOnRelease is set); until then hidden assignments never touch the wire.
Unity services you inject 1
Crossplay binds these in the client context. Inject and call them from your own MonoBehaviours and presenters.
IRoleClient
Data-layer roles service (no UI): the server's per-viewer filtered role pushes as events + cached snapshots. Read-only by design — assignments are server-made and visibility is server-filtered, so there is no request API at all; what this client holds is exactly (and only) what this player is allowed to know. Self-registers under the Crossplay client context.
-
RoleRoster CurrentThe most recently pushed roster across all scopes, or null before any push (a client is normally in one scope at a time; use RosterOf for multi-scope games).
-
RoleRoster RosterOf(long scopeId)The last roster pushed for a specific scope, or null (also null after a clear — an empty roster with YourRoleId == 0 drops the cache).
-
event Action<RoleRoster> RosterChangedRaised on every roster push (assignment changes + the server's resync cadence).
-
event Action<RolesRevealed> RevealedRaised on the end-of-game reveal (all assignments, unfiltered).
UI views you can replace 1
Each ships a working uGUI panel you can drag into a scene. Substitute your own view to keep the logic and change the look — the presenter never knows.
ICrossplayRolesView
Narrow contract the presenter drives. UI-only: render calls in (no intents — the Roles piece is read-only by design).