| name | multiplatform-conversion |
| description | Converting a single-target FlatRedBall2 desktop sample into a dual-target desktop + KNI BlazorGL (Blazor WebAssembly / browser) project. Use when the user mentions web deployment, browser/WASM/itch.io targets, KNI, or asks to add web support to an existing game. Assumes you already have a working desktop sample — see sample-project-setup for the desktop bootstrap. |
Multi-Platform Conversion (Desktop + KNI BlazorGL)
Reference samples: samples/auto/AutoEvalKniBlazorSample/ (minimal — one XNB, no real content) and samples/PlatformKing/ (content-rich — TMX, JSON, PNG animations). Read PlatformKing first when porting any non-trivial game; AutoEval only proves the wiring, not the content story.
Backend selection is by which project file you reference
src/FlatRedBall2.csproj (MonoGame/desktop) and src/Kni/FlatRedBall2.Kni.csproj (KNI/browser) are single-TFM (net10.0) project files, not one multi-targeted project — see the comment atop either file for why. Consumers pick a backend by which file they ProjectReference/which NuGet package (FlatRedBall2.MonoGame vs FlatRedBall2.Kni) they install, not by TFM. This is the linchpin — every gotcha below comes from wiring the wrong file/package to the wrong head.
The KNI file lives in its own Kni\ subfolder, not next to the MonoGame one: two SDK projects globbing the same physical directory hit a real MSBuild parallel-build race (confirmed — -m:1 always succeeds, default parallel builds fail intermittently depending on the solution's project-graph shape). Physical separation removes the race at its root; see the comment atop FlatRedBall2.Kni.csproj for the full story.
Project layout
Three projects, mirroring AutoEvalKniBlazorSample:
GameName/
GameName.Common/ net10.0 game code + Content/ — MonoGame build (see below)
GameName.Common/Kni/ net10.0 KNI build, same source, own subfolder
GameName.Desktop/ net10.0 Program.cs, MonoGame
GameName.BlazorGL/ net10.0 Blazor WASM host
GameName.slnx
The Game subclass (Game1) lives in Common so both heads instantiate the same type. Heads own only their entry points and platform-specific csproj wiring.
Asset placement
Assets belong in Common/Content/ by default. Only move to platform-specific folders if they won't work elsewhere (e.g., platform-specific UI sizes, backend-incompatible shader variants). Most art, audio, and data stay in Common/ so both Desktop and BlazorGL draw from a single source — no duplication.
Common — two single-TFM csproj files, KNI in its own subfolder
Desktop and BlazorGL both now target net10.0, so a single Common project can no longer pick the backend by Condition="'$(TargetFramework)' == ..." — both heads would match the same condition. Splitting into two files that share a directory isn't safe either (the MSBuild parallel-build race above). Instead:
GameName.Common.csproj (MonoGame) stays where it is, default compile items, references FlatRedBall2.csproj/FlatRedBall2.MonoGame. Add <DefaultItemExcludes>$(DefaultItemExcludes);Kni\**</DefaultItemExcludes> so its own glob never reaches into the subfolder below.
GameName.Common\Kni\GameName.Common.Kni.csproj (KNI) sets <EnableDefaultCompileItems>false</EnableDefaultCompileItems> and explicitly compiles the parent's source: <Compile Include="..\**\*.cs" Exclude="..\obj\**;..\bin\**;..\Kni\**" />. References ..\..\..\src\Kni\FlatRedBall2.Kni.csproj/FlatRedBall2.Kni.
GameName.Desktop.csproj references the plain file; GameName.BlazorGL.csproj references the one in Kni\.
See samples/Solitaire/Solitaire.Common/ for a working example, including how its Gum content-copy <Content Include> items pick up Link metadata once the source path (..\Content\...) no longer matches the project's own directory.
No Version attribute — the repo uses NuGet Central Package Management, so versions are pinned once in Directory.Packages.props, not per PackageReference.
Common must NOT add MonoGame.Content.Builder.Task — that belongs on the heads that drive content compilation. Apos.Shapes needs no content-pipeline wiring at all — its shader is embedded in the assembly.
Backend-conditional code
Each file sets its own KNI / MONOGAME define unconditionally (no Condition needed — the file itself is the condition):
<DefineConstants>$(DefineConstants);KNI</DefineConstants>
The two known places #if KNI is needed today:
GraphicsProfile — Apos.Shapes ships SM 4.0+ shaders. MonoGame's top profile is HiDef. KNI's equivalent is FL10_0, which doesn't exist on MonoGame. The Reach default (SM 2.0) crashes at runtime with "Shader model 4.0 is not supported."
- Anywhere a backend exposes a type the other doesn't. Stay vigilant; most XNA surface is shared.
Two canvas patterns — pick one before writing Game1
Stretch-to-viewport (canvas fills the browser) and fixed-size canvas (matches the desktop window) need opposite engine settings. Each pattern is a coordinated set across Game1, holder CSS, body CSS, and the JS host script — mixing them produces the squashing / shifting bugs the engine gates were added to prevent.
Pattern A is the recommended default. With DisplaySettings.AspectPolicy = AspectPolicy.Locked (the engine default), the canvas can fill the browser viewport and the engine pillarboxes/letterboxes the gameplay area to the design ratio internally — no playfield reshaping. Pattern B is only needed for legacy fixed-canvas embeds.
DisplaySettings.AllowUserResizing is the source-of-truth signal — it propagates to Game.Window.AllowUserResizing at init and gates two engine behaviors: the externallyManaged check that skips ApplyWindowSettings, and HandleClientSizeChanged short-circuiting browser resize echoes.
Pattern A — Stretch-to-viewport (recommended)
Canvas fills the browser; the engine pillarbox/letterboxes the design world to its locked aspect ratio inside the canvas. Reference: samples/ShmupSpace, samples/PlatformKing.
- Game1: identical code on Desktop and KNI — set
ResolutionWidth/Height, PreferredWindowWidth/Height, AllowUserResizing = true, all on DisplaySettings. The engine ignores PreferredWindowWidth/Height on KNI (the canvas DOM owns sizing) so no #if KNI is needed.
- Holder + canvas markup: ships from the
FlatRedBall2.BlazorGL package's Pages/Index.razor — fills viewport via position: fixed; top: 0; left: 0; right: 0; bottom: 0. No per-sample Razor needed.
- Body CSS (
wwwroot/index.html): margin: 0; overflow: hidden; — no flex centering needed.
- JS: ships from the package as
_content/FlatRedBall2.BlazorGL/frb-host.js (referenced via one <script> tag in index.html). Defines initRenderJS and tickJS; sets canvas buffer once from holder size.
Pattern B — Fixed-size canvas (legacy)
Canvas locked at exact pixel dimensions; nothing scales. Use only for embeds with a strict pixel budget; otherwise prefer Pattern A + locked aspect.
- Game1: set
PreferredWindowWidth/Height on both backends, plus ds.AllowUserResizing = false. Don't set Window.AllowUserResizing = true. Per-screen PreferredDisplaySettings left unset (same as Pattern A).
- Index.razor override: ship a per-sample
Pages/Index.razor that overrides the package's route (@page "/") with explicit-dimension CSS:
- Holder CSS:
width: NNNpx; height: MMMpx; flex-shrink: 0; — explicit dims, won't shrink in flex centering.
- Canvas CSS:
width: NNNpx; height: MMMpx; display: block; — explicit dims, not 100% (defense in depth if holder is overridden).
- Body CSS:
display: flex; align-items: center; justify-content: center; min-height: 100vh; overflow: auto; background: #222; — centers the canvas; scrolls when viewport is smaller than the canvas.
- JS hooks: still load
frb-host.js from the package; add an inline override script that uses the frbBeforeTick hook to re-pin canvas dimensions each frame. KNI BlazorGL auto-resizes the drawing buffer when the browser resizes; the per-frame lock undoes that:
<script src="_content/FlatRedBall2.BlazorGL/frb-host.js"></script>
<script>
var lockW = 0, lockH = 0;
window.frbAfterInit = function (canvas, holder) {
lockW = holder.clientWidth;
lockH = holder.clientHeight;
};
window.frbBeforeTick = function () {
var c = document.getElementById('theCanvas');
if (c) { if (c.width !== lockW) c.width = lockW; if (c.height !== lockH) c.height = lockH; }
};
</script>
All parts must be present together — omit any one and the buffer or viewport drifts on browser resize.
Game code must avoid System.IO.File for content
Browsers have no filesystem. Any File.ReadAllText / File.OpenRead / File.Exists / Path.GetFullPath call against a content path crashes on WASM with Could not find a part of the path '/Content/...' (the leading / is Path.GetFullPath resolving against the WASM working directory /).
Route every content read through Microsoft.Xna.Framework.TitleContainer.OpenStream(path) instead — it dispatches to File IO on Desktop and HTTP fetch in the browser, single code path:
using var stream = TitleContainer.OpenStream(path);
using var reader = new StreamReader(stream);
var json = reader.ReadToEnd();
This applies to game-specific config loaders (the engine's own PlatformerConfig.FromJson, TopDownConfig.FromJson, TileMap, animation/atlas loaders, and ContentLoader already do the right thing internally). Save data and user settings legitimately need File and stay desktop-only — gate them with #if !KNI.
Content pipeline — single source of truth in Common
Keep Content/ in Common. Both heads consume it without duplication.
Desktop uses MGCB. Link Common's raw runtime-loaded assets (TMX, JSON, animation PNGs — anything not built to XNB) into the Desktop output's Content/ folder:
<Content Include="..\GameName.Common\Content\Tiled\**"
Link="Content\Tiled\%(RecursiveDir)%(Filename)%(Extension)"
CopyToOutputDirectory="PreserveNewest" />
MonoGameContentReference is project-local; Desktop needs its own minimal Content/Content.mgcb (see sample-project-setup).
BlazorGL is the trap. <Content Link="wwwroot\…"> copies to bin/.../wwwroot/ but the file is not registered as a static web asset, so the dev server returns 404. Files must land in the project's physical wwwroot/Content/ directory before the static-web-asset manifest is gathered. Use a <Copy> target:
<Target Name="CopyCommonRawAssetsToWwwroot"
BeforeTargets="GenerateStaticWebAssetsManifest;AssignTargetPaths"
Inputs="@(_CommonRawAssets)"
Outputs="@(_CommonRawAssets -> '$(MSBuildProjectDirectory)\wwwroot\Content\%(RecursiveDir)%(Filename)%(Extension)')">
<Copy SourceFiles="@(_CommonRawAssets)"
DestinationFiles="@(_CommonRawAssets -> '$(MSBuildProjectDirectory)\wwwroot\Content\%(RecursiveDir)%(Filename)%(Extension)')"
SkipUnchangedFiles="true" />
</Target>
Same pattern as the RedirectKniContentToWwwroot target, which writes XNBs to the physical wwwroot. Gitignore the destination tree (wwwroot/Content/.gitignore excluding * except itself) so the copies aren't committed.
BlazorGL head — minimum setup
Reference: AutoEvalKniBlazorSample.BlazorGL. Each sample's .BlazorGL head owns only:
.csproj — SDK = Microsoft.NET.Sdk.BlazorWebAssembly, <KniPlatform>BlazorGL</KniPlatform>, the nkast.Xna / nkast.Kni.Platform.Blazor.GL package list, the RedirectKniContentToWwwroot target. <ProjectReference> to src/FlatRedBall2.BlazorGL/FlatRedBall2.BlazorGL.csproj (the host package, not the engine itself).
Program.cs — standard Blazor WASM bootstrap. Two FRB-specific lines:
builder.RootComponents.Add<FlatRedBall2.BlazorGL.App>("#app");
builder.Services.AddSingleton<Func<Game>>(_ => () => new MyNamespace.Game1());
wwwroot/index.html — the standard Blazor scaffold + script tags:
<script src="_framework/blazor.webassembly.js"></script>
<script src="_content/FlatRedBall2.BlazorGL/frb-host.js"></script>
The package's frb-host.js defines tickJS, initRenderJS, optional hooks
(window.frbBeforeTick, window.frbAfterInit), content prefetching via
content-manifest.json, and keyboard/mouse scroll prevention for itch.io iframes.
Properties/launchSettings.json — pick a unique launch port. AutoEvalKniBlazorSample uses 50470/50471; pick something else. Concurrent debugging across samples breaks if ports collide.
Do not duplicate App.razor, MainLayout.razor, _Imports.razor, Pages/Index.razor, or the tickJS/initRenderJS JS block. They ship from FlatRedBall2.BlazorGL and are wired by the RootComponents.Add<App> and frb-host.js reference above. The package's Index resolves Func<Game> from DI on the first tick — that's why Program.cs must register it.
Content prefetching (automatic)
The FlatRedBall2.BlazorGL NuGet package includes a .targets file that auto-imports
into consuming projects. On every build it enumerates all files under wwwroot/Content/
and writes wwwroot/content-manifest.json — a JSON array of paths relative to wwwroot,
each prefixed Content/.
At runtime, frb-host.js fetches content-manifest.json and fires background fetch()
for every listed file during initRenderJS. Both fetch() and the synchronous
XMLHttpRequest used by TitleContainer.OpenStream share the browser HTTP cache, so
by the time game content loads, the files are served from local cache instead of the
network. Errors are silently swallowed (fire-and-forget).
No game author action required. The manifest is generated automatically from whatever
files exist in wwwroot/Content/ at build time. Add or remove content files and the
manifest stays in sync on the next build.
Landmine: build/*.targets auto-import only happens for NuGet PackageReference
consumers — a ProjectReference to the host project does not import them. That's why
repo samples carry an explicit
<Import Project="...\FlatRedBall2.BlazorGL\build\FlatRedBall2.BlazorGL.targets" />;
without it, no manifest is ever generated. NuGet consumers need nothing.
Verification
dotnet build GameName.Desktop/ clean.
dotnet build GameName.BlazorGL/ clean.
dotnet run --project GameName.Desktop/ plays the original game unchanged.
dotnet run --project GameName.BlazorGL/ serves the dev URL; canvas fills viewport; resizing the window keeps rendering correct (proves AllowUserResizing is set).
Known limitations (as of 2026-08-20)
- No gamepad polling guarantee on web. Browser gamepad APIs require a connected-device gesture before reporting state.
- Audio gated by user gesture. Browsers block audio playback until the user interacts with the page once. Have a "click to start" affordance if music plays on screen entry.
DynamicSoundEffectInstance sample rate must match the browser's AudioContext rate on Blazor.GL, or SubmitBuffer throws (Sample rate 44100 does not match AudioContext sample rate 48000). Desktop OpenAL resamples any source rate for free; Blazor.GL does not, and Kni exposes no public way to read the actual AudioContext rate (feature-requested: kniEngine/kni#2690). Until that lands, fall back to a couple of common candidate rates (48000, then 44100) and retry on failure.
Pitch on SoundEffectInstance/DynamicSoundEffectInstance throws on Blazor.GL with the currently-published Kni NuGet packages. Fixed upstream (kniEngine/kni#2614, #2615) but not yet released — confirmed via diagnostics/MusicPitchWebSpike. Re-check once Kni cuts a release containing both.