# Multiplatform Conversion

> 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.

- Skill: `vchelaru/multiplatform-conversion` (Agent Skill)
- Install (CLI): `npx skillmds@latest add vchelaru/multiplatform-conversion`
- Raw SKILL.md: https://api.skillmd.com/api/skills/vchelaru/multiplatform-conversion/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: vchelaru (https://skillmd.com/u/vchelaru)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/vchelaru/multiplatform-conversion

---


# 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.

**Landmine: list every `ProjectReference` target as its own `<Project Path>` in the `.slnx`.** Visual Studio's IDE-hosted NuGet restore only walks `ProjectReference`s among projects that are themselves listed as solution members — a project that exists on disk and is correctly referenced but missing from the `.slnx` builds fine via `dotnet build`/CLI `msbuild` (both walk the full reference closure regardless of solution membership) but fails with NU1105 ("not part of the current solution") the moment someone opens the `.slnx` in Visual Studio. So `GameName.slnx` needs `GameName.Common\Kni\GameName.Common.Kni.csproj` listed explicitly alongside `GameName.Common.csproj`, not just the projects a human would think of as "the heads." `tests/FlatRedBall2.Tests/Packaging/SolutionMembershipTests.cs` checks this for every `.slnx` in this repo; there's no equivalent check for a downstream game project, so verify it by hand when scaffolding one.

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):

```xml
<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:

```html
<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:

```csharp
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:

```xml
<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:

```xml
<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:
  ```csharp
  builder.RootComponents.Add<FlatRedBall2.BlazorGL.App>("#app");
  builder.Services.AddSingleton<Func<Game>>(_ => () => new MyNamespace.Game1());
  ```
- **`wwwroot/index.html`** — the standard Blazor scaffold + script tags:
  ```html
  <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

1. `dotnet build GameName.Desktop/` clean.
2. `dotnet build GameName.BlazorGL/` clean.
3. `dotnet run --project GameName.Desktop/` plays the original game unchanged.
4. `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.

