Porting 122K Lines of Haxe to HTML5: When Dead Code Elimination Kills Your App
All Is Vanity is a game I’ve been building on and off for years. It’s an isometric action RPG with a custom engine, physics, particle effects, a full UI system, save/load, and around 122,000 lines of Haxe across 692 source files. It targets desktop via C++ through OpenFL and Lime.
This weekend I decided to make it run in a browser.
The small game ports I’d been doing — Snake, TappyPlane, QuickSki — were one-session affairs. Point Claude at a few hundred lines of Haxe, get back a clean Godot conversion, ship it. All Is Vanity is a different beast entirely. You don’t rewrite 122K lines. You make the existing codebase compile to a new target and then fight every assumption the code ever made about its runtime environment.
Here’s what that looked like.
The Codebase
| Stat | Value |
|---|---|
| Source files | 692 |
| Lines of code | ~122,000 |
| Build system | Haxe 4.3.7 + OpenFL 9.5.0 + Lime 8.3.0 |
| Custom engine | org.ffilmation (isometric renderer) |
| Physics | Nape (128+ references) |
| UI | StableXUI (HUD, alerts, tutorials, menus) |
| Particles | zame-particles |
| Debug console | dconsole (13+ files) |
The architecture is layered: Main.hx creates Game.hx, which initializes assets, game components, and the screen manager. The isometric engine handles tile-based rendering through a custom TileViewport backed by OpenFL’s Tilemap. Scenes are loaded from XML, z-sorted with a custom grid sorter, and rendered with tilesheets.
This isn’t a game jam project. This is a decade of accumulated complexity.
Phase 1: Making It Compile
The first task was getting the HTML5 build to produce output at all. OpenFL supports HTML5 as a target, but “supports” means “the framework handles it” — your application code has to cooperate.
Claude explored the entire codebase and identified every platform-specific code pattern that would break on HTML5. The systematic audit found five categories of problems:
1. Native-Only Libraries
hxcpp-debug-server is a C++ debugging library. It can’t exist in a JavaScript build. Same for dconsole — it has HTML5 compatibility issues. And gl_stats, a native GL performance overlay.
The fix for all three: conditional compilation flags in project.xml.
1
2
3
<haxelib name="hxcpp-debug-server" unless="html5" />
<set name="CONSOLE_DEBUGGER" unless="html5" />
<set name="gl_stats" unless="html5" />
2. cpp.vm.Profiler Calls
Three files called cpp.vm.Profiler.start() and .stop() to profile gameplay. These are C++ VM intrinsics — they don’t exist in JavaScript.
1
2
3
4
5
6
7
// Before: crashes on HTML5
cpp.vm.Profiler.start("profiler_output.log");
// After: gated to cpp target only
#if cpp
cpp.vm.Profiler.start("profiler_output.log");
#end
Found in Game.hx, GameTimer.hx, and LevelManager.hx. The PROFILE_GAME_PLAY define was already used but wasn’t combined with a #if cpp guard.
3. cpp.vm.Gc Calls
Manual garbage collection hints — Gc.compact(), Gc.run() — scattered through asset loading and game initialization. Already properly gated in most places, but needed verification across every callsite.
4. dconsole References Behind Defines
The debug console was used in 13+ files, but most references were already behind #if CONSOLE_DEBUGGER. A few in ParticleEditor.hx weren’t. Those got wrapped.
5. Dead Code Elimination
This is where things got interesting. To reduce the HTML5 bundle size, I enabled aggressive dead code elimination:
1
<haxeflag name="-dce full" if="html5" />
The build succeeded. The output was a 12MB JavaScript file and a 29MB asset pack. Everything looked fine at compile time.
Phase 2: The 99% Problem
The game loaded in the browser. The progress bar filled smoothly. 10%… 50%… 90%… 99%.
Then it stopped.
The browser console told a three-part horror story:
Error 1: The Stack Trace From Nowhere
1
2
FSceneGridSorter.zSortComplete (AllIsVanity.js:190283)
at FSceneGridSorter.zSortCube (AllIsVanity.js:190152)
The isometric scene’s z-sort algorithm was running during initialization. The stack trace pointed deep into the sorting code, but the algorithm itself was fine — Claude’s explore agent verified the code was HTML5-compatible. The Timer-based approach had already been commented out in favor of direct function calls. This was a red herring — the sort was running correctly, but something else was failing around it.
Error 2: “Bind Must Be Called on a Function”
1
ERROR dispatching UNLOAD: TypeError: Bind must be called on a function
This one came from the custom preloader. The loading sequence works like this:
- OpenFL’s
Preloaderwraps theCustomPreloadersprite - It registers
display_onUnloadas a listener forEvent.UNLOAD - When assets finish loading, the preloader dispatches
COMPLETE CustomPreloadercallsevent.preventDefault()(to show a delayed transition)- After 500ms, it manually dispatches
Event.UNLOAD - OpenFL’s
display_onUnloadhandler fires, cleans up, then starts the actual game
Step 6 failed. Inside display_onUnload, the call to removeEventListener(Event.UNLOAD, display_onUnload) triggered Haxe’s JavaScript $bind helper, which tried to call .bind() on… nothing.
The method reference display_onUnload didn’t exist in the compiled JavaScript. DCE had stripped it.
Error 3: “Bitmap is not specified”
1
2
3
Called from ru_stablex_ui_skins_Slice3.draw
Called from ru_stablex_ui_skins_Slice3.apply
Called from ru_stablex_ui_widgets_HBox.applySkin
StableXUI’s Slice3 skin was trying to draw the HUD background bar. The skin’s bitmap source was configured in XML:
1
<bgHudBar:Slice3 src="'hud/hudBgBarTileAlt.png'" />
The _getBmp() method checks two things: is _bitmapData set? Is src set? Both were null. The src property — which should have been set by StableXUI’s macro-generated initialization code — was gone. DCE had stripped the property setter.
The error message was “Bitmap is not specified” (both null) rather than “Bitmap not found: hud/hudBgBarTileAlt.png” (path set but asset missing). That distinction was the clue. The path wasn’t failing to resolve — it was never set in the first place.
The Root Cause
All three errors traced back to one line in project.xml:
1
<haxeflag name="-dce full" if="html5" />
Haxe’s dead code elimination has three levels: no, std, and full. The full setting aggressively removes any code it can’t statically prove is reachable. The problem is that several patterns in this codebase are invisible to static analysis:
- Event listeners passed as method references —
addEventListener(Event.UNLOAD, display_onUnload)passes a function by reference. DCE doesn’t always trace through the event system to see thatdisplay_onUnloadis reachable. - Macro-generated property assignments — StableXUI uses compile-time macros (
UIBuilder.regSkins()) to read XML and generate code that sets skin properties. The generated code is correct, but DCE may not track that the Slice3.srcsetter is reachable through the macro output. - Framework internals — OpenFL’s
Preloaderclass uses@:noCompletion privatemethods. These are prime targets for aggressive DCE because they look unused from the outside.
The Fix
One line:
1
<haxeflag name="-dce std" if="html5" />
Changed full to std. Standard DCE still eliminates unused standard library code but doesn’t aggressively strip application and framework methods. The JavaScript output gets a bit larger, but everything works.
I also cleaned up a dangling event listener in the preloader — the stage resize handler was never removed during the transition:
1
2
// Before dispatching UNLOAD, clean up
Lib.current.stage.removeEventListener(Event.RESIZE, resize);
Rebuilt. Loaded in the browser. The progress bar hit 100%, the preloader faded, and the game started.
What I Learned
DCE Full Is a Scalpel Used as a Machete
For small projects with straightforward call graphs, DCE full is fine. For a 122K-line codebase with macros, event systems, XML-driven UI initialization, and framework internals using reflection patterns — it’s a loaded gun. The compiler can’t trace dynamic dispatch, and it can’t see through macros that generate runtime code.
The insidious part: it fails silently at compile time and spectacularly at runtime. There’s no warning that DCE removed a method you actually need. The build succeeds, the output looks fine, and then .bind() gets called on undefined in the browser.
Use dce std for large projects. If you need the size reduction, add @:keep annotations to the specific methods DCE threatens, rather than trusting full DCE to figure it out.
Error Messages Are Clues, Not Conclusions
“Bitmap is not specified” could have sent me down a rabbit hole investigating asset loading, .pak file format issues, or OpenFL’s HTML5 asset pipeline. The crucial detail was that the error was “not specified” (property never set) rather than “not found” (property set but path invalid). That distinction pointed directly at the initialization code being stripped, not the asset loading failing.
Similarly, “Bind must be called on a function” sounds like a JavaScript interop problem. It’s actually a DCE problem — the function exists in the Haxe source but was removed from the JavaScript output.
Large Projects Need Systematic Audits, Not Shotgun Fixes
With 692 source files, you can’t grep for one problem at a time. Claude’s approach was right: explore the entire codebase first, categorize every platform-specific pattern, then fix them systematically. The explore agent spawned subagents in parallel — one to audit cpp.vm usage, another to trace StableXUI’s asset loading, another to verify the scene initialization code. Five categories of problems identified in one pass, rather than discovering them one crash at a time.
The Preloader Is a Landmine
OpenFL’s custom preloader system is elegant in theory — your Sprite gets wrapped by a Preloader that manages the lifecycle, and you just dispatch UNLOAD when you’re ready to transition. In practice, the lifecycle is a chain of events, timers, and callbacks that all need to survive compilation, and the transition happens inside a dispatchEvent call that’s nested several layers deep in framework code. Any broken link in that chain silently prevents the game from starting.
If you’re porting to HTML5 and your game loads but never starts: check the preloader transition first.
What’s Next
The game loads and starts in the browser. That’s a milestone. But “starts” and “is playable” are very different things for a game this size. The next steps are testing actual gameplay — movement, combat, UI interactions, save/load — and fixing whatever HTML5-specific rendering or input issues emerge.
The 12MB JavaScript file and 29MB asset pack are also targets for optimization once everything is functional. But first: make it work, then make it fast.
122,000 lines of Haxe, running in a browser tab. The hard part wasn’t the code — it was one overzealous compiler flag.
