Game development

Should a game engine embed a scripting language?

Iteration speed against a second type system, a second debugger, a marshalling cliff and a second thing to ship. The argument is really about where the line goes.

Embedding a scripting language in an engine is one of the few architectural decisions in game development that is genuinely contested by serious people, and it has been for thirty years. It is contested because both sides are describing real costs, at different points in a project's life, and the costs are not commensurable.

John Carmack has, over the years, publicly expressed scepticism about general-purpose scripting layers in engines — a position that is widely discussed and worth engaging with on its merits. It is also worth noting what id's engines actually did, which is more nuanced than the shorthand suggests: Quake shipped QuakeC, a small purpose-built language compiled to bytecode; Quake III used QVM, in which C was compiled to a virtual machine bytecode with an interpreter and, on some platforms, a JIT. Those are not "no scripting". They are small languages you control, which is a distinct third position from both "embed Lua" and "everything in C++".

The case for a scripting layer

Iteration time is the dominant cost in game development, and compile-link is the enemy. A large C++ engine takes minutes to rebuild and relink. If tuning an enemy's aggression range costs four minutes per attempt, a designer performs a tenth as many experiments, and the quality of a game is close to a function of how many experiments got run. A script change that takes effect instantly is not a convenience; it changes how much design work happens.

The people tuning behaviour should not need to be engine programmers. A designer, a technical artist or a level scripter needs to express intent, not manage lifetimes. Handing them a language with garbage collection, no undefined behaviour and no build step lets them work without a programmer in the loop and without the risk that a mistake corrupts the heap.

Hot reload during play. Changing a value while the game is running, with the state intact, closes the feedback loop entirely. Nothing else in the toolchain offers that.

Crash containment. A script error can be caught, logged, and turned into a warning on screen rather than terminating the process. A null dereference in C++ ends the session. Over a long production this difference in failure cost adds up to a lot of recovered working hours.

Post-certification content. On consoles, patching a signed executable is a slower and more expensive process than shipping data. If gameplay tuning lives in data, a balance change is a content update. If it lives in the binary, it is a patch submission.

Modding. An exposed scripting layer turns players into content producers, and titles with strong modding communities have outlived their contemporaries by many years. This is a commercial argument rather than an engineering one, and it is often the decisive one.

The case against

You now maintain two type systems that disagree. Every type crossing the boundary needs a binding. Bindings are either hand-written, in which case they rot, or generated, in which case you own a code generator and its edge cases. Neither language's compiler checks the other side, so a signature change that would be a compile error within one language becomes a runtime error at the seam — discovered by a tester, in a specific scene, on a Tuesday.

Debugging across the boundary is genuinely bad. Two debuggers, two call stacks, and no unified view. A crash in native code, called from script, called from native code, produces a native stack trace whose middle is opaque and a script stack trace that stops at the boundary. Reconstructing what happened means correlating two traces by hand — a much harder version of reading a stack trace properly.

The marshalling layer has a performance cliff, and it is closer than people expect. The per-call cost of crossing between native code and a scripting VM is small but not negligible, and gameplay code has a habit of being called per-entity per-frame.

Frame budget
Spent at the boundary
Share of the frame
Calls that fit in 5%

The defaults are not a strawman: five thousand entities each calling one script function per frame, at two microseconds a crossing, consumes ten of the sixteen and two-thirds milliseconds a 60 Hz frame contains. The failure is architectural rather than a matter of VM speed, and the fix is always the same shape — script decides policy, native code runs the loop. One script call that configures a behaviour, not one per entity per frame.

Garbage collection lands inside the frame budget. At 60 Hz a frame is 16.67 ms; at 120 Hz it is 8.33 ms. A stop-the-world collection of four milliseconds is a quarter of one frame and a visible hitch. Incremental and generational collectors mitigate it, and allocation discipline — pooling, reusing tables, avoiding per-frame temporaries — mitigates it more, but you have introduced a source of latency that is not under the frame scheduler's control.

You are shipping two languages. Two toolchains, two profilers, two static analysers, two sets of coding standards, two onboarding paths. Every engine feature that needs to be reachable from gameplay has to be exposed twice. And platform constraints bite here specifically: several console platforms and iOS restrict runtime code generation, so a JIT-based runtime cannot ship there and you fall back to an interpreter that is very much slower — meaning your performance characteristics differ by platform in a way your development machines will never show you.

An interpreter that loads user content is an attack surface. Sandboxing an embedded language properly — removing file and process access, bounding memory and execution time, surviving deliberately hostile input — is real security work, and modding support means you are doing it whether you planned to or not.

Hot reload's hidden cost is state migration. Reloading a script whose objects hold live state means either discarding that state, which defeats the purpose, or writing migration logic that maps old state onto new definitions. The second is a small distributed systems problem that nobody budgets for.

How the industry actually resolved it

Nearly everyone shipping today has a scripting layer, but the interesting part is what kind:

  • A general-purpose embedded interpreter. Lua is the archetype and remains extremely common, valued for a tiny footprint and a simple C API. CryEngine used it for gameplay; a great many titles expose it for UI or mods.
  • A managed language compiled ahead of time. Unity's C# is the mainstream example — script-like ergonomics, but compiled, statically typed, and with an ahead-of-time compilation path for platforms that forbid JIT, which sidesteps the console restriction entirely.
  • Visual scripting over a bytecode VM. Unreal replaced UnrealScript with C++ plus Blueprints, betting that the audience for gameplay authoring wants a graph rather than a text language.
  • A purpose-built language. Godot's GDScript, and Roblox's Luau, which is a Lua dialect with a static type system bolted on precisely because the untyped original did not scale to large codebases. Naughty Dog's use of a Lisp for the Jak games is the well-documented historical case of a studio building its own.

Read that list and the trend is unmistakable: toward typed, compiled or bytecode-compiled scripting, and away from dynamically-typed interpretation. Which is a substantial concession to the sceptical position. The industry did not decide scripting was wrong; it decided that an untyped interpreter was the wrong implementation of a right idea.

The strongest form of the sceptical argument

Stated fairly, it is not "scripting is bad". It is this: a scripting layer is very often introduced to solve a build-time problem, and build time is a problem you can attack directly.

Hot-reloadable native modules, edit-and-continue, faster linkers, unity builds, better dependency hygiene and a proper module structure all reduce iteration time without introducing a second language. If you solve slow builds by adding a scripting VM, you have permanently acquired a second type system, a second debugger, a marshalling layer and a second toolchain in order to avoid a problem you could have fixed once. And build times get slower over a project's life, so the workaround grows in scope while the underlying problem stays unfixed.

The counter is equally fair. Even with instantaneous native builds, you would still want a boundary — because the difference between an engine programmer and a level designer is not build speed, it is the appropriate blast radius of a mistake. Data and content are not code, and the reason to have a scripting layer is to put the content on the other side of a wall from the engine, not to iterate faster.

Which reveals what the disagreement is actually about. Nobody proposes scripting the renderer, and nobody proposes recompiling the engine to move a trigger volume. The argument is about where the line goes, and that is a project-specific question rather than a matter of principle.

Deciding for your project

Four questions, in this order:

  1. How many people who are not engine programmers need to change behaviour daily? If the answer is zero, a scripting layer is overhead. If it is thirty, it is infrastructure.
  2. How long is your native incremental build? If it is seconds, one of the main arguments for scripting has already evaporated. If it is four minutes, fix that first and then re-ask the question, because the answer may change.
  3. What is your boundary-crossing count at target scene size? Not at test scene size. Use the calculator above with realistic entity counts, and if the answer is a large fraction of a frame, design the API around batches before you write any of it.
  4. Which platforms are you shipping to? If any of them forbid runtime code generation, a JIT-dependent runtime is out, and you need to know your interpreted performance early rather than in certification.

The one position that is definitely wrong is treating this as settled in either direction. A scripting layer is a substantial, permanent architectural commitment that buys a specific and valuable thing. Adding one because engines have them, or refusing one because a respected engineer was sceptical about them, are the same mistake in opposite directions.