Corecom.zoa.armory · v0.4.0

ZOA Armory

Fire modes, delivery, ballistics, recoil, heat, and reload: the machinery that makes a weapon operate.

Weapon Modding knows what a weapon is made of. Armory knows what it does when you pull the trigger. The division holds all the way down: Armory takes a weapon instance and an operation profile and produces a state machine that tracks rounds, heat, jams, charge, and reload, plus a set of delivery handlers that turn a shot into rays, projectiles, or beams in the world.

Six small ScriptableObjects carry the operational data, and they compose by reference instead of inheriting. A fire mode describes cadence. A delivery describes geometry. A ballistic profile describes what happens to a round in flight. Recoil and reload profiles describe feel and timing. A weapon operation profile aggregates those references and adds the heat and jam model on top, and a binding asset attaches that profile to a weapon or to a whole platform. Swapping one sub-profile changes one axis of behaviour without touching the rest.

At the centre is ArmoryWeaponRuntime: a pure C# state machine, one per equipped weapon, deterministic when you hand it a seed. It does not know where in the world the weapon is. It consumes shots, accumulates heat, runs the reload clock, and raises events; the delivery handlers own the spatial half. The separation keeps the runtime testable in edit mode and keeps replays and networked clients in step.

Around that core sit the services: an equip service that spawns a model under a host's mount and hands back a live handle, a loadout service that stows and draws weapons through an inventory while preserving magazine and attachment state, an attachment swap service, and a pack registry. All four register into the Foundry service registry at scene boot, which is how a player controller consumes them without Armory and Nucleon ever referencing each other.

How it works

Concepts

Fire mode is cadence; delivery is geometry

These are two independent axes and conflating them is the most common way to author a weapon that behaves strangely. FireModeDefinition answers how often and how many: Single, Burst, Auto, Continuous, Charge, or Scatter, plus burst count, projectiles per shot, minimum interval between shots, spread angle, charge time, and whether the trigger has to be released between bursts. FireDeliveryDefinition answers how the damage travels: Hitscan, Projectile, or Beam, plus prefab, damage layers, speed, max distance, beam duration, impact force, and whether rounds pierce.

A weapon carries a list of supported fire modes and a default index, so a rifle can offer single and auto and the runtime cycles between them. Delivery is singular, because a weapon that shoots hitscan in one mode and projectiles in another is a different weapon.

Scatter as a fire mode and a pellet pattern as an asset cover overlapping ground. Projectiles-per-shot on the fire mode is the simple form; attaching a PelletPatternDefinition to the operation profile is the real one, because that is where the distance-growing cone and per-pellet falloff live.

The runtime is a state machine you tick

ArmoryWeaponRuntime holds an ArmoryWeaponState struct: magazine rounds, chambered flag, jammed, reloading, unjamming, heat, selected fire mode index, charging, charge percent, and overheated. Everything the runtime does is a transition on that struct plus an event.

TryConsumeShot is the core call and it is honest about failure. CanFire gates on jam, reload, overheat, unjam, a chambered round, and a valid delivery definition, and the refusal comes back as a sentence you can put on screen. Past that gate the runtime rolls against the heat-adjusted jam chance, and a jam is a distinct outcome from a refusal: the shot was attempted, the weapon is now jammed, and the method still returns false.

Three tick methods drive the clocks: TickCooling dissipates heat and clears overheat lockout, TickReload advances the reload timer and auto-finishes, and TickCharge accumulates charge. The owning controller calls them every frame. TickReload in particular exists because the alternative, a coroutine in the integration layer, left weapons stuck claiming to reload forever when a caller started a reload and never followed up.

The runtime takes an optional seed, and with one supplied the jam rolls are reproducible. It also carries a restoration constructor that adopts a previously captured state snapshot, and ApplyStateSnapshot for save-load and network reconciliation, where ammo, heat, jam, reload, charge, and fire mode have to be restored as one coherent value rather than replayed as partial events.

Heat degrades the weapon before it locks it

The usual heat model is a bar that fills and locks the weapon. Armory adds five knobs on top of that, and together they turn sustained fire into a judgement about when to stop.

Cooling does not start the instant you stop shooting: CoolingDelayAfterShot holds it off briefly so tapping the trigger does not reset the bar. Lockout does not clear at zero either; CooldownThresholdPercent is where the weapon becomes usable again, which by default is halfway down. Between those, HeatFireRatePenaltyStartPercent marks where thermal slowdown begins, ramping toward OverheatedFireRateMultiplier at maximum heat, so a hot weapon fires visibly slower before it stops entirely.

Three more multipliers blend in with heat rather than switching at a threshold: spread widens, recoil grows, and jam chance rises, each toward its authored value at maximum. The runtime exposes them as GetHeatSpreadMultiplier, GetHeatRecoilMultiplier, and GetHeatAdjustedJamChance so the firing path can fold them into the shot it is about to take.

Reload is a guarded state machine with an ammo economy

StartReload refuses to restart an in-progress reload. Without that guard a held or mashed reload key zeroes the elapsed counter every frame, so TickReload never reaches the duration and the weapon is locked in a perpetual reload while re-firing the magazine-eject sound each frame.

It also honours the tactical-reload flag. When the profile forbids tactical reloads, as a bolt-action or shell-fed weapon would, topping off a partial magazine is refused outright and the reload only starts once the magazine is empty. Whether the reload also has to chamber a round is captured at start rather than recomputed, so the duration stays stable for the whole cycle.

FinishReload is reserve-aware but only when you opt in. With no IAmmoReserveSource attached, a reload fills the magazine for free, which is the historical behaviour and keeps existing content working. Attach a source and the runtime computes how many rounds the refill actually needed, draws that many from reserve, and trims the magazine, then the chamber, by whatever the reserve could not fund.

Recoil follows a seeded pattern

RecoilHandler accumulates vertical and horizontal offset per shot and recovers them over time, but the interesting part is what shapes the climb. Kick grows per consecutive shot in a firing string, so the fifth round in a burst climbs harder than the first, and the string resets after a short memory window rather than instantly.

The lateral component blends a deterministic pattern against per-shot jitter. PatternPeriodShots sets the length of one pattern cycle and PatternRandomness blends between following it exactly and rolling freely, with HorizontalBias pulling the whole thing one way for an asymmetric weapon. The jitter runs on a private seeded System.Random rather than the global Unity RNG, because the global stream is shared with every other system and basing recoil on it made replays and networked clients diverge. SeedPattern re-seeds it for per-weapon or per-client synchronisation.

Recovery is two-stage. RecoveryDelay holds the peak briefly so the kick is felt before it starts unwinding, then linear recovery and an exponential settle term run together, with maximum offsets clamping how far the climb can accumulate before recovery wins.

Ballistics model velocity, energy, and falloff

A BallisticProfileDefinition can be as simple as muzzle velocity, effective range, and impact damage. Past that it models the parts of exterior ballistics that change how a weapon reads: a ballistic coefficient and drag coefficient for velocity retention, projectile mass in grams for real kinetic energy, a damage falloff exponent and a minimum damage fraction so a round has a floor rather than decaying to nothing, a transonic instability velocity with a spread penalty for rounds that go unstable as they slow, and a wind drift scale.

BallisticsCalculator implements the maths as static functions, and its overloads are layered so the simple call still works. The three-argument damage-at-range call preserves the original inverse-distance curve; the five-argument one adds the floor and exponent; the profile-taking one uses everything authored. Alongside those sit muzzle and at-range energy, velocity retention, drag acceleration, wind drift, penetration energy cost, trajectory prediction, flight time, and drop.

Penetration composes with the Combat package rather than duplicating it. The profile's armour penetration is the power a round starts with, PenetrationProperties on each target declares what passing through it costs, and the delivery layer runs one deduct-and-continue loop over the two.

Pellets are distributed, not rolled

PelletPatternDefinition attaches to an operation profile and turns one trigger pull into a set of rays. The cone half-angle at impact is a base spread plus a growth per metre, clamped at a maximum, which is what separates a tight choke that stays lethal at medium range from a scattergun that is a close-quarters weapon by construction.

Damage falls off per pellet on its own curve: full damage inside one distance, a linear ramp to a minimum fraction at another, and a floor beyond that. For hitscan this is pure attenuation; for real projectiles the same curve doubles as the velocity-decay surface.

PelletConeSampler decides where each pellet actually goes, using a sunflower spiral, a golden-angle spiral mapped onto the disk by square-root radius, plus a small per-shot jitter. Independent random rolls leave visible empty arcs in roughly one shot in three at typical pellet counts, and a stratified grid reads as machine-perfect; the spiral covers the cone evenly without looking regular.

Attachment stats reach the runtime through the stats kernel

ArmoryWeaponRuntime implements IStatModifierSource. It builds a WeaponStatCalculator over the weapon's installed attachments, converts what comes back into StatModifier values, and evaluates them against a StatBlock seeded from the operation profile's authored numbers.

That evaluation produces a set of Effective properties and GetEffective methods that the firing path reads instead of the raw profile: EffectiveMaxHeat, EffectiveHeatPerShot, EffectiveCoolingPerSecond, EffectiveJamChancePerShot, and GetEffectiveMuzzleVelocity, GetEffectiveRange, GetEffectiveImpactDamage, GetEffectiveSpreadAngle, GetEffectiveReloadDuration, GetEffectiveMagazineCapacity, and so on.

Nothing in Armory knows what an attachment is beyond that. Weapon Modding decides that a compensator emits a recoil multiplier of 0.85, the stats kernel decides how modifiers fold, and Armory only asks what the number is now. An attachment authored for a rifle therefore needs no Armory-side registration.

Services register at scene boot, not by reference

ArmoryRuntime is a single scene component that registers five services into the Foundry service registry during Awake: the equip service, the pack registry, the attachment swap service, the loadout service, and the presentation event stream. It runs at execution order -9500 so consumers resolving in Start find them ready.

Registration is idempotent and reversible. Each Ensure step reuses a pre-registered instance rather than stomping it, which keeps an additive scene load or a third-party override intact, and OnDestroy unregisters only the instances this host actually created.

That indirection exists for a dependency rule: Nucleon's player controller must be able to drive weapons, and Armory must not reference Nucleon. Resolving IWeaponEquipService from the registry satisfies both. The same pattern lets an attachment pickup in another package swap a scope without importing Armory internals.

Firing publishes on two channels

A successful shot raises the local OnFired event and calls every registered IArmoryEventListener, which is how the HUD attached to this weapon updates. It then resolves IFoundryEventBus and publishes a WeaponFiredEvent carrying the weapon's instance and definition ids, which is how audio, analytics, AI, and networking react without importing an Armory type.

The bus resolution is resolve-and-forget: Armory must not hard-require Messaging host setup in order to fire, so a missing bus is silently fine. WeaponFiredEvent carries no origin or direction, because the runtime holds no world position for the shot. Delivery handlers own that and publish ProjectileHitEvent with the resolved spatial context.

A third stream sits alongside for presentation. IWeaponPresentationEventStream is the production source for muzzle VFX, shot audio, and camera impulse; the delivery handlers publish to it and handlers in other packages subscribe. WeaponShotEventBus and DamageFlowBus are diagnostics only, feeding the debug HUDs, and a bridge mirrors a subset across so the debug surfaces keep working.

In the editor

Screens

Screenshot pending

/screenshots/armory-operation-profile-composer.png

The Workbench window with Armory selected and the Operation Profile Composer capability active, showing the fire mode list, the delivery, ballistic, recoil, and reload reference fields populated, and the heat and jam fields grouped below.

The Operation Profile Composer in the ZOA Workbench.

Screenshot pending

/screenshots/armory-weapon-wizard.png

The wizard shell with the eight-step rail on the left, the mount points step showing a scanned socket list with types and relative paths, the preview dock summarising the draft on the right, and a clean issue tray.

The Weapon Wizard after scanning a model's mount points.

Screenshot pending

/screenshots/armory-weapon-model-bundles.png

The Workbench bundle browser listing several sample bundles with their weapon counts and import state, one expanded to show the fire mode, ballistic, recoil, and reload assets it would create, with the import button visible.

The Weapon Model Bundles panel.

Screenshot pending

/screenshots/armory-weapon-state-debug-hud.png

Game view with the F2 diagnostics panel open in the top-right, showing the active weapon's magazine and heat readouts, the last shot's ballistic values, and the damage-flow list with a pierce chain of two hits sharing one shot id.

The Weapon State Debug HUD in play mode.

Setup

Workflow

  1. 01

    Install the runtime into the scene

    Add an ArmoryRuntime component to a scene root, or run the Armory scene installer, which is idempotent and finds an existing one before creating another. Awake registers the equip service, pack registry, attachment swap service, loadout service, and presentation stream. Nothing else in the package resolves until that has happened.

  2. 02

    Author the sub-profiles, then the aggregate

    Create the fire modes a weapon supports, one delivery, one ballistic profile, one recoil profile, and one reload profile, then a WeaponOperationProfileDefinition that references them and fills in the jam and heat model. The Operation Profile Composer in the Workbench does this composition step in a single panel.

  3. 03

    Bind the profile to a weapon

    Create a WeaponArmoryBindingDefinition pointing at the weapon and the profile, or at a platform when a whole family should share it. ArmoryProfileResolver matches the exact weapon first and falls back to the platform, so a family-level binding plus one weapon-level override is a supported shape.

  4. 04

    Equip through the service

    Build a WeaponEquipRequest with the definition, the profile, and a host that exposes a weapon mount and an aim camera, then call TryEquip. The service spawns the model, scans it for mount sockets, applies the preset's attachments, constructs the runtime, and hands back a handle. Supply a random seed when determinism matters.

  5. 05

    Drive the runtime from your controller

    Tick cooling, reload, and charge every frame, call TryConsumeShot on trigger input, and route a success into the delivery handler that matches the profile's delivery kind. Read the effective stats rather than the raw profile numbers so attachments are accounted for. Subscribe to the runtime's events for HUD, audio, and animation.

  6. 06

    Add stowage when the player carries more than one

    Register the host with the loadout service and an IWeaponStowage backend, then use TryPickup, TrySwitchActive, and TryStowActive instead of calling the equip service directly. Each transition snapshots the outgoing weapon onto its item payload and restores the incoming one, so magazine, heat, and attachments round-trip exactly.

  7. 07

    Ship content as packs

    Group weapons into a WeaponPackDefinition with a dotted slug id and register it, either from a catalog pass or by serialising it onto the ArmoryRuntime component so a scene ships with its pack pre-registered. The package's own sample bundles, thirty-odd weapon packs from ancient arms to space opera, install through the Package Manager samples list.

Surface

Key types

ArmoryWeaponRuntime

class

One per equipped weapon. The state machine for fire mode selection, ammunition, heat, jams, charge, and reload, deterministic when constructed with a seed and restorable from a state snapshot.

  • ArmoryWeaponRuntime(WeaponInstance weaponInstance, WeaponOperationProfileDefinition operationProfile, int? seed = null, GameDatabase database = null)
  • bool CanFire(); bool TryConsumeShot(out string reason)
  • void TickCooling(float dt); void TickReload(float dt); void TickCharge(float dt)
  • void StartReload(); void FinishReload(); bool CancelReload()
  • void StartUnjam(); void FinishUnjam(); void ClearJam()
  • void StartCharge(); float ReleaseCharge(); void CancelCharge()
  • bool TryCycleFireMode(int direction); bool TrySetFireMode(int index); FireModeDefinition SelectedFireMode
  • float HeatPercent, EffectiveMaxHeat, EffectiveHeatPerShot, EffectiveJamChancePerShot
  • float GetHeatFireRateMultiplier(), GetHeatSpreadMultiplier(), GetHeatRecoilMultiplier()
  • float GetEffectiveImpactDamage(float), GetEffectiveSpreadAngle(float), GetEffectiveReloadDuration(float)
  • void SetReserveSource(IAmmoReserveSource source)
  • void ApplyStateSnapshot(in ArmoryWeaponState snapshot, bool publishEvents = true)
  • void AddListener(IArmoryEventListener listener); void RemoveListener(...)
  • events OnFired, OnJammed, OnAmmoChanged, OnReloadStart, OnReloadComplete, OnOverheat, OnCooledDown, OnHeatChanged, OnChargeStart/Complete/Fire/Cancel, OnFireModeSwitched

ArmoryWeaponState

struct

The serializable snapshot the runtime mutates: rounds, chamber, jam, reload, heat, fire mode, charge, overheat, unjam. Save and network layers move this struct whole.

  • int magazineRounds; bool chamberLoaded, jammed, reloading, unjamming, overheated
  • float heat; int selectedFireModeIndex; bool charging; float chargePercent

IArmoryEventListener

interface

Listener form of the runtime's events, with default empty implementations on every method so an implementer overrides only what it cares about. Registered on the runtime alongside, not instead of, the C# events.

  • void OnFired(FiredEventData data); void OnJammed(); void OnHit(HitEventData data)
  • void OnAmmoChanged(...); void OnReloadStart(...); void OnReloadComplete(...); void OnChamberLoaded()
  • void OnOverheat(...); void OnCooledDown(...); void OnHeatChanged(...)

WeaponFiredEvent

struct

The cross-package fire event on the Foundry bus. Unlike the local FiredEventData it carries the weapon's instance and definition ids, so a subscriber in another package can correlate without holding the runtime.

  • InstanceId WeaponInstanceId; DefinitionId WeaponDefinitionId
  • int FireModeIndex, MagazineRoundsRemaining; bool ChamberLoaded; float HeatAfterShot

ProjectileHitEvent

struct

Published by the delivery handlers when a shot connects. Positions are float triples rather than Vector3 so the struct lives in the engine-free Core assembly.

  • InstanceId WeaponInstanceId; DefinitionId WeaponDefinitionId
  • HitPointX/Y/Z, HitNormalX/Y/Z; float Distance, Damage, Force; bool Pierced

IWeaponEquipService

service

The single call site that turns a definition, a profile, and a host into a live weapon: spawns the model under the host's mount, scans it for sockets, builds the weapon instance, applies the preset, constructs the runtime, and returns a handle.

  • bool TryEquip(WeaponEquipRequest request, out EquippedWeaponHandle handle, out string reason)
  • bool Unequip(EquippedWeaponHandle handle)
  • event Action<EquippedWeaponHandle> WeaponEquipped, WeaponUnequipped

WeaponEquipRequest

class

Immutable equip request. Model prefab and existing model are mutually exclusive; supplying an existing ItemInstance makes a draw restore that weapon's exact state instead of minting a fresh one.

  • WeaponDefinition Definition; WeaponOperationProfileDefinition OperationProfile; IWeaponEquipHost Host
  • GameObject ModelPrefab | GameObject ExistingModel
  • WeaponPresetDefinition PresetOverride; WeaponPresentationDefinition PresentationOverride
  • int? RandomSeed; ItemInstance ExistingItemInstance
  • WeaponPresetDefinition EffectivePreset; WeaponPresentationDefinition EffectivePresentation

EquippedWeaponHandle

class

A plain DTO bundling everything needed to drive the equipped weapon: the runtime, the weapon instance and its attachment graph, the spawned model, the cached socket scan, and the effective presentation. Nothing ticks from the handle itself.

  • IWeaponEquipHost Host; WeaponDefinition Definition; WeaponOperationProfileDefinition OperationProfile
  • WeaponInstance Instance; ArmoryWeaponRuntime Runtime
  • GameObject Model; MountPointScanner.ScanResult Sockets
  • WeaponPresentationDefinition Presentation; bool IsUnequipped

IWeaponEquipHost

interface

A minimal host contract, declared here instead of imported from Nucleon so the dependency arrow never reverses. Two members: where to parent the model, and what defines the aim line.

  • Transform WeaponMount
  • Camera AimCamera

IWeaponLoadoutService

service

Owns what a host carries. Sits above the equip service and a stowage backend, answers what the host owns and which weapon is live, and provides the three mutating verbs. Every verb raises LoadoutChanged, including on refusal, so UI can simply re-read.

  • void RegisterHost(IWeaponEquipHost host, IWeaponStowage stowage); void UnregisterHost(...)
  • EquippedWeaponHandle GetActive(IWeaponEquipHost host)
  • IReadOnlyList<OwnedWeaponView> GetOwnedWeapons(IWeaponEquipHost host)
  • bool TryPickup(IWeaponEquipHost host, WeaponEquipRequest request, out EquippedWeaponHandle handle, out string reason)
  • bool TrySwitchActive(IWeaponEquipHost host, InstanceId targetItemId, out string reason)
  • bool TryStowActive(IWeaponEquipHost host, out string reason)
  • event Action<WeaponLoadoutChangedArgs> LoadoutChanged

IWeaponStowage

interface

Where stowed weapons live. The production adapter persists them in the player's inventory; tests and headless AI supply an in-memory one. Implementations must preserve the state payload reference and must refuse rather than clobber when capacity is exhausted.

  • bool TryStow(ItemInstance weaponItem, out string reason)
  • bool TryWithdraw(InstanceId weaponInstanceId, out ItemInstance weaponItem, out string reason)
  • bool TryGetItem(InstanceId weaponInstanceId, out ItemInstance weaponItem)
  • IEnumerable<ItemInstance> EnumerateWeapons(); event Action Changed

WeaponInstanceState

class

The item state payload a stowed weapon carries so it remembers its magazine, heat, jam and reload flags, fire mode, pinned operation profile, and installed attachments across a stow and draw cycle.

  • const string TypeId = "zoa.armory.weapon.v1"; int PayloadVersion
  • ArmoryWeaponState State; DefinitionId OperationProfileId
  • List<InstalledAttachmentRecord> InstalledAttachments

IAttachmentSwapService

service

Installs, swaps, and removes attachments on a live weapon: resolves the socket from the handle's cached scan, evicts whatever occupies it, instantiates the visual under the socket, and updates the attachment graph so stat modifiers follow.

  • bool TrySwapAttachment(AttachmentSwapRequest request, out AttachmentSwapResult result, out string reason)
  • bool TryRemoveAttachment(EquippedWeaponHandle weapon, string socketName, out AttachmentRemovalResult result, out string reason)
  • event Action<AttachmentSwapResult> AttachmentInstalled; event Action<AttachmentRemovalResult> AttachmentRemoved

IWeaponPackRegistry

service

Runtime lookup for weapon packs by their dotted slug id, so a package can ship a pack and self-register while consumers resolve by id rather than by asset reference or Resources.Load. Registration is idempotent by id.

  • bool Register(WeaponPackDefinition pack); bool Unregister(string packId)
  • event WeaponPackRegisteredEventHandler PackRegistered; event WeaponPackUnregisteredEventHandler PackUnregistered

ArmoryRuntime

component

The scene-root bootstrap. Registers the five services at execution order -9500, reuses anything already registered, unregisters only what it created, and can pre-register weapon packs serialised onto the component.

  • void EnsureRegistered(); void HandleDestroy()
  • IWeaponEquipService EquipService; IWeaponPackRegistry PackRegistry
  • IAttachmentSwapService SwapService; IWeaponLoadoutService LoadoutService
  • IWeaponPresentationEventStream PresentationStream

ArmoryProfileResolver

class

Finds the operation profile for a weapon by scanning binding assets in the game database, matching the weapon definition exactly first and falling back to its platform. Bindings are cached per database instance.

  • static WeaponOperationProfileDefinition Resolve(GameDatabase database, WeaponDefinition weaponDefinition)

BallisticsCalculator

class

Static exterior-ballistics maths. Overloads layer so the simple three-argument call keeps the original curve while the profile-taking form uses every authored knob.

  • static float CalculateDamageAtRange(float baseDamage, float distance, BallisticProfileDefinition profile, float fallbackEffectiveRange = 0f)
  • static float ApplyArmorPenetration(float baseDamage, float armorRating, float armorPenetration)
  • static float CalculateMuzzleEnergy(...); static float CalculateEnergyAtRange(...)
  • static float CalculateVelocityAtRange(...); static float CalculateDragAcceleration(...)
  • static Vector3 CalculateWindDrift(...); static float CalculatePenetrationEnergyCost(...)
  • static Vector3 PredictPosition(...); static float EstimateFlightTime(...); static float CalculateDrop(...)

HitscanDeliveryHandler

class

Instant ray delivery with optional piercing. The options overload adds a pre-fire line-of-sight gate, so an AI shooting at a target behind partial cover produces zero hits instead of accidentally shooting the wall, plus near-miss publishing for reaction systems.

  • static void Fire(Vector3 origin, Vector3 direction, FireDeliveryDefinition delivery, BallisticProfileDefinition ballistics, List<HitEventData> hits, InstanceId weaponInstanceId = default, DefinitionId weaponDefinitionId = default, GameObject owner = null)
  • static void Fire(..., HitscanFireOptions options, ...)
  • HitscanFireOptions — IntendedTarget, RequireLosToTarget, LosBlockingMask, NearMissRadius, NearMissLayerMask, ImpactClassId

ProjectileDeliveryHandler

class

Spawns physical projectiles at a muzzle with velocity and gravity, distributing them through a spread cone for scatter fire and stamping weapon identity and impact class onto each so hits correlate downstream.

  • static List<GameObject> Fire(Vector3 origin, Vector3 direction, FireDeliveryDefinition delivery, BallisticProfileDefinition ballistics, int projectilesPerShot = 1, float spreadAngle = 0f, Action<HitEventData> onHit = null, InstanceId weaponInstanceId = default, DefinitionId weaponDefinitionId = default, string impactClassId = null, GameObject owner = null)

BeamDeliveryHandler

class

Sustained beam delivery by continuous raycast over a duration, dealing damage per second and driving an optional LineRenderer. Tracks the last collider touched so a beam held on one wall spawns a single decal rather than one per tick.

  • void Start(FireDeliveryDefinition delivery, BallisticProfileDefinition ballistics); void Stop()
  • HitEventData? Tick(Vector3 origin, Vector3 direction, float deltaTime, LineRenderer lineRenderer = null)
  • bool IsActive; float Elapsed, Remaining
  • Action<HitEventData> OnHit; string ImpactClassId; GameObject Owner

RecoilHandler

class

One per weapon. Accumulates kick, grows it across a firing string, blends a deterministic lateral pattern against seeded jitter, and recovers through a delay plus linear and exponential terms.

  • void Configure(RecoilProfileDefinition profile, float recoilReduction = 0f)
  • void ApplyRecoilKick(float impulseMultiplier = 1f); void Tick(float deltaTime)
  • Vector2 GetCurrentOffset(); Vector3 GetCurrentImpulseOffset(); void ApplyToTransform(Transform target)
  • void SeedPattern(int seed); void Reset()
  • float VerticalOffset, HorizontalOffset, CameraImpulse, PushbackOffset

PelletConeSampler

class

Places each pellet inside a scatter cone using a golden-angle sunflower spiral plus per-shot jitter, which covers the cone evenly without the empty arcs of independent random rolls or the regularity of a grid.

  • static Vector3 Sample(Vector3 centerDirection, int pelletIndex, int pelletCount, float coneHalfAngleDegrees, bool addJitter = true)

MaterialPenetrationHandler

class

Evaluates whether a round punches through a surface or glances off it, using material density against projectile energy for penetration and impact angle against surface hardness for ricochet.

  • static PenetrationResult Evaluate(...)
  • PenetrationResult — penetrated, ricocheted, exitPoint, exitDirection, remainingEnergyFactor, hitData

IAmmoReserveSource

interface

Opt-in reserve pool for reloads. With no source attached the runtime reloads for free; with one, FinishReload draws only what reserve can fund and trims the magazine accordingly.

  • int Available
  • int TryTake(int requested)

IWeaponPresentationEventStream

service

The production fan-out for presentation. Armory publishes a typed event the moment a fire, reload, jam, or impact decision is made; VFX, audio, camera, and decal handlers in other packages subscribe. Publishing never throws into the firing path.

  • void Publish(WeaponPresentationEvent evt)
  • event Action<WeaponPresentationEvent> Published

WeaponShotEventBus

class

Static diagnostics hub with a rolling 32-shot history, read by the debug HUDs and scenario tests. Distinct from the presentation stream: this one exists to be looked at, not to drive gameplay feedback.

  • const int HistoryCapacity = 32
  • static event Action<WeaponShotDiagnostic> ShotPublished
  • static WeaponShotDiagnostic[] RecentShots

Surface

Authoring assets

WeaponOperationProfileDefinition

asset

The aggregate that makes a weapon operate. Holds references to the fire modes, delivery, ballistic, recoil, and reload sub-profiles plus an optional pellet pattern, and owns the jam and heat model directly. Create via ZOA > Armory > Weapon Operation Profile.

  • IReadOnlyList<FireModeDefinition> SupportedFireModes; int DefaultFireModeIndex; FireModeDefinition GetFireModeAt(int index)
  • FireDeliveryDefinition FireDelivery; BallisticProfileDefinition BallisticProfile
  • RecoilProfileDefinition RecoilProfile; ReloadProfileDefinition ReloadProfile
  • PelletPatternDefinition PelletPattern; string ImpactClassId
  • float JamChancePerShot, HeatPerShot, MaxHeat, CoolingPerSecond, CoolingDelayAfterShot
  • float CooldownThresholdPercent, HeatFireRatePenaltyStartPercent, OverheatedFireRateMultiplier
  • float HeatSpreadMultiplierAtMax, HeatRecoilMultiplierAtMax, HeatJamChanceBonusAtMax

FireModeDefinition

asset

Cadence. One asset per mode a weapon supports, so a rifle references a Single and an Auto asset and the runtime cycles between them.

  • FireModeKind ModeKind — Single, Burst, Auto, Continuous, Charge, Scatter
  • int BurstCount, ProjectilesPerShot; float TimeBetweenShots, SpreadAngle, ChargeTime
  • bool RequiresTriggerReleaseBetweenBursts

FireDeliveryDefinition

asset

Geometry. How the damage travels from muzzle to target, and which layers are eligible to receive it.

  • DeliveryKind DeliveryKindValue — Hitscan, Projectile, Beam
  • GameObject ProjectilePrefab; LayerMask DamageLayers
  • float ProjectileSpeed, MaxDistance, BeamDuration, ImpactForce; bool PiercesTargets

BallisticProfileDefinition

asset

What happens to a round in flight. Start with muzzle velocity, effective range, and impact damage; reach for the rest when a weapon needs real velocity retention, a damage floor, or transonic behaviour.

  • float MuzzleVelocity, EffectiveRange, GravityScale, ArmorPenetration, ImpactDamage
  • float ProjectileMass (authored in grams); bool HasAuthoredProjectileMass
  • float BallisticCoefficient, DragCoefficient
  • float MinimumDamageFraction, DamageFalloffExponent
  • float TransonicInstabilityVelocity, TransonicSpreadPenalty, WindDriftScale

RecoilProfileDefinition

asset

Kick and its recovery. The first four fields cover a basic weapon; the rest shape a firing string into a pattern the player can learn.

  • float VerticalKick, HorizontalKick, RecoverySpeed, CameraImpulse
  • float MaxVerticalOffset, MaxHorizontalOffset, RecoveryDelay
  • float VerticalKickGrowthPerShot, HorizontalKickGrowthPerShot, HorizontalBias
  • float PatternRandomness; int PatternPeriodShots
  • float SustainedFireShotMemorySeconds, SettleSharpness

ReloadProfileDefinition

asset

Magazine size and the timing around refilling it, including whether the weapon tracks a chambered round separately and whether topping off a partial magazine is allowed at all.

  • int MagazineCapacity; bool UsesChamberedRound, TacticalReloadSupported
  • float ReloadDuration, ChamberDuration, UnjamDuration

PelletPatternDefinition

asset

Multi-pellet scatter. Attach one to an operation profile and the delivery fires that many rays per trigger pull inside a distance-growing cone, each attenuated by its own falloff curve.

  • int PelletCount; float BaseSpreadDegrees, SpreadGrowthPerMeter, MaxSpreadDegrees
  • float FullDamageDistance, MinDamageDistance, MinDamageFraction
  • float ResolveSpreadAtDistance(float distance); float ResolveDamageMultiplier(float distance)

WeaponArmoryBindingDefinition

asset

Attaches an operation profile to a weapon definition, a whole platform, or both. This is the indirection that lets Weapon Modding stay ignorant of Armory: the weapon asset never references a profile.

  • WeaponDefinition WeaponDefinition; WeaponPlatformDefinition WeaponPlatform
  • WeaponOperationProfileDefinition OperationProfile

WeaponPackDefinition

asset

The unit of weapon distribution: a dotted slug id and an ordered list of weapons with optional pack-scoped preset and presentation overrides. List order is display order, and consumers resolve a pack by id through the registry.

  • string PackId, Description; Sprite Icon; int Version; string CacheKey
  • IReadOnlyList<WeaponEntry> Weapons; int WeaponCount; WeaponEntry GetWeaponAt(int index)
  • WeaponEntry — definition, preset, presentation, EffectivePreset, EffectivePresentation
  • bool TryFindEntryByWeaponId(string weaponDefinitionIdString, out WeaponEntry entry)

Usage

Examples

Equipping a weaponcsharp
using UnityEngine;
using ZOA.Armory.Unity.Definitions;
using ZOA.Armory.Unity.Services;
using ZOA.Foundation.Unity.Database;
using ZOA.Messaging;

// The host only has to expose a mount transform and an aim camera.
public sealed class SimpleWeaponHost : MonoBehaviour, IWeaponEquipHost
{
    [SerializeField] private Transform weaponMount;
    [SerializeField] private Camera aimCamera;

    public Transform WeaponMount => weaponMount;
    public Camera AimCamera => aimCamera;

    private EquippedWeaponHandle _handle;

    public void Equip(WeaponDefinition definition, GameObject modelPrefab)
    {
        var equip = FoundryServiceRegistry.Get<IWeaponEquipService>();
        var profile = ArmoryProfileResolver.Resolve(GameDatabase.Instance, definition);

        var request = new WeaponEquipRequest(
            definition: definition,
            operationProfile: profile,
            host: this,
            modelPrefab: modelPrefab,
            randomSeed: 1337);   // deterministic jam rolls

        if (!equip.TryEquip(request, out _handle, out var reason))
            Debug.LogWarning(reason);
    }
}
A controller in another package resolves the service by interface and drives Armory with no compile-time dependency edge.
Driving the runtime each framecsharp
using UnityEngine;
using ZOA.Armory.Unity.Definitions;

private void Start()
{
    var runtime = _handle.Runtime;
    runtime.OnFired += data => _hud.SetAmmo(data.magazineRoundsRemaining, data.chamberLoaded);
    runtime.OnOverheat += data => _hud.FlashOverheat(data.currentHeat, data.maxHeat);
    runtime.OnReloadStart += data => _animator.SetTrigger("Reload");
}

private void Update()
{
    var runtime = _handle.Runtime;
    float dt = Time.deltaTime;

    // The runtime owns no clock of its own.
    runtime.TickCooling(dt);
    runtime.TickReload(dt);

    var mode = runtime.SelectedFireMode;
    if (mode != null && mode.ModeKind == FireModeDefinition.FireModeKind.Charge)
        runtime.TickCharge(dt);

    if (_reloadPressed)
        runtime.StartReload();      // no-op while already reloading

    // Heat stretches the interval between shots before it locks the weapon out.
    float interval = runtime.GetEffectiveTimeBetweenShots(mode.TimeBetweenShots);
    if (_triggerHeld && Time.time - _lastShot >= interval)
    {
        if (runtime.TryConsumeShot(out var reason))
        {
            _lastShot = Time.time;
            DeliverShot();
        }
        else
        {
            _hud.ShowRefusal(reason);   // "Weapon is jammed." and friends
        }
    }
}
Delivering a hitscan shotcsharp
using System.Collections.Generic;
using UnityEngine;
using ZOA.Armory.Core.Events;
using ZOA.Armory.Unity.Handlers;

private readonly List<HitEventData> _hits = new();

private void DeliverShot()
{
    var profile = _handle.OperationProfile;
    var runtime = _handle.Runtime;

    // Read effective values so installed attachments are accounted for,
    // and fold in the heat penalty the runtime is currently reporting.
    float spread = runtime.GetEffectiveSpreadAngle(runtime.SelectedFireMode.SpreadAngle)
                 * (1f + runtime.GetHeatSpreadMultiplier());

    Vector3 direction = ApplyCone(_aimCamera.transform.forward, spread);

    HitscanDeliveryHandler.Fire(
        origin: _muzzle.position,
        direction: direction,
        delivery: profile.FireDelivery,
        ballistics: profile.BallisticProfile,
        hits: _hits,
        weaponInstanceId: _handle.Instance.InstanceId,
        weaponDefinitionId: _handle.Instance.DefinitionId,
        owner: gameObject);

    _recoil.ApplyRecoilKick(1f + runtime.GetHeatRecoilMultiplier());
}
The handler publishes ProjectileHitEvent on the Foundry bus for each hit, so damage sinks and analytics correlate the shot back to the weapon without importing an Armory type.
Firing a shotgun patterncsharp
using UnityEngine;
using ZOA.Armory.Unity.Ballistics;

private void DeliverPellets(float estimatedRange)
{
    var pattern = _handle.OperationProfile.PelletPattern;
    if (pattern == null) { DeliverShot(); return; }

    // The cone widens with range, then clamps.
    float coneHalfAngle = pattern.ResolveSpreadAtDistance(estimatedRange);

    for (int i = 0; i < pattern.PelletCount; i++)
    {
        Vector3 direction = PelletConeSampler.Sample(
            centerDirection: _aimCamera.transform.forward,
            pelletIndex: i,
            pelletCount: pattern.PelletCount,
            coneHalfAngleDegrees: coneHalfAngle,
            addJitter: true);

        if (!Physics.Raycast(_muzzle.position, direction, out var hit,
                             _handle.OperationProfile.FireDelivery.MaxDistance))
            continue;

        // Each pellet attenuates on its own travelled distance.
        float damage = _handle.OperationProfile.BallisticProfile.ImpactDamage
                     * pattern.ResolveDamageMultiplier(hit.distance);

        ApplyDamage(hit, direction, damage, pelletIndex: i);
    }
}
Making reloads draw from a reservecsharp
using ZOA.Armory.Core.Runtime;

public sealed class MagazinePouch : IAmmoReserveSource
{
    private int _rounds;

    public MagazinePouch(int rounds) => _rounds = rounds;

    public int Available => _rounds;

    public int TryTake(int requested)
    {
        int taken = requested < _rounds ? requested : _rounds;
        _rounds -= taken;
        return taken;
    }
}

// Opt in per weapon. With no source attached the runtime keeps its
// historical free full reload, so existing content is unaffected.
_handle.Runtime.SetReserveSource(new MagazinePouch(120));
FinishReload works out how many rounds the refill needed, takes that many from the source, and trims the magazine then the chamber by whatever the reserve could not fund.
Switching weapons through the loadout servicecsharp
using ZOA.Armory.Unity.Services;
using ZOA.Messaging;

var loadout = FoundryServiceRegistry.Get<IWeaponLoadoutService>();
loadout.RegisterHost(this, new InventoryStowageAdapter(playerInventory));

// LoadoutChanged fires on refusals too, so the UI just re-reads.
loadout.LoadoutChanged += args =>
{
    if (args.Transition == WeaponLoadoutChangedArgs.Kind.Refused)
        _hud.ShowRefusal(args.Reason);

    _wheel.Rebuild(loadout.GetOwnedWeapons(this));
};

// Each verb snapshots the outgoing weapon onto its item payload and
// restores the incoming one, so the magazine and attachments survive.
if (!loadout.TrySwitchActive(this, sidearmInstanceId, out var reason))
    _hud.ShowRefusal(reason);

Tooling

Editor tools

Armory Workbench module

ZOA Workbench > Armory

Seven capabilities covering the whole authoring surface: a weapon definition editor, weapon packs by way of the loadout composer, a fire mode editor, a combined ballistics and delivery panel covering projectile, beam, hitscan, recoil, and reload data, the operation profile composer, a migration lab for staged checks on legacy assets, and the weapon model bundle browser.

Weapon Wizard

Tools > ZOA > Advanced > Define > Weapons > Weapon Wizard

Eight steps on the shared FoundryWizardShell, ending in a Summary step that is the only path that writes assets. From an empty project you pick a model prefab, scan its mount points, choose default attachments, and save, and you get weapon, preset, and presentation assets that work in a scene without touching a raw inspector. Validation surfaces in the issue tray with quick fixes attached.

Operation Profile Composer

ZOA Workbench > Armory > Operation Profile Composer

Composes the fire mode, delivery, ballistic, recoil, and reload sub-profiles into a complete operation profile in one panel, rather than making you keep five inspectors open and remember which references which.

Weapon Model Bundles

ZOA Workbench > Armory > Weapon Model Bundles

Discovers, previews, imports, and removes the operational profile bundles shipped as package samples. Each bundle carries fire modes, ballistics, recoil, and reload definitions for a category of weapon, so a new project gets a tuned starting point instead of a blank profile.

Auto-Import Bundled Weapons

Tools > ZOA > Advanced > Generate > Weapons > Content Packs > Auto-Import Bundled Weapons

Drives the Weapon Modding auto-importer over the shipped FBX models to produce rigged prefabs and definitions in one pass, which is the fastest route from a fresh clone to a scene with working weapons.

Armory scene installer

Installs the ArmoryRuntime component into the active scene and nothing else. Idempotent, and it builds no demo scene: it mirrors the shape of the persistence and equipment installers so a composition utility can call all three uniformly.

Read this

Notes and caveats

See also