Le panneau latéral est la première chose qu’un admin veut à son nom, et la première chose qu’il casse. Le symptôme est toujours identique : les lignes clignotent, le panneau semble respirer, et sur un écran de portable ça devient illisible. Ce n’est pas un défaut du client. C’est la conséquence directe d’un choix de conception de l’API Bukkit, et une fois que tu l’as compris tu ne peux plus l’écrire de travers. Voici le mécanisme exact, la correction, la fréquence de rafraîchissement qui ne coûte rien, et la vraie origine des données que tu veux afficher.
Ce qu’est vraiment un scoreboard
Un panneau latéral n’est pas un composant d’interface. C’est un objectif de scoreboard, un Objective, affiché dans l’emplacement SIDEBAR. Ses lignes sont des entrées auxquelles un score entier est associé. Le client trie les entrées par score décroissant et affiche ce score à droite, en rouge. Le détail du système est documenté sur minecraft.wiki, et les signatures exactes des classes se lisent dans le javadoc de Paper.
Trois conséquences que la plupart des tutoriels passent sous silence :
- Quinze lignes maximum sous le titre. Écrire une seizième ligne ne provoque aucune erreur, ne remplit aucun journal : elle n’apparaît simplement jamais. C’est un des bugs les plus frustrants à diagnostiquer parce qu’il est totalement silencieux.
- Une entrée est une clé unique. Deux lignes strictement identiques sont impossibles : la seconde écrase la première. C’est exactement pour ça que tes deux lignes de séparation vides fusionnent en une seule et que ton panneau se retrouve avec un trou en bas.
- Le nombre rouge à droite est l’ordre de tri, pas une décoration. Depuis 1.20.3 le jeu sait le masquer nativement grâce au format de nombre, et Paper l’expose sur l’objectif. Les vieilles bidouilles à base de scores identiques ou de couleurs invisibles n’ont plus lieu d’être.
Pourquoi l’API brute scintille
Voici le code que tu trouves partout, et qui est la cause unique du scintillement. Lis bien les deux lignes du milieu.
// Le patron recopié dans presque tous les tutoriels.
// C'est lui, et lui seul, qui fait scintiller le panneau.
Scoreboard board = Bukkit.getScoreboardManager().getNewScoreboard();
Objective objective = board.registerNewObjective(
"sb", Criteria.DUMMY, Component.text("Mon serveur"));
objective.setDisplaySlot(DisplaySlot.SIDEBAR);
// Le texte affiché EST la clé de la ligne.
objective.getScore("\u00A7fArgent \u00A76" + balance).setScore(2);
objective.getScore("\u00A7fPing \u00A76" + player.getPing()).setScore(1);
player.setScoreboard(board);Le texte affiché est la clé. Au rafraîchissement suivant, le solde a changé de 100 à 105, donc la chaîne a changé, donc ce n’est plus la même entrée. Le serveur doit envoyer un paquet de suppression pour l’ancienne ligne, puis un paquet de création pour la nouvelle. Entre les deux, le client possède un panneau dont il manque une ligne, et il le dessine tel quel. Répète l’opération vingt fois par seconde et ton œil intègre le trou : c’est ça, le scintillement.
La variante encore plus destructrice consiste à recréer un Scoreboard complet et à rappeler setScoreboard à chaque cycle. Là, le client démonte et remonte tout l’affichage, titre compris. Le panneau ne clignote plus, il saute.
Deux pièges collatéraux
setScoreboard est un remplacement total : deux plugins qui veulent la sidebar se battent, et le dernier à écrire gagne. Si ton panneau disparaît sans erreur, cherche l’autre plugin avant de chercher ton bug.La correction : découpler la clé du texte
La solution tient en une phrase : la clé ne doit jamais bouger. Tu crées quinze entrées invisibles une fois pour toutes, tu les assignes à quinze Team, et le texte visible vient du préfixe de la Team. Changer un préfixe est une mise à jour en place : un seul paquet, aucune suppression, aucun trou. Bonus immédiat, tes deux lignes de séparation identiques redeviennent possibles, puisque ce sont leurs clés qui diffèrent et pas leur texte.
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;
/** Un panneau par joueur. Quinze lignes, créées une seule fois. */
public final class Sidebar {
private static final int MAX_LINES = 15;
private static final String[] KEYS = new String[MAX_LINES];
static {
// Une clé invisible et unique par ligne : un simple code couleur.
// C'est cette clé que le client indexe, jamais le texte affiché.
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);
}
/** Appelé à chaque rafraîchissement. Aucune création, aucune suppression. */
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)); // mise à jour en place
objective.getScore(KEYS[i]).setScore(shown - i);
} else {
board.resetScores(KEYS[i]); // ligne inutilisée
}
}
}
}Deux remarques sur ce code. D’abord, la fameuse limite de seize caractères par préfixe appartient au passé : elle concernait les serveurs 1.8. Depuis 1.13 le préfixe est un composant de texte, donc les tutoriels qui te font découper ta ligne en préfixe plus entrée plus suffixe te font perdre ton temps. Ensuite, écris line.prefix(Component) et pas la vieille variante qui prend une chaîne. La raison mérite sa propre section.
Le piège Adventure 5, et pourquoi c’est le scoreboard qui trinque
Paper 26.x embarque Adventure 5, qui a supprimé des API auparavant seulement dépréciées, et l’essentiel de ces suppressions touche les messages et le texte affiché aux joueurs. Or un panneau latéral, c’est exactement ça : du texte affiché aux joueurs, ligne par ligne. C’est donc statistiquement le premier fichier de ton plugin qui refusera de compiler quand tu passeras de 1.21 à 26.x. Ce n’est pas une intuition, c’est mécanique : c’est la classe de ton projet qui a la plus forte densité d’appels à l’API de texte.
Et la question suivante, personne ne la pose : sur quelle version compiler ? Le relevé bStats sur 62 948 serveurs donne 1.21.11 à 36,4 %, 26.1.2 à 14,9 %, 26.2 à 7,4 % et 1.21.4 à 7,2 %. Autrement dit, la famille 1.21 reste majoritaire, et 26.2 ne devance 1.21.4 que d’un cheveu quelques semaines après sa sortie du 16 juin 2026. Tu peux vérifier le relevé sur bStats.
Ma cible de compilation pour un plugin de scoreboard
FastBoard, et le moment exact où il gagne
FastBoard existe parce que tout ce qui précède est du travail répété que personne n’a envie de refaire. La bibliothèque envoie les paquets de scoreboard directement, gère les clés invisibles, la limite de quinze lignes et les écarts entre versions du jeu, et n’expose que quelques méthodes. Sa licence est permissive, donc tu peux la redistribuer dans ton .jar sans contaminer ton code, ce qui n’est pas vrai de tout l’écosystème. Le dépôt est sur GitHub.
Ce que FastBoard t’enlève
La gestion des clés invisibles
Le diff des lignes modifiées
Les écarts entre versions du jeu
Un panneau par joueur, sans effort
Licence permissive, redistribuable
Ce qu’il ne fait pas
Une dépendance à relocaliser
Une abstraction que tu ne contrôles pas
Rien sur l’origine de tes données
Rien sur le coût de tes requêtes
Rien sur la fréquence, à toi de choisir
Mon arbitrage, assumé. En dessous de vingt joueurs simultanés, avec un panneau identique pour tout le monde et un serveur figé sur une seule version, l’API Bukkit avec des Teams suffit largement : tu gardes un .jar sans dépendance et tu comprends chaque ligne de ton code. Dès que le panneau devient personnel, c’est-à-dire dès que chaque joueur y voit son argent, son grade ou son classement, ou dès que tu dépasses la vingtaine de connectés, je passe à FastBoard sans discuter. Le code que tu écrirais pour gérer proprement quinze clés par joueur sur plusieurs versions du jeu, c’est précisément ce que FastBoard fait déjà, en mieux testé. Et au-delà d’une centaine de joueurs, le goulot d’étranglement n’est plus l’API du scoreboard du tout : c’est la source de tes placeholders.
À quelle fréquence rafraîchir
Un serveur tourne à vingt ticks par seconde, et le réflexe par défaut est de rafraîchir le panneau à chaque tick. C’est une erreur d’un ordre de grandeur, et l’arithmétique suffit à le montrer. Quinze lignes, vingt rafraîchissements par seconde, cent joueurs connectés : cela fait 30 000 mises à jour de ligne par seconde, chacune traduite en paquet vers un client. Le même panneau rafraîchi une fois par seconde, soit toutes les vingt ticks, coûte 1 500 mises à jour par seconde. Vingt fois moins, pour un résultat que personne ne peut distinguer : aucun joueur ne lit un solde qui change vingt fois par seconde.
Ma règle, sans nuance inutile :
- 20 ticks par défaut. Argent, grade, joueurs en ligne, ping, statistiques. Une fois par seconde, personne ne verra jamais la différence.
- 4 ticks, uniquement pour un compte à rebours ou une barre de progression que le joueur regarde activement. Cinq fois par seconde, c’est fluide à l’œil et cinq fois moins cher qu’un rafraîchissement à chaque tick.
- 1 tick, jamais. Aucune information affichable dans un panneau latéral ne justifie cette fréquence. Si tu crois avoir un contre-exemple, c’est probablement un titre ou une barre d’action que tu devrais utiliser à la place.
- Ne redessine que les lignes qui ont changé. Garde l’état précédent et compare. Sur un panneau de cinq lignes où seul le ping bouge, tu n’envoies plus qu’une ligne sur cinq.
Décale les tâches entre les joueurs
Les placeholders : d’où viennent réellement les données
Ce que les gens veulent afficher est toujours la même liste : argent, grade, ping, joueurs en ligne, parfois un classement. Ce qu’ils ignorent, c’est que ces cinq lignes n’ont pas du tout le même prix. Deux sont gratuites, une est imprévisible, et une peut à elle seule faire chuter le TPS de ton serveur.
| Origine réelle | Coût | Rythme conseillé | |
|---|---|---|---|
| Joueurs en ligne | Liste en mémoire du serveur | Négligeable | À chaque rafraîchissement |
| Ping | Exposé par l’API Paper | Négligeable | À chaque rafraîchissement |
| Grade | LuckPerms, ou Vault | En mémoire, appel de plugin | Toutes les 5 s |
| Argent | Service Economy de Vault | Inconnu, souvent du SQL | Asynchrone, en cache |
| Statistiques | Ta base SQLite ou MySQL | Disque ou réseau | Asynchrone, en cache |
| Classement | Agrégat sur toute la table | Le plus cher de tous | Une fois par minute, global |
La ligne argent est le vrai piège, et elle mérite qu’on s’y arrête. Vault n’est qu’un standard d’interopérabilité : derrière l’appel qui te rend le solde, l’implémentation réelle peut lire une simple table en mémoire ou taper dans MySQL. Tu ne le sais pas, et tu ne peux pas le savoir, parce que ça dépend du plugin d’économie que ton utilisateur a installé. Conclusion : tu ne l’appelles jamais depuis la boucle d’affichage. Un accès base de données sur le thread principal fait chuter le TPS, et un panneau qui rafraîchit à la seconde déclenche cet accès dans un tout autre ordre de grandeur qu’un joueur qui tape une commande de solde.
L’architecture correcte est donc à deux vitesses. Une tâche asynchrone lente remplit un cache. La boucle d’affichage, rapide, ne lit que ce cache et les valeurs déjà en mémoire. Le joueur voit trois petits points pendant la première seconde de sa connexion, et c’est tout ce que ça coûte.
Et PlaceholderAPI ? C’est le standard de fait pour les variables, mais c’est un plugin que ton administrateur installe, pas une bibliothèque que tu embarques : sa licence l’interdit dans du code redistribué. Même chose pour EssentialsX. La bonne façon de faire est de les déclarer en dépendance souple, de ne les appeler que s’ils sont présents, et d’écrire tes lignes pour qu’elles restent lisibles sans eux. Vault, en revanche, est fait pour ça.
name: MinaxSidebar
version: 1.0.0
main: fun.minax.sidebar.SidebarPlugin
api-version: '1.21'
# Vault, LuckPerms et PlaceholderAPI sont des plugins que TON ADMIN installe.
# En softdepend, ton plugin démarre même s'ils sont absents.
softdepend: [Vault, LuckPerms, PlaceholderAPI]
libraries:
# Le pilote JDBC SQLite est déjà fourni avec Paper. HikariCP non :
# on le déclare ici, le serveur le télécharge, plus besoin de shading.
- com.zaxxer:HikariCP:<version>Le bloc libraries remplace le shading : tu déclares la dépendance et le serveur la télécharge au lieu que tu l’embarques. À noter, parce que ça surprend tout le monde : le pilote JDBC SQLite est déjà fourni avec Paper, tu n’as rien à déclarer pour lui. HikariCP, non. Le détail des blocs de plugin.yml est dans la documentation Paper, et l’ancienne référence Bukkit reste consultable sur hub.spigotmc.org.
Voilà le service complet, avec le cache asynchrone et la boucle d’affichage séparés :
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 {
/** Ce qui coûte cher : lu toutes les 5 secondes, hors du thread principal. */
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("Pas d'economie Vault : la ligne argent restera masquee.");
}
// Lecture lente : jamais sur le thread principal, jamais dans la boucle d'affichage.
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("MON SERVEUR", NamedTextColor.GOLD));
boards.put(id, sidebar);
// Le scheduler du joueur suit le joueur de region en region : le meme code
// tourne sur Paper et sur Folia. Le parametre null est le "retired",
// appele si le joueur disparait avant la tache.
// Le delai initial est decale par joueur pour eviter un pic a chaque seconde.
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);
}
/** Tourne en asynchrone. On itere notre propre liste, pas 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) {
// Branche ici LuckPerms ou le service Permission de Vault.
return "Joueur";
}
/** Tourne sur le thread du joueur. Ne lit que du cache et de la memoire. */
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("Grade ", NamedTextColor.WHITE)
.append(Component.text(group, NamedTextColor.GOLD)),
Component.text("Argent ", NamedTextColor.WHITE)
.append(Component.text(money, NamedTextColor.GOLD)),
Component.text("Ping ", NamedTextColor.WHITE)
.append(Component.text(player.getPing() + " ms", NamedTextColor.GOLD)),
Component.text("En ligne ", NamedTextColor.WHITE)
.append(Component.text(
String.valueOf(Bukkit.getOnlinePlayers().size()),
NamedTextColor.GOLD)),
Component.text("minax.fun", NamedTextColor.GRAY));
}
}Ce qui va casser
Cette partie est la plus utile de l’article, parce que ce sont des pannes silencieuses : rien dans les journaux, juste des chiffres qui deviennent faux.
- Le renommage en mode hors ligne. C’est le piège numéro un et personne n’en parle. 75,1 % des serveurs tournent en mode hors ligne, et dans ce mode l’identifiant unique d’un joueur est dérivé de son pseudo. Un joueur change de pseudo, son identifiant change, et l’argent que ton panneau affichait devient orphelin : la ligne repasse à zéro sans la moindre erreur. Le jour où le serveur passerait en mode en ligne, c’est toute la table qui devient orpheline d’un coup. Stocke sur l’identifiant, mais conserve le dernier pseudo connu dans une colonne indexée, sinon tu n’auras aucun moyen de recoller les données.
- Folia. Une tâche de rafraîchissement écrite avec l’ancien ordonnanceur global part en crash sur Folia, qui exécute les régions du monde sur plusieurs threads. Il faut passer par le RegionScheduler, et pour un panneau par joueur l’ordonnanceur du joueur est le bon choix puisqu’il suit le joueur de région en région. La documentation est sur docs.papermc.io.
- La seizième ligne, et les doublons. Aucun message d’erreur dans les deux cas. Si une ligne manque à ton panneau, compte-les avant de déboguer.
- La commande de rechargement à chaud. Elle laisse des tâches orphelines qui continuent de dessiner sur un panneau qui n’existe plus, avec un plugin déjà déchargé. Redémarre le serveur.
- Un seul .jar pour les deux familles. Adventure 5 a supprimé des API, il ne les a pas seulement dépréciées, et ton compilateur ne voit que la version que tu cibles. Le code compile donc très bien, puis casse au chargement sur l’autre famille, sous forme de méthode introuvable. Un .jar construit contre 26.x tombe sur 1.21, et un .jar construit contre 1.21 tombe sur 26.x dès qu’il appelle une des méthodes disparues. Aucune des deux directions n’est sûre : construis un artefact par famille.
Ton panneau latéral, généré et testé en ligne
Décris les lignes que tu veux, la fréquence et les sources. Minax écrit le Java, le compile, et tu testes le rendu sur un vrai serveur Paper avant d'installer le .jar.
Créer mon pluginMon arbitrage en trois lignes
Le scintillement n’est pas une fatalité, c’est une clé qui bouge : fixe la clé, fais porter le texte par le préfixe d’une Team, et le problème disparaît définitivement. Rafraîchis toutes les vingt ticks, jamais à chaque tick, et ne redessine que ce qui a changé. Et surtout, ne laisse jamais une requête de base de données entrer dans ta boucle d’affichage : c’est là que se perd le TPS, pas dans les paquets de scoreboard.
Sous vingt joueurs, l’API Bukkit avec des Teams suffit. Au-dessus, ou dès que le panneau devient personnel, FastBoard. Pour la suite, notre guide d’optimisation couvre le reste du budget par tick, et si un chargement se passe mal, le guide de dépannage liste les erreurs par ordre de fréquence.


