HomeJavaModsScoreboardAPI-FOLIA
ScoreboardAPI-FOLIA
ModsJava

ScoreboardAPI-FOLIA

Packet-based scoreboard, nametag & tab API for Paper and Folia, per-player sidebars, MiniMessage, animations, zero depe

⬇ Download on Hangar
# FoliaBoard A **Folia-native, packet-level scoreboard API** for Paper & Folia. It removes the single hardest part of scoreboards on Folia — knowing *which thread* may touch a player's board and not racing when they move between regions — and gives you a fluent, MiniMessage-first API where **every call is safe from any thread**. ```java FoliaBoard board = FoliaBoard.create(this); board.createBoard(player) .placeholders(true) .title("MY SERVER") .blankLine() .lines("Player: %player%", "Online: %online%", "Ping: %ping%ms") .blankLine() .line("play.myserver.net") .build(); ``` - **Zero threading work.** Call from the main thread, an async task, a region thread — anywhere. - **Zero third-party deps.** Packets are built and sent directly. No ProtocolLib, no MegaVex. - **MiniMessage everywhere.** Any `String` argument is parsed as MiniMessage (gradients, hover, click…). - **Runs on Paper too.** The same jar works on plain Paper (everything just runs on the main thread). - **Validated live on Folia 1.21.11.** --- ## Table of contents 1. [Why it exists](#why-it-exists) 2. [Requirements](#requirements) 3. [Installation](#installation) 4. [Quick start](#quick-start) 5. [Sidebars](#sidebars) 6. [Layout profiles](#layout-profiles) 7. [Nametags](#nametags) 8. [Below-name & tab-list numbers](#below-name--tab-list-numbers) 9. [Tab-list header & footer](#tab-list-header--footer) 10. [Number formats](#number-formats) 11. [Animations](#animations) 12. [Placeholders](#placeholders) 13. [MiniMessage & text](#minimessage--text) 14. [Events & hooks](#events--hooks) 15. [Async utilities](#async-utilities) 16. [Threading model](#threading-model) 17. [Performance](#performance) 18. [Lifecycle, cleanup & `/reload`](#lifecycle-cleanup--reload) 19. [API reference](#api-reference) 20. [Version support & the honest caveat](#version-support--the-honest-caveat) 21. [Building from source](#building-from-source) --- ## Why it exists Packet scoreboard libraries are excellent but warn that their board/team objects are **not thread-safe**. On Folia that warning is the whole game: there is no main thread, players tick on **region threads**, they **migrate** between regions, and join/quit fires on region threads. FoliaBoard's core idea: confine every mutation of a player's board to that player's [`EntityScheduler`](https://docs.papermc.io/folia/reference/region-logic) — Folia's scheduler that follows the entity across regions and runs its tasks strictly sequentially. That yields thread-safety with **zero locks**, and it's all hidden. You describe *what* to show; FoliaBoard handles *where* and *when* it's safe to send the packets. --- ## Requirements - **Paper or Folia 1.20.6+** (modern per-score display components). - **Java 21**. --- ## Installation Two ways to use it (see [`INSTALLATION.md`](INSTALLATION.md) for the full guide): - **Shade** `foliaboard-core` into your plugin (self-contained; ships **no `plugin.yml`** so it can't clobber yours) — shown below. - **Install the standalone `FoliaBoard` plugin** (the Modrinth download) and `softdepend: [FoliaBoard]` — one shared jar for all your plugins; then `ScoreboardAPI.get()...`. **1. Depend on the core library:** ```xml jitpack.io https://jitpack.io com.github.SpirtySprite.ScoreboardAPI-FOLIA foliaboard-core 1.0.3 ``` **2. Shade + relocate it** (so two plugins bundling it can't collide): ```xml org.apache.maven.plugins maven-shade-plugin 3.6.0 packageshade net.foliaboard com.yourplugin.libs.foliaboard ``` **3. Mark your plugin Folia-ready** — required or it won't load on Folia: ```yaml name: YourPlugin main: com.yourplugin.YourPlugin version: 1.0.0 api-version: '1.20' folia-supported: true softdepend: [PlaceholderAPI] # optional; enables the placeholder bridge ``` --- ## Quick start Two styles — pick one. **A. Instance (recommended):** ```java public final class YourPlugin extends JavaPlugin { private FoliaBoard board; @Override public void onEnable() { board = FoliaBoard.create(this); } @Override public void onDisable() { if (board != null) board.close(); } } ``` **B. Static handle** (if you'd rather not pass the instance around): ```java @Override public void onEnable() { ScoreboardAPI.init(this); } @Override public void onDisable() { ScoreboardAPI.shutdown(); } // anywhere: ScoreboardAPI.createBoard(player).title("Hi").line("Welcome!").build(); ``` All examples below use a `board` (a `FoliaBoard`); with the static handle just write `ScoreboardAPI` or `ScoreboardAPI.get()`. Add `import static net.foliaboard.api.text.Text.mini;` (or use `Text.mini(...)`) when you want a `Component` from a MiniMessage string. --- ## Sidebars The right-hand board. There are four ways to drive one — from most convenient to most manual. ### 1. Fluent builder ```java board.createBoard(player) .placeholders(true) // resolve %built-ins% + PlaceholderAPI on strings .refreshEvery(4) // ticks between auto-refreshes of dynamic content .title("MY SERVER") .blankLine() .line("Player: %player%") .lines("Kills: 0", "Deaths: 0") // several at once .blankLine() .line("play.myserver.net") .build(); // returns the live Sidebar ``` If any content is a placeholder string, an `Animation`, or a supplier, the board is **dynamic** and FoliaBoard auto-refreshes it on the player's own thread — you never write a scheduler. Everything else is painted once. Titles and lines accept `String` (MiniMessage), `Component`, or `Animation`. Lines may also carry a [number format](#number-formats). ### 2. Manual control Full control, still safe from any thread: ```java Sidebar sb = board.sidebar(player); // get or create this player's sidebar sb.title(mini("Kit Selector")); sb.line(0, mini("Coins: 1500")); sb.line(1, mini("Kills"), NumberFormat.fixed(mini("12"))); // per-line number sb.visible(false); // hide without discarding sb.visible(true); sb.clearLines(); sb.close(); // remove entirely (auto on quit) // read-back: Component title = sb.title(); List lines = sb.lines(); int count = sb.lineCount(); ``` Only genuinely-changed lines produce packets, so frequent updates never flicker. ### 3. Global provider — one description for everyone ```java board.setGlobalSidebar(SidebarProvider.of( p -> mini("MY SERVER"), p -> List.of(mini("Online: " + Bukkit.getOnlinePlayers().size())), 10)); // refresh every 10 ticks ``` Or implement the interface for `visible(player)` control. FoliaBoard attaches a sidebar to every player, refreshes it per player on the right thread, and cleans up on quit. ### 4. Global layout — same shape, per-player content ```java board.setGlobalSidebar(Layout.named("main", b -> b .placeholders(true) .title("MY SERVER") .line("Rank: %rank%"))); ``` See [Layout profiles](#layout-profiles) for switching between several. > **One driver per board.** A global provider/layout yields automatically to any explicit > `createBoard(...).build()` or `applyLayout(...)` for that player, so they never fight. --- ## Layout profiles A **layout** is a reusable, named board template you can apply to any player and switch between instantly (lobby ↔ minigame, per-world boards, …). It's just a recorded recipe of builder calls. ```java Layout lobby = Layout.named("lobby", b -> b .placeholders(true) .title(Animations.cycle(Duration.ofMillis(400), mini("LOBBY"), mini("LOBBY"))) .blankLine() .line("Rank: %rank%") .line("Coins: %coins%")); Layout minigame = Layout.named("minigame", b -> b .title("SKYWARS") .line("Kills", NumberFormat.fixed(mini("0")))); board.registerLayout(lobby).registerLayout(minigame); board.applyLayout(player, "lobby"); // switch instantly, any time board.setWorldLayout("minigame_world", "minigame"); // auto-applied on join & world change board.unregisterLayout("minigame"); // remove a layout ``` - Applying a layout **replaces** the previous board cleanly (no stale leftover lines). - Leaving a world-layout world for one with no layout **clears** the board. - Fires a cancellable [`LayoutApplyEvent`](#events--hooks) so other plugins can override per rank/region. --- ## Nametags Control the text around a player's name (above their head **and** in the tab list): prefix, suffix, name colour, visibility, collision, and tab-list sort — sent as a scoreboard team, so it doesn't fight Bukkit teams. Updates use team-*modify*, so they never flicker. ```java board.createNametag(player) .prefix("[VIP] ") .suffix(" ★") .color(NamedTextColor.YELLOW) .tabSort(10) // lower sorts higher in the tab list (0–9999) .nametagVisibility(Nametag.Visibility.ALWAYS) .collision(Nametag.Collision.NEVER) .apply(); ``` ### Per-viewer nametags Show a target's name differently to different viewers — classic ally/enemy colouring: ```java board.createNametag(player) .prefix("[VIP] ") .perViewer((viewer, target, style) -> { if (areAllies(viewer, target)) style.color(NamedTextColor.GREEN).prefix("✦ "); else style.color(NamedTextColor.RED).prefix("☠ "); }) .apply(); ``` The resolver runs per viewer on that viewer's thread; it starts from the global defaults. Call `.apply()` again whenever relationships change (e.g. on a timer, or on a team-join event). --- ## Below-name & tab-list numbers Shared objectives that show a number below every player's name, or beside their tab-list entry. ```java board.belowName().title(mini("❤")).score(player, 20); // hearts below the name board.tabList().score(player, player.getPing()); // ping in the tab list board.belowName().remove(player.getName()); // remove one entry board.belowName().hide(); // hide for everyone… board.belowName().show(); // …and bring it back ``` ### Per-viewer numbers ```java board.tabList().scoreFor(viewer, target.getName(), value); // only `viewer` sees this value board.tabList().removeFor(viewer, target.getName()); // revert to the shared value ``` Quit players are cleaned up automatically (no leaks, no phantom scores). --- ## Tab-list header & footer ```java board.tabHeaderFooter(player, "MY SERVER", "Online: " + Bukkit.getOnlinePlayers().size()); board.clearTabHeaderFooter(player); ``` Accepts `String` (MiniMessage) or `Component`. Sent on the player's region thread. ### Tab-list entry styling & sorting Style how a player appears **in the tab list**, independently of their above-head nametag, and sort the list — **with no scoreboard team**, so it doesn't conflict with other team-based plugins. ```java board.tabName(player, "★ " + player.getName()); // tab prefix ≠ above-head prefix board.tabOrder(player, staff ? 100 : 0); // higher sorts higher (Paper 1.21.2+) board.resetTabName(player); // back to the vanilla name if (!board.tabOrderSupported()) { /* pre-1.21.2: use nametag tabSort instead */ } ``` - **Tab vs. above-head are now separate.** The above-head prefix comes from a [nametag](#nametags) (a team); the tab prefix comes from `tabName(...)` (no team). Use either or both. - **Flicker-free, dynamic sorting.** `tabOrder(...)` changes instantly with no team-name trick. (On 1.21.1 and older, fall back to nametag `tabSort`.) - **Team-conflict friendly.** Because tab styling needs no team, a server that already runs a team-based nametag/prefix plugin can use FoliaBoard purely for the tab list (and sidebars) without fighting over teams. Above-head prefixes still require a team — that's a vanilla limitation — so simply don't create FoliaBoard nametags if another plugin owns the above-head text. `tabName`/`tabOrder` are per-target (shown the same to everyone), built on stable Paper API. ### Per-viewer tab names To show a target a *different* tab name to *different* viewers, use the packet-level API: ```java if (board.perViewerTabSupported()) { // 1.20.6+ with the player-info packet board.tabNameFor(viewer, target, "ENEMY " + target.getName()); board.resetTabNameFor(viewer, target); // back to default } ``` This is a **manual** send (no automatic lifecycle): re-apply it when you need it, e.g. on the viewer's join or after the server resends player info. It's built on `ClientboundPlayerInfoUpdatePacket` and **fails safe** — if the server build doesn't support it, `perViewerTabSupported()` returns false and the calls no-op rather than erroring. --- ## Number formats Control the red score number the client draws on the right of each entry (1.20.3+). Sidebars hide it by default; override per line or on shared objectives. ```java NumberFormat.blank(); // hide it (sidebar default) NumberFormat.fixed(mini("✖")); // replace it with any component NumberFormat.styled(Style.style(NamedTextColor.GOLD)); // keep the number, restyle it NumberFormat.defaultFormat(); // the vanilla red number board.sidebar(player).line(0, mini("Kills"), NumberFormat.fixed(mini("12"))); ``` --- ## Animations Self-timed — no ticking or registration. Call `current()` and return it; the frame is derived from the clock. ```java Animation title = Animations.cycle(Duration.ofMillis(400), mini("HUB"), mini("HUB")); Animation marquee = Animations.scrollText(Duration.ofMillis(150), "welcome to the server!", 24, TextColor.color(0x8AB4F8)); Animation pulse = Animations.pulseColor(Duration.ofSeconds(2), "EVENT LIVE", TextColor.color(0xff0000), TextColor.color(0xffff00)); Animation typed = Animations.typewriter(Duration.ofMillis(80), Component.text("Loading…")); // use directly in a builder / layout: board.createBoard(player).title(title).line(marquee).build(); ``` `cycle`, `scrollText`, `pulseColor`, and `typewriter` are code-point safe (won't split emoji). Animations are global wall-clock phase (every player sees the same frame at the same instant). --- ## Placeholders A fast engine that replaces `%tokens%` using, in order: your resolvers → built-ins → PlaceholderAPI (if installed; bridged reflectively, no hard dependency). ```java board.placeholders().register("rank", p -> p.isOp() ? "Admin" : "Member"); board.placeholders().register("coins", p -> economy.balance(p)); String text = board.placeholders().apply(player, "Rank: %rank%"); // -> "Rank: Admin" Component c = board.placeholders().component(player, "Rank: %rank%"); // MiniMessage + %papi% ``` **Built-ins:** `%player%` / `%player_name%` / `%name%`, `%displayname%`, `%world%`, `%online%`, `%max_players%`, `%ping%`, `%health%`, `%x%` / `%y%` / `%z%`. **Injection-safe:** in `component(...)`, placeholder *values* are escaped before parsing, so a value like a display name containing `` or a PAPI value with `` renders literally and can't inject formatting or click events into your board. `%built-ins%` such as `%ping%` are cheapest read on the player's own thread — the builder/provider already do that for you. --- ## MiniMessage & text `Text` is the one-stop helper. ```java Component c = Text.mini("hello"); Component tag = Text.mini("Click!'>Spawn"); String mm = Text.toMini(someComponent); // round-trip back to a string ``` Every `String` argument across the API goes through MiniMessage, so you rarely need `Text` directly. --- ## Events & hooks **Line processor** — rewrite every line/title of every board just before it's sent: ```java board.addLineProcessor((viewer, index, line) -> index == LineProcessor.TITLE ? line : line.decoration(TextDecoration.ITALIC, false)); // e.g. kill stray italics ``` **Bukkit events:** ```java @EventHandler public void onCreate(SidebarCreateEvent e) { getLogger().info("Board created for " + e.getPlayer().getName()); } @EventHandler public void onLayout(LayoutApplyEvent e) { // cancellable + swappable if (e.getPlayer().hasPermission("vip")) e.setLayout(board.layout("vip_lobby")); } ``` Both fire on the player's region thread (synchronous Folia-safe events). --- ## Async utilities Folia-safe helpers for *your* surrounding work (FoliaBoard's own calls are already thread-safe): ```java AsyncUtil.async(plugin, () -> { // off any game thread int coins = db.loadCoins(uuid); AsyncUtil.onPlayer(plugin, player, () -> // hop to the player's region thread board.createBoard(player).line("Coins: " + coins).build()); }); AsyncUtil.asyncLater(plugin, task, Duration.ofSeconds(5)); AsyncUtil.global(plugin, () -> { /* global game state */ }); boolean folia = AsyncUtil.isFolia(); ``` --- ## Threading model - **Public API is callable from any thread.** Mutations to a player's board are queued and applied on that player's region thread, in order, so nothing races. - **On Paper** (non-Folia) everything runs on the main thread — the same code, no branches. - **You never schedule anything** for scoreboard work. For your own logic, use `AsyncUtil`. Internally: each `Sidebar` keeps *desired* state (written under a lock from any thread) and *sent* state (touched only on the region thread inside a debounced flush that diffs and emits minimal packets). --- ## Performance FoliaBoard is built to stay cheap even with many players and fast, animated boards: - **Minimal packets.** Sidebars diff desired-vs-sent state and send only changed lines. Below-name/tab score updates skip the broadcast entirely when the value is unchanged. - **No re-parsing.** Dynamic placeholder lines cache their parsed MiniMessage and only re-parse when the resolved string actually changes. - **Cheap conversions.** The (very common) empty component — blank spacer lines, empty titles — is converted to its vanilla form once and reused. Score-holder names are interned. - **Fast send path.** Packets go out through cached `MethodHandle`s (with a reflection fallback). - **No busy loops.** Nothing polls; work is event- and scheduler-driven, and refresh loops stop the instant a board closes or a player leaves. Practical guidance: pick a `refreshEvery(...)` that matches your content — `2–4` ticks for smooth animations, `10–20` for mostly-static boards. Static content isn't refreshed at all. ### Observability `board.stats()` returns a snapshot for profiling TPS impact: ```java FoliaBoardStats s = board.stats(); // s.totalPackets(), s.providerRefreshes(), s.activeSidebars(), s.activeNametags() getLogger().info(s.toString()); ``` FoliaBoard also warns (once) when a board exceeds the 15-line client limit or a single line/title is unusually large (likely accidental payload bloat). --- ## Remembering a player's layout Optionally persist which layout a player was on, so it's re-applied on their next join with no join-listener glue. Back the store with anything (a map, a config, a database, a storage plugin): ```java board.setLayoutStore(new LayoutStore() { public CompletableFuture remember(UUID player, String layout) { return db.putAsync(player, layout); } public CompletableFuture lastLayout(UUID player) { return db.getAsync(player); } }); ``` When set, `applyLayout(...)` records the layout name, and FoliaBoard re-applies it on join (unless a global or per-world layout already drives that player's board). --- ### Observability `board.stats()` returns a snapshot for profiling TPS impact: ```java FoliaBoardStats s = board.stats(); // s.totalPackets(), s.providerRefreshes(), s.activeSidebars(), s.activeNametags() getLogger().info(s.toString()); ``` FoliaBoard also warns (once) when a board exceeds the 15-line client limit or a single line/title is unusually large (likely accidental payload bloat). --- ## Remembering a player's layout Optionally persist which layout a player was on, so it's re-applied on their next join with no join-listener glue. Back the store with anything (a map, a config, a database, a storage plugin): ```java board.setLayoutStore(new LayoutStore() { public void remember(UUID player, String layout) { db.put(player, layout); } public CompletableFuture lastLayout(UUID player) { return db.getAsync(player); } }); ``` When set, `applyLayout(...)` records the layout name, and FoliaBoard re-applies it on join (unless a global or per-world layout already drives that player's board). --- ## Lifecycle, cleanup & `/reload` - Create once in `onEnable`, call `board.close()` in `onDisable`. `close()` cancels every task, unregisters the listener, and tears down all boards, nametags and objectives. - **Auto-cleanup** on quit, world-change and plugin-disable — no ghost players, no leaks. - **`/reload` is discouraged** (on Paper/Folia generally). Because FoliaBoard registers a listener and scheduler tasks, prefer a full restart. A clean disable→enable cycle won't leak (guarded against scheduling while disabled), but `/reload` remains unsupported as a reload mechanism. --- ## API reference | Type | Key members | |---|---| | `FoliaBoard` | `create(plugin)`, `createBoard(p)`, `createNametag(p)`, `sidebar(p)`, `removeSidebar(p)`, `setGlobalSidebar(provider\|layout)`, `clearGlobalSidebar()`, `registerLayout/unregisterLayout/layout/applyLayout`, `setWorldLayout/clearWorldLayout`, `setLayoutStore`, `nametag(p)`, `belowName()`, `tabList()`, `tabName/resetTabName/tabOrder`, `tabNameFor/resetTabNameFor/perViewerTabSupported`, `tabHeaderFooter/clearTabHeaderFooter`, `addLineProcessor`, `placeholders()`, `stats()`, `close()` | | `ScoreboardAPI` | `init(plugin)`, `get()`, `shutdown()`, `createBoard(p)`, `createNametag(p)`, `sidebar(p)` | | `BoardBuilder` | `placeholders(bool)`, `refreshEvery(ticks)`, `title(...)`, `line(...)`, `lines(...)`, `blankLine()`, `build()` | | `Sidebar` | `title(...)`, `line(...)`, `lines(...)`, `removeLine`, `clearLines`, `visible(...)`, `title()`, `lines()`, `lineCount()`, `close()` | | `NametagBuilder` | `prefix/suffix/color/nametagVisibility/collision`, `tabSort(int)`, `perViewer(resolver)`, `apply()` | | `Nametag` | `prefix/suffix/color/…`, `perViewer(resolver)`, `apply()`, `remove()` | | `ScoreObjective` | `title(...)`, `score(player\|entry, v)`, `remove(entry)`, `scoreFor/removeFor(viewer,…)`, `hide()`, `show()` | | `SidebarProvider` | `title(p)`, `lines(p)`, `visible(p)`, `refreshIntervalTicks()`, `of(...)` | | `Layout` | `named(name, recipe)`, `applyTo(board, p)` | | `NumberFormat` | `blank()`, `fixed(c)`, `styled(style)`, `defaultFormat()` | | `Animations` | `cycle`, `scrollText`, `pulseColor`, `typewriter`, `mini` | | `Placeholders` | `register(...)`, `apply(p,text)`, `component(p,text)` | | `Text` | `mini(...)`, `toMini(c)` | | `AsyncUtil` | `async`, `asyncLater`, `onPlayer`, `global`, `isFolia` | | events / hooks | `SidebarCreateEvent`, `LayoutApplyEvent`, `LineProcessor` | --- ## Version support & the honest caveat - Requires **Paper/Folia 1.20.6+**, **Java 21**. Targets the Mojang-mapped runtime and modern per-score display-component packets. **Validated live end-to-end on Folia 1.21.11.** - The packet layer (`NmsPacketAdapter`) reaches into server internals by reflection, and adapts at load to whether score-packet fields are `Optional<…>` or `@Nullable`. It's the **only** version-specific file, and it fails **loudly at load** (never mid-game) if a handle can't resolve. If a future Minecraft release moves a field or changes a packet's shape, that one file is where you adjust it. --- ## Building from source ```bash mvn clean package ``` - `foliaboard-core/target/foliaboard-core-1.0.0.jar` — the library (depend on this). - `foliaboard-demo/target/FoliaBoard-1.0.0.jar` — a standalone demo plugin. Drop it into `plugins/`, join, and use `/fbdemo lobby|minigame` to see everything at once. Add `-Dfoliaboard.debug=true` to log every scoreboard packet. See [`foliaboard-demo/.../ExamplePlugin.java`](foliaboard-demo/src/main/java/net/foliaboard/example/ExamplePlugin.java) for a complete, runnable example. #

ScoreboardAPI-FOLIA is a free Minecraft Java mod. Compatible with Minecraft 1.20.6, 1.21, 1.21.1, 1.21.10 and newer. Downloaded 4 times (via Hangar). Download it and open it directly in the game.

Explore more