Integrating Free API into your application

Step-by-step guide for porting a legacy Win32 game or application to use Free API so it can compile and run natively on Linux, macOS, Android, and Web.

Prerequisites

Add Free API to your CMake project

The recommended approach is to include Free API as a Git submodule and add it as a CMake subdirectory.

Add as a Git submodule

bash
# Inside your game repository
git submodule add https://github.com/openeggbert/free-api.git external/free-api
git submodule update --init --recursive
      

Configure CMakeLists.txt

CMakeLists.txt
cmake_minimum_required(VERSION 3.23)
project(MyGame CXX)

# SDL3 must be available before free-api is added.
# If using the free-eggbert vendored SDL, include ThirdPartySDL.cmake first.
# Otherwise install SDL3 via your package manager.
find_package(SDL3 REQUIRED)
find_package(SDL3_image REQUIRED)
find_package(SDL3_mixer REQUIRED)

# Disable tests when embedding free-api
set(FREE_API_BUILD_TESTS OFF CACHE BOOL "" FORCE)
add_subdirectory(external/free-api)

add_executable(MyGame
    src/main.cpp
    # ... your source files
)

set_target_properties(MyGame PROPERTIES
    CXX_STANDARD 20
    CXX_STANDARD_REQUIRED YES
)

target_link_libraries(MyGame PRIVATE
    free-api::free-api          # the library itself
    free-api::compat_headers    # drop-in windows.h, io.h, etc.
)
      
Two separate targets free-api::free-api links the compiled .a library. free-api::compat_headers is an interface-only target that adds the include/ directory (and include_non_windows/ on non-Windows hosts) to your include path. Both are required.
Do not add compat_headers globally The compatibility headers must not be visible to SDL3 or other third-party libraries — they would break. Apply free-api::compat_headers only as a PRIVATE dependency of your game executable or library.

Entry point — WinMain bridge

Win32 games use WinMain instead of main. Free API provides a macro that creates a standard main that calls your WinMain transparently.

Option A — use the macro (recommended, no source changes)

If your source file already contains a WinMain function, simply add the macro call at the top of the file (or in a separate translation unit):

main.cpp
#include <windows.h>

// Generates a standard main() that calls WinMain.
// Only active on non-Windows platforms.
FREE_API_IMPLEMENT_WINMAIN()
{
    // Original WinMain body
    WNDCLASSA wc = {};
    wc.lpfnWndProc   = MyWindowProc;
    wc.hInstance     = hInstance;
    wc.lpszClassName = "MyGameClass";
    RegisterClass(&wc);

    HWND hwnd = CreateWindowA(
        "MyGameClass", "My Game",
        WS_OVERLAPPEDWINDOW, 100, 100, 800, 600,
        NULL, NULL, hInstance, NULL);
    ShowWindow(hwnd, SW_SHOW);

    MSG msg;
    while (GetMessage(&msg, NULL, 0, 0)) {
        TranslateMessage(&msg);
        DispatchMessage(&msg);
    }
    return (int)msg.wParam;
}
      

Option B — manual bridge

For finer control, call FreeApiRunWinMain directly:

main_bridge.cpp
#include <windows.h>

// Forward-declare WinMain from the game's original source
int WINAPI WinMain(HINSTANCE, HINSTANCE, LPSTR, int);

int main(int argc, char** argv)
{
    return FreeApiRunWinMain(&WinMain, argc, argv);
}
      

Writing a window procedure

The window procedure (WNDPROC) works identically to Win32. Free API delivers all events through the same WM_* message constants.

C++ — WNDPROC example
LRESULT CALLBACK MyWindowProc(HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM lParam)
{
    switch (uMsg) {

    case WM_CREATE:
        // Window was just created; initialise game state here
        return 0;

    case WM_DESTROY:
        PostQuitMessage(0);
        return 0;

    case WM_KEYDOWN:
        switch (wParam) {
        case VK_ESCAPE:  PostQuitMessage(0); break;
        case VK_LEFT:    /* move player left */ break;
        case VK_RIGHT:   /* move player right */ break;
        case VK_SPACE:   /* fire */ break;
        }
        return 0;

    case WM_MOUSEMOVE: {
        int x = LOWORD(lParam);  // cursor X in client coords
        int y = HIWORD(lParam);  // cursor Y in client coords
        // BOOL lBtn = (wParam & MK_LBUTTON) != 0;
        return 0;
    }

    case WM_LBUTTONDOWN:
        /* handle left click */
        return 0;

    case WM_TIMER:
        /* periodic WM_TIMER from SetTimer */
        return 0;

    }
    return DefWindowProcA(hWnd, uMsg, wParam, lParam);
}
      

Rendering with the GDI bitmap subset

Free API implements just enough GDI to blit pre-rendered bitmaps to the window. This is the pattern used by Speedy Blupi and Planet Blupi.

C++ — load BMP and blit to window
// Load a BMP file from disk
HBITMAP hBmp = (HBITMAP)LoadImageA(
    NULL, "data/sprites.bmp",
    IMAGE_BITMAP, 0, 0, LR_LOADFROMFILE);

// Create a memory DC and select the bitmap into it
HDC hdcMem = CreateCompatibleDC(NULL);
HGDIOBJ hOld = SelectObject(hdcMem, hBmp);

// Get a surface DC for the window (returned by the game engine internals)
// In Free API, the window DC is obtained through the message pump.
// A common pattern is to keep a global HDC set up during WM_CREATE.
HDC hdcWindow = GetWindowDC(hWnd);  // if available in your integration

// Blit: copy 64×64 sprite at (srcX, srcY) to window at (dstX, dstY)
StretchBlt(
    hdcWindow, dstX, dstY, 64, 64,
    hdcMem,    srcX, srcY, 64, 64,
    SRCCOPY);

// Cleanup
SelectObject(hdcMem, hOld);
DeleteDC(hdcMem);
DeleteObject(hBmp);
      
StretchBlt limitations Only SRCCOPY from a memory DC (with selected bitmap) to a surface DC is implemented. Raster operations other than SRCCOPY, pattern brushes, transparent blits, and alpha-blended blits are not supported.

Reading / writing individual pixels

C++
// Read a pixel colour from a memory DC
COLORREF col = GetPixel(hdcMem, x, y);
BYTE r = GetRValue(col);
BYTE g = GetGValue(col);
BYTE b = GetBValue(col);

// Write a pixel
SetPixel(hdcMem, x, y, RGB(255, 0, 0));  // bright red
      

MIDI music playback

Free API implements MCI MIDI playback using TinySoundFont + TinyMidiLoader over an SDL3 audio stream. No system MIDI driver is required.

Provide a SoundFont

Place a SoundFont 2 file at one of these locations (first found wins):

  1. Set the environment variable: FREE_API_SOUNDFONT=/path/to/file.sf2
  2. Place at assets/soundfont/default.sf2 relative to the working directory
  3. Place at soundfont/default.sf2 relative to the working directory
Free SoundFonts GeneralUser GS (freely redistributable, ~30 MB) — download from schristiancollins.com.
FluidR3_GM — available as the fluid-soundfont-gm Debian/Ubuntu package.

Playing MIDI music — MCI API

C++ — open and play a MIDI file
#include <windows.h>
#include <mmsystem.h>

MCIDEVICEID g_mciDevice = 0;

void PlayMusic(const char* midiPath, HWND hWndNotify)
{
    // Close any currently open device
    if (g_mciDevice) {
        mciSendCommand(g_mciDevice, MCI_CLOSE, 0, 0);
        g_mciDevice = 0;
    }

    MCI_OPEN_PARMS op = {};
    op.lpstrDeviceType  = "sequencer";
    op.lpstrElementName = midiPath;

    MCIERROR err = mciSendCommand(0, MCI_OPEN,
        MCI_OPEN_TYPE | MCI_OPEN_ELEMENT,
        (DWORD_PTR)&op);

    if (err != MMSYSERR_NOERROR) {
        char msg[128];
        mciGetErrorString(err, msg, sizeof(msg));
        OutputDebugString(msg);
        return;
    }

    g_mciDevice = op.wDeviceID;

    MCI_PLAY_PARMS pp = {};
    pp.dwCallback = (DWORD_PTR)hWndNotify;  // window to receive MM_MCINOTIFY

    mciSendCommand(g_mciDevice, MCI_PLAY, MCI_NOTIFY, (DWORD_PTR)&pp);
}

void StopMusic()
{
    if (g_mciDevice) {
        mciSendCommand(g_mciDevice, MCI_CLOSE, 0, 0);
        g_mciDevice = 0;
    }
}
      

Handling MM_MCINOTIFY

C++ — detect song end in WNDPROC
case MM_MCINOTIFY:
    if (wParam == MCI_NOTIFY_SUCCESSFUL) {
        // Song finished — loop or play next track
        PlayMusic("data/music/level2.mid", hWnd);
    }
    return 0;
      

Setting MIDI volume

C++
// Open a dummy MIDI output handle
HMIDIOUT hmo;
midiOutOpen(&hmo, MIDI_MAPPER, 0, 0, 0);

// Set volume: high word = right channel, low word = left channel (0–0xFFFF)
DWORD vol = 0xC000C000;  // ~75% volume both channels
midiOutSetVolume(hmo, vol);

midiOutClose(hmo);
      

Multimedia timers (MMTIMER)

Games that need sub-message-loop timing use timeSetEvent to fire a callback at a precise interval. Free API implements this with SDL3 timers on a private thread.

C++ — periodic timer callback
#include <windows.h>
#include <mmsystem.h>

UINT g_timerId = 0;

void CALLBACK TimerProc(UINT uTimerID, UINT uMsg,
                         DWORD_PTR dwUser, DWORD_PTR dw1, DWORD_PTR dw2)
{
    HWND hWnd = (HWND)dwUser;
    // Post a custom update message to the main window
    PostMessage(hWnd, WM_USER + 1, 0, 0);
}

void StartGameTimer(HWND hWnd)
{
    // Fire every 50 ms (20 Hz), 1 ms resolution
    g_timerId = timeSetEvent(50, 1, TimerProc, (DWORD_PTR)hWnd, TIME_PERIODIC);
}

void StopGameTimer()
{
    if (g_timerId) {
        timeKillEvent(g_timerId);
        g_timerId = 0;
    }
}
      
Thread safety The callback fires on an SDL timer thread. Free API's internal message queue is mutex-protected, so calling PostMessage from the callback is safe. Do not call other Win32-like APIs from the callback unless you know they are thread-safe.

Cross-platform file paths

Legacy Win32 games hard-code backslash paths and sometimes drive letters. Free API handles this transparently.

Automatic fopen normalisation

Every C++ translation unit that includes <windows.h> gets a fopen wrapper that converts paths automatically. You do not need to change game source files:

C++ — these paths work transparently on Linux
// All of these are handled by the fopen wrapper:
FILE* f1 = fopen("data\\config.def", "r");       // backslash → slash
FILE* f2 = fopen("C:\\game\\data\\map.dat", "rb"); // drive + backslash → relative
FILE* f3 = fopen("DATA\\LEVEL01.BLP", "rb");      // uppercase fallback
      

Directory creation

C++
// Creates the directory; backslashes and drive letters are handled
CreateDirectoryA("savegames", NULL);
CreateDirectoryA("C:\\MyGame\\saves", NULL);  // becomes "MyGame/saves" relative
      

File search (directory listing)

C++ — _findfirst/_findnext pattern
#include <io.h>

struct _finddata_t fd;
intptr_t handle = _findfirst("levels\\*.blp", &fd);
if (handle != -1) {
    do {
        printf("Found: %s\n", fd.name);
    } while (_findnext(handle, &fd) == 0);
    _findclose(handle);
}
      

Input pipeline details

Understanding how SDL3 events flow into the WinAPI message queue helps diagnose and debug input issues.

SDL3 OS events
      │
      ▼
PeekMessageA()  ←  called every frame by game's message loop
      │
      ├─ SDL_PumpEvents()     — pull OS events into SDL queue
      ├─ PumpSdlEvents()      — translate SDL events → WM_* messages
      │       │
      │       ├─ SDL_EVENT_MOUSE_MOTION    → WM_MOUSEMOVE  lParam=(y<<16|x)
      │       ├─ SDL_EVENT_MOUSE_BUTTON_*  → WM_LBUTTONDOWN/UP etc.
      │       ├─ SDL_EVENT_KEY_DOWN/UP     → WM_KEYDOWN / WM_KEYUP
      │       ├─ SDL_EVENT_TEXT_INPUT      → WM_CHAR
      │       └─ SDL_EVENT_WINDOW_*       → WM_ACTIVATEAPP etc.
      │
      └─ dequeue next WM_* message → returned to caller
            │
            ▼
      DispatchMessageA()  →  WNDPROC(hWnd, uMsg, wParam, lParam)
      

Key design decisions

BehaviourReason
PeekMessageA pumps SDL on every callPrevents input starvation when other messages are ahead in the queue. Real Windows does the same.
WM_ACTIVATEAPP(0) is suppressedSDL environments often deliver spurious FOCUS_LOST at startup. Sending deactivation would freeze games that guard rendering behind g_bActive.
Mouse/keyboard routed by SDL windowIDEvents go to the HWND matching the SDL windowID; if no match, fall back to active/first window so startup events are not lost.
F10 uses WM_SYSKEYDOWN/UPMatches Windows behaviour where F10 activates the menu bar (many games test for this explicitly).

Testing & debugging

Run the built-in tests

bash
cmake -B build -DFREE_API_BUILD_TESTS=ON
cmake --build build
ctest --test-dir build --output-on-failure
      

Included tests:

Test binaryWhat it verifies
basic_testBasic compilation and symbol linkage.
test_input_pipelineEnd-to-end input pipeline: injects SDL events programmatically and verifies WM_MOUSEMOVE, WM_LBUTTONDOWN/UP, WM_RBUTTONDOWN, WM_KEYDOWN/UP flow correctly to a WNDPROC.
test_timebVerifies sys/timeb.h / _ftime compatibility (non-Windows only).

Enable debug logging

bash — input debug
FREE_API_DEBUG_INPUT=1 ./MyGame
bash — MIDI debug
FREE_API_DEBUG_MIDI=1 ./MyGame
bash — specify SoundFont
FREE_API_SOUNDFONT=/usr/share/sounds/sf2/FluidR3_GM.sf2 ./MyGame

Known limitations & unsupported areas

AreaStatusNotes
Real GDI drawing (lines, text, brushes, pens)UNSUPPORTEDOnly bitmap blitting is implemented.
Win32 resources (LoadResource, FindResource)UNSUPPORTEDStubs return NULL. Use file-based asset loading instead.
Common dialogs (COMMDLG)UNSUPPORTEDcommdlg.h header exists but functions are stubs.
CD audio (MCI cdaudio)UNSUPPORTEDGracefully declined. Use MIDI or WAV instead.
MIDI loopingTODOPlayback stops at end-of-song; loop flag in MCI_PLAY is not yet implemented.
Concurrent MIDI tracksTODOOnly one track at a time is supported.
Child window semanticsUNSUPPORTEDWS_CHILD is parsed but parent/child coordinate transforms are not implemented.
Unicode APIs (*W variants)UNSUPPORTEDOnly ANSI (*A) variants are implemented.
JoystickSTUBjoyGetNumDevs returns 0; joyGetPosEx is a no-op.
Palettes / DIB sectionsUNSUPPORTED256-colour palette games may not render correctly.
Security descriptorsIGNOREDSECURITY_ATTRIBUTES parameter is always ignored.
Web audio autoplayPARTIALBrowsers require a user gesture before audio plays. MIDI may be silent until the user clicks.

Minimal working game skeleton

A self-contained example that shows window creation, input handling, bitmap rendering, and MIDI playback.

game.cpp — complete skeleton
#include <windows.h>
#include <mmsystem.h>

static HWND      g_hWnd      = NULL;
static HDC       g_hdcMem   = NULL;
static HBITMAP   g_hBmp     = NULL;
static MCIDEVICEID g_music  = 0;

void Render()
{
    if (!g_hdcMem || !g_hBmp) return;

    RECT rc; GetClientRect(g_hWnd, &rc);
    HDC hdcWin = (HDC)GetWindowDC(g_hWnd); // game-specific DC

    StretchBlt(hdcWin, 0, 0, rc.right, rc.bottom,
               g_hdcMem, 0, 0, 320, 200, SRCCOPY);
}

LRESULT CALLBACK WndProc(HWND hWnd, UINT msg, WPARAM wp, LPARAM lp)
{
    switch (msg) {
    case WM_CREATE:
        g_hBmp  = (HBITMAP)LoadImageA(NULL, "data/title.bmp",
                      IMAGE_BITMAP, 0, 0, LR_LOADFROMFILE);
        g_hdcMem = CreateCompatibleDC(NULL);
        SelectObject(g_hdcMem, g_hBmp);
        {
            MCI_OPEN_PARMS op = {};
            op.lpstrDeviceType  = "sequencer";
            op.lpstrElementName = "data/music/theme.mid";
            if (!mciSendCommand(0, MCI_OPEN,
                    MCI_OPEN_TYPE | MCI_OPEN_ELEMENT, (DWORD_PTR)&op)) {
                g_music = op.wDeviceID;
                MCI_PLAY_PARMS pp = { (DWORD_PTR)hWnd };
                mciSendCommand(g_music, MCI_PLAY, MCI_NOTIFY, (DWORD_PTR)&pp);
            }
        }
        return 0;

    case WM_DESTROY:
        if (g_music) mciSendCommand(g_music, MCI_CLOSE, 0, 0);
        if (g_hdcMem) DeleteDC(g_hdcMem);
        if (g_hBmp)   DeleteObject(g_hBmp);
        PostQuitMessage(0);
        return 0;

    case WM_KEYDOWN:
        if (wp == VK_ESCAPE) DestroyWindow(hWnd);
        return 0;

    case MM_MCINOTIFY:
        if (wp == MCI_NOTIFY_SUCCESSFUL && g_music) {
            MCI_PLAY_PARMS pp = { (DWORD_PTR)hWnd };
            mciSendCommand(g_music, MCI_PLAY, MCI_NOTIFY, (DWORD_PTR)&pp);
        }
        return 0;
    }
    return DefWindowProcA(hWnd, msg, wp, lp);
}

FREE_API_IMPLEMENT_WINMAIN()
{
    WNDCLASSA wc = {};
    wc.lpfnWndProc   = WndProc;
    wc.hInstance     = hInstance;
    wc.lpszClassName = "GameWindow";
    RegisterClass(&wc);

    g_hWnd = CreateWindowA(
        "GameWindow", "My Game",
        WS_OVERLAPPEDWINDOW, 100, 100, 800, 600,
        NULL, NULL, hInstance, NULL);
    ShowWindow(g_hWnd, SW_SHOW);

    MSG msg;
    while (PeekMessage(&msg, NULL, 0, 0, PM_REMOVE)) {
        if (msg.message == WM_QUIT) break;
        TranslateMessage(&msg);
        DispatchMessage(&msg);
        Render();
    }
    return (int)msg.wParam;
}