Documents the ImplantApp lifecycle ordering and the nav-stack state guarantee at each hook: - Class-level docstring on implant_app.gd describes on_install, on_open, on_close, and on_insert_deactivated: when each fires, what nav state subclasses can rely on, and what is safe to do (construct + register_screen in on_install; data refresh + read nav.current() in on_open; pause timers in on_close; no close_app manual call in on_insert_deactivated — call super or replicate the guard). - Arch doc gains a "Lifecycle hooks" subsection under ImplantApp base class with a four-row contract table plus explanatory notes on two load-bearing invariants: why on_install sees an empty stack (bottom-up _ready order, no open signal yet); why on_close must not push/pop (would destroy preserved position on reopen). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
24 KiB
Implant App Pattern
Status: Proposed — introduced in Sprint 36 atlas refactor (#844). implant_app.gd, implant_nav_stack.gd, implant_app_manifest.gd, and implant_registry.gd land alongside the atlas split. Economics panel is migrated in the same PR as the second reference implementation.
Context
The implant is the diegetic frame for every HUD tool the player carries — map atlas, economics monitor, Drifter's Guide reader (GTTR), knowledge journal, and anything we add later. These are not ad-hoc panels. They share z-index rules (D-170), a component library (D-169), an input model (one app active at a time, others occluded or hidden), and a visual identity.
The useful mental model: treat the implant as a faux mobile OS.
| Mobile OS concept | Implant equivalent | State |
|---|---|---|
| Window manager | HudGroups autoload (D-170) |
Exists |
| UIKit / widget set | D-169 component library (ImplantPanel, etc.) |
Exists |
| Activity / App | ImplantApp base class |
Introduced here |
| Manifest / Info.plist | ImplantAppManifest resource + app.tres |
Introduced here |
| Nav controller | ImplantNavStack |
Introduced here |
| Intent dispatcher | (planned — cross-app routing) | Seam sketched, not built yet |
| PackageManager | ImplantRegistry autoload |
Introduced here (in-tree scan only) |
| Mod SDK / .ipa | pck-mounted app bundles, signing | Deferred (Phase 6+) |
This document specifies the four pieces landing now and the seams left open for the deferred pieces.
Design drivers
- Cargo-cult elimination. Every implant app currently re-implements ~30 LOC of boilerplate: anchoring,
visible = false,MOUSE_FILTER_STOP,HudGroups.register,app_changedfiltering,set_insert_active,toggle_visible. Drift between apps is already visible — the atlas and economics panel handleINSERTmode differently. Absorb all of it into a base class. - Moddability as first-class driver. A modder or content author writes a self-contained app directory, drops it under
client/ui/implant/apps/, and the implant discovers and registers it at startup. No core-code edits required to ship a new app. This constraint shapes every downstream choice — manifest format, key-binding routing, data-channel seam, inter-app signalling. - Intra-app navigation without ad-hoc enums. The atlas ships a
Levelenum and manual screen management. Each new app repeating that invention is wrong. One nav primitive, reused.
The Manifest — file-based discovery
Each app directory contains an app.tres (Godot Resource) declaring the app's identity and capabilities. At startup, ImplantRegistry scans res://ui/implant/apps/*/app.tres and builds the installed-app registry. This scan is the seam that later extends to mod discovery — once we mount modded pck files under a known path, the same scan finds their manifests.
# client/ui/implant/implant_app_manifest.gd
extends Resource
class_name ImplantAppManifest
@export var schema_version: int = 1 # bump on breaking manifest changes
@export var app_path: String = "" # unique HudGroups id, e.g. "implant/map"
@export var scene_path: String = "" # res:// path to the app's root scene (empty = metadata-only)
@export var default_mode: String = "fullscreen" # "gameplay" | "insert" | "fullscreen"
@export var default_key: int = -1 # e.g. KEY_M = 77; -1 = no default binding
@export var preserves_state: bool = true # keep nav stack across close/open
schema_version lets the registry differentiate mods built against older or newer manifest shapes. ImplantRegistry.CURRENT_SCHEMA_VERSION names the version the current build understands. On scan:
schema_version == CURRENT: silent, proceed normally.schema_version < CURRENT:print_verbose("backfilled from v{n}"), proceed with field defaults.schema_version > CURRENT:push_warning("proceeding best-effort"), proceed.
Reject-on-unknown is deliberately avoided — forward tolerance matters more than strict validation in a modded context. An app that relies on a field the build does not understand may malfunction at runtime; that failure is traceable via the warning.
default_mode is a string ("gameplay" / "insert" / "fullscreen") rather than the raw HudGroups.Mode int, because editing enum ints in .tres is fragile. The registry resolves the string to HudGroups.Mode via ImplantRegistry.get_resolved_mode(app_path); invalid strings skip the manifest with a warning.
display_name and icon_path are intentionally omitted at this stage — they add surface without callers. Add them only when a launcher UI needs them.
Keeping the manifest declarative — a Resource, not a script — means the installed-app list is data. Mod authors don't need to touch GDScript to register. The scan output can be cached. Validation happens in one place.
Generic key routing in main.gd
Today (main.gd:180ish):
if event.keycode == KEY_M:
atlas_panel.toggle_visible()
elif event.keycode == KEY_N:
economics_panel.toggle_visible()
Every new app requires a main.gd edit. Fatal for moddability. Replace with a registry-driven router:
for manifest in ImplantRegistry.get_manifests():
if manifest.default_key == event.keycode:
HudGroups.toggle_app(manifest.app_path, manifest.default_mode)
return
Adding a new app = drop directory, restart, done.
Key-binding collision policy for this sprint: first-wins, warn on startup. Future sprint: settings UI to remap.
ImplantApp base class
Absorbs every piece of cargo-culted boilerplate. Each app's shell extends ImplantApp and overrides lifecycle hooks instead of reinventing infrastructure.
# client/ui/implant/implant_app.gd
class_name ImplantApp
extends Control
var manifest: ImplantAppManifest = null
var nav: ImplantNavStack = null
var _screens: Dictionary = {} # screen_id → Control
var _current_screen_id: String = ""
signal app_opened(mode: int)
signal app_closed
func _ready() -> void:
set_anchors_preset(Control.PRESET_FULL_RECT)
mouse_filter = Control.MOUSE_FILTER_STOP
visible = false
if manifest:
HudGroups.register(self, manifest.app_path)
HudGroups.app_changed.connect(_internal_app_changed)
nav = ImplantNavStack.new()
nav.screen_changed.connect(_on_screen_changed)
add_child(nav)
on_install()
# --- Internal wiring — do not override ---
func _internal_app_changed(app_path: String, mode: int) -> void:
if manifest == null:
return
if app_path != manifest.app_path:
if visible:
visible = false
on_close()
app_closed.emit()
return
match mode:
HudGroups.Mode.FULLSCREEN, HudGroups.Mode.INSERT:
if not visible:
visible = true
if not manifest.preserves_state:
nav.reset_to_default()
elif nav.is_empty():
nav.push_default()
on_open(mode)
app_opened.emit(mode)
HudGroups.Mode.GAMEPLAY:
if visible:
visible = false
on_close()
app_closed.emit()
# --- Screen management (base-owned) ---
## Register a screen under an id. Base adds it as a child and starts it hidden.
## Call from on_install(). Wire signals BEFORE calling register_screen.
func register_screen(id: String, screen: Control) -> void:
# Warns + returns on duplicate id; base-parents if unparented; forces visible = false.
...
func current_screen_id() -> String:
return _current_screen_id
## Default screen-swap. Base handles leave/hide/enter/show with has_method tolerance
## and skips leave/hide when replacing the same screen id (payload-only update).
## Subclasses rarely override.
func _on_screen_changed(new_id: String) -> void:
# See implant_app.gd for the full body.
...
# --- Lifecycle hooks — subclasses override ---
func on_install() -> void: pass # once, after _ready
func on_open(_mode: int) -> void: pass # every transition to visible
func on_close() -> void: pass # every transition to hidden
func on_insert_deactivated() -> void: # SnapshotConsumers calls this
# Default: closes the app if it is active in INSERT mode. FULLSCREEN apps inherit no-op.
pass
func handle_intent(_action: String, _params: Dictionary) -> void: pass # future — Intents
What the subclass is responsible for:
- Loading its own manifest in
_ready()before callingsuper._ready(). - In
on_install: constructing screens, callingsetup/signal wiring on them, thenregister_screen(id, screen). Settingnav.set_default(id)once. - Reading state via
current_screen_id(), emitting app-specific signals, handling app-specific input.
What the subclass is not responsible for:
HudGroupswiring. Ever.visiblemanagement (app-level or screen-level). Ever.- Anchor / mouse-filter boilerplate. Ever.
add_childon registered screens.register_screenhandles parenting._current_screen_idbookkeeping. The base owns it.- Enter/leave dispatch. The default
_on_screen_changeddrives it; screens that need enter/leave implement those methods, the base invokes them viahas_methodtolerance.
Lifecycle hooks
The four hooks fire in a defined order with a defined nav-stack guarantee at each point. Subclasses can rely on this contract without inspecting HudGroups state directly.
| Hook | When it fires | Nav stack state when it fires | Intended use |
|---|---|---|---|
on_install() |
Once, from _ready(), after nav is created |
Empty. No screens have been pushed yet. | Construct screens; call register_screen(id, screen); call nav.set_default("..."). Wiring only — do not read nav.current(). |
on_open(mode) |
Every activation (FULLSCREEN or INSERT) | Non-empty. Base ensures: preserves_state=false → reset_to_default() called; preserves_state=true and stack was empty → push_default() called; preserves_state=true and stack non-empty → untouched. |
Data refresh; announce current screen; start animations. nav.current() is safe here. |
on_close() |
Every deactivation (GAMEPLAY or different app takes focus) | Stack preserved — base does not mutate it. | Pause timers; stop high-frequency feeds; save scroll position. Do not push or pop — the stack survives for the next on_open. |
on_insert_deactivated() |
SnapshotConsumers calls this when server drops insert state | Whatever on_close() left it (if the app was already closed) or the live state (if the app is still open) |
Base default: close if active in INSERT mode, no-op otherwise. FULLSCREEN apps override. Do not call close_app() manually — call super() or replicate the guard. |
Why on_install sees an empty stack: _ready() fires bottom-up — children before parents. The base's _ready() creates nav and then calls on_install() synchronously. No HudGroups.app_changed signal has fired yet (that comes from open_app(), which requires the game to be running). Subclasses that call nav.current() in on_install always see "" — which is always wrong. The right pattern is to call nav.set_default("my_first_screen") in on_install and let the base push it on the first on_open.
Why on_close must not push/pop: The nav stack is the in-flight navigation position that survives across close/reopen cycles (when preserves_state=true). Mutating it in on_close destroys the user's position. If an app needs to reset navigation on close, set preserves_state=false in the manifest instead — the base handles the reset at the top of on_open, which is the right moment.
ImplantNavStack — intra-app navigation
Apps push and pop screens. The stack is owned by ImplantApp (one per app instance — no global nav state).
# client/ui/implant/implant_nav_stack.gd
class_name ImplantNavStack
extends Node
signal screen_changed(current_screen_id: String)
var _stack: Array[String] = []
var _payloads: Array[Dictionary] = []
var _default_screen_id: String = ""
func set_default(screen_id: String) -> void
func push(screen_id: String, payload: Dictionary = {}) -> void
func pop() -> void
func replace(screen_id: String, payload: Dictionary = {}) -> void
func reset_to_default() -> void
func is_empty() -> bool
func push_default() -> void
func current() -> String
func current_payload() -> Dictionary
Mutation is synchronous. push() updates _stack, emits screen_changed before returning. The shell's handler synchronously calls enter(payload) on the new screen and leave() on the previous one. No call_deferred races; a frame always ends with a coherent nav state.
Screens are plain Control nodes, registered via ImplantApp.register_screen(id, screen). Subclass on_install constructs each screen, wires its signals, and calls register_screen — base handles add_child and visible = false. Screens never touch HudGroups, APP_PATH, app_changed, or their own visibility. They only emit nav signals back to the shell (e.g. select_system(system_id)), and the shell translates to nav.push("system", {...}).
Screens optionally expose:
func enter(payload: Dictionary) -> void # called when pushed or re-surfaced
func leave() -> void # called when popped or superseded
The base's default _on_screen_changed invokes these via has_method — screens that need neither don't ship stubs. The base also detects same-screen-replace (nav.replace("system", new_payload) while already on "system") and re-enters with the fresh payload without calling leave first. Atlas exercises this on the reach → system-picker → system-orbital transition.
Nav-stack edge cases
Behavior defined by the base class — subclasses do not re-implement these.
open_app()withpreserves_state = false: stack cleared, default screen pushed. State-free apps never surprise the user with stale context.open_app()withpreserves_state = true, empty stack: default screen pushed. Typical first-ever-open case.open_app()withpreserves_state = true, non-empty stack: no mutation — app re-opens on the screen the user was last viewing. This is the atlas case (reopen on the body you were looking at).push()/pop()/replace(): synchronous. Stack mutates inside the call;screen_changedfires before the call returns; the shell's_on_screen_changedhandler swaps the visible screen viaenter/leavesynchronously. No frame-boundary coherence issues.- Stack ownership: each
ImplantAppinstance owns exactly oneImplantNavStack. No shared nav state across apps. Cross-app navigation goes through Intents (below), not through a shared stack. - Popping an empty stack: no-op; log a warning. Preserves the invariant that a visible app always has at least one screen.
Inter-app navigation — Intents (seam only)
Economics → Atlas linking is the first real case. Today it's handled by a direct signal economics_link_requested(planet_id) wired in main.gd. Hardcoded routing between named apps is fatal for modded apps — a modded atlas replacement or a modded economics replacement can't receive the signal.
The target pattern:
# emitted by any app's shell:
signal intent_requested(target_app: String, action: String, params: Dictionary)
# economics app emits:
intent_requested.emit("implant/map", "focus_planet", {"planet_id": id})
An intents dispatcher (sibling of ImplantRegistry) routes the intent to the target app's shell, calling handle_intent(action, params) which the registered target app overrides.
This sprint: keep the existing economics_link_requested wiring. The base class exposes handle_intent as a no-op hook so subclasses don't need rewiring once the dispatcher lands. Refactor to Intents is mechanical and can happen in a small follow-up PR.
Data channels — seam flagged
Modded apps will want to subscribe to simulation data: observer snapshots, entity events, region-filtered feeds, periodic economics rollups. Today, apps reach into GameState and SnapshotConsumers directly.
This sprint: no change. Apps continue to use the direct path.
Future seam: a DataChannels autoload apps subscribe to by channel name, with manifest-declared requirements:
# in app.tres
@export var subscribed_channels: Array[String] = ["snapshot/observer", "economics/rollup"]
The registry wires subscriptions on on_install. Modded apps get their data through a documented API rather than poking core globals. Not implemented this sprint — flagged so the base-class API doesn't accidentally close the door. Specifically: on_install is the right hook for channel subscription; on_close is not where channels get unsubscribed (an app stays subscribed while hidden). Binding subscription lifetime to app instance lifetime, not visibility, is the right shape.
D-169 component library — keep, hands off
ImplantPanel, ImplantHeader, ImplantSeparator, ImplantDataRow, ImplantTextBlock are already modder-friendly — pure Control nodes styled via default_implant.tres. Modded apps compose with them exactly like in-tree apps.
Flagged risk: if any primitive grows a dependency on a specific in-tree data model (e.g. ImplantDataRow taking a CurrencyAmount-typed parameter instead of a generic string), moddability regresses. Rule: keep primitives data-shape agnostic. Adapters live in each app, not in the shared library.
Directory layout
client/ui/implant/
default_implant.tres # D-169 theme
implant_panel.gd # D-169 primitives — unchanged
implant_header.gd
implant_separator.gd
implant_data_row.gd
implant_text_block.gd
implant_theme.gd
implant_app.gd # NEW — base class
implant_nav_stack.gd # NEW — nav helper
implant_app_manifest.gd # NEW — manifest resource class
implant_registry.gd # NEW — autoload; scans apps/ at startup
widgets/ # shared cross-app widgets
# (future: reach_map_widget.gd once extracted from atlas)
apps/
atlas/
app.tres # manifest: app_path=implant/map, default_key=KEY_M
atlas_app.gd # extends ImplantApp — owns nav + shared header
atlas_app.tscn # root scene
atlas_marker_overlay.gd # moved from implant/
atlas_overlay_bar.gd
atlas_viewer.gd
screens/
reach_screen.gd # plain Control; enter/leave; emits nav signals
system_screen.gd
planet_screen.gd
regional_screen.gd
economics/
app.tres # manifest: app_path=implant/economics, default_key=KEY_N
economics_app.gd # extends ImplantApp
economics_app.tscn
screens/
overview_screen.gd # currently the only screen; keep the directory shape
Autoload order: implant_registry must scan before main.gd queries manifests for key routing. Manifest scan is lazy-on-first-read to sidestep the class_name autoload parse-order trap documented in CLAUDE.md.
Phasing
Landed — Sprint 36 #844 and review rounds
implant_app.gd,implant_nav_stack.gd,implant_app_manifest.gd— new classesimplant_registry.gd— autoload; lazy-scansapps/*/app.treson firstget_manifests()call, exposesget_manifests(),get_resolved_mode(),instantiate_all(parent),get_app_instance(app_path)- Manifest
schema_versionfield withImplantRegistry.CURRENT_SCHEMA_VERSION = 1; tiered warn/backfill/proceed behavior scene_pathconsumed:ImplantRegistry.instantiate_all($AppsContainer)called fromhud.gd._ready().hud.tscnno longer direct-instances apps — holds only theAppsContainerplacement node.main.gdlooks up app instances viaImplantRegistry.get_app_instance(app_path).main.gdkey routing replaced with registry-driven loop;KEY_M/KEY_Nliterals removedImplantAppbase owns_screens/_current_screen_id;register_screen(id, screen)+current_screen_id()+ default_on_screen_changedwithhas_methodtolerance and same-screen-replace detection- Atlas: split into
apps/atlas/withatlas_app.gd+ per-screenControls.Levelenum replaced by nav-stack screen ids.on_installis construct → setup → wire →register_screenper screen. - Economics: relocated to
apps/economics/economics_app.gd, refactored toextends ImplantApp;on_installreduced to three lines (construct overview_screen, register_screen, nav.set_default) - Both apps ship
schema_version = 1manifests SnapshotConsumerscallson_insert_deactivated()uniformly; the base's default closes the app when active in INSERT mode- Legacy
implant/map/starchartHudGroups path deleted hud.gd: single_ready()call toImplantRegistry.instantiate_all($AppsContainer)seeds all registered apps; Godot's bottom-up_readyordering guaranteesmain.gdfinds them when it looks up byapp_path
Follow-up (not this sprint, not blockers)
- Intents dispatcher — replace
economics_link_requestedwith genericintent_requestedrouting. Mechanical refactor. DataChannelsautoload — manifest-declared snapshot subscriptions. Seam already reserved viaon_installlifecycle position.- Launcher UI — once a fourth implant app exists, a chooser becomes necessary. Until then, key bindings suffice.
- Settings-UI key remapping — resolves key-binding collisions beyond first-wins.
handle_global_keylifecycle hook — newImplantAppoverride; shells handle intra-app keys (e.g. economics[/]system navigation). Main.gd routes unhandled keydown to the active app instead of hardcoding per-app bindings.
Deferred (Phase 6+, not sprint-scoped)
- Shipped-build mod discovery (pck mounting, manifest signing, trust model)
- Scripting API surface — what mods can safely call into core
- Mod registry UI — enable/disable, conflict resolution, version compatibility
Review checklist — for future PRs adding a new implant app
- App directory lives under
client/ui/implant/apps/{name}/withapp.tres,{name}_app.gd,{name}_app.tscn - Manifest includes
schema_version: int = 1(or higher if manifest schema has bumped) - Manifest
default_modeis one of"gameplay","insert","fullscreen" - Manifest
app_pathis unique;default_keyeither -1 or non-colliding - App script
extends ImplantApp; its_ready()assignsmanifest = load("res://ui/implant/apps/{name}/app.tres")then callssuper._ready() on_installconstructs andregister_screens each screen; setsnav.set_default("...")once. No directadd_childon screens; no.visible =on screens; no_current_screen_idfield.- Screens are plain
Controls. They optionally defineenter(payload)/leave(). They do not touchHudGroups,APP_PATH, orapp_changed. - Nothing in
hud.tscndirect-instances the new app. Discovery happens via the registry. - No
KEY_*literals inmain.gdtied to the new app — rely ondefault_keyin the manifest. - Cross-app wiring (if any) stays on a named signal today; migrate to
handle_intentwhen Intents dispatcher lands.
Risks / open items
- Manifest scan load cost. Cheap in-tree but becomes load-time-variable once modded apps enter the mix. Flag for profiling once registered apps > ~10.
- Autoload parse-order.
implant_registrymust deferclass_name ImplantAppManifestresolution to firstget_manifests()call rather than_ready(). SeeCLAUDE.md"Autoload parse-order rule." - Manifest validation. An invalid
app.tres(missing fields, bad paths) in a modded app must not crash startup. Warn, skip, continue. - Scene-vs-script coupling. The manifest points at
scene_path; the shell script is loaded from the scene's root node. If a modder ships a scene with the wrong root script, failures should be diagnosable. Flagged for the eventual mod SDK docs.