Scoreboards & tab list
Sidebar is a scoreboard sidebar built from Adventure components, so you get MiniMessage, hex colours and per-line formatting without touching teams or entry strings yourself. Lines are diffed on every update, so only what actually changed gets sent to the client.
class MySidebar : Sidebar() {
override val updateIntervalTicks = 20L
override fun title(player: Player): Component = "<s_blue><b>MyServer".mm()
override fun lines(player: Player): List<Component> = listOf(
Component.empty(),
"<white>Kills: <green>${player.getStatistic(Statistic.PLAYER_KILLS)}".mm(),
"<white>Online: <green>${Bukkit.getOnlinePlayers().size}".mm()
)
override fun shouldShow(player: Player): Boolean = player.world.name != "lobby"
}
Then show it:
SidebarManager.show(player, sidebar)
SidebarManager.refresh(player) // force an update now
SidebarManager.hide(player)
One instance can serve every player - the manager keeps the per-player state - and it cleans up on quit and on plugin disable by itself. By default it refuses to take over a scoreboard that another plugin already owns and warns instead; set SidebarManager.takeOverExisting = true if you want it to win anyway. Boards are capped at SidebarManager.MAX_LINES (15) lines.
Async sidebars
If a value on your board is expensive - a balance behind a database query, a rank from another plugin's cache - reading it in lines() means doing that work on the main thread every update, for every player. AsyncSidebar splits it in two: fetch runs off the main thread and returns a snapshot, title and lines run on the main thread with that snapshot.
class MySidebar : AsyncSidebar<MySidebar.Data>() {
data class Data(val balance: String, val rank: Component)
override val updateIntervalTicks = 20L
// Off the main thread - only touch thread-safe things here, not the Bukkit API.
override fun fetch(player: Player) = Data(
balance = economy.formatted(player.uniqueId),
rank = ranks.prefixOf(player.uniqueId)
)
// Main thread - statistics, inventories and the world are safe here.
override fun lines(player: Player, data: Data): List<Component> = listOf(
"<white>Balance: <green>${data.balance}".mm(),
"<white>Rank: ".mm().append(data.rank),
"<white>Kills: <green>${player.getStatistic(Statistic.PLAYER_KILLS)}".mm()
)
override fun title(player: Player, data: Data): Component = "<s_blue><b>MyServer".mm()
}
Fetches never overlap - if one is still running when the next is due, that tick is skipped - and lines is never called before the first snapshot lands, so you get a blank board for a moment rather than a board full of zeroes. Use fetchIntervalTicks if you want to fetch less often than you render.
Text that comes from outside your plugin (rank prefixes, nicknames) is usually legacy-coded rather than MiniMessage, so build it into a component and append it instead of interpolating it into a .mm() string - otherwise the colour codes show up as literal text.
Tab list
The tab list works with one adapter for the whole server. Set it once and every player online, and everyone who joins later, gets it:
object MyTablist : Tablist() {
override fun header(viewer: Player) = "<s_blue><b>MyServer".mm()
override fun footer(viewer: Player) = "<gray>Online: <white>${Bukkit.getOnlinePlayers().size}".mm()
// How a player shows up in everyone's tab list, null keeps their normal name
override fun name(target: Player) = "<gray>[${rank(target)}] <white>${target.name}".mm()
// Higher shows first
override fun order(target: Player) = rankWeight(target)
}
TablistManager.use(MyTablist)
Header and footer are per viewer. Name and order are per player and the same for everyone, that's how Minecraft's tab list works. Order uses Paper's list order, not scoreboard teams, so it never fights with sidebars.
It's built to stay out of the server's way:
- Spread out. With the default 20 tick interval about 1/20th of the players update each tick, instead of everyone in the same tick.
- Only changes are sent. Header/footer, name and order are compared with what the player already has, so an unchanged tab list costs no packets.
- Folia safe. Every player is updated on their own region thread.
If the header or footer needs slow data, use AsyncTablist. fetch runs off the main thread, and rendering always uses the latest result, so a slow database never holds up a tick:
object StatsTablist : AsyncTablist<Stats>() {
override val fetchIntervalTicks = 100L
override fun fetch(viewer: Player): Stats = StatsRepository.loadBlocking(viewer.uniqueId)
override fun header(viewer: Player, data: Stats) = "<s_blue>Kills: <white>${data.kills}".mm()
}
TablistManager.refresh(player) / refreshAll() update right away (e.g. after a rank change), and TablistManager.clear() removes it and resets everyone.