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
- ▸ CMake 3.23+ — the build system used by Free API.
- ▸ C++20 compiler — GCC 12+, Clang 14+, or MSVC 2022+.
- ▸ SDL3, SDL3_image, SDL3_mixer — either vendored (recommended) or installed system-wide.
- ▸ SoundFont .sf2 file — required for MIDI music playback (see MIDI section below).
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
# 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
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. )
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.
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):
#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:
#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.
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.
// 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);
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
// 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):
- Set the environment variable:
FREE_API_SOUNDFONT=/path/to/file.sf2 - Place at
assets/soundfont/default.sf2relative to the working directory - Place at
soundfont/default.sf2relative to the working directory
FluidR3_GM — available as the
fluid-soundfont-gm Debian/Ubuntu package.
Playing MIDI music — MCI API
#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
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
// 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.
#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; } }
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:
// 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
// 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)
#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
| Behaviour | Reason |
|---|---|
PeekMessageA pumps SDL on every call | Prevents input starvation when other messages are ahead in the queue. Real Windows does the same. |
WM_ACTIVATEAPP(0) is suppressed | SDL environments often deliver spurious FOCUS_LOST at startup. Sending deactivation would freeze games that guard rendering behind g_bActive. |
| Mouse/keyboard routed by SDL windowID | Events 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/UP | Matches Windows behaviour where F10 activates the menu bar (many games test for this explicitly). |
Testing & debugging
Run the built-in tests
cmake -B build -DFREE_API_BUILD_TESTS=ON
cmake --build build
ctest --test-dir build --output-on-failure
Included tests:
| Test binary | What it verifies |
|---|---|
basic_test | Basic compilation and symbol linkage. |
test_input_pipeline | End-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_timeb | Verifies sys/timeb.h / _ftime compatibility (non-Windows only). |
Enable debug logging
FREE_API_DEBUG_INPUT=1 ./MyGame
FREE_API_DEBUG_MIDI=1 ./MyGame
FREE_API_SOUNDFONT=/usr/share/sounds/sf2/FluidR3_GM.sf2 ./MyGame
Known limitations & unsupported areas
| Area | Status | Notes |
|---|---|---|
| Real GDI drawing (lines, text, brushes, pens) | UNSUPPORTED | Only bitmap blitting is implemented. |
| Win32 resources (LoadResource, FindResource) | UNSUPPORTED | Stubs return NULL. Use file-based asset loading instead. |
| Common dialogs (COMMDLG) | UNSUPPORTED | commdlg.h header exists but functions are stubs. |
| CD audio (MCI cdaudio) | UNSUPPORTED | Gracefully declined. Use MIDI or WAV instead. |
| MIDI looping | TODO | Playback stops at end-of-song; loop flag in MCI_PLAY is not yet implemented. |
| Concurrent MIDI tracks | TODO | Only one track at a time is supported. |
| Child window semantics | UNSUPPORTED | WS_CHILD is parsed but parent/child coordinate transforms are not implemented. |
| Unicode APIs (*W variants) | UNSUPPORTED | Only ANSI (*A) variants are implemented. |
| Joystick | STUB | joyGetNumDevs returns 0; joyGetPosEx is a no-op. |
| Palettes / DIB sections | UNSUPPORTED | 256-colour palette games may not render correctly. |
| Security descriptors | IGNORED | SECURITY_ATTRIBUTES parameter is always ignored. |
| Web audio autoplay | PARTIAL | Browsers 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.
#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; }