Assets
An asset is any file a mod's manifest lists in assets[] (see Manifest reference § assets) — models, textures, audio, and small JSON sidecars. The editor derives that list from your file tree; the one choice you have is to keep a file out of it with excludedAssets, which makes it load on demand instead of up front. This page covers what's allowed, how big it can be, and a deliberately narrower list you'll also encounter that is not the same thing.
The allowlist — one source, three gates#
There is a single set of 19 allowed asset extensions, exported as MOD_ASSET_ALLOWED_EXTENSIONS from packages/shared/src/modManifest.ts:
.json .png .jpg .jpeg .webp .gif .bmp .avif .ktx2 .basis .glb .gltf .bin .mp3 .ogg .wav .txt .csv .hdr
This is a readonly as const tuple that the server's content-type policy (ASSET_CONTENT_TYPE_ALLOWLIST in apps/server/src/githubScanner.ts) is typed against — the Record type requires every key in the shared union to be present and rejects any key outside it, so editing one without the other is a compile error, not a runtime drift bug.
The direct-upload gate is one extension narrower, deliberately. UPLOADABLE_ASSET_EXTENSIONS (apps/server/src/githubScanner.ts) is derived from the same tuple with .bin filtered out, so it holds 18. The file names three gates and keeps them distinct: what a GitHub manifest may declare (19), what content-type policy applies (19), and what a client may POST to the server (18). A hand-authored repo may legitimately ship scene.gltf alongside scene.bin, so .bin stays manifest-legal — but no editor flow produces a lone .bin (modelImportFormats.ts marks it companion-only, rejects one on its own, and packs a .gltf + .bin selection into a single self-contained GLB before anything is uploaded), so accepting one over the upload route would only ever accept a formatless 50 MB blob. githubScanner.test.ts asserts the difference is exactly [".bin"] rather than leaving it implicit.
So: to check "may a manifest declare this?", read the 19. To check "may I upload this from the editor?", read the 18.
Per-extension content-type policy#
The manifest allowlist only says which extensions exist. The server additionally enforces a MIME-type expectation per extension when it fetches a declared asset from GitHub — this is server-side policy, not part of the shared contract:
| Extension | Accepted content-types |
|---|---|
.json |
application/json, text/json, text/plain |
.png |
image/png |
.jpg / .jpeg |
image/jpeg |
.webp |
image/webp |
.gif |
image/gif |
.bmp |
image/bmp, image/x-ms-bmp |
.avif |
image/avif |
.ktx2 |
image/ktx2, application/octet-stream |
.basis |
application/octet-stream |
.glb |
model/gltf-binary, application/octet-stream |
.gltf |
model/gltf+json, application/json, text/plain |
.bin |
application/octet-stream |
.mp3 |
audio/mpeg |
.ogg |
audio/ogg |
.wav |
audio/wav, audio/wave, audio/x-wav |
.txt |
text/plain |
.csv |
text/csv, application/csv, text/plain |
.hdr |
image/vnd.radiance, application/octet-stream, text/plain — plus a byte check, see HDR skies |
application/octet-stream and binary/octet-stream are additionally accepted for every extension as a generic-binary fallback (some CDNs/servers mislabel content-type).
- Missing content-type header:
"Asset is missing a content-type header."(codeasset-content-type-missing) - Content-type doesn't match policy:
`Asset content-type ${normalized} does not match expected policy for ${extension}.`(codeasset-content-type-mismatch)
Two different gates, two different messages#
There are two places this allowlist is enforced, and they fail differently:
- Declaring an asset in the manifest (
assets[]) — an unrecognized extension fails with`Unsupported asset type: ${extension || "none"}.`(codeunsupported-asset-type), fromvalidateManifestPaths(). - Uploading a file directly in the editor — the same check, but the message additionally lists every allowed extension:
`Unsupported asset type: ${extension || "none"}. Allowed: ${sorted list}.`(also codeunsupported-asset-type), fromvalidateUploadedAssetExtension(). Unlike the scanner's asset-content-type check (which passes unknown extensions through untouched, matching a manifest-driven allowlist that could in principle grow), a direct upload with an unrecognized extension is rejected outright — e.g. a.exeis never accepted, full stop. The one addition is the reservedrules/folder: a.mdor.pdfis accepted there and only there, because a rulebook is published outsideassets[]entirely (seerules). The same two extensions anywhere else in the tree are refused exactly as before.
Size cap#
50 MB per asset (50 * 1024 * 1024 bytes), checked twice by the scanner — once against the content-length response header (fast-fail before downloading the body) and once against the actual downloaded byte length (in case the header lied):
"Assets must be 50 MB or smaller in v1."(codeasset-too-large)
.hdr skies have a tighter cap — see below.
HDR skies#
.hdr is Radiance RGBE, the high-dynamic-range panorama format for the Environment Skybox slot. An 8-bit image clips the sun to the same white as a cloud; an HDR keeps its real brightness, so the room it lights gets proper highlights, reflections and shadow direction. The sky must be an equirectangular (2:1) panorama.
- 10 MB cap (
MAX_HDR_ASSET_BYTES,packages/shared/src/assetContentTypes.ts). Every player downloads the sky, and a 4K panorama is typically 20–50 MB. Checked from the header and again from the bytes:HDR skies must be 10 MB or smaller — re-import it in the editor, which downscales it to fit.(codeasset-too-large). - Byte check. GitHub has no registered content type for
.hdr, so the policy accepts generic ones, and the scanner requires the file to start with#?RADIANCEor#?RGBEinstead:A .hdr asset must be a Radiance RGBE image (starting with #?RADIANCE or #?RGBE).(codeasset-content-mismatch). .exris not an asset. The editor's Upload accepts.exrand converts it to.hdron import, the same way FBX/OBJ become GLB, so an.exrnever reaches a repo. It reads single-part scanline EXRs with NONE, RLE, ZIP or PIZ compression; for anything else (tiled, PXR24, B44, DWA), download the.hdrversion of the sky.- Import fits the cap for you. A
.hdrwithin 10 MB is stored byte-for-byte. A larger one, and every converted.exr, is re-encoded with run-length compression and its resolution halved until it fits. Halving is done in linear light, so the lighting does not dim. In practice a 10 MB sky is about 2K — plenty for lighting, a little soft as a visible backdrop.
Imported skies land in skies/. Drag one onto Environment › Skybox.
A narrower, third list — and why it's not drift#
You'll also encounter SUPPORTED_TEXTURE_MIME_TYPES / SUPPORTED_TEXTURE_EXTENSIONS in packages/shared/src/sceneEditor.ts — 7 raster formats, deliberately narrower than the 19-extension asset allowlist:
png jpg jpeg webp gif bmp avif
This is not a bug or a third source of drift — it gates a completely different question. The asset allowlist answers "can this file exist in a mod's repo?" This narrower list answers "can the browser's <img>-based texture pipeline decode this file client-side?" .ktx2 and .basis are GPU-compressed formats — they are valid, allowed mod assets (used for optimized texture delivery), but they are not something an <img> tag or canvas.drawImage() can decode. Their absence from the texture-decode list is correct behavior, not an oversight to reconcile.
If you're authoring textures by hand rather than through the editor's Basis-compression pipeline, use one of the 7 raster formats above so the editor can actually preview/manipulate it; .ktx2/.basis files are something the editor's own compression step produces, not something you'd typically hand-upload as a source texture.
See also#
- Manifest reference §
assets— how declared assets are wired intoentry/assets[]. - Required and conventional files — the file-tree envelope (
sha256,sizeBytes, the 2000-file / 50 MB caps) anddiffTrees(). - Sidecars — the two
.jsonsidecar shapes (*.meta.json,*.deck.json) validated on top of the generic asset checks above. - Cover art and screenshots — the even narrower 4-extension rule for the reserved
media/folder. - Mod scripting API —
TableDecalState.url, which is a browser-loadable URL rather than one of the repo-relative paths on this page.
MOD_ASSET_ALLOWED_EXTENSIONS#
19 values. Every extension manifest.assets may declare. The server's content-type map is keyed off this same list, so the two can no longer drift.
| Extension |
|---|
.json |
.png |
.jpg |
.jpeg |
.webp |
.gif |
.bmp |
.avif |
.ktx2 |
.basis |
.glb |
.gltf |
.bin |
.mp3 |
.ogg |
.wav |
.txt |
.csv |
.hdr |
Nineteen extensions, and the single answer to "may a mod declare this file?". The tuple is as const and
ModAssetExtension derives from it; the server's ASSET_CONTENT_TYPE_ALLOWLIST
(apps/server/src/githubScanner.ts) is a Record keyed by that union, so it must name every extension in
the list and may name no other. Adding or removing one here is a compile error until the content-type policy
is updated to match — the two cannot drift silently, and only the MIME expectations per extension are
server-owned.
Three gates read from this list and they are not the same width. A manifest may declare all nineteen. A
direct editor upload accepts eighteen: UPLOADABLE_ASSET_EXTENSIONS filters out .bin deliberately,
because a hand-authored repo may ship scene.gltf beside scene.bin while no client flow produces a lone
one — the import path packs a .gltf + .bin selection into a single GLB first. The browser's texture
pipeline decodes a narrower seven, which is why .ktx2 and .basis are legal assets you cannot hand the
editor as a source texture. .hdr (a Radiance sky) is the one extension with its own rules: a 10 MB cap
(MAX_HDR_ASSET_BYTES) and a signature check, and the editor converts .exr into it on import rather than
storing an .exr. An unrecognized extension is unsupported-asset-type at either gate. See
What gets rejected.
