Skip to content

Scheduling

Hytale provides HytaleServer.SCHEDULED_EXECUTOR for scheduling delayed and repeating tasks outside the world tick loop.

public static final ScheduledExecutorService SCHEDULED_EXECUTOR =
Executors.newSingleThreadScheduledExecutor(ThreadUtil.daemon("Scheduler"));

Key characteristics:

  • Single-threaded - tasks don’t overlap, execute sequentially
  • Daemon thread - won’t prevent JVM shutdown
  • Shared across all mods - keep tasks quick

Since tasks run on the scheduler thread (not a world thread), you’ll often need to post results back using world.execute().

The scheduler is single-threaded - heavy tasks block other scheduled tasks. For CPU-intensive or blocking I/O work, use ForkJoinPool.commonPool():

CompletableFuture.runAsync(() -> {
// runs on ForkJoinPool.commonPool()
byte[] data = loadLargeFile();
// post result to world thread
world.execute(() -> processData(data));
});

For custom thread pools, use ThreadUtil:

import com.hypixel.hytale.server.core.util.concurrent.ThreadUtil;
// bounded cached pool with daemon threads
private final ExecutorService pool = ThreadUtil.newCachedThreadPool(
4, // max threads
ThreadUtil.daemonCounted("MyMod-Worker-%d") // "MyMod-Worker-1", "MyMod-Worker-2", etc.
);
CompletableFuture.runAsync(() -> doWork(), pool);

Shut down custom executors in shutdown().

One-time execution after a delay:

// run once after 5 seconds
HytaleServer.SCHEDULED_EXECUTOR.schedule(() -> {
getLogger().atInfo().log("5 seconds have passed!");
}, 5, TimeUnit.SECONDS);
// delayed teleport
HytaleServer.SCHEDULED_EXECUTOR.schedule(() -> {
world.execute(() -> {
if (ref.isValid()) {
Teleport teleport = Teleport.createForPlayer(targetWorld, spawnPoint);
store.addComponent(ref, Teleport.getComponentType(), teleport);
}
});
}, 3, TimeUnit.SECONDS);

Waits the delay between task completion and next start. Preferred for most cases:

// auto-save every 5 minutes
HytaleServer.SCHEDULED_EXECUTOR.scheduleWithFixedDelay(() -> {
saveData();
}, 5, 5, TimeUnit.MINUTES); // initial delay, period

Runs at fixed intervals regardless of task duration. Use for timing-sensitive tasks:

// ping clients every second
HytaleServer.SCHEDULED_EXECUTOR.scheduleAtFixedRate(() -> {
sendPingPackets();
}, 1, 1, TimeUnit.SECONDS);

Store the ScheduledFuture and cancel in shutdown():

public class MyPlugin extends JavaPlugin {
private ScheduledFuture<?> saveTask;
@Override
protected void setup() {
this.saveTask = HytaleServer.SCHEDULED_EXECUTOR.scheduleWithFixedDelay(
this::autoSave, 5, 5, TimeUnit.MINUTES
);
}
@Override
protected void shutdown() {
if (this.saveTask != null) {
this.saveTask.cancel(false); // false = don't interrupt if running
}
}
private void autoSave() {
// save logic
}
}
future.cancel(false); // let current execution finish, prevent future runs
future.cancel(true); // interrupt current execution (use carefully)

A task can cancel itself when a condition is met:

ScheduledFuture<?>[] taskHolder = new ScheduledFuture[1];
taskHolder[0] = HytaleServer.SCHEDULED_EXECUTOR.scheduleWithFixedDelay(() -> {
if (shouldStop()) {
taskHolder[0].cancel(false);
return;
}
doWork();
}, 1, 1, TimeUnit.SECONDS);

When a task might be rescheduled:

private final AtomicReference<ScheduledFuture<?>> currentTask = new AtomicReference<>();
public void scheduleTask() {
ScheduledFuture<?> newTask = HytaleServer.SCHEDULED_EXECUTOR.schedule(
this::doWork, 10, TimeUnit.SECONDS
);
ScheduledFuture<?> oldTask = this.currentTask.getAndSet(newTask);
if (oldTask != null) {
oldTask.cancel(false);
}
}
private ScheduledFuture<?> saveTask;
@Override
protected void start() {
this.saveTask = HytaleServer.SCHEDULED_EXECUTOR.scheduleWithFixedDelay(
() -> this.dataStore.saveToDisk(),
5, 5, TimeUnit.MINUTES
);
}
@Override
protected void shutdown() {
if (this.saveTask != null) {
this.saveTask.cancel(false);
}
this.dataStore.saveToDisk(); // final save
}

Wait for activity to settle before acting:

private ScheduledFuture<?> debounceTask;
public void onSomethingChanged() {
if (this.debounceTask != null) {
this.debounceTask.cancel(false);
}
this.debounceTask = HytaleServer.SCHEDULED_EXECUTOR.schedule(
this::processChanges,
1, TimeUnit.SECONDS
);
}

Execute a sequence of actions with delays:

ScheduledFuture<?>[] task = new ScheduledFuture[1];
Queue<Runnable> actions = new LinkedList<>(List.of(
() -> sendMessage("3..."),
() -> sendMessage("2..."),
() -> sendMessage("1..."),
() -> startGame()
));
task[0] = HytaleServer.SCHEDULED_EXECUTOR.scheduleWithFixedDelay(() -> {
Runnable action = actions.poll();
if (action == null) {
task[0].cancel(false);
return;
}
action.run();
}, 0, 1, TimeUnit.SECONDS);

For in-game cooldowns (interactions, abilities), Hytale uses tick-based timing rather than scheduled tasks:

public class CooldownHandler implements Tickable {
private final Map<String, Float> cooldowns = new ConcurrentHashMap<>();
public void setCooldown(String key, float seconds) {
this.cooldowns.put(key, seconds);
}
public boolean isOnCooldown(String key) {
return this.cooldowns.containsKey(key);
}
@Override
public void tick(float dt) {
this.cooldowns.entrySet().removeIf(entry -> {
entry.setValue(entry.getValue() - dt);
return entry.getValue() <= 0;
});
}
}

This approach:

  • Respects world pausing and time dilation
  • Doesn’t require cancellation management
  • Naturally integrates with the game loop

The scheduler thread is not a world thread. Calling world methods directly from scheduled tasks is unsafe:

HytaleServer.SCHEDULED_EXECUTOR.schedule(() -> {
world.setBlock(x, y, z, "hytale:stone"); // wrong thread!
}, 5, TimeUnit.SECONDS);

Post to the world thread instead:

HytaleServer.SCHEDULED_EXECUTOR.schedule(() -> {
world.execute(() -> {
world.setBlock(x, y, z, "hytale:stone");
});
}, 5, TimeUnit.SECONDS);

See Threading for the full threading model.