All Articles
SubstrateLuaFFIPolkadotWebSocketsGameDev

Shipping SubLua to Humans: WebSockets, Games, and the Painful Parts of "Production Ready"

Adding WebSockets, advanced crypto, and a production game to SubLua looked simple on paper. In reality, it forced us to confront macOS app bundles, SSL errors, LuaRocks quirks, and the difference between something that "works on my machine" and something any player can double-click.

By Abhiraj Mengade

When we shipped the first version of SubLua, the hardest problems felt solved.

We had a fast FFI bridge from LuaJIT into Rust, dynamic metadata so we didn't brick on runtime upgrades, and a signer that could push real transactions to Westend and Polkadot. It was the kind of thing systems engineers get excited about: pointers, SCALE blobs, and no JSON in sight.

The next phase was supposed to be the easy part: "just" add WebSockets, advanced crypto features, a game, and nice packaging.

It turned out to be the phase where we had to treat SubLua like a product, not a research prototype.


1. WebSockets: When "Just Use WebSockets" Meets TLS and Mobile Networks

On paper, the WebSocket story is straightforward:

  • Maintain a pool of connections keyed by endpoint (for example, wss://westend-rpc.polkadot.io).
  • Reuse them across balance queries and subscriptions.
  • Reconnect with backoff when links drop.

Underneath, there are three non-obvious problems:

  1. Tokio runtimes are not free.
  2. TLS errors look like "random flakiness" from Lua.
  3. Lua test runners are often run from weird environments (CI, local, behind proxies).

We decided early that the FFI boundary should stay simple: no async callbacks, no Lua coroutines, just blocking functions that initiate or query WebSocket state.

So the Rust side exposes a small surface:

  • ws_connect(url, connection_id)
  • ws_get_stats(connection_id)
  • ws_query_balance(url, address)
  • ws_reconnect(connection_id)
  • ws_disconnect(connection_id)

Internally, those all operate on a global connection pool: a map from connection_id to a WebSocketConnection struct backed by an Arc/RwLock.

The critical detail is where the Tokio runtime lives.

  • For regular HTTP RPC we can spin up a temporary runtime per FFI call and block_on the future.
  • For WebSockets, that approach is suicidal – you can't reasonably tear down and recreate an async runtime for every ping/pong.

The solution was:

  • A single global Tokio runtime, initialized via a OnceLock.
  • A connection pool inside that runtime.
  • FFI calls that are thin wrappers around synchronous entry points into that pool.

This architecture gave us:

  • Deterministic lifecycle: connections live as long as the process.
  • A clean Lua API: connect once, then treat queries as simple functions.
  • A single place to implement exponential backoff, heartbeats, and stats.

The bugs we actually had to debug were nowhere near that elegant:

  • macOS returning OSStatus -26276 (TLS peer certificate errors) from certain CI runners.
  • Tests failing intermittently because the node endpoint refused a connection for a few seconds.
  • Lua tests being run from directories where "require("sublua")" didn't resolve at all.

The practical compromise:

  • Unit tests assert that WebSocket functions are present and can handle failures gracefully.
  • Pre-commit hooks run WebSocket tests, but treat "network/SSL issues" as warnings, not blockers.
  • The Lua API always returns a structured result with success: boolean and an error string, so calling code can distinguish "node is down" from "this SDK is broken".

The engineering lesson: a "production" WebSocket client isn't about features, it's about admitting that the network is hostile and treating transient failures as first-class states.


2. Advanced Crypto: Multisig, Proxies, Identity – Without Lying About Safety

The second big feature bucket was "advanced crypto": multisignature accounts, proxy accounts, and on-chain identity.

Substrate already has all of this baked in:

  • Multisig pallet for N-of-M approvals.
  • Proxy pallet for delegated permissions.
  • Identity pallet for human-readable metadata.

Our job was to wrap these in a way that felt natural to Lua developers without pretending that they're simple.

2.1. Multisig Addresses

A multisig account in Substrate is just another 32-byte account identifier derived from:

  • The set of signatories.
  • The threshold.
  • A pallet prefix (to avoid collisions).

The catch is that you really don't want to re-implement the derivation formula by hand in Lua. If you get a single byte wrong, you'll show users an address that can never be controlled on-chain.

We made two decisions:

  1. Derive multisig addresses using Rust's primitives (sp-core and the real pallets).
  2. Expose only a very small surface to Lua:
    • create_address(signers, threshold)
    • get_address(signers, threshold)

Everything else – approvals, timepoints, and weight calculation – is better done by higher-level tools or direct pallet calls.

2.2. Proxy Types

The Proxy pallet supports multiple granular permission types:

  • Any
  • NonTransfer
  • Governance
  • Staking
  • IdentityJudgement

In Rust, these are an enum with a finite set of variants.

In Lua, they should be:

  • A string constant ("Any", "NonTransfer", ...) or
  • A table of constants hanging off a module.

So the proxy module exposes:

  • TYPES.ANY, TYPES.NON_TRANSFER, TYPES.GOVERNANCE, ...
  • proxy.add, proxy.remove, proxy.query.

The tricky part was validation:

  • If we pass a bad proxy type down into Rust, it panics.
  • If we validate too aggressively in Lua, we end up duplicating Rust's type system in dynamic code.

We compromised on a thin validation layer:

  • Validate that a given type is one of the known strings.
  • Reject anything else early with a clear error.

2.3. On-chain Identity

For SubLua, the goal was not to fully mirror every single field of the identity pallet, but to give game developers a safe, limited surface:

  • identity.set(signer, { display = "Alice", web = "https://..." })
  • identity.clear(signer)
  • identity.query(address)

The real work was on the Rust side:

  • Correctly building the pallet call with dynamic metadata.
  • Accepting that runtime upgrades can reorder or rename call variants.
  • Keeping the Lua surface stable even if pallets internally evolve.

3. Shipping a Game: From "Tech Demo" to Something You Can Double-Click

The goal sounded deceptively simple:

A complete and polished game that demonstrates production-ready blockchain integration.

The first version of the game did that in spirit – it talked to Westend, fetched balances, and could submit transactions. But it had two fatal flaws:

  1. It ran in "demo mode" as soon as you double-clicked the .app on macOS.
  2. It assumed the player had SubLua and Love2D installed globally.

From an engineer's perspective, this was "fine". From a player's perspective, it was unusable.

Fixing it forced us to drill through three layers of abstraction:

3.1. Love2D's Sandbox vs. Real Files

Love2D treats the game directory as a virtual filesystem.

  • When you run "love .", it can see your source tree.
  • When you fuse a .love into a .app bundle, the working directory changes to inside the app bundle.

SubLua, on the other hand, expects to find:

  • Lua modules on package.path (sublua/*.lua).
  • A shared library on standard system paths or a known directory.

The fix was to explicitly compute paths relative to the macOS app bundle:

  • Use love.filesystem.getExecutablePath() to locate the running binary.
  • Walk up to the Contents/Frameworks directory inside the bundle.
  • Extend package.path to point at the bundled "sublua" modules.

Once we stopped pretending Love2D would "just know" where our SDK was, the demo-versus-live bug went away.

3.2. True One-Click Experience

The second UX demand was very blunt:

"I should be able to send someone a .app, they double-click, and it just works. No Lua, no SubLua, no Love2D."

That meant:

  • Bundling the Love2D runtime.
  • Bundling all SubLua Lua files.
  • Bundling a precompiled FFI shared library.

The game build pipeline now does exactly that:

  1. Build the FFI library via Rust for macOS targets.
  2. Create a fused .love file containing the game, the "sublua" directory, and a "precompiled" directory with the FFI.
  3. Copy Love.app, rename it, and drop the fused .love and dylib into the appropriate bundle paths.
  4. Zip the .app for distribution.

This is not glamorous engineering, but it's the difference between:

  • "Here is a GitHub repo, good luck" and
  • "Here is a macOS app, double-click and your game pays you testnet tokens."

4. Packaging: The LuaRocks Reality Check

We also had to make SubLua something users could install without reading the source tree.

That meant:

  • Publishing a LuaRocks module.
  • Shipping versioned rockspecs.
  • Providing a one-liner installer.
  • Making sure the FFI shared library is in a place where our loader actually finds it.

The piece that surprised us was how fragile "smart" FFI loading can be.

Our first version of the loader built a list of search paths like:

  • SUBLUA_FFI_PATH from the environment.
  • ./precompiled/<platform>/.
  • ~/.sublua/lib/.
  • Standard system paths.

Then it did the obvious:

  • Iterate the list with ipairs and try to load each candidate.

On macOS, this silently broke whenever SUBLUA_FFI_PATH was unset:

  • In Lua, ipairs stops at the first nil.
  • If the first element of the array is nil, it never iterates the rest.

So the presence or absence of an optional environment variable completely disabled every fallback path.

The fix was embarrassingly simple:

  • Build a filtered_paths array that only contains non-nil strings.
  • Iterate that instead.

Once this was in place, the one-liner installer became truly frictionless:

  • Run a single curl | bash.
  • Installer puts the FFI into ~/.sublua/lib/.
  • The loader automatically discovers it on the first require("sublua").

No environment variables, no path tweaking, no symlinks.


5. Pre-Commit Hooks and the Boring Parts of Being Serious

The last category of work was the least glamorous and the most important:

  • Pre-commit hook to run tests.
  • EditorConfig for consistent indentation.
  • CONTRIBUTING.md and CHANGELOG.md.

The pre-commit hook is intentionally boring:

  • Run core tests.
  • Run user-API tests.
  • Run advanced crypto tests.
  • Attempt WebSocket tests, but don't fail the commit purely on network/SSL issues.

This enforces a contract:

  • If you break the FFI boundary or the high-level Lua API, you feel it the very next time you try to commit.
  • If Westend has a bad day, you can still work locally without fighting your tools.

It also mirrors what CI does on GitHub, so we don't get the "works locally, fails in CI" split brain.


6. What I Would Do Differently Next Time

If I had to rebuild SubLua from scratch with what we've learned, I would:

  1. Treat distribution as a first-class concern from day zero.
  2. Design the WebSocket API around failure modes, not happy paths.
  3. Start from the Love2D app packaging, not from "love .".
  4. Keep the Lua surface aggressively small and move complexity into Rust.

Closing Thoughts

The first version of SubLua was about proving that Lua can talk to Substrate without exploding.

This phase was about proving that it can do so in a way a human can actually use:

  • WebSockets that survive real networks.
  • Advanced crypto packaged in a friendly API.
  • A game that a non-developer can double-click.
  • A LuaRocks package you can install without reading a single line of Rust.

SubLua is no longer just a bridge between languages; it's starting to look like a platform you can actually build on.

If you're a game developer, a tooling engineer, or someone who lives in the Lua ecosystem and wants to pull real value from Substrate chains, this is where it starts to get interesting.

GitHub: https://github.com/MontaQLabs/sublua

MontaQ Labs | Ideas. Code. Impact.