Corecom.zoa.nucleon · v0.4.0

ZOA Nucleon

The player: composition, perception, movement modules, survival attributes, per-limb damage, and the authoring stack that builds it all.

Nucleon is the largest package in the product and the one you will spend the most time inside. It owns everything that constitutes a player: the rig hierarchy, the camera pivot chain, the movement module stack, the survival attribute model, per-limb damage routing, gated input subscription, the HUD scaffold, and the editor tooling that composes a working rig from a profile asset. When another package says it needs the player, it is asking Nucleon for one.

The architectural centre is a division that pays for itself everywhere: `PlayerController` owns per-frame spatial perception and the movement pipeline, and everything that changes how the player moves is an `IMovementModule` MonoBehaviour attached to the same rig. Jump, posture, lean, head bob, swim, climb, parkour, wall-run, dodge, click-to-move, edge-scroll, orbit camera, fog-of-war reveal, and squad movement are all modules. Which ones get attached is decided by a `ControllerProfileType` preset, so switching a project from a competitive FPS to a MOBA means selecting a different preset.

The second thing to internalise is that the runtime assembly and the editor assembly are doing very different jobs. The runtime is components, services, and events. The editor half, which is a third of the package, is a composition pipeline: `PlayerProfileFactory` is the single canonical builder every path funnels through, wrapped by headless authoring services for animator, statistics, HUD, weapons, and IK, and surfaced through the Player Control Workbench. A rig built headlessly by a scene generator and a rig built interactively by the wizard come out component-for-component equivalent, because both funnel through the same factory.

Nucleon depends on a lot because composition depends on a lot. It pulls in Armory for weapons, Inventory and Equipment for carried items, Combat for the damage contracts, Stats, Animation, EntityRig, AI, Audio, VFX, Input, Persistence, Messaging, and the networking abstractions. It does not depend on Gameplay: the composition layer above it spawns Nucleon rigs, never the other way around.

How it works

Concepts

Perception is batched once per frame, and modules must not probe

`PlayerController.PerceptionData` is the book of record for per-frame spatial state: the grounding result, a forward wall probe, an upward head-clearance probe, and player-relative left and right wall probes. It is populated once per frame by a single batched `JobHandle` completion that runs before any module's `HandleInput`, `UpdateModule`, or `ModifyMovement` call.

The rule attached to it is a hard one and the package states it as doctrine. No `IMovementModule` may run its own per-frame `Physics.Raycast`, `SphereCast`, or `OverlapSphere` for player-rig spatial queries. When a module needs a perception axis the standard set lacks, it extends the struct and the batch, never adds a sibling probe. The reason is throughput: three to five `RaycastCommand`s scheduled in one handle complete in roughly the same wall time as one, because Unity parallelises the batch, while a synchronous raycast per module multiplies linearly.

There is exactly one documented exception. A module may run a one-shot probe at the instant of a commit decision, such as the parkour module's downward find-the-ledge-top probe on the frame the vault button is pressed. These are input-gated and rare, not per-frame, and the cost is bounded.

Velocity has two channels so impulses survive locomotion

A naive controller that writes input-driven velocity every frame destroys any external impulse applied earlier in the same frame. Nucleon splits the state: an internal `_velocity` holding vertical motion plus the last input-driven planar locomotion, and a separate decaying external planar channel fed by wall jumps, knockback, and `ApplyImpulse`.

The public `Velocity` property exposes the combined planar velocity so existing callers read the true value, and its setter routes any planar component beyond the current input planar into the external channel. Both idioms then behave correctly: a read-modify-write of `Velocity`, which is how a normal jump preserves x and z, is idempotent, while a fresh planar set, which is how a wall jump works, becomes persistent momentum that decays.

Movement is a module stack chosen by a controller profile

`IMovementModule` has four hooks. `Initialize` binds the controller, `HandleInput` and `UpdateModule` run per frame, and `ModifyMovement(ref Vector3 horizontal, ref float vertical, ref bool handled)` is where the module actually changes motion. Setting `handled` to true stops the chain, which is how a climb or a vault takes exclusive control of a frame. `GetAffectedPivots` declares which transforms the module writes, so the authoring tooling can detect two modules fighting over one pivot.

`MovementModulePresets` is the catalog mapping each `ControllerProfileType` to the set of module type names enabled by default. It is pure data keyed by type-name strings rather than `System.Type`, so the interactive wizard and the headless factory read the same table with no reflection and no editor dependency. Adding a module and forgetting to extend the relevant preset means that module is off for fresh builds, which is the intended failure mode: a module opts in.

`MouseLookModule` is in every preset. The module is always attached; its initial enabled state comes from `ControllerProfile.MouseLookEnabledByDefault`, which the factory applies after the Cinemachine rig is built. FPS and third-person profiles start with look live; MOBA, RTS, top-down RPG, isometric, and twin-stick profiles attach it suppressed so the cursor drives the view. A runtime view switch then flips it without tearing down the player.

Profiles and views are two orthogonal axes

`ControllerProfileType` picks the feel: ImmersiveSurvival, CompetitiveFPS, TacticalShooter, ArcadeShooter, CinematicGamepad, Simulation, ThirdPersonAction, ThirdPersonAdventure, TopDownRPG, IsometricJRPG, TwinStickShooter, MOBA_RTS. It drives the module mix, mouse gain, max speed, jump force, step offset, slope limit, and whether the cursor starts unlocked.

`GameplayViewType` picks the framing: FirstPerson, ThirdPersonShoulder, ThirdPersonFollow, Orbital, TopDown, Isometric. `GameplayViewSwitcher` applies a view at runtime by re-posing the yaw pivot, pitch pivot, and follow target, and reshaping the camera's FOV and projection. It explicitly does not own the data: every per-view tuple lives in `GameplayViewFramingCatalog` so the edit-time factory and the runtime switcher can never disagree.

Separating the two axes makes a third-person tactical shooter or a first-person MOBA expressible without a combinatorial explosion of presets. `ControllerProfileCatalog` supplies the per-profile tuning; the framing catalog supplies the per-view pose.

Two pivot layouts, six transforms, one owner each

The factory builds one of two hierarchies. Capsule layout, chosen when no humanoid rig is supplied, produces the pivot chain CameraYawPivot, CameraPitchPivot, CameraHeadBobPivot, RecoilPivot, and a plain Camera plus CameraFollowTarget. Humanoid layout adds posture and lean pivots for a six-link chain of Yaw, Posture on the pelvis, Lean on the spine, Pitch on the head, HeadBob, Recoil, plus a reparented VisualRig root with Animator, RigBuilder, avatar resolution, and a master look target.

The discipline is that each layer has exactly one writer. `HeadBobModule` writes CameraHeadBobPivot's local position, the weapon controller's recoil writes RecoilPivot's local euler through `PlayerController.RecoilTransform`, and `MouseLookModule` writes CameraPitchPivot. Three layers, none competing for the same transform, which is why head bob and recoil compose instead of stomping each other.

Survival attributes are authored as data assets

A `SurvivalAttributeTypeSO` describes one attribute: display name, default and max value, regen and decay rates in units per second, a colour gradient, an icon, and regen and decay sounds. Twelve canonical packs ship (Nutrition, Hydration, BodyTemperature, Toxicity, Oxygen, Stamina, Health, Shields, Armor, Psionics, Mana, Encumbrance) and a project authors more with the same asset type.

`SurvivalAttributesManager` reads a `SurvivalAttributeTypeDatabase` at Awake and spawns one `AttributeDataProvider` child GameObject per listed type, so HUD, damage, save, and telemetry consumers each subscribe to the attribute they care about rather than to a monolithic health component. `RegisterTypeAtRuntime` adds a type after Awake, which is how a per-scene adorner layers on something like Sanity that the default database does not ship.

`SurvivalAttributeRegistry` gives two access surfaces over the same assets. Named accessors such as `SurvivalAttributeRegistry.Health` are compile-time convenience and are explicitly not exhaustive. `DiscoverAll` and `FindByName` materialise everything under `Resources/Survival/Attributes/`, which is the surface a downstream game with its own attributes should use. The discovery result is cached, so call `InvalidateDiscoveryCache` after authoring a pack rather than waiting for a domain reload.

Environment feeds the tick. `PlanetaryCriteria` is a ScriptableObject describing oxygen percentage, gravity relative to Earth, solar radiation, surface temperature, and water prevalence. It is an asset rather than a component so Earth, Mars, Moon, and Venus can be authored once and pushed onto every manager in a scene by `PlanetaryEnvironmentInstaller`. Cross-package consumers watch `AttributeChangedEvent` on the event bus rather than importing the provider.

Damage routes through one of two modes, chosen by an asset

`PlayerHealthMode` is Simple or PerLimb, and `PlayerHealthModeDefinition` is the asset that selects it. In Simple mode every hitbox region still applies its damage multiplier, but all damage merges into the single Health attribute on the survival manager, which is the classic FPS health bar. In PerLimb mode each `PlayerHitboxRegion` owns its own pool.

PerLimb has a specific rule worth knowing before you tune it. A limb whose pool reaches zero is blacked out, and a subsequent hit on an already-blacked limb is lethal: the system drains the global Health pool by a killing amount, so the death still flows through the normal `PlayerKilledEvent` path. The per-limb pools are gates; the global pool reports death. Region status is coarse and derived: Healthy above sixty percent, Damaged below it, Blackened at zero, which is what the silhouette HUD, weapon shake, and movement slowdown listeners react to.

The layout and the mode are authored as separate assets. `PlayerHitboxProfileDefinition` describes where the zones go, what shape they are, and their damage multipliers, with the bundled default shipping seven zones: Head at 2.0, Chest at 1.0, Abdomen at 0.85, both arms at 0.6, both legs at 0.7. Pairing one layout with both a sim mode and an arcade mode then costs two assets and one hitbox authoring.

Input is subscribed through a bus that owns the gate

Every input-handling component in Nucleon subscribes through `InputSubscriptionBus.Subscribe`, which attaches a wrapper to the `InputAction`, records the subscription, and returns an `IInputSubscription` you dispose in `OnDisable`. The wrapper evaluates the gate before forwarding, so handlers shrink to doing the thing with no boilerplate.

There are two layers of gate. The local one is `IInputGated.ShouldHandleInput`, the consumer's own assessment of its applicability: click-to-move is gated on having a NavMeshAgent and being in a top-down view, squad movement on being the leader with at least one follower, parkour on an affordance currently being detected. No outside observer can compute those correctly, which is why the gate lives on the consumer. The global one is `IInputContext`, which reports whether a modal panel is open or a cutscene is running; when it says input is off, every gated subscription is suppressed. When no context is registered the bus behaves as if the context is open, so a project that never wires it gets the old behaviour.

The payoff is diagnostic. Because the bus counts both fired and gated invocations per subscription, the Input Subscriptions window can tell you that a particular action was gated forty-seven times last session, which is how you catch a gate predicate that is too aggressive. Note that the interface is for the single-gate case: a component with different gates per handler, like the weapon controller whose firing gates on having a weapon while slot cycling must work unarmed, uses per-handler early returns instead.

The factory is the single composition path

`PlayerProfileFactory.Build` is the one canonical rig builder, shared by the Player Control Workbench, the scene generators, the headless `PlayerAuthoringService`, and the standalone prefab builder in the Gameplay package. It absorbs every step that used to be duplicated in the wizard's configurator pipeline, which is what guarantees that a generated rig and a wizard-built rig are capability-equivalent down to the component level.

Every step is an opt-in flag on `Options`: the Cinemachine rig, movement modules, player audio, footstep manager, saveable entity, settings runtime, body yaw synchroniser, interactor, animation awareness rig, visual effects bridge, autowire, and hitbox authoring. Defaults produce a playable, wizard-grade rig so `new Options()` just works, and a headless test opts out of the pieces it owns.

Asset resolution follows a documented priority chain. A field is filled from the caller's explicit `Options` value first, then the per-call `PlayerProfileDefinition`, then the project `FoundryPlayerDefaults` default profile, then that defaults asset's per-category fallbacks, then the registrar's active defaults, and only then a hardcoded back-compat path that logs a warning telling you to author a defaults asset. A project overrides the whole chain by registering a higher-priority `FoundryPlayerDefaults` with `ZOAPlayerDefaultsRegistrar`.

Parkour moves are discovered providers

`ParkourModule` is a coordinator. The actual moves are `IParkourMoveProvider` implementations discovered at Awake, each carrying a `ParkourMoveDefinition` asset for its tuning and an `Order` for affordance arbitration, by convention 10s for vault, 20s for mantle, 30s for wall-run, and 100 and above for a project move that should beat the built-ins.

Each frame the coordinator asks every provider to classify the current perception into a `ParkourAffordance`, a readonly struct carrying the winning provider, the affordance kind, the hit point, the projected landing point, and the hit collider. The landing point is there so a project can render a target indicator before the player commits. On input the coordinator routes execution back to the provider that produced the affordance, then ticks it until the move reports completion.

The doctrine applies here as strictly as anywhere: `TryProbeAffordance` is a pure read of perception, and the one-shot probe inside `TryExecute` is the documented exception. A cross-package projection of the affordance goes out on the bus without the provider reference, so a HUD prompt can subscribe without calling back into the parkour stack.

In the editor

Screens

Screenshot pending

/screenshots/nucleon-player-control-workbench.png

The wizard window with the tab rail down the left, a built player rig selected, the hierarchy tab showing the pivot chain with per-pivot status, and the Problems counter in the header reading zero errors and a small warning count.

The Player Control Workbench on the Hierarchy tab.

Screenshot pending

/screenshots/nucleon-input-subscriptions.png

The Input Subscriptions window in play mode listing eight to twelve rows with action name, phase, owner component, fired and gated counts, at least one row showing a high gated count, and the Scene Gates foldout expanded below.

Live input subscriptions with gate state and counters.

Screenshot pending

/screenshots/nucleon-survival-attributes-inspector.png

Hierarchy showing the player root with several provider child objects (HealthProvider, StaminaProvider, HydrationProvider), and the inspector showing the manager with its type database and a planetary criteria asset assigned.

A SurvivalAttributesManager and its spawned providers.

Screenshot pending

/screenshots/nucleon-limb-damage-hud.png

Game view with the silhouette HUD visible, one limb rendered in the Damaged state and one Blackened, alongside the global health bar, so the two-tier PerLimb model reads at a glance.

Per-limb damage state in play mode.

Setup

Workflow

  1. 01

    Author the project defaults once

    Create a FoundryPlayerDefaults asset and point it at a default PlayerProfileDefinition plus per-category fallbacks for model, animator controller, and sound profile. Register it with ZOAPlayerDefaultsRegistrar at a priority above the bundled installer's. Everything downstream inherits from this, and the factory stops logging its author-a-defaults-asset warning.

  2. 02

    Author a player profile per character

    Create a PlayerProfileDefinition and fill only the fields that differ from the project default: the model prefab, the weapon mount bone, the animator controller, the controller profile, the starting view, and the capsule dimensions. Null means inherit, so a profile stays small.

  3. 03

    Build the rig

    Open the Player Control Workbench and walk the tabs, or call PlayerProfileFactory.Build headlessly with an Options carrying the profile. Supply Options.HumanoidRig for the six-pivot humanoid layout, or leave it null for the capsule layout used by tests and lightweight pawns. The result carries the rig root and, in humanoid layout, the visual rig root.

  4. 04

    Choose the survival model

    Run the ensure-packs and ensure-database menu items to get the twelve canonical attribute packs and a default database, then attach a SurvivalAttributesManager pointed at the database your character should carry. Author custom attributes through the Attribute Type Wizard or the custom-attribute window; they land under Resources/Survival/Attributes and become discoverable through the registry.

  5. 05

    Choose the damage model

    Run the ensure-hitbox-profile and ensure-health-mode menu items to get the bundled seven-zone profile and the default mode asset. Clone them to retune. Add PlayerHitboxHealthSystem to the rig root for PerLimb routing, or leave the health mode on Simple if a single health bar is the design.

  6. 06

    Wire input through the bus

    Subscribe every InputAction through InputSubscriptionBus rather than direct plus-equals, passing the owning component as the gate when one predicate covers everything it does. Dispose the returned handle in OnDisable. Add InputContextSceneInstaller so a pause menu or a cutscene can suppress gameplay input globally without every component learning about menus.

  7. 07

    Extending the movement stack

    New behaviour is a MovementModuleBase subclass reading PerceptionData rather than casting, declaring the pivots it writes through GetAffectedPivots, and setting movementHandled only when it owns the frame outright. Add its type name to the relevant entries in MovementModulePresets, otherwise it defaults to off for every fresh build.

  8. 08

    Validate before you ship the rig

    Run the validation service, or the Workbench's Problems view, which aggregates every applicable tab's issues into one annotated list. Use the Ownership Audit panel to catch direct-control drift, meaning components that bypass the module stack and write the rig's transforms behind the controller's back.

Surface

Key types

PlayerController

component

The rig's centre. Owns batched perception, the two-channel velocity model, the module list, and the pivot transforms modules write to. Runs at execution order -900 and requires a CharacterController and a PlayerInput.

  • PerceptionData Perception { get; }
  • Vector3 Velocity { get; set; }
  • bool IsGrounded, IsTouchingGround, IsSprinting
  • float SlopeAngle, CurrentSpeed, MaxRunSpeed, CoyoteTime
  • Transform YawTransform, PitchTransform, EyePivot, RecoilTransform
  • GameplayViewType View; ControllerProfileType Profile
  • IReadOnlyList<IMovementModule> Modules
  • bool RegisterMovementModule(IMovementModule module, bool initialize = true)
  • void ApplyImpulse(Vector3 impulse); void SetView(GameplayViewType view)
  • void RequestScriptedDisplacement(Vector3 worldDelta); void RequestScriptedMoveTo(Vector3 worldPosition)

PlayerController.PerceptionData

struct

The per-frame spatial snapshot every module reads instead of casting. Extending this struct and the batch that fills it is the sanctioned way to add a new spatial axis.

  • GroundingResult Ground
  • RaycastHitResult WallFront, HeadObstacle, WallLeft, WallRight

IPlayerControlPlane

interface

The read-write control surface a system drives the player through without touching the controller's internals. Inputs and tuning are settable; velocity, grounding, sprint state, and perception are read-only.

  • Vector2 MoveInput { get; set; }; Vector2 LookInput { get; set; }
  • bool JumpInput { get; set; }; bool SprintInput { get; set; }
  • float MaxSpeed { get; set; }; float JumpForce { get; set; }
  • Vector3 Velocity { get; }; float HorizontalSpeed { get; }; bool IsGrounded, IsSprinting
  • PlayerController.PerceptionData Perception { get; }
  • void ApplyImpulse(Vector3 impulse)

IMovementModule

interface

One behaviour in the movement stack. ModifyMovement is where a module changes motion; setting movementHandled true claims the frame exclusively and stops the chain.

  • void Initialize(PlayerController controller)
  • void HandleInput(); void UpdateModule()
  • void ModifyMovement(ref Vector3 horizontalMove, ref float verticalVelocity, ref bool movementHandled)
  • IEnumerable<PivotInfo> GetAffectedPivots()

MovementModuleBase

component

The MonoBehaviour base every shipped module derives from. Supplies the controller reference, virtual no-op hooks, camera and view-transform resolution that prefers the rig's own camera over Camera.main, and contributor deregistration on destroy.

  • PlayerController Controller { get; }
  • protected Camera ResolvePlayerCamera()
  • protected Transform ResolvePlayerViewTransform()

ControllerProfileType

enum

Twelve feel presets: ImmersiveSurvival, CompetitiveFPS, TacticalShooter, ArcadeShooter, CinematicGamepad, Simulation, ThirdPersonAction, ThirdPersonAdventure, TopDownRPG, IsometricJRPG, TwinStickShooter, MOBA_RTS. Drives the module mix and the tuning.

GameplayViewType

enum

Six framings: FirstPerson, ThirdPersonShoulder, ThirdPersonFollow, Orbital, TopDown, Isometric. Orthogonal to the controller profile, so a third-person tactical shooter is one combination rather than a new preset.

MovementModulePresets

class

The single table mapping a controller profile to the movement modules enabled by default, held as type-name strings so it stays pure data with no reflection and no editor dependency. The wizard and the headless factory read the same entries.

ControllerProfile

class

The tuning payload behind a profile type: mouse gain, delta-time cancellation, FOV, max speed, jump force, CharacterController step offset and slope limit, whether the cursor starts unlocked, and whether mouse look starts enabled.

  • float MouseGain, CameraFOV, MaxSpeed, JumpForce
  • float StepOffset (default 0.4), SlopeLimit (default 45)
  • bool CancelDeltaTime, RequiresUnlockedCursor, MouseLookEnabledByDefault

GameplayViewSwitcher

component

Applies a view to the camera rig at runtime by re-posing yaw, pitch, and follow-target pivots and reshaping FOV and projection. It applies framing only; controller tuning stays on PlayerController.

ZOAPlayerRootMarker

component

The identity stamp on a player rig root: local slot index, local versus remote kind, and peer id for remotes. Zero runtime cost, no update loop. Present so the Workbench and the spawn service identify a rig unambiguously instead of name-matching, and so death events can attribute a kill to the right peer.

  • ZOA/Player/Player Root Marker
  • int LocalSlotIndex; ZOAPlayerKind PlayerKind; bool IsLocal, IsRemote; string PeerId
  • void SeedIdentity(int localSlotIndex, ZOAPlayerKind kind, string peerId = null, string displayLabel = null)

PlayerContextBehaviour

component

Publishes the rig as IPlayerContext through FoundryServiceRegistry on enable, so anything asking who the player is resolves a service instead of calling FindWithTag. The camera is resolved lazily so a rig spawned later in bootstrap is still discovered.

  • ZOA/Player/Player Context
  • GameObject PlayerRoot { get; }; Camera PlayerCamera { get; }
  • event Action<IPlayerContext> Changed

SurvivalAttributesManager

component

Reads an attribute type database and spawns one AttributeDataProvider child per type, then ticks regen and decay against the environment. Consumers subscribe per attribute.

  • SurvivalAttributeTypeDatabase TypeDB; PlanetaryCriteria Environment
  • IAttributeDataProvider GetProvider(SurvivalAttributeTypeSO type)
  • IAttributeDataProvider RegisterTypeAtRuntime(SurvivalAttributeTypeSO type)
  • event Action<SurvivalAttribute> OnAttributeChanged

SurvivalAttributeRegistry

class

Static lookup over attribute assets under Resources/Survival/Attributes. Named accessors cover the twelve canonical packs; DiscoverAll and FindByName are the extensibility surface a project with its own attributes should use.

  • static SurvivalAttributeTypeSO Health, Stamina, Oxygen, Shields, Armor, Mana, Psionics, Nutrition, Hydration, BodyTemperature, Toxicity, Encumbrance
  • static IReadOnlyList<SurvivalAttributeTypeSO> DiscoverAll()
  • static void InvalidateDiscoveryCache()

IAttributeDataProvider

interface

One attribute's live value. This is what a HUD bar, a damage handler, or a save contributor binds to, and its change event is what AttributeChangedEvent projects onto the bus.

  • SurvivalAttributeTypeSO Type { get; set; }
  • float CurrentValue { get; set; }; float MaxValue { get; }
  • event Action<float, float> OnValueChanged

IAttributeModule

interface

A per-attribute tick behaviour: regeneration, environmental drain, a project-specific curve. Attached to a SurvivalAttribute with AddModule and ticked with the current planetary criteria.

  • void Initialize(SurvivalAttribute attribute)
  • void Tick(SurvivalAttribute attribute, PlanetaryCriteria env, float deltaTime)

PlayerHitboxRegion

component

One zone on the rig with three jobs: multiply incoming damage, own a per-limb pool in PerLimb mode, and broadcast the Healthy, Damaged, and Blackened transitions the silhouette HUD and the limb-effect listeners consume. ApplyLimbDamage returns the overflow past zero, which is what decides whether the next hit is a finisher.

  • ZOA/Nucleon/Player Hitbox Region
  • string RegionName; float DamageMultiplier, MaxHealth, CurrentHealth; bool IsBlackedOut
  • PlayerLimbStatus Status
  • float ApplyLimbDamage(float damage, out bool wasAlreadyBlackedOut)
  • void Heal(float amount); void Configure(string region, float multiplier); void ConfigureHealth(float max)
  • event Action<PlayerHitboxRegion, float, float> HealthChanged; event Action<PlayerHitboxRegion> BlackedOut

PlayerHitboxHealthSystem

component

The router that reads the health-mode asset at Awake and decides where damage goes. In Simple mode it is dormant and damage lands on the survival manager; in PerLimb mode it looks up the region by the damage context's hit-region name, drains that pool, and applies the lethal-overflow rule. It never owns total health on its own.

  • ZOA/Nucleon/Player Hitbox Health System
  • PlayerHealthMode CurrentMode

PlayerKilledEvent

struct

Published on the event bus on the alive-to-dead transition, carrying victim, instigator, and both peer ids resolved from the root markers. The death predicate is encoded once on the publisher, so subscribers never reason about attribute shapes. The publisher fires once per transition; subscribers should still handle repeats defensively.

  • GameObject Victim, Instigator
  • string VictimPeerId, InstigatorPeerId

PlayerDamagedEvent

struct

Published on every non-zero damage tick, after the pool is mutated so the HUD sees it the same frame. Carries the applied amount plus hit point and incoming direction for directional indicators, both falling back to zero when the originator supplied neither.

  • GameObject Victim, Instigator; float AmountApplied
  • Vector3 HitPoint, IncomingDirection
  • string VictimPeerId, InstigatorPeerId

InputSubscriptionBus

class

Central registry and gate wrapper for InputAction subscriptions. Owns the plus-equals and minus-equals discipline, evaluates both the local gate and the global input context before forwarding, and counts fired versus gated invocations for the diagnostics window.

  • static IInputSubscription Subscribe(InputAction action, InputPhase phase, Action<InputAction.CallbackContext> handler, IInputGated gate = null, Object owner = null, string handlerLabel = null)
  • static IInputSubscription SubscribePerformedAndCanceled(InputAction action, Action<InputAction.CallbackContext> performed, Action<InputAction.CallbackContext> canceled, IInputGated gate = null, Object owner = null, string handlerLabel = null)
  • static IReadOnlyList<IInputSubscription> ActiveSubscriptions { get; }
  • static void CopyActiveSubscriptions(List<IInputSubscription> destination)

IInputGated

interface

A component's own answer to whether its input handlers should run. Keep the property cheap: it is evaluated on every callback. Use it when one gate covers everything the component does, and per-handler early returns when it does not.

  • bool ShouldHandleInput { get; }

IInputContext

interface

Project-wide ambient input state: the active profile and view, whether a modal is open, whether a cutscene is running, and the composite verdict the bus consults. Absent registration the bus behaves as if input is enabled.

  • ControllerProfileType Profile { get; }; GameplayViewType View { get; }
  • bool IsModalOpen { get; }; bool IsCutsceneActive { get; }; bool IsPlayerInputEnabled { get; }
  • event Action Changed

IParkourMoveProvider

interface

One parkour move as a plug-in. Probe classifies perception into an affordance, Execute commits, Tick advances until the move completes. Order arbitrates when several providers report on the same frame.

  • ParkourMoveDefinition Definition { get; }; int Order { get; }
  • bool TryProbeAffordance(in PlayerController.PerceptionData perception, Transform playerTransform, out ParkourAffordance affordance)
  • bool TryExecute(PlayerController controller, in ParkourAffordance affordance)
  • void Tick(PlayerController controller, float deltaTime, ref bool stillRunning)

IGameplayDirector

service

A gameplay-neutral session state machine over registered subsystems, owning the Idle, Loading, Playing, Paused, Ended phases and guaranteeing callback ordering. It does not run a tick loop; subsystems drive their own updates.

  • GameplayPhase Phase { get; }; float PhaseTime { get; }
  • void RegisterSubsystem(IGameplaySubsystem subsystem); void UnregisterSubsystem(IGameplaySubsystem subsystem)
  • bool Begin()
  • event Action<GameplayPhase, GameplayPhase> OnPhaseChanged

IGameplaySubsystem

interface

One participant in a director's session. OnInitialize returning false keeps the director in Loading and publishes a failure event without unregistering the subsystem, so a single failure does not short-circuit the rest and the author can recover and retry.

  • bool OnInitialize(IGameplayDirector director)
  • void OnStart(); void OnEnd()
  • void OnPhaseChanged(GameplayPhase from, GameplayPhase to)

IWeaponHost / IWeaponController

interface

The two halves of the weapon seam. The host supplies the mount transform and aim camera and reacts to an equip; the controller exposes firing and aiming state and the verbs that drive it. Anything can be a host: player, AI, turret.

  • Transform WeaponMount { get; }; Camera AimCamera { get; }
  • void SetAiming(bool aiming); void OnWeaponEquipped(IWeaponController wpn)
  • bool IsFiring { get; }; bool IsAiming { get; }
  • void StartFiring(); void StopFiring(); void StartAiming(); void StopAiming(); void Reload(); void TriggerHitReaction()

PlayerWeaponController

component

The player's weapon host. Binds an equip service and a loadout service, drives recoil through the rig's recoil pivot, and reports diagnostic state about the last auto-equip attempt so a failed spawn loadout is explainable rather than silent.

  • ZOA/Nucleon/Player Weapon Controller
  • bool IsAiming, IsFiring, IsUnarmedBlocking
  • bool HasBoundEquipService, HasRegisteredLoadoutHost
  • bool LastAutoEquipAttempted, LastAutoEquipSucceeded
  • void BindEquipService(IWeaponEquipService service); void BindLoadoutService(IWeaponLoadoutService service)
  • event Action<bool> AimingChanged; event Action<EquippedWeaponHandle> WeaponChanged; event Action<PlayerWeaponController> OnDryFire

PlayerProfileFactory

class

The single canonical rig builder, editor-only because it uses AssetDatabase, TypeCache, and the model importer. Every path that produces a Foundry player rig goes through it, so generated and wizard-built rigs come out equivalent.

  • static BuildResult Build(ControllerProfileType profile, GameplayViewType view)
  • static BuildResult Build(ControllerProfileType profile, GameplayViewType view, Options options)
  • Options.HumanoidRig selects humanoid layout; null selects capsule layout
  • BuildResult.Root, BuildResult.VisualRigRoot

IPlayerAuthoringService

service

The headless entry point to rig authoring, used by generators, automation, and tests. Implementations must return a rig functionally equivalent to Workbench output so a headless build can be opened in the wizard afterwards without surprises.

  • PlayerAuthoringResult Author(PlayerAuthoringRequest request)

IPlayerValidationService

service

Aggregates every applicable wizard tab's validation into one answer, so a generator, a test, and the wizard UI agree on whether a rig is well-formed. Non-applicable tabs are skipped rather than counted as passing.

  • PlayerValidationSummary Validate(GameObject target)
  • (int completedSteps, int totalSteps) GetProgress(GameObject target)
  • IReadOnlyList<string> GetAllIssues(GameObject target)

LocalMultiplayerDirector

component

Orchestrates a local split-screen session over up to four pre-spawned pawns. It does not compose the rigs: callers spawn them through the factory and wire their PlayerInput components in. It clones the action asset per pawn, disables auto control-scheme switching, pairs each pawn explicitly, and publishes join and drop on the event bus.

  • ZOA/Nucleon/Local Multiplayer Director

Surface

Authoring assets

PlayerProfileDefinition

asset

One character as an asset: model prefab, avatar override, weapon mount bone and offsets, animator controller, sound profile, controller profile, default view, default weapon pack and index, and capsule dimensions. Every slot is nullable, and null means fall back to the project defaults.

  • ZOA/Foundry/Player/Player Profile
  • GameObject ModelPrefab; Avatar AvatarOverride; string WeaponMountBoneName
  • RuntimeAnimatorController AnimatorController; SoundProfileDefinition SoundProfile
  • ControllerProfileType ControllerProfile; GameplayViewType DefaultView
  • string DefaultWeaponPackId; int DefaultWeaponIndex
  • float CapsuleHeight (default 1.85), CapsuleRadius (default 0.4)

FoundryPlayerDefaults

asset

The project-level defaults bag: a default player profile plus per-category fallbacks for model, animator controller, and sound profile. Registered by the bundled installer at editor load; override the project by registering a higher-priority instance with ZOAPlayerDefaultsRegistrar.

  • ZOA/Foundry/Project/Player Defaults

SurvivalAttributeTypeSO

asset

One attribute's definition: display name, default and max value, regen and decay rates in units per second, colour gradient, icon, and regen and decay sounds. The unit of extensibility for the whole survival model.

  • ZOA/Survival/Attribute Type

SurvivalAttributeTypeDatabase

asset

The list of attribute types a character tracks. The survival manager spawns one provider per entry at Awake, so this asset is effectively the character's stat sheet.

  • ZOA/Survival/Attribute Type Database

PlanetaryCriteria

asset

A per-planet environmental descriptor driving the attribute tick: oxygen percentage, an Earth-oxygen baseline for scaling, gravity relative to Earth, solar radiation, surface temperature in Celsius, and water prevalence. Defaults describe Earth; designers duplicate and tweak.

  • ZOA/Survival/Planetary Criteria

PlayerHealthModeDefinition

asset

Selects Simple or PerLimb routing and carries the per-limb pool sizes. Authored separately from the hitbox profile so one zone layout can pair with a simulation mode and an arcade mode without duplicating the zone authoring.

  • ZOA/Nucleon/Player Health Mode

PlayerHitboxProfileDefinition

asset

The zone layout the hitbox builder stamps onto a rig: per zone a target humanoid bone with a positional fallback for non-humanoid rigs, a collider shape and dimensions, and a damage multiplier. The bundled default ships seven zones from Head at 2.0 down to arms at 0.6.

  • ZOA/Nucleon/Player Hitbox Profile

ParkourMoveDefinition

asset

Tuning and presentation for one parkour provider: a stable move id, display name and icon for the prompt, which perception axis the move reads, the affordance kind reported on the bus, and the height band the move applies to.

  • ZOA/Movement/Parkour Move
  • ZOA/Movement/Parkour Move Catalog

RigContract

asset

Declares what an animation rig must provide: an avatar requirement mode, required humanoid bones, and named slots for rig layers, IK targets, IK hints, and twist chains, each with aliases. Compatibility profiles let one contract match several vendors' naming by matching tokens in the root name.

  • ZOA/Nucleon/Full Body/Rig Contract
  • Canonical slots: FootRig, AimRig, LeftFootTarget, RightFootTarget, LeftFootHint, RightFootHint, SpineTwist

WaveDefinition

asset

A data-driven wave schedule: an ordered list of waves, each with a spawn interval, an optional boss flag and boss agent id, and spawn entries pairing a neutral agent-type-id string with a count. Strings, so the package knows nothing about what a grunt actually is.

  • ZOA/Gameplay/Wave Definition

WaveEscalationPolicy

asset

The pluggable difficulty curve. Two hooks, ScaleSpawnInterval and ScaleSpawnCount, called on every per-wave read, so swapping the policy asset changes wave parameters with no controller change. No policy assigned means identity pass-through; Linear and Scaled variants ship.

  • ZOA/Gameplay/Linear Wave Escalation Policy
  • ZOA/Gameplay/Scaled Wave Escalation Policy

ScoringProfile

asset

Maps neutral agent-type-id strings to per-kill score values with a default for unlisted ids. Consumed by ScoreTracker when a kill is registered without an explicit override, and knows nothing about actual agent types.

  • ZOA/Gameplay/Scoring Profile

SurfaceRegistry

asset

Maps Unity PhysicsMaterial assets to ZOA SurfaceMaterial assets, building a dictionary on load for fast per-footstep lookup. This is how the grounding result turns into the right footstep sound and impact effect.

  • ZOA/Surface Registry
  • ZOA/Environment/Surface Material

HUDLayoutProfile

asset

Named screen regions stored as normalised rects keyed by the UXML name attribute, so a HUD layout is repositionable data rather than hardcoded style. Its sibling HUD Attribute Layout Profile does the same for attribute bars.

  • ZOA/UI Toolkit/HUD Layout Profile
  • ZOA/UI Toolkit/HUD Attribute Layout Profile

Attribute pickup packs

component

Eleven ready-made pickups under ZOA/Survival/Pickups, one per canonical attribute: Health, Stamina, Armor, Shields, Nutrition, Hydration, Oxygen, Body Temperature, Toxicity, Mana, and Psionics. Each is an AttributePickup preconfigured against its registry entry in Reset.

InputContextSceneInstaller

component

Publishes an IInputContext through FoundryServiceRegistry so the subscription bus has a global gate to consult. Without it the bus treats the context as open, which is the pre-existing behaviour.

  • ZOA/Nucleon/Input Context Scene Installer

PlanetaryEnvironmentInstaller

component

Pushes one PlanetaryCriteria asset onto every SurvivalAttributesManager in the active scene, so a level's climate is set in one place rather than per character.

  • ZOA/Survival/Planetary Environment Installer

Usage

Examples

A movement module that reads perception instead of castingcsharp
using UnityEngine;
using ZOA.Nucleon.Player;
using ZOA.Nucleon.Player.Movement;

[AddComponentMenu("Game/Movement Modules/Ledge Grab")]
public sealed class LedgeGrabModule : MovementModuleBase
{
    [SerializeField] private float grabPullSpeed = 3f;

    private bool _hanging;

    public override void UpdateModule()
    {
        // DOCTRINE: read the batched perception; never run a per-frame
        // Physics.Raycast of your own for player-rig spatial queries.
        var p = controller.Perception;
        _hanging = !controller.IsGrounded
                   && p.WallFront.HasHit
                   && !p.HeadObstacle.HasHit;
    }

    public override void ModifyMovement(ref Vector3 horizontal, ref float vertical, ref bool handled)
    {
        if (!_hanging) return;

        horizontal = Vector3.zero;
        vertical = grabPullSpeed;

        // Claim the frame: no module after this one gets a say.
        handled = true;
    }
}
Setting handled true is exclusive. Use it only when the module owns the frame outright, as a climb or vault does.
Subscribing input through the bus with a gatecsharp
using UnityEngine;
using UnityEngine.InputSystem;
using ZOA.Nucleon.Input.Gating;

public sealed class CommandWheelInput : MonoBehaviour, IInputGated
{
    [SerializeField] private InputActionReference wheelAction;

    private IInputSubscription _sub;

    // The component's own assessment. Keep it cheap: it runs on every callback.
    public bool ShouldHandleInput => _squad != null && _squad.FollowerCount > 0;

    private void OnEnable()
    {
        _sub = InputSubscriptionBus.Subscribe(
            wheelAction.action,
            InputPhase.Performed,
            OnWheelOpened,
            gate: this,
            owner: this);
    }

    private void OnDisable()
    {
        _sub?.Dispose();
        _sub = null;
    }

    // No gate boilerplate here: the bus already refused the call when the
    // gate or the global IInputContext said no, and counted it as gated.
    private void OnWheelOpened(InputAction.CallbackContext _) => OpenWheel();

    private SquadCoordinator _squad;
    private void OpenWheel() { /* ... */ }
}
Reading and mutating a survival attributecsharp
using UnityEngine;
using ZOA.Nucleon.Systems.Survival;

public sealed class ThirstMeter : MonoBehaviour
{
    [SerializeField] private SurvivalAttributesManager attributes;

    private IAttributeDataProvider _hydration;

    private void OnEnable()
    {
        // Named accessors cover the twelve canonical packs; a project
        // attribute resolves through FindByName or DiscoverAll instead.
        _hydration = attributes.GetProvider(SurvivalAttributeRegistry.Hydration);
        if (_hydration != null)
            _hydration.OnValueChanged += OnHydrationChanged;
    }

    private void OnDisable()
    {
        if (_hydration != null)
            _hydration.OnValueChanged -= OnHydrationChanged;
    }

    private void OnHydrationChanged(float current, float max) => Redraw(current / max);

    public void Drink(float amount)
    {
        if (_hydration == null) return;
        _hydration.CurrentValue = Mathf.Min(_hydration.MaxValue, _hydration.CurrentValue + amount);
    }

    private void Redraw(float normalized) { /* ... */ }
}
Registering an attribute the default database does not shipcsharp
using ZOA.Nucleon.Systems.Survival;

// A per-scene adorner layering a Sanity attribute onto the spawned rig.
public void AddSanity(SurvivalAttributesManager attributes, SurvivalAttributeTypeSO sanityType)
{
    // Idempotent: returns the existing provider when the type is already
    // registered. The manager spawns a provider child and the Update loop
    // picks it up on the next frame.
    var provider = attributes.RegisterTypeAtRuntime(sanityType);

    // The registry caches its discovery scan; invalidate after authoring.
    SurvivalAttributeRegistry.InvalidateDiscoveryCache();
}
Reacting to player death across packagescsharp
using System;
using UnityEngine;
using ZOA.Messaging;
using ZOA.Nucleon.Combat;

public sealed class DefeatWatcher : MonoBehaviour
{
    private IDisposable _killed;
    private IDisposable _damaged;

    private void OnEnable()
    {
        var bus = FoundryServiceRegistry.Get<IFoundryEventBus>();
        _killed = bus.Subscribe<PlayerKilledEvent>(OnKilled);
        _damaged = bus.Subscribe<PlayerDamagedEvent>(OnDamaged);
    }

    private void OnDisable()
    {
        _killed?.Dispose();
        _damaged?.Dispose();
    }

    // The publisher fires once per alive-to-dead transition, but handle a
    // repeat defensively: a respawn can cross zero again in the same frame.
    private void OnKilled(PlayerKilledEvent evt) => ShowDefeat(evt.InstigatorPeerId);

    // Instigator is null for environmental causes: fall back to the
    // incoming direction rather than dereferencing it.
    private void OnDamaged(PlayerDamagedEvent evt)
    {
        var from = evt.Instigator != null
            ? (evt.Instigator.transform.position - transform.position).normalized
            : -evt.IncomingDirection;

        PointArrow(from, evt.AmountApplied);
    }

    private void ShowDefeat(string killerPeerId) { }
    private void PointArrow(Vector3 direction, float amount) { }
}
Building a rig headlesslycsharp
#if UNITY_EDITOR
using ZOA.Nucleon.GamePlay;
using ZOA.Nucleon.Tools.Editor.Factories;

// Capsule layout: HumanoidRig left null. Defaults already produce a
// playable, wizard-grade rig, so only the deviations are set here.
var options = new PlayerProfileFactory.Options
{
    Name = "TestPawn",
    CapsuleHeight = 1.8f,
    AttachCinemachineRig = false,   // the test owns its own camera
    RunAutowire = false,
};

var result = PlayerProfileFactory.Build(
    ControllerProfileType.CompetitiveFPS,
    GameplayViewType.FirstPerson,
    options);

var rig = result.Root;
#endif
Every build step is an opt-in flag, so a headless caller drops the pieces it owns without forking the factory.
A parkour providercsharp
using UnityEngine;
using ZOA.Nucleon.Player;
using ZOA.Nucleon.Player.Movement.Parkour;

public sealed class SlideUnderProvider : MonoBehaviour, IParkourMoveProvider
{
    [SerializeField] private ParkourMoveDefinition definition;

    public ParkourMoveDefinition Definition => definition;

    // Above the built-ins (vault 10s, mantle 20s, wall-run 30s).
    public int Order => 120;

    // PURE READ. No physics probes here.
    public bool TryProbeAffordance(
        in PlayerController.PerceptionData perception,
        Transform playerTransform,
        out ParkourAffordance affordance)
    {
        affordance = ParkourAffordance.None;

        if (!perception.HeadObstacle.HasHit || !perception.Ground.IsStableGrounded)
            return false;

        var hit = perception.HeadObstacle.Hit.point;
        affordance = new ParkourAffordance(
            this,
            ParkourAffordanceKind.None,
            hit,
            landingPoint: playerTransform.position + playerTransform.forward * 2.5f,
            perception.HeadObstacle.Collider);

        return true;
    }

    // A one-shot probe IS permitted here: input-triggered, not per-frame.
    public bool TryExecute(PlayerController controller, in ParkourAffordance affordance) => true;

    public void Tick(PlayerController controller, float deltaTime, ref bool stillRunning) { }
}
Registering a subsystem with the gameplay directorcsharp
using ZOA.Nucleon.GamePlay;

public sealed class AmbientMusicSubsystem : IGameplaySubsystem
{
    private IGameplayDirector _director;

    // Returning false keeps the director in Loading and publishes a
    // failure event. The subsystem is NOT unregistered, so you can fix
    // the condition and re-attempt without rebuilding the list.
    public bool OnInitialize(IGameplayDirector director)
    {
        _director = director;
        return TryLoadPlaylist();
    }

    public void OnStart() => Play();
    public void OnEnd() => Stop();

    // Fires for every transition including pause. OnStart is NOT re-fired
    // when resuming, so resume handling belongs here.
    public void OnPhaseChanged(GameplayPhase from, GameplayPhase to)
    {
        if (to == GameplayPhase.Paused) Duck();
        else if (from == GameplayPhase.Paused) Unduck();
    }

    private bool TryLoadPlaylist() => true;
    private void Play() { }
    private void Stop() { }
    private void Duck() { }
    private void Unduck() { }
}

Tooling

Editor tools

Player Control Workbench

Tools > ZOA > Advanced > Build > Characters > Player > Player Control Workbench

The unified player wizard and the package's centre of gravity. Roughly two dozen tabs cover hierarchy, camera, animator, IK setup, posture animation, hitboxes, physics, stats, HUD, loadout, sockets, input, NavMesh, surfaces, visual effects, squad presentation, T-pose baking, avatar retargeting, rig validation, scene auditing, integrations, and a master check. Every tab reports validation and progress through the same contract, which is why the aggregate answer is trustworthy.

Nucleon Workbench module

ZOA Workbench > Player & Camera

Registered at order 100 under the workflow.character lane, surfacing sixteen capabilities: the Player Control wizard, the ownership audit, standalone prefab building, the loadout composer, the animator wizard, FPS arms and full-body routing, the first-person mesh split, full-body migration, the humanoid avatar creator, shared-skeleton remapping, attribute type and custom attribute authoring, audio mixer repair, the HUD composer, input subscription diagnostics, hierarchy comparison, and the SVG converter.

Ownership Audit

ZOA Workbench > Player & Camera > Ownership Audit

Audits which wizard owns which part of a rig, inventories the movement modules present, and flags direct-control drift where a component writes the rig's transforms behind the controller's back. This is the tool that catches the class of bug the module stack exists to prevent.

Input Subscriptions diagnostics

Tools > ZOA > Advanced > Validate > Diagnostics > Input Subscriptions

Lists every live InputSubscriptionBus registration with its action, phase, owner, handler label, gate, and the fired and gated counters. The Scene Gates foldout walks the IInputGated implementors and shows each one's live predicate value, which is how you find the gate that is silently swallowing an action.

Bundled asset installers

Tools > ZOA > Advanced > Generate > Characters > Player

Idempotent ensure-menu items for the pieces a project needs before it can build a rig: the canonical survival attribute packs, the default attribute database, the seven-zone hitbox profile, the default health mode, the rig contract, and the bundled player defaults. Each detects existing assets and skips them, so local edits survive a re-run.

Rigging and mesh tools

Tools > ZOA > Advanced > Build > Characters > Rigging

Humanoid Avatar Creator and its batch processor generate and tune avatar mappings; the Shared Skeleton Wizard remaps arms and body meshes onto one skeleton for modular first- and third-person setups; the First Person Body Mesh Split Wizard cuts a full-body SkinnedMeshRenderer into arms, head-only, and body-without-head renderers that share a single skeleton.

Survival and HUD authoring

Tools > ZOA > Advanced > Define and Build

The Attribute Type Wizard and the custom-attribute window author SurvivalAttributeTypeSO packs with validation and HUD colour setup. The HUD Layout Editor positions named regions as normalised rects. Create Default Planetary Presets emits the environment assets the survival tick reads.

Hierarchy printers and comparison

GameObject > ZOA > Print Hierarchy; Tools > ZOA > Advanced > Maintain > Utilities

Context-menu printers dump a hierarchy, a parent chain, a skeleton with bone transforms, or a single transform in local and world space, and the Hierarchy Comparison wizard diffs two roots structurally. Unglamorous and constantly useful when a rig built one way does not match a rig built another.

Read this

Notes and caveats

See also