EternalTD Levels & Maps
A "level" in EternalTD is a YAML config in plugins/EternalTD/levels/ paired with a template world folder in plugins/EternalTD/worlds/. When a player joins, EternalTD clones the template world into the server world container and runs the session in that cloned copy.
Level Config Fields
| Field | Type | Default | Notes |
|---|---|---|---|
isEnabled | bool | true | Disabled levels are skipped during load |
levelName | string | null | Display name shown in messages, scoreboards, and NPC menus |
levelDescription | string list | [] | Lines shown in NPC menus. Supports $highscoreWave and $highscorePlayer placeholders |
worldName | string | null | The template folder name under plugins/EternalTD/worlds/ |
startLocation | string list | null | List of serialized locations where mobs spawn |
endLocation | string list | null | List of serialized locations the mobs walk to (the "red" tiles) |
levelLocations | string list | null | Every walkable grid square in the level; in the current build these must come from the package or be written into the YAML manually |
wavesConfigFile | string | required | Filename of the linked wave config (waves/<name>.yml) |
highscoreWave | int | 0 | Best wave reached on this level |
highscorePlayerName | string | "no one" | Display name of the player who set the score |
environment | enum | NORMAL | World environment used when the cloned world is loaded |
The grid size used throughout is 3 blocks per logical square (constant GRID_SIZE in code).
waveCount no longer exists. Older level files that still carry the key are simply ignored — the plugin never reads it and never writes it back.
Location String Format
Locations in EternalTD are serialized as comma-separated strings of the form:
worldName,x,y,z,yaw,pitch
Downloaded packages normally provide these values. For a custom map, the current selection and register commands do not save levelLocations, so you must write the generated grid locations into the level YAML manually.
World Lifecycle
When a player joins a level:
- EternalTD checks that this player does not already have a copy in flight. A second
/etd joinwhile the first is still copying is refused with "Your level is already being prepared." - It looks up the template folder by
worldNameinplugins/EternalTD/worlds/, and rejects the join if the world folder is missing, the linked waves file failed to load, or the world name is not a safe folder name. - It reserves the next free numeric suffix (
<worldName>_0,<worldName>_1, ...). Reservation is synchronized and remembers names that are reserved but not yet on disk, so two players joining at once cannot collide on the same instance name. - The template is copied into the server world container off the main thread, so the server does not stall on a large map.
- The cloned world is loaded as a temporary void world through MagmaCore's
TemporaryWorldManager, so Paper 26.1+ migration is quarantined and missing chunks come back as void rather than freshly generated terrain. - The player is teleported into the new world; an internal
InstanceProtectorapplies EternalTD's protection rules.
Template Requirements
The copy is validated before anything is written, and a template is rejected if:
- it is not a directory, or it is a symbolic link
- it has no
level.datat its root - it contains a symbolic link anywhere inside it
- it contains a file named
.eternaltd-instance(that name is reserved, see below)
A failed copy is rolled back and the player is told the level was not started.
Instance Ownership Markers
Every clone gets a .eternaltd-instance file written at its root recording the template name and the instance name. EternalTD will only delete a world folder whose marker matches the instance it thinks it is deleting. This is the guard that stops a stale name collision, a hand-made folder, or a mismatched session from taking out a world EternalTD did not create — if the marker is absent or does not match, the folder is left in place and a warning is logged instead.
Stale instances left over from a crash are cleaned up when levels load, and even then only for folders that carry a valid marker and are not currently loaded.
Session Start Validation
A session only becomes playable if all of the following hold. Any failure aborts the run, restores the player (inventory, game mode, flight, scoreboard) and deletes the temporary world:
levelLocations,startLocation, andendLocationare all present and non-empty — otherwise "This level doesn't have a complete play area, start, and end configuration!"- the play area itself parses into a valid grid — otherwise "This level's play area is invalid!"
- every start tile has both a land path and an air path to some end tile — otherwise "This level does not have complete land and air paths from every start to an end!"
When the session ends:
- Any remaining players in the cloned world are teleported back to the spawn location from
config.yml, or kicked if no spawn is configured. - The world's protection rules are removed and all remaining towers are torn down (a tower that throws while being removed is logged and skipped rather than stranding the rest).
- The chunk tickets taken out over the play area are released (see Path Validation).
- The cloned world is unloaded and deleted from disk (
TemporaryWorldManager.permanentlyDeleteWorld), at both the legacy and the Paper 26.1+ migrated layouts. - Ending is idempotent — a second
end()(for example/etd quitimmediately followed by/etd hub) is a no-op.
Instance Protection Rules
While a level is active, the cloned world has these rules applied:
- Explosions disabled
- Liquid flow disabled
- Elytra disabled
- Fly toggling prevented
- Friendly fire prevented
- Vanilla mob spawning prevented
Map Authoring Workflow
The current map authoring flow uses the in-game tooling:
- Place a template world folder under
plugins/EternalTD/worlds/<worldName>/. - Create or download a matching level YAML in
plugins/EternalTD/levels/. - Run
/etd reloadand join the level world manually (or open it in single-player to set up). - Use
/etd selectfloorand right-/left-click two corners to mark the play area, or use/etd selectfloorcoordinates <x1> <y1> <z1> <x2> <y2> <z2>to supply them directly. - Run
/etd showselection <level>to confirm the selection looks right. - Run
/etd register <level>to clear the selection. Note that in the current build neitherregisternorshowselectionactually persist the floor region — the helper that would savelevelLocations(LevelsConfigFields#addLevelLocations) is defined but never invoked by a command. You currently have to writelevelLocationsinto the level YAML by hand if it isn't already populated by a downloaded package. - Stand on a start spawn tile and run
/etd register <level> start. Repeat for every start tile (this command does persist intostartLocation). - Stand on an end tile and run
/etd register <level> end. Repeat for every end tile (this command does persist intoendLocation). - Reload again and test the level by joining it through the NPC menu or
/etd join <level>.
The selection commands generate grid squares using:
size = abs(corner1 - corner2 + 1) / 3
A square is skipped when its floor block is passable, or when the block directly above the floor is not passable. In other words the floor must be solid and the space above it must be clear for the square to register as playable — select the floor, not the air above it.
Path Validation
EternalTD caches, for each start tile, the cheapest A* path to any end tile — one land path and one air path. With multiple end tiles it compares the finished paths by cost and keeps the shortest, so a map with several exits routes enemies to the nearest reachable one rather than the first one found.
The land cache is rebuilt when the session starts, whenever a tower is placed, and whenever a tower is sold. The air cache is only computed once, on the first rebuild: air paths ignore towers entirely, so nothing a player builds can invalidate them.
Air enemies use that separate air path, which ignores tower blocking entirely and instead follows the airborne offset (4 blocks above the configured path).
Loading the play area also force-loads every chunk that contains a grid square, so a long run cannot stall on an unloaded chunk. Those chunk tickets are released when the session ends, just before the instance world is deleted.
The cache is also the gate on whether a level is playable at all: if any start tile is missing either its land path or its air path, the session is refused at join time rather than starting a run the enemies cannot finish. The same check runs when a tower is placed, so a tower that would wall off a start tile is rejected and the gold is not spent.
A start or end location that does not sit at the centre of its 3x3 grid square is dropped with a console warning, which is the usual cause of a level reporting an incomplete path set.
Every resolved start and end tile is also marked non-constructable, so players cannot wall off a spawn or an exit by building on it. Those tiles show up light blue under the placement highlight.
NPCs and Level Menus
NPC configs in plugins/EternalTD/npcs/ link villager NPCs to one or more levels. Right-clicking the NPC opens a 9-slot inventory listing each level as a green stained-glass pane labelled with the level name and description.
| Field | Type | Default | Notes |
|---|---|---|---|
isEnabled | bool | true | Disabled NPCs are skipped |
levelIDs | string list | required | Filenames of the levels this NPC offers |
location | string | null | Spawn location in the standard worldName,x,y,z,yaw,pitch format |
name | string | "Default Name" | NPC display name |
difficulty | string | "Difficulty: Not Set" | Difficulty label shown above the NPC |
disguise | string | null | LibsDisguises descriptor (e.g. custom:etd_tutorial_npc) |
customDisguiseData | string | null | Extra LibsDisguises command data — usually the long player-skin string |
The villager is spawned invulnerable, AI-disabled, persistent, and tagged with EternalTD's NPC namespaced key. If LibsDisguises is installed and both disguise and customDisguiseData are set, the villager is disguised on spawn.
A floating armor stand with the difficulty label is spawned 2.3 blocks above the NPC.
Spawn Behavior
DefaultConfig controls how players are managed in the hub world:
setupDone— flag tracking whether first-time setup guidance has been completed (defaultfalse).spawnLocations— defaults toetd_spawn,0,65,0,0,0and is always written to the config. It is only resolved and used when theetd_spawnworld exists.manageSpawn— defaults totrue. When enabled, joining players are teleported to the spawn location 1 tick after login.playerGuide— the in-game guide book text.nightbreak.autoDownloadPluginUpdates— shared MagmaCore setting (defaultfalse). When enabled, plugin and content updates are downloaded automatically on startup.
When manageSpawn is true and the spawn world is loaded, every player that joins the server is teleported to spawnLocations. Players who were already connected while EternalTD was still initializing are swept to the same spawn once initialization finishes, unless they are already in the hub world.