Docs / Voting

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

  1. Game mode (or match server) starts a vote with options + window.
  2. Eligible players receive match.vote_start via WS.
  3. Players cast votes during the window (may change up to max_revotes times).
  4. On close, votes are tallied; match.vote_result is broadcast.
  5. 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
end

Erlang

%% 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 reached

Methods

  • 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_weight for a separate pool merged at a ratio.

Window types

  • fixed (default): runs for window_ms then closes.
  • ready_up: closes when all eligible have voted (or timeout).
  • hybrid: ready-up but enforce min_window_ms before 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
end

Erlang

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.