Elimination & victory
The two abstract members at the heart of every game module are onPlayerDeath and evaluateVictory. Together they answer “who is still in?” and “is the game over?”. This page describes both pipelines in detail, including the deferred-elimination flow that the GDK provides for resurrection mechanics, second-life roles, and timed payback windows.
The death pipeline
Section titled “The death pipeline”When an active player dies, the GDK intercepts the vanilla PlayerDeathEvent, captures the killer (either Bukkit’s native killer or the most recent attacker tracked over the last few seconds), fires the GDK-level UHCPlayerDeathEvent and UHCPlayerKilledByPlayerEvent, and asks your module what to do. When the module returns Deferred, the GDK also fires UHCPlayerEntersLimboEvent so observers can react to the limbo window opening (UI cues, prompts to other roles, …).
Returning Eliminate
Section titled “Returning Eliminate”The simplest case: the player is out. The GDK puts them in spectator on the next tick, broadcasts the elimination message (including the killer when applicable), restores their profile if a previous scenario erased it, and teleports them back into the game world to spectate.
override fun onPlayerDeath(player: Player, context: GameContext) = EliminationDecision.EliminateThis is the only return value the two reference modules UHCClassic and UHCClassicTeams ever produce.
Returning KeepAlive
Section titled “Returning KeepAlive”The player has died (vanilla mechanics already triggered: drops, death message, respawn) but is allowed to stay in the game. Combined with a custom revival flow (a respawn point you set in your UHCPlayerDeathEvent listener, a checkpoint scenario, etc.), it lets you build “X-chances” mechanics without leaning on the deferred branch.
private val secondLifeUsed: MutableSet<UUID> = HashSet()
override fun onPlayerDeath(player: Player, context: GameContext): EliminationDecision { val firstDeath = secondLifeUsed.add(player.uniqueId) return if (firstDeath) { EliminationDecision.KeepAlive } else { EliminationDecision.Eliminate }}Returning Deferred(...)
Section titled “Returning Deferred(...)”The deferred branch is the GDK’s general-purpose answer to “delay the decision”. The framework holds the player in a managed “limbo” state for the requested duration, and asks your module again at the end of the window:
class Deferred( val delayTicks: Long, val limboLocation: Location? = null, val onLimboReady: ((Player) -> Unit)? = null,) : EliminationDecision()| Parameter | Effect |
|---|---|
delayTicks | How long the limbo lasts, in Bukkit ticks (50 ms each). Must be positive. |
limboLocation | Where the player auto-respawns when they re-enter the world. Defaults to the death location. |
onLimboReady | Callback fired on the first tick after respawn, once limbo effects have been applied. Use it to inject custom items. |
While the player is in limbo, the GDK:
- Auto-respawns them at
limboLocation. - Freezes them (no movement, no input pickup).
- Makes them invisible to other players.
- Makes them invulnerable to damage.
- Adds them to the eliminated set provisionally, so
context.activePlayers()does not include them during the window.
When the delay expires (or your module calls gdkGameManager.forceResolveDeferredElimination(player) earlier), the GDK calls resolveDeferredElimination(player, context) and expects an Eliminate or KeepAlive answer. Returning a second Deferred is invalid and the GDK will treat it as Eliminate after logging a warning.
private enum class Branch { AUTO_REVIVE, MEDIC_PROMPT }private val pendingBranch: MutableMap<UUID, Branch> = HashMap()private val medicSavedThisTurn: MutableSet<UUID> = HashSet()
private companion object { val AUTO_REVIVE_DELAY = GDKConstants.TaskDelay.after(Duration.ofSeconds(1)) val MEDIC_PROMPT_DELAY = GDKConstants.TaskDelay.after(Duration.ofSeconds(30))}
override fun onPlayerDeath(player: Player, context: GameContext): EliminationDecision { if (player.firstDeath()) { pendingBranch[player.uniqueId] = Branch.AUTO_REVIVE return EliminationDecision.Deferred(AUTO_REVIVE_DELAY) } if (medicAlive() && !medicConsumed) { sendMedicPrompt(player) pendingBranch[player.uniqueId] = Branch.MEDIC_PROMPT return EliminationDecision.Deferred(MEDIC_PROMPT_DELAY) } return EliminationDecision.Eliminate}
override fun resolveDeferredElimination( player: Player, context: GameContext,): EliminationDecision { val branch = pendingBranch.remove(player.uniqueId) ?: return EliminationDecision.Eliminate return when (branch) { Branch.AUTO_REVIVE -> EliminationDecision.KeepAlive Branch.MEDIC_PROMPT -> { if (medicSavedThisTurn.remove(player.uniqueId)) { EliminationDecision.KeepAlive } else { EliminationDecision.Eliminate } } }}The onLimboReady hook
Section titled “The onLimboReady hook”Some modes need a custom inventory while the player is in limbo (a “last shot” weapon, a temporary disguise, etc.). The constructor’s third parameter is exactly that escape hatch:
private companion object { val LAST_SHOT_WINDOW = GDKConstants.TaskDelay.after(Duration.ofSeconds(10))}
private fun openLastShotWindow(player: Player) { player.inventory.clear() player.inventory.addItem(buildLastShotItem())}
override fun onPlayerDeath(player: Player, context: GameContext): EliminationDecision { return EliminationDecision.Deferred( delayTicks = LAST_SHOT_WINDOW, limboLocation = player.location, onLimboReady = ::openLastShotWindow, )}The callback runs on the main thread, on the first tick after the auto-respawn, after the GDK has applied the freeze / invisibility / invulnerability effects. Anything you do to the player from there will survive until the window closes.
The victory pipeline
Section titled “The victory pipeline”evaluateVictory(context) is called once per second and returns a non-null VictoryResult. The GDK feeds anything other than VictoryResult.NoWinnerYet to finishGame, which broadcasts the result, fires UHCGameFinishesEvent then UHCGameFinishedEvent, tears down listeners and the kill leaderboard, and switches the lobby state.
VictoryResult.SingleWinner
Section titled “VictoryResult.SingleWinner”The simplest case: one player wins. The GDK broadcasts:
[UHC] » <playerName> a gagné la partie !override fun evaluateVictory(context: GameContext): VictoryResult { val active = context.activePlayers() return if (active.size == 1) VictoryResult.SingleWinner(active.first()) else VictoryResult.NoWinnerYet}VictoryResult.MultipleWinners
Section titled “VictoryResult.MultipleWinners”Several players share the win. The default broadcast joins their display names with a comma. Use this for team victories, coalition-based modes, or any “the X coalition wins” scenario.
override fun evaluateVictory(context: GameContext): VictoryResult { val survivors = context.activePlayers() val onlyTeam = survivors.map { teamOf(it) }.distinct().singleOrNull() ?: return VictoryResult.NoWinnerYet val winners = survivors.filter { teamOf(it) == onlyTeam }.toSet() return if (winners.size == 1) { VictoryResult.SingleWinner(winners.first()) } else { VictoryResult.MultipleWinners(winners) }}VictoryResult.CustomAnnouncement
Section titled “VictoryResult.CustomAnnouncement”Use this when the default broadcast does not fit your story: a coalition with a special title, an “everyone lost” outcome, or a bespoke colour scheme. The GDK still tracks the winner set for the post-game scoreboard and stats, but uses your message verbatim for the broadcast.
return VictoryResult.CustomAnnouncement( players = winners, message = "${ChatColor.GOLD}The Crimson Pact has fulfilled the prophecy.",)Returning VictoryResult.NoWinnerYet
Section titled “Returning VictoryResult.NoWinnerYet”NoWinnerYet means “do not finish the game yet”. evaluateVictory is invoked once a second, so returning the sentinel simply postpones the decision. There is no special “draw” return value: if your mode has draws, model them with CustomAnnouncement and an empty winners set, or keep returning NoWinnerYet until your mode reaches a determinate state. The same NoWinnerYet value is what the GDK passes to finishGame on a host force-stop, so the post-game broadcast still surfaces the “no winner” wording in that case.
canStart: a sealed answer
Section titled “canStart: a sealed answer”The companion to evaluateVictory is canStart(onlinePlayerCount), which the GDK calls before launching the game. It returns a sealed UHCStartability:
sealed class UHCStartability { object CanStart : UHCStartability() data class CannotStart(val reason: String) : UHCStartability()}CanStart greenlights the launch; CannotStart halts it and the GDK relays the reason back to the host with the [UHC] » <reason> red prefix. Use the sealed result so a future linter / IDE can warn you on missing branches and so the success state stops being a sentinel null.
override fun canStart(onlinePlayerCount: Int): UHCStartability { val minimum = teamSize + 1 return if (onlinePlayerCount < minimum) { UHCStartability.CannotStart("At least $minimum players are required for two teams.") } else { UHCStartability.CanStart }}Combining death and victory
Section titled “Combining death and victory”A common pattern is to keep an inverted index (UUID -> team) updated by your event listeners and consult it from both onPlayerDeath (to decide whether to eliminate) and evaluateVictory (to count surviving teams). Pure functions on the index make both callbacks trivial to unit-test without spinning up a Bukkit server.
Reference: which event fires when?
Section titled “Reference: which event fires when?”| Lifecycle moment | Event | Carries |
|---|---|---|
| Game start preconditions met | UHCGameStartingEvent | (no payload, fires before player spread) |
| Game fully launched | UHCGameStartedEvent | (no payload, fires after onGameStart callback) |
| Player dies (active player) | UHCPlayerDeathEvent | Player |
| Killer resolved on death | UHCPlayerKilledByPlayerEvent | killer: Player, victim: Player |
Module returns Eliminate | UHCPlayerEliminationEvent | Player |
Module returns Deferred | UHCPlayerEntersLimboEvent | Player, Location, delayTicks: Long |
Module returns KeepAlive | (no event; the GDK does nothing) | — |
| Player revived from limbo | UHCPlayerResurrectionEvent | Player, Location |
| Game outcome resolved | UHCGameFinishesEvent | VictoryResult (fires before listener teardown) |
| Game fully torn down | UHCGameFinishedEvent | VictoryResult (fires after teardown) |
UHCPlayerKilledByPlayerEvent always fires before the generic UHCPlayerDeathEvent, so role handlers that need to read killer / victim team membership before another listener mutates it can subscribe to the more specific event. See UHC events for the full reference.