Files
settled-reach/docs/architecture/implant-app-pattern.md
T
jpmschweitzer be9cfa2fca docs(architecture): specify implant app pattern for #844
Introduces the "faux mobile OS" framing: ImplantApp base class,
ImplantNavStack, ImplantAppManifest (app.tres), and ImplantRegistry
autoload. Moddability is a first-class design driver — apps are
droppable directories discovered at startup, main.gd key routing is
manifest-driven, and D-169 primitives stay data-shape agnostic.

Phasing: full pattern lands in the Sprint 36 atlas refactor PR
(#844); Intents dispatcher and DataChannels seam are sketched but
deferred; shipped-build mod discovery stays Phase 6+.

Includes review checklist for #844 and nav-stack edge cases.
2026-04-19 12:13:56 +02:00

18 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 app_path: String = ""          # unique HudGroups id, e.g. "implant/map"
@export var display_name: String = ""      # human-facing, e.g. "Atlas"
@export var icon_path: String = ""         # res:// path to icon texture
@export var scene_path: String = ""        # res:// path to the app's root scene
@export var default_mode: int = 2          # HudGroups.Mode.FULLSCREEN
@export var default_key: int = -1          # e.g. KEY_M; -1 = no default binding
@export var preserves_state: bool = true   # keep nav stack across close/open

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

signal app_opened(mode: int)
signal app_closed
signal insert_deactivated

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()

# --- 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: pass           # SnapshotConsumers calls this
func _on_screen_changed(_screen_id: String) -> void: pass  # override to swap visible screen
func handle_intent(_action: String, _params: Dictionary) -> void: pass  # future — Intents

What the subclass is responsible for:

  • Building its root scene (via scene_path in the manifest).
  • Registering screens with the nav stack.
  • Overriding _on_screen_changed to swap the visible screen.
  • Emitting app-specific signals (e.g. economics_link_requested today, intent_requested later).

What the subclass is not responsible for:

  • HudGroups wiring. Ever.
  • visible management. Ever.
  • Anchor / mouse-filter boilerplate. Ever.

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. They expose:

func enter(payload: Dictionary) -> void      # called when pushed / re-surfaced
func leave() -> void                          # called when a new screen pushes on top / popped

Screens never touch HudGroups, APP_PATH, or app_changed. They only emit nav signals back to the shell — e.g. select_system(system_id) — and the shell translates to nav.push("system", {...}).

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

Lands this PR (#844)

  • implant_app.gd, implant_nav_stack.gd, implant_app_manifest.gd — new classes
  • implant_registry.gd — new autoload; scans apps/*/app.tres, exposes get_manifests()
  • main.gd key routing replaced with registry-driven loop; KEY_M / KEY_N literals removed
  • Atlas: split into apps/atlas/ with atlas_app.gd + per-screen Controls. Level enum removed in favor of nav-stack screen ids
  • Economics: relocated to apps/economics/economics_app.gd, refactored to extends ImplantApp
  • Both apps ship their app.tres manifests
  • SnapshotConsumers updated to call on_insert_deactivated() uniformly instead of per-panel hacks
  • Legacy implant/map/starchart HudGroups path deleted

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.

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 #844

Use while implementing; check during review.

  • Every cargo-culted snippet absorbed into ImplantApp: anchoring, visible = false, MOUSE_FILTER_STOP, HudGroups.register, app_changed filtering, set_insert_active, toggle_visible. No app shell re-implements any of these.
  • Atlas shell uses ImplantNavStack — no resurrected Level enum by another name.
  • Screens are plain Controls. They do not touch HudGroups, APP_PATH, or app_changed. They only emit nav signals to the shell.
  • SnapshotConsumers calls on_insert_deactivated() uniformly — no per-panel type checks.
  • Economics refactor is behaviorally identical — same economics_link_requested signal path, intra-app selection state preserved across close/reopen.
  • main.gd contains no KEY_M / KEY_N literals tied to specific apps — all routing via ImplantRegistry.get_manifests().
  • Both apps ship a valid app.tres with all required fields.
  • No app reaches into another app's internals — cross-app communication stays on the economics_link_requested seam (until Intents 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.