elections

Elections (Standing Offices)

Standing, weighted elections for offices - one per scope (an area, a region, a server). Voters accrue vote points every period up to a cap and spend any number on any eligible candidates; every tally decays each period, so support has to be kept up. The leader is the top candidate over a minimum (a different bar for the sitting leader is allowed; ties keep the incumbent), recomputed each period, with a LeaderChanged event. Who may vote and stand, allowances, caps and minimums are the game's IElectionPolicy, per account and scope. Works by account id with nobody online; standings, my points and casting over the wire; durable.

Category Social Seams 3 Services 1 Options 1 Wire ids 9 Unity types 1
Server
services.AddCrossplayElections();
Unity package
com.crossplay.elections
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.AddCrossplayElections(); and it wins.

IElectionPolicy game SPI

Who may vote and stand in a scope, and the numbers of an election - per account and per scope, so a game can give a noble more vote points than a peasant or ask a higher bar of a challenger. The default (ConfiguredElectionPolicy) lets everyone vote and stand and answers every number from ElectionOptions; implement this (delegating to a ConfiguredElectionPolicy for what you do not change) and register it before AddCrossplayElections. Account-keyed: it judges offline players too.

  • long AllowancePerPeriod(long accountId, string scope)

    Points this voter gains each period in the scope.

  • double? DecayPercent(string scope)

    Percent of every tally in the scope that decays each period (null = DecayPercent). Defaulted so adding it breaks no existing policy (rule 25).

  • long MaxPerVoterPerCandidate(string scope)

    The most one voter may have on one candidate in the scope (0 = no cap).

  • long MaxTallyPerCandidate(string scope)

    The most one candidate's tally may reach in the scope (0 = no cap).

  • bool MayStand(long accountId, string scope)

    May this account stand (receive votes) in the scope?

  • bool MayVote(long accountId, string scope)

    May this account vote in the scope?

  • long MinimumToLead(string scope, long candidateAccountId, bool incumbent)

    The tally a candidate needs to lead (incumbent = they lead now).

  • double? PeriodHours(string scope)

    The scope's period length in hours (null = PeriodHours). Changing it for a scope that already has an election re-buckets time once. Defaulted so adding it breaks no existing policy (rule 25).

  • long PointCap(long accountId, string scope)

    The most points this voter can hold in the scope.

Providers you can swap 2

Infrastructure seams. Crossplay ships a working implementation of each — replacing one changes where data lives or how it moves, never a game rule.

IElectionClock provider

The clock periods are counted by (a seam so tests control time).

  • long UtcNowMs { get; }

    Now, unix ms UTC.

IElectionStore provider

Where elections are kept. The default is in memory; persistence composes a document-backed one.

  • ElectionScopeRecord LoadScope(string scope)

    One scope, or null.

  • ElectionVoterRecord LoadVoter(long accountId, string scope)

    One voter's state in a scope, or null.

  • void SaveScope(ElectionScopeRecord scope)

    Saves a scope.

  • void SaveVoter(ElectionVoterRecord voter)

    Saves a voter's state.

  • IReadOnlyList<string> Scopes()

    Every scope with an election.

Services you call 1

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

IElectionService

Standing elections, one per scope (an area, a region): voters accrue vote points every period up to a cap and spend any number on any eligible candidates; every tally decays each period; the leader is the top candidate over the minimum (ties keep the incumbent), recomputed at each period. Works by account id - nobody needs to be online. Who may vote and stand, and every number, is the game's IElectionPolicy.

  • long AdjustTally(string scope, long candidateAccountId, long delta, string reason = "")

    Server-side: adds (or, negative, removes) support for a candidate directly - an item used, an agent arriving - spending no voter's points and bound to no voter. The tally is clamped to [0, the per-candidate cap when there is one]; the candidate must be able to stand in the scope. The leader is recomputed at once when RecomputeLeaderOnCast is on (otherwise at the next period), and decay applies to it like any tally. Returns the new tally, or -1 when refused (bad scope, no candidate, may not stand). reason is logged. Defaulted so adding it breaks no existing implementation (rule 25).

  • ushort Cast(long voterAccountId, string scope, long candidateAccountId, long points, out long remaining)

    Spends a voter's points on a candidate. Returns 0 or an ElectionNotificationCodes value.

  • IReadOnlyDictionary<long, long> Given(long voterAccountId, string scope)

    What the voter has on each candidate in the scope now (decayed like the tallies). Defaulted (none).

  • void Grant(long voterAccountId, string scope, long points)

    Gives a voter extra points (up to their cap) - a reward, an event.

  • long Leader(string scope)

    The scope's leader (0 = none).

  • event Action<string, long, long> LeaderChanged

    A scope's leader changed: (scope, old leader, new leader) - 0 = none.

  • ushort Move(long voterAccountId, string scope, long candidateAccountId)

    Moves everything the voter has given in the scope onto one candidate in a single step - a standing vote changed at any time. The same checks as Cast (may vote, may stand, caps); nothing given yet answers NotEnoughPoints (cast first). Defaulted so adding it breaks no existing implementation (rule 25).

  • long NextPeriodFor(string scope)

    When the scope's next period starts, unix ms UTC (scopes may have their own period). Defaulted.

  • long NextPeriodUtcMs { get; }

    When the next period starts, unix ms UTC.

  • ElectionVoterStatus Points(long voterAccountId, string scope)

    A voter's points, cap and allowance in a scope.

  • long Recompute(string scope)

    Recomputes a scope's leader now (it is otherwise recomputed each period). Returns the leader.

  • IReadOnlyList<ElectionStanding> Standings(string scope)

    The scope's candidates, highest tally first (ties: lower account id first).

Configuration 1

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

ElectionOptions

Elections tuning, bound from Crossplay:Elections. The per-account and per-scope numbers here are what the default ConfiguredElectionPolicy answers; a game policy can answer them per account or scope.

  • bool AllowSelfVote { get; set; }

    A voter may vote for themselves.

  • long AllowancePerPeriod { get; set; }

    Points a voter gains each period (default policy).

  • double DecayPercent { get; set; }

    Percent of every tally that decays each period (0 = none, 100 = all).

  • int MaxCatchUpPeriods { get; set; }

    The most missed periods processed at once after downtime (decay is applied per period).

  • int MaxListed { get; set; }

    The most candidates a standings reply lists.

  • long MaxPerVoterPerCandidate { get; set; }

    The most one voter may have on one candidate at a time, decaying with the tally (0 = no cap; default policy).

  • int MaxScopeLength { get; set; }

    Longest scope key, in characters.

  • long MaxTallyPerCandidate { get; set; }

    The most one candidate's tally may reach (0 = no cap; default policy).

  • long MinimumToLead { get; set; }

    The tally a challenger needs to become leader (default policy).

  • long MinimumToStayLeader { get; set; }

    The tally the sitting leader needs to stay leader (default policy).

  • double PeriodHours { get; set; }

    Length of one period, in hours: each period voters accrue, tallies decay and leaders are recomputed.

  • long PointCap { get; set; }

    The most points a voter can hold (default policy).

  • bool RecomputeLeaderOnCast { get; set; }

    Recompute the leader right after every cast, not only at the period tick.

Wire messages 9

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

7801 ElectionStandingsRequest

Client -> server: a scope's ranked candidates and its leader.

7802 ElectionStandings

Server -> client: a scope's ranked candidates (highest tally first) and its leader.

7803 ElectionPointsRequest

Client -> server: my vote points in a scope.

7804 ElectionPoints

Server -> client: my vote points in a scope.

7805 ElectionCastRequest

Client -> server: spend vote points on a candidate (call again for another candidate).

7806 ElectionCastResult

Server -> client: the answer to a cast.

7807 ElectionMoveRequest

Client -> server: move everything I have given in the scope onto one candidate (a standing vote, changed).

7808 ElectionGivenRequest

Client -> server: what I have on each candidate in a scope (to show my current vote).

7809 ElectionGiven

Server -> client: what I have on each candidate (Tally = my points on them; Rank unused), most first.

Unity services you inject 1

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

IElectionClient

Client SDK for Elections - standing elections per scope. Each call is one awaitable request (Core's request/reply helper - it never throws; a timeout completes unsuccessfully). What a scope and an office mean, and how standings are shown, are the game's. Pure SDK, no UI.

  • UniTask<ElectionStandings> StandingsAsync(string scope, CancellationToken ct = default)

    A scope's ranked candidates, its leader and when the next period starts.

  • UniTask<ElectionPoints> PointsAsync(string scope, CancellationToken ct = default)

    My vote points, cap and allowance in a scope.

  • UniTask<ElectionCastResult> CastAsync(string scope, long candidateAccountId, long points, CancellationToken ct = default)

    Spends my vote points on a candidate (call again to support another).

  • UniTask<ElectionCastResult> MoveAsync(string scope, long candidateAccountId, CancellationToken ct = default)

    Moves everything I have given in the scope onto one candidate (my standing vote, changed). Defaulted so an existing implementation of this interface still compiles (rule 25).

  • UniTask<ElectionGiven> GivenAsync(string scope, CancellationToken ct = default)

    What I have on each candidate in the scope - to tick the candidate I back. Defaulted (rule 25).