-
Notifications
You must be signed in to change notification settings - Fork 33
Meetings and Voting
Mira API replaces the vanilla voting system with a custom one that supports multi-voting, vote manipulation at runtime, and event hooks at every stage of a meeting. This page describes how the system works and how to interact with it.
When a meeting starts, Mira attaches a PlayerVoteData component to every player. This component tracks how many votes that player has remaining and which players they have voted for. The vanilla single-vote system is fully replaced — all vote casting, processing, and result display go through Mira.
The overall flow of a meeting looks like this:
- Meeting starts —
PlayerVoteDatais reset,StartMeetingEventis fired. - Players cast votes — each vote goes through
BeforeVoteEvent,HandleVoteEvent, andAfterVoteEvent. - Voting ends —
CheckForEndVotingEventfires, thenProcessVotesEventon the host to determine who is ejected. - Results are displayed —
PopulateResultsEventfires, thenVotingCompleteEvent. - Meeting ends —
EndMeetingEventfires.
Each PlayerControl has a PlayerVoteData component attached to it. You can access it using the GetVoteData() extension method:
var voteData = somePlayer.GetVoteData();PlayerVoteData exposes the following members:
| Member | Type | Description |
|---|---|---|
Owner |
PlayerControl |
The player this vote data belongs to. |
Votes |
List<CustomVote> |
The list of votes this player has cast. |
VotesRemaining |
int |
How many votes this player still has available. Cannot go below zero. |
VotedFor(byte playerId) |
bool |
Returns true if this player has voted for the given player ID. |
VoteForPlayer(byte playerId) |
— | Adds a vote for the given player ID. |
RemovePlayerVote(byte playerId) |
— | Removes one vote cast toward the given player ID. |
RemovePlayerVote(CustomVote vote) |
— | Removes the specific vote. |
SetRemainingVotes(int amount) |
— | Sets how many votes this player has remaining. |
IncreaseRemainingVotes(int amount) |
— | Adds to the remaining vote count. |
DecreaseRemainingVotes(int amount) |
— | Subtracts from the remaining vote count. |
CustomVote is a simple record struct that represents a single cast vote:
public record struct CustomVote(byte Voter, byte Suspect);Voter is the player ID of the person who voted, and Suspect is the player ID they voted for. A skip vote uses the special ID 253.
VotingUtils provides static helpers for working with votes:
| Method | Description |
|---|---|
CalculateVotes() |
Collects all votes from all alive players and returns them as a List<CustomVote>. |
CalculateNumVotes(IEnumerable<CustomVote> votes) |
Returns a Dictionary<byte, float> mapping each suspect ID to the number of votes they received. |
GetExiled(List<CustomVote> votes, out bool isTie) |
Returns the NetworkedPlayerInfo of the player with the most votes, or null if there is a tie or no votes. |
RpcCastVote(PlayerControl source, byte voterId, byte suspectId) |
Networked RPC to cast a vote. Should be called via PlayerControl.LocalPlayer. |
RpcRemoveVote(PlayerControl source, byte voterId, byte votedFor) |
Networked RPC to remove a vote. Host only. |
The following events fire during a meeting, in order. See the Events page for full property listings.
| Event | When it fires |
|---|---|
StartMeetingEvent |
After the meeting starts and Mira resets all vote data. |
BeforeVoteEvent |
Before the local player's vote is confirmed. Cancelable. |
AfterVoteEvent |
After the local player successfully votes or skips. |
MeetingSelectEvent |
When the local player selects a vote area. AllowSelect can be set to block the selection. |
HandleVoteEvent |
When Mira processes an incoming vote from any player. Cancelable — cancel to apply custom vote behavior instead. |
CheckForEndVotingEvent |
When the game checks whether all players have voted. Set ForceEndVoting to end voting early. |
DummyVoteEvent |
Before a dummy player selects their vote. Cancelable. |
ProcessVotesEvent |
On the host, after votes are tallied. Set ExiledPlayer to change who is ejected. |
PopulateResultsEvent |
When vote icons are being displayed. Cancelable (not recommended). |
VotingCompleteEvent |
When the voting phase ends. |
EjectionEvent |
When the ejection sequence begins. |
EndMeetingEvent |
When the meeting ends. |
To give a role extra votes at the start of a meeting, use StartMeetingEvent. This event fires after Mira resets all vote data, so any modifications made here will persist through the meeting.
[RegisterEvent]
public static void OnMeetingStart(StartMeetingEvent _)
{
foreach (var player in PlayerControl.AllPlayerControls.ToArray())
{
if (player.Data.Role is MayorRole)
{
player.GetVoteData().IncreaseRemainingVotes(1);
}
}
}HandleVoteEvent fires when Mira is about to record a vote from any player. You can cancel it and apply your own logic instead. The following example implements a mayor-style role whose single vote counts as five:
[RegisterEvent(15)]
public static void OnHandleVote(HandleVoteEvent @event)
{
if (@event.VoteData.Owner.Data.Role is not ProsecutorRole) return;
// Cancel default vote handling.
@event.Cancel();
// Use all remaining votes and cast five for the target.
@event.VoteData.SetRemainingVotes(0);
for (var i = 0; i < 5; i++)
{
@event.VoteData.VoteForPlayer(@event.TargetId);
}
// Clear all other players' votes.
foreach (var player in PlayerControl.AllPlayerControls.ToArray())
{
if (player == @event.VoteData.Owner) continue;
player.GetVoteData().Votes.Clear();
player.GetVoteData().VotesRemaining = 0;
}
}By default, the player with the most votes is ejected. You can change this in ProcessVotesEvent, which runs on the host after votes are tallied. The event exposes the full vote list and allows you to replace the ExiledPlayer.
The following example turns the meeting into a weighted lottery: each vote a player received is one entry in the pool, so a player with three votes is three times more likely to be ejected than a player with one vote.
[RegisterEvent]
public static void OnProcessVotes(ProcessVotesEvent @event)
{
// Build a weighted pool — one entry per vote received.
var pool = @event.Votes
.Where(v => v.Suspect != 253) // exclude skips
.Select(v => GameData.Instance.GetPlayerById(v.Suspect))
.Where(p => p != null && !p.IsDead && !p.Disconnected)
.ToList();
if (pool.Count == 0) return;
// Pick a random entry from the pool.
var rng = new System.Random();
@event.ExiledPlayer = pool[rng.Next(pool.Count)];
}Because ExiledPlayer is set, Mira will use it directly when calling RpcVotingComplete, bypassing the normal majority-vote result.
You can hold voting open even after all players have cast their votes by canceling CheckForEndVotingEvent. This is useful for effects like locked voting or timed reveals:
[RegisterEvent]
public static void OnCheckEndVoting(CheckForEndVotingEvent @event)
{
if (SomeConditionIsActive())
{
@event.Cancel(); // Prevent voting from ending this tick.
}
}You can also set ForceEndVoting inside the event to end it regardless of who has voted:
@event.ForceEndVoting = true;