Tutorial 95: Speedy Blupi: A Real-World CNA Port

CNA — C++ XNA 4.0 reimplementation

What you’ll learn

  • What a real MonoGame codebase looked like before the port.
  • The porting process, and which API differences actually caused work.
  • How the content was adapted, and the patterns that recurred.

Before you startTutorial 83: Migrating from MonoGame/FNA to CNA — this is the same migration, carried out on a real game rather than in the abstract.

How to read this tutorial. This is a worked walkthrough of porting a typical 2D MonoGame platformer to CNA, not an audited case study. The translation patterns below are the ones that recur in any port and are worth learning; treat any specific result as illustrative rather than measured. For validation work that is mechanically checked, see the XNA oracle corpus and the FNA differential harness described on the Showcase page.

The starting point

Speedy Blupi is an open-source action platformer with a tile-based world, multiple game modes, animated sprites and SDL-based audio. It is part of the Blupi game series by EPSITEC SA. It stands in here for the general shape of a 2D MonoGame game: SpriteBatch throughout, content loaded through ContentManager, and a custom tilemap renderer.

Original MonoGame codebase

The original Speedy Blupi C# MonoGame codebase used: SpriteBatch for all 2D rendering, ContentManager.Load<Texture2D> for XNB assets, Song and MediaPlayer for background music, SoundEffect for game audio, GamePad and Keyboard input, and a custom tilemap renderer built on SpriteBatch.Draw calls.

Porting process to CNA

  1. Replace using Microsoft.Xna.Framework; with using namespace Microsoft::Xna::Framework;
  2. Replace new SpriteBatch(GraphicsDevice) with std::make_unique<SpriteBatch>(getGraphicsDeviceProperty())
  3. getContentProperty().Load<Texture2D>("name") can stay as-is — CNA's ContentManager reads .xnb and falls back to loose files. If you would rather drop the pipeline, construct directly: Texture2D("assets/name.png", gd)
  4. Song and MediaPlayer need no replacement — MediaPlayer genuinely plays songs via SDL3_mixer. Swapping to a looping SoundEffectInstance is an option, not a requirement
  5. Replace List<T> with std::vector<T> or sharp-runtime's System::Collections::Generic::List<T> (whose count is getCountProperty(), not Count)
  6. Replace null with nullptr, string with std::string
  7. Replace (float)gameTime.getElapsedGameTimeProperty().getTotalSecondsProperty() with static_cast<float>(gt.getElapsedGameTimeProperty().getTotalSecondsProperty())
  8. XACT AudioEngine / WaveBank / SoundBank now work directly in CNA (real .xgs/.xsb/.xwb parsing) — no replacement needed unless you prefer the simpler SoundEffect constructor API

API compatibility lessons

  • Rectangle.Intersects() works identically in CNA and MonoGame — no changes needed
  • Color constructor new Color(r,g,b,a) maps to Color(r,g,b,a) — identical
  • SpriteBatch.Draw overloads are matched 1:1 in CNA — no changes to draw calls
  • Vector2.Zero, Vector2.One are static members in CNA exactly as in XNA
  • MathHelper.Clamp, MathHelper.Lerp behave identically
  • GamePad.GetState(PlayerIndex.One) maps to GamePad::GetState(PlayerIndex::One) — identical semantics

Content adaptation

You have two routes, and they can be mixed asset by asset.

Keep compatible .xnb files. CNA reads them through 50 built-in readers with FFmpeg (49 without video). Call CNA::Internal::Xnb::RegisterAllBuiltInXnbReaders() once at startup. There is no reflection-based discovery, but custom ContentTypeReader<T> creators can be registered explicitly. Compiled effects additionally require a capable renderer build.

Or export to loose files. Export PNGs from the original XNB textures with the MonoGame Content Builder into an assets/ directory and construct textures directly. CNA loads PNG through SDL3_image, and ContentManager falls back to loose files when no .xnb is present.

Custom shaders still need a deliberate portability decision. Alpha.1 can load XNA/FNA D3D9 Effect Framework binaries on FNA3D and explicitly enabled SDL_GPU, EasyGL-family or Vulkan builds, but it does not compile HLSL .fx source or automatically convert DXBC/MGFX. Effects outside that compatible binary path must use the active renderer's ShaderEffect source or binary format. A purely 2D SpriteBatch game like this one is usually unaffected.

Key porting patterns

// Pattern 1: Convert content loading
// C# MonoGame:
//   texture = getContentProperty().Load<Texture2D>("Blupi/blupi");
// CNA C++:
//   texture_ = std::make_unique<Texture2D>("assets/Blupi/blupi.png", gd);

// Pattern 2: Convert Song/MediaPlayer to looping SoundEffectInstance
// C# MonoGame:
//   MediaPlayer.Play(getContentProperty().Load<Song>("music/theme"));
//   MediaPlayer.IsRepeating = true;
// CNA C++:
SoundEffect music("assets/music/theme.ogg");
auto musicInst = music->CreateInstance();
musicInst->setIsLoopedProperty(true);
musicInst->Play();

// Pattern 3: Convert string interpolation to std::string
// C# MonoGame:
//   string path = $"sprites/{name}.png";
// CNA C++:
std::string path = "assets/sprites/" + name + ".png";

// Pattern 4: Convert foreach to range-for
// C# MonoGame:
//   foreach (var tile in tiles) tile.Draw(spriteBatch);
// CNA C++:
for (auto& tile : tiles_) tile.Draw(*spriteBatch_);

// Pattern 5: Convert nullable references to std::optional
// C# MonoGame:
//   Texture2D? overrideTexture = null;
// CNA C++:
std::optional<Texture2D*> overrideTexture_ = std::nullopt;

// Pattern 6: Convert C# events to sharp-runtime System::EventHandler<TEventArgs>
// C# MonoGame:
//   public event Action<int> OnScoreChanged;
// CNA C++ with sharp-runtime: the payload travels in an EventArgs subclass.
struct ScoreChangedEventArgs : System::EventArgs {
    SharpRuntime::intcs NewScore = 0;
};

System::EventHandler<ScoreChangedEventArgs> OnScoreChanged;

// Subscribe with += (the handler takes sender + args):
OnScoreChanged += [](System::Object* sender, const ScoreChangedEventArgs& e) {
    // react to e.NewScore
};

// Raise it (Invoke() is an alias for Raise()):
ScoreChangedEventArgs args;
args.NewScore = score_;
OnScoreChanged.Raise(this, args);