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.
The brief
Section titled “The brief”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.
Project layout
Section titled “Project layout”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.ktA 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.
Plugin entry point
Section titled “Plugin entry point”package fr.example.bountyhunter
import fr.noradrenalin.gdk.GDKMainimport 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.
package fr.example.bountyhunter.teams
import fr.noradrenalin.gdk.module.role.UHCGameTeamimport fr.noradrenalin.gdk.utils.GDKNamespacedKeyimport 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.
package fr.example.bountyhunter.roles
import fr.noradrenalin.gdk.module.role.UHCGameRoleimport fr.noradrenalin.gdk.module.role.UHCVictoryConditionTypeimport fr.noradrenalin.gdk.utils.GDKItemsimport fr.noradrenalin.gdk.utils.GDKNamespacedKeyimport fr.example.bountyhunter.teams.TargetsTeamimport 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."}package fr.example.bountyhunter.roles
import fr.noradrenalin.gdk.GDKMainimport fr.noradrenalin.gdk.events.UHCGameFinishesEventimport fr.noradrenalin.gdk.events.UHCPlayerKilledByPlayerEventimport fr.noradrenalin.gdk.module.role.UHCGameRoleimport fr.noradrenalin.gdk.module.role.UHCVictoryConditionTypeimport fr.noradrenalin.gdk.roles.GDKGameRolesManagerimport fr.noradrenalin.gdk.utils.GDKItemsimport fr.noradrenalin.gdk.utils.GDKNamespacedKeyimport fr.noradrenalin.gdk.utils.TemporaryEffectimport fr.example.bountyhunter.teams.HuntersTeamimport org.bukkit.Bukkitimport org.bukkit.ChatColorimport org.bukkit.Materialimport org.bukkit.entity.Playerimport org.bukkit.event.EventHandlerimport org.bukkit.event.EventPriorityimport org.bukkit.potion.PotionEffectimport org.bukkit.potion.PotionEffectTypeimport java.time.Durationimport 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) !== thisis the first line of every handler. Without it, an arbitrary kill from any player would trigger the contract logic. (getCurrentRolereturns the role driving the player’s behaviour right now — including any active impersonation; usegetInitialRoleonly for identity checks like “did this carrier already burn a one-shot ability?”.)- The “pick a fresh target” decision is delegated upward via the
pickFreshTargetlambda, so the role does not need a direct reference to the module or the world. contracts.clear()from theUHCGameFinishesEventlistener: per-game state is purged before the role is unregistered. TheFinishesevent fires before listener teardown so the role’s own handler still observes it; pair it withUHCGameFinishedEventif you need to react after the post-game scoreboard is up.
Composition state
Section titled “Composition state”A small data class to hold “how many Hunters does the host want?”:
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 }}Wiring the module
Section titled “Wiring the module”The module ties everything together: it owns the composition, the roles, the team registry, and the onGameStart / onGameFinish lifecycle.
package fr.example.bountyhunter
import fr.noradrenalin.gdk.GDKMainimport fr.noradrenalin.gdk.module.EliminationDecisionimport fr.noradrenalin.gdk.module.GameContextimport fr.noradrenalin.gdk.module.HostConfigurationDirectionimport fr.noradrenalin.gdk.module.UHCGameModuleimport fr.noradrenalin.gdk.module.UHCStartabilityimport fr.noradrenalin.gdk.module.VictoryResultimport fr.noradrenalin.gdk.module.role.UHCGameRoleimport fr.noradrenalin.gdk.module.role.UHCGameTeamimport fr.noradrenalin.gdk.utils.GDKItemsimport fr.example.bountyhunter.roles.BountyHunterRoleimport fr.example.bountyhunter.roles.TargetRoleimport fr.example.bountyhunter.teams.HuntersTeamimport fr.example.bountyhunter.teams.TargetsTeamimport net.md_5.bungee.api.ChatColorimport org.bukkit.Bukkitimport org.bukkit.Materialimport org.bukkit.entity.Playerimport 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 VictoryResult — VictoryResult.NoWinnerYet is the “keep polling” sentinel.
End-to-end flow
Section titled “End-to-end flow”What you have not had to write
Section titled “What you have not had to write”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
UHCPlayerKilledByPlayerEventalready 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
PlayerItemConsumeEventlistener 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.
Where to go from here
Section titled “Where to go from here”- 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
onGameStartwith 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.