Skip to content

Building a mode end-to-end

This page walks through the construction of a fictional mode, “Bounty Hunter UHC”, that exercises every concept the previous guides introduced separately. The mode is small enough to fit in a single page yet large enough to involve roles, teams, a deferred elimination flow, host configuration, the day / night cycle, and game-clock-aware scheduling.

Players are split into two factions: a small team of Bounty Hunters wearing a permanent Speed I buff, and the rest of the players as Targets. Each Hunter is secretly assigned one Target as their contract. When a Hunter eliminates their assigned Target, they receive a 6-second buff and are reassigned a new Target chosen from the remaining survivors. The Hunters win when every Target has been eliminated. The Targets win when every Hunter has been eliminated. The host configures the number of Hunters from the in-game dashboard.

The mode is inspired by traditional manhunt and bounty mechanics, but every line below is illustrative; do not paste it into a real plugin without replacing the placeholder logic with your own playtested rules.

my-bounty-hunter/
├── plugin.yml
├── build.gradle.kts
└── src/main/kotlin/fr/example/bountyhunter/
├── Main.kt
├── BountyHunterModule.kt
├── BountyHunterComposition.kt
├── teams/
│ ├── HuntersTeam.kt
│ └── TargetsTeam.kt
└── roles/
├── BountyHunterRole.kt
└── TargetRole.kt

A more elaborate mode would add views/ (config panels), commands/ (slash commands) and victory/ (a dedicated resolver), but the layout above is enough to ship the brief.

Main.kt
package fr.example.bountyhunter
import fr.noradrenalin.gdk.GDKMain
import org.bukkit.plugin.java.JavaPlugin
class Main : JavaPlugin() {
override fun onEnable() {
super.onEnable()
val gdk = GDKMain.getInstance().orElseThrow {
NoSuchElementException("UHCGDK plugin missing on this server.")
}
val module = BountyHunterModule(gdk)
gdk.gdkGamesRegistry.registerModule(module)
}
}

The module is registered but not declared active: hosts pick it from the dashboard like any other module.

teams/HuntersTeam.kt
package fr.example.bountyhunter.teams
import fr.noradrenalin.gdk.module.role.UHCGameTeam
import fr.noradrenalin.gdk.utils.GDKNamespacedKey
import org.bukkit.ChatColor
object HuntersTeam : UHCGameTeam {
override fun id() = GDKNamespacedKey("bh", "hunters")
override fun name() = "Hunters"
override fun chatColor() = ChatColor.GOLD
override fun isFriendlyFireEnabled() = false
override fun canAlliesSeeInvisiblePlayers() = true
override fun minMembers() = 1
}
object TargetsTeam : UHCGameTeam {
override fun id() = GDKNamespacedKey("bh", "targets")
override fun name() = "Targets"
override fun chatColor() = ChatColor.AQUA
override fun isFriendlyFireEnabled() = true
override fun minMembers() = 2
}

Both teams are object declarations because there is exactly one of each per game. HuntersTeam.minMembers() = 1 enforces “at least one Hunter at game start”; TargetsTeam.minMembers() = 2 makes sure the bounty mechanic has enough Targets to be interesting. The team id() is a GDKNamespacedKey (the GDK’s 1.8-compatible port of the modern Spigot NamespacedKey); it doubles as a stable equality key in registry maps.

The Hunter role drives the contract assignment. The Target role is intentionally minimal: a Target is just a tagged civilian, and the Hunter’s listener is where the kill / reassign logic lives.

roles/TargetRole.kt
package fr.example.bountyhunter.roles
import fr.noradrenalin.gdk.module.role.UHCGameRole
import fr.noradrenalin.gdk.module.role.UHCVictoryConditionType
import fr.noradrenalin.gdk.utils.GDKItems
import fr.noradrenalin.gdk.utils.GDKNamespacedKey
import fr.example.bountyhunter.teams.TargetsTeam
import org.bukkit.Material
object TargetRole : UHCGameRole {
override fun id() = GDKNamespacedKey("bh", "target")
override fun name() = "Target"
override fun description() = "Survive. Outlast every Hunter."
override fun defaultTeam() = TargetsTeam
override fun symbolItem() = GDKItems.Builder(Material.LEATHER_CHESTPLATE, 1, name()).build()
override fun victoryCondition() = UHCVictoryConditionType.LastTeamAlive
override fun victoryHint() = "Win when every Hunter has been eliminated."
}
roles/BountyHunterRole.kt
package fr.example.bountyhunter.roles
import fr.noradrenalin.gdk.GDKMain
import fr.noradrenalin.gdk.events.UHCGameFinishesEvent
import fr.noradrenalin.gdk.events.UHCPlayerKilledByPlayerEvent
import fr.noradrenalin.gdk.module.role.UHCGameRole
import fr.noradrenalin.gdk.module.role.UHCVictoryConditionType
import fr.noradrenalin.gdk.roles.GDKGameRolesManager
import fr.noradrenalin.gdk.utils.GDKItems
import fr.noradrenalin.gdk.utils.GDKNamespacedKey
import fr.noradrenalin.gdk.utils.TemporaryEffect
import fr.example.bountyhunter.teams.HuntersTeam
import org.bukkit.Bukkit
import org.bukkit.ChatColor
import org.bukkit.Material
import org.bukkit.entity.Player
import org.bukkit.event.EventHandler
import org.bukkit.event.EventPriority
import org.bukkit.potion.PotionEffect
import org.bukkit.potion.PotionEffectType
import java.time.Duration
import java.util.UUID
class BountyHunterRole(
private val rolesManager: GDKGameRolesManager,
private val pickFreshTarget: (hunter: UUID) -> Player?,
) : UHCGameRole {
override fun id() = GDKNamespacedKey("bh", "hunter")
override fun name() = "Bounty Hunter"
override fun description() = "Hunts assigned targets for a reward."
override fun defaultTeam() = HuntersTeam
override fun maxInstances() = 4
override fun symbolItem() = GDKItems.Builder(Material.BOW, 1, name()).build()
override fun victoryCondition() = UHCVictoryConditionType.LastTeamAlive
override fun victoryHint() = "Win when every Target has been eliminated."
override fun permanentPotionEffects(): Set<PotionEffect> = setOf(
PotionEffect(PotionEffectType.SPEED, Int.MAX_VALUE, 0, true, false),
)
private val contracts: MutableMap<UUID, UUID> = HashMap()
fun assignContract(hunter: UUID, target: UUID) {
contracts[hunter] = target
}
@EventHandler(priority = EventPriority.HIGH)
fun onKilledByHunter(event: UHCPlayerKilledByPlayerEvent) {
val killer = event.killer
if (rolesManager.getCurrentRole(killer.uniqueId) !== this) return
val expectedTarget = contracts[killer.uniqueId] ?: return
if (expectedTarget != event.victim.uniqueId) return
// Reward and rotate the contract.
TemporaryEffect.apply(killer, PotionEffectType.SPEED, 1, Duration.ofSeconds(6))
TemporaryEffect.apply(killer, PotionEffectType.ABSORPTION, 0, Duration.ofSeconds(6))
val newTarget = pickFreshTarget(killer.uniqueId)
if (newTarget != null) {
contracts[killer.uniqueId] = newTarget.uniqueId
killer.sendMessage("${GDKMain.GDK_PREFIX_SUCCESS} ${ChatColor.GOLD}Bounty collected. Your new target is ${newTarget.name}.")
} else {
contracts.remove(killer.uniqueId)
killer.sendMessage("${GDKMain.GDK_PREFIX_SUCCESS} ${ChatColor.GOLD}Bounty collected. No more targets remain.")
}
}
@EventHandler
fun onGameFinishes(@Suppress("UNUSED_PARAMETER") event: UHCGameFinishesEvent) {
contracts.clear()
}
}

A few patterns worth highlighting:

  • The role does not reach into Bukkit.getPluginManager() to register itself. Bukkit invokes the @EventHandlers because the framework registers the role’s listener when its first carrier is assigned.
  • getCurrentRole(uuid) !== this is the first line of every handler. Without it, an arbitrary kill from any player would trigger the contract logic. (getCurrentRole returns the role driving the player’s behaviour right now — including any active impersonation; use getInitialRole only for identity checks like “did this carrier already burn a one-shot ability?”.)
  • The “pick a fresh target” decision is delegated upward via the pickFreshTarget lambda, so the role does not need a direct reference to the module or the world.
  • contracts.clear() from the UHCGameFinishesEvent listener: per-game state is purged before the role is unregistered. The Finishes event fires before listener teardown so the role’s own handler still observes it; pair it with UHCGameFinishedEvent if you need to react after the post-game scoreboard is up.

A small data class to hold “how many Hunters does the host want?”:

BountyHunterComposition.kt
package fr.example.bountyhunter
class BountyHunterComposition {
var huntersCount: Int = DEFAULT_HUNTERS_COUNT
private set
fun increment() { if (huntersCount < MAX_HUNTERS) huntersCount++ }
fun decrement() { if (huntersCount > MIN_HUNTERS) huntersCount-- }
private companion object {
const val DEFAULT_HUNTERS_COUNT = 1
const val MIN_HUNTERS = 1
const val MAX_HUNTERS = 4
}
}

The module ties everything together: it owns the composition, the roles, the team registry, and the onGameStart / onGameFinish lifecycle.

BountyHunterModule.kt
package fr.example.bountyhunter
import fr.noradrenalin.gdk.GDKMain
import fr.noradrenalin.gdk.module.EliminationDecision
import fr.noradrenalin.gdk.module.GameContext
import fr.noradrenalin.gdk.module.HostConfigurationDirection
import fr.noradrenalin.gdk.module.UHCGameModule
import fr.noradrenalin.gdk.module.UHCStartability
import fr.noradrenalin.gdk.module.VictoryResult
import fr.noradrenalin.gdk.module.role.UHCGameRole
import fr.noradrenalin.gdk.module.role.UHCGameTeam
import fr.noradrenalin.gdk.utils.GDKItems
import fr.example.bountyhunter.roles.BountyHunterRole
import fr.example.bountyhunter.roles.TargetRole
import fr.example.bountyhunter.teams.HuntersTeam
import fr.example.bountyhunter.teams.TargetsTeam
import net.md_5.bungee.api.ChatColor
import org.bukkit.Bukkit
import org.bukkit.Material
import org.bukkit.entity.Player
import java.util.UUID
class BountyHunterModule(private val gdk: GDKMain) : UHCGameModule() {
private val composition = BountyHunterComposition()
private val rolesManager get() = gdk.gdkGameManager.gdkGameRolesManager
private val teamsManager get() = gdk.gdkGameManager.gdkGameTeamsManager
private val hunterRole = BountyHunterRole(
rolesManager = rolesManager,
pickFreshTarget = ::pickFreshTargetFor,
)
override fun name() = "${ChatColor.GOLD}Bounty ${ChatColor.YELLOW}Hunter"
override fun description() = "Hunters chase secret targets. Targets fight back."
override fun symbolItem() = GDKItems.Builder(Material.GOLD_INGOT, 1, name()).build()
// ---------- Host configuration ----------
override fun isConfigurable() = true
override fun hasConfigPanel() = false
override fun onHostConfiguresModule(direction: HostConfigurationDirection) {
when (direction) {
HostConfigurationDirection.INCREASE -> composition.increment()
HostConfigurationDirection.DECREASE -> composition.decrement()
}
}
override fun configurableStatusLines() = listOf(
"${ChatColor.GRAY}Hunters: ${ChatColor.YELLOW}${composition.huntersCount}",
)
// ---------- Pre-start validation ----------
override fun canStart(onlinePlayerCount: Int): UHCStartability {
val minimum = composition.huntersCount + 2
return if (onlinePlayerCount < minimum) {
UHCStartability.CannotStart("Il faut $minimum joueurs minimum (${composition.huntersCount} chasseurs + 2 cibles).")
} else {
UHCStartability.CanStart
}
}
// ---------- Roles & teams ----------
override fun listGameRoles(): Set<UHCGameRole> = setOf(hunterRole, TargetRole)
override fun listGameTeams(): Set<UHCGameTeam> = setOf(HuntersTeam, TargetsTeam)
// ---------- Game lifecycle ----------
override fun onGameStart(context: GameContext) {
// Hand the desired composition to the GDK and let it draw / assign / fire
// UHCRolesAttributedEvent. attributeRoles also applies each role's defaultTeam.
val composition = mapOf(
hunterRole.id() to this.composition.huntersCount,
TargetRole.id() to (context.activePlayers().size - this.composition.huntersCount),
)
val assignment = gdk.gdkGameManager.attributeRoles(composition, giveStarterItems = true)
// Build the pool of just-assigned Targets (UUIDs) and hand each Hunter a contract.
val pool = assignment.filterValues { it == TargetRole.id() }.keys.toMutableList()
assignment.filterValues { it == hunterRole.id() }.keys.forEach { hunter ->
if (pool.isEmpty()) return@forEach
val target = pool.removeAt(pool.indices.random())
hunterRole.assignContract(hunter, target)
Bukkit.getPlayer(hunter)?.sendMessage(
"${GDKMain.GDK_PREFIX} ${ChatColor.GOLD}Your contract: ${ChatColor.YELLOW}${Bukkit.getPlayer(target)?.name ?: "<offline>"}",
)
}
}
override fun onPlayerDeath(player: Player, context: GameContext): EliminationDecision =
EliminationDecision.Eliminate
override fun evaluateVictory(context: GameContext): VictoryResult {
val active = context.activePlayers()
val aliveUuids = active.map { it.uniqueId }.toSet()
val huntersAlive = teamsManager.isAlive(HuntersTeam, aliveUuids::contains)
val targetsAlive = teamsManager.isAlive(TargetsTeam, aliveUuids::contains)
return when {
huntersAlive && !targetsAlive -> winnersOf(HuntersTeam)
!huntersAlive && targetsAlive -> winnersOf(TargetsTeam)
else -> VictoryResult.NoWinnerYet
}
}
private fun winnersOf(team: UHCGameTeam): VictoryResult {
val winners = teamsManager.getPlayersOfTeam(team)
.mapNotNull { Bukkit.getPlayer(it) }.toSet()
return if (winners.size == 1) {
VictoryResult.SingleWinner(winners.first())
} else {
VictoryResult.MultipleWinners(winners)
}
}
private fun pickFreshTargetFor(hunter: UUID): Player? {
val active = gdk.gdkGameManager.activePlayers()
return active.firstOrNull { player ->
player.uniqueId != hunter && rolesManager.getInitialRole(player.uniqueId) === TargetRole
}
}
}

The module composes (rather than inherits) the Composition and the BountyHunterRole, exposes the framework hooks the dashboard reads, and reads the team / role indices when evaluating victory. There is no bespoke “alive map” maintained by the module: the framework is the source of truth, and the module derives every decision from context.activePlayers() and teamsManager.getPlayersOfTeam. Note that evaluateVictory returns a non-null VictoryResultVictoryResult.NoWinnerYet is the “keep polling” sentinel.

PlantUML Diagram

The module above is intentionally short. By relying on the framework, you skipped:

  • World generation, player spreading, world border progression.
  • PvP delay, invulnerability fade, episode broadcasts, day / night events.
  • Killer attribution (the UHCPlayerKilledByPlayerEvent already resolved the killer for you).
  • Listener registration and unregistration for the role.
  • Restoring the Hunter’s permanent Speed I after a milk drink (the manager’s PlayerItemConsumeEvent listener handles it).
  • Spectator switch, kill-leaderboard rendering, victory broadcast.
  • Cleanup on game finish (the framework unregisters listeners and tears down per-game timers).

What is left is the actual design of your mode. Everything else lives upstream.

  • Add a config panel that lets the host pick the contract length, the buff duration, and the bonus drop on bounty completion. See Host configuration.
  • Replace the trivial onGameStart with a deferred attribution at the start of episode 2, broadcasting an “open the envelope” announcement when contracts go live. See Scheduling & time.
  • Add a “Witness” role that earns a bonus if it sees a bounty completed inside its line of sight. See Roles & teams.
  • Add a deferred-elimination flow where a Target hit by a non-contract Hunter has 5 seconds to retaliate before being eliminated. See Elimination & victory.