LÖVE 12 for the web: the real LÖVE 12 engine compiled to WebAssembly, with a player for publishing games and a browser-based editor for making them. Try the editor at https://mostlime12195.github.io/cupid/.
Cupid is an independent project, not affiliated with the LÖVE team.
This isn't a reimplementation of the LÖVE API. It's the upstream C++ engine
(love2d/love main branch, 12.0 development) built with Emscripten, plus a
small set of patches for the browser. Games behave the way they do on
desktop, including LÖVE's own error screen, shaders, Box2D physics, audio
decoding, and save files.
| Path | What it is |
|---|---|
dist/ |
Ready-to-host build output: index.html (editor), player.html, love.mjs + love.wasm (engine), examples/ |
love/ |
LÖVE source (upstream commit ef388d4) with web patches |
deps/lua-5.1.5/ |
PUC Lua 5.1.5 (LuaJIT can't target WebAssembly) |
deps/theora/ |
libtheora 1.2.0 decoder, for love.video |
web/native/ |
Web entry point (web_main.cpp) replacing LÖVE's blocking main() |
web/player/ |
The player page (standalone and embeddable) |
web/editor/ |
The Cupid editor |
web/examples/ |
Example projects used by the editor |
build.py |
Build script (reads source lists from love/CMakeLists.txt) |
serve.py |
Local dev server |
package.py |
Command-line web export for a game folder or .love |
tools/ |
Test games: bench (benchmark), smoke (feature checks), threads, video, https, filedialog, textinput; test_server.py serves them |
love.js is the established way to run LÖVE on the web. Its latest release (11.4.1, 2022) ships LÖVE 11.4.
| love.js 11.4.1 | This project | |
|---|---|---|
| LÖVE version | 11.4 | 12.0 (current development branch) |
| Lua speed | PUC Lua 5.1 interpreter | Same interpreter plus a Lua-to-WebAssembly JIT: 1.7–5.6× faster on Lua-heavy code (measured in V8) |
love.thread |
Compatibility build: Thread:start() returns false. Threaded build: needs SharedArrayBuffer, i.e. COOP/COEP headers |
Works on any host, no special headers (see below) |
| Audio | Compatibility build: "dodgy audio" (its readme) | Streaming refilled from the frame loop and a timer; keeps playing in background tabs |
bit library (LuaJIT built-in) |
Missing | Included (LuaBitOp), as a global and as require "bit" |
LuaJIT language extensions (goto, \x escapes, yield inside pcall, ...) |
Missing | Included (see "Lua compatibility") |
| Memory | Grows, but the initial size must cover the game's assets (-m) |
Grows as needed; nothing to configure |
| Packaging | Node CLI builds a page and data package per game | Any .love file runs as is: by URL, drag and drop, or from the editor |
| Editor | None | Built-in editor with run, console and export |
The love.js entries come from its 11.4.1 npm package and readme. The threads
and bit rows were checked by running the same test game in both.
Lua-heavy code runs 1.7–5.6× faster than love.js (code that mostly calls
C library functions gains 10–20%), thanks to a JIT that compiles hot Lua
functions to WebAssembly (see The Lua JIT). Neither
LuaJIT nor any other machine-code JIT can run in a browser, so love.js (and
this project with ?jit=0) interprets every Lua instruction.
Pure-Lua benchmark (tools/jit/bench.lua), median of 3 runs in V8 (Node
26, same engine as Chrome), same build flags as the web build. "JIT off" is
the plain PUC Lua 5.1 interpreter, which performs like love.js's (see the
next table):
| Test | JIT off | JIT on | Speedup |
|---|---|---|---|
| Mandelbrot 200×200 (float math loops) | 58.6 ms | 10.5 ms | 5.6× |
| Update 10k particles (table fields) | 27.0 ms | 5.9 ms | 4.6× |
| Sieve of Eratosthenes, 1M (array reads/writes) | 46.0 ms | 12.2 ms | 3.8× |
Recursive fib(27) (Lua calls) |
26.6 ms | 15.5 ms | 1.7× |
| OOP with metatables, 300k objects | 115.0 ms | 97.2 ms | 1.2× |
| String formatting and patterns | 29.5 ms | 25.5 ms | 1.2× |
Fill and table.sort 200k numbers |
39.8 ms | 36.1 ms | 1.1× |
The last three spend most of their time in C library functions
(setmetatable, string.format, table.sort) and allocation, which the
JIT doesn't change.
Against love.js, measured with the same .love (tools/bench, which runs
unchanged on 11.4 and 12) in the same Chrome instance, back to back, before
the JIT existed:
Test (tools/bench) |
love.js 11.4.1 | This project, interpreter only |
|---|---|---|
Lua: recursive fib(27) |
11.9 ms | 11.7 ms |
| Lua: fill and sort 200k-element table | 34.1 ms | 28.9 ms |
| Lua: string formatting and pattern matching | 20.4 ms | 20.2 ms |
| Lua: OOP with metatables (300k objects) | 69.3 ms | 71.4 ms |
ImageData:mapPixel on 512×512 |
9.6 ms | 9.0 ms |
| CPU time to submit 20k sprites + 5k rectangles | 5.6 ms | 6.1 ms |
Page load to love.load (local server, cached) |
90 ms | 112 ms |
One back-to-back run on an Apple Silicon Mac; runs varied by 10–20% while
other work was running, so treat differences under ~15% as noise. Reaching
parity needed one fix: with link-time optimization, Lua's interpreter loop
was inlined into the setjmp-protected function behind
pcall/coroutine.resume, so every call paid for WebAssembly exception
handling. luaV_execute is now kept out of line (deps/lua-5.1.5/lvm.c).
The same benchmark in the official macOS build of LÖVE 12 from the same
upstream commit (love2d/love CI run for ef388d4), which uses LuaJIT with
its JIT on, median of 3 runs:
| Test | Desktop LÖVE 12 (LuaJIT) | This project (web, JIT on) |
|---|---|---|
Recursive fib(27) |
0.9 ms | 15.5 ms |
| Fill and sort 200k numbers | 32.0 ms | 36.1 ms |
| String formatting and patterns | 5.3 ms | 25.5 ms |
| OOP with metatables, 300k objects | 10.9 ms | 97.2 ms |
ImageData:mapPixel on 512×512 |
0.3 ms | 9.0 ms (browser, no JIT) |
| CPU time to submit 20k sprites + 5k rectangles | 14.1 ms (Metal) | 5.6–6.1 ms (WebGL 2) |
LuaJIT's trace compiler is in a different league for pure Lua (10–17× faster on call- and allocation-heavy code); expect CPU-heavy Lua to need noticeably more headroom on the web. Engine-side work (drawing, physics, audio and image decoding) is the same C++ code on both, and drawing was actually cheaper on the web in this test, so games that spend their frame in LÖVE's C++ code rather than in Lua lose little. The web column for the Lua rows comes from the Node/V8 runs above, not from inside a browser.
tools/p5bench is the same workload written the idiomatic p5.js way
(tint, push/translate/rotate/image/pop, rect, pixels[]).
Both ran in the same Chromium on an Apple Silicon Mac, 800×600 canvas at
pixel density 1. "Frame period" is the measured time between frames, so it
includes GPU work.
| Drawing 20k tinted, rotated sprites + 5k rectangles | Frame period |
|---|---|
| This project (WebGL 2) | 12 ms |
p5.js 1.11.3, WEBGL |
400 ms |
p5.js 2.0.3, WEBGL |
617 ms |
| p5.js 1.11.3, default canvas | about 3,800 ms |
| p5.js 2.0.3, default canvas | about 3,100 ms |
| p5.js 2.0.3, default canvas, sprites not tinted | 50 ms |
LÖVE batches consecutive draws of the same texture into one GPU draw call; p5.js issues each shape separately, and its default canvas renderer redraws the image into an offscreen canvas for every tinted draw.
Pure-language speed goes the other way, since V8 compiles JavaScript to machine code directly (p5.js: median of 6 page loads; this project: 2 page loads):
| Test | p5.js (JavaScript) | This project (Lua, JIT on) |
|---|---|---|
Recursive fib(27) |
1.0 ms | 12.5–13.9 ms |
| Fill and sort 200k numbers | 22 ms | 33–38 ms |
| String formatting and patterns | 3.4 ms | 25–30 ms |
| 300k small objects | 1.4 ms | 109–117 ms |
| Fill a 512×512 image | 0.6 ms (pixels[]) |
14–15 ms (mapPixel) |
So a sketch that draws a lot runs far faster here, while one dominated by its own number crunching runs faster in p5.js.
Download size is larger than love.js because LÖVE 12 itself is: it adds the glslang shader compiler, HarfBuzz text shaping, a Noto Sans default font, and here also Theora video.
| love.js 11.4.1 | This project | |
|---|---|---|
Engine (.wasm), gzip |
1.63 MB | 2.52 MB |
| Glue JS, gzip | 0.07 MB | 0.06 MB |
Browsers can't block the main thread, and real threads need cross-origin
isolation headers that many hosts don't send. Instead, each love.thread
Thread gets its own Lua state and runs as a coroutine on the main thread
(love/src/modules/thread/web/GreenThreads.cpp):
- Threads are preempted by an instruction-count hook (Lua 5.1 can yield from
hooks), so even
while true do ... endworker loops don't freeze the page. Channel:demand,Channel:supply,Thread:waitandlove.timer.sleepyield the thread until they can complete.- When the main thread blocks (for example
channel:demand()inlove.load), other threads run until it can continue. A real deadlock raises an error instead of hanging the tab. - Threads get whatever time is left in each 60 Hz frame after the game's update and draw (2 to 12 ms).
The trade-off: threads share one CPU core with the game, so they make blocking-style code work but don't add parallel speed.
Desktop LÖVE runs LuaJIT, which is Lua 5.1 plus extensions. Cupid runs PUC
Lua 5.1.5 with the LuaJIT 2.1 extensions that games and libraries commonly
use added back (deps/lua-5.1.5), so code written for desktop LÖVE loads
and behaves the same:
| Extension | Notes |
|---|---|
goto and ::labels:: |
Lua 5.2 rules: no jumping into a local's scope, labels are visible in nested blocks, goto continue to a label at the end of a loop works |
\xXX, \z and \u{XXXX} string escapes |
|
coroutine.yield inside pcall/xpcall |
Inside a coroutine the function runs in a child coroutine that passes yields through. coroutine.running/status report the parent; xpcall handlers run at the error, so debug.traceback shows the failing code. Outside coroutines pcall is the plain C function, at full speed |
load(string [, name [, mode [, env]]]), loadfile(name, mode, env) |
|
xpcall(f, handler, ...) |
Passes the extra arguments to f |
math.log(x, base), string.rep(s, n, sep) |
|
string.format: %s uses __tostring and accepts any value; %a |
|
table.move, require("table.new"), require("table.clear") |
|
coroutine.isyieldable, package.searchpath, os.exit(true/false) |
|
bit |
LuaBitOp, as on LuaJIT |
What can't work in a browser fails with a message that says why, instead of a generic error:
require("ffi"),require("jit"),jit.*modules andstring.buffer: "isn't available on the web: it's part of LuaJIT ...". Thejitglobal is nil, so libraries that checkif jit thentake their plain-Lua path.require("socket"),"enet"and LuaSocket's other modules: browsers have no raw sockets; userequire("https").- 64-bit literals (
1LL,1ULL) and imaginary literals (2i): a syntax error that names LuaJIT's FFI. - Yielding inside a metamethod, a
for ... initerator or a C function such astable.sort's comparator: the error says that yielding works insidepcallbut not there. (LuaJIT allows the first two.)
The editor checks a project's Lua files each time it runs or exports, and
package.py does the same when packaging. Problems appear in the console
(or on stderr) with the file and line: LuaJIT-only and networking modules
required without pcall, 64-bit literals, compute shaders, io.popen and
os.execute, unguarded jit.* calls, plus notes about
love.system.getOS() returning "Web" and threads sharing one core. The
rules are in web/editor/compat-rules.json.
deps/lua-5.1.5/ljit.inc compiles a Lua function to a small WebAssembly
module once it has been called, or has looped, 50 times. The browser then
compiles that to machine code like any other wasm.
- Lua values stay in the Lua stack, exactly where the interpreter keeps them, so compiled code can be entered and left at any instruction: after calls, when a coroutine resumes, from the middle of a hot loop, or to hand a frame back to the interpreter when a debug hook is set.
- Arithmetic, comparisons, numeric
forloops, tests, moves, constants, and reads and writes of table fields (constant string keys, hashed at compile time) and array slots run inline. Everything that can involve metatables, the GC, errors or calls goes to helpers that are copies of the interpreter's own code, so behavior (including error messages) is identical. - Compiled functions call other compiled functions directly, up to 64 deep.
- Frames with a line or count hook are interpreted, so
debug.sethookandlove.thread's scheduler work unchanged. ?jit=0on the player turns it off.
tools/jit/ builds the standalone interpreter for Node and runs a
differential test suite: every test must print exactly the same with the
JIT off, compiling everything immediately, and with a mix of compiled and
interpreted frames.
sh tools/jit/build-node.shbash tools/jit/test.shRequires the Emscripten SDK (tested with 6.0.13) and Python 3.10+.
source /path/to/emsdk/emsdk_env.shpython3 build.pyBuilds are incremental (about 400 source files the first time). Other options:
python3 build.py --debugfor assertions and no optimizationpython3 build.py --web-onlyto copy only the player, editor and examples intodist/python3 build.py --profilingto keep function names for size analysis (writes tobuild/profiling/)
Then serve dist/:
python3 serve.pyand open http://localhost:8765/ for the editor or http://localhost:8765/player.html?game=examples/hello.love for the player.
The test games in tools/ run with python3 tools/test_server.py 8772,
which zips them on request and adds the routes the https test needs:
http://localhost:8772/player.html?game=tools/https.love (or smoke,
filedialog, textinput, ...).
dist/index.html. Everything is stored in the browser (IndexedDB); nothing is
uploaded anywhere.
- Projects with a file tree; import files, folders,
.loveand.zipby drag and drop - CodeMirror 6 editor with Lua highlighting and LÖVE 12 autocompletion. The
API list (
web/editor/love-api.json) is generated by introspecting the real engine (web/editor/tools/introspect.html) - Run (F5 or Ctrl+Enter), Stop, Restart, Pause, run after saving, pop-out window
- Command palette (Ctrl+Shift+P or F1), go to file (Ctrl+P), go to function (Ctrl+Shift+O), go to line from the status bar
- Settings (Ctrl+,): theme (system, light, dark), font size (Ctrl +/−), tab size, spaces or tabs, word wrap (Alt+Z), line numbers, whitespace, bracket closing, suggestions, auto save, trimming whitespace on save, game scaling (fit or pixel-perfect), the Lua JIT, console timestamps and wrapping, and a list of every keyboard shortcut
- Console with clickable
file:lineerrors that jump to the line, filtering, and copying the output - Web compatibility check on every run and export, with a status bar count of issues that opens a list of them
- Image, audio and font previews
- Export
.love, export a ready-to-upload web build (zip), share links for small projects - Examples: platformer, particles, shaders, physics, text input, canvas and blend modes, synth
- Works on narrow screens
The editor loads CodeMirror from esm.sh, so the first load needs an internet connection (it falls back to a plain text area if that fails).
Copy player.html, player.js, player.css, love.mjs and love.wasm next
to your game.love, and add this to player.html's <head>:
<meta name="love-game" content="game.love">The editor's Export for web does this for you, as does the command line:
python3 package.py path/to/game out/ --zipEither produces a zip you can upload to itch.io as an HTML game. Serving .wasm as application/wasm
lets browsers compile the engine while it downloads; most hosts already do.
Player URL options:
| Option | Effect |
|---|---|
?game=url |
Load a .love from a URL |
?autoplay=1 |
Skip the Play button (audio may stay muted until the first click) |
?scale=fit (default) / integer / none |
How a fixed-size window is scaled to the page |
?pixelated=1 |
Nearest-neighbour upscaling (also implied by scale=integer) |
?embed=1 |
Embedded mode, controlled with postMessage (see web/player/player.js) |
love.system.getOS()returns"Web".- Save files live in IndexedDB and are flushed every 2 seconds, when the tab is hidden, and when the game quits.
love.timer.sleepon the main thread doesn't block (it lets threads run instead).- Text input is enabled by default, as on desktop, except on touch devices
(no hover, coarse pointer), where it starts off as on Android and iOS.
While it's on, a hidden text field has keyboard focus instead of the
canvas, so IME composition (Chinese, Japanese, Korean), emoji pickers,
dictation and on-screen keyboards reach
love.textinput(composition in progress is reported tolove.textedited).love.keypressedworks as before; keys that belong to an IME composition don't produce key presses. Pasting into the field is ignored, as on desktop: uselove.system.getClipboardText. - On touch devices, tap the game while text input is on to bring up the
on-screen keyboard (browsers only show it in response to a tap).
love.keyboard.setTextInput(true, x, y, w, h)moves the hidden field over that rectangle, which is where IME candidate windows appear. require("https")(lua-https) works, using a synchronousXMLHttpRequest: it blocks like on desktop. Browser rules apply: other sites must allow cross-origin requests (CORS), anhttpspage can't requesthttpURLs, and some request headers (User-Agent,Content-Length, ...) are set by the browser. Network errors, including refused CORS requests, return status0with an error message as the body (like lua-https on macOS). Response header names are lower case. Relative URLs are resolved against the page, so a game can request files hosted next to it.love.window.showFileDialoguses the browser's file picker.openfileandopenfoldercopy what was picked into the in-memory file system (/picked/<n>/...) and return those paths; read them as on desktop withlove.filesystem.openNativeFile,mountFullPathorio.open. Filters become the picker'sacceptlist; browsers can't show a title or button labels.savefilereturns/picked/<n>/<defaultname>. When the game has written and closed that file, the browser downloads it under that name (again on every later save to it).- Cancelling returns an empty list, as on desktop.
- Browsers only open pickers in response to a click or key press. If the
game calls
showFileDialogfromlove.keypressedorlove.mousepressed, the picker opens directly (in browsers that reportnavigator.userActivation). Otherwise, and always forsavefile, the player shows a small bar with a "Choose file…" / "Save" button and Cancel; the picker opens when it's clicked.
- Games with
t.window.resizable = truefill the page and getlove.resize. love.window.setFullscreenresizes the game immediately; the browser enters real fullscreen on the next click or key press (browsers only allow fullscreen from a user gesture).
- Networking modules:
socket(LuaSocket) andenet(browsers have no raw sockets).httpsworks, see above. - WOFF2 fonts (FreeType is built without Brotli); TTF and OTF work.
- LuaJIT itself and its
ffilibrary (Lua runs on PUC Lua 5.1 with LuaJIT's language extensions and this project's own JIT; see "Lua compatibility"). - Compute shaders (WebGL 2 has none).
web/native/web_main.cpp replaces LÖVE's main(). On desktop LÖVE resumes
its boot coroutine in a while loop. boot.lua yields once per iteration of
love.run's main loop, so on the web that coroutine is resumed once per
requestAnimationFrame instead. Nothing in LÖVE's own Lua code changes.
The engine patches (git diff dca79b4 -- love deps) are kept small so they
can be rebased onto newer LÖVE commits:
- Add an
LOVE_EMSCRIPTENplatform (config.h) and leave out LuaSocket and ENet. - lua-https gets a browser backend
(
love/src/libraries/luahttps/src/emscripten, requests made inweb/native/library_love.js). - WebGL 2 differences: no texture swizzle (LA8 textures use
LUMINANCE_ALPHA), noGL_FRAMEBUFFER_SRGBtoggle. love.timerusesemscripten_get_now; sleeping doesn't block.- A Lua-to-WebAssembly JIT (above), in
deps/lua-5.1.5/ljit.incwith small hooks inlvm.c,ldo.candlfunc.c. - Green threads for
love.thread(above); audio streaming refills from the frame loop and a timer instead of a pool thread, and recovers from underruns. love.videodecodes Theora from the frame loop too, instead of its worker thread. libtheora's decoder is built from source (deps/theora, without its x86/ARM assembly); libogg is Emscripten's port.- A streaming Source seeked while paused or stopped no longer skips ahead when played again (this showed up as a video with audio jumping ahead).
boot.luaskips modules the build doesn't include.- LuaBitOp (
deps/luabitop) is loaded as the globalbit, as on LuaJIT. - SDL3 text input is started when the window is created (not on touch
devices). Text from the page's hidden text field is pushed as SDL text
events by
web/native/web_main.cpp. love.window.showFileDialoghands the dialog tolibrary_love.jsinstead of SDL (love/src/modules/window/sdl/Window.cpp).
LÖVE is under the zlib license (love/license.txt); its bundled libraries
keep their own licenses. Lua 5.1 is MIT (deps/lua-5.1.5/COPYRIGHT), as is
LuaBitOp (deps/luabitop/bit.c). libtheora is BSD-style
(deps/theora/COPYING).
Changes in this repository are marked in the source and follow the same
licenses.