Unreasonable Effectiveness

How I Ported an Abandoned Android Game to the Web in a Few Hours with AI

Back in 2019 and 2020, my friends and I used to play this simple but addictive game called Mayan Jump 2. You control a little character jumping endlessly down a rotating cylinder, dodging walls and trap blocks while building up combos. It was one of those games β€” easy to pick up, hard to put down, perfect for killing a few minutes while waiting for coffee.

Sometime around 2021, I went looking for it again. Gone. Unlisted from the Play Store. The developers, BadDog Game, had seemingly moved on. I forgot about it.

Fast forward to June 2026. Something reminded me of the game, and I went searching one more time. I found the APK on APKPure, last updated November 21, 2018 β€” almost eight years ago. I installed it on my Android test device, and to my surprise, it worked flawlessly. No compatibility issues, no crashes. The game was alive and well, just... trapped in an APK.

I shared this with a friend who also wanted to play. The problem: they're on iOS, and so am I. We don't have spare Android devices lying around. So I decided to take a crack at reverse-engineering the APK and porting the game. My initial plan was to extract what I could and rebuild it in Godot for iOS. I gave myself a couple of weeks.

It ended up taking a few hours. Here's how.

What Was Inside the APK

I started by unpacking the APK (it's just a ZIP file). The usual Android boilerplate was there β€” classes.dex, resources.arsc, Firebase and Play Services properties. But the real treasure was in the assets/ folder.

Inside, I found configuration files that immediately caught my attention:

// assets/app.json
{
    "name": "layaApp",
    "version": "1.0.1",
    "mainjs": "scripts/index.js"
}

// assets/GameConfig.json
{
    "gameId": "33011",
    "channelId": "chuanyin.mycs",
    "version": "1020013"
}

The word "laya" stood out. I had never heard of it despite working in tech for nearly six years, four of them professionally. A quick search revealed LayaAir β€” a Chinese game engine that compiles TypeScript to JavaScript and renders with WebGL. It was designed to build games for the web first, with native wrappers (called LayaNative or "Conch") for Android and iOS.

This changed everything. If the game was built for the web, I didn't need to rewrite it in Godot at all. I just needed to unwrap it from its Android shell.

The LayaNative Runtime: A Web App in Disguise

The assets/scripts/ directory contained the LayaNative runtime β€” the layer that bootstraps the web app inside the Android APK. Files like index.js, config.js, and async.js handled font initialization, splash screens, and downloading assets through native Android APIs.

But these weren't the game. They were the wrapper. The actual game was in a cache directory: assets/cache/stand.alone.version/. Inside were 81 hex-named files, a binary filetable.bin, and a text index called allfiles.txt.

The allfiles.txt listed the real filenames:

/code.js
/game/BG.png
/home/homebg.png
/index.html
/jump3d.max.js
/laya.js
/LayaScene_JumpDown/JumpDown.ls
/LayaScene_Role/Role.lh
/sound/jumpA.wav
...

Eighty-one files β€” the entire game, from the engine to every texture, 3D model, material, animation, sound effect, and sprite atlas.

Decoding the Cache

The hex-named files in the cache were mapped to their real names through filetable1.txt, which paired each hash with a content identifier. I wrote a Python script to cross-reference the filetable with the allfiles list and extract everything into a proper directory structure:

with open('filetable1.txt') as f:
    ft1 = [l.strip().split() for l in f if l.strip()]

with open('allfiles.txt') as f:
    af = [l.strip() for l in f if l.strip()]

# Map hash β†’ filename and copy to output
for ft_entry, filename in zip(ft1, af):
    hash_name = ft_entry[0]
    src = os.path.join(cache_dir, hash_name)
    dst = os.path.join(out_dir, filename.lstrip('/'))
    shutil.copy2(src, dst)

extract-cache.py β€” the extraction script

All 81 files extracted cleanly. The game's complete source was laid bare:

Layer File Size
Engine laya.js 2.6 MB
Game code jump3d.max.js 52 KB
Utilities utils.min.js 6 KB
Entry index.html 981 bytes

The game code in jump3d.max.js was fully readable β€” unminified, with comments, class names, and author annotations. A developer named Jayden at BadDog Game had built this. The 52 KB source revealed the complete game architecture:

// The game's boot sequence β€” astonishingly simple
function LayaAir3D(){
    Laya3D.init(750, 1334, true);        // Initialize WebGL at portrait resolution
    Laya.stage.bgColor = "#484B58";
    Laya.stage.scaleMode = "fixedwidth"; // Scale to fit screen width
    Laya.stage.addChild(LayerManager.instance);
    // ... load scenes, atlases, textures ...
}

// Auto-start at the bottom of the file
new LayaAir3D();

The game was 750Γ—1334 portrait, used a 3D rotating cylinder with 24 segments for the platform, had combo mechanics, fire mode, trap blocks, and a complete UI with home screen, revive dialog, and settlement screen. All of it was standard JavaScript calling the LayaAir WebGL API.

The Web Port

The architecture was now crystal clear:

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ assets/scripts/index.js  ← native boot  β”‚  LayaNative runtime
β”‚ assets/scripts/config.js ← splash screenβ”‚  (what we REMOVE)
β”‚ assets/scripts/async.js  ← downloader   β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
           β”‚ calls loadUrl("index.html")
           β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ index.html                              β”‚  The actual web app
β”‚   <script src="laya.js">                β”‚  (what we KEEP)
β”‚   <script src="utils.min.js">           β”‚
β”‚   <script src="jump3d.max.js">          β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
           β”‚ new LayaAir3D() auto-starts
           β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ LayaAir WebGL Engine + Game Logic       β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

For the web port, I could skip the entire LayaNative runtime. The browser would load index.html directly, which loads the engine and game scripts in order, and the game auto-starts. But the engine was built expecting the Conch runtime to provide certain APIs. Accessing conchConfig, conch, PlatformClass, and filesystem functions like fs_exists would crash on web where these don't exist.

Step 1: The Conch Shim

I wrote conch-shim.js β€” a compatibility layer loaded before the engine that stubs out every Conch API:

// Critical: we deliberately leave window.conch UNDEFINED.
// This causes every `if (conch)` guard in the engine to fail,
// making it use standard WebGL instead of the Conch render backend.

window.conchConfig = {
    getOS: function() { return 'Conch-web'; },
    getRuntimeVersion: function() { return '2.1.3.1-web'; },
    localizable: true,
    // ...
};

// The game's Android SDK calls are guarded by Browser.onAndroid,
// which is false on web β€” they're skipped automatically.
// But we provide stubs just in case:
window.PlatformClass = {
    createClass: function() {
        return { call: function() {} };
    }
};

This was the key design decision: don't fake the Conch environment β€” let the engine's own guard clauses fall through to the WebGL path. Only stub what might be accessed without a guard.

Step 2: The Aspect Ratio Bug

I fired up a local HTTP server (python3 -m http.server 8080), opened the browser, and hit our first real problem. The console showed:

[web] Canvas ready β€” internal=750x445 viewport=1384x822

The WebGL framebuffer was 750Γ—445 instead of 750Γ—1334. The game was rendering at a squished landscape aspect ratio, clipping 67% of the vertical content. The play button was nowhere to be seen.

I traced the issue through the 2.6 MB engine source. LayaAir's Browser class was using window.innerWidth and window.innerHeight to determine the canvas size:

// laya.js, line ~25941 β€” the culprit
__getset(1, Browser, 'clientWidth', function(){
    return Browser.window.innerWidth || Browser.document.body.clientWidth;
});

__getset(1, Browser, 'clientHeight', function(){
    return Browser.window.innerHeight || Browser.document.body.clientHeight;
});

On my desktop (1384Γ—822 viewport), the engine created a canvas matching the landscape viewport ratio, completely ignoring the game's 750Γ—1334 portrait design. The fix, in fix-aspect-ratio.js, overrides these getters to return portrait-proportioned values:

Object.defineProperty(Browser, 'clientWidth', {
    get: function() {
        var vw = window.innerWidth;
        var vh = window.innerHeight;
        // Fit portrait game into the viewport
        return Math.min(vw, vh * 750 / 1334);
    },
    configurable: true
});

Object.defineProperty(Browser, 'clientHeight', {
    get: function() {
        var vw = window.innerWidth;
        var vh = window.innerHeight;
        return Math.min(vh, vw * 1334 / 750);
    },
    configurable: true
});

Refresh. internal=750x1334. The full game appeared. But there was a new problem: clicks weren't landing where I clicked.

Step 3: The Mouse Coordinate Bug

With the game centered on screen (via CSS margin: auto), the canvas sits at an offset from the viewport origin. On my display, the canvas was at left: 120px. LayaAir's mouse event handler converts viewport coordinates to design coordinates through a transform matrix:

// laya.js, line 21739-21742 β€” mouse event handling
this._point.setTo(e.pageX || e.clientX, e.pageY || e.clientY);
this._stage._canvasTransform.invertTransformPoint(this._point);
_this.mouseX = this._point.x;
_this.mouseY = this._point.y;

The _canvasTransform handled scaling (viewport pixels β†’ design pixels) but had no translation component for the canvas position. A click at viewport x=352 (center of the game) mapped to design x=571 instead of the correct x=375, shifting all hit-testing to the right. I had to click the leftmost edge of buttons to register a hit.

The fix patches the specific transform instance to subtract the canvas's viewport offset:

function applyPatch(ct) {
    var origInvert = ct.invertTransformPoint;
    ct.invertTransformPoint = function(point) {
        var canvas = document.querySelector('canvas');
        if (canvas) {
            var rect = canvas.getBoundingClientRect();
            point.x -= rect.left;
            point.y -= rect.top;
        }
        return origInvert.call(this, point);
    };
}

This patches only the _canvasTransform instance (not the Matrix prototype, which would break rendering transforms throughout the engine). The LayaAir engine reuses the same Matrix object across resizes, so the patch survives window resizing.

Step 4: Audio

Chrome's autoplay policy blocks AudioContext creation before user interaction. The engine tries to initialize Web Audio immediately and gets rejected. But LayaAir has a built-in recovery mechanism β€” SoundManager._stageOnFocus() is triggered on the first mousedown event on the stage, which resumes the audio context. A gentle click anywhere on the game and all the sound effects come alive.

Background music, however, never played. Digging through the assets, I found only three WAV files β€” jumpA.wav, BreakStone.wav, and RoleHit.wav β€” all short sound effects. No music file existed in the APK. The background music on Android was likely streamed through the native layer or an ad network. A small loss, but the sound effects capture the game's feel perfectly.

Step 5: The Mobile Browser Gauntlet

With the game running on desktop, I deployed it to Vercel and opened it on my iPhone 16. The results were... mixed.

Safari, Opera, and Arc Search rendered it perfectly β€” the game filled the viewport edge to edge, centered, beautiful. But Chrome iOS and Firefox iOS had issues. The game was visibly shifted left and up, as if the centering CSS wasn't taking effect. Firefox had black margins on the sides. Same game, same code, different browsers, different results.

This turned into a trial-and-error session across three approaches to centering:

Attempt 1: position: fixed + margin: auto. Worked on desktop, broke on Chrome iOS. Chrome's Blink engine on iOS handles margin: auto inside position: fixed differently than WebKit.

Attempt 2: Flexbox on <html>. Safari and Opera loved it. Chrome and Firefox ignored it entirely β€” the game sat at the top-left corner. Turns out display: flex on the root <html> element isn't reliably supported across mobile browsers.

Attempt 3: position: absolute + transform: translate(-50%, -50%). The oldest centering trick in CSS. Works on every browser since IE9. And because our fix-aspect-ratio.js patch uses getBoundingClientRect() (which accounts for CSS transforms), mouse coordinates remained correct.

This got the game centered on all browsers. Chrome and Firefox still don't fill the viewport width perfectly β€” there are thin black bars on the sides β€” while Safari, Opera, and Arc achieve edge-to-edge fit. The difference comes down to how each browser reports window.innerWidth versus the actual visual viewport. I noted this as a known issue and moved on. The game is playable and centered everywhere.

This was a humbling reminder that mobile browsers are not a monolith β€” especially on iOS, where Apple mandates that all browsers use WebKit under the hood, yet Chrome and Firefox still manage to behave differently in edge cases like viewport unit calculation and positioning.

The Result

The complete port is 78 files, 15 MB, and runs in any browser with WebGL support. Here's what we ended up with:

mayan-jump-web/
β”œβ”€β”€ index.html              ← Entry point (our code)
β”œβ”€β”€ conch-shim.js           ← Conch API stubs (our code)
β”œβ”€β”€ fix-aspect-ratio.js     ← Engine patches (our code)
β”œβ”€β”€ laya.js                 ← LayaAir engine (unmodified)
β”œβ”€β”€ utils.min.js            ← Utilities (unmodified)
β”œβ”€β”€ jump3d.max.js           ← Game logic (unmodified)
└── ... 72 asset files      ← Textures, models, sounds (unmodified)

Three files of original code. Everything else is the game exactly as BadDog Game shipped it in 2018. The entire source code for the port is available at github.com/ladhahq/mayan-web.

To run it yourself:

git clone https://github.com/ladhahq/mayan-web.git
cd mayan-web
python3 -m http.server 8080
# Open http://localhost:8080

Reflections

I told my friend this port would take a couple of weeks. It took a few hours.

This isn't because I'm particularly skilled at reverse-engineering β€” I had never touched a LayaAir project before. It's because the ideas were there, and AI served as an accelerator at every step. When I understood that the APK was just a web app in a shell, the path was clear: unwrap it, stub the native APIs, fix the engine bugs, serve it. The AI (DeepSeek v4 Pro, running through the Claude Code harness) did the heavy lifting β€” scanning 2.6 MB of engine source for specific patterns, tracing the exact lines where clientWidth was defined, finding the mouse coordinate transform buried in a 131-line function, writing the Python extraction script, authoring the compatibility shims.

I still needed to understand the architecture. The AI couldn't tell me "this is a LayaAir project wrapped in LayaNative" β€” that came from recognizing the file structure, researching what LayaBox was, and connecting the dots. But once I had that understanding, the AI collapsed what would have been days of tedious code archaeology into minutes of targeted analysis.

To the developers at BadDog Game β€” Jayden, Youqi, and the team β€” thank you for building something so elegantly simple. Mayan Jump 2 is 52 KB of game logic, a handful of 3D models, and a few sprite sheets. It has no server dependency, no always-online requirement, no microtransactions (in the APK, at least). Just a tight game loop and a satisfying mechanic. Games like this deserve to be preserved.

And to my friend on iOS: you can play now. Just open mayan-web.vercel.app.


The complete source code and port are available at github.com/ladhahq/mayan-web.

#ai #tech