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’ssetnextmapor a rock-the-vote decides what is next.- More ways to open the ballot:
vote_lead_fraction, and a score limit the host feeds throughnote_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), withcountdown_startedand a per-secondcountdown_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.
Admin vote commands used to be root-only
DotVoteCommands.admin_permission was "changelevel" — a command’s name, not a
flag anybody holds — so every admin vote command silently needed root. It is
dot-server’s changemap flag now. A server that worked round it by handing out
root can take that back.
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 useDotVoteListSourceand hand it aCallable.- DotVoteRules
Every policy decision, as one
DotConfig, layereddefaults < 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_endwaits for the round to end. A time or score limit reaching its lead is held (is_waiting_for_round_end) until the host’s nextnote_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 needsnote_round_endand a score onenote_score; a game that calls neither and is handedtrigger: round_endvalidates 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;adoptandformatted_aton 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_staletells 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_fnmaps 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_loadedsignal. 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.
A vote that could not open was the last one that would ever be offered
The clock fires once and latches — it has to, or an expired limit opens a ballot
on every tick. So a vote_due arriving during a cooldown, or with too few
players, was dropped with one warning. The most ordinary sequence there is
reaches it: a vote fails for want of a quorum, somebody rocks the vote thirty
seconds later, and the server never changes anything again.
Two documented policies can be one behaviour
extend_needs_majority erases “extend” from a tie when it is on. With it off,
the ordinary tie-break ran — and every pseudo-option sorts last in ballot order,
so BALLOT_ORDER handed the tie to the new thing as well. Two settings, one
outcome. Nothing short of testing both positions of a setting can see it,
and the usual mechanical detector (a name that occurs exactly once) does not
fire, because the name occurs twice and one branch is a no-op.
