
From Lua to V8
From Lua to V8
Hehehe what about javascript over there?!
If you've done any game development you've probably touched Lua at some point. It's everywhere — Love2D, Defold, Roblox, modding scene, embedded scripting in countless engines. It's light, it's fast, it embeds beautifully.
On the other side of the engineering spectrum, outside of the creative world of games, we have... IT and software land. I spend a lot of time there. Mostly as a consultant these days.
And on the software-side we're not seeing that much Lua. We have the language of the web, our friend — JavaScript!
So why not JavaScript instead of Lua for game scripting? There are a lot of web developers and bun.sh is great. And there are like 1000000 npm packages — what could go wrong?
What about V8?
So I started looking at V8. Google's JavaScript engine. The thing that powers all the things — Chrome, Node, Deno, Cloudflare Workers. The obvious concern is size and overhead.
Lua is tiny — there are even a couple of nice header-only libraries.
V8 is not — it's like a gigantic pile of code that you have to compile and link into your application. Or spend hours building from source.
But what you get is a powerful JavaScript engine that can be embedded in your application.
// some javascript to run
const char* src = "let hp = 100; hp -= 25; hp;";
// create an isolated V8 instance (think: one sandbox)
v8::Isolate* isolate = v8::Isolate::New(params);
// create an execution context (global scope for the script)
v8::Local<v8::Context> context = v8::Context::New(isolate);
// compile and run
v8::Local<v8::String> source = v8::String::NewFromUtf8(isolate, src).ToLocalChecked();
v8::Local<v8::Script> script = v8::Script::Compile(context, source).ToLocalChecked();
v8::Local<v8::Value> result = script->Run(context).ToLocalChecked();
// read the result as a number
double hp = result->NumberValue(context).FromJust();
printf("HP: %.0f\n", hp); // "HP: 75"
// useful? idk maybe
// can just write this in c directly and not include a gigantic v8 library
int hp = 100;
hp -= 25;
printf("HP: %d\n", hp); // "HP: 75"
// but think of the adventure..Do you really need sandbox isolation in a game engine that runs on V8? Maybe. It's pretty nice. Is it faster than Lua? Maybe, go try it yourself. You do get all of the Google-powered compilation and JS magic sauce that's been cooking for years.
Wish it wasn't so painful to embed and live with.
The Deno connection
How do other people live with V8?
Enter Deno. Because our buddies at Anthropic are zigging it up with JavaScriptCore.
Deno's deno_core and rusty_v8 crates give you a Rust-native way to bootstrap V8, create isolated contexts, and inject your own functions. You define ops in Rust, expose them to JavaScript, and suddenly you have a scripting layer where the hot path runs in Rust but the logic is written in JS.
Deno acknowledges the pain and keeps their V8 bindings alive.
use anyhow::Result;
use function_runner::create_js_runtime;
#[tokio::main]
async fn main() -> Result<()> {
// create a V8 runtime (isolated sandbox)
let mut runtime = create_js_runtime();
// some javascript to run
let code = r#"
const res = await fetch("https://httpbin.org/json");
const data = await res.json();
console.log(data);
"#;
// load and run it
let module_id = runtime
.load_main_es_module_from_code(&"file:///main.js".parse()?, code)
.await?;
runtime.mod_evaluate(module_id).await?;
runtime.run_event_loop(Default::default()).await?;
Ok(())
}Maybe Deno + Bevy could be a thing.
I started prototyping this for a game use. Script sandboxes, hot-reloading, exposing game state to JS contexts. It worked well. Really well.
Pain the the middle
So js running all fine, but the real pain is production grade javascript and typescript.
Deno unlocks typescript support. It is with nodejs_compat and other js runtimes like bun and deno is where the good javascript support is.
V8 out of the box can't reallt handle a console.log or a setTimeout this is js function you have to bridge yourself in your native layer.
Ideally you just want all that for free - deno_core gives you a part of that but not all. Look deep into the horros of supabase to find out.
Wait — this is a serverless runtime
What I had built was essentially isolated JavaScript execution contexts sharing a single V8 instance, with Rust handling the heavy lifting underneath. Each script sandbox was independent, had its own scope, couldn't interfere with others.
That's a serverless function.
If I pair each sandbox to an endpoint... I thought for a while and felt like the key differentiator to other FaaS is really good streaming support. So I worked on that for a bit. The main challenge was managing the streaming pool of connections and working with async/await in Rust.
So now I had a platform. One where streaming isn't bolted on as an afterthought but is the default mode of operation.
Yielding a server-sent event stream was easy:
export default async function streaming(request: Request) {
const encoder = new TextEncoder();
async function* sseGenerator() {
const tokens = [
"Hello ",
"Worlds",
" Worlds.",
" Ah, living in the multiverse."
];
for (const token of tokens) {
yield encoder.encode(token);
// simulate your wallet getting spent on tokens
await new Promise((res) => setTimeout(res, 30 + Math.random() * 20));
}
}
return new Response(sseGenerator(), {
status: 200,
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
},
});
}So that's how Tiderun happened.