Un serveur qui grandit finit toujours par avoir un site. Et un site finit toujours par vouloir parler au jeu : une boutique doit livrer un grade, un classement doit s'afficher hors du jeu, un compte Discord doit être relié à un pseudo, un boss tombé doit déclencher une annonce. Tout ça marche très bien, à condition de comprendre qu'il ne s'agit pas d'un tuyau mais de deux, qu'ils n'ont pas les mêmes contraintes, et que le seul endroit où l'on ne fait jamais de réseau est précisément celui où l'on est tenté de le faire.
Les deux sens de circulation
Sortant et entrant ne se traitent pas de la même façon
Le premier sens est le jeu qui pousse vers le web. Il se passe quelque chose dans le monde, et le site doit finir par le savoir : un joueur se connecte, un score change, un boss meurt. Le serveur de jeu est ici la source de vérité, et un retard de quelques secondes ne gêne personne.
Le second sens est le web qui pousse vers le jeu. Un paiement est validé, un modérateur clique dans le CMS, une récompense doit tomber. Cette fois c'est le site qui détient la vérité, le joueur attend, et une erreur ne se voit pas dans un graphe de TPS mais dans un ticket de support qui dit « j'ai payé et je n'ai rien reçu ». Les deux sens n'échouent pas de la même manière, donc ils ne se codent pas de la même manière.
| Le jeu pousse (sortant) | Le web pousse (entrant) | |
|---|---|---|
| Exemples | Kill de boss, score, connexion | Achat payé, grade à livrer |
| Déclencheur | Un événement du monde | Une action sur le site |
| Tolérance au retard | Élevée, quelques secondes | Faible, le joueur attend |
| Risque principal | Perdre l’événement | Livrer deux fois |
| Ce qu’il faut | File d’attente et relance | Identifiant de livraison |
| Port à ouvrir | Aucun | Aucun, le plugin tire |
Cas 1 : la boutique en ligne qui doit livrer un grade
Le cas le plus demandé, et le plus mal câblé
La chaîne correcte comporte quatre maillons, et l'ordre compte. Le paiement est confirmé côté site. Le site écrit une ligne de livraison avec un identifiant unique, un compte destinataire et ce qu'il faut appliquer. Le plugin, depuis un thread asynchrone, demande la liste des livraisons en attente. Il applique, puis il confirme. Ce n'est qu'après la confirmation que le site considère la livraison terminée.
Deux détails font toute la différence entre un système qui tient et un système qui génère des litiges. Le premier est l'identifiant de livraison : si le plugin relit la même ligne deux fois, parce qu'un redémarrage est tombé entre l'application et la confirmation, il doit reconnaître qu'il a déjà donné le grade. Le second est le joueur hors ligne : une livraison qui arrive alors que son destinataire est déconnecté ne se jette pas, elle se garde pour la prochaine connexion.
package fun.minax.bridge;
import org.bukkit.entity.Player;
import java.util.List;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
/** Tache planifiee en ASYNCHRONE : c'est ici, et nulle part ailleurs, que l'on parle au reseau. */
public final class DeliveryPuller implements Runnable {
private final WebBridgePlugin plugin;
private final Set<String> alreadyApplied = ConcurrentHashMap.newKeySet();
public DeliveryPuller(WebBridgePlugin plugin) {
this.plugin = plugin;
}
@Override
public void run() {
List<Delivery> pending = plugin.fetchPending();
for (Delivery delivery : pending) {
if (!alreadyApplied.add(delivery.id())) {
continue; // deja livre : un doublon ne redonne pas le grade
}
Player player = plugin.getServer().getPlayer(delivery.target());
if (player == null) {
plugin.keepForNextJoin(delivery); // hors ligne : on garde, on ne jette pas
continue;
}
plugin.onOwnerThread(player, () -> plugin.apply(delivery, player));
}
}
public record Delivery(String id, UUID target, String payload) {
}
}Ce que tu vas casser
Pourquoi une file d'attente plutôt qu'un appel direct
La différence entre un événement retardé et un événement perdu
Appeler le site directement depuis un écouteur d'événement paraît plus court. En réalité cela lie la santé de ton serveur de jeu à la disponibilité d'une machine que tu ne contrôles pas. Le site répond en trois secondes parce que l'hébergeur mutualise, et ton tick dure trois secondes. Le site est en maintenance, et l'événement est perdu, sans trace, sans relance.
Une file d'attente casse ce couplage. L'écouteur empile un message et rend la main immédiatement. Une tâche asynchrone vide la file par paquets, et remet les éléments dans la file si l'envoi échoue. Le site peut disparaître une heure, tes événements attendent.
package fun.minax.bridge;
import org.bukkit.plugin.java.JavaPlugin;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ConcurrentLinkedQueue;
public final class WebBridgePlugin extends JavaPlugin {
private final ConcurrentLinkedQueue<String> outbox = new ConcurrentLinkedQueue<>();
private HttpClient http;
@Override
public void onEnable() {
saveDefaultConfig();
this.http = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(5))
.build();
getServer().getPluginManager().registerEvents(new GameEventListener(this), this);
getServer().getScheduler().runTaskTimerAsynchronously(this, this::flush, 100L, 100L);
getServer().getScheduler().runTaskTimerAsynchronously(this, new DeliveryPuller(this), 200L, 600L);
}
/** Appelable depuis n'importe quel thread : on empile, on ne parle pas au reseau. */
public void enqueue(String jsonEvent) {
outbox.add(jsonEvent);
}
private void flush() {
List<String> batch = new ArrayList<>();
String item;
while (batch.size() < 50 && (item = outbox.poll()) != null) {
batch.add(item);
}
if (batch.isEmpty()) {
return;
}
String body = "[" + String.join(",", batch) + "]";
String timestamp = String.valueOf(System.currentTimeMillis() / 1000L);
try {
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(getConfig().getString("endpoint", "")))
.header("Content-Type", "application/json")
.header("X-Bridge-Timestamp", timestamp)
.header("X-Bridge-Signature", Hmac.sign(timestamp + "." + body, secret()))
.timeout(Duration.ofSeconds(10))
.POST(HttpRequest.BodyPublishers.ofString(body))
.build();
HttpResponse<String> response = http.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() >= 500) {
batch.forEach(outbox::add); // panne du site : on remet en file
}
} catch (Exception exception) {
batch.forEach(outbox::add);
getLogger().warning("Envoi reporte, file conservee : " + exception.getMessage());
}
}
private String secret() {
return getConfig().getString("secret", "");
}
}Cette version garde la file en mémoire, donc un redémarrage la perd. Pour une boutique, c'est inacceptable et il faut la rendre durable. Bonne nouvelle : le pilote JDBC SQLite est déjà fourni avec Paper, tu n'as donc aucune dépendance à ajouter pour persister ta file. HikariCP, lui, n'est pas fourni. Le jour où tu en as besoin, il se déclare dans le bloc libraries: de plugin.yml, que le serveur télécharge au démarrage, au lieu d'être embarqué dans le .jar.
La commande /reload tue ta file
/reload laisse derrière lui des tâches et des écouteurs orphelins. Ta tâche de vidage peut se retrouver dupliquée, ou au contraire arrêtée pendant que la file continue de se remplir. Sur un serveur qui pousse des paiements, redémarre proprement, toujours.Pourquoi un appel réseau ne part jamais du thread principal
Le seul thread qu'il ne faut jamais bloquer
Un serveur tourne à 20 ticks par seconde, ce qui laisse 50 millisecondes par tick pour tout : les mobs, la redstone, la génération de terrain et ton plugin. Un aller-retour HTTP vers un site web n'offre aucune garantie de tenir dans ce budget, et la résolution DNS non plus. Le code suivant est la cause la plus fréquente de « mon serveur lag depuis que j'ai installé le pont ».
// A NE PAS FAIRE : le reseau depuis le thread principal
@EventHandler
public void onJoin(PlayerJoinEvent event) throws Exception {
String body = """
{"player": "%s"}
""".formatted(event.getPlayer().getName());
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://mon-site.fr/api/join"))
.POST(HttpRequest.BodyPublishers.ofString(body))
.build();
// Chaque milliseconde passee ici est prise sur le tick du serveur.
HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.ofString());
}La même règle vaut pour la base de données : un accès qui bloque le thread principal fait chuter le TPS, qu'il parte vers MySQL à l'autre bout du réseau ou vers un fichier SQLite local. Le schéma sain est toujours le même. Le réseau et la base sur un thread asynchrone, et le retour vers l'API du serveur sur le thread propriétaire de l'entité ou de la région.
Ce « thread propriétaire » n'est pas une précaution de style. Sur Folia, les régions du monde s'exécutent sur plusieurs threads, et un plugin qui suppose un thread principal unique provoque des crashs. Il faut passer par le RegionScheduler pour un travail lié à un endroit, et par le planificateur de l'entité pour un travail lié à un joueur. Voir la documentation Paper et la Javadoc pour les signatures exactes.
/** Execute l'action sur le bon thread, que le serveur soit Paper ou Folia. */
public void onOwnerThread(Player player, Runnable action) {
if (FOLIA) {
player.getScheduler().run(this, task -> action.run(), null);
} else {
getServer().getScheduler().runTask(this, action);
}
}
private static final boolean FOLIA = detectFolia();
private static boolean detectFolia() {
try {
Class.forName("io.papermc.paper.threadedregions.RegionizedServer");
return true;
} catch (ClassNotFoundException notFolia) {
return false;
}
}Cas 2 : le classement affiché sur le site
Le sens sortant, avec un piège de conception
Ici la tentation est de faire lire par le site la base du plugin. C'est une mauvaise idée pour trois raisons. Le fichier SQLite vit sur la machine du jeu, donc il faudrait l'exposer. Une lecture concurrente pose un verrou sur un fichier dont le serveur a besoin. Et la page d'accueil du site déclenche alors une requête sur la machine qui fait tourner le monde, exactement au moment où tu ne veux pas d'invités.
Le bon modèle est un instantané. Toutes les cinq minutes, une tâche asynchrone calcule le top cinquante et l'envoie au site, qui le sert depuis son propre cache. Le classement devient une donnée du site, plus une donnée du jeu. Le trafic web n'a alors aucun effet sur le TPS, et le classement survit à un serveur de jeu éteint.
Envoie toujours les deux clés
Cas 3 : lier un compte Discord et un compte en jeu
La preuve doit naître dans le jeu
La version fausse de cette fonctionnalité est un champ « ton pseudo Minecraft » sur le site. N'importe qui peut y écrire le nom d'un autre, et récupérer ses achats. Le lien doit partir du jeu, parce que c'est le seul endroit où l'identité est déjà établie par la session du serveur.
Le joueur tape /link en jeu. Le plugin fabrique un code court, à usage unique, valable dix minutes, et le pousse vers le site par la file sortante. Le joueur colle ce code sur le site ou dans le bot Discord. Le site vérifie que le code existe, n'est pas expiré, n'a pas déjà servi, puis écrit le lien. Le code circule dans le sens jeu vers web, la confiance suit le même sens.
private static final SecureRandom RANDOM = new SecureRandom();
private final Map<String, PendingLink> pendingCodes = new ConcurrentHashMap<>();
/** Un code court, a usage unique, valable dix minutes. */
public String createLinkCode(Player player) {
byte[] raw = new byte[4];
RANDOM.nextBytes(raw);
String code = HexFormat.of().withUpperCase().formatHex(raw);
long expiresAt = System.currentTimeMillis() + Duration.ofMinutes(10).toMillis();
pendingCodes.put(code, new PendingLink(player.getUniqueId(), player.getName(), expiresAt));
return code;
}
public record PendingLink(UUID uuid, String name, long expiresAt) {
}Cas 4 : le webhook qui annonce un événement
Sortant, groupé, et compatible Adventure 5
Annoncer hors du jeu qu'un boss est tombé ou qu'un événement démarre est le cas le plus simple, parce qu'il ne demande rien en retour. Il passe par la même file sortante. La seule règle supplémentaire concerne le débit : ne fais pas un appel HTTP par kill. Groupe, et n'envoie qu'un paquet toutes les cinq secondes. Les services qui reçoivent des webhooks limitent le nombre d'appels, et un plugin qui se fait limiter perd ses messages.
Le piège Adventure 5
Sécurité : une clé partagée, pas un endpoint ouvert
Le point où la plupart des ponts artisanaux échouent
Un endpoint qui accepte « donne le grade VIP à ce joueur » et ne vérifie que l'existence du joueur est un endpoint ouvert. La bonne façon tient en trois éléments. Un secret partagé, connu du site et du plugin, jamais présent dans une URL. Une signature HMAC-SHA256 calculée sur le corps exact de la requête, avec un horodatage inclus dans la signature pour qu'il ne puisse pas être réécrit. Une comparaison en temps constant, faute de quoi la vérification elle-même fuit de l'information.
package fun.minax.bridge;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.HexFormat;
public final class Hmac {
private Hmac() {
}
public static String sign(String payload, String secret) throws Exception {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
return HexFormat.of().formatHex(mac.doFinal(payload.getBytes(StandardCharsets.UTF_8)));
}
/** Comparaison en temps constant : un equals() classique fuit la longueur du prefixe correct. */
public static boolean verify(String payload, String secret, String received) throws Exception {
byte[] expected = sign(payload, secret).getBytes(StandardCharsets.UTF_8);
return MessageDigest.isEqual(expected, received.getBytes(StandardCharsets.UTF_8));
}
}Reste la question du sens entrant. Mon avis est net : je n'ouvre jamais de port d'écoute HTTP dans le processus du serveur Minecraft. Le plugin vit dans la même machine virtuelle que le monde, une requête mal formée ou un flot de connexions se paie directement en TPS, et l'adresse IP du serveur de jeu devient une cible. L'endpoint entrant reste sur le site, et c'est le plugin qui vient le lire.
Le piège numéro un : l'UUID en mode hors ligne
75,1 % des serveurs sont concernés
Voici la mesure que presque aucun tutoriel de boutique ne mentionne. 75,1 % des serveurs tournent en mode hors ligne. Dans ce mode, l'UUID d'un joueur est dérivé de son pseudo. Il ne vient pas de Mojang, il est calculé à partir du nom.
La conséquence est brutale pour un pont web. Si le joueur change de pseudo, ou si tu passes un jour le serveur en mode en ligne, la clé change et toutes les commandes enregistrées sur l'ancien UUID deviennent orphelines. Le joueur a bien payé, la ligne existe bien dans ta base, et pourtant personne ne la réclamera jamais. C'est la première cause des litiges de boutique, et elle se joue au moment du choix de la clé primaire, pas au moment du bug.
La parade est un identifiant de compte interne, stable, que tu génères toi-même. L'UUID et le pseudo deviennent des attributs de ce compte, des attributs qui peuvent changer. Les achats, eux, ne pointent que sur le compte.
CREATE TABLE account (
account_id TEXT PRIMARY KEY, -- genere par toi, ne change jamais
minecraft_uuid TEXT NOT NULL, -- attribut : peut changer en mode hors ligne
minecraft_name TEXT NOT NULL, -- attribut : change a chaque renommage
discord_id TEXT,
updated_at INTEGER NOT NULL
);
CREATE UNIQUE INDEX account_by_uuid ON account(minecraft_uuid);
CREATE TABLE delivery (
delivery_id TEXT PRIMARY KEY, -- l'identifiant qui rend la livraison idempotente
account_id TEXT NOT NULL REFERENCES account(account_id),
payload TEXT NOT NULL,
applied_at INTEGER -- NULL tant que le jeu n'a pas confirme
);Le test qui révèle le problème en une minute
Sur quelle version brancher le pont
Le parc réel, pas la dernière version annoncée
Minecraft est passé au versionnage calendaire, la dernière version est la 26.2, sortie le 16 juin 2026. Ce n'est pas pour autant la version à viser. Un relevé bStats sur 62 948 serveurs donne une image très différente de celle des annonces.
| Part du parc | Ce que ça implique pour ton pont | |
|---|---|---|
| 1.21.11 | 36,4 % | La cible majoritaire, de loin |
| 26.1.2 | 14,9 % | La cible calendaire raisonnable en production |
| 26.2 | 7,4 % | Pas de build Paper stable, à éviter en production |
| 1.21.4 | 7,2 % | Du 1.21 plus ancien, encore bien présent |
La famille 1.21 reste majoritaire. Concrètement, un pont annoncé en api-version: '1.21' touche la plus grande part du parc. Côté compilation, Java 25 est le plancher des versions calendaires, mais compiler en Java 21 reste le bon choix : c'est ce que fait tourner la majorité du parc, et un .jar Java 21 se charge sur un runtime plus récent. L'inverse est faux. Dernier point utile pour ton build : Minecraft est désobfusqué depuis la 26.1 et le remapper de Paper a disparu, il n'y a donc plus d'étape de remapping à prévoir sur les cibles calendaires.
Mon arbitrage
Ce que je recommande, et à partir de quand ça change
En dessous de 50 joueurs simultanés, et pour quelques dizaines de livraisons par jour, je recommande le tirage simple : le plugin interroge le site toutes les trente secondes. Le joueur attend au pire une demi-minute après son achat, ce que personne ne remarque, et tu n'as strictement rien à sécuriser en entrée puisque rien n'entre. C'est le meilleur rapport robustesse sur complexité, et c'est là que la plupart des serveurs devraient s'arrêter.
Au-delà, quand le grade doit tomber en moins d'une seconde parce que le joueur regarde son écran en achetant, je raccourcis l'intervalle à cinq secondes plutôt que d'ouvrir un port. Un tirage court reste plus simple à sécuriser qu'un endpoint entrant, et coûte quelques requêtes HTTP par minute, ce qui n'est rien.
Sur un réseau de plusieurs serveurs derrière un proxy, la file cesse d'être un détail d'implémentation et devient un composant à part. Un seul service détient les livraisons en attente, chaque serveur de jeu tire les siennes, et le marquage « appliqué » vit dans ce service et nulle part ailleurs. Sinon deux serveurs appliquent la même livraison, et l'identifiant de livraison local ne les protège pas l'un de l'autre.
Un pont web généré, testé, puis installé
Décris la boutique, le classement ou le webhook que tu veux brancher. Minax écrit le Java, le compile et te rend un .jar que tu peux tester en ligne avant de le poser sur ton serveur.
Créer mon pluginConclusion
Retiens cinq choses. Deux sens, deux jeux de contraintes. Une file d'attente entre les deux, jamais un appel direct. Aucun réseau et aucune base sur le thread propriétaire du monde. Une clé partagée avec signature, et un plugin qui tire plutôt qu'un port qui écoute. Et avant d'écrire la première ligne, choisis une clé primaire qui survit à un changement de pseudo, parce que 75,1 % des serveurs sont dans le cas où l'UUID n'en est pas une. Pour la suite, notre guide sur les plugins d'économie détaille le stockage des soldes, et le guide de dépannage couvre les erreurs de chargement que tu croiseras au premier déploiement.



