Skip to content

Host configuration

The GDK ships an opinionated configuration UI for hosts: a Nether Star in the host’s hotbar opens the main dashboard, where the host picks a game module, tunes scenarios, and starts the game. Your module plugs into that UI through a small set of optional methods on UHCGameModule. This page covers the four hooks the dashboard reads, when to use each, and how to surface validation errors before a game starts.

The dashboard’s “Mode” panel lists every registered module as one inventory slot. The slot displays:

  • The module’s symbolItem() ItemStack (its icon).
  • The module’s name() and description().
  • Optional configuration status lines returned by configurableStatusLines().

The host interacts with the slot through three click actions:

ClickBehaviour
Left clickSelects the module as active (calls GDKRegistry.setActiveModule under the hood).
Shift + left clickCalls onHostConfiguresModule(HostConfigurationDirection.INCREASE) if the module is configurable inline (isConfigurable() = true, hasConfigPanel() = false).
Shift + right clickEither calls onHostConfiguresModule(HostConfigurationDirection.DECREASE) (inline mode) or opens the dedicated config panel via openConfigPanel(player, onClose).
PlantUML Diagram

The simplest path: a module exposes one numeric parameter (a team size, a kill threshold, a difficulty level). The host adjusts it directly from the mode panel without leaving the inventory.

class TeamUHCModule(var teamSize: Int = 2) : UHCGameModule() {
override fun isConfigurable() = true
override fun hasConfigPanel() = false // (default): inline routing
override fun onHostConfiguresModule(direction: HostConfigurationDirection) {
when (direction) {
HostConfigurationDirection.INCREASE -> if (teamSize < 10) teamSize++
HostConfigurationDirection.DECREASE -> if (teamSize > 2) teamSize--
}
}
override fun configurableStatusLines() = listOf(
"${ChatColor.GRAY}Team size: ${ChatColor.YELLOW}$teamSize",
)
}

configurableStatusLines() returns lore lines appended below the description on the module’s slot. The framework reads them every time the inventory is refreshed, so they always reflect the current value.

For richer configurations (a role composition with one slot per role, a multi-axis tuning, an enable-disable toggle list, etc.), implement a dedicated panel and tell the framework to route shift + right click to it.

class BountyHunterModule(private val gdk: GDKMain) : UHCGameModule() {
private val compositionPanel = BountyHunterCompositionPanel(rolesById.values.toList(), composition, gdk)
override fun isConfigurable() = true
override fun hasConfigPanel() = true
override fun openConfigPanel(player: Player, onClose: (Player) -> Unit) {
compositionPanel.open(player, onClose = onClose)
}
override fun configurableStatusLines() = listOf(
"${ChatColor.GRAY}Roles: ${ChatColor.YELLOW}${composition.totalCount()}",
"${ChatColor.GRAY}Hunters cap: ${ChatColor.YELLOW}${composition.countOf("bh:hunter")}",
)
}

The framework calls openConfigPanel(host, onClose) whenever the host shift + right-clicks the slot. Your panel must call the supplied onClose(host) callback when the host dismisses it, so the dashboard can re-open the mode-selection inventory in the same place.

GDKMenu and GDKPaginatedMenu are typealiases over the FastInv inventory primitives shaded into the GDK; using the aliases keeps the FastInv dependency an implementation detail of the framework.

A panel implementation typically:

  1. Builds a GDKMenu inventory whose slots represent options.
  2. Renders the current state by reading from your module’s configuration object.
  3. On click, mutates the configuration (e.g. increments a role count, toggles a flag) and re-renders.
  4. Calls onClose(player) when the host closes the inventory.

The examples/ directory of the GDK repository contains a worked composition panel implementation showing pagination, role icons, and shift-click increments; treat it as a starting point rather than a copy-paste source.

canStart(onlinePlayerCount) runs when a host triggers /h start. Returns a sealed UHCStartability:

  • UHCStartability.CanStart greenlights the launch.
  • UHCStartability.CannotStart(reason) halts it; the GDK forwards reason to the host with the standard error prefix.
override fun canStart(onlinePlayerCount: Int): UHCStartability {
if (onlinePlayerCount < teamSize + 1) {
return UHCStartability.CannotStart("Il faut au moins ${teamSize + 1} joueurs pour avoir au moins deux équipes.")
}
if (composition.totalCount() != onlinePlayerCount) {
return UHCStartability.CannotStart("La composition (${composition.totalCount()}) ne correspond pas aux joueurs en ligne ($onlinePlayerCount).")
}
return UHCStartability.CanStart
}

canStart runs synchronously and should not perform expensive work: no world generation, no database I/O, no plugin messaging. Validate, then return.

The GDK renders a per-player scoreboard for the active game phase. Modules can append extra lines through scoreboardExtraLines(player):

override fun scoreboardExtraLines(player: Player): List<String> {
val team = teamOf(player) ?: return emptyList()
val mates = team.members
.filter { it.uniqueId != player.uniqueId }
.map { mate ->
if (mate.isOnline) "${ChatColor.GREEN}${mate.name}" else "${ChatColor.RED}${mate.name}"
}
return buildList {
add("${ChatColor.GRAY}■ Team")
if (mates.isEmpty()) {
add(" ${ChatColor.GRAY}▪ ${ChatColor.YELLOW}No teammates")
} else {
mates.forEach { add(" ${ChatColor.GRAY}▪ $it") }
}
}
}

The framework calls scoreboardExtraLines(player) once per player on every refresh tick. Cap the line width: the Spigot 1.8.8 chat box is 320 px wide, and the scoreboard sidebar is even tighter (about 40-character soft cap before the client clips). Use GDKText.Alignment.justify (or the addJustifiedLore helper on the item builder) if you need a known column width.

The mode panel uses your module’s symbolItem() to render the slot. Use GDKItems.Builder for vanilla materials and GDKItems.HeadItemBuilder for custom player heads (skinned via base64 textures):

override fun symbolItem(): ItemStack = GDKItems.Builder(Material.IRON_SWORD, 1, name())
.setLore(GDKConstants.MenuInventories.MEDIUM_LORE_SEPARATOR)
.addJustifiedLore(description()) // 70-char width + ChatColor.GRAY by default
.addItemFlags(ItemFlag.HIDE_ATTRIBUTES, ItemFlag.HIDE_ENCHANTS)
.build()

addJustifiedLore accepts overloads for a custom width and prefix colour (addJustifiedLore(text, 60, ChatColor.AQUA)), and replaces the previous addLore(GDKText.Alignment.justify(text, 70).map { "${ChatColor.GRAY}$it" }) boilerplate.

The framework adds the configuration status lines (from configurableStatusLines()) below the lore you set; do not try to encode them in symbolItem() directly, otherwise they will not refresh when the host changes the configuration.

A module that exposes a numeric parameter inline, validates it on start, and renders its configured state on the scoreboard typically reads:

class MyUHCModule : UHCGameModule() {
var killTarget: Int = 5
override fun name() = "${ChatColor.WHITE}My ${ChatColor.AQUA}UHC"
override fun description() = "Reach $killTarget eliminations to win."
override fun symbolItem() = GDKItems.Builder(Material.SKULL_ITEM, 1, name()).build()
override fun isConfigurable() = true
override fun onHostConfiguresModule(direction: HostConfigurationDirection) {
when (direction) {
HostConfigurationDirection.INCREASE -> if (killTarget < 20) killTarget++
HostConfigurationDirection.DECREASE -> if (killTarget > 1) killTarget--
}
}
override fun configurableStatusLines() =
listOf("${ChatColor.GRAY}Kills to win: ${ChatColor.YELLOW}$killTarget")
override fun canStart(onlinePlayerCount: Int): UHCStartability = when {
onlinePlayerCount < 2 -> UHCStartability.CannotStart("Il faut au moins 2 joueurs.")
killTarget < 1 -> UHCStartability.CannotStart("Le seuil de victoire doit être strictement positif.")
else -> UHCStartability.CanStart
}
override fun scoreboardExtraLines(player: Player): List<String> {
val kills = player.getStatistic(Statistic.PLAYER_KILLS)
return listOf(
"${ChatColor.GRAY}■ Goal",
" ${ChatColor.GRAY}▪ ${ChatColor.YELLOW}$kills${ChatColor.GRAY} / ${ChatColor.GREEN}$killTarget",
)
}
override fun onPlayerDeath(player: Player, context: GameContext) =
EliminationDecision.Eliminate
override fun evaluateVictory(context: GameContext): VictoryResult {
val leader = context.activePlayers().maxByOrNull { it.getStatistic(Statistic.PLAYER_KILLS) } ?: return VictoryResult.NoWinnerYet
val leaderKills = leader.getStatistic(Statistic.PLAYER_KILLS)
return if (leaderKills >= killTarget) VictoryResult.SingleWinner(leader) else VictoryResult.NoWinnerYet
}
}

This module needs no roles, no teams, and no per-game listeners. Yet it surfaces every host-facing affordance: configuration, validation, scoreboard. Anything richer (a panel, a role composition, a sub-team layout) builds on the same primitives.