Skip to content

Roles & teams

A “Free-For-All” mode (like the bundled UHCClassic) does not need roles or teams: every player is a faction of one, and victory is decided by counting survivors. Anything richer than that, including team-based modes, social-deduction modes, asymmetric modes, or coalitions formed mid-game, is built on the GDK’s role / team model. This page explains the model in depth and how to wire it into a UHCGameModule.

PlantUML Diagram

A team is a static or dynamic faction; a role is a per-player state machine that drives effects, listeners, and a victory predicate. Most modes pair them: every role has a defaultTeam(), and teams are how victory is usually counted. Pure team modes (no roles) are perfectly valid and just leave listGameRoles() empty.

Both UHCGameRole.id() and UHCGameTeam.id() return a GDKNamespacedKey, modelled after the modern Spigot NamespacedKey (which 1.8.8 lacks). It is a plain data class of (namespace, key), both validated as lowercase [a-z0-9_./-]+. Equality is structural, so the GDK can use GDKNamespacedKey as a Map key without surprises.

import fr.noradrenalin.gdk.utils.GDKNamespacedKey
GDKNamespacedKey("bh", "hunter") // canonical "namespace:key" form
GDKNamespacedKey.parse("bh:hunter") // accepts the textual form, throws on garbage

Use the canonical pluginId:role-or-team-name convention. The asString() helper returns the textual form whenever you need to log / serialise the id.

A team is the cheapest contribution you can make to a module. The minimum is an id() and a name(); everything else has a default.

package fr.example.bountyhunter.teams
import fr.noradrenalin.gdk.module.role.UHCGameTeam
import fr.noradrenalin.gdk.utils.GDKNamespacedKey
import org.bukkit.ChatColor
object BountyHuntersTeam : UHCGameTeam {
override fun id() = GDKNamespacedKey("bh", "hunters")
override fun name() = "Bounty Hunters"
override fun chatColor() = ChatColor.GOLD
override fun isFriendlyFireEnabled() = false
override fun canAlliesSeeInvisiblePlayers() = true
override fun minMembers() = 1
override fun maxMembers() = 4
}
MemberEffect on the game
id()Stable identifier used by the role registry, the action log, the configuration panel and equals. Use the pluginId:name convention.
chatColor()Used by the GDK to colour the team’s name in announcements and scoreboard sections.
isFriendlyFireEnabled()When false, hits between members are cancelled by the GDK’s equipment / damage rules.
canAlliesSeeInvisiblePlayers()Allies can see one another through invisibility (handy for “the pack sees its own” mechanics).
alliesNameTagVisibility()Default is ALWAYS. Hide tags from non-allies for stealth-based teams.
minMembers() / maxMembers()Used by UHCGameModule.canStart() to validate the host’s role composition before the game launches.

Static teams are usually object declarations because there is exactly one instance per module. Mark them so the registry can rely on referential equality.

Some teams only exist during a game (a “Couple” formed by a matchmaker-like role, a “Truce” coalition formed mid-game, etc.). Use GDKGameTeamsManager.createDynamicTeam(id, base, members):

val truce: UHCGameTeam = teamsManager.createDynamicTeam(
dynamicId = GDKNamespacedKey("bh", "truce-${player1.uniqueId}-${player2.uniqueId}"),
base = TruceTeamTemplate, // a UHCGameTeam describing colour, friendly fire, etc.
members = setOf(player1.uniqueId, player2.uniqueId),
)

The returned team shadows base’s display fields and has its own identity; it can be passed to assignActualTeam, replaceMember, and dissolveTeam exactly like a static team. isDynamicTeam(team) lets you treat dynamic membership specially: for example, a “thief”-style role can call replaceMember instead of assignActualTeam when its victim was on a dynamic team, so the dynamic team’s identity survives the swap rather than being torn down because its only remaining member changed.

A role is an object (or class) implementing UHCGameRole. The minimum a role must declare is its identity, its symbol, an optional default team, and a victory condition.

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.BountyHuntersTeam
import org.bukkit.Material
object BountyHunterRole : UHCGameRole {
override fun id() = GDKNamespacedKey("bh", "hunter")
override fun name() = "Bounty Hunter"
override fun description() = "Hunts marked targets for a reward."
override fun defaultTeam() = BountyHuntersTeam
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 non-hunter has been eliminated."
}

The base interface lets a role describe persistent state that the framework applies on assignment and renews while the game runs:

MemberBehaviour
permanentPotionEffects()A set of PotionEffects applied on assignment and re-applied after the player drinks milk (PlayerItemConsumeEvent listener owned by the manager). Override to return a different set per phase (day vs. night, etc.).
permanentMaxHealthDelta(uuid)A per-carrier offset to max health in HP points (2 HP = 1 heart). Re-evaluated on assignment and after revivePlayer. Default 0.0.
starterItems()Items handed out to the player when their actual role is assigned (only when giveStarterItems = true).
specialItems()Items the role considers “its own” (e.g. for filtering pickups).

permanentPotionEffects is queried on every relevant moment (assignment, milk drink, revive), so a role can flip its return based on internal state. A typical pattern is a phase-bound role that grants Strength I only at night:

private var isNight = false
override fun permanentPotionEffects(): Set<PotionEffect> =
if (isNight) setOf(PotionEffect(PotionEffectType.INCREASE_DAMAGE, Int.MAX_VALUE, 0)) else emptySet()
@EventHandler fun onNight(event: UHCNightStartEvent) { isNight = true }
@EventHandler fun onDay(event: UHCDayStartEvent) { isNight = false }

When a role gains or loses a carrier, the GDK fires UHCGameRoleAssignedEvent and UHCGameRoleUnassignedEvent. Both carry the carrier UUID, the role and a Reason (ACTUAL, IMPERSONATION, SWAP). The role’s listener is already registered when the events fire, so the role can subscribe with the standard “filter by this” idiom and react to its own assignment without a side hook:

@EventHandler
fun onAssigned(event: UHCGameRoleAssignedEvent) {
if (event.role !== this) return
wolfChat.setEligibility(event.playerUuid, WolfChatEligibility.SEND_AND_RECEIVE)
}
@EventHandler
fun onUnassigned(event: UHCGameRoleUnassignedEvent) {
if (event.role !== this) return
wolfChat.clearEligibility(event.playerUuid)
}

The unassign event fires before the role’s listener is unregistered (when the last carrier is removed), so the role’s own handler still sees the event. Use these for things the framework does not know about (chat-channel eligibility, scoreboard tag, role-specific colour mapping).

Because a UHCGameRole is a Listener, @EventHandler methods on the class are fired by Bukkit. Every handler must filter to “this role’s carriers” before running, otherwise an event fired by an unrelated player will trigger the role’s logic. The recommended idiom is:

@EventHandler
fun onPlayerKill(event: UHCPlayerKilledByPlayerEvent) {
val killer = event.killer
if (rolesManager.getCurrentRole(killer.uniqueId) !== this) return
// ...kill-specific logic
}

Two reasons to use getCurrentRole rather than getInitialRole:

  1. A player impersonating a role (see below) should run that role’s listeners.
  2. Reference equality (!== in Kotlin) on the role singleton is faster and intent-revealing than comparing ids.
sealed class UHCVictoryConditionType {
object LastTeamAlive : UHCVictoryConditionType()
object LastPlayerAlive : UHCVictoryConditionType()
class Dynamic(predicate: () -> Boolean) : UHCVictoryConditionType()
}

LastTeamAlive and LastPlayerAlive are informational by themselves: the GDK does not interpret them. Your module’s evaluateVictory is where these become real, usually by counting alive teams via teamsManager.isAlive(team, aliveFilter). Dynamic lets a role declare a predicate the module’s victory resolver can call to ask the role whether it considers itself a winner. This is how role mechanics like “you win with whoever you fell in love with, or with the village otherwise” stay encapsulated inside the role rather than leaking into the resolver.

The module exposes its contributions through three simple list methods. The framework reads them at game start, registers the listeners, validates the composition against canStart, and unregisters everything on game finish. The actual attribution (which player gets which role, which team they end up in) is delegated to the GDK via gdkGameManager.attributeRoles(composition).

class BountyHunterModule(
private val gdk: GDKMain,
) : UHCGameModule() {
private val gameManager get() = gdk.gdkGameManager
private val rolesManager get() = gameManager.gdkGameRolesManager
private val teamsManager get() = gameManager.gdkGameTeamsManager
private val rolesById: Map<GDKNamespacedKey, UHCGameRole> = listOf(
BountyHunterRole,
TargetRole,
BystanderRole,
).associateBy { it.id() }
override fun listGameRoles(): Set<UHCGameRole> = rolesById.values.toSet()
override fun listGameTeams(): Set<UHCGameTeam> = setOf(BountyHuntersTeam, TargetsTeam, BystandersTeam)
override fun listGameListeners(): Set<UHCGameListener> = setOf(missionLogger, scoreboardSync)
override fun onGameStart(context: GameContext) {
// Delegate the actual binding (player -> role -> defaultTeam) to the GDK. The
// composition map is the host's intent: how many of each role to draw. The GDK
// shuffles, assigns roles, applies each role's defaultTeam, and fires
// UHCRolesAttributedEvent for us. The returned assignment lets us drive any
// module-specific announcement messaging on top.
val assignment = gameManager.attributeRoles(composition.rolesCounts, giveStarterItems = true)
assignment.forEach { (uuid, roleId) ->
val role = rolesById[roleId] ?: return@forEach
Bukkit.getPlayer(uuid)?.let { announceRoleTo(it, role) }
}
}
}

The framework guarantees:

  • The first time a role gets a carrier, its listener is registered.
  • The last time a role loses its last carrier, its listener is unregistered.
  • UHCGameRoleAssignedEvent / UHCGameRoleUnassignedEvent fire for every carrier change.
  • permanentPotionEffects() is applied on assignment, on revive, and re-applied on milk consumption.

You decide when to call attributeRoles. A “social-deduction” mode might delay attribution until the start of episode 2 by scheduling the dispatch via GDKTaskScheduler.scheduleAt(...); the bundled UHCClassicTeams module assigns Bukkit-level scoreboard teams in onGameStart and ignores the role API entirely. Both are valid: the framework only requires that the assignment happens at some point during the game’s lifetime if you want roles in play.

The roles manager supports three modifications beyond the usual assign / unassign:

rolesManager.swapPlayersRoles(uuidA, uuidB)
rolesManager.setImpersonatedRole(uuid, role)
rolesManager.clearImpersonation(uuid)

swapPlayersRoles exchanges the two players’ actual roles. UHCGameRoleAssignedEvent / UHCGameRoleUnassignedEvent fire twice (once per player); permanent effects are detached from the previous role and applied for the new one. Use this for “trickster” mechanics that move a role from one player to another.

Impersonation is a non-destructive disguise: the original (initial) role is preserved, but the player behaves as the impersonated role until you clear the disguise. The framework:

  • Pauses the initial role’s permanent effects and listeners (for that carrier only).
  • Activates the impersonated role’s permanent effects and listeners.
  • Switches getCurrentRole(uuid) to the impersonated role.
rolesManager.setImpersonatedRole(thiefUuid, victimRole)
// thief now plays as victimRole; their initial role (Thief) is preserved for identity checks
rolesManager.clearImpersonation(thiefUuid)

This is the right primitive for “I steal your role” mechanics: the thief is still tracked as a Thief by getInitialRole, so single-instance limits and identity-based commands keep working, while their behaviour is fully driven by the impersonated role.

The host configures the role composition (how many of each role) through your module’s panel. canStart is the right place to validate that composition before any world is generated. It returns a sealed UHCStartability: CanStart greenlights the launch, CannotStart(reason) halts it and the GDK relays the reason back to the host.

override fun canStart(onlinePlayerCount: Int): UHCStartability {
listGameRoles().forEach { role ->
val configured = composition.countOf(role.id())
if (configured > role.maxInstances()) {
return UHCStartability.CannotStart("Role ${role.name()} is limited to ${role.maxInstances()} player(s).")
}
}
listGameTeams().forEach { team ->
val members = listGameRoles()
.filter { it.defaultTeam()?.id() == team.id() }
.sumOf { composition.countOf(it.id()) }
if (members < team.minMembers()) {
return UHCStartability.CannotStart("Team ${team.name()} requires at least ${team.minMembers()} member(s).")
}
team.maxMembers()?.let { upper ->
if (members > upper) return UHCStartability.CannotStart("Team ${team.name()} is capped at $upper member(s).")
}
}
val total = composition.totalCount()
if (total != onlinePlayerCount) {
return UHCStartability.CannotStart("Composition ($total) does not match online players ($onlinePlayerCount).")
}
return UHCStartability.CanStart
}

Because role and team ids are GDKNamespacedKey instances, the equality checks (it.defaultTeam()?.id() == team.id()) are structural; no string parsing required. The validation rules above are entirely declarative: they read off the role / team metadata you already provide (maxInstances, minMembers, maxMembers) and only need to be re-implemented when your mode introduces a constraint that the standard metadata cannot express.