Hi!
For the past while I’ve been working on something fairly ridiculous: getting Super Mario 64 to actually run inside Roblox! Not a recreation, not a remake with parts and meshes, the actual game code, executing at runtime, rendering into an EditableImage. 20-30 fps with native codegen.
Everything you see in that video is being produced by the original game’s code and drawn pixel by pixel by a rasterizer written in Luau. The entire screen is one ImageLabel.
Wait, what am I actually looking at?
The screen is a single EditableImage. Every frame, Luau computes the colour of every pixel and pushes the whole buffer with WritePixelsBuffer. There is no 3D engine involved anywhere - Roblox’s renderer is only being asked to draw one flat image.
So all the 3D - transforming vertices, clipping triangles, perspective-correct texture mapping, the z-buffer, the N64’s colour combiner - is code split apart from the original codebase into Luau.
How it works
The pipeline looks like this:
Super Mario 64 decomp PC port (C)
│ Port to WebAssembly with software rendering, no WebGL/SDL
V
WebAssembly Build
│ Spider WebAssembly Luau compiler
V
Compiled Luau ModuleScript (~50 MB of generated Luau)
│ Link through the WebAssembly import table
V
Rasterizer + audio mixer
│
V
EditableImage
Step one was getting the game to build as WebAssembly at all. I started from the Mario 64 decomp PC port, and built it with a target that has no OpenGL, no SDL, and no filesystem. Software rendering with as few WebAssembly imports as I could manage is important for performance.
Step two was transpiling that .wasm into Luau. For this, I used GitHub - SovereignSatellite/Spider: WebAssembly to Luau and LuaJIT translation · GitHub and the output is an enormous ModuleScript - about 50 MB.
Step three - Using only the transpiled WebAssembly blob is slow. Like, really really slow. I’m talking half a frame per second. Transpiled WebAssembly is several layers removed from what Luau’s native code generator likes. So I moved the worst offenders off of it.
Moving the software renderer and audio mixer into Luau
I modified the C build so that the performance-critical subsystems, instead of doing the work themselves, call out across the WebAssembly boundary to functions I implement. Those functions are hand-written native Luau modules.
Game logic, physics, the camera and the display-list interpreter all stay inside the wasm as transpiled C. What moved out into hand-written Luau:
- The triangle rasterizer - around 850 lines covering clipping, the z-buffer, perspective-correct texture mapping and the N64’s colour combiner. This is what turned the framerate from an unplayable slideshow to a playable experience.
- Vertex transform and lighting - matrix transforms, normals, per-vertex lighting, fog, and clip flags. Moving this boosted performance by a little bit.
- The audio mixer - a port of the N64’s audio DSP ops: ADPCM decode, resampling, the envelope mixer, and so on.
All three use native codegen, and they’re written specifically for it - which has real consequences for how you write the code (more on that below).
The game still thinks it’s rendering normally. It builds its display lists, sets up its matrices, and calls what it believes is a graphics driver. That “driver” is Luau.
Things that were genuinely painful
Fog parameters
This one cost me an embarrassing amount of time.
The C code passes fog parameters across the boundary as int16_t, and they are routinely negative. When you call an imported function from WebAssembly with an i32 argument, JavaScript hands it to you signed - so testing in a browser, everything looked perfect.
Luau’s transpiled bridge hands it to you unsigned.
So a fog offset of -1000 arrived as 4294966296. Fog instantly saturated, and everything past a certain distance turned into flat grey soup. The fix is trivial once you know:
if v >= 0x80000000 then v -= 0x100000000 end
…but the reason it was hard to find is that my browser test harness could never reproduce it. The bug only existed on the Luau side of the bridge.
Exploded geometry
The ucode this build uses stores vertex positions as floats, not the fixed-point 16-bit values the classic N64 GBI documentation describes, and the vertex struct is 24 bytes rather than 16. Read it the documented way and you get a beautiful mess of triangles fired in every direction. I now verify every struct layout by compiling offsetof probes rather than trusting docs.
Writing Luau that the native compiler actually likes
A few things that measurably mattered:
- Calling built-in functions -
buffer.readu8(...),math.floor(...),bit32.band(...) - Using the
//floor-division operator instead of Luau’smath.floorwherever I could. - Everything lives in
buffers, not tables. The framebuffer, the depth buffer, textures, the command stream.
Verifying it’s actually correct
I can’t run Luau outside Studio, so “does it look right?” is a terrible test. Instead I built a harness that runs the WebAssembly build in Node with a JavaScript reimplementation of each Luau module, mirroring the C maths operation-for-operation. That gives a reference I can compare to the C renderer.
Current state / limits
- Renders at 320×240 internally - same resolution that the N64 renders the game at.
- I get around 20-30 fps on my CPU (Intel i9 13900-KS).
- Audio is fully synthesized in Luau. The mixer is a faithful port of the N64 DSP ops, producing 32 kHz PCM.
Getting raw PCM out to actual playback is honestly the jankiest part of the whole project, since Roblox has no “here is a buffer of samples, play it” API.
I use a custom-built system I made for a few other projects already that streams base64 encoded PCM audio through Roblox log files.
A python script then listens to the log files and outputs it to the OS.
Unfortunately, getting audio to work is practically impossible without this external playback method. - This is private and closed-source, which will probably never change.
Why?
For fun! I like doing these types of projects where I try to push the limits of the engine.
I’ve had this idea ever since I found out about the precursor to Spider, Wasynth. I tried to make it before but to no avail at all. Ever since that day I’ve made several ports of other projects using it, and only this time I managed to truly port Mario 64 at a playable framerate.
If you have any questions, feel free to ask in the comments! I’ll try my best to answer!
This is a technical showcase built on the open-source SM64 decompilation project. The build process requires you to supply your own legally-obtained ROM to extract assets from, and none of that data is distributed here.
