Voting
In-match voting for roguelike-style group decisions: path choices, item picks, run modifiers, map votes. Five methods, templates, spectator voting, async quorum, anti-tyranny mitigations.
Flow
- Game mode (or match server) starts a vote with options + window.
- Eligible players receive
match.vote_startvia WS. - Players cast votes during the window (may change up to
max_revotestimes). - On close, votes are tallied;
match.vote_resultis broadcast. - Game mode receives
vote_resolved/3.
Starting a vote
Two paths: automatic via callback, or manual via API.
Lua
-- Lua: automatic
function game.vote_requested(state)
if state.phase == "vote_pending" then
return {
template = "path_choice",
options = { "jungle", "volcano", "caves" },
window_ms = 15000,
method = "plurality",
}
end
return nil
endErlang
%% Erlang: manual
asobi_match_server:start_vote(MatchPid, #{
template => <<"path_choice">>,
options => [
#{id => <<"jungle">>, label => <<"Jungle Path">>},
#{id => <<"volcano">>, label => <<"Volcano Path">>},
#{id => <<"caves">>, label => <<"Ice Caves">>}
],
window_ms => 15000,
method => <<"plurality">>,
visibility => <<"live">>
}).Config keys
options [map()] required — [{id, label}, ...]
template binary() "default" — reference to vote_templates
window_ms pos_integer() 15000
method binary() "plurality" | "approval" | "weighted" | "ranked"
visibility binary() "live" | "hidden"
tie_breaker binary() "random" | "first"
veto_enabled boolean() false
weights map() per-voter weights for weighted method
max_revotes pos_integer() 3
quorum float() fraction of eligible needed (async)
default_votes map() fallback votes at resolution time
delegation map() voter -> delegate
window_type binary() "fixed" | "ready_up" | "hybrid" | "adaptive"
supermajority float() 0..1 threshold
require_supermajority boolean() no_consensus if not reachedMethods
- Plurality — one option each; most votes wins. Ties use
tie_breaker. - Approval — submit a list of options you'd accept. Highest total approval wins. Good for “avoid the worst”.
- Weighted — votes multiplied by per-voter weight. Defaults to 1 if not listed.
- Ranked — submit a preference list. Iteratively eliminate lowest; transfer to next preference until majority.
- Spectator-weighted — pass
spectators+spectator_weightfor a separate pool merged at a ratio.
Window types
- fixed (default): runs for
window_msthen closes. - ready_up: closes when all eligible have voted (or timeout).
- hybrid: ready-up but enforce
min_window_msbefore early close. - adaptive: shrinks remaining time to 3s when supermajority is reached. Resets if lost.
Async voting
For non-real-time games — not all players online at once.
#{
quorum => 0.5, %% at least 50% must vote
default_votes => #{<<"p2">> => <<"opt_b">>}, %% fallback at resolution
delegation => #{<<"p3">> => <<"p1">>} %% follows p1's choice
}If quorum isn't met, the result has winner => undefined and status => "no_quorum".
Templates
Reusable configs in sys.config; per-call overrides win:
{asobi, [
{vote_templates, #{
<<"boon_pick">> => #{method => <<"plurality">>, window_ms => 15000},
<<"path_choice">> => #{method => <<"approval">>, visibility => <<"hidden">>},
<<"weighted_pick">>=> #{method => <<"weighted">>, window_ms => 15000}
}}
]}Anti-tyranny
Frustration accumulator
Losers get a cumulative weight bonus on future votes: 1 + lost * frustration_bonus. Resets on a win. Configured at match start:
asobi_match_sup:start_match(#{
game_module => my_game,
frustration_bonus => 0.5 %% default 0.5; 0 disables
}).Supermajority requirement
Force 2/3 or 3/4 consensus; otherwise no_consensus and the game mode decides (re-vote, default, etc.).
Veto tokens
Give each player a limited number of vetoes per match:
#{veto_tokens_per_player => 2}Clients veto via {type: "vote.veto"}. Match server enforces token accounting.
Handling results
Lua
function game.vote_resolved(template, result, state)
if template == "path_choice" then
state.current_path = result.winner
elseif template == "item_pick" then
state = add_item(result.winner, state)
end
return state
endErlang
vote_resolved(<<"path_choice">>, #{winner := W}, State) ->
{ok, State#{current_path => W}};
vote_resolved(<<"item_pick">>, #{winner := I}, State) ->
{ok, add_item(I, State)}.WS + REST
See the WebSocket voting messages for vote.cast, vote.veto, match.vote_start, match.vote_tally, match.vote_result. REST: GET /api/v1/matches/:id/votes, GET /api/v1/votes/:id.
Late-arriving votes: a 500ms grace period after the window compensates for network latency — casts that arrive just after close are still counted.