Running a C++ Game in the Browser
This article assumes you have read the Emscripten toolchain guide and have the compiler installed. Where that article focuses on the toolchain mechanics, this one follows a real game through the port and dwells on the practical differences between a native program and a wasm one, the places where a working native game does something the browser will not allow.
Step 1: Start From an SDL-Based Game
The smoothest C++ ports start from a game built on SDL. SDL gives you a window, a renderer, input events, and audio through a single cross-platform API, and Emscripten ships a port of SDL that maps all of that onto browser equivalents: the window becomes a canvas, the renderer draws through WebGL, input events come from the browser, and audio plays through the Web Audio system. Because your game already talks to SDL rather than directly to the operating system, the platform-specific layer is the part Emscripten replaces, and your game logic on top of it does not care that it moved.
If your game uses raw OpenGL ES instead of SDL's renderer, that also maps onto WebGL, so the same logic applies. The harder cases are games that call operating-system functions directly or use desktop-only OpenGL features, since those have no browser equivalent and need adapting. For a game built cleanly on SDL or OpenGL ES, though, the foundation is already portable, which is exactly why so many C++ ports begin there.
Step 2: Refactor the Loop Into a Frame Function
This is the change that matters most, and it is the same one every wasm port faces. A native C++ game runs a loop that polls events, updates, renders, and repeats until quit, blocking the whole time. The browser cannot tolerate a function that never returns, because it needs to regain control to paint and handle events. So you take the body of that loop, one frame's worth of work, and turn it into a function. Then you hand that function to emscripten_set_main_loop and let the browser call it once per frame.
The subtlety is state. A native loop keeps its variables alive naturally because it never leaves the function. A per-frame function returns each time, so anything that must persist between frames, the game world, timers, the renderer handle, has to live outside the function, either as part of a game-state structure you pass in or as longer-lived state. Restructuring around a frame function that operates on persistent state, rather than local variables in an endless loop, is the core of the port and usually the part that takes the most thought.
The native infinite loop becomes a frame function the browser calls, and any state that must survive between frames has to move out of the loop's local scope. Everything else in the port is comparatively minor.
Step 3: Adapt Input Handling
Input is one of the easier parts, because SDL's event model carries over. You keep polling SDL events the same way you did natively, and Emscripten feeds browser keyboard, mouse, and touch events into that queue. Your existing handling of key presses and mouse clicks works largely unchanged. The thing to add is touch, since a web game will be played on phones, and SDL surfaces touch events that your native build may never have handled. Adding touch support is less a porting chore than a feature you want anyway, and the broader game controls material covers how to handle keyboard, mouse, and touch together cleanly.
One browser-specific wrinkle is that some actions, such as locking the pointer for a first-person camera or starting audio, can only begin in response to a user gesture like a click. The browser blocks them otherwise, as a privacy and annoyance safeguard. So a native game that grabs the mouse or starts sound at startup needs a small change to do those things after the first click instead. This is not an SDL issue, it is a browser policy, and every web game lives with it.
Step 4: Preload Assets
Your game loads textures, sounds, and data files from disk, but the browser has no disk for it to read. Emscripten gives you a virtual filesystem and lets you preload your asset folder into it at build time, so your existing file-opening code finds everything at the same paths it expected. This is the least invasive way to handle assets for a port, because you change nothing in the game's loading code, you only tell the compiler which files to package.
For a large game, preloading everything means a large download before the game starts, so you may later switch heavy assets to load asynchronously over the network as they are needed. But for getting a game running and verifying the port, preloading is the right first move, since it removes asset loading as a variable. Get the game playable with preloaded assets, then optimize loading afterward if size demands it.
Step 5: Handle the Differences
A handful of native habits behave differently in the browser, and these are the gotchas that surprise people. Blocking calls of any kind are forbidden, so a native function that sleeps, waits, or spins until something happens will hang the tab and must be restructured to yield between frames. Threading exists through Web Workers and shared memory but requires specific server headers and is not the drop-in pthreads experience native code assumes, so a heavily threaded game needs care or a single-threaded fallback. And the concept of exiting the program is different, since the browser tab simply persists, so cleanup-on-exit logic may never run the way it does natively.
None of these are show-stoppers, but each one can turn a port that compiled successfully into one that freezes or misbehaves at runtime, which is the frustrating kind of bug because the build looked fine. Knowing the list in advance, no blocking, careful threading, no real exit, lets you scan your code for these patterns deliberately rather than discovering them one mysterious hang at a time.
Step 6: Build and Verify
With the loop refactored, input adapted, assets preloaded, and the differences handled, you build with the SDL2 port flag and an optimization flag for a release build, then serve the output over HTTP and play it. Test in Chrome, Firefox, and Safari, and on a real phone, because WebGL behavior and performance vary across them. Watch the load time, since the wasm binary plus preloaded assets sets how long a first-time player waits, and trim or stream assets if it is too long.
When it plays correctly across browsers, the port is done, and you have a C++ game running on the open web with no install, reachable by a link. That is the payoff that makes the effort worthwhile, and it is why WebAssembly turned the browser into a viable target for serious native game code. From here, the performance article covers squeezing the build smaller and faster, and the broader picture of how engines automate this whole process is in the engines overview.