| name | content-hot-reload |
| description | Content hot-reload in FlatRedBall2. Use when watching content files (JSON configs, PNGs, TMX maps, etc.) for changes during development and reloading them without killing the game. Covers Screen.WatchContentDirectory, Screen.WatchContent, source/output mapping, debouncing, and the in-place vs screen-restart decision. |
Content Hot-Reload in FlatRedBall2
When iterating on content during development — tweaking JSON configs, swapping a sprite, editing a TMX map — you don't want to restart the game. The engine watches your source content folder (the one with the .csproj, the one your editor saves to) and reloads on change.
Two-folder model
A built .NET game has content in two places:
| Folder | What it is |
|---|
<csproj_dir>/Content/ | Source. Where you edit. Lives in source control. |
bin/Debug/.../Content/ | Output. Build copies here. App reads from here. |
Default game code reads the output (PlatformerConfig.FromJson("Content/player.json") resolves against AppContext.BaseDirectory). When the user edits the source, the output is stale until MSBuild re-copies.
The engine handles this by copying source → output as part of the watch pipeline, before invoking your callback. You write reload logic that reads from the output path as usual; the engine just makes sure the output is fresh.
API
Directory watch (the common case)
public override void CustomInitialize()
{
WatchContentDirectory("Content", _ => RestartScreen(RestartMode.HotReload));
}
To dispatch by file type:
WatchContentDirectory("Content", relPath =>
{
switch (Path.GetExtension(relPath))
{
case ".json":
ReloadJsonConfig(relPath);
break;
default:
RestartScreen(RestartMode.HotReload);
break;
}
});
Single-file watch
WatchContent("Content/player.platformer.json", () =>
PlatformerConfig.FromJson("Content/player.platformer.json").ApplyTo(player.Platformer));
Use when you want surgical control or to skip the cost of watching a whole tree.
Custom source/output mapping
By default, source path == output path. If your build pipeline maps differently (e.g. <None Update="Assets/..." TargetPath="Content/..." />), pass the destination explicitly:
WatchContent("Assets/Configs/player.json", reload, destinationPath: "Content/player.json");
WatchContentDirectory("Assets", relPath => ..., destinationDirectory: "Content");
Two reload strategies
1. In-place reload — patch the existing object
Dispatch by extension; each path either patches the live object or returns false so you can fall back to restart:
WatchContentDirectory("Content", rel =>
{
var ext = Path.GetExtension(rel);
if (ext == ".json") ReloadJsonConfig(rel);
else if (ext == ".png") { }
else if (ext == ".tmx" && !map.TryReloadFrom("Content/" + rel)) RestartScreen(RestartMode.HotReload);
else if (ext == ".achx" && !Animations.TryReloadFrom("Content/" + rel, Engine.Content)) RestartScreen(RestartMode.HotReload);
else RestartScreen(RestartMode.HotReload);
});
.png — auto-reloaded before the callback (AutoReloadAction → Engine.Content.TryReload, patches Texture2D pixels in place). Only tracks PNGs loaded via Engine.Content.Load<Texture2D>("path.png") (extension required; xnb pipeline loads are not tracked). Opt out: set watcher.AutoReloadAction = null. Dimension change silently fails — add an explicit .png → Engine.Content.TryReload + restart fallback if you edit resolutions.
.tmx — TileMap.TryReloadFrom patches tile IDs and rebuilds every TSC registered via GenerateCollisionFromClass/Property, including shapes sourced from object-layer rectangles/polygons. Returns false on structural change (map/layer/tileset diff). Hand-authored mutations on a generated TSC are wiped — put augmentations in CustomInitialize.
- Object-layer content is compared by count only, not by position/size/rotation. If you move or resize an object-layer rectangle/polygon without adding or removing one,
TryReloadFrom reports true (compatible) but the live map's object layers are never re-synced from disk — only tile-layer cell data is patched in place. The result: the TSC (and any entities spawned via CreateEntities from that same object layer) keep the stale pre-edit shape/position after a "successful" reload. If your object layers can be edited independently of the tile layers, don't rely on in-place reload to pick that up — force RestartScreen(RestartMode.HotReload) for object-layer edits, or add/remove a throwaway object alongside the real edit to force the count check to fail and trigger a restart.
.achx — AnimationChainList.TryReloadFrom patches by chain name; live Sprite.CurrentAnimation references keep playing. Every sprite must share one list instance — per-spawn re-parse defeats this. See the animation skill.
2. Screen restart — RestartScreen(RestartMode.HotReload)
Use when the change invalidates references the game holds.
- PNG with new dimensions — must
new the Texture2D, orphans every Sprite referencing the old one.
- Structural TMX changes — layers added/removed, map resized, object layer modified.
- Anything you're unsure about — restart is always safe; in-place can leave dangling refs.
RestartMode.HotReload triggers SaveHotReloadState / RestoreHotReloadState so the player doesn't snap back to spawn. See the screens skill for the recipe.
Source root detection
FlatRedBallService.SourceContentRoots is a list of source directories, auto-detected at engine construction:
- Walk up from
AppContext.BaseDirectory looking for a .sln or .slnx. If found, every referenced project that has a Content/ subdirectory is added to the list — multi-project layouts (e.g. Common+Desktop+Web) just work.
- If no solution is found, fall back to the first
.csproj directory walking up (single root).
- If neither, the list is empty and
WatchContent* methods no-op.
WatchContent("Content/foo.json", ...) and WatchContentDirectory("Content", ...) resolve the path against every root and register a watcher per root that contains the path.
Override for unusual layouts:
engine.SourceContentRoots.Clear();
engine.SourceContentRoots.Add("C:/path/to/my/project");
OutputContentRoot defaults to AppContext.BaseDirectory. Override only if your build writes content to a non-standard location.
Shipping builds
In a shipped game there's no .csproj/.sln next to the executable, so SourceContentRoots is empty. WatchContent/WatchContentDirectory return null and skip registration. No #if DEBUG needed — hot-reload is a dev-only no-op in release.
Debouncing
Editors and build tools fire bursts of events around each save (write + flush + rename). The engine uses global debouncing: it waits until all writes settle (no new events for ~150 ms) before processing the dirty batch. Per-file debouncing would let the first file's reload trigger before later writes in the same burst landed.
For directory watches: every dirty file in the batch fires its own callback in the same tick. If your callback always restarts the screen, multiple invocations collapse to a single restart automatically (the pending-change slot is last-write-wins).
Tune via the returned watcher:
var w = WatchContentDirectory("Content", reload);
w.Debounce = TimeSpan.FromMilliseconds(50);
Lifecycle
- Watchers are owned by the screen that created them. Auto-disposed on
MoveToScreen, RestartScreen, or RestartScreen(RestartMode.HotReload).
- File events fire on a background thread; the engine queues them and drains on the game thread during
Update (right after the pending screen change is flushed, before entity / collision / activity passes).
- If your callback throws
IOException (file mid-write), the watcher silently retries after the next debounce window. Other exceptions propagate.
Only files already in the build output are tracked (with an allowlist)
By default the engine ignores any source-folder change for a file that doesn't already exist in the build output. This filters out editor temp files (Photoshop scratch files, IDE autosaves, lock files) that appear in the content directory but were never copied by MSBuild — they don't trigger your callback, so a directory-wide RestartScreen handler isn't fired by editor noise.
Exception: the auto-copy allowlist. ContentDirectoryWatcher.AutoCopyExtensions defaults to { ".png", ".tsx" }. Files with those extensions flow through even when the destination doesn't exist yet — the engine creates the dest directory, copies the file, and fires the callback. This covers the common "TMX now references a newly-added PNG / tileset" case without requiring a rebuild first.
Customize per-watcher:
var w = WatchContentDirectory("Content", reload);
w.AutoCopyExtensions.Add(".ogg");
w.AutoCopyExtensions.Remove(".png");
Gum file types (.gumx, .gusx, .gutx, .behx, .ganx) are intentionally excluded — Gum runs its own hot-reload pipeline and doubling up would conflict. Don't add them.
Other extensions still follow the dest-exists gate — a brand-new .json or .tmx still requires one rebuild before hot-reload notices it (acceptable for non-asset files, since code usually needs to reference them anyway).
Gotchas
- Watch the source folder, not
bin/Debug. The engine handles this for you when you use the path-based overloads (WatchContent("Content/foo.json", ...)); paths are resolved against SourceContentRoots. If you bypass it with the IFileWatcher injection overload, you choose the path yourself.
- Hot-reload is dev-time iteration. Don't rely on
WatchContent calls as gameplay logic — in shipping they no-op.
- In-place reload requires the type/shape to be unchanged. A schema change in your JSON still requires a screen restart — the live object's fields don't know about new property names.