Files
settled-reach/docs/architecture/implant-app-pattern.md
T
jpmschweitzerandClaude Opus 4.6 522116fb12 docs: PR #131 review — lifecycle ordering contract (item 9)
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>
2026-04-19 15:26:31 +02:00

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

  1. Cargo-cult elimination. Every implant app currently re-implements ~30 LOC of boilerplate: anchoring, visible = false, MOUSE_FILTER_STOP, HudGroups.register, app_changed filtering, set_insert_active, toggle_visible. Drift between apps is already visible — the atlas and economics panel handle INSERT mode differently. Absorb all of it into a base class.
  2. 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.
  3. Intra-app navigation without ad-hoc enums. The atlas ships a Level enum 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 calling super._ready().
  • In on_install: constructing screens, calling setup/signal wiring on them, then register_screen(id, screen). Setting nav.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:

  • HudGroups wiring. Ever.
  • visible management (app-level or screen-level). Ever.
  • Anchor / mouse-filter boilerplate. Ever.
  • add_child on registered screens. register_screen handles parenting.
  • _current_screen_id bookkeeping. The base owns it.
  • Enter/leave dispatch. The default _on_screen_changed drives it; screens that need enter/leave implement those methods, the base invokes them via has_method tolerance.

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=falsereset_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() with preserves_state = false: stack cleared, default screen pushed. State-free apps never surprise the user with stale context.
  • open_app() with preserves_state = true, empty stack: default screen pushed. Typical first-ever-open case.
  • open_app() with preserves_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_changed fires before the call returns; the shell's _on_screen_changed handler swaps the visible screen via enter / leave synchronously. No frame-boundary coherence issues.
  • Stack ownership: each ImplantApp instance owns exactly one ImplantNavStack. 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 classes
  • implant_registry.gd — autoload; lazy-scans apps/*/app.tres on first get_manifests() call, exposes get_manifests(), get_resolved_mode(), instantiate_all(parent), get_app_instance(app_path)
  • Manifest schema_version field with ImplantRegistry.CURRENT_SCHEMA_VERSION = 1; tiered warn/backfill/proceed behavior
  • scene_path consumed: ImplantRegistry.instantiate_all($AppsContainer) called from hud.gd._ready(). hud.tscn no longer direct-instances apps — holds only the AppsContainer placement node. main.gd looks up app instances via ImplantRegistry.get_app_instance(app_path).
  • main.gd key routing replaced with registry-driven loop; KEY_M / KEY_N literals removed
  • ImplantApp base owns _screens / _current_screen_id; register_screen(id, screen) + current_screen_id() + default _on_screen_changed with has_method tolerance and same-screen-replace detection
  • Atlas: split into apps/atlas/ with atlas_app.gd + per-screen Controls. Level enum replaced by nav-stack screen ids. on_install is construct → setup → wire → register_screen per screen.
  • Economics: relocated to apps/economics/economics_app.gd, refactored to extends ImplantApp; on_install reduced to three lines (construct overview_screen, register_screen, nav.set_default)
  • Both apps ship schema_version = 1 manifests
  • SnapshotConsumers calls on_insert_deactivated() uniformly; the base's default closes the app when active in INSERT mode
  • Legacy implant/map/starchart HudGroups path deleted
  • hud.gd: single _ready() call to ImplantRegistry.instantiate_all($AppsContainer) seeds all registered apps; Godot's bottom-up _ready ordering guarantees main.gd finds them when it looks up by app_path

Follow-up (not this sprint, not blockers)

  • Intents dispatcher — replace economics_link_requested with generic intent_requested routing. Mechanical refactor.
  • DataChannels autoload — manifest-declared snapshot subscriptions. Seam already reserved via on_install lifecycle 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_key lifecycle hook — new ImplantApp override; 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}/ with app.tres, {name}_app.gd, {name}_app.tscn
  • Manifest includes schema_version: int = 1 (or higher if manifest schema has bumped)
  • Manifest default_mode is one of "gameplay", "insert", "fullscreen"
  • Manifest app_path is unique; default_key either -1 or non-colliding
  • App script extends ImplantApp; its _ready() assigns manifest = load("res://ui/implant/apps/{name}/app.tres") then calls super._ready()
  • on_install constructs and register_screens each screen; sets nav.set_default("...") once. No direct add_child on screens; no .visible = on screens; no _current_screen_id field.
  • Screens are plain Controls. They optionally define enter(payload) / leave(). They do not touch HudGroups, APP_PATH, or app_changed.
  • Nothing in hud.tscn direct-instances the new app. Discovery happens via the registry.
  • No KEY_* literals in main.gd tied to the new app — rely on default_key in the manifest.
  • Cross-app wiring (if any) stays on a named signal today; migrate to handle_intent when 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_registry must defer class_name ImplantAppManifest resolution to first get_manifests() call rather than _ready(). See CLAUDE.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.