Tutorial 80: Cross-Platform Build Guide

CNA — C++ XNA 4.0 reimplementation

ℹ

What you’ll learn

  • Which targets CNA supports, at what level, and what each needs from the build.
  • CMake toolchain files, and guarding platform code with #ifdef.
  • Path separators and case-sensitive file systems — the two portability bugs that bite first.
  • Running OpenGL ES on the desktop through Mesa.

Before you start — Tutorial 20: Building and Running Your Game — this generalises that single-platform build to the rest of the matrix.

Platform matrix

These are support levels as they actually stand, not aspirations. The renderer column names one sensible choice per platform — CNA has 14 renderer identities in total, and the platform gates that decide which are even configurable are covered in Tutorial 72. Windows-only renderers: DIRECTX9 and DIRECTX11. Emscripten-only: WEBGL2. Apple-only: METAL (macOS and iOS). On iOS the allow-list is SDL_RENDERER and METAL. The default is WEBGL2 under Emscripten, OPENGLES3 on Linux and SDL_RENDERER everywhere else. The rows below are the target operating system; the platform-implementation and audio axes follow in Platform and audio layers.

Platform Typical renderer Level Notes
Linux OPENGLES3 (default), VULKAN, SDL_RENDERER, OPENGL33, SOFTWARE, and the rest Primary Development and CI platform. Every renderer except the four gated to Windows, Emscripten or Apple is Linux-configurable. Platform layers: SDL3 (default; X11 and Wayland sessions through SDL’s video drivers), TERMINAL, HEADLESS. CI runs under Xvfb with Mesa software drivers, so the GPU pixel matrix is not an automatic gate.
Windows SDL_RENDERER (default); the two Windows-only renderers; the portable ones Supported, thinly tested The native MSVC workflows (Direct3D 11, the SDL3 platform on Windows, and the content pipeline) do not run on ordinary pushes, and no automatic workflow builds for Windows. Windowing, input, IME and gamepads come from SDL3; a build without SDL has no window, and its audio must be NULL. Video is never built.
macOS SDL_RENDERER (default), METAL Supported, build workflows configured apple-ci builds the SDL_RENDERER configuration on macos-26 automatically, runs the portable suites and launches a self-contained .app bundle; metal-macos-ci builds METAL and runs its ^Metal tests (see Platform Support). Deployment floor macOS 13.3. Windowing comes from the SDL3 platform layer.
Android SDL_RENDERER (default) Code paths exist, unverified NDK sensor backends and build wiring are present, plus one Gradle demo project. No CI and no CMake preset. See Tutorial 82.
Web (Emscripten) WEBGL2 (default), WEBGPU (experimental) Supported, with caveats A multi-renderer build/link workflow covers WEBGL2;WEBGPU and does not run the bundle in a browser (see Platform Support). Saves persist through IndexedDB (a threaded build needs -DCNA_EMSCRIPTEN_USE_WASMFS=OFF); no video — see Tutorial 81. A stack-allocated Game is fine in this snapshot.
iOS SDL_RENDERER and METAL (the two allowed) Experimental cmake/toolchains/ios.cmake and the ios and ios-simulator presets exist. The Apple workflow final-links device apps and installs and launches a one-frame simulator smoke app, for both renderers; that says nothing about physical devices, input, audio or pixels. Deployment floor iOS 16.3. tvOS is unsupported.

Prerequisites that catch people out

  • Sibling checkouts, not submodules. Every build needs ../sharp-runtime next to cna/, on its apple/m4-stabilization branch, and CNA itself must be its apple/m4-stabilization branch (git clone -b apple/m4-stabilization); the GitHub default branches are older. Because the Linux default renderer is OPENGLES3, a default Linux build also needs ../easy-gl (develop) and ../meta-gl (apple/m4-stabilization) — as does any of the three GL identities.
  • FFmpeg is optional. CNA_ENABLE_VIDEO defaults to AUTO: FFmpeg video is built only when its four development packages are found. It is never built for Windows, Emscripten, Android or iOS. Video and VideoPlayer still link everywhere; without a backend they throw NotSupportedException when used, and the CNA_VIDEO_AVAILABLE compile definition tells you at build time.
  • Development packages before the first configure. The vendored SDL3 is built at first configure with whichever X11, Wayland, OpenGL and audio development packages exist at that moment, and caches the result in .sdl-prebuilt-* (see Tutorial 02 for the package list).
  • Submodules: git submodule update --init fetches five (SDL, SDL_image, SDL_mixer, draco, googletest). Non-recursive is correct, and much faster.
  • The C++ framework has no general install/export package. C++ consumers add_subdirectory the CNA tree. The experimental C layer (CNA_BUILD_C_API=ON, which requires CNA_ENABLE_NET=ON) declares a separate package, but no CI job builds it, so it is not a shipping path.

CMake toolchains

CNA uses a standard CMake build with optional toolchain files for cross-compilation. Select the appropriate toolchain file for your target and pass -DCNA_GRAPHICS_RENDERER=OPENGLES3 (or VULKAN / SDL_RENDERER) to choose the renderer. The equivalent single-option form -DCNA_RENDERER_OPENGLES3=ON also works — use one or the other, never both.

# Linux native (default) -- clone CNA and sharp-runtime with -b apple/m4-stabilization
cmake -S . -B build -DCNA_GRAPHICS_RENDERER=OPENGLES3

# Windows cross from Linux using MinGW
cmake -S . -B build-win \
  -DCMAKE_TOOLCHAIN_FILE=cmake/toolchains/mingw-w64.cmake \
  -DCNA_GRAPHICS_RENDERER=DIRECTX11

# WebAssembly via Emscripten (CI uses emsdk 6.0.3)
emcmake cmake -S . -B build-wasm \
  -DCNA_GRAPHICS_RENDERER=WEBGL2 \
  -DCMAKE_BUILD_TYPE=Release
# (or pass -DCMAKE_TOOLCHAIN_FILE=$EMSDK/upstream/emscripten/cmake/Modules/Platform/Emscripten.cmake)

# Android (requires NDK; the tree's only Android project pins NDK 30, API 24, arm64-v8a)
cmake -S . -B build-android \
  -DCMAKE_TOOLCHAIN_FILE=$ANDROID_NDK/build/cmake/android.toolchain.cmake \
  -DANDROID_ABI=arm64-v8a \
  -DANDROID_PLATFORM=android-24 \
  -DCNA_GRAPHICS_RENDERER=SDL_RENDERER

# macOS native (SDL_RENDERER; METAL is the macOS-only native renderer)
cmake --preset macos
# ... or: cmake -S . -B build-macos -DCNA_GRAPHICS_RENDERER=METAL

# iOS (macOS host with Xcode): device and simulator presets, SDL_RENDERER only
cmake --preset ios
cmake --preset ios-simulator

# Build the whole directory, or a real target such as CnaTests --
# "--target CNA" no longer works: CNA is an INTERFACE library with no sources.
cmake --build build

Conditional platform code with #ifdef

#include <cstdlib>
#include <string>

// Where a hand-rolled save directory would live on each target. Prefer
// StorageDevice/StorageContainer (Tutorial 19): CNA resolves the per-user root
// for you, the same way on every windowing platform.
std::string SaveDirectory()
{
#if defined(_WIN32)
    // Windows: %LOCALAPPDATA%
    const char* base = std::getenv("LOCALAPPDATA");
    return std::string(base ? base : ".") + "\\MyGame\\";
#elif defined(__EMSCRIPTEN__)
    // WebAssembly: CNA mounts IndexedDB-backed storage for StorageDevice
    // (/cna-storage) and IsolatedStorage (/save) and restores it before main().
    // A hand-built path outside those mounts is in memory only and does not
    // survive a page reload. Threaded builds need CNA_EMSCRIPTEN_USE_WASMFS=OFF.
    return "";   // use StorageDevice instead of a hand-built path
#elif defined(__ANDROID__)
    // Android: HOME is not defined and the working directory is not writable.
    // StorageDevice asks the platform for the app's private files directory.
    return "";   // use StorageDevice instead of a hand-built path
#elif defined(__APPLE__)
    const char* home = std::getenv("HOME");
    return std::string(home ? home : ".") + "/Library/Application Support/MyGame/";
#else
    // Linux and other Unix: ~/.local/share (StorageDevice also honours $XDG_DATA_HOME)
    const char* home = std::getenv("HOME");
    return std::string(home ? home : ".") + "/.local/share/MyGame/";
#endif
}

Path separators

CNA's ContentManager accepts both / and \ on all platforms. Internally paths are normalized. Prefer / in your code and avoid hardcoding \. To find the executable directory at runtime in a platform-neutral way, ask your platform layer (with the default SDL3 platform that is SDL_GetBasePath()); a windowless build configured with CNA_ENABLE_SDL=OFF has no SDL, so do not depend on it in game code that must build there.

File system case sensitivity

Linux file systems are case-sensitive; Windows NTFS is case-insensitive by default. Always use consistent casing for asset filenames (prefer all lowercase). A common portability bug is naming a file Assets/Textures/Logo.png on Windows but referencing it as assets/textures/logo.png on Linux — the Windows build loads it fine while the Linux build silently fails.

OpenGL ES on desktop (Mesa)

CNA's OPENGLES3 renderer targets OpenGL ES 3.0, which runs on desktop Linux via Mesa's GLES implementation. No special configuration is needed; Mesa exposes GLES through the same EGL/GLX path used for desktop OpenGL.

# Verify GLES support:
glxinfo | grep "OpenGL ES"
# Expected: OpenGL ES profile version string: OpenGL ES 3.2 Mesa ...

Platform and audio layers

CNA separates the target operating system, the graphics renderer, the host-platform implementation (CNA_PLATFORM) and the audio implementation (CNA_AUDIO_PLATFORM). SDL3 is the default for both and CNA’s one windowing implementation (it reaches Windows, X11, Wayland, macOS, iOS, Android and the browser through SDL’s own video drivers); the headless and terminal platforms have no window, and a native ALSA audio backend has CNA’s own mixer. These axes have explicit compatibility rules, so do not infer host behaviour from the renderer name alone.

OptionValuesOffered when
CNA_PLATFORMSDL3 (default), HEADLESSalways
TERMINALnon-Windows targets; CPU and no-output renderers only (SOFTWARE, HEADLESS, STUB)
CNA_AUDIO_PLATFORMSDL3 (default), NULLalways
ALSALinux only
CNA_ENABLE_SDLAUTO (default), ON, OFFOFF configures no SDL and refuses the SDL3 platform, SDL3 audio and the three SDL-linking renderers (SDL_RENDERER, SDL_GPU, FNA3D)

SDL12 and EMSCRIPTEN are reserved platform values and OPENAL and WASAPI are reserved audio values; all four fail configuration rather than falling back. Only the SDL3 and ALSA audio values define SOUND_ENABLED and provide a mixer for the XNA playback classes; NULL exposes a lower-level device selection, not equivalent high-level playback. Windows and macOS have no SDL-free audio path, so an SDL-free (windowless) build there uses NULL audio. The Platforms reference has the full matrices, the Windows, X11 and Wayland page covers how SDL3 reaches each window system and what an SDL-free build is, and Tutorial 127 walks the selection rules.

Two recipes that CNA’s own CI configures and runs (see Tutorial 138 for the SDL-free walkthrough):

# No SDL at all: windowless, ALSA audio through CNA's own mixer, no-output renderer
cmake -S . -B build-nosdl -DCMAKE_BUILD_TYPE=Debug \
  -DCNA_ENABLE_SDL=OFF -DCNA_PLATFORM=HEADLESS \
  -DCNA_AUDIO_PLATFORM=ALSA -DCNA_GRAPHICS_RENDERER=HEADLESS

# POSIX terminal: the SOFTWARE rasterizer presents frames to a TTY
cmake -S . -B build-term -DCMAKE_BUILD_TYPE=Debug \
  -DCNA_PLATFORM=TERMINAL -DCNA_AUDIO_PLATFORM=NULL -DCNA_GRAPHICS_RENDERER=SOFTWARE

CMakeLists.txt with platform detection

cmake_minimum_required(VERSION 3.20)
project(MyGame CXX)
set(CMAKE_CXX_STANDARD 23)

# Add the CNA source checkout (the C++ framework has no general installed package),
# without CNA's own tests and demos
set(CNA_BUILD_TESTS OFF CACHE BOOL "" FORCE)
set(CNA_BUILD_EXAMPLES OFF CACHE BOOL "" FORCE)
add_subdirectory(../cna cna-build)

add_executable(MyGame main.cpp)
target_link_libraries(MyGame PRIVATE CNA)   # the target is CNA; there is no CNA::CNA alias

# Platform-specific finishing touches. The Android system libraries (android, log)
# and MinGW's winmm arrive transitively through CNA; nothing to add here for them.
if(WIN32)
    # The vendored SDL3 is a shared library on Windows: copy the DLLs beside the executable
    if(COMMAND cna_copy_sdl_runtime)
        cna_copy_sdl_runtime(MyGame)
    endif()
elseif(EMSCRIPTEN)
    set_target_properties(MyGame PROPERTIES SUFFIX ".html")
    # Blocking Game::Run() needs Asyncify (-sASYNCIFY=1)
    target_link_libraries(MyGame PRIVATE CNA::EmscriptenAsyncify)
    target_link_options(MyGame PRIVATE -sALLOW_MEMORY_GROWTH=1
        "SHELL:--shell-file ${CMAKE_SOURCE_DIR}/shell.html")
    cna_apply_emscripten_renderer_link_contract(MyGame)   # WebGL version flags for WEBGL2
endif()

# Detect 64-bit build
if(CMAKE_SIZEOF_VOID_P EQUAL 8)
    message(STATUS "Building 64-bit")
else()
    message(STATUS "Building 32-bit")
endif()

Cross-platform file path helper

#include <string>
#include <algorithm>

// Normalize path separators to forward slash
std::string NormalizePath(std::string path) {
    std::replace(path.begin(), path.end(), '\\', '/');
    return path;
}

// Join two path segments safely
std::string JoinPath(const std::string& base, const std::string& rel) {
    if (base.empty()) return rel;
    std::string result = NormalizePath(base);
    if (result.back() != '/') result += '/';
    result += NormalizePath(rel);
    return result;
}