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.
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> ClosedRaised 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.
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).
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.
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).
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.
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> OpenedRaised when a vote opens for this client (it is among the eligible voters).
-
event Action<VoteTally> TallyChangedRaised on every live tally push (anonymous counts; only if the server enables it).
-
event Action<VoteClosed> ClosedRaised 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.