English
Español
GitHub
Paper · Folia · Spigot

MagmaLib
the scheduler that flows

One file, zero dependencies, three platforms. Type-safe pipelines, global task tracking, async teleport, and TaskGroups. Everything FoliaLib has — and more.

MyPlugin.java
1
file to copy
0
external deps
3
platforms detected
19
unit tests
New in v3.0.0

cancelAllTasks() for clean shutdown · TaskGroup for per-player/region cancellation · teleportAsync() with platform fallbacks · isPaper() / isSpigot() 3-platform detection · runTimerWhile() · @MainThread / @AnyThread annotations

Installation

Requirements

Paper 1.20+ · Folia 1.20+ · Spigot 1.20+ · Java 17+

Maven

pom.xml
<repositories>
  <repository>
    <id>jitpack.io</id>
    <url>https://jitpack.io</url>
  </repository>
</repositories>

<dependencies>
  <dependency>
    <groupId>com.github.MagmaEnginers</groupId>
    <artifactId>MagmaLib</artifactId>
    <version>3.0.0</version>
    <scope>provided</scope>
  </dependency>
</dependencies>

Gradle (Kotlin DSL)

build.gradle.kts
repositories { maven("https://jitpack.io") }
dependencies {
  compileOnly("com.github.MagmaEnginers:MagmaLib:3.0.0")
}

Alternative: single-file copy

No build tool? Copy MagmaLib.java into your project — zero external dependencies, zero relocation needed.

  1. Download MagmaLib.java
  2. Place it at src/main/java/your/package/MagmaLib.java
  3. Update the package line at the top. Done.

Quick Start

Java
public class MyPlugin extends JavaPlugin {

  private final TaskGroup tasks = MagmaLib.newTaskGroup("my-plugin");

  @Override public void onEnable() {
    // 1. Init — auto-detects Folia / Paper / Spigot
    MagmaLib.init(this);

    // 2. Simple task — correct thread on every platform
    tasks.add(MagmaLib.task(() -> player.sendMessage("Hello!"))
      .with(player).afterTicks(20).run());

    // 3. Type-safe pipeline
    MagmaLib.<Integer>task(() -> computeScore(player))
      .thenApply(s -> s > 100 ? "S rank!" : "Keep going")
      .thenAccept(player::sendMessage)
      .exceptionally(e -> { getLogger().warning(e.getMessage()); return "Error"; })
      .run();

    // 4. Async teleport with platform fallbacks
    MagmaLib.teleportAsync(player, destination)
      .thenAccept(ok -> player.sendMessage(ok ? "Teleported!" : "Failed"));
  }

  @Override public void onDisable() {
    MagmaLib.cancelAllTasks(); // clean shutdown — cancels everything
  }
}

Migration from Bukkit Scheduler

Replacing Bukkit.getScheduler() is mechanical — one-to-one replacements below.

Before (Bukkit)
Bukkit.getScheduler()
  .runTask(plugin, r);
After (MagmaLib)
MagmaLib.task(r).run();
Before
runTaskLater(plugin, r, 20);
After
.afterTicks(20).run();
Before
runTaskTimer(plugin, r, 0, 20);
After
.everyTicks(20).run();
Before
runTaskAsynchronously(plugin, r);
After
.async().run();
Never combine .async() with .with(entity)

They are mutually exclusive. .async() overrides entity routing and sends everything to AsyncScheduler — any Bukkit API call inside will throw AsyncCatcher. Use .with(entity) alone; it already picks the right thread on every platform.

MagmaLib v3 vs FoliaLib

Objective feature comparison

Both libraries solve the same problem. Here's what differs on paper (no pun intended).

Feature MagmaLib v3 FoliaLib
Type-safe composition pipeline✅ thenApply / thenAccept / exceptionally
Global task tracking + cancelAllTasks()✅ ConcurrentHashMap registry
TaskGroup (per-player/region cancel)✅ Named groups
teleportAsync() with platform fallbacks✅ Folia + Paper + Spigot✅ Folia + Paper
3-platform detection (isFolia/isPaper/isSpigot)⚠️ 2 only
runTimerWhile() readable inverse timer
@MainThread / @AnyThread annotations✅ IDE thread hints
Zero-allocation Direct API✅ runDirect*⚠️ Partial
Zero external dependencies✅ Single file❌ Requires shade
Backward compatibility (1.8.8+)❌ 1.20+ only✅ 1.8.8+
Production track record⚠️ v3 is new✅ Years in prod
Unit test suite✅ 19 tests⚠️ Limited
When to choose each
  • MagmaLib — Paper/Folia 1.20+, you want composition pipelines, per-player cancellation, or you're tired of relocation.
  • FoliaLib — you need Spigot 1.8.8 support or want a battle-tested library with years of production history.

Initialization

Java
@Override
public void onEnable() {
    MagmaLib.init(this); // one call — detects platform, caches result
}

@Override
public void onDisable() {
    MagmaLib.cancelAllTasks(); // cancels everything scheduled through MagmaLib
}

init(Plugin)

static void init(Plugin plugin)

Call once in onEnable(). Detects Folia/Paper/Spigot via reflection and caches all three results. Throws IllegalArgumentException if plugin is null.

cancelAllTasks()

static void cancelAllTasks()

Cancels every task ever scheduled through MagmaLib and clears the global registry. Always call in onDisable().

getTrackedTasks()

static Set<Task> getTrackedTasks()

Returns an unmodifiable snapshot of all live tasks. Useful for diagnostics.

pruneRegistry()

static void pruneRegistry()

Removes cancelled/completed tasks from the global registry to prevent memory leaks on long-running servers.

Platform Detection v3.0

v3 detects three platforms, not two. All results are cached in volatile fields after the first call — safe in hot paths.

isFolia()

static boolean isFolia()

true when RegionizedServer is present. Uses RegionScheduler, EntityScheduler, GlobalRegionScheduler.

isPaper()

static boolean isPaper()

true on Paper (non-Folia). Enables native Entity.teleportAsync() in teleportAsync().

isSpigot()

static boolean isSpigot()

true when neither Folia nor Paper extensions are detected. Falls back to synchronous Bukkit scheduler.

Exactly one is always true

After init(), exactly one of isFolia(), isPaper(), isSpigot() returns true. They are mutually exclusive and exhaustive.

Task Builder

Fluent API that automatically routes to the correct Folia scheduler. Every run() call registers the task in the global registry and optionally adds it to a TaskGroup.

task(Runnable) v1.x

TaskBuilder<Void> task(Runnable)

Entry point for fire-and-forget tasks.

task(Supplier<T>) v2.1

<T> TaskBuilder<T> task(Supplier<T>)

Entry point for typed composition pipelines.

.at(Location)

TaskBuilder<T> at(Location)
Paper → main threadFolia → RegionScheduler

.with(Entity)

TaskBuilder<T> with(Entity)
Paper → main threadFolia → EntityScheduler

.afterTicks(n)

TaskBuilder<T> afterTicks(long)

Initial delay in ticks. 1 tick = 50 ms.

.everyTicks(n)

TaskBuilder<T> everyTicks(long)

Repeating period. Makes the task a timer.

.cancelIf()

TaskBuilder<T> cancelIf(BooleanSupplier)

Checked before each execution. If true, skips the run.

.handleException()

TaskBuilder<T> handleException(Consumer<Throwable>)

Custom error handler. Falls back to plugin logger with full context.

.named(String) New

TaskBuilder<T> named(String)

Tags the task. Appears in error logs: [task='X' location=…].

.inGroup(TaskGroup) v3

TaskBuilder<T> inGroup(TaskGroup)

Auto-registers with a TaskGroup on run(). Group can cancel it later.

.unsafe() ⚡ hot path

TaskBuilder<T> unsafe()

Skips chunk-loaded and entity-valid checks. Only when you guarantee them manually.

.run()

Task run()

Dispatches the task. Registers it globally. Returns a cancellable handle.

Composition Pipeline v2.1

Functional API modelled after CompletableFuture. Every step is fully type-checked via generics — no raw casts, no ClassCastException.

task(() → T) thenApply(T → R) thenApply(R → S) thenAccept(S → void) exceptionally(e → T) run()
Java — full pipeline
MagmaLib.<ItemStack[]>task(() -> player.getInventory().getContents())
  .named("InventoryProcessor")
  .at(player.getLocation())
  .inGroup(playerGroup)                              // v3: linked to group
  .thenApply(items -> Arrays.stream(items)
      .filter(Objects::nonNull).count())           // ItemStack[] → long
  .thenApply(count -> "You have " + count + " items") // long → String
  .thenAccept(player::sendMessage)                  // consume
  .exceptionally(e -> {
    getLogger().warning("Error: " + e.getMessage());
    return new ItemStack[0];
  })
  .run();

.thenApply()

<R> TaskBuilder<R> thenApply(Function<T,R>)

Transforms and changes the builder's generic type. Chainable multiple times — safe, no raw casts.

.thenAccept()

TaskBuilder<T> thenAccept(Consumer<T>)

Consumes for a side effect. Multiple calls chain in order.

.exceptionally()

TaskBuilder<T> exceptionally(Function<Throwable,T>)

Catches any exception and returns a fallback to keep the pipeline alive.

Requires Supplier mode

thenApply, thenAccept, exceptionally are only available when using task(Supplier<T>). Calling them on task(Runnable) throws IllegalStateException immediately at config time, not at runtime.

Direct API ⚡ Hot Paths

Zero-builder methods for high-frequency loops. No allocations beyond the lambda. Not registered in the global task registry.

Manual guarantees required

These skip all safety checks. You must ensure: location.getWorld() != null, chunk loaded, entity.isValid(). No exceptions will be thrown for bad inputs.

runDirect(Runnable)

void runDirect(Runnable)

Next tick on global/main thread.

runDirectAt(Location, Runnable)

void runDirectAt(Location, Runnable)

Immediate, region-routed. Best for bulk block operations.

runDirectWith(Entity, Runnable)

void runDirectWith(Entity, Runnable)

Immediate on entity scheduler. For tight per-entity loops.

runDirectLater(Runnable, ticks)

void runDirectLater(Runnable, long ticks)

Delayed global task. Folia: GlobalRegionScheduler receives ticks directly.

runDirectTimer(Runnable, ticks)

void runDirectTimer(Runnable, long ticks)

Repeating global task. Initial delay is always ≥ 1 tick on Folia.

runTimerUntilFast()

Task runTimerUntilFast(Runnable, long, BooleanSupplier)

Self-cancelling timer. Cancels via its own handle — not just skips execution.

Global Task Tracking v3.0

Every task dispatched via the TaskBuilder is automatically registered in a ConcurrentHashMap. This enables clean plugin shutdowns with a single call.

Java — clean shutdown
@Override
public void onDisable() {
    // Cancels ALL tasks scheduled through MagmaLib — timers, delayed, repeating.
    // Logs: "[MagmaLib] Cancelled N task(s) on shutdown."
    MagmaLib.cancelAllTasks();
}
FoliaLib has no equivalent

FoliaLib requires you to track and cancel each task manually. With MagmaLib, one call on onDisable() is enough regardless of how many tasks are running.

cancelAllTasks()

static void cancelAllTasks()

Cancels all running tasks, clears the registry. Logs how many were cancelled.

getTrackedTasks()

static Set<Task> getTrackedTasks()

Returns an unmodifiable snapshot. Iterate to inspect or cancel selectively.

pruneRegistry()

static void pruneRegistry()

Removes dead tasks from the registry. Call periodically on high-throughput plugins to keep memory clean.

TaskGroup v3.0

Named groups of tasks that cancel together. Ideal for per-player effects, per-region machines, or any feature you need to shut down atomically.

Java — per-player task group
// On player join — create a group
TaskGroup group = MagmaLib.newTaskGroup("player-" + player.getUniqueId());

group.add(MagmaLib.task(() -> updateAura(player))
  .with(player).everyTicks(5).run());

group.add(MagmaLib.task(() -> checkCooldown(player))
  .everyTicks(20).run());

// Or use .inGroup() directly on the builder
MagmaLib.task(() -> sendBossBar(player))
  .with(player).everyTicks(2)
  .inGroup(group)   // auto-adds on run()
  .run();

// On player quit — one call cancels everything
group.cancelAll();

add(Task)

Task add(Task)

Registers a task with the group. Returns the task for further chaining.

cancelAll()

void cancelAll()

Cancels every running task in the group and clears it.

prune()

void prune()

Removes dead tasks without cancelling live ones. Call on a tick to keep the group lean.

activeCount()

int activeCount()

Returns the number of currently running tasks in the group.

teleportAsync() v3.0

Platform-aware async teleport that does the right thing on every server type.

PlatformHow it teleports
FoliaVia EntityScheduler.execute() on the entity's region thread. Fully concurrent-safe.
PaperVia native Entity.teleportAsync() — Paper's own async teleport API.
SpigotFallback: schedules sync teleport on next tick via BukkitScheduler.
Java
// Basic — uses TeleportCause.PLUGIN
MagmaLib.teleportAsync(player, destination)
  .thenAccept(success -> {
    if (success) player.sendMessage("Teleported!");
    else         player.sendMessage("Teleport failed.");
  });

// Custom cause
MagmaLib.teleportAsync(player, destination, TeleportCause.COMMAND)
  .thenAccept(ok -> logTeleport(player, ok))
  .exceptionally(e -> { getLogger().warning("TP failed: " + e.getMessage()); return null; });
Null safety

teleportAsync(null, location) and teleportAsync(entity, null) both return a future that completes immediately with false — no NPE, no exception.

Async & CompletableFuture

runAsync(Runnable)

CompletableFuture<Void> runAsync(Runnable)

Async scheduler, exceptions propagate via completeExceptionally.

MagmaLib.runAsync(() -> heavyIO())
  .thenAccept(v ->
    MagmaLib.runSync(() -> player.sendMessage("Done")));

callAsync(Supplier<T>)

CompletableFuture<T> callAsync(Supplier<T>)

Like runAsync but returns a value.

MagmaLib.callAsync(() -> db.query(uuid))
  .thenAccept(r ->
    MagmaLib.runSync(() -> updateHUD(r)));

callSync(Supplier<T>)

CompletableFuture<T> callSync(Supplier<T>)

Runs on global/main thread, returns value via future. Use when you need Bukkit API data from async code.

runSync(Runnable)

void runSync(Runnable)

Schedules on global/main thread. Platform-agnostic shorthand.

Helpers

forAllPlayers()

void forAllPlayers(Consumer<Player>)

Main thread. Per-player exception isolation — one player crashing doesn't affect others.

forAllLoadedChunks()

void forAllLoadedChunks(Consumer<Chunk>)

Main thread (fixed in v2.1.2 — was incorrectly async). getLoadedChunks() is not thread-safe.

runTimerUntil()

Task runTimerUntil(Runnable, long, BooleanSupplier)

Self-cancelling when condition is true. Actually cancels the handle — not just skips.

runTimerWhile() v3

Task runTimerWhile(Runnable, long, BooleanSupplier)

Inverse of runTimerUntil: runs while condition is true, cancels when it becomes false.

runWithRetry()

void runWithRetry(Runnable, int, long, TimeUnit)

First attempt immediate, retries delayed. Throws if maxAttempts < 1.

executeIfLoaded()

void executeIfLoaded(Location, Runnable)

Silent no-op if chunk is unloaded. Otherwise routes to the chunk's region.

ticksToMs() / msToTicks()

long ticksToMs(long) · long msToTicks(long)

Inline conversion. ×50 and ÷50 respectively.

safeMessage(Throwable)

String safeMessage(Throwable)

Null-safe error description. Falls back to class name when getMessage() is null.

runTimerWhile() in practice

// Runs every tick while player is alive and online — cancels automatically
MagmaLib.runTimerWhile(
  () -> updateHUD(player),
  1,
  () -> player.isOnline() && !player.isDead()
);

Entity Scheduling

Golden rule

Always use .with(entity) for entity operations. Folia routes to the EntityScheduler for that entity's region. Paper runs on the main thread. In both cases Bukkit API is safe to call.

Java — safe entity task
MagmaLib.task(() -> {
  entity.setCustomName("§aProcessed");
  entity.setGlowing(true);
})
.with(entity)
.cancelIf(() -> !entity.isValid())
.handleException(e -> getLogger().warning("Entity error: " + e.getMessage()))
.run();
.async() + .with() = crash

Never combine them. .async() discards entity routing and forces AsyncScheduler. Any Bukkit API call — playSound, setVelocity, sendMessage — will throw AsyncCatcher at runtime. The fix: use .with(entity) alone.

Platform Compatibility

FeaturePaper / SpigotFolia
task().run()✅ BukkitScheduler✅ GlobalRegionScheduler
.at(location)✅ Main thread✅ RegionScheduler
.with(entity)✅ Main thread✅ EntityScheduler
.async()✅ BukkitScheduler async✅ AsyncScheduler
thenApply / thenAccept
teleportAsync()✅ Paper native async✅ EntityScheduler
cancelAllTasks()
TaskGroup
runTimerWhile()
isPaper() / isSpigot()
@MainThread / @AnyThread✅ IDE hints✅ IDE hints

Changelog

v3.0.0

cancelAllTasks() New

Global task registry + single-call shutdown for onDisable(). FoliaLib has no equivalent.

TaskGroup New

Named groups for per-player or per-feature cancellation. .inGroup() on the builder.

teleportAsync() New

Platform-aware: Folia EntityScheduler → Paper native → Spigot next-tick.

isPaper() / isSpigot() New

3-platform detection. All three are mutually exclusive, cached as volatile.

runTimerWhile() New

Readable inverse of runTimerUntil. Runs while condition is true, cancels when false.

@MainThread / @AnyThread New

Source annotations to give IDE thread-safety hints. Zero runtime overhead.

v2.1.2 — 9 Bug Fixes

#1 thenApply unsafe cast

Multi-level chains used raw cast → silent ClassCastException. Replaced with typed List<Function>.

#2 runAsync Folia units

AsyncScheduler.runAtFixedRate takes ms — delay was inconsistent. Fixed.

#3 runDirectLater ×50

GlobalRegionScheduler.runDelayed takes ticks. Old code passed ms → 50× delay.

#4 runDirectTimer ×50

Same unit confusion. Folia timers ran at 1/50th the intended frequency.

#5 runTimerUntilFast no cancel

Stop condition fired but timer kept running. Now cancels via its own handle.

#6 forAllLoadedChunks async

getLoadedChunks() is not thread-safe. Was running async → CME risk. Now sync.

#7 runTimerUntil no cancel

Same as #5 for the builder-based version. cancelIf only skipped body.

#8 runWithRetry extra dispatch

First attempt went through runLater(0ms). Now runs immediately.

#9 maxAttempts < 1

runWithRetry(…, 0, …) silently ran and logged "failed after 0 attempts". Now throws IllegalArgumentException.

FAQ

Does v1.x code work in v3.0?
Yes, 100%. All v1.x and v2.x APIs are unchanged. Every new feature in v3 is purely additive — TaskGroup, teleportAsync, etc. are opt-in.
Can I use .async() with .with(entity)?
No — they are mutually exclusive. .async() overrides entity routing and sends everything to AsyncScheduler. Any Bukkit API call inside (sounds, teleports, messages) will trigger AsyncCatcher. Use .with(entity) alone — it already picks the correct thread on every platform.
How do I manage per-player tasks cleanly?
Use TaskGroup: create one per player on join with MagmaLib.newTaskGroup("player-" + uuid), add tasks with .inGroup(group) or group.add(task.run()), and call group.cancelAll() on quit. Clean, zero manual tracking.
Why did my Folia timer run 50× too slow?
You were on v2.1.0 or earlier. runDirectTimer and runDirectLater passed milliseconds to Folia schedulers that expect ticks. Fixed in v2.1.2 (bugs #3 and #4). Upgrade to v3.0.0.
What is .inGroup() vs group.add()?
They produce identical results — .inGroup(group).run() calls group.add(task) internally after dispatch. .inGroup() is syntactic sugar that keeps the builder chain readable. Use whichever feels cleaner.
Should I call cancelAllTasks() or group.cancelAll() on disable?
Call MagmaLib.cancelAllTasks() in onDisable() — it cancels everything regardless of groups. If you also have groups, their tasks get cancelled twice (harmless, since cancel() is idempotent). You can optionally call group.cancelAll() earlier for fine-grained cleanup.
How does platform detection work?
MagmaLib.init() attempts Class.forName("io.papermc.paper.threadedregions.RegionizedServer") for Folia, then Class.forName("io.papermc.paper.configuration.GlobalConfiguration") for Paper, and defaults to Spigot if both fail. Results are stored in three volatile Boolean fields — each subsequent call is a single field read with zero overhead.