Meta

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).

Server
services.AddCrossplayRoles();
Unity package
com.crossplay.roles
Depends on
core

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> RoleChanged

    Raised 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.

5001 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.

5002 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 Current

    The 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> RosterChanged

    Raised on every roster push (assignment changes + the server's resync cadence).

  • event Action<RolesRevealed> Revealed

    Raised 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).