Meta

Voting

Server-opened proposals over opaque choice ids: one ballot per eligible voter, live anonymous tallies, deadline/everyone-voted close, outcome decided by an IVoteRules SPI (plurality default). The game acts on Closed; the piece never executes outcomes.

Server
services.AddCrossplayVoting();
Unity package
com.crossplay.voting
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.AddCrossplayVoting(); and it wins.

IVoteRules game SPI

The game's tally rules: called once when a vote closes (deadline, everyone voted, or an explicit close) with the full context — all ballots, choices, and eligible voters — and answers the outcome. Quorum, supermajority, weighted votes, "skip beats everything" are all expressible here because the context carries everything.

// Two-thirds supermajority or no winner:
public VoteOutcome Decide(VoteContext ctx)
{
    (long choice, int count) = TopChoice(ctx);
    return count * 3 >= ctx.Eligible.Count * 2 ? VoteOutcome.Winner(choice) : VoteOutcome.None();
}
  • VoteOutcome Decide(VoteContext ctx)

    Decides the closing ctx's outcome.

Services you call 1

Crossplay implements these. Resolve them from DI and call them from your own systems.

IVotingService

Server-authoritative votes: proposals, one ballot per eligible voter, live anonymous tallies, a deadline (or everyone-voted) close, and an outcome decided by the game's IVoteRules. Everything on the wire is opaque ids — an eject vote's choices are player account ids, a map vote's are map ids, a surrender vote's are 1/0; the framework never knows. The piece never executes an outcome: the game reacts to Closed (eject via Rounds' TryEliminate, load the map, end the match — all game glue, zero piece references).

  • event Action<VoteContext, VoteOutcome> Closed

    Raised when a vote closes (deadline, everyone voted, or an explicit close), with the rules-decided outcome. THE server-side glue seam — the game acts on the result here.

  • VoteContext ContextOf(long voteId)

    The live context of an open vote, or null. Read it synchronously; never cache it.

  • VotingResult TryCast(ISession voter, long voteId, long choiceId)

    Casts (or, when AllowChangeVote is on, changes) the caller's ballot — the wire path. A failed wire cast also pushes the coded notification.

  • VotingResult TryClose(long voteId)

    Closes the vote NOW (a game event pre-empted it), deciding the outcome from the ballots cast so far.

  • VotingResult TryOpen(long scopeId, ushort voteKind, IReadOnlyList<long> choices, IReadOnlyList<ISession> eligible, float seconds, byte[] data = null)

    Opens a vote for scopeId, pushing VoteOpened to every eligible voter. Choices are de-duplicated; unauthenticated sessions are skipped.

Configuration 1

Every tunable lives in an options object — there are no magic numbers to hunt for.

VotingOptions

Configurable voting rules, passed to AddCrossplayVoting. Defaults live here, never inline in handlers/services (the no-hardcoded-values rule).

  • bool AllowChangeVote { get; set; }

    When true, an eligible voter may re-cast to change their ballot while the vote is open. Off by default — the first ballot locks (the Among Us rule).

  • bool BroadcastLiveTally { get; set; }

    When true (the default) every landed ballot pushes an anonymous VoteTally (counts only, never who). Turn off for secret-until-closed votes.

  • bool EndWhenAllVoted { get; set; }

    When true (the default) a vote closes as soon as every eligible voter has cast — no waiting out the clock on a unanimous room.

  • int MaxChoices { get; set; }

    Maximum choices per vote; opens past this cap are refused.

  • int MaxConcurrentVotesPerScope { get; set; }

    Maximum simultaneously open votes per scope; opens past this cap are refused.

  • int MaxDataBytes { get; set; }

    Maximum size of the opaque vote Data payload, in bytes.

  • bool RevealBallotsOnClose { get; set; }

    When true, VoteClosed includes who voted for what (the Among-Us-style reveal). Off by default — ballots stay anonymous.

  • double StatePushIntervalSeconds { get; set; }

    When > 0, each open vote re-pushes its VoteOpened (with a refreshed SecondsRemaining) to its online eligible voters on this cadence — so a voter who dropped and reconnected mid-vote self-heals its client cache within one interval, exactly as Rounds' periodic RoundState re-push heals reconnectors. The client handler already applies VoteOpened idempotently, so no client change is needed. 0 (the default) preserves today's send-once behavior.

Wire messages 5

The protocol this piece speaks. Ids are allocated per piece so they can never collide.

4901 VoteOpened

Server → eligible voters: a vote opened. VoteKind, Choices, and Data are the game's vocabulary — a choice id is a player account id in an eject vote, a map id in a map vote, 1/0 in a surrender vote; the framework never knows. Clients render it however they like (buttons, a wheel, a chat prompt — a text-only client works unchanged).

4902 VoteCastRequest

Client → server: cast a ballot in an open vote. One ballot per eligible voter; whether a voter may change an already-cast ballot is the server's VotingOptions.AllowChangeVote.

4903 VoteCastResponse

Server → client: result of a VoteCastRequest. On failure ReasonCode carries a VoteNotificationCodes value (the same code is also pushed through the Core notification service).

4904 VoteTally

Server → eligible voters: the live per-choice counts after a ballot lands (sent only while the server's VotingOptions.BroadcastLiveTally is on). Deliberately anonymous — counts only, never who; identities appear at most once, in Ballots, and only when the game enables the reveal.

4905 VoteClosed

Server → eligible voters: the vote closed (deadline hit, everyone voted, or the game closed it). Carries the outcome as decided by the server's IVoteRules — the piece never executes consequences; the game reacts (eject the winner, load the map, end the match).

Unity services you inject 1

Crossplay binds these in the client context. Inject and call them from your own MonoBehaviours and presenters.

IVotingClient

Data-layer voting service (no UI): open/tally/closed push events with cached active votes, plus the single request — the async ballot cast. Votes only ever open from the server (proposals are unforgeable). Self-registers under the Crossplay client context.

  • event Action<VoteOpened> Opened

    Raised when a vote opens for this client (it is among the eligible voters).

  • event Action<VoteTally> TallyChanged

    Raised on every live tally push (anonymous counts; only if the server enables it).

  • event Action<VoteClosed> Closed

    Raised when a vote closes (its cached entry is dropped first).

  • VoteOpened ActiveOf(long voteId)

    The cached open vote, or null (unknown id, or already closed). This is an in-memory read of the last VoteOpened/VoteClosed push — NOT a server re-query — so across a reconnect gap it can be stale in both directions (a vote opened while offline is absent; one closed while offline lingers) until the next relevant push. Drive UI from the Opened/Closed events, not by polling this. When the server sets VotingOptions.StatePushIntervalSeconds, an open vote re-pushes on that cadence, so this cache self-heals after a resume within one interval.

  • UniTask<VoteCastResponse> CastAsync(long voteId, long choiceId, CancellationToken ct = default)

    Casts (or, when the server allows it, changes) this client's ballot.

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.

ICrossplayVotingView

Narrow contract the presenter drives. UI-only: render calls in, user intents out.