Love2D reaches the browser through 2dengine's love.js player — a standalone player that runs .love files in the browser via an Emscripten build of LÖVE 11.5. No compile step: just package your game as a .love and drop the player's player.js, love.js, and love.wasm alongside.
Build for web
- Download the 2dengine/love.js repo and extract it — call the extracted dir
LOVEJS_DIST. It must containplayer.js,style.css,11.5/love.js,11.5/love.wasm, andlua/normalize1.lua+lua/normalize2.lua. - Package your game as a
.love(zipconf.lua,main.lua, and any other modules). - Copy
player.js,style.css, the11.5/folder, and thelua/folder alongside your.loveand anindex.htmlthat loadsplayer.js?g=your-game.love&v=11.5.
The example's build.sh does all this for you — see example-love2d/build.sh.
SDK integration
love.js has no Lua↔JS FFI: love.system.openURL isn't wired in the 11.5 build, and love.js.eval referenced in the 2dengine docs isn't in the distributed wasm. The only channel that works reliably is stdout — LÖVE's print() is piped through Emscripten's Module.print → console.log. The example uses that channel.
Lua side — emit prefixed commands
-- wavedash.lua
local M = {}
local PREFIX = "[WAVEDASH_BRIDGE]"
function M.init()
print(PREFIX .. "init")
end
function M.update_load_progress(fraction)
local clamped = math.max(0, math.min(1, fraction or 0))
print(string.format("%sprogress:%.6f", PREFIX, clamped))
end
return M
JS side — sniff console.log, dispatch to the SDK
Ship this as wavedash-bridge.js loaded from index.html before player.js. The script wraps console.log up front; when love.js later does Module.print = console.log.bind(console), it captures the already-wrapped function, so every LÖVE print() flows through the sniffer.
(function () {
const PREFIX = "[WAVEDASH_BRIDGE]";
const Wavedash = window.Wavedash;
const handlers = {
init() {
Wavedash.init({ debug: true });
},
progress(raw) {
const value = Math.max(0, Math.min(1, Number(raw) || 0));
Wavedash.updateLoadProgressZeroToOne(value);
},
};
const realLog = console.log.bind(console);
console.log = function (...args) {
if (args.length === 1 && typeof args[0] === "string" && args[0].startsWith(PREFIX)) {
const [name, ...rest] = args[0].slice(PREFIX.length).split(":");
handlers[name]?.(rest.join(":"));
return;
}
realLog(...args);
};
})();
Call it from the game
At the end of love.load:
local wavedash = require("wavedash")
function love.load()
-- ...game setup...
wavedash.update_load_progress(1)
wavedash.init()
end
Adding more SDK methods
Add another handler in the JS bridge for each SDK method you need, and a matching Lua wrapper that emits the prefixed line. For example, score submission — uploadLeaderboardScore takes a leaderboard ID, so the handler resolves the name to an ID via Wavedash.getLeaderboard("name") first:
submitScore(rest) {
const [name, score] = rest.split(":");
Wavedash.getLeaderboard(name).then((lb) => {
if (lb.success) Wavedash.uploadLeaderboardScore(lb.data.id, Number(score), true);
});
},
function M.submit_score(name, score)
print(string.format("%ssubmitScore:%s:%d", PREFIX, name, score))
end
Then call from Lua: wavedash.submit_score("level-1", 3500).
wavedash.toml
game_id = "YOUR_GAME_ID_HERE"
upload_dir = "./build"
love.js 11.5 bundles the entire LÖVE runtime (~10-15MB). Compress images and prefer OGG for audio to keep the initial payload small.