Deep Dive: Building MissionForge — a Contract Authoring Tool
Status: new, in development. Two concrete, documented fixes are baked directly into the shipped script’s own comments — a stray-table-hole export bug and a keyboard-polling rewrite that cut bridge calls per tick from 14 to 2 — which is real evidence of iteration against the running game, not just a design written once and left alone. The full round-trip (Forge → web tool → deployed contract → played to completion) hasn’t been independently re-confirmed end to end in this writeup.
MissionForge (scripts/OnKey/MissionForge.lua) is the in-game half of the Contract Framework’s authoring pipeline — walk around, drop objectives and units, export a paste-ready dump for the web tool to finish. It shares its GFx menu and input-handling lineage directly with ForgeCam, but almost everything about how it places things is a deliberate departure from that earlier tool — because the two are solving genuinely different problems.
Why not just reuse ForgeCam’s approach wholesale
ForgeCam’s whole design centers on one fact: the only way to get continuous controller input is to hijack the PDA widget, and opening the PDA pauses the world. That’s fine for a free-flying camera authoring tool — you want the world to hold still while you compose a shot or a placement. It’s a much worse fit for a mission-authoring tool that needs the player’s own character to walk around a normal, unpaused level, place things at believable distances apart, and get a feel for how a firefight will actually read.
MissionForge’s own header states the model plainly: “Runs in the LIVE, UNPAUSED world. You just WALK your merc around; the drop point is ALWAYS the character’s feet.” No camera hijack, no PDA, no pause — just walking, with a menu overlay and a ring-shaped cursor anchored directly to the player’s own character (so it tracks position for free, no per-tick repositioning cost at all).
The bigger departure: no live preview, at all
ForgeCam’s central discovery was that Object.SetPosition won’t move a spawned human — its ghost preview had to work around that by re-spawning the preview object every time it needed to move, throttled to avoid churn. MissionForge sidesteps the entire problem a different way: it never spawns a live preview to move in the first place.
Every placement drops an inert marker, not the real thing:
- Infantry → a faction-colored “Supply Drop (…)” crate.
- Vehicles → the empty variant of the chosen vehicle (crew suffixes like
" (Full)"/" (Driver)"stripped from the template name). - Props → the prop itself (already inert, nothing to strip).
- Objectives / support call-ins / triggers / AI orders → a
TinyGeometryanchor plus a ground ring and the same radar/PDA/world marking Support Effects & Triggers documents for the real framework.
The export still carries the real template string ("HMMWV (Softtop) (Full)", not the empty placeholder that was actually spawned) — the placeholder only exists so the world isn’t full of live, active AI wandering around and shooting at things while you’re trying to compose a mission around them. This is a smaller, cheaper, and fundamentally simpler answer to “how do I preview a placement” than ForgeCam’s re-spawn-to-simulate-movement trick — it works because MissionForge never needed the preview to move at all, only to mark a spot.
Squads: one placement, a whole formation
Beyond single units, MissionForge builds a SQUADS branch per faction from two tables: a per-faction role → template map (off, rifle, mg, at, elite, boss, tank, apc, truck, heli, …) and 13 squad archetypes (Fire Team, Rifle Squad, Armor Platoon, up to a 24-unit Battle Group), each just a list of {role, count} pairs. Dropping a squad places every unit in a grid formation at your feet, rotated to match your facing, all sharing one squad id — so a single Backspace undoes the entire squad in one step, not one unit at a time.
Bugs found and fixed along the way
- A trailing-nil hole could silently truncate exports.
RemoveLast/RemoveNearestused to pop entries directly out of the placements array; a pop that left a hole made#F.items(and thereforeipairsover it) unreliable — anything placed after the hole would vanish from the export without any error. The fix,compact(), rebuilds the array as a genuinely dense sequence viapairs()before every undo/remove/export, which also recovers anything that was already stranded past an earlier hole:local function compact() local keys = {} for k in pairs(F.items) do if type(k) == "number" then keys[#keys + 1] = k end end table.sort(keys) local out = {} for _, k in ipairs(keys) do out[#out + 1] = F.items[k] end F.items = out end - Per-key polling was a real, measured framerate hit. The comments record the fix directly: keyboard input dropped from 14 lua-bridge calls per tick down to 2 — one
Loader.PopKeyEvents()ring-buffer drain for discrete presses (menu nav, place, undo, export — each fires exactly once per press, in order, focus-gated) plus a singleLoader.GetKeyboardState()snapshot for the two held keys (objective radius,/.). This is the same “drain the ring, don’t poll every key” idiom ForgeCam and the Contract Board both converged on independently. - The heartbeat tick rate itself was tuned down.
TICKis explicitly commented as “PERF TEST: halved from 0.05/20Hz” — the self-reschedulingEvent.TimerRelativeheartbeat that drains input and eases the panel now runs at ~10Hz instead of ~20Hz, with no loss of responsiveness noticed, since the actual input drain (PopKeyEvents) doesn’t miss anything regardless of how often it’s called — it’s a ring buffer, not a poll.
The heartbeat: simpler than ForgeCam’s, because the world isn’t paused
Since MissionForge never pauses anything, its self-rescheduling Event.TimerRelative loop doesn’t need ForgeCam’s wall-clock-vs-simulation-time distinction at all — Sys.RealTimeStamp is still used for the actual elapsed-time measurement (clamped to 0.25s max, in case of a stall), but there’s no PDA pause to work around, so the loop is a plain generation-guarded self-rearm:
local function startTick(gen)
local function loop()
if not F.active or F.gen ~= gen then return end
local dt = TICK
if F.stamp then
local e = Sys.TimeStampGetElapsed(F.stamp)
if e and e > 0 then dt = e end
Sys.TimeStampMark(F.stamp)
end
if dt > 0.25 then dt = 0.25 end
local ok, err = pcall(Update, dt)
if not ok then Loader.Printf("MissionForge: ERROR -> " .. tostring(err)) end
Event.Create(Event.TimerRelative, { TICK }, loop)
end
Event.Create(Event.TimerRelative, { TICK }, loop)
end
The F.gen generation counter is what lets toggling MissionForge off (or back on) cleanly kill the old chain — each loop() call checks it still matches the generation that started it, so a stale chain from before a toggle just quietly stops rescheduling itself instead of needing an explicit cancel/handle-tracking scheme.
The export
Pressing End dumps one evaluable Lua table to lua_loader_printf.log:
MISSIONFORGE_EXPORT = {
name = "forge_<t>", anchor = { x=, y=, z= },
spawns = { { x=, y=, z=, yaw= }, ... }, -- optional player teleport point(s); co-op = 1 per hero
units = { { faction=, kind=, spawn=, placeholder=, x=, y=, z=, yaw=, group= }, ... },
objectives = { { type=, x=, y=, z=, radius=, yaw=, group= }, ... },
support = { { effect=, x=, y=, z=, radius=, group= }, ... }, -- artillery/flyby/heli/reinforce zones
triggers = { { x=, y=, z=, radius=, group= }, ... }, -- generic trigger zones
orders = { { behavior=, x=, y=, z=, radius=, group= }, ... }, -- AI orders (move/patrol/defend/attack/hold/face)
}
This is deliberately a much flatter, more mechanical shape than a real Contract.Register definition — positions, templates, and group tags only. No trigger conditions, no relations, no reward, no text. That’s by design: the web tool fills in everything that isn’t a physical placement — owner factions, ordnance choices, trigger modes, relations, and every piece of narrative text. group tags are the one piece of structure MissionForge does carry over: units, objectives, support, triggers, and orders placed while the same group letter (cycled with T) is active all arrive pre-associated, so the web tool’s auto-wiring (a trigger fires the support/order sharing its group) has something real to connect on import rather than starting from nothing.
Controls reference
See MissionForge in the Contract Framework section for the full day-to-day controls table, catalog structure, and export workflow.
Open questions
- The full round-trip hasn’t been independently re-walked here. Forge → export → web tool import → generated Lua → deployed contract → played to actual completion is a long chain; this writeup verified each piece against source, not a single unbroken live session through all of them.
- Co-op behavior is untested. The export format explicitly supports one spawn point per hero (
spawns = { ... }), but multi-player authoring/playtesting isn’t confirmed here. - Whether the squad-formation grid ever collides with terrain/geometry on uneven ground isn’t addressed in the source — it’s a flat local grid rotated to facing, with no ground-height sampling beyond the player’s own
yat drop time.
See also
- ForgeCam — the camera/input/menu lineage this tool shares, and the paused-world constraints it deliberately does not inherit.
- Contract Framework — the section index for the whole system MissionForge feeds into.
- MissionForge (reference) — controls, catalog, and workflow for day-to-day use.
- The Web Tool — what happens to a
MISSIONFORGE_EXPORTdump next.