wasmley
v3.2.0
Published
Lua 5.4 runtime compiled to WebAssembly - run Lua in the browser with OpenAL audio, WebGL graphics, HTTP fetch, and 15+ embedded libraries and JIT
Maintainers
Readme
🌙 Wasmley
The official Lua runtime compiled to WebAssembly — run Lua in the browser at near-native speed with OpenAL audio, WebGL graphics, and HTTP fetch.
Named after Roberto Ierusalimschy, the creator of Lua (with a WASM twist).
What's New in 3.0
- 🔊 OpenAL Audio — Play sounds, music, and generate tones
- 🎮 WebGL 2.0 — Full GPU-accelerated graphics with shaders
- 🖼️ Canvas Graphics — Simple 2D drawing API
- 🌐 Enhanced HTTP — Response headers, timeouts, request cancellation
- 📦 15+ Embedded Libraries — JSON, functional programming, OOP, and more
Features
| Feature | Description | |---------|-------------| | ✅ Full Lua 5.4 | The real PUC-Rio implementation | | 🔊 OpenAL Audio | Play PCM audio, control volume/pitch | | 🎮 WebGL 2.0 | Shaders, buffers, textures | | 🖼️ Canvas 2D | Rectangles, circles, lines, text | | 🌐 HTTP Fetch | GET/POST with headers, timeout, cancel | | 📚 15+ Libraries | json, lume, moses, inspect, etc. | | ⚡ Near-native | WASM performance |
Installation
npm install wasmleyOr via CDN:
<script src="https://unpkg.com/wasmley/lua.js"></script>Quick Start
High-Level API (Recommended)
const { create } = require('wasmley');
const lua = await create({
print: console.log,
printErr: console.error
});
lua.run(`
print("Hello from Lua!")
local json = require("json")
print(json.encode({hello = "world"}))
`);Browser with Audio
<script src="lua.js"></script>
<script>
async function main() {
const lua = await LuaModule({ print: console.log });
const L = lua._luaL_newstate();
lua._luaL_openselectedlibs(L, 0xFFFFFFFF, 0);
lua._luaL_openpurelibs(L);
lua._luaL_openhttp(L);
lua._luaL_openaudio(L);
// Run Lua code that plays audio
}
main();
</script>Audio API
-- Initialize OpenAL
audio.init()
-- Generate a 440Hz sine wave tone
local sampleRate = 44100
local duration = 0.5
local freq = 440
local samples = {}
for i = 0, sampleRate * duration - 1 do
local t = i / sampleRate
local value = math.floor(math.sin(2 * math.pi * freq * t) * 32767)
local lo = value % 256
local hi = math.floor(value / 256) % 256
if hi < 0 then hi = hi + 256 end
if lo < 0 then lo = lo + 256 end
table.insert(samples, string.char(lo, hi))
end
local pcmData = table.concat(samples)
local buffer = audio.newBuffer(pcmData, 1, 44100, 16)
local source = audio.play(buffer, false, 1.0, 1.0)
-- Control playback
audio.setVolume(source, 0.5)
audio.setPitch(source, 1.2)
audio.pause(source)
audio.resume(source)
audio.stop(source)
-- Cleanup
audio.quit()HTTP API
Simple GET
http.get("https://api.example.com/data", function(res)
print("Status: " .. res.status)
print("Data: " .. res.data)
print("Content-Type: " .. (res.headers["content-type"] or "unknown"))
end)POST JSON
http.postJson("https://api.example.com/users",
{ name = "Alice", email = "[email protected]" },
function(res)
print("Created: " .. res.data)
end
)Full Options
local reqId = http.fetch("https://api.example.com/data", {
method = "POST",
body = '{"key": "value"}',
headers = {
["Content-Type"] = "application/json",
["Authorization"] = "Bearer token123"
},
timeout = 5000, -- 5 seconds
onsuccess = function(res)
print("Status: " .. res.status)
print("OK: " .. tostring(res.ok))
-- res.headers is a table with lowercase keys
end,
onerror = function(err)
print("Error: " .. err.statusText)
end
})
-- Cancel request
http.cancel(reqId)WebGL API
gl.init("#canvas")
gl.viewport(0, 0, 800, 600)
gl.clearColor(0.1, 0.1, 0.2, 1.0)
-- Create shader program
local vertexShader = [[
attribute vec2 a_position;
void main() {
gl_Position = vec4(a_position, 0.0, 1.0);
}
]]
local fragmentShader = [[
precision mediump float;
uniform vec4 u_color;
void main() {
gl_FragColor = u_color;
}
]]
local program = gl.createProgramFromSource(vertexShader, fragmentShader)
gl.useProgram(program)
-- Create vertex buffer
local vbo = gl.createBuffer()
gl.bindBuffer(gl.ARRAY_BUFFER, vbo)
gl.bufferData(gl.ARRAY_BUFFER, {0, 0.5, -0.5, -0.5, 0.5, -0.5}, gl.STATIC_DRAW)
-- Draw
gl.clear(gl.COLOR_BUFFER_BIT)
gl.drawArrays(gl.TRIANGLES, 0, 3)Graphics API (2D Canvas)
gfx.init("#canvas")
gfx.clear(0.1, 0.1, 0.2, 1.0)
gfx.setColor(1, 0, 0, 1) -- Red
gfx.rect(100, 100, 200, 150, true) -- Filled rectangle
gfx.setColor(0, 1, 0, 1) -- Green
gfx.circle(400, 300, 50, true) -- Filled circle
gfx.setColor(1, 1, 1, 1) -- White
gfx.text("Hello Wasmley!", 100, 50, "24px Arial")Embedded Libraries
| Library | Description |
|---------|-------------|
| json | JSON encoder/decoder |
| dkjson | Robust JSON with error handling |
| inspect | Human-readable table dumps |
| serpent | Serializer/pretty-printer |
| lume | Game dev utilities |
| classic | Tiny OOP library |
| middleclass | Full-featured OOP |
| moses | Functional programming |
| fun | High-performance iterators |
| flux | Tweening/animation |
| schema | Data validation |
| pl.* | Penlight utilities |
Building from Source
# Install dependencies
git clone https://github.com/Juicywoowowow/Wasmley
cd Wasmley
# Build
make
# Run dev server
make serveLicense
MIT
Claude section!
Wasmley's Vertex JIT Compiler — Deep Dive
What Problem Does It Solve?
In normal WebGL/OpenGL, vertex data must be:
- Packed into binary — positions, colors, normals all as contiguous bytes in a specific format
- Type-converted — normalize byte values (0-255 → 0.0-1.0), convert floats to binary representation
- Stride-aware — know exactly how many bytes between each vertex
- Properly aligned — GPU expects data at specific byte offsets
Game developers normally do this manually:
-- Manual packing (tedious and error-prone)
local data = {}
for i, vertex in ipairs(vertices) do
-- Pack position (3 floats = 12 bytes)
table.insert(data, vertex.position[1])
table.insert(data, vertex.position[2])
table.insert(data, vertex.position[3])
-- Pack color (4 normalized bytes = 4 bytes)
table.insert(data, math.floor(vertex.color[1] / 255 * 255))
table.insert(data, math.floor(vertex.color[2] / 255 * 255))
table.insert(data, math.floor(vertex.color[3] / 255 * 255))
table.insert(data, math.floor(vertex.color[4] / 255 * 255))
end
-- Upload to GPU
gl.bufferData(gl.ARRAY_BUFFER, data, gl.STATIC_DRAW)
-- Manually setup vertex pointers
local posLoc = gl.getAttribLocation(program, "position")
gl.enableVertexAttribArray(posLoc)
gl.vertexAttribPointer(posLoc, 3, gl.FLOAT, false, 28, 0) -- 28 = stride
local colorLoc = gl.getAttribLocation(program, "color")
gl.enableVertexAttribArray(colorLoc)
gl.vertexAttribPointer(colorLoc, 4, gl.UNSIGNED_BYTE, true, 28, 12) -- 12 = offsetThis is tedious, error-prone, and requires understanding:
- Byte sizes for each type (float = 4 bytes, byte = 1 byte, etc.)
- Memory alignment and stride
- Type normalization rules
- Offset calculations
What vjit Does:
vjit (Vertex JIT compiler) abstracts all of this away:
local format = vjit.format({
{ name = "position", type = "float", components = 3 },
{ name = "color", type = "nubyte", components = 4 }
})
vjit.bufferData(format, vertices, gl.ARRAY_BUFFER, gl.STATIC_DRAW)
vjit.setupAttribs(format, shaderProgram)It automatically:
- Calculates stride — knows position is 12 bytes (3 floats), color is 4 bytes (4 ubytes)
- Calculates offsets — position starts at 0, color starts at 12
- Converts types — turns Lua tables into binary data with proper type conversion
- Normalizes values — converts 0-255 byte values to 0.0-1.0 float range
- Setups vertex pointers — calls glVertexAttribPointer with correct parameters
How It Works Internally:
Step 1: Format Definition
vjit.format({ ... })Creates a schema describing vertex layout. It calculates:
- Total stride (16 bytes: 12 for position + 4 for color)
- Offset for each attribute
- OpenGL type enum for each field
- Normalization flags
Step 2: JIT Compilation
When you call vjit.bufferData(format, vertices, ...), it:
- Generates code — Creates a function that converts Lua tables to binary
- Optimizes — Hardcodes offsets, types, and stride
- Executes — Runs the generated function on all vertices
- Uploads — Sends binary data to GPU via glBufferData
This is "JIT" because it compiles the conversion function at runtime based on the format you defined.
Step 3: Binary Packing The generated function does something like:
local function packVertex(vertex, buffer, offset)
-- Position: 3 floats starting at offset 0
buffer[offset] = vertex.position[1] -- float
buffer[offset + 4] = vertex.position[2] -- float
buffer[offset + 8] = vertex.position[3] -- float
-- Color: 4 normalized ubytes starting at offset 12
buffer[offset + 12] = math.floor(vertex.color[1]) -- ubyte (0-255)
buffer[offset + 13] = math.floor(vertex.color[2]) -- ubyte
buffer[offset + 14] = math.floor(vertex.color[3]) -- ubyte
buffer[offset + 15] = math.floor(vertex.color[4]) -- ubyte
endStep 4: Auto Setup Vertex Pointers
vjit.setupAttribs(format, shaderProgram) does:
local posLoc = gl.getAttribLocation(shaderProgram, "position")
gl.enableVertexAttribArray(posLoc)
gl.vertexAttribPointer(posLoc, 3, gl.FLOAT, false, 16, 0)
local colorLoc = gl.getAttribLocation(shaderProgram, "color")
gl.enableVertexAttribArray(colorLoc)
gl.vertexAttribPointer(colorLoc, 4, gl.UNSIGNED_BYTE, true, 16, 12)Type System:
vjit supports all OpenGL types:
- float — 4 bytes, IEEE 754 floating point
- byte/ubyte — 1 byte, signed/unsigned integer
- short/ushort — 2 bytes, signed/unsigned integer
- int/uint — 4 bytes, signed/unsigned integer
- nbyte/nubyte — normalized byte (0-255 maps to 0.0-1.0)
- nshort/nushort — normalized short
Dynamic Buffers:
For streaming data (particles, deformable meshes), vjit provides:
local dynBuffer = vjit.dynamicBuffer(format, maxVertices)
-- Add vertices dynamically
for x = 0, 100 do
for y = 0, 100 do
dynBuffer:add(x, y, 0, 255, 128, 64, 255)
end
end
-- Upload to GPU when ready
dynBuffer:upload()The :add() method packs each vertex on-the-fly without creating intermediate tables.
Performance Implications:
Without vjit:
- Manually pack each vertex (slow, error-prone)
- Type conversions scattered throughout code
- Easy to make stride/offset mistakes
- Hard to change vertex format (requires rewriting all packing code)
With vjit:
- Compiled packing function (fast, no per-vertex overhead)
- Type conversions happen once during format definition
- Stride/offset calculated automatically
- Change vertex format once, everything updates
Why This Matters for Wasmley:
Game developers expect:
- Easy vertex data management
- No manual byte packing
- Type safety
- Auto shader binding
vjit delivers all of that. It's the difference between:
-- Before: ~20 lines of tedious manual packing
vjit.bufferData(format, vertices, gl.ARRAY_BUFFER, gl.STATIC_DRAW)
vjit.setupAttribs(format, program)
-- After: 2 lines of clean, declarative codeReal-World Usage:
-- Define a complex vertex format
local format = vjit.format({
{ name = "position", type = "float", components = 3 },
{ name = "normal", type = "float", components = 3 },
{ name = "texCoord", type = "float", components = 2 },
{ name = "color", type = "nubyte", components = 4 }
})
-- Load 3D model (e.g., from obj file or procedurally generated)
local model = loadModel("character.obj")
-- Upload with one call
vjit.bufferData(format, model.vertices, gl.ARRAY_BUFFER, gl.STATIC_DRAW)
vjit.setupAttribs(format, shaderProgram)
-- Draw
gl.drawArrays(gl.TRIANGLES, 0, #model.vertices)This is what separates toy runtimes from production game engines. vjit is the kind of QoL feature that game developers expect. It shows Wasmley isn't just "Lua in WASM"—it's a complete, thoughtful game development platform.
"Lua" means "moon" in Portuguese. Wasmley brings the moon to the web. 🌙
