diff --git a/bedrock/src/main/java/fr/moussax/bedrock/sound/SoundCue.java b/bedrock/src/main/java/fr/moussax/bedrock/sound/SoundCue.java
index 458d920d..0ac8a991 100644
--- a/bedrock/src/main/java/fr/moussax/bedrock/sound/SoundCue.java
+++ b/bedrock/src/main/java/fr/moussax/bedrock/sound/SoundCue.java
@@ -22,6 +22,35 @@
*/
public record SoundCue(Sound sound, float volume, float pitch, long delay) {
+ /**
+ * Creates an immediate sound cue without delay.
+ *
+ * @param sound Bukkit sound type
+ * @param volume sound volume
+ * @param pitch sound pitch
+ * @return sound cue
+ */
+ @NonNull
+ public static SoundCue of(@NonNull Sound sound, float volume, float pitch) {
+ Objects.requireNonNull(sound, "sound cannot be null");
+ return new SoundCue(sound, volume, pitch, 0L);
+ }
+
+ /**
+ * Creates a sound cue with a configured delay in ticks.
+ *
+ * @param sound Bukkit sound type
+ * @param volume sound volume
+ * @param pitch sound pitch
+ * @param delay delay in ticks
+ * @return sound cue
+ */
+ @NonNull
+ public static SoundCue of(@NonNull Sound sound, float volume, float pitch, long delay) {
+ Objects.requireNonNull(sound, "sound cannot be null");
+ return new SoundCue(sound, volume, pitch, delay);
+ }
+
/**
* Plays this sound at the specified location after the configured delay.
*
diff --git a/bedrock/src/main/java/fr/moussax/bedrock/ui/bossbar/Bossbar.java b/bedrock/src/main/java/fr/moussax/bedrock/ui/bossbar/Bossbar.java
new file mode 100644
index 00000000..333df877
--- /dev/null
+++ b/bedrock/src/main/java/fr/moussax/bedrock/ui/bossbar/Bossbar.java
@@ -0,0 +1,415 @@
+package fr.moussax.bedrock.ui.bossbar;
+
+import fr.moussax.bedrock.scheduling.PluginContext;
+import fr.moussax.bedrock.sound.SoundCue;
+import org.bukkit.Bukkit;
+import org.bukkit.Location;
+import org.bukkit.boss.BarColor;
+import org.bukkit.entity.Entity;
+import org.bukkit.entity.Player;
+import org.bukkit.plugin.Plugin;
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
+import java.time.Duration;
+import java.util.Collection;
+import java.util.Objects;
+import java.util.function.IntFunction;
+import java.util.function.Predicate;
+import java.util.function.Supplier;
+
+/**
+ * Static entry point for displaying and managing boss bars.
+ *
+ *
Supports timed alerts that drain over their lifespan, automated countdowns, and persistent
+ * {@linkplain BossbarSection sections} (global or per player) with visibility predicates. Bars stack:
+ * each alert and each visible section is its own bar.
+ *
+ *
If {@link #initialize(Plugin, long)} was never called, the first call initializes the service
+ * from {@link PluginContext}, which must then be bound.
+ */
+public final class Bossbar {
+
+ /**
+ * Default duration for alerts sent without an explicit one (3 seconds).
+ */
+ public static final Duration DEFAULT_DURATION = Duration.ofSeconds(3);
+
+ private static final long DEFAULT_PERIOD_TICKS = 1L;
+
+ private Bossbar() {
+ }
+
+ /**
+ * Initializes or retrieves the shared boss bar service for the given plugin.
+ *
+ * @param plugin owning plugin
+ * @param periodTicks interval in ticks between render passes
+ * @return the active boss bar service
+ */
+ @NonNull
+ public static BossbarService initialize(@NonNull Plugin plugin, long periodTicks) {
+ BossbarService service = BossbarService.getOrCreate(plugin);
+ service.start(periodTicks);
+ return service;
+ }
+
+ /**
+ * Creates a visibility predicate that is true for players in the same world within a radius
+ * of a moving point, e.g. a boss.
+ *
+ *
+ *
+ * @param center supplies the center, or {@code null} when it no longer exists (nobody sees the bar)
+ * @param radius radius in blocks
+ * @return visibility predicate
+ */
+ @NonNull
+ public static Predicate within(@NonNull Supplier<@Nullable Location> center, double radius) {
+ double radiusSquared = radius * radius;
+ return player -> {
+ Location location = center.get();
+ return location != null
+ && location.getWorld() != null
+ && location.getWorld().equals(player.getWorld())
+ && player.getLocation().distanceSquared(location) <= radiusSquared;
+ };
+ }
+
+ /**
+ * Creates a visibility predicate that is true for players in the same world within a radius
+ * of an entity, automatically verifying that the entity is valid and alive.
+ *
+ * @param entity target entity
+ * @param radius radius in blocks
+ * @return visibility predicate
+ */
+ @NonNull
+ public static Predicate within(@NonNull Entity entity, double radius) {
+ Objects.requireNonNull(entity, "entity cannot be null");
+ double radiusSquared = radius * radius;
+ return player -> entity.isValid()
+ && !entity.isDead()
+ && entity.getWorld() != null
+ && entity.getWorld().equals(player.getWorld())
+ && player.getLocation().distanceSquared(entity.getLocation()) <= radiusSquared;
+ }
+
+ /**
+ * Shows a white alert that drains over {@link #DEFAULT_DURATION}.
+ *
+ * @param player target player
+ * @param message title text
+ */
+ public static void sendAlert(@NonNull Player player, @NonNull String message) {
+ sendAlert(player, message, DEFAULT_DURATION);
+ }
+
+ /**
+ * Shows a white alert that drains over the given duration.
+ *
+ * @param player target player
+ * @param message title text
+ * @param duration lifespan
+ */
+ public static void sendAlert(@NonNull Player player, @NonNull String message, @NonNull Duration duration) {
+ sendAlert(player, BossbarAlert.of(message, duration));
+ }
+
+ /**
+ * Shows a white alert with the given priority.
+ *
+ * @param player target player
+ * @param message title text
+ * @param priority precedence (highest first)
+ * @param duration lifespan
+ */
+ public static void sendAlert(@NonNull Player player, @NonNull String message, int priority, @NonNull Duration duration) {
+ sendAlert(player, BossbarAlert.of(message, priority, duration));
+ }
+
+ /**
+ * Shows a colored alert with the given priority.
+ *
+ * @param player target player
+ * @param message title text
+ * @param color bar color
+ * @param priority precedence (highest first)
+ * @param duration lifespan
+ */
+ public static void sendAlert(
+ @NonNull Player player,
+ @NonNull String message,
+ @NonNull BarColor color,
+ int priority,
+ @NonNull Duration duration
+ ) {
+ sendAlert(player, BossbarAlert.of(message, color, priority, duration));
+ }
+
+ /**
+ * Shows a white alert that drains over the given duration and plays a sound cue.
+ *
+ * @param player target player
+ * @param message title text
+ * @param duration lifespan
+ * @param soundCue optional audio cue
+ */
+ public static void sendAlert(
+ @NonNull Player player,
+ @NonNull String message,
+ @NonNull Duration duration,
+ @Nullable SoundCue soundCue
+ ) {
+ sendAlert(player, BossbarAlert.of(message, BarColor.WHITE, 0, duration, soundCue));
+ }
+
+ /**
+ * Shows a colored alert with priority and an audio cue.
+ *
+ * @param player target player
+ * @param message title text
+ * @param color bar color
+ * @param priority precedence (highest first)
+ * @param duration lifespan
+ * @param soundCue optional audio cue
+ */
+ public static void sendAlert(
+ @NonNull Player player,
+ @NonNull String message,
+ @NonNull BarColor color,
+ int priority,
+ @NonNull Duration duration,
+ @Nullable SoundCue soundCue
+ ) {
+ sendAlert(player, BossbarAlert.of(message, color, priority, duration, soundCue));
+ }
+
+ /**
+ * Shows a fully configured alert.
+ *
+ * @param player target player
+ * @param alert alert to show
+ */
+ public static void sendAlert(@NonNull Player player, @NonNull BossbarAlert alert) {
+ service().sendAlert(player, alert);
+ }
+
+ /**
+ * Shows a fully configured alert to several players.
+ *
+ * @param players target players
+ * @param alert alert to show
+ */
+ public static void sendAlert(@NonNull Collection extends Player> players, @NonNull BossbarAlert alert) {
+ BossbarService service = service();
+ for (Player player : players) {
+ service.sendAlert(player, alert);
+ }
+ }
+
+ /**
+ * Shows a white alert to every online player.
+ *
+ * @param message title text
+ * @param duration lifespan
+ */
+ public static void broadcastAlert(@NonNull String message, @NonNull Duration duration) {
+ broadcastAlert(BossbarAlert.of(message, duration));
+ }
+
+ /**
+ * Shows a fully configured alert to every online player.
+ *
+ * @param alert alert to show
+ */
+ public static void broadcastAlert(@NonNull BossbarAlert alert) {
+ sendAlert(Bukkit.getOnlinePlayers(), alert);
+ }
+
+ /**
+ * Shows a countdown that drains with the time left (e.g. {@code "Rebooting in %ds"}).
+ *
+ * @param player target player
+ * @param format format string containing {@code %d}
+ * @param seconds countdown duration in seconds
+ */
+ public static void sendCountdown(@NonNull Player player, @NonNull String format, int seconds) {
+ sendCountdown(player, format, seconds, 0, null);
+ }
+
+ /**
+ * Shows a countdown that drains with the time left and plays a sound cue.
+ *
+ * @param player target player
+ * @param format format string containing {@code %d}
+ * @param seconds countdown duration in seconds
+ * @param soundCue optional audio cue
+ */
+ public static void sendCountdown(@NonNull Player player, @NonNull String format, int seconds, @Nullable SoundCue soundCue) {
+ sendCountdown(player, format, seconds, 0, soundCue);
+ }
+
+ /**
+ * Shows a countdown with a custom priority.
+ *
+ * @param player target player
+ * @param format format string containing {@code %d}
+ * @param seconds countdown duration in seconds
+ * @param priority precedence (highest first)
+ */
+ public static void sendCountdown(@NonNull Player player, @NonNull String format, int seconds, int priority) {
+ sendCountdown(player, format, seconds, priority, null);
+ }
+
+ /**
+ * Shows a countdown with a custom priority and audio cue.
+ *
+ * @param player target player
+ * @param format format string containing {@code %d}
+ * @param seconds countdown duration in seconds
+ * @param priority precedence (highest first)
+ * @param soundCue optional audio cue
+ */
+ public static void sendCountdown(@NonNull Player player, @NonNull String format, int seconds, int priority, @Nullable SoundCue soundCue) {
+ sendAlert(player, BossbarAlert.countdown(format, priority, seconds, soundCue));
+ }
+
+ /**
+ * Shows a countdown with a custom second formatter.
+ *
+ * @param player target player
+ * @param formatter function receiving the remaining whole seconds
+ * @param seconds countdown duration in seconds
+ */
+ public static void sendCountdown(@NonNull Player player, @NonNull IntFunction formatter, int seconds) {
+ sendCountdown(player, formatter, seconds, 0, null);
+ }
+
+ /**
+ * Shows a countdown with a custom second formatter and priority.
+ *
+ * @param player target player
+ * @param formatter function receiving the remaining whole seconds
+ * @param seconds countdown duration in seconds
+ * @param priority precedence (highest first)
+ */
+ public static void sendCountdown(@NonNull Player player, @NonNull IntFunction formatter, int seconds, int priority) {
+ sendCountdown(player, formatter, seconds, priority, null);
+ }
+
+ /**
+ * Shows a countdown with a custom second formatter, priority, and audio cue.
+ *
+ * @param player target player
+ * @param formatter function receiving the remaining whole seconds
+ * @param seconds countdown duration in seconds
+ * @param priority precedence (highest first)
+ * @param soundCue optional audio cue
+ */
+ public static void sendCountdown(
+ @NonNull Player player,
+ @NonNull IntFunction formatter,
+ int seconds,
+ int priority,
+ @Nullable SoundCue soundCue
+ ) {
+ sendAlert(player, BossbarAlert.countdown(formatter, BarColor.WHITE, priority, seconds, soundCue));
+ }
+
+ /**
+ * Registers a global section owned by the service's plugin, shown to every player for whom it is visible.
+ *
+ * @param section section to register
+ */
+ public static void register(@NonNull BossbarSection section) {
+ service().register(section);
+ }
+
+ /**
+ * Registers a global section owned by the given plugin. It is removed by
+ * {@link #unregisterAll(Plugin)} or when that plugin is disabled.
+ *
+ * @param plugin owning plugin
+ * @param section section to register
+ */
+ public static void register(@NonNull Plugin plugin, @NonNull BossbarSection section) {
+ service().register(plugin, section);
+ }
+
+ /**
+ * Registers a section for one player only.
+ *
+ * @param player target player
+ * @param section section to register
+ */
+ public static void register(@NonNull Player player, @NonNull BossbarSection section) {
+ service().register(player, section);
+ }
+
+ /**
+ * Unregisters a global section.
+ *
+ * @param sectionId section id
+ */
+ public static void unregister(@NonNull String sectionId) {
+ service().unregister(sectionId);
+ }
+
+ /**
+ * Unregisters a section registered for one player.
+ *
+ * @param player target player
+ * @param sectionId section id
+ */
+ public static void unregister(@NonNull Player player, @NonNull String sectionId) {
+ service().unregister(player, sectionId);
+ }
+
+ /**
+ * Unregisters every global section owned by a plugin.
+ *
+ * @param plugin owning plugin
+ */
+ public static void unregisterAll(@NonNull Plugin plugin) {
+ service().unregisterAll(plugin);
+ }
+
+ /**
+ * Forces an immediate render for a player instead of waiting for the next periodic pass.
+ *
+ * @param player target player
+ */
+ public static void update(@NonNull Player player) {
+ service().renderPlayer(player);
+ }
+
+ /**
+ * Clears a player's alerts, restoring the sections underneath.
+ *
+ * @param player target player
+ */
+ public static void clearAlerts(@NonNull Player player) {
+ service().clearAlerts(player);
+ }
+
+ /**
+ * Clears a player's alerts and player-level sections and removes every bar from their screen.
+ * Global sections reappear on the next render.
+ *
+ * @param player target player
+ */
+ public static void clear(@NonNull Player player) {
+ service().clear(player);
+ }
+
+ private static BossbarService service() {
+ BossbarService service = BossbarService.getInstance();
+ if (service != null) {
+ return service;
+ }
+ return initialize(PluginContext.get(), DEFAULT_PERIOD_TICKS);
+ }
+}
diff --git a/bedrock/src/main/java/fr/moussax/bedrock/ui/bossbar/BossbarAlert.java b/bedrock/src/main/java/fr/moussax/bedrock/ui/bossbar/BossbarAlert.java
new file mode 100644
index 00000000..03e7e6c9
--- /dev/null
+++ b/bedrock/src/main/java/fr/moussax/bedrock/ui/bossbar/BossbarAlert.java
@@ -0,0 +1,427 @@
+package fr.moussax.bedrock.ui.bossbar;
+
+import fr.moussax.bedrock.sound.SoundCue;
+import org.bukkit.boss.BarColor;
+import org.bukkit.boss.BarStyle;
+import org.bukkit.entity.Player;
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
+import java.time.Duration;
+import java.util.Objects;
+import java.util.function.Function;
+import java.util.function.IntFunction;
+import java.util.function.ToDoubleFunction;
+
+/**
+ * An immutable specification for a temporary boss bar with a lifespan.
+ *
+ *
Alerts stack above persistent {@link BossbarSection}s, highest priority first. An
+ * {@linkplain #exclusive(String, int, Duration) exclusive} alert hides all persistent sections
+ * while it is active. When an alert expires, the bars underneath are restored automatically.
+ *
+ *
Unless a progress function is given, the bar drains from full to empty over the alert's
+ * lifespan. When queued into a composer, each viewing player's audio cue plays once.
+ */
+public record BossbarAlert(Function titleSupplier,
+ @Nullable IntFunction<@Nullable String> countdownFormatter,
+ @Nullable ToDoubleFunction progressSupplier, BarColor color, BarStyle style,
+ int priority, boolean exclusive, Duration duration,
+ @Nullable SoundCue soundCue) implements Comparable {
+
+ /**
+ * Constructs an alert specification with explicit parameters.
+ *
+ * @param titleSupplier title function; {@code null} hides the bar for that player
+ * @param countdownFormatter countdown formatter receiving remaining seconds, or {@code null}
+ * @param progressSupplier fill ratio function, or {@code null} to drain over the lifespan
+ * @param color bar color
+ * @param style bar style
+ * @param priority precedence (highest first)
+ * @param exclusive whether persistent sections are hidden while active
+ * @param duration lifespan duration
+ * @param soundCue optional sound played once per player on first render
+ */
+ public BossbarAlert(
+ @NonNull Function titleSupplier,
+ @Nullable IntFunction<@Nullable String> countdownFormatter,
+ @Nullable ToDoubleFunction progressSupplier,
+ @NonNull BarColor color,
+ @NonNull BarStyle style,
+ int priority,
+ boolean exclusive,
+ @NonNull Duration duration,
+ @Nullable SoundCue soundCue
+ ) {
+ this.titleSupplier = Objects.requireNonNull(titleSupplier, "titleSupplier cannot be null");
+ this.countdownFormatter = countdownFormatter;
+ this.progressSupplier = progressSupplier;
+ this.color = Objects.requireNonNull(color, "color cannot be null");
+ this.style = Objects.requireNonNull(style, "style cannot be null");
+ this.priority = priority;
+ this.exclusive = exclusive;
+ this.duration = Objects.requireNonNull(duration, "duration cannot be null");
+ this.soundCue = soundCue;
+ }
+
+ /**
+ * Constructs an alert specification with millisecond duration.
+ */
+ public BossbarAlert(
+ @NonNull Function titleSupplier,
+ @Nullable ToDoubleFunction progressSupplier,
+ @NonNull BarColor color,
+ @NonNull BarStyle style,
+ int priority,
+ boolean exclusive,
+ long durationMillis,
+ @Nullable SoundCue soundCue
+ ) {
+ this(
+ titleSupplier,
+ null,
+ progressSupplier,
+ color,
+ style,
+ priority,
+ exclusive,
+ Duration.ofMillis(Math.max(0L, durationMillis)),
+ soundCue
+ );
+ }
+
+ /**
+ * Creates a default-priority, white, draining alert.
+ *
+ * @param title title text
+ * @param duration lifespan
+ * @return the alert
+ */
+ @NonNull
+ public static BossbarAlert of(@NonNull String title, @NonNull Duration duration) {
+ return of(title, BarColor.WHITE, 0, duration, null);
+ }
+
+ /**
+ * Creates a default-priority, white, draining alert with an audio cue.
+ *
+ * @param title title text
+ * @param duration lifespan
+ * @param soundCue optional sound cue
+ * @return the alert
+ */
+ @NonNull
+ public static BossbarAlert of(@NonNull String title, @NonNull Duration duration, @Nullable SoundCue soundCue) {
+ return of(title, BarColor.WHITE, 0, duration, soundCue);
+ }
+
+ /**
+ * Creates a white draining alert with the given priority.
+ *
+ * @param title title text
+ * @param priority precedence (highest first)
+ * @param duration lifespan
+ * @return the alert
+ */
+ @NonNull
+ public static BossbarAlert of(@NonNull String title, int priority, @NonNull Duration duration) {
+ return of(title, BarColor.WHITE, priority, duration, null);
+ }
+
+ /**
+ * Creates a draining alert.
+ *
+ * @param title title text
+ * @param color bar color
+ * @param priority precedence (highest first)
+ * @param duration lifespan
+ * @return the alert
+ */
+ @NonNull
+ public static BossbarAlert of(@NonNull String title, @NonNull BarColor color, int priority, @NonNull Duration duration) {
+ return of(title, color, priority, duration, null);
+ }
+
+ /**
+ * Creates a draining alert with an audio cue.
+ *
+ * @param title title text
+ * @param color bar color
+ * @param priority precedence (highest first)
+ * @param duration lifespan
+ * @param soundCue optional sound cue
+ * @return the alert
+ */
+ @NonNull
+ public static BossbarAlert of(
+ @NonNull String title,
+ @NonNull BarColor color,
+ int priority,
+ @NonNull Duration duration,
+ @Nullable SoundCue soundCue
+ ) {
+ Objects.requireNonNull(title, "title cannot be null");
+ return of(_ -> title, color, priority, duration, soundCue);
+ }
+
+ /**
+ * Creates a draining alert with a per-player title.
+ *
+ * @param titleSupplier title function; {@code null} hides the bar for that player
+ * @param color bar color
+ * @param priority precedence (highest first)
+ * @param duration lifespan
+ * @return the alert
+ */
+ @NonNull
+ public static BossbarAlert of(
+ @NonNull Function titleSupplier,
+ @NonNull BarColor color,
+ int priority,
+ @NonNull Duration duration
+ ) {
+ return of(titleSupplier, color, priority, duration, null);
+ }
+
+ /**
+ * Creates a draining alert with a per-player title and audio cue.
+ *
+ * @param titleSupplier title function; {@code null} hides the bar for that player
+ * @param color bar color
+ * @param priority precedence (highest first)
+ * @param duration lifespan
+ * @param soundCue optional sound cue
+ * @return the alert
+ */
+ @NonNull
+ public static BossbarAlert of(
+ @NonNull Function titleSupplier,
+ @NonNull BarColor color,
+ int priority,
+ @NonNull Duration duration,
+ @Nullable SoundCue soundCue
+ ) {
+ Objects.requireNonNull(duration, "duration cannot be null");
+ return new BossbarAlert(titleSupplier, null, null, color, BarStyle.SOLID, priority, false, duration, soundCue);
+ }
+
+ /**
+ * Creates a draining alert that hides all persistent sections while it is active.
+ *
+ * @param title title text
+ * @param duration lifespan
+ * @return the alert
+ */
+ @NonNull
+ public static BossbarAlert exclusive(@NonNull String title, @NonNull Duration duration) {
+ return exclusive(title, 0, duration, null);
+ }
+
+ /**
+ * Creates an exclusive draining alert with an audio cue.
+ *
+ * @param title title text
+ * @param duration lifespan
+ * @param soundCue optional sound cue
+ * @return the alert
+ */
+ @NonNull
+ public static BossbarAlert exclusive(@NonNull String title, @NonNull Duration duration, @Nullable SoundCue soundCue) {
+ return exclusive(title, 0, duration, soundCue);
+ }
+
+ /**
+ * Creates a draining alert that hides all persistent sections while it is active.
+ *
+ * @param title title text
+ * @param priority precedence among alerts (highest first)
+ * @param duration lifespan
+ * @return the alert
+ */
+ @NonNull
+ public static BossbarAlert exclusive(@NonNull String title, int priority, @NonNull Duration duration) {
+ return exclusive(title, priority, duration, null);
+ }
+
+ /**
+ * Creates a draining alert that hides all persistent sections with an audio cue.
+ *
+ * @param title title text
+ * @param priority precedence among alerts (highest first)
+ * @param duration lifespan
+ * @param soundCue optional sound cue
+ * @return the alert
+ */
+ @NonNull
+ public static BossbarAlert exclusive(
+ @NonNull String title,
+ int priority,
+ @NonNull Duration duration,
+ @Nullable SoundCue soundCue
+ ) {
+ Objects.requireNonNull(title, "title cannot be null");
+ Objects.requireNonNull(duration, "duration cannot be null");
+ return new BossbarAlert(_ -> title, null, null, BarColor.WHITE, BarStyle.SOLID, priority, true, duration, soundCue);
+ }
+
+ /**
+ * Creates a countdown alert. The title is derived from remaining wall-clock time and the bar drains alongside it.
+ *
+ * @param formatter function receiving the remaining whole seconds and returning the title
+ * @param color bar color
+ * @param priority precedence (highest first)
+ * @param seconds initial countdown duration in seconds
+ * @return the alert
+ */
+ @NonNull
+ public static BossbarAlert countdown(
+ @NonNull IntFunction<@Nullable String> formatter,
+ @NonNull BarColor color,
+ int priority,
+ int seconds
+ ) {
+ return countdown(formatter, color, priority, seconds, null);
+ }
+
+ /**
+ * Creates a countdown alert with an audio cue.
+ *
+ * @param formatter function receiving the remaining whole seconds and returning the title
+ * @param color bar color
+ * @param priority precedence (highest first)
+ * @param seconds initial countdown duration in seconds
+ * @param soundCue optional sound cue
+ * @return the alert
+ */
+ @NonNull
+ public static BossbarAlert countdown(
+ @NonNull IntFunction<@Nullable String> formatter,
+ @NonNull BarColor color,
+ int priority,
+ int seconds,
+ @Nullable SoundCue soundCue
+ ) {
+ Objects.requireNonNull(formatter, "formatter cannot be null");
+ Duration duration = Duration.ofSeconds(Math.max(0, seconds));
+ return new BossbarAlert(_ -> null, formatter, null, color, BarStyle.SOLID, priority, false, duration, soundCue);
+ }
+
+ /**
+ * Creates a white countdown alert from a format string.
+ *
+ * @param format string format containing {@code %d} (e.g. {@code "Rebooting in %ds"})
+ * @param priority precedence (highest first)
+ * @param seconds initial countdown duration in seconds
+ * @return the alert
+ */
+ @NonNull
+ public static BossbarAlert countdown(@NonNull String format, int priority, int seconds) {
+ return countdown(format, priority, seconds, null);
+ }
+
+ /**
+ * Creates a white countdown alert from a format string with an audio cue.
+ *
+ * @param format string format containing {@code %d} (e.g. {@code "Rebooting in %ds"})
+ * @param priority precedence (highest first)
+ * @param seconds initial countdown duration in seconds
+ * @param soundCue optional sound cue
+ * @return the alert
+ */
+ @NonNull
+ public static BossbarAlert countdown(@NonNull String format, int priority, int seconds, @Nullable SoundCue soundCue) {
+ Objects.requireNonNull(format, "format cannot be null");
+ return countdown(format::formatted, BarColor.WHITE, priority, seconds, soundCue);
+ }
+
+ /**
+ * Returns a copy of this alert with the specified audio cue.
+ *
+ * @param soundCue sound cue to play on initial render
+ * @return new alert instance
+ */
+ @NonNull
+ public BossbarAlert withSound(@Nullable SoundCue soundCue) {
+ return new BossbarAlert(titleSupplier, countdownFormatter, progressSupplier, color, style, priority, exclusive, duration, soundCue);
+ }
+
+ /**
+ * Returns a copy of this alert with the specified bar color.
+ *
+ * @param color new bar color
+ * @return new alert instance
+ */
+ @NonNull
+ public BossbarAlert withColor(@NonNull BarColor color) {
+ return new BossbarAlert(titleSupplier, countdownFormatter, progressSupplier, color, style, priority, exclusive, duration, soundCue);
+ }
+
+ /**
+ * Returns a copy of this alert with the specified bar style.
+ *
+ * @param style new bar style
+ * @return new alert instance
+ */
+ @NonNull
+ public BossbarAlert withStyle(@NonNull BarStyle style) {
+ return new BossbarAlert(titleSupplier, countdownFormatter, progressSupplier, color, style, priority, exclusive, duration, soundCue);
+ }
+
+ /**
+ * Returns a copy of this alert with the specified precedence priority.
+ *
+ * @param priority precedence priority
+ * @return new alert instance
+ */
+ @NonNull
+ public BossbarAlert withPriority(int priority) {
+ return new BossbarAlert(titleSupplier, countdownFormatter, progressSupplier, color, style, priority, exclusive, duration, soundCue);
+ }
+
+ /**
+ * Returns a copy of this alert with the specified exclusivity flag.
+ *
+ * @param exclusive whether sections should be hidden
+ * @return new alert instance
+ */
+ @NonNull
+ public BossbarAlert exclusive(boolean exclusive) {
+ return new BossbarAlert(titleSupplier, countdownFormatter, progressSupplier, color, style, priority, exclusive, duration, soundCue);
+ }
+
+ /**
+ * Evaluates the title for the given player.
+ *
+ * @param player viewing player
+ * @return title, or {@code null} to hide the bar for that player
+ */
+ public @Nullable String title(@Nullable Player player) {
+ if (countdownFormatter != null) {
+ int seconds = (int) Math.ceil(duration.toMillis() / 1000.0);
+ return countdownFormatter.apply(seconds);
+ }
+ return titleSupplier.apply(player);
+ }
+
+ /**
+ * Evaluates the fill ratio for the given player without an active session context.
+ *
+ * @param player viewing player
+ * @return progress in {@code [0.0, 1.0]}
+ */
+ public double progress(@Nullable Player player) {
+ if (progressSupplier != null) {
+ return progressSupplier.applyAsDouble(player);
+ }
+ return 1.0;
+ }
+
+ public long durationMillis() {
+ return duration.toMillis();
+ }
+
+ @Override
+ public int compareTo(@NonNull BossbarAlert other) {
+ return Integer.compare(other.priority, this.priority);
+ }
+}
diff --git a/bedrock/src/main/java/fr/moussax/bedrock/ui/bossbar/BossbarComposer.java b/bedrock/src/main/java/fr/moussax/bedrock/ui/bossbar/BossbarComposer.java
new file mode 100644
index 00000000..b9092623
--- /dev/null
+++ b/bedrock/src/main/java/fr/moussax/bedrock/ui/bossbar/BossbarComposer.java
@@ -0,0 +1,395 @@
+package fr.moussax.bedrock.ui.bossbar;
+
+import fr.moussax.bedrock.sound.SoundCue;
+import org.bukkit.Bukkit;
+import org.bukkit.boss.BarColor;
+import org.bukkit.boss.BarFlag;
+import org.bukkit.boss.BarStyle;
+import org.bukkit.boss.BossBar;
+import org.bukkit.entity.Player;
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
+import java.util.*;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.function.IntFunction;
+import java.util.function.Supplier;
+
+/**
+ * Manages one player's boss bars: per-player sections, modal alerts, and the
+ * global sections shared by every player.
+ *
+ *
Each visible section or alert is backed by its own Bukkit {@link BossBar}, because a Bukkit
+ * bar has a single title and progress for its whole audience while suppliers are per player.
+ * Bars are only touched when their content changed, and are re-added in order only when the
+ * visible stack order changes.
+ *
+ *
{@link #render(Player)}, {@link #clear()} and {@link #detach()} must run on the main thread.
+ * Registration and alert methods are thread-safe.
+ */
+public final class BossbarComposer {
+
+ /**
+ * Prevents redundant packet dispatch from floating-point jitter while preserving smooth sub-frame lerping.
+ */
+ private static final double PROGRESS_EPSILON = 1e-5;
+
+ private final Supplier