Spring API Split (RFC) #2953
Replies: 7 comments 14 replies
-
|
Regarding docs/autocomplete, how are other tables (e.g. |
Beta Was this translation helpful? Give feedback.
-
|
Fwiw, should rename to Recoil API Split (RFC) This seems like an absolute mountain of work fraught with many, many, potential missteps along the way. Who would be taking on this monumental task, and who would audit it? Edit: I read this twice and still missed it. Am I to understand that this is already in process by the BAR team? |
Beta Was this translation helpful? Give feedback.
-
|
An Aside: Moved to Engine Development |
Beta Was this translation helpful? Give feedback.
-
|
As far as I can tell the tradeoff is
As is, this tradeoff sounds terrible. Tool support is generally good but I don't see why a smart tool wouldn't be able to achieve the same thing without downsides. I think I speak for ZK here but if other ZK devs want to weigh in then sure. If the rest of Recoil somehow ends up taking this tradeoff and plow through their codebases then ZK will likely follow suit though. |
Beta Was this translation helpful? Give feedback.
-
|
I think the idea has merit but the implementation could be improved to justify the effort. What I mean by that is if you're going to be touching the API surface this heavily why not see if we can make the naming and organisation better. Here's the problem; I come to the codebase and find that everything I need to know is gated by engine jargon, ie: Gadget, Widget, LuaRules, LuaUI, LuaGaia, Synced, Unsynced, Sim. These are all scopes but they're not exclusive scopes, they overlap. Someone tells me I need to write a "widget", so WTF is LuaUI? What is unsynced? How are they relevant to my widget? What is a "Spring" supposed to be? If you're going to do a namespace cleanup do it properly. Instead of jargon help the author know what functions each scope has. I haven't put much thought into this but I think a cleaner design would be to move all the Spring.* functions that work in a widget (or are shared) into the widget table, ie: Spring.Log() -> widget.Log() |
Beta Was this translation helpful? Give feedback.
-
|
This has been a long running issue and I am not surprised at the proposal, despite being in the camp that is now blind to the issue and (perhaps therefore) somewhat skeptical that such a radical breaking change is required. I'd tend to agree with SpliFF that if you're futzing with the namespace do it better, why Spring anything when the engine is now Recoil, and the chosen bucket names are ludicrously long for anyone not using your dev software. Sync, USync, Shared? I can see arguments against using widget and gadget. |
Beta Was this translation helpful? Give feedback.
-
|
How about something like this? /* @function Spring.SetUnitHealth
+ * @envs Synced LuaRules
/* @function Spring.GetUnitHealth
+ * @envs Synced LuaRules, Unsynced LuaRules, LuaUI
/* @function Spring.GetCameraPosition
+ * @envs Unsynced LuaRules, LuaUI
/* @function Spring.Echo
+ * @envs Synced LuaRules, Unsynced LuaRules, LuaUI, LuaMenu, LuaIntro, LuaGaia, LuaParser
/* @function VFS.Include
+ * @envs Synced LuaRules, Unsynced LuaRules, LuaUI, LuaMenu, LuaIntro, LuaGaia, LuaParser, unitsync
/* @function os.clock
+ * @envs Unsynced LuaRules, LuaUI, LuaMenu, LuaIntro, LuaGaia
This is similar to what you did, except strictly just adds precise info about which function is available in which envs. So:
For example, if doc extractor uses annotations to produce processed output like return {
Spring = {
SetUnitHealth = {
SyncedLuaRules = true,
},
GetUnitHealth = {
SyncedLuaRules = true,
UnsyncedLuaRules = true,
LuaUI = true
},
...
},
VFS = {
...
},
os = {
...
},
...
}Then BAR could read this file and put something like this at Lua entry points: local mapping = VFS.Include("tools/mapping_generated_from_engine_sources.lua")
-- only carve the Spring table for now
Shared, SpringUnsynced, SpringSynced = {}, {}, {}
for funcName, availableEnvs in pairs(mapping.Spring) do
local targetTable
if availableEnvs.SyncedLuaRules
and availableEnvs.UnsyncedLuaRules
and availableEnvs.LuaUI
then
targetTable = Shared
elseif availableEnvs.SyncedLuaRules
and not availableEnvs.UnsyncedLuaRules
then
targetTable = SpringSynced
elseif ... -- more rules
...
end
targetTable[funcName] = Spring[funcName]
end
Spring = nil -- hard enforcementand do something similar for tools. |
Beta Was this translation helpful? Give feedback.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
-
Spring API split (RFC)
Authors: Daniel/attean. Last update: 2026-04-28.
This is an RFC, not a decision. It enumerates the problem, the options for addressing it, and the tradeoffs. Each game/engine project can adopt, reject, or defer independently — the engine-side work needed to enable the split is itself a precondition that's currently in flight in RecoilEngine PR #2799.
This RFC is shared with Recoil engine maintainers and Recoil-based-game maintainers (BAR, Zero-K, others) because the decision affects the engine's public Lua API surface and therefore every game built on it. Each game has a separate adoption decision; the engine has a "do we ship the split namespace at all" decision.
Decisions to make
This RFC bundles two separable decisions. Each can be adopted, rejected, or deferred independently.
Springtable; runtime-only context errorsSpringimmediately, or laterReviewer table
To be filled in by reviewers; LGTM = "the document accurately describes my position and the tradeoffs," not approval of any particular option.
Background
Recoil-derived engines expose a large Lua API on the
Springtable. Each function falls into one of three contexts:Echo,GetGameFrame, math helpers, etc.) and a handful of pure functions.At runtime, the per-context environments expose the wrong-context functions as
nil. So a widget callingSpring.SetUnitTeam(...)(synced) crashes with "attempt to call a nil value" the moment that code path runs — not at load, not at edit time, not in CI.The type system today reflects none of this. Static analysis (LuaLS / EmmyLua) sees
Springas a single table where every function is callable from everywhere. Editor autocomplete suggests synced functions inside widgets and vice versa. Documentation is split across wiki pages by convention, not by mechanism.What's broken today
Three concrete problems:
Context-mismatched calls only fail at runtime. A widget that calls
Spring.GiveOrderToUnit(synced) getsniland crashes when that code is exercised. The bug doesn't surface in editor warnings, in CI type-checks, or in unit tests that don't hit the offending branch. We see this category of bug recurring in BAR.Editor tools can't help. Autocomplete inside a widget includes synced functions (which will crash). Autocomplete inside a gadget includes unsynced functions (which will crash). Hover-docs don't tell the developer the function's context unless the wiki happens to say so.
Documentation discipline is implicit. Every contributor needs to internalize "this function is gadget-only" as a remembered fact. New contributors don't have that memory. The wiki is not always current, especially for newer functions.
Decision 1: Spring API split
Addresses the three problems above.
Split
Springinto three tables:SpringSynced,SpringUnsynced,SpringShared. Each Lua-exposed engine function is registered into exactly one of them, corresponding to its context. The per-context Lua environments expose the relevant tables — widgets cannot see synced functions (the runtime gates that direction); synced code (gadgets) can call unsynced functions (the runtime doesn't gate that direction — see e.g.Spring.PlaySoundinLuaUnitScript.cpp's comments). The static-catch property therefore protects only the widget→synced direction; the gadget→unsynced direction remains a runtime-discipline question.The engine side of this work — namespacing the
@functionannotations directly (@function SpringSynced.X,@function SpringUnsynced.X,@function SpringShared.X) so the lua-doc-extractor buckets output files accordingly, plus the missing type decorators — is in RecoilEngine PR #2799. No separate context tag is added; the namespace lives in the@functionline itself.The codemod that mass-rewrites call sites for an existing codebase (BAR's case) is open-sourced as part of BAR-Devtools; other Recoil games can reuse it.
Options
Springtable. Context errors fail at runtime.SpringSynced/SpringUnsynced/SpringShared. Per-context envs expose the relevant pair. LegacySpringretired or aliased — aliasing decision is Decision 2 below.Spring; add a per-function context tag and a custom checker. (Hypothetical; not Recoil's pattern today.)Tradeoffs
@functionannotations namespaced + extractor bucketing + per-context env registration)SpringSynced.Xreads the context aloud)Spring.X; tag is metadata)Springas deprecation alias (timing in Decision 2)Upsides of B
Context-mismatched calls fail at edit time, not runtime. This is the load-bearing property. Today's failure mode — "widget calls SyncedRead, gets nil, crashes when invoked" — is only discovered by running the relevant code path. Post-split, it's a type error caught by
emmylua_check(or any LSP that reads the generated stubs) at edit time and in CI. For BAR specifically, this drops one of the most common gadget/widget bugs from runtime to type-check time, even before tests cover the path.Editor experience becomes context-aware out-of-the-box. Inside a gadget, the LSP hides unsynced functions; inside a widget, it hides synced ones. Any standard Lua LSP that reads the stubs gets this for free — no custom tooling. Hover-docs and goto-definition land on the right table.
Namespace itself reads aloud the context. A reader sees
SpringSynced.SetUnitTeamand immediately knows the function is gadget-only. Today the same information lives in the wiki and in contributor memory. This also forces engine-source discipline going forward: adding a new Lua-exposed function requires authoring it under the right@function SpringSynced/SpringUnsynced/SpringShared.Xnamespace, which is information the engine should be tracking anyway.Codemod-able mass migration. The split is mechanical: each function has a deterministic correct destination based on its engine-side namespace. BAR has done this on its own codebase via the
mig-spring-splitleaf in the BAR-Devtools migration tooling — the migration ships as a single reviewable PR rather than a hand-edited rolling refactor. Any game with a similar shape can reuse the codemod.Downsides of B
These are real costs, and the "three starting points" framing in particular is the one that gets cited as the muscle-memory tax.
Three namespaces means three "starting points" for type lookup. Today: "what's the function for X?" → look at
Spring.Xand grep. Post-split: "is X synced, unsynced, or shared?" → know the right table first, then find the function. For developers familiar with the existing API, this is muscle-memory friction (a contributor who reaches forSpring.Echowill land on the wrong namespace until the codemod or LSP corrects them). For new developers it's actually clearer because the context is explicit — so the cost falls disproportionately on existing contributors. Cross-namespace search tooling (the LSP, project-wide grep) mitigates but doesn't eliminate this.Type-stub generation gets more complex. The lua-doc-extractor tooling has to bucket output files by
@functionnamespace. PR refactor(Lua): Spring.X -> SpringBucket.X #2799 wires this up. For Recoil maintainers, the ongoing cost is that every new Lua-exposed function has to be authored under the correctSpringSynced/SpringUnsynced/SpringSharednamespace in its@functionline — discipline, not a one-time cost.Fork compatibility surface. Recoil-based games that have their own widget/gadget code (Zero-K, BYAR-Chobby, MetalFactionTA, etc.) need to either migrate (codemod-able, but the surface is large) or rely on the deprecation
Springalias. The shim approach is straightforward technically but adds a deprecation lifecycle for the engine to manage.Documentation has to be re-shaped. Existing wiki pages, tutorials, third-party blog posts, and forum answers say "see
Spring.X" everywhere. The search-and-replace surface is wide, and historical material can't easily be retroactively fixed. During the deprecation alias window, oldSpring.Xcalls still work; after the alias is removed, anyone reading old docs sees calls that don't compile.API stability across versions becomes coarser. A function that was added as unsynced and later becomes safe to call synced today is an internal engine detail. Post-split, it's a namespace move — a more visible API change. This makes some classes of evolution slightly more disruptive.
"Which context is this function?" disagreements. For functions whose context is ambiguous or has changed, putting them in a single canonical table forces a decision. Some functions might end up in
SpringShareddefensively when really they should beSpringUnsynced. The split surfaces these debates instead of letting them stay implicit.Scope: why just
Spring?Springisn't the only table whose surface varies by context.glandRmlUiare unsynced-only (a gadget callinggl.Xblows up at runtime).os,io, anddebugare partially restricted in synced for determinism reasons (noos.time, noos.execute, etc.).math.randomis technically callable in synced but using it breaks replay determinism — convention is to reach for sync-safe sources instead. None of these context restrictions are reflected in the type stubs today.The underlying mechanism (per-context type stubs registered into per-context environments) generalizes to all of these. This RFC scopes to
Springfor cost/benefit reasons:Springis by far the largest surface (~hundreds of functions) and where the bug class shows up most. Migration ROI is highest there.os,io,debug) are upstream Lua APIs that BAR/Recoil contributors already know from outside the engine context. ReshapingosintoosSynced/osUnsyncedwould diverge from every Lua reference, tutorial, and AI completion in the world. The cost ratio is dramatically worse than for the Recoil-specificSpringtable.gl/RmlUifit the same conceptual bucket asSpring— Recoil-specific modules with runtime context restrictions the type system doesn't express today. They're smaller (one-context-only), so the fix is just a one-shot annotation rather than a namespace split. Reasonable candidates to ride along with this RFC; can also land independently if scope creep is a concern.For the engine
@functionannotation directly (@function SpringSynced.X/@function SpringUnsynced.X/@function SpringShared.X) — no separate context tag, just the@functionline itself. PR refactor(Lua): Spring.X -> SpringBucket.X #2799 lays groundwork.Springtable's status — alias-with-deprecation, or kept indefinitely — is Decision 2 below.)For each Recoil-based game
Springtable to staple onto the appropriateSpringXtable instead.For third-party tooling
Spring.Xpatterns will surface stale completions for some time.Recommendation
Daniel/attean — B (three-table split). The static-catch property is genuinely valuable and not achievable cleanly under A or C. C requires custom tooling that no out-of-the-box LSP supports, which means each game maintaining the checker; B leverages the standard Lua LSP infrastructure that already exists. The migration cost is real but bounded (codemod-able, one-time per codebase), whereas the cost of A is unbounded over the long run as the context-mismatch bug class keeps recurring.
The "three starting points" cost is the most cited downside. My counter is that the cost falls on existing contributors during the migration window, and is offset within months by the editor-tooling improvement (autocomplete becomes correct, not noisy) and within the first year by the bugs-not-shipped delta.
This is a recommendation, not a position taken on behalf of the projects involved. Each game's maintainers and the Recoil engine maintainers have their own constraints I don't see fully.
Decision 2:
Springdeprecation timingConditional on Decision 1 = B. If the three-table split lands, what happens to the legacy
Springtable? It exists today; the split adds three namespaced tables;Springcan either be deprecated immediately, deprecated later, or kept indefinitely as a permanent typed alias.This decision is independent in principle (you could split tables without ever touching
Spring's status) but only meaningful if Decision 1 = B is adopted.Options
Springis marked---@deprecatedin the type stubs. LSPs (lua-language-server, EmmyLua) surface a soft warning at everySpring.Xcall site. Runtime behavior unchanged — the alias still works — but every editor open of legacy code shows yellow squiggles encouraging migration. Engine docs explicitly say "use the namespaced form."Springships as a typed alias without@deprecated. At some future point — after BAR's migration lands cleanly, after Zero-K signs on, after some specific engine version, etc. — the@deprecatedtag is added. The "later" specifics are intentionally left to opinion-holders to flesh out.Springis a permanent typed alias. Never deprecated. Both forms (Spring.XandSpringSynced.X) are blessed and expected to coexist long-term. The static-catch property of the namespaced form is opt-in for those who want it; existing code stays type-clean indefinitely.Tradeoffs
Springtable is the old way"@deprecatedannotation, one-timeRecommendation
Daniel/attean — A (deprecate immediately). Soft LSP warnings are the gentlest possible nudge — they don't break anyone's code, don't change runtime behavior, and don't gate any merge. They just put the migration story in front of contributors at the point where the migration is cheapest (when they're already editing the file). C concerns me because "two equally-blessed APIs" tends to ossify into "everyone uses the old one because that's what the docs/wiki/AI-completions still show," which is exactly the state that motivated this whole RFC. B is reasonable but I'd want a concrete trigger ("when X happens, we add the tag") rather than indefinite deferral.
The one objection I take seriously: under-resourced games with small maintainer teams might find the constant LSP warning demoralizing if they have no bandwidth to migrate. The mitigations are (a)
Springstill works at runtime, so there's no actual breakage, and (b) most LSPs let users disable specific deprecation warnings per-workspace. But the concern is real.This is a personal recommendation; opinion-holders below have weight here, especially anyone speaking for a smaller Recoil-based game.
Open questions specific to Decision 2
@deprecated? lua-language-server does; EmmyLua does. Custom in-house tooling may not. Are there contributor populations who'd never see the warning?Open questions (cross-cutting)
Audience and stakes
Each stakeholder group has a different shape of cost/benefit:
@functionannotations into the three tables; wire the extractor to bucket output files; decide on the legacySpringtable. Ongoing cost: discipline on new function additions to author them under the right namespace. Benefit: cleaner public API, fewer "why does this crash?" issues filed against the engine.References
mig-spring-splitPR (#7290) — concrete codemod-driven migration on the BAR codebase.Spring.Xreferences that needed manual attention beyond the codemod, useful for any game running the same migration.bar-lua-codemod spring-splitis open-source and reusable.Beta Was this translation helpful? Give feedback.
All reactions