> For the complete documentation index, see [llms.txt](https://buildsystem.eintosti.de/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://buildsystem.eintosti.de/managing-worlds/downloading-a-world.md).

# Downloading Worlds

`/worlds download <world>` packs a world into a save a player can drop into their client's `saves` folder, and sends them a clickable link to fetch it. The feature is **off by default** — enabling it hands out world data.

Archives are served one of two ways. The built-in server (`storage: local`) opens a port on the machine and streams the file itself. Pre-signed links (`storage: s3`) upload the archive to the bucket your backups already use and send the player a signed, expiring URL — no port, no size limit, and the download never touches the game server.

***

## Why an export is needed

Since Paper 26.1 a server world is no longer its own folder. Every world is a *dimension* of the main level:

```
worlds/<level-name>/
├── level.dat
└── dimensions/minecraft/
    ├── overworld/
    ├── the_nether/
    ├── the_end/
    └── <your-world>/        <- a BuildSystem world lives here
        ├── region/
        └── data/minecraft/world_gen_settings.dat
```

Copying that folder out by hand gives you something the client cannot open. It has no `level.dat`, its chunks sit under the wrong dimension, and its saved data is in the wrong place: Paper keeps a per-world copy of the level-scoped files (`world_gen_settings.dat`, `game_rules.dat`, `weather.dat`, …) inside each dimension folder, while a single-player save keeps one set at the root. A client that finds no generator settings there refuses the world outright.

The export rebuilds all of that. The world becomes the save's overworld, gets a `level.dat` named after it, and its data is split by scope:

```
<world>/
├── level.dat                                       from the main level, renamed and scrubbed
├── data/minecraft/                                 level-scoped: world_gen_settings, game_rules, weather, …
└── dimensions/minecraft/overworld/
    ├── region/  entities/  poi/
    └── data/minecraft/                             dimension-scoped: raids, world_border, chunk_tickets
```

A world still stored in the pre-26.1 flat layout already is a save, so only its `level.dat` is rewritten - it keeps its own nether and end, and dimensions belonging to other worlds are left behind.

***

## Enabling downloads

```yaml
world:
  download:
    enabled: false
    storage: local
    port: 8080
    url: "http://localhost:8080"
    behind-proxy: false
    expiration-minutes: 30
    max-size-mb: 2048
    max-storage-mb: 8192
    max-concurrent-downloads: 3
```

| Key                        | Default                   | Description                                                                                 |
| -------------------------- | ------------------------- | ------------------------------------------------------------------------------------------- |
| `enabled`                  | `false`                   | Turns downloads on.                                                                         |
| `storage`                  | `local`                   | `local` for the built-in server, `s3` for pre-signed links.                                 |
| `port`                     | `8080`                    | Port the built-in server listens on. Must be reachable by players. Ignored on `s3`.         |
| `url`                      | `"http://localhost:8080"` | Base address players are linked to. Ignored on `s3`.                                        |
| `behind-proxy`             | `false`                   | Identify clients by `X-Forwarded-For`. Enable only behind a reverse proxy. Ignored on `s3`. |
| `expiration-minutes`       | `30`                      | How long a link stays valid. The archive is deleted when it expires.                        |
| `max-size-mb`              | `2048`                    | Largest single export. `local` only.                                                        |
| `max-storage-mb`           | `8192`                    | Budget shared by all live downloads. `local` only.                                          |
| `max-concurrent-downloads` | `3`                       | Transfers served at once. `local` only.                                                     |

Set `url` to whatever players actually reach — a domain, or the address of a reverse proxy. The server speaks plain HTTP, so put it behind a proxy and point `url` at the HTTPS address if links leave your network. When you do, also set `behind-proxy: true`, or every request looks like it came from the proxy and link pinning and rate limiting stop telling players apart.

Toggling `enabled` or `storage` takes effect on `/config reload`; no restart needed.

### Serving from S3

```yaml
world:
  download:
    enabled: true
    storage: s3

storage:
  s3:
    access-key: YOUR_ACCESS_KEY
    secret-key: YOUR_SECRET_KEY
    region: eu-central-1
    bucket: my-bucket
```

`storage: s3` reads the credentials, bucket and endpoint from the root `storage.s3` section — there is nothing to configure twice, and any S3-compatible service works. Backups do not have to be on S3; the two features pick their backend independently. If `storage.s3` is incomplete, downloads stay off and the console says which key is missing.

What changes:

* **No port.** Nothing has to be reachable from outside, so this works behind NAT or on a host that will not open one.
* **No size limit.** Archives upload in parts, so `max-size-mb` and `max-storage-mb` do not apply and a world of any size can be downloaded.
* **Your uplink carries the world once**, on upload, instead of once per player fetching it.
* **The link is https**, signed by S3 and valid for `expiration-minutes`.

Uploads live under the `downloads/` prefix and are deleted when their link expires. Anything left by a crash is cleared at the next startup, so the bucket does not accumulate archives.

{% hint style="warning" %}
A pre-signed link is a pure bearer token. Unlike a local link it cannot be pinned to one client or rate limited, so anyone it is forwarded to can use it until it expires. Keep `expiration-minutes` short.
{% endhint %}

{% hint style="info" %}
You pay S3 for the storage and for egress on every download. A 2 GB world fetched ten times is 20 GB of transfer.
{% endhint %}

***

## Downloading a world

* **Command**: `/worlds download <world>`
* **Permission**: `buildsystem.download` (`OP`, supports `.self` / `.other`)

The world is saved first - waiting for its chunks to reach disk, so the archive never catches a region file mid-write - then zipped in the background. An animated progress bar runs in the action bar while it packs, showing real progress - bytes packed against the world's total size - with a sweeping highlight and spinner so a pause on one large region file still reads as working. On `s3` the bar then switches to the upload and tracks that the same way, so it never sits full while work is still running. When it is ready the player gets a clickable message; the link opens in their browser. Only one export per player runs at a time.

Restyle the bar through `worlds_download_progress` and `worlds_download_uploading` in `messages.yml`, which take `%world%`, `%bar%`, `%percent%` and `%spinner%`. The finished message is `worlds_download_finished`; its `%button%` placeholder marks where the clickable link goes, and only that segment carries the click and hover - its text is `worlds_download_button`.

Extract the archive into `.minecraft/saves/` and the world appears in the single-player list.

{% hint style="info" %}
A dimension world has no `level.dat` of its own, so its export borrows the main level's - gamemode and difficulty start out matching the server's main level rather than the exported world. A flat-layout world keeps its own settings.
{% endhint %}

***

## What is exposed

Treat a link as private. Everything else is closed off.

With `storage: s3`, the link is signed by S3 and dies with its expiry; the pinning, rate limiting and transfer caps below are properties of the built-in server and do not apply. Everything about what an export *contains* applies either way.

* Each export is reachable only through a random 256-bit token that forms the entire URL path. Requests cannot name a file, so nothing outside `plugins/BuildSystem/downloads` is reachable.
* Archives and their links are deleted together: on expiry, on reload, and on shutdown. Anything left behind by a crash is wiped at startup, since its link did not survive the restart.
* An unknown or expired link returns a bare `404`.
* A link is pinned to the first client that uses it. Forwarding it to someone else does not work, while the player who asked for it can still retry or resume.
* Requests are rate limited to 30 per minute per address, and concurrent transfers are capped, so one host cannot saturate the endpoint. A request over the transfer cap gets `503` with `Retry-After`.
* Exports never include player data (`players/`, `playerdata/`, `stats/`, `advancements/`), so downloading the main world does not hand out the whole server's inventories. The exported `level.dat` also drops the server's brand, version and server-side datapacks.

{% hint style="warning" %}
`buildsystem.download` hands out the full contents of a world. Grant it with the same care as `buildsystem.backup`.
{% endhint %}
