Streaming and catalogs
A level does not have to be on the device before the player asks for it. Ferret fetches what a level needs when it is needed, checks every byte, keeps it for the next visit, and for a big level starts playing from a small core while the rest comes in around the player. Nothing here is special to one game: it is the same LevelService.travel the rest of the engine uses.
One store for every download#
AssetStore is the only thing that downloads. It queues requests with priorities and a concurrency limit, resumes a cut download with Range, retries with backoff, gives up a stalled file (no bytes for 15 s) with a clear reason, and checks sha256 when the caller has a hash. A failure is a StreamError with a reason: network, http, timeout, integrity, origin, aborted, size or build.
Files are cached in a persistent cache (PersistentCache; detectBackend picks Cache Storage, IndexedDB or memory). A revisit asks for nothing it already holds, and a cached file is still checked against its hash before it is handed on. The rules of what may be fetched are strict: https only (http for localhost), no credentials in the URL, an optional allow-list of origins.
Commands, in the debug channel and in the viewer:
| Command | What it does |
|---|---|
stream.status | files queued, loading, done, failed, and the bytes held |
stream.cache | what the persistent cache holds; {clear: true, pkg?} empties it |
stream.plan | what fetching a list of references would download |
assets.require, assets.status, assets.live | bring one reference to the device, ask if it is there, list the models asked for at run time |
Catalogs#
A catalog is a JSON file that lists levels and the packages that hold them. buildCatalog writes one next to the packages, CatalogClient reads it (a signed catalog is checked; unsigned is for development), and installCatalogs registers each catalog level with the LevelService, so travel('castle-gate') works for a level that is still on the server.
Only the files a level needs are fetched: the package index is read first with ranged requests, then the manifest, the scene and what they name. Before a travel, LevelService.plan tells how many bytes it will download (cached means a short wait), and the catalog.levels command lists levels with their state on the device. In the viewer: ?catalog=<url> (add &unsigned for a development catalog).
Streaming inside a level#
A big level is cut into cells (pkg.chunk in the UE pipeline and postprocess.mjs --chunk* do it; sample-streamed.mjs makes a 10 x 10 town to try). The manifest's streaming block says how:
"streaming": {
"cell": 64,
"loadRadius": 150, "unloadRadius": 210, "lodRadius": 400,
"cells": {
"0_0": { "bounds": [0, 0, 0, 64, 10, 64], "file": "chunks/0_0.glb", "size": 180000, "lod": "lods/0_0.glb" }
},
"bundles": { "keep": { "files": ["keep.glb"] } }
}
StreamingService keeps the cells around the focus loaded: the persistent entities (the player), else the active camera, so a viewer flying around streams too. Cells inside loadRadius come in, nearest first and the ones in front of the view first; cells past unloadRadius are released (the gap between the two stops a cell flickering on the border). Between loadRadius and lodRadius a cell can show a cheaper lod file. Instantiation is paced by a per-frame budget (count and milliseconds), so streaming never pushes a frame over the tier's budget; a failed cell is retried later.
The ready gate holds the player out until the cells at the spawn are in, so the level starts from its core and never from a hole. Collision stays resident.
A level that does not fit in memory adds four things to the block. A cell with "pinned": true (the play area round the spawn) is in the ready gate, loaded first and never released or evicted. layers give a kind of cell its own radii ("layers": { "foliage": { "loadRadius": 96, "unloadRadius": 144 } }, a cell names it with "layer": "foliage"): foliage is wanted closer than houses. memoryBudget (bytes, one block per tier package; the host may pass a smaller one in the options, and tuning.memoryScale scales it) caps what is resident, counting each cell's memory (default size): a nearer cell evicts farther ones to fit and waits when it still does not (stream:budget, the gate does not wait for it), and a nearer cell is never evicted for a farther one, so a full budget does not make cells flap. maxPerFrame, budgetMs and concurrency set the pacing in the block (the host's options win); maxPerFrame: 1 puts one cell into the scene per update. Command stream.memory. tools/ue-export/split-instances.mjs cuts a processed glTF of thin-instanced scenery (foliage) into grid cells by instance position, for such a block.
Bundles are named groups of files for things that should arrive with a zone or on demand (loadBundle(name)): an entity with needs: "keep" shows a placeholder box and is made whole when the bundle is in.
Events: stream:cell and stream:bundle. Commands: stream.cells (state of every cell) and stream.debug (a view of cells and their state over the scene). The editor edits the block like any other part of the manifest: edit.streaming.set, .field, .cell, edit.tags.set and edit.data.set, all with undo and schema checks.
Prefetch#
PrefetchService downloads in the idle time of the game. Targets come from the level's data.prefetch, from near(zone, level) (the player is by the door to the next level) and from hint(level, {inSeconds}). It downloads one file at a time, pauses at once when any travel starts, and with inSeconds it uses its bandwidth estimate to wait until the download can still finish in time. A prefetched level reports plan.cached, and the travel then makes no download. levels.prefetch and prefetch.status are the commands.
Assets at run time#
RuntimeAssets is for a behaviour that needs a model, sound or texture it did not know about at load time (a dropped item, an NPC skin). One reference scheme: a path relative to the game's base, ferret://<package>/<path> (an open package) or https://... (the store). bytes(ref) and url(ref) give verified bytes or a URL the engine's loaders accept. model(ref) returns a ModelHandle with a placeholder in the same frame; the model replaces it, in the same place, when it arrives, and a failure is an asset:failed event and state: 'failed', never an exception in the frame loop.
What keeps a bad file out#
- A flipped byte in any remote file is caught by its hash and never reaches a loader (tested for glb, ktx2, png, ogg, json,
.ferretand mp4). - A
.ferretpackage is opened under limits (ZIP_LIMITS): entry count, unpacked size of one file and of all, a compression ratio no deflate stream can reach, JSON size. A directory that lies about a size is stopped while inflating, at the declared size. - Paths are checked, signatures and hashes are verified before anything is used, and package code runs under a
CodePolicy.
Trying a bad link#
node tools/serve.mjs [port] [dir] --range --throttle <kbps> --latency <ms> --fail <rate> --offline-after <bytes> serves a folder over a slow, flaky or cut link on purpose. The Playwright specs slow-link.spec.ts (a slow link and a cut link) and budgets.spec.ts (first play on a 50 Mbit/s link within 10 s, a revisit within 5 s, no stalled frame loop) use it; run them after node tools/build.mjs viewer. Budgets that need a real device (the console tier's memory peak, 30 FPS) are measured on the simulators and devices.
See also Writing a transition for what the player sees while a level downloads.