# Builds and uploads

> What a build is and what the uploader accepts. Zip or folder, limits, path rules, allowed file types, relative paths, the sandbox, caching, the builds list, retention and the storage limit.

A build is one upload of your game's web files. Once it is sealed, nobody edits it, including you.

This page is the reference. For the walkthrough, see [Publish a game](/docs/publish). The limits, allowed file types and path rules here are generated from the code the uploader and the server use, and published as `https://www.fradiation.games/docs/build-rules.json`. If this page and that file disagree, the file wins.

## What a build is

- A set of files that forms a web game, with an entry page.
- Uploaded at `/dev/<slug>` or with `node check-build.mjs deploy`, checked, and then sealed. A sealed build never changes.
- Identified by a 16-character hex id. The Builds list shows the first 7.
- Served at `https://<slug>.containment.cloud/<buildId>/<path>`.
- One of a game's builds is **Live**. That is the one players get.

Each upload creates a new build. To change a file, upload a new build.

## Zip or folder

You give the uploader either a `.zip` or a folder. Drop a zip on the tray or click **browse**. For a folder, click **pick a folder**. Dropping a folder doesn't work.

The browser unzips and checks the build before anything is sent. Picking the folder skips zipping and unzipping, and doesn't load the whole build into memory.

### The entry file

`index.html` must be at the top of the build. To use another page, set `entry` in `fradiation.json` to its path, for example `"entry": "game/start.html"`. The path is relative to the top of the build and must match a file exactly.

A URL that ends in `/` serves the `index.html` in that folder.

### One top-level folder

If every file sits inside one top-level folder, that folder is dropped, once. This makes a zipped project folder work.

```text
MyGame.zip
  MyGame/index.html     becomes   index.html
  MyGame/assets/a.js    becomes   assets/a.js
```

A second level of folders isn't dropped. If the entry file still isn't at the top, the upload stops with `No index.html at the top of the build.`

### Junk files

These are skipped silently. They aren't uploaded and they don't count toward the limits.

- `__MACOSX` folders
- `.DS_Store`
- `Thumbs.db`
- `desktop.ini`
- `.git` folders
- Any file or folder whose name starts with `._`

### fradiation.json

`fradiation.json` at the top of the build is read for `entry`, `viewport`, `threads`, `mutations` and `boards`. The uploader doesn't store it as a build file, and it isn't served from your game's origin. See [fradiation.json](/docs/fradiation-json).

## Limits

Sizes use binary units: 1 MB is 1,048,576 bytes, the same as the uploader.

| Limit | Value |
| --- | --- |
| Files per build | 1,000 |
| Total size | 500 MB. Every path counts, even two paths with identical bytes. |
| One file | 200 MB |
| Path length | 240 characters |
| Files uploaded in parts | Files over 64 MB, automatically. Not a limit. |
| Stage size | 160 to 4096 wide, 120 to 4096 high |
| Patch notes | 2,000 characters |
| Mutations per upload | 30, of which at most 3 `gamma` and 1 `cherenkov` |
| Scoreboards per upload | 10 |
| Uploads | 20 per rolling 24 hours, across all your games. Failed and cancelled uploads count. |
| Upload time | The upload grant lasts 3 hours. |
| Cover | 800 by 600 JPEG made in your browser. Up to 2 MB. |
| Storage | 2 GB per developer, soft. See [Retention and storage](#retention-and-storage). |

## Path rules

Every file path in the build must follow these rules.

- Relative, with forward slashes, and no leading slash.
- At most 240 characters.
- Each folder and file name starts with a letter, a digit or an underscore.
- Each name uses only `A-Z a-z 0-9 . _ space ( ) + -`.
- No name ends in a dot or a space.
- No path appears twice.
- The file has an extension from the allowed list.

What that rules out:

- Dotfiles and dot folders such as `.gitignore`, `.htaccess` and `.well-known/`.
- Non-ASCII names such as `é.png` or `日本語.png`.
- `#`, `%`, `&`, `[`, `]`, `@`, `=`, `,` and `'` in names.
- Backslashes in paths.

Paths are case-sensitive on the server. `Assets/a.png` and `assets/a.png` are different files, and a reference with the wrong case returns 404. Windows and most macOS setups ignore case locally, so a mismatch shows up only after the upload.

Spaces are allowed, but they turn into `%20` in URLs. Prefer dashes or underscores.

## Allowed file types

Extensions are matched without regard to case. Anything not on this list is refused, and so is a file with no extension.

| Kind | Extensions |
| --- | --- |
| Pages and scripts | `html` `htm` `js` `mjs` `css` |
| Text and data | `json` `map` `txt` `xml` |
| WebAssembly and engine data | `wasm` `data` `pck` `bin` `unityweb` `mem` `basis` |
| Images | `png` `jpg` `jpeg` `gif` `webp` `avif` `ico` `svg` `ktx2` |
| 3D models | `glb` `gltf` |
| Audio | `mp3` `ogg` `wav` `m4a` |
| Video | `mp4` `webm` |
| Fonts | `woff` `woff2` `ttf` `otf` |

Refused, for example: `exe`, `dll`, `zip`, `md`, `csv`, `bmp`, `tga`, `dds`, `flac`, `aac`, `opus`, `fbx`, `obj`. Convert or remove them. The machine-readable list is in [build-rules.json](https://www.fradiation.games/docs/build-rules.json).

### Pre-compressed files

Add `.br` (Brotli) or `.gz` (gzip) after an allowed extension: `Game.wasm.br`, `Game.framework.js.gz`, `Game.data.br`.

- The file is served with the `Content-Type` of the inner extension and the matching `Content-Encoding`.
- Unity's Brotli and gzip builds work with Decompression Fallback off.
- `Game.data.gz` is served as `application/gzip`. That is Unity's Safari workaround.
- The inner extension must be allowed. `save.tar.gz` is refused.
- The server labels a file by its name and doesn't read the bytes. A `.br` file must really be Brotli.

## Relative paths

Every build has its own URL:

```text
https://<slug>.containment.cloud/<buildId>/index.html
https://<slug>.containment.cloud/<buildId>/assets/game.js
```

The build id changes with every upload. A path that starts with `/` skips it. `/assets/game.js` resolves to `https://<slug>.containment.cloud/assets/game.js`, which isn't inside any build, and the server answers 404.

Wrong:

```html
<script src="/assets/game.js"></script>
<link rel="stylesheet" href="/style.css" />
<script>
  fetch("/data/levels.json");
</script>
```

Right:

```html
<script src="assets/game.js"></script>
<link rel="stylesheet" href="./style.css" />
<script>
  fetch("data/levels.json");
</script>
```

The same goes for CSS `url()` values and anything your engine or bundler generates. In Vite, set `base: "./"`. Other bundlers have an equivalent public path setting. Never hard-code the slug or the build id.

Absolute URLs to other origins are fine, for example an API. With `threads` on, they need extra headers. See [Threads](#threads).

## The frame

Your game runs in a sandboxed iframe on its own origin. It loads after the player clicks **Start**. **Restart** reloads it.

```html
<iframe
  sandbox="allow-scripts allow-same-origin allow-pointer-lock allow-popups allow-popups-to-escape-sandbox allow-modals allow-orientation-lock"
  allow="fullscreen; gamepad; autoplay; accelerometer; gyroscope; xr-spatial-tracking"
></iframe>
```

Every file a game origin serves also carries this header:

```text
Content-Security-Policy: frame-ancestors <Fradiation's own origins>; base-uri 'self'; form-action 'none'; object-src 'none'
```

`allow-same-origin` gives the game its real origin, so `localStorage` and `IndexedDB` work. Unity PlayerPrefs and Godot `user://` saves depend on that. It is safe because the game's origin is a different site from Fradiation.

What your game can do:

- Run scripts, WebAssembly and WebGL.
- Use pointer lock, fullscreen, gamepads, motion sensors and XR.
- Open popups, and use `alert`, `confirm` and `prompt`.
- Lock the screen orientation.
- Call external APIs with `fetch`.
- Store data on its own origin.
- Talk to the site through the [SDK](/docs/sdk).

What it can't do:

- Touch the main site. It is on another origin, and its only channel is the SDK.
- Navigate the page around it. `allow-top-navigation` isn't granted.
- Submit forms. `allow-forms` isn't granted, and the CSP sets `form-action 'none'`.
- Load plugins (`object-src 'none'`) or point `<base>` at another origin (`base-uri 'self'`).
- Be embedded on another site. `frame-ancestors` allows only Fradiation.
- Use the camera, the microphone or geolocation. They aren't in the `allow` list.
- Count on downloads. `allow-downloads` isn't in the sandbox list, so browsers can refuse a download the game starts.

### Storage

Each game has its own origin, `https://<slug>.containment.cloud`. Its `localStorage`, `IndexedDB` and Unity cache belong to that origin.

- No other game can read them, and neither can the site.
- All of a game's builds share them, because the origin doesn't change with the build. Saves survive updates. Keep your save format readable by newer and older builds.
- The slug is permanent, so the origin is too.

## Viewport

The cabinet letterboxes the game to the aspect ratio of `viewport`. Set `viewport` in `fradiation.json`, or enter a stage size in the uploader. It is the game's native resolution.

Make the game fill its window, 100% wide and 100% high, and handle resize. Don't hard-code a canvas size. Players can also go fullscreen.

```html
<style>
  html,
  body {
    margin: 0;
    width: 100%;
    height: 100%;
    overflow: hidden;
    background: #000;
  }
  canvas {
    display: block;
    width: 100%;
    height: 100%;
  }
</style>
```

## Threads

> [!WARNING]
> Threaded builds don't run on the site yet. A frame only gets `SharedArrayBuffer` when every page above it is cross-origin isolated too, and Fradiation's game pages aren't, so `crossOriginIsolated` is false inside the cabinet. Export without threads: in Unity leave multithreading off, and in Godot use 4.3 or later with Thread Support off.

`"threads": true` is accepted and already changes how the build is served, ready for when the game pages are isolated:

- The build is served under `https://<slug>.containment.cloud/coi/<buildId>/…`.
- Files carry `Cross-Origin-Opener-Policy: same-origin` and `Cross-Origin-Embedder-Policy: require-corp`.
- Everything the game loads from another origin, scripts, fonts, images and `fetch` calls, must send CORP or CORS headers. Bundle everything instead.
- Files from your own build already send `Cross-Origin-Resource-Policy: cross-origin`.
- The setting belongs to each build. Leave it `false` unless the engine needs threads.

## Caching

- HTML files (`html`, `htm`) are sent with `Cache-Control: no-cache` and revalidated on every load.
- Every other file is sent with `Cache-Control: public, max-age=31536000, immutable`, a year.
- A new build has a new URL, so an update never fights a cache.
- You can't patch a file in a sealed build. Upload a new build.

## Content-addressed uploads

Only files the game doesn't have yet are uploaded.

1. The uploader (the browser uploader on the game's Manage page, or `check-build.mjs deploy`) reads every file and computes its SHA-256.
2. It sends the site the file list: paths, sizes and hashes.
3. The site answers with the hashes the game doesn't have.
4. The uploader sends only those files, straight to the play server, four at a time. Files over 64 MB go up in parts.
5. The site checks that every file arrived at its full size, writes the build's file list and seals the build.

What follows from it:

- A file's identity is its bytes. Renaming a file doesn't upload it again, and two paths with identical bytes are stored once.
- Bytes that don't change aren't sent. A hashed bundler filename such as `index-a1b2c3.js` is fine. Anything that changes a file's bytes, such as an embedded build timestamp, sends it again.
- Storage is per game. Nothing is shared between games.
- The Builds list shows what each upload sent: `(1.6 KB new)` or `(nothing new)`. The uploader shows how much was left unchanged, and so does `deploy` (`newFiles` and `uploadedBytes` with `--json`).
- The 500 MB limit counts every path. Storage counts each distinct file once per game.

## The builds list

The **Builds** panel on `/dev/<slug>` lists every build, newest first: the first 7 characters of its id, date, file count, size, how much was new, stage size and patch notes.

| Tag | Meaning |
| --- | --- |
| Live | The build players get. A draft's live build is always its newest. |
| Testing | The build a playtest serves. |
| Pinned | Its files are kept whatever retention says. |
| Uploading | The upload hasn't finished. |

| Action | What it does |
| --- | --- |
| Open | Opens the build directly on its origin, outside the cabinet. The SDK has no site to connect to there. |
| Test | Sends the build to your playtest. Shown when a playtest exists. |
| Make live | Makes the build the live one. That is how you ship an update and how you roll back. |
| Pin | Keeps its files. Click again to unpin. |
| Hold to delete files | Deletes the build's files now. See below for when it appears. |

Any sealed build that still has its files can be made live. Players get it on their next load. A build whose files were deleted can't be made live, sent to testers or opened. Upload it again as a new build.

From a terminal, `node check-build.mjs builds --game <slug>` prints the same list, and `live <buildId>` and `playtest <buildId>` do what **Make live** and **Test** do. See [Deploy from the command line](/docs/agents#deploy-from-the-command-line).

## Retention and storage

There is no build quota. Old builds lose their files on a schedule, and their rows stay.

### What keeps its files

For each game:

- The live build.
- The build its playtest serves, even while the playtest is closed.
- Pinned builds.
- The 5 newest builds that still have files.

### Everything else

Any other build is scheduled for deletion. Its files are deleted 30 days later.

- The 30 days count from when the site first notices the build is no longer needed. That happens after an upload, **Make live**, a change to the playtest build or a pin, and when you load `/dev` or `/dev/<slug>`. A late notice only makes the wait longer.
- The Builds list shows `Files will be deleted <date>. Pin it to keep them.`
- If the build becomes needed again before then, the clock resets.
- When the files go, the build's row, patch notes, playtest feedback and judgment history stay. The list shows `Files deleted <date>.`
- A file shared by several builds stays until the last build that uses it loses its files.
- Failed and abandoned uploads are deleted with their files on the next sweep. An upload left open for 4 hours is marked failed.

### Storage limit

The soft limit is 2 GB per developer.

- It counts every build that still has files, including uploads in progress. Each distinct file counts once per game. Covers aren't counted.
- One upload may cross the limit.
- Once your total is at or over 2 GB, the next upload first deletes the files of builds already scheduled for deletion, without waiting for the 30 days.
- The upload is refused only if you are still at or over the limit: `Your builds use X of 2 GB. Delete or unpin some to upload more.`
- At 80% the game's page warns you. Every build that isn't live or testing then gets **Hold to delete files**. Before that, it appears only on builds already scheduled for deletion.
- Deleting files removes the pin. It doesn't work on the live build or the testing build.

`/dev` shows a storage meter and each game's size.

## Check a build first

`check-build.mjs` is a zero-dependency Node 20 or later script. It checks a build against the real rules on your machine.

```bash
curl -fsSL https://www.fradiation.games/skills/fradiation/check-build.mjs -o check-build.mjs
```

```powershell
Invoke-WebRequest -UseBasicParsing https://www.fradiation.games/skills/fradiation/check-build.mjs -OutFile check-build.mjs
```

```bash
node check-build.mjs <folder-or-zip>
node check-build.mjs <folder-or-zip> --json
node check-build.mjs pack <folder> [out.zip]
```

- The first command checks a folder or a zip.
- `--json` prints a machine-readable report, for agents. It can go anywhere in the command.
- `pack` takes a folder. It checks it, and zips it the way the uploader expects only if the check passes. Without `out.zip`, it writes `<folder>.zip` next to the folder. It won't write the zip inside the folder.
- Exit code 1 means the build would be refused.

With an API token in `FRADIATION_TOKEN`, the same script uploads:

```bash
node check-build.mjs deploy <folder-or-zip> --game <slug> [--live] [--playtest] [--notes "..."]
```

It runs the check first and uploads nothing if the check fails. See [Deploy from the command line](/docs/agents#deploy-from-the-command-line).

The script is also in the agent skill at `https://www.fradiation.games/skills/fradiation/SKILL.md`. See [Working with agents](/docs/agents).

## Zipping

The uploader refuses a zip whose entry names contain backslashes. Windows PowerShell 5.1 `Compress-Archive` can write them:

```text
assets\a.js: paths must be relative, with forward slashes
```

Use one of these instead:

1. Pick the folder directly in the uploader. There is no zip.
2. `node check-build.mjs deploy <folder>`. It uploads the folder with an API token. There is no zip.
3. `node check-build.mjs pack <folder>`. It checks the build and zips it.
4. `tar` on Windows 10 or later, and on macOS. Both ship bsdtar, which writes zips.

```bash
tar -a -c -f build.zip -C <folder> .
```

5. On macOS or Linux, Info-ZIP:

```bash
cd <folder> && zip -r ../build.zip .
```

> [!WARNING]
> Git Bash and most Linux distributions ship GNU tar. It can't write zips. `tar -a -c -f build.zip` there writes a tar archive named `build.zip`, and the uploader says `That zip couldn't be read.` In Git Bash, call Windows' own tar: `/c/Windows/System32/tar.exe`.

## Errors

| Message | Cause and fix |
| --- | --- |
| `No index.html at the top of the build.` | The entry file isn't at the top, after the one top-level folder is dropped. Move it, or set `entry`. |
| `<path>: paths must be relative, with forward slashes` | A backslash or a leading slash. See [Zipping](#zipping). |
| `<path>: file type not allowed` | The extension isn't on the list, or there is none. |
| `<path>: "<name>" isn't an allowed file or folder name` | A name starts with a dot, uses a character outside the allowed set, or ends with a dot or a space. Rename it. |
| `<path> is in the build twice.` | Two entries in the zip have the same path. |
| `<path> is over 200 MB.` | One file is over the limit. |
| `<n> files; the limit is 1000.` | Too many files. |
| `<size>; the limit is 500 MB.` | The build is too big. |
| `There's nothing in it.` | No files after junk is skipped. |
| `That zip couldn't be read.` | The file isn't a zip, or it is damaged. See [Zipping](#zipping). |
| `fradiation.json isn't valid JSON.` | A syntax error. JSON has no comments and no trailing commas. |
| `fradiation.json: <field>: <message>` | A field breaks the schema. See [fradiation.json](/docs/fradiation-json). |
| `Twenty uploads in a day is the limit. Try again tomorrow.` | The daily upload limit. |
| `Your builds use X of 2 GB. Delete or unpin some to upload more.` | The storage limit. See above. |
| `<n> files didn't arrive intact (<paths>). Upload the build again.` | The connection dropped, or a file was cut short. Upload again. |
| `That build's files were deleted. Upload it again.` | The build was past retention. Upload it as a new build. |
| `That token doesn't exist.`, `was revoked` or `expired on <date>` | `deploy` and the API only. Make a new token under Agents & tokens (`/dev/agents`) and set `FRADIATION_TOKEN` again. |
| `This token only works for <slug>.` | A one-game token was used for another game. Leave out `--game`, or use a token for that game. |
| `Pass --game <slug>. This token works for all your games: ...` | Say which game. |
| `Start a playtest first.` | `playtest <buildId>` on a game with no playtest. Start one on the game's Manage page. (`deploy --playtest` still uploads, and warns.) |
| `That upload isn't open any more. Start it again.` | The upload was finished, cancelled or abandoned (open for over 4 hours). Deploy again. |
