Skip to content

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.

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.

PlantUML Diagram

The contract is split into three concentric rings:

  1. Identity (display strings + symbol item): used by the host UI and by the GDK’s internal logging.
  2. Game logic (death decision, victory evaluation, lifecycle hooks): the rules of your mode.
  3. 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.

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)
}
MethodSemantics
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.

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.

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.

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 }
}
}

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.

Called every second by the GDK’s central victory tick. Always returns a non-null VictoryResult:

  • VictoryResult.NoWinnerYet to 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
}
}

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()
}

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.