Skip to main content

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

FieldTypeDefaultNotes
isEnabledbooltrueDisabled levels are skipped during load
levelNamestringnullDisplay name shown in messages, scoreboards, and NPC menus
levelDescriptionstring list[]Lines shown in NPC menus. Supports $highscoreWave and $highscorePlayer placeholders
worldNamestringnullThe template folder name under plugins/EternalTD/worlds/
startLocationstring listnullList of serialized locations where mobs spawn
endLocationstring listnullList of serialized locations the mobs walk to (the "red" tiles)
levelLocationsstring listnullEvery walkable grid square in the level; in the current build these must come from the package or be written into the YAML manually
wavesConfigFilestringrequiredFilename of the linked wave config (waves/<name>.yml)
highscoreWaveint0Best wave reached on this level
highscorePlayerNamestring"no one"Display name of the player who set the score
environmentenumNORMALWorld 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:

  1. EternalTD checks that this player does not already have a copy in flight. A second /etd join while the first is still copying is refused with "Your level is already being prepared."
  2. It looks up the template folder by worldName in plugins/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.
  3. 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.
  4. The template is copied into the server world container off the main thread, so the server does not stall on a large map.
  5. 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.
  6. The player is teleported into the new world; an internal InstanceProtector applies 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.dat at 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, and endLocation are 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 quit immediately 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:

  1. Place a template world folder under plugins/EternalTD/worlds/<worldName>/.
  2. Create or download a matching level YAML in plugins/EternalTD/levels/.
  3. Run /etd reload and join the level world manually (or open it in single-player to set up).
  4. Use /etd selectfloor and right-/left-click two corners to mark the play area, or use /etd selectfloorcoordinates <x1> <y1> <z1> <x2> <y2> <z2> to supply them directly.
  5. Run /etd showselection <level> to confirm the selection looks right.
  6. Run /etd register <level> to clear the selection. Note that in the current build neither register nor showselection actually persist the floor region — the helper that would save levelLocations (LevelsConfigFields#addLevelLocations) is defined but never invoked by a command. You currently have to write levelLocations into the level YAML by hand if it isn't already populated by a downloaded package.
  7. Stand on a start spawn tile and run /etd register <level> start. Repeat for every start tile (this command does persist into startLocation).
  8. Stand on an end tile and run /etd register <level> end. Repeat for every end tile (this command does persist into endLocation).
  9. 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.

FieldTypeDefaultNotes
isEnabledbooltrueDisabled NPCs are skipped
levelIDsstring listrequiredFilenames of the levels this NPC offers
locationstringnullSpawn location in the standard worldName,x,y,z,yaw,pitch format
namestring"Default Name"NPC display name
difficultystring"Difficulty: Not Set"Difficulty label shown above the NPC
disguisestringnullLibsDisguises descriptor (e.g. custom:etd_tutorial_npc)
customDisguiseDatastringnullExtra 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 (default false).
  • spawnLocations — defaults to etd_spawn,0,65,0,0,0 and is always written to the config. It is only resolved and used when the etd_spawn world exists.
  • manageSpawn — defaults to true. 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 (default false). 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.