Anatomy of a game module
A UHCGameModule is the central object that defines how a single UHC game session behaves. Each registered module is one selectable entry in the host’s mode-selection panel; only one module is “active” at any given time, and the active module is the one consulted during a running game. This page walks through the lifecycle of a module from server boot to game finish, and lists every callback the GDK invokes on it.
Lifecycle overview
Section titled “Lifecycle overview”The diagram below shows the full life of a module instance. It is constructed once when your plugin enables and lives until the server stops; the per-game state lives between onGameStart and onGameFinish.
The UHCGameModule surface
Section titled “The UHCGameModule surface”The contract is split into three concentric rings:
- Identity (display strings + symbol item): used by the host UI and by the GDK’s internal logging.
- Game logic (death decision, victory evaluation, lifecycle hooks): the rules of your mode.
- Optional features (host configuration, roles, teams, listeners, scoreboard extras): opt in only when you need them.
abstract class UHCGameModule { // Identity abstract fun name(): String abstract fun description(): String abstract fun symbolItem(): ItemStack open fun author(): String? = null
// Game logic abstract fun onPlayerDeath(player: Player, context: GameContext): EliminationDecision abstract fun evaluateVictory(context: GameContext): VictoryResult open fun onGameStart(context: GameContext) {} open fun onGameFinish(context: GameContext) {} open fun resolveDeferredElimination(player: Player, context: GameContext): EliminationDecision = EliminationDecision.Eliminate
// Pre-start validation open fun canStart(onlinePlayerCount: Int): UHCStartability = UHCStartability.CanStart open fun beforeStart(context: GameContext, initiator: Player): PreStartDecision = PreStartDecision.Continue
// Host configuration UX open fun isConfigurable(): Boolean = false open fun hasConfigPanel(): Boolean = false open fun onHostConfiguresModule(direction: HostConfigurationDirection) {} open fun openConfigPanel(player: Player, onClose: (Player) -> Unit) {} open fun configurableStatusLines(): List<String> = emptyList()
// Roles, teams, listeners open fun listGameRoles(): Set<UHCGameRole> = emptySet() open fun listGameTeams(): Set<UHCGameTeam> = emptySet() open fun listGameListeners(): Set<UHCGameListener> = emptySet() open fun forceRoleAttribution(): Boolean = false
// Scoreboard open fun scoreboardExtraLines(player: Player): List<String> = emptyList()}Each ring has its own dedicated guide; this page focuses on the core lifecycle.
GameContext: the read-only view
Section titled “GameContext: the read-only view”The GDK never hands modules a reference to its internal GDKGameManager. Instead, every callback receives a GameContext that exposes only what a module is allowed to observe and the one mutator it is allowed to call.
interface GameContext { fun activePlayers(): Set<Player> fun eliminatedPlayers(): Set<Player> fun allPlayers(): Set<Player> fun setMaximumGroupSize(size: Int)}| Method | Semantics |
|---|---|
activePlayers() | Players still competing right now: alive, not in spectator mode, not eliminated. Shrinks as the game progresses. |
eliminatedPlayers() | Players permanently out of the current game. |
allPlayers() | Snapshot of the initial roster. Stable for the whole game, regardless of who has died. |
setMaximumGroupSize(n) | Announces the largest valid party / team size for chat colouring and /group validation. Typically called from onGameStart. |
The lifecycle hooks
Section titled “The lifecycle hooks”canStart(onlinePlayerCount)
Section titled “canStart(onlinePlayerCount)”Called by the GDK when a host runs /h start, before any world preparation. Returns a sealed UHCStartability: CanStart greenlights the launch, CannotStart(reason) halts it and the GDK relays the reason back to the host with the standard [UHC] » error prefix.
override fun canStart(onlinePlayerCount: Int): UHCStartability { if (onlinePlayerCount < 4) { return UHCStartability.CannotStart("Il faut au moins 4 joueurs pour lancer ce mode.") } if (chosenTeamSize < 2) { return UHCStartability.CannotStart("La taille d'équipe doit être d'au moins 2.") } return UHCStartability.CanStart}canStart runs synchronously on the main server thread. Keep it cheap: it is an input-validation gate, not a place to allocate teams or generate worlds. Allocation belongs in onGameStart.
beforeStart(context, initiator)
Section titled “beforeStart(context, initiator)”Hook fired once canStart has returned CanStart, before the GDK spreads players and runs the regular start sequence. The default implementation returns PreStartDecision.Continue and the GDK proceeds normally. Modules that need a pre-game phase (a draft, a vote, a cinematic) override this to return PreStartDecision.Defer, take over the start flow themselves, and re-enter gdkGameManager.startGame(initiator) once their work completes; the second invocation should report Continue so the GDK resumes the regular pipeline.
override fun beforeStart(context: GameContext, initiator: Player): PreStartDecision { if (draftCompleted) return PreStartDecision.Continue startDraft(context) // captain bidding pre-phase return PreStartDecision.Defer // GDK aborts this start; we re-enter later}The bundled Slave Market example uses this hook to run its captain auction before the regular start sequence.
onGameStart(context)
Section titled “onGameStart(context)”Fires once, after the world is ready, players have been spread, and starter kits have been distributed. Its role is to set up your module’s per-game state: assign roles, build teams, register module-internal scheduled tasks, switch scoreboards, and so on.
private val playersByTeam: MutableMap<UUID, MyTeam> = HashMap()
override fun onGameStart(context: GameContext) { context.setMaximumGroupSize(teamSize) playersByTeam.clear()
val shuffled = context.activePlayers().shuffled() shuffled.chunked(teamSize).forEachIndexed { index, members -> val team = MyTeam("team_$index", colorFor(index)) members.forEach { playersByTeam[it.uniqueId] = team } }}onPlayerDeath(player, context)
Section titled “onPlayerDeath(player, context)”Fired exactly once per active player death. The return value tells the GDK what to do next:
EliminationDecision.Eliminate: eliminate the player on the next tick (default behaviour).EliminationDecision.KeepAlive: keep them in the game (e.g. a second-life mechanic).EliminationDecision.Deferred(...): postpone the decision; the GDK puts the player in a frozen, invisible “limbo” state until either the configured delay expires or a module-driven save resolves it.
The dedicated Elimination & victory guide covers the deferred branch in detail.
evaluateVictory(context)
Section titled “evaluateVictory(context)”Called every second by the GDK’s central victory tick. Always returns a non-null VictoryResult:
VictoryResult.NoWinnerYetto keep the game running.VictoryResult.SingleWinner(player)for a single player.VictoryResult.MultipleWinners(setOfPlayers)for a team or coalition.VictoryResult.CustomAnnouncement(players, message)to override the default victory broadcast with your own line.
override fun evaluateVictory(context: GameContext): VictoryResult { val survivors = context.activePlayers() return when { survivors.isEmpty() -> VictoryResult.NoWinnerYet survivors.size == 1 -> VictoryResult.SingleWinner(survivors.first()) allOnSameTeam(survivors) -> VictoryResult.MultipleWinners(survivors) else -> VictoryResult.NoWinnerYet }}onGameFinish(context)
Section titled “onGameFinish(context)”Fires once when the GDK declares the end of the game (either after evaluateVictory returns a winner-bearing result, or after a host force-stop with VictoryResult.NoWinnerYet). Use it to clear module-internal state.
The framework already takes care of:
- Firing
UHCGameFinishesEvent(victoryResult)(so any role / listener can react before being unregistered, with the outcome in hand). - Unregistering the listeners returned by
listGameListeners(). - Restoring the lobby world, putting all players in spectator, and broadcasting the kill leaderboard.
- Firing
UHCGameFinishedEvent(victoryResult)once the post-cleanup state is settled.
You typically only need to clear local maps, cancel module-side scheduled tasks, and let module sub-components (roles, listeners) handle their own cleanup via their UHCGameFinishesEvent listeners.
override fun onGameFinish(context: GameContext) { moduleScheduledTask?.cancel() moduleScheduledTask = null playersByTeam.clear() pendingDecisions.clear()}Module identity and equality
Section titled “Module identity and equality”The base class implements equals / hashCode based on name(). Two modules with the same display name are considered the same module; the registry rejects duplicates. If you ship multiple variants of the same mode (e.g. “Bounty Hunter” / “Bounty Hunter (hardcore)”), give them distinct name() strings.
Where to go next
Section titled “Where to go next”- Elimination & victory: the death pipeline, deferred limbo, and victory shapes.
- Roles & teams: how to declare role / team contributions, register listeners, and use impersonation.
- Host configuration: inline configuration vs. dedicated config panels, scoreboard extras, and pre-start validation.
- Scheduling & time: game-clock-aware tasks, day / night cycles, and episode boundaries.
- Building a mode end-to-end: a worked walkthrough that wires every concept together.