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.
Depends on (10)
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.
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.
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.
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.
Setup
Workflow
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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
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);
}
}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
}
}
}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());
}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);
}
}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));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