The existing tech blocking system lives in the experimental options tab (modoptions.lua lines 1766-1832). Seth authored it and has moved on from maintaining it. C3BO has built a TechCore gameplay variant on top of it with active community testing, including dedicated "tech core" buildings, no-sharing + tax economy settings, and a per-team tech research mechanic.
The system has known bugs (click/selection issues, debug spam) and no integration with the modes/policies architecture from the sharing_tab branch. This document plans the migration.
For background on the Controller/Policy architecture, see Game Controllers & Policies.
| File | Role |
|---|---|
| game_tech_blocking.lua | Synced gadget: tech point tracking, tech level transitions, build blocking |
| gui_tech_points.lua | UI widget: tech points bar, tech level display, popup notifications |
| api_build_blocking.lua | Centralized build blocking API (GG.BuildBlocking) |
| alldefs_post.lua lines 519-533 | Unit def post-processing: injects tech_points_gain and tech_build_blocked_until_level |
| modoptions.lua lines 1766-1832 | Mod options: tech_blocking, thresholds, unit_creation_reward_multiplier |
Discord feedback threads and C3BO's direct messages document the bugs and gameplay vision. Key findings from testing:
- Click/selection bugs make the feature frustrating to use in its current state
- The passive XP system (labs generating tech points) incentivizes degenerate lab spam
- C3BO's TechCore variant replaces passive XP with dedicated buildings, producing better gameplay
- Tech level permanence (once researched, stays unlocked) is the intended and desired behavior
game_tech_blocking.lua line 231: the AllowCommand hook is redundant with api_build_blocking.lua line 318. Both gadgets register for CMD.BUILD via gadgetHandler:RegisterAllowCommand(CMD.BUILD).
The tech blocking gadget already manages blocking state through the GG.BuildBlocking API:
GameStart(line 138): callsGG.BuildBlocking.AddBlockedUnitfor all units above current tech levelincreaseTechLevel(line 104): callsGG.BuildBlocking.RemoveBlockedUnitwhen a tech level is reached
Since api_build_blocking.lua enforces all blocking via its own AllowCommand, the second hook in game_tech_blocking creates a racing duplicate. Multiple testers report being unable to click units, labs becoming unselectable, and specific map locations becoming unclickable.
Fix: Remove gadgetHandler:RegisterAllowCommand(CMD.BUILD) (line 112) and the entire AllowCommand function (lines 231-242) from game_tech_blocking.lua. The gadget should only manage state via GG.BuildBlocking.AddBlockedUnit/RemoveBlockedUnit. This is not a policy architecture change -- api_build_blocking.lua already owns enforcement for all build blocking. The tech blocking gadget already delegates to it correctly. The redundant hook is just a leftover that needs cleaning up.
GameFrame recalculates totalTechPoints every second from alive buildings, but only calls increaseTechLevel on upward transitions (lines 216-226). When buildings are destroyed, points drop but tech level stays. This is correct and intended. Tech levels are permanent once researched -- like an RTS tech unlock, not a maintained buff. You need enough alive Catalysts to reach the threshold, but after that the level is latched. Losing buildings before reaching the threshold sets you back; losing them after doesn't.
Lines 68-69 and 73-74: Spring.Echo logs every tech-related unit def at init. This is debug code left in production. Remove.
spGetTeamRulesParam(teamID, "tech_level") is not always defaulted. Add or 1 consistently throughout the gadget and UI widget.
Replace Seth's options with cardinal options that follow the sharing tab philosophy: each option does exactly one thing.
-
tech_blocking_per_team: Unnecessary indirection. Raw thresholds are transparent -- the lobby host adjusts for their game size. -
unit_creation_reward_multiplier: Default 0 (disabled). When nonzero, every unit'spowerstat multiplied by the value is added to the team's tech points on construction. This incentivizes degenerate lab/unit spam. The explicittech_core_valueapproach via dedicated Catalyst buildings is the clean replacement. -
tech_points_gain(passive XP system): The entire passive XP accumulation from labs. C3BO already zeroes this out for all units via tweakunits. In the clean implementation, the only point source istech_core_valuefrom alive Catalyst buildings. Remove theallyXPGains/xpGeneratorstracking from the gadget entirely.
-
tech_blocking(bool): Master toggle. Stays as-is. -
t2_tech_threshold(number): Raw number of tech points (Catalysts, each worth 1) needed to unlock T2. The value is absolute, not per-player. Lobby host adjusts for their game size. Default tuned for 8v8. -
t3_tech_threshold(number): Raw number of tech points (Catalysts) needed to unlock T3. Same: absolute, transparent.
These are the composition mechanism that allows transfer and tax policies to vary by tech level:
unit_sharing_mode_at_t2:UnitSharingModethat activates when team reaches tech 2unit_sharing_mode_at_t3:UnitSharingModethat activates when team reaches tech 3tax_resource_sharing_amount_at_t2: Tax rate that applies when team reaches tech 2tax_resource_sharing_amount_at_t3: Tax rate that applies when team reaches tech 3
These are optional. When unset, the base unit_sharing_mode / tax_resource_sharing_amount applies at all tech levels. When set, Synced.GetPolicy resolves the effective value via a generic resolver:
local function resolveByTechLevel(modOptions, baseKey, techLevel)
if techLevel >= 3 then
local v = modOptions[baseKey .. "_at_t3"]
if v then return v end
end
if techLevel >= 2 then
local v = modOptions[baseKey .. "_at_t2"]
if v then return v end
end
return modOptions[baseKey]
endThis keeps policy artifacts as data. The existing ValidateUnits / IsShareableDef / classifyUnitDef pipeline works unchanged -- it still receives a single sharingMode string. The resolver just picks which string based on ctx.techLevel.
Tech level is published to TeamRulesParams as tech_level (the gadget already does this). The context factory adds it to PolicyContext so all policies can access ctx.techLevel.
Synced.GetPolicy in unit_transfer_synced.lua currently reads modOptions.unit_sharing_mode to determine the sharing mode. Change to:
local mode = resolveByTechLevel(modOptions, "unit_sharing_mode", ctx.techLevel)Everything downstream -- mode to unitDef classification, per-mode cache, validation -- is unchanged. The pipeline still receives a single sharingMode string; the resolver just picks which one based on tech level.
The resource transfer policy reads modOptions.tax_resource_sharing_amount. Change to:
local taxRate = resolveByTechLevel(modOptions, "tax_resource_sharing_amount", ctx.techLevel)Currently imperative: gadgets call GG.BuildBlocking.AddBlockedUnit / RemoveBlockedUnit at arbitrary times. This should eventually become policy-driven (see Future Work). For the immediate tech core work, the gadget continues using GG.BuildBlocking imperatively, but the design keeps concerns separated (state publisher vs. blocking decisions) so the transition is incremental.
Tech levels are permanent once reached. The build blocking flow is one-directional: units get unblocked when a tech level is reached and never re-blocked for that reason. Tech points (sum of tech_core_value from alive Catalysts) can fluctuate, but the latched tech_level only goes up. This simplifies the blocking logic -- no regression path needed.
Tech level does NOT produce its own PolicyResult. It's context data that existing policies consume. No new policy type, no pipeline composition, no ordering constraints. The _at_t2 / _at_t3 modOption pattern for transfers is the pragmatic implementation; build blocking as a full policy domain is the architectural direction.
Create new dedicated unit defs instead of monkey-patching existing Asylum shields via alldefs_post.lua.
armcatalyst, corcatalyst, legcatalyst
Ship in base game (e.g., units/ArmBuildings/TechCore/armcatalyst.lua). Always present in game data. Not on any constructor's buildoptions by default -- the tech blocking gadget adds them to T1 con build menus when the mode is active.
objectname points to existing Asylum (T3 shield) models. The Catalyst is a separate unit definition with completely different stats.
| Unit | Model |
|---|---|
armcatalyst |
Units/ARMGATET3.s3o |
corcatalyst |
Units/CORGATET3.s3o |
legcatalyst |
Units/LEGGATET3.s3o |
Based on C3BO's testing (refined from his tweakunits overrides):
- ~1000 metal, ~10000 energy, T1-buildable
tech_core_value = 1in customParams- Small/cosmetic shield (~200 power, ~100 radius), -100 energy upkeep
- Non-reclaimable, minimal wreck value
- Fusion-class explosion (investment is at risk)
The entire tech blocking section (lines 519-533) that injects tech_points_gain and tech_build_blocked_until_level into existing unit customParams gets removed. The Catalyst unit defs declare tech_core_value directly. The gadget uses each unit def's existing techlevel field to determine what to block at which level -- this is already declarative data on the unit def, not runtime injection.
-- modes/tech_core.lua
local GlobalEnums = VFS.Include("modes/global_enums.lua")
return {
key = GlobalEnums.Modes.TechCore,
name = "Tech Core",
desc = "Tech levels gate unit construction. Build Catalysts to advance. Sharing unlocks with tech.",
allowRanked = false,
modOptions = {
[GlobalEnums.ModOptions.TechBlocking] = {value = true, locked = true},
[GlobalEnums.ModOptions.T2TechThreshold] = {value = 8, locked = false},
[GlobalEnums.ModOptions.T3TechThreshold] = {value = 12, locked = false},
[GlobalEnums.ModOptions.UnitSharingMode] = {value = "disabled", locked = true},
[GlobalEnums.ModOptions.UnitSharingModeAtT2] = {value = "t2_cons", locked = true},
[GlobalEnums.ModOptions.UnitSharingModeAtT3] = {value = "enabled", locked = true},
[GlobalEnums.ModOptions.ResourceSharingEnabled] = {value = true, locked = true},
[GlobalEnums.ModOptions.TaxResourceSharingAmount] = {value = 0.30, locked = false},
[GlobalEnums.ModOptions.TaxResourceSharingAmountAtT2] = {value = 0.20, locked = false},
[GlobalEnums.ModOptions.TaxResourceSharingAmountAtT3] = {value = 0.10, locked = false},
}
}This is purely declarative data. The mode:
- Enables tech blocking with raw thresholds (8 Catalysts for T2, 12 for T3 -- tuned for 8v8, adjust in lobby)
- Starts with no unit sharing, unlocks T2 con sharing at tech 2, full sharing at tech 3
- Applies 30% tax, reduced to 20% at tech 2, 10% at tech 3
- All values are cardinal modOptions resolved by the existing infrastructure
The existing gui_tech_points.lua widget shows a fill bar and tech level number. It needs to be reworked for the Catalyst-based system where values are small integers (e.g., 3/8 Catalysts) rather than large accumulated point totals.
The widget should display:
- Current Catalyst count: How many alive Catalysts the team has right now
- Next threshold: How many are needed for the next tech level (e.g., "3 / 8 for T2")
- Final threshold: The T3 target, so players can plan ahead
- Progress bar: Visual fill from current toward next threshold
- "One more" indicator: Clear visual emphasis when the team is one Catalyst away from the next level (aids team communication -- players need to know when to prioritize building the last one)
All data is already available via TeamRulesParams: tech_points (current alive Catalyst count), tech_level (current latched level), and the thresholds from mod options. The widget reads these -- no new synced infrastructure needed.
-- additions to modes/global_enums.lua
M.Modes.TechCore = "tech_core"
M.ModOptions.TechBlocking = "tech_blocking"
M.ModOptions.T2TechThreshold = "t2_tech_threshold"
M.ModOptions.T3TechThreshold = "t3_tech_threshold"
M.ModOptions.UnitSharingModeAtT2 = "unit_sharing_mode_at_t2"
M.ModOptions.UnitSharingModeAtT3 = "unit_sharing_mode_at_t3"
M.ModOptions.TaxResourceSharingAmountAtT2 = "tax_resource_sharing_amount_at_t2"
M.ModOptions.TaxResourceSharingAmountAtT3 = "tax_resource_sharing_amount_at_t3"C3BO's TechCore variant includes a constructor unit ("Printer") buildable from the Catalyst, and T1.5 metal extractors buildable by the Printer. These are interesting gameplay additions but are separate unit def / balance work. The mode infrastructure supports adding them as additional units gated by tech level.
The GG.BuildBlocking imperative API should eventually be replaced by policy-driven evaluation: context in, blocked set out, controller reconciles. This eliminates an entire class of bugs (racing AllowCommand hooks, scattered imperative state mutations) and makes build blocking composable and testable. Terrain, modoption, and tech-level blocking become policies with different evaluation frequencies (once at init vs. periodic). The api_build_blocking.lua enforcement mechanism stays; what changes is what drives it. This is the same pattern as transfer policies and should be designed as a peer domain alongside team_transfer.
Once the Phase 2 DSL (from explain_policies.md Section 6) lands, the tech-schedule modOptions could be expressed more elegantly as policy declarations. The _at_t2 / _at_t3 modOption pattern is the pragmatic Now implementation; the DSL is the elegant Next.
If a future modes infrastructure supports parameterizing defaults by lobby size, thresholds could auto-adjust. For now, raw values plus lobby host adjustment is sufficient and transparent.