Skip to content

· Tutorial

Scoreboard plugin: a sidebar that never flickers

Scoreboard sidebar displayed on a Minecraft server

The sidebar is the first thing a server owner wants branded, and the first thing they break. The symptom never varies: lines strobe, the panel seems to breathe, and on a laptop screen it becomes unreadable. That is not a client bug. It is the direct consequence of one design decision in the Bukkit API, and once you understand it you can never write it wrong again. Here is the exact mechanism, the fix, the refresh rate that costs nothing, and the real origin of every value you want to display.

What a scoreboard really is

A sidebar is not a UI widget. It is a scoreboard objective, an Objective, displayed in the SIDEBAR slot. Its lines are entries, each with an integer score attached. The client sorts entries by descending score and prints that score on the right, in red. The system is documented on minecraft.wiki, and the exact class signatures live in the Paper javadoc.

Three consequences that most tutorials quietly skip:

  • Fifteen lines maximum below the title. A sixteenth line throws no exception and writes nothing to the log: it simply never shows up. It is one of the most frustrating bugs to chase precisely because it is completely silent.
  • An entry is a unique key. Two strictly identical lines cannot coexist: the second overwrites the first. That is exactly why your two blank separator lines collapse into one and your sidebar ends up with a hole at the bottom.
  • The red number on the right is the sort order, not decoration. Since 1.20.3 the game can hide it natively through the number format, and Paper exposes that on the objective. The old workarounds built on duplicate scores or invisible colours are dead weight now.

Why the raw API flickers

Here is the code you find everywhere, and the single cause of the flicker. Read the two middle lines carefully.

Java
// The pattern copied into nearly every tutorial.
// This, and nothing else, is what makes the sidebar flicker.
Scoreboard board = Bukkit.getScoreboardManager().getNewScoreboard();
Objective objective = board.registerNewObjective(
        "sb", Criteria.DUMMY, Component.text("My server"));
objective.setDisplaySlot(DisplaySlot.SIDEBAR);

// The visible text IS the key of the line.
objective.getScore("\u00A7fBalance \u00A76" + balance).setScore(2);
objective.getScore("\u00A7fPing \u00A76" + player.getPing()).setScore(1);

player.setScoreboard(board);

The visible text is the key. On the next refresh the balance moved from 100 to 105, so the string changed, so it is no longer the same entry. The server has to send a remove packet for the old line and a create packet for the new one. In between, the client holds a sidebar that is one line short, and it draws it exactly like that. Do it twenty times per second and your eye integrates the gap. That is the flicker.

The even more destructive variant is building a brand new Scoreboard and calling setScoreboard every cycle. Now the client tears down and rebuilds the entire display, title included. The panel stops flickering and starts jumping.

Two collateral traps

Never touch the server main scoreboard: it is shared by everyone and persisted in the world data, so your test lines will survive a restart. And remember that setScoreboard is a total replacement. Two plugins that both want the sidebar fight over it, and the last writer wins. If your panel vanishes with no error in the log, look for the other plugin before you look for your bug.

The fix: decouple the key from the text

The whole solution is one sentence: the key must never move. Create fifteen invisible entries once, assign each one to a Team, and let the visible text ride on the team prefix. Changing a prefix is an in-place update: one packet, no removal, no gap. You also get a free win, because two identical separator lines become legal again: it is their keys that differ, not their text.

Java
package fun.minax.sidebar;

import net.kyori.adventure.text.Component;
import org.bukkit.Bukkit;
import org.bukkit.entity.Player;
import org.bukkit.scoreboard.Criteria;
import org.bukkit.scoreboard.DisplaySlot;
import org.bukkit.scoreboard.Objective;
import org.bukkit.scoreboard.Scoreboard;
import org.bukkit.scoreboard.Team;

import java.util.List;

/** One sidebar per player. Fifteen lines, created exactly once. */
public final class Sidebar {

    private static final int MAX_LINES = 15;
    private static final String[] KEYS = new String[MAX_LINES];

    static {
        // One invisible, unique key per line: a bare colour code.
        // The client indexes this key, never the text you display.
        String codes = "0123456789abcde";
        for (int i = 0; i < MAX_LINES; i++) {
            KEYS[i] = "\u00A7" + codes.charAt(i);
        }
    }

    private final Scoreboard board;
    private final Objective objective;

    public Sidebar(Player player, Component title) {
        this.board = Bukkit.getScoreboardManager().getNewScoreboard();
        this.objective = board.registerNewObjective("minax_sidebar", Criteria.DUMMY, title);
        this.objective.setDisplaySlot(DisplaySlot.SIDEBAR);

        for (int i = 0; i < MAX_LINES; i++) {
            Team line = board.registerNewTeam("minax_line_" + i);
            line.addEntry(KEYS[i]);
        }
        player.setScoreboard(board);
    }

    /** Called on every refresh. Nothing is created, nothing is removed. */
    public void render(List<Component> lines) {
        int shown = Math.min(lines.size(), MAX_LINES);
        for (int i = 0; i < MAX_LINES; i++) {
            Team line = board.getTeam("minax_line_" + i);
            if (line == null) continue;

            if (i < shown) {
                line.prefix(lines.get(i));                     // in-place update
                objective.getScore(KEYS[i]).setScore(shown - i);
            } else {
                board.resetScores(KEYS[i]);                     // unused line
            }
        }
    }
}

Two notes on that class. First, the famous sixteen character prefix limit belongs to the past, it applied to 1.8 servers. Since 1.13 the prefix is a text component, so any tutorial that has you slicing a line into prefix plus entry plus suffix is wasting your evening. Second, call line.prefix(Component) and not the legacy String variant. That deserves its own section.

The Adventure 5 trap, and why the sidebar takes the hit

Paper 26.x ships Adventure 5, which removed APIs that were previously only deprecated, and most of those removals sit in messages and player-facing text. A sidebar is precisely that: player-facing text, fifteen lines of it. So it is statistically the first file in your plugin that will refuse to compile when you move from 1.21 to 26.x. That is not a hunch, it is mechanical: it is the class in your project with the highest density of text API calls.

Which raises the question nobody asks: what do you compile against? The bStats sample of 62,948 servers puts 1.21.11 at 36.4%, 26.1.2 at 14.9%, 26.2 at 7.4% and 1.21.4 at 7.2%. In other words the 1.21 family is still the majority, and 26.2 is barely ahead of 1.21.4 a few weeks after its release on 16 June 2026. You can check the numbers on bStats.

My build target for a scoreboard plugin

Java 21, API 1.21, load tested on 26.1.2. Java 25 is the floor for the calendar versions, but a .jar compiled for Java 21 loads on a newer runtime while the reverse is false, so one file covers two thirds of the fleet. On the production side, Paper 26.2 has no stable build yet, which makes 26.1.2 the sane target. A pleasant detail along the way: Minecraft has been deobfuscated since 26.1 and the Paper remapper is gone, so there is no translation layer between your code and the game any more.

FastBoard, and the exact point where it wins

FastBoard exists because everything above is repeated work nobody enjoys redoing. The library sends the scoreboard packets directly, handles the invisible keys, the fifteen line ceiling and the differences between game versions, and exposes only a handful of methods. Its licence is permissive, so you can redistribute it inside your .jar without contaminating your own code, which is not true of the whole ecosystem. The repository is on GitHub.

What FastBoard takes off your plate

Invisible key management

Diffing only the changed lines

Version to version differences

One board per player, effortlessly

Permissive licence, redistributable

What it does not solve

A dependency you must relocate

An abstraction you do not control

Nothing about where your data lives

Nothing about your query cost

Nothing about the refresh rate

My call, stated plainly. Under twenty concurrent players, with one identical panel for everybody and a server pinned to a single version, the raw Bukkit API with Teams is plenty: you keep a dependency-free .jar and you understand every line you shipped. The moment the panel becomes personal, meaning each player sees their own balance, rank or position, or the moment you pass twenty concurrent players, I switch to FastBoard without debating it. The code you would write to manage fifteen keys per player across several game versions is exactly what FastBoard already does, with far more testing behind it. And past a hundred players the bottleneck is not the scoreboard API at all: it is where your placeholders come from.

How often to refresh

A server runs at twenty ticks per second, and the default instinct is to refresh the sidebar every tick. That is an order-of-magnitude mistake, and plain arithmetic proves it. Fifteen lines, twenty refreshes per second, one hundred players online: that is 30,000 line updates per second, each one turned into a packet aimed at a client. The same sidebar refreshed once per second, every twenty ticks, costs 1,500 updates per second. Twenty times cheaper, for a result nobody can tell apart, because no player reads a balance that changes twenty times a second.

My rule, with no useless nuance:

  • 20 ticks by default. Money, rank, online count, ping, stats. Once per second and nobody will ever notice the difference.
  • 4 ticks only for a countdown or a progress bar the player is actively watching. Five times per second reads as smooth and costs five times less than every tick.
  • 1 tick, never. No value you can put in a sidebar justifies that rate. If you think you have a counter-example, it is probably a title or an action bar you should be using instead.
  • Only redraw the lines that changed. Keep the previous state and compare. On a five line panel where only the ping moves, you send one line out of five.

Stagger the tasks across players

If every sidebar refreshes on the same tick you create a load spike once per second instead of a steady stream. On a busy server that spike shows up in your TPS readings. Give each player a different initial delay derived from their identifier: the total work is identical, but it is spread out.

Placeholders: where the data actually comes from

What people want on screen is always the same list: money, rank, ping, online count, sometimes a leaderboard position. What they do not realise is that those five lines cost wildly different amounts. Two are free, one is unpredictable, and one can single-handedly drag your TPS down.

Real originCostSuggested rhythm
Online countServer list in memoryNegligibleEvery refresh
PingExposed by the Paper APINegligibleEvery refresh
RankLuckPerms, or VaultIn memory, plugin callEvery 5 s
BalanceVault Economy serviceUnknown, often SQLAsync, cached
Player statsYour SQLite or MySQLDisk or networkAsync, cached
LeaderboardAggregate over the tableThe most expensive oneOnce a minute, global

The balance line is the real trap and it deserves a paragraph. Vault is only an interoperability standard: behind the call that returns a balance, the actual implementation may read an in-memory map or hit MySQL. You do not know which, and you cannot know, because it depends on whichever economy plugin your user installed. Conclusion: you never call it from the render loop. A database access on the main thread drags the TPS down, and a sidebar refreshing once per second triggers that access in a completely different order of magnitude from a player typing a balance command.

So the correct architecture has two speeds. A slow async task fills a cache. The fast render loop reads that cache plus values already in memory. The player sees three dots for the first second after joining, and that is the entire cost.

What about PlaceholderAPI? It is the de facto standard for variables, but it is a plugin your admin installs, not a library you bundle: its licence rules that out for redistributed code. Same story for EssentialsX. The right approach is to declare them as soft dependencies, call them only when present, and write your lines so they still read correctly without them. Vault, on the other hand, is built for exactly this.

plugin.yml
name: MinaxSidebar
version: 1.0.0
main: fun.minax.sidebar.SidebarPlugin
api-version: '1.21'

# Vault, LuckPerms and PlaceholderAPI are plugins YOUR ADMIN installs.
# As softdepend, your plugin still starts when they are missing.
softdepend: [Vault, LuckPerms, PlaceholderAPI]

libraries:
  # The SQLite JDBC driver already ships with Paper. HikariCP does not:
  # declare it here, the server downloads it, no shading needed.
  - com.zaxxer:HikariCP:<version>

The libraries block replaces shading: you declare the dependency and the server downloads it instead of you embedding it. Worth flagging because it surprises everyone: the SQLite JDBC driver already ships with Paper, so you declare nothing for it. HikariCP does not. The plugin.yml blocks are documented on docs.papermc.io, and the older Bukkit reference is still readable on hub.spigotmc.org.

Here is the full service, with the async cache and the render loop kept apart:

Java
package fun.minax.sidebar;

import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.NamedTextColor;
import net.milkbowl.vault.economy.Economy;
import org.bukkit.Bukkit;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.Listener;
import org.bukkit.event.player.PlayerJoinEvent;
import org.bukkit.event.player.PlayerQuitEvent;
import org.bukkit.plugin.RegisteredServiceProvider;
import org.bukkit.plugin.java.JavaPlugin;

import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.TimeUnit;

public final class SidebarPlugin extends JavaPlugin implements Listener {

    /** The expensive part: read every 5 seconds, off the main thread. */
    private record Stats(double money, String group) {}

    private final Map<UUID, Stats> cache = new ConcurrentHashMap<>();
    private final Map<UUID, Sidebar> boards = new ConcurrentHashMap<>();
    private final Set<UUID> tracked = ConcurrentHashMap.newKeySet();

    private Economy economy;

    @Override
    public void onEnable() {
        getServer().getPluginManager().registerEvents(this, this);

        RegisteredServiceProvider<Economy> provider =
                getServer().getServicesManager().getRegistration(Economy.class);
        if (provider != null) {
            economy = provider.getProvider();
        } else {
            getLogger().info("No Vault economy found, the balance line stays hidden.");
        }

        // Slow read: never on the main thread, never inside the render loop.
        getServer().getAsyncScheduler()
                .runAtFixedRate(this, task -> refreshStats(), 1L, 5L, TimeUnit.SECONDS);
    }

    @Override
    public void onDisable() {
        boards.clear();
        cache.clear();
        tracked.clear();
    }

    @EventHandler
    public void onJoin(PlayerJoinEvent event) {
        Player player = event.getPlayer();
        UUID id = player.getUniqueId();
        tracked.add(id);

        Sidebar sidebar = new Sidebar(player, Component.text("MY SERVER", NamedTextColor.GOLD));
        boards.put(id, sidebar);

        // The player scheduler follows the player from region to region, so the
        // same code runs on Paper and on Folia. The null argument is the
        // "retired" callback, fired if the player is gone before the task.
        // The initial delay is staggered per player to avoid a spike every second.
        long offset = Math.abs(id.hashCode() % 20);
        player.getScheduler().runAtFixedRate(
                this, task -> sidebar.render(build(player)), null, 20L + offset, 20L);
    }

    @EventHandler
    public void onQuit(PlayerQuitEvent event) {
        UUID id = event.getPlayer().getUniqueId();
        tracked.remove(id);
        boards.remove(id);
        cache.remove(id);
    }

    /** Runs async. We iterate our own set, not getOnlinePlayers(). */
    private void refreshStats() {
        for (UUID id : tracked) {
            double money = economy == null
                    ? 0D
                    : economy.getBalance(Bukkit.getOfflinePlayer(id));
            cache.put(id, new Stats(money, groupOf(id)));
        }
    }

    private String groupOf(UUID id) {
        // Plug LuckPerms or the Vault permission service in here.
        return "Player";
    }

    /** Runs on the player thread. Reads cache and memory only. */
    private List<Component> build(Player player) {
        Stats stats = cache.get(player.getUniqueId());
        String money = stats == null ? "..." : String.format("%.0f", stats.money());
        String group = stats == null ? "..." : stats.group();

        return List.of(
                Component.text("Rank ", NamedTextColor.WHITE)
                        .append(Component.text(group, NamedTextColor.GOLD)),
                Component.text("Balance ", NamedTextColor.WHITE)
                        .append(Component.text(money, NamedTextColor.GOLD)),
                Component.text("Ping ", NamedTextColor.WHITE)
                        .append(Component.text(player.getPing() + " ms", NamedTextColor.GOLD)),
                Component.text("Online ", NamedTextColor.WHITE)
                        .append(Component.text(
                                String.valueOf(Bukkit.getOnlinePlayers().size()),
                                NamedTextColor.GOLD)),
                Component.text("minax.fun", NamedTextColor.GRAY));
    }
}

What will break

This is the most useful part of the article, because these are silent failures: nothing in the logs, just numbers quietly going wrong.

  • A rename in offline mode. This is trap number one and almost nobody writes about it. 75.1% of servers run in offline mode, and in that mode a player identifier is derived from their name. A player renames, their identifier changes, and the balance your sidebar was showing is orphaned: the line drops to zero with no error anywhere. The day that server flips to online mode, the whole table is orphaned at once. Key on the identifier, but keep the last known name in an indexed column, otherwise you will have no way to reattach the data.
  • Folia. A refresh task written against the old global scheduler crashes on Folia, which runs world regions across several threads. You need the RegionScheduler, and for a per-player panel the player scheduler is the right one because it follows the player from region to region. The documentation lives on docs.papermc.io.
  • The sixteenth line, and duplicates. No error message in either case. If a line is missing from your panel, count them before you start debugging.
  • The hot reload command. It leaves orphaned tasks that keep drawing onto a sidebar that no longer exists, from a plugin that is already unloaded. Restart the server instead.
  • One single .jar for both families. Adventure 5 removed APIs, it did not merely deprecate them, and your compiler only ever sees the version you target. So the code compiles cleanly, then breaks at load time on the other family, as a missing method. A .jar built against 26.x breaks on 1.21, and a .jar built against 1.21 breaks on 26.x the moment it calls one of the methods that went away. Neither direction is safe: build one artefact per family.

Your sidebar, generated and tested online

Describe the lines you want, the refresh rate and the sources. Minax writes the Java, compiles it, and you check the result on a real Paper server before installing the .jar.

Create my plugin

My verdict in three lines

Flicker is not fate, it is a key that moves: pin the key, let a Team prefix carry the text, and the problem is gone for good. Refresh every twenty ticks, never every tick, and only redraw what changed. Above all, never let a database query into your render loop, because that is where TPS actually goes, not in scoreboard packets.

Under twenty players the raw Bukkit API with Teams is enough. Above that, or as soon as the panel becomes personal, FastBoard. For the rest of your per-tick budget our optimisation guide covers the ground, and if a load goes wrong the troubleshooting guide lists the errors in order of frequency.

Related articles