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.
Concepts
Section titled “Concepts”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.
Identifying roles and teams
Section titled “Identifying roles and teams”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" formGDKNamespacedKey.parse("bh:hunter") // accepts the textual form, throws on garbageUse the canonical pluginId:role-or-team-name convention. The asString() helper returns the textual form whenever you need to log / serialise the id.
Declaring teams
Section titled “Declaring teams”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.UHCGameTeamimport fr.noradrenalin.gdk.utils.GDKNamespacedKeyimport 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}| Member | Effect 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.
Dynamic teams
Section titled “Dynamic teams”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.
Declaring roles
Section titled “Declaring roles”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.UHCGameRoleimport fr.noradrenalin.gdk.module.role.UHCVictoryConditionTypeimport fr.noradrenalin.gdk.utils.GDKItemsimport fr.noradrenalin.gdk.utils.GDKNamespacedKeyimport fr.example.bountyhunter.teams.BountyHuntersTeamimport 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."}Per-carrier effects
Section titled “Per-carrier effects”The base interface lets a role describe persistent state that the framework applies on assignment and renews while the game runs:
| Member | Behaviour |
|---|---|
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 }Reacting to assignment / unassignment
Section titled “Reacting to assignment / unassignment”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:
@EventHandlerfun onAssigned(event: UHCGameRoleAssignedEvent) { if (event.role !== this) return wolfChat.setEligibility(event.playerUuid, WolfChatEligibility.SEND_AND_RECEIVE)}
@EventHandlerfun 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).
Listener semantics inside a role
Section titled “Listener semantics inside a role”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:
@EventHandlerfun 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:
- A player impersonating a role (see below) should run that role’s listeners.
- Reference equality (
!==in Kotlin) on the role singleton is faster and intent-revealing than comparing ids.
Victory conditions
Section titled “Victory conditions”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.
Wiring roles and teams into a module
Section titled “Wiring roles and teams into a module”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/UHCGameRoleUnassignedEventfire 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.
Impersonation, swapping, displayed roles
Section titled “Impersonation, swapping, displayed roles”The roles manager supports three modifications beyond the usual assign / unassign:
rolesManager.swapPlayersRoles(uuidA, uuidB)rolesManager.setImpersonatedRole(uuid, role)rolesManager.clearImpersonation(uuid)Swapping
Section titled “Swapping”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
Section titled “Impersonation”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 checksrolesManager.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.
Pre-start composition validation
Section titled “Pre-start composition validation”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.