Bukkit & Folia builds
The two builds are feature-identical. Every difference is about one question: which thread does this task belong to?
Why two builds
Folia splits the world into independent regions, each ticked by its own thread. There is no main thread at all, and Bukkit.getScheduler() does not exist — not "behaves differently", but the class is absent.
So every "do this later" has to answer a new question: which region owns it?
| Need | Bukkit way | Folia way |
|---|---|---|
| Detonate a firework one tick later | runTaskLater(plugin, task, 1L) | Run delayed on the firework entity's region |
| Open a GUI | runTask(plugin, this::open) | Run on the viewer entity's region |
| Return to the "main" thread after async work | runTask(plugin, ...) | Run on the global region thread |
| Register command aliases | Touch CommandMap directly | Hop to the global region thread first |
On top of that, Folia only loads plugins that declare folia-supported: true in plugin.yml. That alone forces a separate jar.
The reverse is also true: the Folia build cannot run on plain Paper. It depends on the entity and region schedulers from folia-api, which Paper does not ship.
Code differences
A file-by-file diff comes down to nine places:
| File | Difference |
|---|---|
util/Schedulers.java | Folia only — wraps every scheduling entry point; no other class touches a scheduler |
CreeperConfettiPro.java | Adds volatile caches for confettiChance and debugEnabled, plus reloadCachedSettings() |
events/CreeperExplodeListener.java | Reads the chance from the cache; detonates through runOnEntityLater |
effects/FireworkUtil.java | spawnAndDetonate schedules on the firework entity's region |
effects/FireworkStyleManager.java | Every public method is synchronized |
LanguageManager.java | currentLanguage becomes volatile; message accessors become synchronized; the GeoIP callback uses runGlobal |
commands/sub/ReloadCommand.java | Refreshes the caches first, moves alias registration to the global region thread |
gui/BaseGui.java | openLater() opens on the viewer's region |
plugin.yml | api-version: '1.19' and folia-supported: true |
Nothing else changed. Same config file, same commands, same GUI, same language files — configuration files can be copied between the two builds. The jars themselves are not interchangeable: pick the one that matches your server.
The scheduler wrapper
Schedulers is a small static utility that turns "where should this run" into six methods:
| Method | Runs on | Typical use |
|---|---|---|
runOnEntity(plugin, entity, task) | The entity's region, next tick | Opening a GUI, entity work |
runOnEntityLater(plugin, entity, task, delay) | The entity's region, delayed | Delayed firework detonation |
runAtLocation(plugin, loc, task) | The region owning the location | Position-based tasks |
runAtLocationLater(plugin, loc, task, delay) | Same, delayed | Same, with a delay |
runGlobal(plugin, task) | The global region thread | Command registration, non-region state |
runAsync(plugin, task) | Any async thread | Network, file IO |
runAsyncDelayed(plugin, task, delay, unit) | Any async thread, delayed | The delayed update check on startup |
The core is a handful of lines, but it handles the Folia traps:
public static void runOnEntity(Plugin plugin, Entity entity, Runnable task) {
entity.getScheduler().run(plugin, scheduled -> task.run(), () -> { });
}
public static void runOnEntityLater(Plugin plugin, Entity entity, Runnable task, long delayTicks) {
entity.getScheduler().runDelayed(plugin, scheduled -> task.run(), () -> { },
Math.max(1L, delayTicks)); // Folia requires a delay of at least 1 tick
}
public static void runGlobal(Plugin plugin, Runnable task) {
Bukkit.getGlobalRegionScheduler().run(plugin, scheduled -> task.run());
}
Math.max(1L, delayTicks)— Folia rejects a delay of 0, so it is clamped to 1;- the third argument is the cancellation callback, left empty: the plugin has no delayed tasks to clean up;
- once an entity is removed its scheduler is invalidated automatically, so there is no "operating on a dead entity" error.
Thread-safety strategy
Swapping schedulers is not enough. On Folia, players in different regions can trigger explosions simultaneously while an admin edits the config on another thread — shared state needs protection. The plugin uses two mechanisms.
1. Volatile caches: a safe snapshot for region threads
Bukkit.getConfig() returns a FileConfiguration that is not thread-safe. The listener used to read getConfig().getDouble("confetti_chance") directly, which on Folia means several region threads reading a map that may be mid-write.
The Folia build reads the values into volatile fields on enable and reload; region threads only touch the fields:
private volatile double confettiChance = 100.0D;
private volatile boolean debugEnabled = false;
public void reloadCachedSettings() {
this.confettiChance = getConfig().getDouble("confetti_chance", 100.0D);
this.debugEnabled = getConfig().getBoolean("debug", false);
}
/cc chance and /cc reload both refresh the cache, so changes apply immediately without a restart.
2. synchronized: guarding the collections
FireworkStyleManager keeps two LinkedHashMaps, and reads (drawing a style) can happen on a different thread than writes (add, remove, chance change). Every public method is synchronized in the Folia build:
public synchronized List<FireworkEffect> getRandomEffects() { ... }
public synchronized boolean setChance(String name, double value) { ... }
public synchronized void save() { ... }
The same applies to LanguageManager: its messages map is rebuilt wholesale on reload, so loadMessages(), getMessage() and getConsoleMessage() are locked, and currentLanguage is volatile.
The locks are tiny — one map operation or one string build — never held across methods, and never wrap IO or scheduling. So they cannot cause noticeable stalls on a region thread.
3. Ordering: coming back to the right thread
Language detection needs the network, so it must be async; but the result touches Bukkit state, so it must hop back. That is the one place the two builds differ structurally:
// Bukkit build
runTaskAsynchronously(plugin, () -> {
String country = resolver.resolve();
runTask(plugin, () -> applyCountry(country));
});
// Folia build
Schedulers.runAsync(plugin, () -> {
String country = resolver.resolve();
Schedulers.runGlobal(plugin, () -> applyCountry(country));
});
Porting checklist
If you extend this project, or port another plugin to Folia, run through these:
- Replace every
Bukkit.getScheduler()with a region / global / async scheduler; - before touching an entity or block, work out which region owns it, then use
entity.getScheduler()orgetRegionScheduler(); - never pass 0 as a delay — one tick minimum;
- never assume "the current thread is the main thread"; handlers can run on any region thread;
- shared mutable state needs a lock or a volatile snapshot;
- add
folia-supported: truetoplugin.ymland raiseapi-versionto 1.19+; - compile for Java 17 and depend on
dev.folia:folia-api.