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.
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
- 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 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_pathin the manifest). - Registering screens with the nav stack.
- Overriding
_on_screen_changedto swap the visible screen. - Emitting app-specific signals (e.g.
economics_link_requestedtoday,intent_requestedlater).
What the subclass is not responsible for:
HudGroupswiring. Ever.visiblemanagement. 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()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
Lands this PR (#844)
implant_app.gd,implant_nav_stack.gd,implant_app_manifest.gd— new classesimplant_registry.gd— new autoload; scansapps/*/app.tres, exposesget_manifests()main.gdkey routing replaced with registry-driven loop;KEY_M/KEY_Nliterals removed- Atlas: split into
apps/atlas/withatlas_app.gd+ per-screenControls.Levelenum removed in favor of nav-stack screen ids - Economics: relocated to
apps/economics/economics_app.gd, refactored toextends ImplantApp - Both apps ship their
app.tresmanifests SnapshotConsumersupdated to callon_insert_deactivated()uniformly instead of per-panel hacks- Legacy
implant/map/starchartHudGroups path deleted
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.
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_changedfiltering,set_insert_active,toggle_visible. No app shell re-implements any of these. - Atlas shell uses
ImplantNavStack— no resurrectedLevelenum by another name. - Screens are plain
Controls. They do not touchHudGroups,APP_PATH, orapp_changed. They only emit nav signals to the shell. SnapshotConsumerscallson_insert_deactivated()uniformly — no per-panel type checks.- Economics refactor is behaviorally identical — same
economics_link_requestedsignal path, intra-app selection state preserved across close/reopen. main.gdcontains noKEY_M/KEY_Nliterals tied to specific apps — all routing viaImplantRegistry.get_manifests().- Both apps ship a valid
app.treswith all required fields. - No app reaches into another app's internals — cross-app communication stays on the
economics_link_requestedseam (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_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.