Skip to content

Threading

Hytale uses a multi-threaded architecture where each world runs on its own thread. Understanding this model is essential for writing correct mod code.

ThreadPurpose
Main ThreadServer bootstrap and shutdown orchestration
WorldThread - {name}Per-world tick loop (entities, chunks, tasks)
ChunkLighting - {name}Per-world light propagation
WorldMap - {name}Per-world map generation
ForkJoinPool.commonAsync I/O, chunk loading
SchedulerPeriodic tasks (single-threaded)

There is no single “main game thread” - each world is independent.

Each World extends TickingThread and runs at 30 TPS by default. The tick loop:

  1. Process queued tasks (taskQueue)
  2. Tick all entities (entityStore.tick())
  3. Tick all chunks (chunkStore.tick())
  4. Process any new tasks
  5. Sleep until next tick
// world tick cycle (simplified)
protected void tick(float dt) {
this.consumeTaskQueue(); // run scheduled work
this.entityStore.tick(dt); // tick entities
this.chunkStore.tick(dt); // tick chunks
this.consumeTaskQueue(); // run any new work
this.tick++;
}

Operations that require the world thread:

OperationWhy
Block access (setBlock, getState)Modifies chunk data
Entity manipulation (addEntity, removeEntity)Modifies entity store
Entity lookup (world.getEntity(uuid))Iterates entity store
Chunk access (getChunkIfLoaded)Accesses chunk store
Component modification (store.addComponent)Not thread-safe

Operations safe from any thread:

OperationWhy
Universe.get()Static singleton
Universe.get().getWorlds()ConcurrentHashMap
Universe.get().getPlayers()Returns snapshot copy
playerRef.getUuid(), getUsername()Immutable fields
world.getName()Immutable field
world.isAlive()AtomicBoolean
world.getPlayerRefs()Unmodifiable concurrent view
world.getPlayerCount()Concurrent map size

Schedule work to run on the world thread:

// from an event handler (may run on any thread)
private void onPlayerDisconnect(PlayerDisconnectEvent event) {
PlayerRef playerRef = event.getPlayerRef();
Ref<EntityStore> ref = playerRef.getReference();
if (ref == null) return;
World world = ref.getStore().getExternalData().getWorld();
world.execute(() -> {
if (!ref.isValid()) return;
store.removeComponent(ref, MyComponent.getComponentType());
});
}

For methods that may be called from either context:

public void doSomething(World world, Ref<EntityStore> ref) {
if (world.isInThread()) {
// already on world thread - do work directly
actuallyDoSomething(world, ref);
} else {
// wrong thread - schedule it
world.execute(() -> actuallyDoSomething(world, ref));
}
}

Assert that code runs on world thread (throws if not):

public void doWorldOnlyThing() {
world.debugAssertInTickingThread(); // throws if wrong thread
// ... implementation
}

A Ref<EntityStore> becomes invalid when the entity is removed (death, despawn, player disconnect, chunk unload). This matters when there’s a delay between capturing the ref and using it:

// delayed task - entity might be gone by execution time
HytaleServer.SCHEDULED_EXECUTOR.schedule(() -> {
world.execute(() -> {
if (!ref.isValid()) return;
Player player = store.getComponent(ref, Player.getComponentType());
// ...
});
}, 30, TimeUnit.SECONDS);

For immediate world.execute() calls (same tick), the ref is almost always still valid. The check matters more for:

  • Scheduled tasks with delays (seconds/minutes)
  • Stored refs used later (cached in a field)
  • Async operations that take time

Blocking the world thread freezes all ticking:

world.execute(() -> {
Thread.sleep(5000); // world freezes for 5 seconds!
});

Use the scheduled executor for delays instead:

HytaleServer.SCHEDULED_EXECUTOR.schedule(() -> {
world.execute(() -> {
// do delayed work
});
}, 5, TimeUnit.SECONDS);

The Scheduling page covers HytaleServer.SCHEDULED_EXECUTOR for:

  • Delayed tasks
  • Repeating tasks
  • Posting results back to world thread

Run periodic task, post results to world:

HytaleServer.SCHEDULED_EXECUTOR.scheduleWithFixedDelay(() -> {
// runs on scheduler thread
SomeData data = computeSomething();
// post result to world thread
world.execute(() -> {
applyData(data);
});
}, 1, 1, TimeUnit.MINUTES);