Bukkit, Spigot, Paper, Folia: What You're Actually Running

There is a fifteen-year fork tree underneath every Minecraft server, and most admins copy a jar without knowing what's in it. Here's the whole chain, with the code — including the config key most DAB guides get wrong.

Bukkit, Spigot, Paper, Folia: What You're Actually Running
Photo by Johny Goerend / Unsplash

There is a fork tree spanning fifteen years underneath every Minecraft server running today. Most admins copy a jar file and start the server without knowing what they're running, how it got there, or what the code does differently from everything upstream of it.

That's fine right up until something breaks and the answer depends on which layer you're standing on.

This covers the whole chain. Bukkit's API design. CraftBukkit's implementation. Spigot's patches and why they're losing relevance. Paper's December 2024 hard fork. Pufferfish's DAB algorithm at the maths level. Purpur's configuration system. Folia's threading model. Leaf as the current kitchen-sink fork. And the dead projects whose code still runs inside everything you're using.

2010: Where This Started

Running a server in 2010 meant patching the vanilla jar directly. Want chat commands, permissions, teleportation? You modded bytecode. Every fork was incompatible with every other fork, and every Mojang update meant redoing your patches from scratch.

Bukkit's team separated two things that had been tangled together: the plugin API and the server implementation. Bukkit was the API. CraftBukkit was the implementation that ran the server and called into it. Plugins wrote against Bukkit and ran on CraftBukkit. When Mojang updated, only CraftBukkit changed.

That separation is the foundation everything else sits on, fifteen years later.

The Bukkit API

Bukkit is a Java interface specification. A plugin talks to the game through Bukkit's types; the implementation behind them can be anything that satisfies the contract.

Every plugin starts by extending JavaPlugin:

// Every Bukkit plugin starts here. onEnable() fires when the server loads
// the plugin. Registration of listeners, commands and tasks goes here.
// Note the class implements CommandExecutor -- without it, setExecutor(this)
// will not compile.
 
public class MyPlugin extends JavaPlugin implements Listener, CommandExecutor {
 
    @Override
    public void onEnable() {
        getServer().getPluginManager().registerEvents(this, this);
        getCommand("hello").setExecutor(this);
 
        // Repeating task, every 100 ticks (5 seconds)
        getServer().getScheduler().scheduleSyncRepeatingTask(this, () -> {
            getServer().broadcastMessage("Server is alive.");
        }, 0L, 100L);
    }
 
    @Override
    public boolean onCommand(CommandSender sender, Command cmd, String label, String[] args) {
        sender.sendMessage("Hello.");
        return true;
    }
}

That implements CommandExecutor is not decoration. setExecutor() takes a CommandExecutor, and a class that doesn't implement it won't compile — a detail dropped from a lot of tutorial code.

getServer() returns a Server interface with 200+ methods covering world manipulation, player lookup, scheduler access. CraftBukkit implements it with CraftServer. Every fork in the ecosystem provides its own implementation of that same interface.

Events work through registration and dispatch. Plugins register listeners; the server calls PluginManager.callEvent() on every game action; listeners fire in priority order:

public class MyListener implements Listener {
 
    @EventHandler(priority = EventPriority.NORMAL, ignoreCancelled = true)
    public void onPlayerJoin(PlayerJoinEvent event) {
        event.getPlayer().sendMessage("Welcome, " + event.getPlayer().getName());
    }
 
    @EventHandler(priority = EventPriority.HIGHEST)
    public void onBlockBreak(BlockBreakEvent event) {
        // HIGHEST runs after NORMAL -- can override earlier decisions
        if (event.getBlock().getType() == Material.BEDROCK) {
            event.setCancelled(true);
        }
    }
}

Hold onto scheduleSyncRepeatingTask. It schedules to the main thread by definition, and that is precisely what Folia deletes.

CraftBukkit: The Translation Layer

CraftBukkit wraps Minecraft's internal classes with Bukkit API types. Every Player in your plugin is a CraftPlayer wrapping a ServerPlayer. Every Block wraps a BlockPos and a LevelChunk.

Those internal (NMS) names change with every Minecraft version. CraftBukkit's job is absorbing that churn so plugin code doesn't have to. Spigot maintains it today as the base of their distribution; Paper absorbed it during the hard fork and maintains it independently.

Spigot: The First Performance Fork

Spigot forked CraftBukkit in 2012 to fix performance problems the original team wasn't prioritising:

Entity activation ranges. Entities far from players tick less often — the conceptual ancestor of everything in this article.

Chunk loading work. Less work loading from disk, batched chunk sends, view distance separated from simulation distance.

spigot.yml. Knobs server.properties never exposed: mob spawn rates, activation ranges, Netty thread counts.

Spigot also introduced BuildTools as a legal workaround. After Bukkit's 2014 DMCA dispute, Spigot couldn't distribute pre-built jars containing Mojang's code. BuildTools downloads the vanilla jar, decompiles it, applies patches on your machine, and produces the jar locally. Spigot never distributes Mojang's code.

Why Spigot Is Fading

On 13 December 2024, Paper hard-forked. Before that, Paper pulled every Spigot update into its own codebase before applying its own patches — Paper was Spigot plus more, and its release cycle was gated on Spigot shipping first, from a closed development process.

Paper's announcement said it directly:

Since the project's inception, Paper has been built on top of Spigot... As a result of such divergence, our strict policy to stay up-to-date with Spigot has been limiting the project, most noticeably with slower version updates since its updates to snapshots, pre-releases and release candidates are worked on behind closed doors.

From 1.21.4, Paper applies Mojang's changes independently and no longer waits. It also switched to official Mojang mappings, so NMS class names are stable and readable rather than obfuscated.

Spigot's remaining case is backwards compatibility with very old plugins. For a new server there is no reason to start there.

Paper and the Mappings Change

Paper is now its own project, roughly 1,600 patches on top of vanilla. The mappings switch is the part worth understanding if you write plugins or read Paper's source.

Before the fork, NMS names were obfuscated. A class Mojang calls ServerPlayer appeared as EntityPlayer under CraftBukkit's mappings, inside a version-relocated package like net.minecraft.server.v1_21_R1. Every update could rename things arbitrarily, so anyone touching NMS through reflection rewrote their calls each version.

With Mojang mappings, the names are Mojang's own — ServerPlayer, Level, BlockPos, ChunkMap — and stable unless Mojang renames them. Reflection-based access now survives minor updates.

Paper also extends Bukkit's events with async variants for read-only work:

// Paper's async chat event fires OFF the main thread.
// Safe here: reading the player and the message.
// Unsafe here: world.setBlock(), player.teleport(), anything touching world state.
 
public class ChatListener implements Listener {
 
    @EventHandler
    public void onAsyncChat(AsyncChatEvent event) {
        Player player = event.getPlayer();
        Component message = event.message();
 
        if (containsProfanity(message)) {
            event.setCancelled(true);
            // Hop back to the main thread for anything touching world state
            Bukkit.getScheduler().runTask(plugin, () ->
                player.sendMessage(Component.text("Message blocked.")));
        }
    }
}

Chat events firing synchronously block the tick. On a busy server with active chat the cumulative cost is measurable, and moving it off the critical path is free latency.

Pufferfish and DAB

Pufferfish is a Paper fork from the team behind Pufferfish Host, aimed at large servers. Its headline patch is DAB — Distance-Adjusted Brain ticking.

The problem: entity brains, the AI system governing villager schedules, mob behaviour and pathfinding, are expensive. Vanilla runs a full brain tick for every entity every tick regardless of distance from any player. A villager restocking trades 300 blocks away runs the same processing as one a player is actively trading with.

The tick interval scales with the square of distance:

interval = distanceToPlayer² / 2^activation_dist_mod

With the default activation-dist-mod of 8, the divisor is 256:

At  32 blocks:  1024 / 256 =  4  -> brain ticks every 4 server ticks
At  64 blocks:  4096 / 256 = 16  -> every 16 ticks
At 128 blocks: 16384 / 256 = 64  -> every 64 ticks, roughly 3 seconds

Two things about that formula get misreported constantly.

It's an interval, not a frequency, despite what it's usually called. A bigger number means the entity ticks less often. And because it's an interval, values below 1 are meaningless — which is why the inner radius exists.

start-distance makes short-range examples moot. Default is 12 blocks, and entities closer than that are not affected by DAB at all. Any worked example at 10 blocks is describing behaviour that doesn't happen.

The direction of activation-dist-mod is the part to get right, and Pufferfish's own documentation states it plainly: increase this value to make distant entities tick more frequently, decrease it to make them tick less. Higher mod means a bigger divisor means a shorter interval. It should generally be left at 8.

The real defaults, as shipped:

dab:
  enabled: true
  start-distance: 12        # inside this radius, no throttling at all
  max-tick-freq: 20         # never tick less often than every 20 ticks
  activation-dist-mod: 8    # leave this alone unless you have a measurement
  blacklisted-entities: []  # these always tick at full rate
 
inactive-goal-selector-throttle: true
 
misc:
  disable-method-profiler: true

Note inactive-goal-selector-throttle — not -disable, which is a key that doesn't exist — and disable-method-profiler living under misc: rather than at the top level. Both are easy to get wrong from secondhand configs, and a mistyped YAML key is silently ignored.

The blacklist deserves thought. Villager restocking, breeding cooldowns and profession changes all depend on brain ticks. If your players run trading halls you may want villager blacklisted to avoid trade desync — at the cost of every villager ticking at full rate regardless of distance, which is where the savings were. Profile first and check whether villager brain ticks are actually in your flame graph.

SIMD Map Rendering

Pufferfish accelerates MapCanvas rendering with Java's Vector API. Vanilla processes one pixel at a time — one byte operation per pixel, per frame, per player looking at a map. On a server with dozens of image maps that's real main-thread time.

The Vector API has been an incubating module since Java 16, and is still incubating — it has not graduated to preview or final. That matters practically: incubator modules require --add-modules jdk.incubator.vector and can change between releases.

// Vanilla: one pixel per iteration
for (int i = 0; i < pixels.length; i++) {
    output[i] = colorMap[pixels[i] & 0xFF];
}
 
// SIMD: 16-32 bytes per instruction via jdk.incubator.vector
VectorSpecies<Byte> SPECIES = ByteVector.SPECIES_256;
int loopBound = SPECIES.loopBound(pixels.length);
 
for (int i = 0; i < loopBound; i += SPECIES.length()) {
    ByteVector chunk = ByteVector.fromArray(SPECIES, pixels, i);
    chunk.intoArray(output, i);
}
// Tail: whatever didn't fit the vector width
for (int i = loopBound; i < pixels.length; i++) {
    output[i] = colorMap[pixels[i] & 0xFF];
}

That is the shape of the optimisation, written to illustrate the pattern rather than lifted from Pufferfish's patch set. If your server has no image maps, this costs nothing and gains nothing.

Pufferfish+ is the paid tier adding async pathfinding and async entity tracking, moving A* computation and entity tracking off the main thread entirely.

Purpur: The Configuration Layer

Purpur sits above Pufferfish and includes all of its patches. The goal isn't more speed — with every extra option off, behaviour is identical to Pufferfish. What you get is purpur.yml.

mobs:
  villager:
    # Villagers stuck in 1x1 cells burn CPU pathfinding somewhere unreachable
    lobotomize:
      enabled: true
      check-interval: 100
 
    brain-ticks: 4
    use-brain-ticks-only-when-lagging: true
 
  zombie:
    aggressive-towards-villager-when-lagging: false

The structural idea worth stealing is the lag threshold:

settings:
  lagging-threshold: 19.0

Below that TPS, every -when-lagging behaviour engages. You are defining, in advance, which gameplay fidelity you sacrifice first under load. Zombie-villager aggression costs CPU. Villager brain ticks cost CPU. A defined degradation hierarchy means critical systems stay up while secondary AI drops off gracefully — which is a far better failure mode than everything degrading at once.

Purpur's reputation for rideable cows and flying squids is earned, but none of it is on by default. The production-relevant options are brain-tick throttling, lobotomise, and the lag threshold.

Because Purpur includes Pufferfish, running Purpur gets you DAB and the rest in one jar.

Folia: Deleting the Main Thread

In any pre-Folia server, thread safety is simple: if you're on the main thread you can do anything, because one thread owns the entire game state.

Folia removes that thread. Not "makes it faster" — removes it. Independent regions each get their own tick loop, ticked in parallel on a thread pool.

How thoroughly it's gone is visible in Paper's own utility class, which Folia patches to make the old assumption unrepresentable. From 0001-Region-Threading-Base.patch in PaperMC/Folia, commit 14b7fee:

     public static void ensureMain(String reason, Runnable run) {
         if (!isMainThread()) {
            if (true) throw new UnsupportedOperationException(); // Folia - region threading
             if (reason != null) {
                 MinecraftServer.LOGGER.warn("Asynchronous " + reason + "!", new IllegalStateException());
             }

if (true) throw is a blunt instrument and exactly the right one. The method that used to mean "get me onto the main thread" now cannot succeed, because there is no main thread to get onto.

Ownership is enforced by assertions throughout the codebase:

        TickThread.ensureTickThread("Cannot tick an entity off-main");
        TickThread.ensureTickThread("Cannot tick player chunk loader async");
        TickThread.ensureTickThread("Closing world off-main");

Violate the rule and you get an exception at the point of the violation, not silent corruption three ticks later.

For plugins, BukkitScheduler is deprecated wholesale and replaced by four schedulers: RegionScheduler (run at a location), EntityScheduler (run for an entity, wherever it currently lives), AsyncScheduler (genuinely off-thread work), and GlobalRegionScheduler (server-wide things like weather and shutdown).

EntityScheduler is where the model gets genuinely different. An entity walks, gets pushed by a piston, crosses a region boundary — and a task queued against it has to follow it to whatever thread now owns it. It can also be removed before the task ever runs, by a different region thread. So Folia's scheduler carries a retirement concept Paper never needed:

    public boolean isRetiredOffThread() {
        synchronized (this.stateLock) {
            return this.tickCount == RETIRED_TICK_COUNT;
        }
    }

with the accompanying javadoc describing retirement as "preventing new tasks from being scheduled and invoking the retired callback on all currently scheduled tasks."

That's why Folia breaks every plugin. It isn't a method rename. Your task no longer runs on a thread — it runs near a thing, and the runtime works out where that thing is when the time comes. Plugin authors have to reason about region ownership for every world-state access, and Folia refuses to load plugins that haven't declared folia-supported: true.

Leaf: The Kitchen-Sink Fork

Leaf is a Paper fork from Winds-Studio that aggregates patches from across the ecosystem — Pufferfish, Purpur, Luminol, Nitori, Moonrise, Plazma, SparklyPaper and several discontinued projects.

Its own contribution beyond aggregation is data-structure work: replacing standard collections in hot paths with faster alternatives. The representative change is swapping Object2ObjectOpenHashMap for Reference2ReferenceOpenHashMap in attribute storage. The difference is that the first calls .equals() for key comparison while the second uses reference equality — and Minecraft's attribute keys are singleton registry objects, so reference equality is both correct and cheaper.

Every entity has an AttributeMap. Every AI tick touches attributes. Eliminating hash computation and .equals() calls across millions of lookups per second is invisible to players and measurable under profiling.

The honest caveat: Leaf is maintained by a small team, and breadth of patches from many upstream sources means more surface area for interaction bugs. That's a risk assessment, not a disqualification. If you have the operational capacity to test updates, it's worth evaluating.

The Dead Forks Running Inside Everything

Tuinity shipped the Starlight lighting engine — a rewrite of Minecraft's light propagation that eliminated most lighting-related lag spikes. Tuinity merged into Paper. Starlight is why modern Paper handles chunk lighting so much better than older servers.

Airplane pioneered distance-based brain throttling before Pufferfish absorbed and extended the idea into DAB. The project was discontinued and its developer moved on; the technique lives on downstream, which is how most good ideas in this ecosystem propagate — by being reimplemented in a fork that outlives the original.

Mohist, Magma, CatServer and Arclight are hybrid forks running mods and plugins together. They work for some combinations and are notorious for bizarre interactions that neither Paper nor Forge will support. Both facts are acknowledged by the projects themselves. If you need mods and plugins on one server, test extensively on staging, and know that security updates land slower than on Paper.

The Inheritance Chain, for Plugin Authors

Bukkit API (interface specification)
    └── Spigot API (extends Bukkit)
        └── Paper API (independent since 1.21.4)
            ├── Pufferfish API   (adds pufferfish.yml access)
            │   └── Purpur API   (adds purpur.yml access)
            ├── Leaf API         (adds leaf config access)
            └── Folia API        (replaces the scheduler entirely)

A plugin targeting paper-api runs on Paper, Pufferfish, Purpur and Leaf. One targeting purpur-api runs only on Purpur and its forks. One using Folia's RegionScheduler compiles against Paper-API and fails at runtime anywhere pre-Folia, because getRegionScheduler() isn't there.

The correct dependency for a new plugin:

<repositories>
    <repository>
        <id>papermc</id>
        <url>https://repo.papermc.io/repository/maven-public/</url>
    </repository>
</repositories>
 
<dependencies>
    <dependency>
        <groupId>io.papermc.paper</groupId>
        <artifactId>paper-api</artifactId>
        <version>1.21.5-R0.1-SNAPSHOT</version>
        <scope>provided</scope>
    </dependency>
</dependencies>

One dependency, four platforms. The only reason to target spigot-api in 2026 is if you specifically need to run on Spigot.

What You're Actually Choosing

Picking a jar is three decisions at once: what plugins can you run, how does the server use your CPU, and who fixes it when it breaks.

Stability and maximum plugin compatibility: Paper. The hard fork means faster updates and independent development. Roughly 1,600 patches covering async chunk loading, entity activation, hopper optimisation and allocation reduction is already more than most servers need.

Maximum performance with vanilla parity: Pufferfish. DAB cuts entity brain cost substantially. Full Paper plugin compatibility.

Configurability plus Pufferfish performance: Purpur. One jar. The lag threshold system and villager lobotomise justify it on their own for servers with heavy trading infrastructure.

Aggregated community patches: Leaf. More patches, more headroom, smaller maintenance team.

High player count, spread-out players, 16+ physical cores, willing to rebuild your plugin stack: Folia. Everything else first.

And the ordering that actually matters: server software beats Java version beats GC flags beats config tuning. Vanilla to Paper is architectural. Java 21 to 25 is incremental. Do them in that order.

The tools to decide with are Spark's profiler for what's slow, its GC monitor for whether the collector is the bottleneck, and the flame graph for whether it's your plugins. Make the call with data rather than a Discord recommendation.

Fifteen years of the Java community building performance and flexibility on top of Mojang's single-threaded game loop. Every choice in that tree made sense given its constraints. Knowing it tells you not just which jar to run but why — and that's the difference between fixing a problem and copying flags until something accidentally works.