Skip to content

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.

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, …).

PlantUML Diagram

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.Eliminate

This is the only return value the two reference modules UHCClassic and UHCClassicTeams ever produce.

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

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()
ParameterEffect
delayTicksHow long the limbo lasts, in Bukkit ticks (50 ms each). Must be positive.
limboLocationWhere the player auto-respawns when they re-enter the world. Defaults to the death location.
onLimboReadyCallback 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
}
}
}
}

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.

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.

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
}

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

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.",
)

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.

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

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.

PlantUML Diagram
Lifecycle momentEventCarries
Game start preconditions metUHCGameStartingEvent(no payload, fires before player spread)
Game fully launchedUHCGameStartedEvent(no payload, fires after onGameStart callback)
Player dies (active player)UHCPlayerDeathEventPlayer
Killer resolved on deathUHCPlayerKilledByPlayerEventkiller: Player, victim: Player
Module returns EliminateUHCPlayerEliminationEventPlayer
Module returns DeferredUHCPlayerEntersLimboEventPlayer, Location, delayTicks: Long
Module returns KeepAlive(no event; the GDK does nothing)
Player revived from limboUHCPlayerResurrectionEventPlayer, Location
Game outcome resolvedUHCGameFinishesEventVictoryResult (fires before listener teardown)
Game fully torn downUHCGameFinishedEventVictoryResult (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.