Updated

dot-vote

One configurable engine for voting on what plays next — games, maps or modes. Rock the vote, nominations, per-choice time limits, four counting methods, five tie-breaks, quorums and cooldowns.

The shape every server in this genre has had since 2005 — rtv, nominate, mapchooser, timeleft, extend — rebuilt so that a community that wants it to work differently changes a number rather than forking it.

Requires dot-core. dot-server and dot-map are optional and are never imported.

var votes := DotVoteDirector.new()
votes.rules = DotVoteRules.new()
votes.source = DotVoteGameSource.of(server.games)   # or a map catalogue, or your own
votes.player_count_fn = func() -> int: return players.size()
add_child(votes)

votes.begin(&"lobby")        # what is running now
votes.advance(delta)         # once per tick

That is the whole integration. Everything else is configuration.

The one seam

The thing being voted for is an id, and a DotVoteSource says what an id means. That is the only reason one engine drives dot-server’s games and dot-map’s catalogue without naming either.

DotVoteGameSource.of(server.games)          # dot-server's games
DotVoteMapSource.of(catalogue, session)     # dot-map's maps
DotVoteListSource.of(my_choices)            # anything else, with a Callable

Both integrations are duck-typed, so this addon installs in a project that has never heard of either. Per-choice settings live in the thing’s own metadata, so a game’s time limit is written beside the game:

# content/arena/game.yml
metadata:
  vote:
    time_limit_sec: 2400
    weight: 2.0

A source that cannot change anything is legitimate: the director runs the whole vote and emits change_due for the host to act on, which is exactly what a client mirroring a server’s ballot does.

What it does

Rock the vote A fraction of the players, a minimum player count, a delay at the start of every map, idempotent per player, and votes withdrawn when their owner disconnects.
Nominations Per-player caps, a total cap, seconding, admin bypass, and reserved ballot places so three organised players cannot decide every map.
Time limits In seconds, in rounds, or both — per choice, so a forty-minute surf map and a ten-minute bhop map are not forced under one number. duration_sec defaults to forty-five minutes, and 0 is around the clock: with rock-the-vote on, only a rock-the-vote ever changes anything.
The ballot Up to N options, filled five ways, with “extend” and “none of these” as options a server turns on or off. Opens a configurable lead time before the map ends, so the change happens on time.
Counting Plurality, approval, instant runoff, or a majority runoff. Quorums, weighted ballots, and five tie-breaks — four of which a player watching can predict.
Cooldowns In plays or in wall-clock minutes, per choice, clamped against the pool so a long cooldown on a short rotation cannot exclude everything.
Applying Immediately, at the end of the round, or when the clock runs out — with a delay so players can read the result.
Commands nominate, rtv, votefor, timeleft, nextmap, revote, extend, endvote, on a console and in chat, plus the admin set setnextmap, nominate_addmap, forcertv and votereload. Every name configurable; DotVoteCommands.resolve_fn turns what a player typed into a choice id, for a game whose ids carry a prefix.

Parity with the community map-choosers

Servers in this genre have run the same plugins for fifteen years: an end-of-map chooser, rock-the-vote, nominations, and a sounds layer over the first. Everything those expose is here as a DotVoteRules setting rather than as a second plugin, and the addon’s docs/parity.md lists every old cvar, command, query and event row by row against what covers it — so a server owner who knows the old cvar can find the new setting, and a gap is a row that says so. The old names are only a lookup key; nothing reads them.

What that brought in, in outline:

  • end_vote, the switch. Off, a limit still ends the map and the rotation; an admin’s setnextmap or a rock-the-vote decides what is next.
  • More ways to open the ballot: vote_lead_fraction, and a score limit the host feeds through note_score (score_limit, vote_lead_score, extend_score, trigger: score_limit) — frags, team wins, points, whatever the game counts.
  • A counted-down warning before a ballot and before a runoff (vote_warning_sec, runoff_warning_sec), with countdown_started and a per-second countdown_tick.
  • Ballot shape: on_no_votes (keep, random or rotation), early_vote_keep (“don’t change” on a rock-the-vote ballot, resuming the clock rather than restarting it), include_abstain, pseudo_options_first, shuffle_ballot, and unofficial choices marked as such.
  • Rock-the-vote and nomination rules: rtv_interval_sec, rtv_apply, rtv_after_decided, nominations_forget_leavers, and a reason on every nomination removal.
  • Sounds as cue ids for the start, the end, the warnings and each second, emitted through a signal — so there is still no dependency on dot-audio.

Some rows are deliberately different, and they are the ones to read before migrating. max_extends: 0 means unlimited, where the old 0 meant “no extends” — include_extend: false is none. Seconding a nomination is on by default, because fill: most_nominated needs it. Menus, hint boxes and translations are not here at all: dot-vote draws nothing, and the signals carry what a HUD needs.

Every config layer reaches a running ballot. The rules layer as a game’s code defaults < the running game’s metadata < a file < DOT_VOTE_* < --vote-* (DotVoteRules.layer_over_defaults), refused whole when the result does not validate. The self-test sets settings through each layer and asserts the ballot — not merely the parsed value — because a setting that parses and never reaches the vote is the failure a server owner would actually see.

When the time is up

A map’s clock, a round limit and how long one round lasts are three different settings, and the last is the game’s own rule — dot-match’s — which this addon never sees. When the map’s clock runs out, time_up decides what happens to the round in progress: change_now (the default) or finish_round, which holds the change until the host’s next note_round_end, for at most finish_round_max_sec. It only waits on a host that sets DotVoteDirector.round_based — one that reports every round end — because a deathmatch has no round to finish and would otherwise sit out the whole cap; and only a time or score limit waits, since a rock-the-vote or an admin’s command is somebody asking for now.

Votebans

DotVoteBans is a yes or no about one player, and not a ballot: it has its own clock, its own electorate (everybody but the target) and a consequence the game carries out through ban_fn (on a dot-server, DotBanManager). Its settings are DotVoteBanRules, one sv_voteban* cvar each, read before every decision so a console change applies to the next vote: sv_voteban (the server’s switch, which also stops a game’s own), sv_voteban_start_roles and sv_voteban_immune_roles (comma lists of roles; * is everybody), sv_voteban_minutes (0 is permanent), sv_voteban_threshold, sv_voteban_cooldown, sv_voteban_seconds and sv_voteban_min_voters.

A game starts one with start(DotVoteBans.GAME, target, reason); the reason reaches the ban. The game as starter skips the start role and the starter’s cooldown and nothing else, so an automatic trigger cannot ban an immune role. It passes the moment enough say yes and fails once that many yes can no longer happen or the clock runs out: silence is a no. Commands: voteban <player> [reason], voteban_yes, voteban_no.

A ballot a client can draw

DotVoteBallotView.state_of(director, people_fn) is the ballot as a screen draws it: the options in the order a typed number indexes them (option i is !votefor i+1 on every screen), the counts, who chose what so each voter’s avatar can sit on their choice, and how to choose (ballot_input: numbers, pointer or both — a rule, because what the ballot says to press has to be true on every screen at once). ballot_show_voters: false sends counts without names, for a secret ballot. DotVoteBallotFeed sends that state through a send_fn only when it has changed, and is polled rather than signal-driven, because a voter who leaves is withdrawn without a signal. There is still no wire format and no UI here: the dictionary rides whatever the host has, and dot-ui’s DotBallotPanel draws it.

Where a game plugs in

DotVoteSource subclass

source_name, choices, current_id, supports_apply, apply. Or use DotVoteListSource and hand it a Callable.

DotVoteRules

Every policy decision, as one DotConfig, layered defaults < file < DOT_VOTE_* < --vote-*, and enum settings written by name (method: instant_runoff). The self-test fails if any one of them is read by nothing.

trigger: round_end waits for the round to end. A time or score limit reaching its lead is held (is_waiting_for_round_end) until the host’s next note_round_end; a round limit and a rock-the-vote do not wait.

drop_unfed() takes out what a host cannot drive. A round trigger needs note_round_end and a score one note_score; a game that calls neither and is handed trigger: round_end validates and then waits for ever. drop_unfed(feeds_rounds, feeds_score) falls back to the time limit, zeroes the limits nothing reports, moves end-of-round applies to immediate, and returns what it took so the host can log it.

DotVoteClockView

The time left as somebody who is not running the clock sees it — what a client’s HUD draws. state_of(director) on the server; adopt and formatted_at on the client. Games used to draw time left from their own map session, which on a client nothing extends, so an “extend” won and the HUD kept counting down to the old end. is_stale tells a server when an extend, a new map or a stopped clock has made clients’ count wrong, and a running clock is re-sent every thirty seconds even when it agrees, because a server counts in simulated time and a client in wall time. There is no wire format: the game encodes the dictionary.

DotVoteDirector callables

player_count_fn, is_admin_fn, is_spectator_fn, voters_fn, weight_fn, announce_fn. Who counts as a player, an admin or a spectator; what one player’s vote is worth; and how the result reaches the room.

DotVoteCommands.prefix / names

What the commands are called. voter_fn maps a speaker to a voter id.

DotVoteDirector.begin_on_apply

Whether the director announces the change it just made, or leaves that to the host’s own game_loaded signal. Both firing halves every cooldown — two entries in the play history for one play, so a “played in the last 5” cooldown is quietly a cooldown of 2 or 3.

Signals

vote_due, vote_opened, vote_cast, tally_updated, vote_closed, change_due, changed, nominated, rocked, extended, warning, expired, round_ended.