Skip to content

Scheduling & time

UHC games are paced by time: PvP comes online after a delay, invulnerability fades shortly after, the world border closes over a quarter-hour, episodes pass every twenty minutes, day and night alternate for role mechanics. Modules layer their own deadlines on top: “open the medic prompt for 30 seconds”, “attribute roles at the start of episode 2”, “tick a switch window every second”. The GDK provides a single scheduler designed for these patterns and resilient to the developer’s /dev timewarp debug command. This page describes the scheduler, its three flavours, and the cycle / episode events you can listen to.

The GDK distinguishes two clocks:

  • The wall clock is real time. Bukkit’s vanilla scheduler (runTaskLater, runTaskTimer) is wall-clock based. Use it for player-perception cadences (a particle refresh, a peripheral marker) where the elapsed real seconds are what matters.
  • The game clock is “elapsed time since onGameStart”, as observed by GDKGameManager.getGameStartInstant(). Most game-design deadlines are game-clock based: “10 minutes until PvP”, “15 minutes until the border starts closing”, “20 minutes per episode”. The GDK’s /dev timewarp command shifts the game clock backward to fast-forward these deadlines without waiting in real time.

A wall-clock task booked through BukkitScheduler is unaware of timewarps. A game-clock-aware task booked through GDKTaskScheduler is rebooked every time the host warps the clock, so deadlines fire at the right time.

The scheduler is reachable from any module via gdkMain.gdkGameManager.gdkTaskScheduler. It exposes three booking methods, all of which return a Handle you can cancel().

val handle: GDKTaskScheduler.Handle = scheduler.scheduleAfter(Duration.ofMinutes(2)) {
// game-clock-relative one-shot
}

scheduleAt(targetElapsed) and scheduleAfter(delay)

Section titled “scheduleAt(targetElapsed) and scheduleAfter(delay)”

Game-clock-relative one-shots. The action fires when the elapsed game time reaches the target.

  • scheduleAt(Duration): fire when the elapsed time reaches the absolute target.
  • scheduleAfter(Duration): fire delay after now (scheduleAt(now + delay)).

On a timewarp, the scheduler cancels the underlying Bukkit task, recomputes the remaining delay against the warped clock, and re-books. If the warp crossed the target, the action fires on the next tick.

val rolesAttribution = scheduler.scheduleAfter(Duration.ofMinutes(10)) {
dispatchRolesAndStartCycle(context)
}

scheduleRepeating(initialDelay, period, shouldFireDuringTimewarp = false)

Section titled “scheduleRepeating(initialDelay, period, shouldFireDuringTimewarp = false)”

Game-clock-period repeater. The first fire is initialDelay after now; each subsequent fire is period later, on the game clock. Implemented internally as a self-rescheduling one-shot, so it stays warp-aware.

By default, on a large warp, the scheduler snaps the next fire forward to the first boundary still in the future and runs the action exactly once. This is the right default for refresh-style tasks where only the latest state matters (the scoreboard refresh, a per-second visibility check).

If your task represents a discrete event per iteration (a per-second damage tick on a poisoned player, a periodic broadcast), pass shouldFireDuringTimewarp = true. The scheduler will then fire once per missed iteration, up to a hard cap of 50 catch-up runs (the cap exists to avoid floods on huge warps).

// Refresh-style: the latest "is the window open?" state is the only one that matters.
val tickHandle = scheduler.scheduleRepeating(
initialDelay = Duration.ofSeconds(1),
period = Duration.ofSeconds(1),
) {
val elapsed = Duration.between(gameManager.getGameStartInstant(), Instant.now())
role.tick(elapsed)
}

scheduleRepeatingWallClock(initialDelay, period)

Section titled “scheduleRepeatingWallClock(initialDelay, period)”

Wall-clock-period repeater, not warp-aware. Use for visual / sensory cadences whose period reflects player perception, not game-design progression.

val markerRefresh = scheduler.scheduleRepeatingWallClock(
initialDelay = Duration.ZERO,
period = Duration.ofMillis(100),
) {
refreshPeripheralMarker()
}

The scheduler still owns the cancellation, so markerRefresh.cancel() on game finish (or your module’s teardown path) terminates the loop the same way as game-clock tasks.

ScenarioSchedulerWhy
Attribute roles 10 min into the gamescheduleAfter(Duration.ofMinutes(10))Game-clock deadline, must follow timewarps.
Tick a “switch window” every secondscheduleRepeating(1s, 1s)Game-clock cadence; the latest state is what matters (refresh-style).
Burn 1 HP per second from a curse rolescheduleRepeating(1s, 1s, shouldFire=true)Each tick is a discrete event; on a warp, the missed ticks should still fire (capped to 50).
Refresh a peripheral particle 10 times per secondscheduleRepeatingWallClock(0, 100ms)Visual cadence, not game time. Should not double-tick on a warp.
6-second buff after a killTemporaryEffect.apply(...) (utility)Bukkit’s potion duration handles expiry; no scheduler needed.
Bukkit-native one-shot on the next tickBukkit.getScheduler().runTaskLater(plugin, runnable, 1L)Trivial wall-clock one-shot, not worth the indirection.

Even though Spigot 1.8.8 has its own day / night world cycle, the GDK runs a logical cycle independent of world time: a fixed schedule that drives the day/night events:

  • Day 1 lasts 10 minutes.
  • From t = 600 s, the game alternates 10 min of NIGHT and 10 min of DAY, for 20-minute Minecraft-standard cycles.

Each transition fires two events back-to-back: an …EndsEvent for the phase that just finished, then a …StartEvent for the new phase. All four carry both the in-game day index and the GDK episode number active at the moment:

  • UHCDayStartEvent(dayNumber: Int, episode: Int)
  • UHCNightStartEvent(dayNumber: Int, episode: Int)
  • UHCDayEndsEvent(dayNumber: Int, episode: Int)
  • UHCNightEndsEvent(dayNumber: Int, episode: Int)
PlantUML Diagram

Subscribe from a role or a listener to apply or remove phase-bound effects. Use the …EndsEvent to close anything you opened on the matching …StartEvent (chat windows, bossbars, …) without double-bookkeeping which phase is currently active:

@EventHandler
fun onNight(event: UHCNightStartEvent) {
rolesManager.getPlayersOfRole(this).forEach { uuid ->
Bukkit.getPlayer(uuid)?.addPotionEffect(NIGHT_STRENGTH, true)
}
}
@EventHandler
fun onDay(event: UHCDayStartEvent) {
rolesManager.getPlayersOfRole(this).forEach { uuid ->
Bukkit.getPlayer(uuid)?.removePotionEffect(PotionEffectType.INCREASE_DAMAGE)
}
}

If your module assigns roles after the game has already passed the first night (e.g. role attribution is delayed to episode 2), the framework helps you re-sync: gdkGameManager.currentDayPhase() returns the (DayPhase, dayNumber) pair for the current elapsed time, and gdkGameManager.timerManager.getCurrentEpisode() returns the live episode counter, so you can refire the relevant event yourself once the listeners are registered.

val (phase, day) = gameManager.currentDayPhase()
if (phase == DayPhase.NIGHT) {
val episode = gameManager.timerManager.getCurrentEpisode().toInt()
Bukkit.getPluginManager().callEvent(UHCNightStartEvent(day, episode))
}

UHCDayStartEvent and UHCNightStartEvent are both idempotent inputs: a role’s listener should make adding the effect / opening the chat channel a no-op when the state already matches, so a refire is safe.

The episode counter tracks how many “episodes” have elapsed: the first episode lasts 10 minutes, every subsequent episode 20 minutes, mirroring the standard UHC cadence. The framework broadcasts an episode-start / episode-end pair into chat, but does not currently expose an UHCEpisodeStartEvent. If your module needs to act on an episode boundary, schedule a deadline via GDKTaskScheduler:

private fun scheduleAtEpisode2Start() {
scheduler.scheduleAfter(Duration.ofMinutes(10)) {
// We're now at the start of episode 2.
gameManager.attributeRoles(rolesConfig.rolesCounts)
}
}

GDKConstants.TaskDelay and GDKConstants.TaskPeriod are convenience constants and helpers used pervasively in the GDK source. They convert Duration to Bukkit ticks for the few APIs that still take ticks directly.

import fr.noradrenalin.gdk.utils.GDKConstants
// Bukkit-native API (still tick-based)
runnable.runTaskLater(plugin, GDKConstants.TaskDelay.ONE_SECOND_DELAY)
runnable.runTaskLater(plugin, GDKConstants.TaskDelay.after(Duration.ofMinutes(2)))
runnable.runTaskTimer(plugin, GDKConstants.TaskDelay.NO_DELAY, GDKConstants.TaskPeriod.EVERY_SECOND)
runnable.runTaskTimer(plugin, GDKConstants.TaskDelay.NO_DELAY, GDKConstants.TaskPeriod.every(Duration.ofMinutes(5)))

GDKTaskScheduler itself takes Duration directly; the constants only matter when you call into Bukkit’s scheduler.

A typical role’s relationship to time:

PlantUML Diagram

The role owns its handles and cancels them on the way out. The framework owns the timewarp catch-up and the day / night events; the module never has to handle either by hand.