---
title: "C# style & conventions"
slug: /workflows/csharp-style/
markdown: /workflows/csharp-style.md
section: architecture-csharp
tier: 2
summary: "Naming, structure and language-feature rules for game code."
sources: ['csharp.md']
canonical: https://unityworkflowlibrary.dev/workflows/csharp-style/
read_first: /read-first.md
---

> Read first, on every prompt: [non-destructive rules](/read-first.md).

# C# style & conventions

> Naming, structure and language-feature rules for game code.

**Core coding standards first.** The three HARD, sweepable standards below (ONE-PATH-CODE-SHAPE, EVENT-ONLY-MANAGER-TOPOLOGY and ZERO-ALLOCATION-BY-DEFAULT) are the backbone of this library's approach to keeping technical debt down; the rest of the page is naming, structure and language-feature detail. This page covers regular Unity programming (MonoBehaviour & C#); DOTS/ECS code has its own section.

## ONE-PATH-CODE-SHAPE — one dependency, one path, one owner (HARD, sweepable)

Developer ruling (a tower-defense game, after a font host that wired four fonts by `[SerializeField]`
AND re-loaded them by asset path from an `EnsureFonts` that three consumers called "just in case").
These shapes are defects wherever they appear, and a scripted sweep over your script folders finds them mechanically.

- **One acquisition path per dependency.** A reference is wired once, by the one object that owns
  it, and consumed. `wired ?? Find`, `if (x == null) x = Load`, `Instance ??= new` are a
  second path — the day the wire breaks the code keeps working wrong, and nobody sees it. Delete
  the second path; a missing wire fails loud once (`Debug.LogError`, then stop).
- **No consumer-side `Ensure*`.** A consumer never repairs its provider's readiness. Order is a
  lifecycle contract the provider owns: it provides in `Awake` (or under `DefaultExecutionOrder`),
  consumers read from `OnEnable`/`Start` or later. An `Ensure` call at N sites is N admissions
  that the lifecycle was never designed.
- **A static utility is injected by exactly one host, once.** The host is the only type that knows
  the asset identity; the static holds values, never paths. `"Assets/…"` strings in runtime C# are
  the tell.
- **Editor conveniences live in `Editor/` code.** `#if UNITY_EDITOR` + `AssetDatabase` inside a
  runtime class is a hidden second path with a different behaviour in the player. If opening a
  scene without its boot scene should still render, that is a Control Panel action or a
  validator, not a branch in the runtime type.
- **Readiness flags die with the second path.** `FontsReady`, `IsInitialized`, `_bound` that exist
  so consumers can poll-and-repair are the second path's bookkeeping; remove them together.
- **One responsibility per class, one job per method, and reuse over re-derivation.** A host does
  one thing (wire fonts). A fix applied at N call sites is a missing method — write it once where
  the data lives and call it. A method that acquires, validates AND builds is three methods, and
  the acquisition one is the one that should not exist.
- **The chain is visible end to end.** Provider → consumer must be readable as a straight line
  (scene object → `Awake` → static → factory). If explaining a value's origin needs the word
  "or", the shape is wrong.

Sweep tiers: FAIL = ENSURE-CALL, LAZY-ACQUIRE(-COALESCE), INSTANCE-SELF-CREATE, ASSET-PATH-IN-CODE,
EDITOR-BRANCH-IN-RUNTIME. REVIEW = READY-FLAG, LEGACY-FALLBACK-COMMENT, DUAL-PROVIDER (a static
`Set*/Bind*` with more than one caller). Editor/ folders are exempt; vendor folders are excluded
with `SWEEP_EXCLUDE_DIRS`. Companion prose: "A replacement path never keeps a silent legacy
fallback" above, the references page for the wiring rules.

**LAZY-ACQUIRE-IS-NOT-A-CTS-REARM.** The lazy-acquire patterns end in `new\s`, so they also match
`_cts ??= new CancellationTokenSource`. That one is legal and the sweep now excludes it. A
cancelled `CancellationTokenSource` **cannot be reused** — after `Cancel` every token it hands out
is already cancelled — so a system that cancels and disposes its CTS at teardown has to build a
fresh one for the next operation. One writer, one owner, a paired `Cancel`/`Dispose`, and no
other supplier: that is not the "dependency someone else owns, acquired a second way" this rule is
about. Same shape as DOTS-LOOKUP-IS-NOT-A-SCAN — when a gate over-matches a legitimate API, fix the
**tool** with a narrow exclusion; a `rule-check:allow` at the call site would also hide the next real
violation on that line. Regression-test the exclusion with a probe holding every spelling of the
excluded shape (bare and fully qualified) plus a real violation that must still fail.

**BCL-ENSURE-IS-NOT-A-SECOND-PATH.** ENSURE-CALL matched `response.EnsureSuccessStatusCode` in an HTTP fetch.
A .NET base-library `Ensure*` (`EnsureSuccessStatusCode`, `EnsureCapacity`) is a check or a buffer grow on a
value the caller already owns, not a consumer repairing a provider's readiness, so the sweep skips those names.
Any other `Ensure*` still fails.

**ENSURE-IS-NOT-A-VENDOR-TICK-CONTRACT.** ENSURE-CALL also fires on an engine or package API whose own
per-tick contract is "call this before you use the context I handed you" - typically a scratch-allocation
primer on a borrowed update context, called once per chunk or once per update. The sweep now excludes that
one spelling. It is not a violation and it has NO fix: the provider is the package, the ordering is the
package's, and the temp collections do not exist until the call runs, so deleting the call deletes the
allocation the very next vendor utility reads. The tell that separates it from a real ENSURE-CALL is that
the thing being ensured belongs to a type you do not own and the call takes NO arguments - it primes the
receiver, it does not repair a dependency you could have wired. The engine's own shipped samples call it
at the same site, which is the cheapest confirmation available: grep the package for the method name and
see whether the vendor calls it in the same position you do.

A readiness repair that merely resembles the shape still FAILS - an `Ensure*` on YOUR type, or one taking
an argument (`EnsureCreationOfTmpCollectionsForMyOwnCache(7)`), is the defect this rule is for.
Probe-tested per LAZY-ACQUIRE-IS-NOT-A-CTS-REARM: the probe holds every vendor spelling (bare, field-
qualified, fully qualified) plus three real violations that must still fail.

**ENSURE-IS-NOT-A-CONSENT-PROMPT.** ENSURE-CALL matches any method named `Ensure*`, which makes a
privacy gate — `Consent.EnsureDecision(onResolved)` — a FAIL. It is not one, and the sweep now
excludes it. ENSURE-CALL is about state the process **already owns and could have ordered**: a
reference, an initialised subsystem, a spawned singleton. That is why its fix is "delete the call AND
the method" — the lifecycle provides it instead. A decision only an agent OUTSIDE the process can
produce has no such fix: no `DefaultExecutionOrder` makes a person answer a consent form in `Awake`,
and the continuation the call takes IS the ordering mechanism. Deleting the call deletes the request.

The claim to defeat is that consent already arrives by a second path — the change event the consumers
subscribe to — so the pull is redundant. **Settle it by tracing every RAISER of that event, never by
reading the subscriber list.** Observed: the change event was raised only from inside the resolve, and
the two entry points that would otherwise raise it (a settings toggle, and registering the CMP that
draws the form) had ZERO callers, because no consent dialog had shipped yet. The push was therefore
downstream of the pull, not parallel to it — deleting the pull removes the only trigger, and an
analytics SDK that ships opted-OUT then goes silent for the life of the app with no error and no log.
A second path that only ever fires because the first one called it is ONE path drawn twice.

Two callers of one memoized resolve is also not a second path: it caches, so the second consumer
receives the stored answer and cannot produce a different one. Asking lazily at first need is the
correct posture — a boot-time owner that asks once prompts in builds where the consuming feature is
switched off entirely, and prompting for consent you do not yet need is a defect of its own.

Both halves of the exclusion are load-bearing: the ensured noun must name an out-of-process decision
(`Consent`, `Decision`, `Permission`, `Authori[sz]ation`, `Tracking`, `Privacy`) AND the call must
take a continuation or be awaited. A readiness repair that merely borrows the noun
(`EnsureConsentCacheLoaded`) or takes a callback under another one (`EnsureManagerReady(OnReady)`)
still FAILS. Known limit: a DECLARATION whose first parameter is any identifier is skipped, so a
violation of that shape surfaces at its call site. Probe-tested per LAZY-ACQUIRE-IS-NOT-A-CTS-REARM.


## EVENT-ONLY-MANAGER-TOPOLOGY — managers own one thing and only ever talk in events (HARD, sweepable)

Developer ruling (a tower-defense game, same session as ONE-PATH-CODE-SHAPE: the font host was one
symptom of agents not understanding how the codebase is meant to run). The shape the developer wants,
verbatim: *central single-responsibility managers; controllers for those managers; all of it runs on
events only and never references each other, so nothing relies on what does not belong to its
responsibility.* Swept by `tools/one-path-sweep.sh` (checks 9–10).

- **A manager owns exactly one responsibility** (economy, waves, tower health, loading, audio) and
  is the only writer of that state. It PUBLISHES what happened on its event bus; it never reads or
  calls another manager. `OtherManager.Instance.X` inside a manager is a FAIL, not a convenience.
- **A controller belongs to one manager and may reference only that manager.** It drives or
  presents that manager's thing (a `HealthController` for the health manager). Everything it needs
  from elsewhere arrives as an event it subscribed to. A controller holding two managers has two
  responsibilities — split it, or turn the second reference into a subscription.
- **Nothing else reaches into a manager.** A presenter, system, screen or helper that calls
  `SomeManager.Instance.Value` or a manager's static method is coupling to state it does not own;
  it consumes the manager's event (or a value the event carries) instead. Static events on the
  manager type ARE the sanctioned seam (`GameManager.GoldChanged += …`).
- **Never do the same thing twice, least of all in one method.** Two sources for one value — a
  serialized wire AND a `GetComponent`/`Find`/`Load` of the same type, a call made "in case the
  first one did not run" — is the exact defect this rule and ONE-PATH exist for. One source, one
  call, one owner; if the first did not run, the lifecycle is wrong and that is what gets fixed.
- **The event carries the data.** If a subscriber needs to reach back into the publisher after the
  event to get what it needs, the event payload is incomplete — put the value on the event.

Sweep tiers: FAIL = MANAGER-REFERENCES-MANAGER, CONTROLLER-REFERENCES-FOREIGN-MANAGER (more than one
manager type), WIRED-AND-FETCHED (a `[SerializeField]` of type T beside a `Get*/Find*/Load<T>` in the
same class). REVIEW = MANAGER-REFERENCES-CONTROLLER, CONTROLLER-REFERENCES-CONTROLLER, MANAGER-REACH
(a non-controller calling `*Manager.Instance` / a manager static method). `OtherManager.Event += / -=` lines are
stripped before the type scan everywhere: subscribing to another manager's static event is the seam, not a reference.
Engine types that merely end in the suffix (`SceneManager`, `EntityManager`, `CharacterController`,
`AnimatorController`…) are ignored; add project ones with `SWEEP_TOPOLOGY_IGNORE=A,B`. Companion:
"Decouple cross-system comms with events, not singletons" above, and ONE-PATH-CODE-SHAPE.

## ZERO-ALLOCATION-BY-DEFAULT — every line is written so it does not cause GC collection (HARD, sweepable)

- **Rule:** zero managed allocation is the default everywhere, not just in hot paths. Any managed allocation is an exception that must be justified (a one-time load or warmup, a genuinely needed new identity), with the reason stated in a short comment or in the PR/report. "Low allocation" and "allocation outside `Update`" are not the standard; the goal is no unintended GC collections at all during a session.
- **Why:** Unity's GC (Boehm, incremental by default) pauses the main thread; small allocations stay invisible in play and show up as periodic hitches on devices and in long sessions, and they get harder to remove the more code depends on them. Many devs never open the profiler, so the debt hides.
- **Common hidden allocators:** LINQ, lambdas/closures capturing locals, string concatenation/interpolation/`ToString` in loops, boxing (value types to `object`/interface, enum keys in `Dictionary` without a comparer, `params` arrays), `foreach` over non-`List` interfaces, coroutine `yield return new WaitForSeconds` each time (cache it), `GetComponents`/`FindObjectsOfType` arrays, physics non-`NonAlloc` queries, `Camera.main` in old versions, `UnityEvent` invoke args, `Debug.Log` in shipping paths.
- **Fixes:** cache and reuse (`Clear`, not `new`), pools, `NonAlloc`/`List`-overload APIs, `StringBuilder` or cached strings, `struct` with generic constraints instead of interfaces, `ArrayPool`/`NativeArray` for scratch.
- **Check (1) Profiler:** CPU module GC Alloc column at 0 B on gameplay frames, plus the `GC.Collect` / `GC.Alloc` markers in the Timeline view; enable Allocation Call Stacks to find each source.
- **Check (2) Memory Profiler:** compare snapshots across a long session for managed heap growth.
- **Check (3) Runtime counters:** at runtime or in a debug build, track `GC.CollectionCount(0)` and `Profiler.GetMonoUsedSizeLong` / `GC.GetTotalMemory` in a debug overlay or log. The expected collection count during gameplay is 0.
- **Check (4) Tests:** unit or performance tests with `Assert.That(() => {...}, Is.Not.AllocatingGCMemory())`, run on every feature.
- **Check (5) While writing:** a heap allocation viewer plugin in Rider or Visual Studio flags allocations in the editor as the code is written.
- **Related:** "Data structures are `struct`" and "Reuse before you allocate" below, the logging-allocation notes on this page, the `GetComponents`/`TryGetComponent` allocation notes on Runtime references & object lifecycle, and the [Performance & memory](/workflows/perf-memory/) page.

## Naming, structure and language features


- **Full descriptive names everywhere.** No single-letter vars — including loop counters, lambdas,
  foreach, and **method parameters** (`Foo(in EntityQuery q)`, `out var g`). Cryptic names add
  review friction. A parameter is the one position a reader cannot rename locally, so it is the
  worst place to abbreviate, not an exemption.
- **Data structures are `struct`, not `class`.** (Part of ZERO-ALLOCATION-BY-DEFAULT.) Avoids per-instance heap allocation and GC churn
  (matters most in hot paths / per-frame code). Reserve `class` for things with identity, shared
  reference semantics, or inheritance.
- **Local functions / lambdas inside a `struct` cannot capture instance fields (CS1673).** Copy
  `this` or the field to a local, then use that. (DOTS case: see ECS core mechanics, STRUCT-CAPTURE-IN-ISYSTEM.) Query expressions have the same ban.
- **An overload set drags EVERY candidate's parameter types into the caller (CS0012).** Overload
  resolution loads all candidates, so a caller that cannot see one overload's parameter type fails
  even when it calls a different overload with types it can see. `Foo(in CollisionWorld)` next to
  `Foo(EntityManager)` breaks any assembly without the physics reference, and the error names the
  type, never the overload set — so it reads as "add the reference" when the real choice is whether
  that assembly should depend on it at all. Give the boundary-friendly method a DISTINCT NAME and
  the sibling stops being a candidate. Verified with a 3-assembly control: shared name + no
  reference fails; distinct name + no reference compiles; shared name + reference compiles.
- **Reuse before you allocate — every `new` on a reference type is a heap allocation that feeds GC.** (Hard rule and profiler check: ZERO-ALLOCATION-BY-DEFAULT at the top of this page.)
  A `new` on a class / array / `List`/`HashSet`/`Dictionary`/`StringBuilder` / a closure / a boxed value
  allocates on the managed heap; do it in a loop or a per-frame/per-tick path and those allocations pile
  into GC pressure and frame hitches. DEFAULT TO REUSE: hoist a collection to a field and `Clear` it
  each call instead of `new`-ing one; keep a grow-only scratch buffer or an object pool rather than
  allocating per item; fill an existing buffer instead of returning a fresh array; cache anything stable.
  A fresh `new` is for genuinely one-shot setup (constructor / `Awake` / `OnCreate` / init) or when the
  object needs its own identity. (`new` on a `struct` does NOT allocate — it's inline; this rule is about
  reference types.) The test before any `new`: *could this have been reused from last time?* If yes, reuse
  it. (Pairs with the hot-path rule in unity.md; surfaced from a codebase audit that found
  presentation systems `new`-ing a `HashSet` every frame.)
- **COMMENT-BUDGET (HARD) — a doc comment is a label, not an essay.** Explain WHY, never WHAT,
  inside a hard budget, counted in SENTENCES so it cannot be gamed by wrap width: member `///` summary
  **≤ 2 sentences / 30 words**, each `<param>` / `<returns>` **≤ 1 sentence / 20 words** (delete it
  outright if it only restates the parameter name), type header **≤ 5 sentences / 80 words** (1 for an
  obvious type), inline `//` **≤ 2 sentences / 30 words**. Over budget is a defect — rewrite the block,
  never append to it. Banned outright:
  anything the signature already says, numbered walkthroughs of the body, `<list>`/`<item>`/`<para>`
  tables, banners, teaching paragraphs, design history, and XML doc on obvious fields. Keep a footgun
  as a CLAUSE, not a story — *"pass restScale; reading localScale mid-deal captures FlightScalePeak and
  compounds ×1.1 per interruption"* replaces four sentences and loses nothing. A fact that genuinely
  needs more than 2 lines is ARCHITECTURE: put it in the project's agent instructions or `docs/` and leave a
  one-line pointer at the code. (: a Unity codebase had accumulated 1,191 comment blocks
  averaging 48 words — ~57k words of agent prose, median `///` block 32 words, worst 482 — because
  "minimal, terse" with no number bound nothing.)
- **Never grow a comment you did not have to touch.** Editing a method is not a licence to re-document
  it, and a summary is not a changelog. If an existing comment is already over budget, condense it in
  passing — never extend it.
- **No defensive code for impossible cases.** Don't add error handling / fallbacks / validation for
  states that can't occur. Validate only at real boundaries (user input, files, network, external APIs).
- **Decouple cross-system comms with events, not singletons.** Prefer `event Action<T>` (or domain
  events) over direct references or a global `Instance` — the producer fires and knows nothing about
  who listens; subscribers own their own subscribe/unsubscribe. Static events are the decoupling seam
  (reference the type, not an instance). Reach for a singleton only for a genuinely single, well-known
  service. Keeps systems swappable and testable.
- **Every subscription needs a paired teardown — trace the whole lifecycle BEFORE you write it.**
  Each `+=` / event subscribe / `RegisterCallback` / delegate hook must have a matching unsubscribe
  owned by the same object, and you must be able to answer "when does this tear down, and can it run
  twice?" *as you write it*, not discover the leak later. A subscription with no teardown is a bug by
  construction (it leaks and fires N times). The worst form is re-subscribing inside a method that
  runs repeatedly (per-frame / per-refresh / per-render) on a long-lived object — handlers accumulate
  silently. Callbacks on objects that OUTLIVE the call belong in one-time setup; only objects created
  fresh and discarded each pass may subscribe inline. (UI Toolkit: `element.Clear` removes children,
  NOT callbacks on the element itself.)
- **Apply SOLID at the seams, not everywhere.** One responsibility per type; at system boundaries
  depend on an interface, not a concrete class (DIP), so implementations swap without touching
  callers. (Pairs with the next rule — skip the interface for code with one implementation that
  won't change.)
- **No premature abstraction.** Three similar lines beat the wrong abstraction. Don't build for
  hypothetical future requirements.
- **Least tech debt is the default bar — fix the owner, don't stack workarounds.** Prefer the smallest
  change that makes the *real* contract correct (types, ownership, structural-change order, one system
  drains) over force-enables, stale-handle re-gets in a loop, retry glue, or a second parallel path.
  A one-line "bandage" that leaves the broken pattern in place is **more** debt than a slightly longer
  correct sequence. When ECS/Unity invalidates handles or order matters, encode the correct order in
  code once; don't paper over the exception. (Pairs with Fable "no bandages"; DOTS structural-buffer
  rule lives in `dots-netcode.md`.)
- **Reuse the existing centralized helper — never re-add the same code.** Before writing any helper
  (id resolution, `IsLocalPlayer`, a lookup, a formatter, a "current X" getter, …), search first
  (grep/graph the name *and* the value it would wrap) for one that already exists, and call it. If the
  same logic already lives somewhere, use it; if it's duplicated across 2+ places, promote it to one
  shared spot and route callers through it — do not paste a fresh copy. Re-implementing an existing
  accessor is a review defect, not a convenience. (Surfaced: a third hand-rolled
  `IsLocalPlayer` was added when `CustomNetworkManager.LocalPlayerId` was the known central getter.)
- **Build the system for the CATEGORY, not a hardcoded one bolted to a single instance.** When the
  thing you're asked for is plainly ONE member of an obvious domain category — a cupboard is a
  *placeable/deployable*, a fireball is a *projectile/effect*, a sword is an *item* — build the
  general system keyed off a shared component the members opt into, NOT a one-off system/tag/field
  named after that single instance. The tell you got it wrong: a type name carries a specific noun
  (`ClaimNavBlockSystem`, `ClaimNavBlocked`) while the behavior it encodes is generic ("a placed
  thing occupies a nav footprint"). The proof: **if adding the second member of the category tomorrow
  would mean copy-pasting the system and renaming the noun, it should have been general today.**
  Members opt in by carrying a shared component/flag; one system processes all of them; per-member
  differences become AUTHORED DATA (footprint size, cost, cooldown), not forked code. This does NOT
  contradict "no premature abstraction" — that rule bans building for *hypothetical* requirements;
  this rule is about naming the *real noun the feature already belongs to*, whose siblings are known or
  imminent (the user describing "a cupboard" almost always means "placeables, starting with a
  cupboard"). Generalize across the category that already exists in the domain; don't invent layers for
  categories that don't. When the user describes a concrete feature, read for the category it implies
  and build that — surface it ("building this as a general deployable system, cupboard is the first")
  rather than silently shipping a hardcoded instance.
- **Never write bare `Object.` for Unity APIs — use `UnityEngine.Object.` (CS0104).** With
  `using System;` and `using UnityEngine;` both in scope (almost every Unity file), the bare type
  name `Object` is ambiguous between `System.Object` and `UnityEngine.Object`. Agents keep shipping
  `Object.FindObjectsByType` / `Object.Destroy` / `Object.Instantiate` and burning a compile cycle.
  **Default:** qualify Unity statics as `UnityEngine.Object.…`. Prefer concrete APIs that do not
  need the type name at all (`Destroy(go)`, `Instantiate(prefab)` as instance methods in scope, or
  fully qualified). Do not "fix" CS0104 by deleting `using System;` — that just moves pain. Do not
  introduce runtime `FindObjectsByType` to paper over a missing wired ref (see NO-RUNTIME-LOOKUPS).

> **Why this rule exists** (Qualify UnityEngine.Object (CS0104)): Agents kept writing bare `Object.` and burned a compile cycle on CS0104 each time, repeatedly across projects (one case: a map Confirm Drop action).
> *Source: Seen repeatedly across active Unity projects, including a space sandbox game.*

- **A replacement path never keeps a silent legacy fallback — wire it, delete the old branch, and
  verify the NEW path's distinguishing fingerprint at runtime.** The lethal shape:
  `if (HasComponent<NewThing>) { new path } else { old behavior }`. The day the wiring breaks (a
  spawn/init rewrite drops the AddComponent, a prefab loses the authoring), the feature silently
  reverts to the old behavior — no error, no log, and every knob tuning the new path becomes dead
  code while still LOOKING live. Correct shape: the owner (spawn/init/baker) adds the component
  unconditionally, and the consumer has NO fallback — a missing component fails loud (nothing
  happens) instead of silently wrong. And when claiming a new path "landed": verify its
  DISTINGUISHING runtime signal (the new component enabled in live data, the new event firing),
  never just a downstream symptom the old path also produces — "kills are flowing" verified both
  paths at once.

> **Why this rule exists** (No silent legacy fallback): Melee contact-frame damage regressed to instant for EVERY unit when a UnitSpawn rewrite dropped the PendingMeleeStrike add; the instant fallback hid it through multiple play-verified sessions while contact norms were tuned as dead code.
> *Source: A survival RTS project, play-verified sessions.*

- **Silence logs with `[Conditional]`, never by commenting out the call sites.** Two reasons, and
  the second is the one that bites. (1) `[System.Diagnostics.Conditional("MY_LOGS")] static void
  Log(object m)` makes the COMPILER delete the call *and its arguments* at every call site when the
  define is absent — so `Log($"seat {i} did {thing}")` stops allocating the interpolated string
  entirely. A runtime `if (enabled)` guard still pays for the interpolation on every call; only
  `Conditional` removes it. (2) Commenting out a log that is the SOLE body of a braceless
  `if`/`else`/`for` silently moves the next statement into that branch — a control-flow change with
  no compile error. Multi-line log statements are the same trap from the other side: one `//`
  comments the first line and orphans the `+ "..."` continuation. Scan for both before any
  comment-out sweep. Keep `LogWarning`/`LogError` un-stripped by default so a release build still
  leaves a trail. Editor-only convenience: add `[Conditional("UNITY_EDITOR")]` alongside — multiple
  Conditional attributes combine as OR. Gameplay-mutating debug entry points (`[ContextMenu]`
  cheats that force a win/state) are a separate concern: wrap those in `#if UNITY_EDITOR` so they
  cannot ship, after checking no scene UnityEvent references them by name.

- **`[Conditional]` cannot express "unless" — wrap the ATTRIBUTE in `#if`, not the body.** Multiple
  `Conditional` attributes combine as OR and there is no negative form, so "log in dev, strip in
  production" is not writable as an attribute. Making the *presence* of the attribute conditional
  is:

  ```csharp
  #if MYAPP_ENV_PROD && !UNITY_EDITOR
      [Conditional("MYAPP_LOGS")]   // production: calls erased unless the escape hatch is set
  #endif
      public static void Log(object message) => Debug.Log(message);
  ```

  With the attribute absent the method is an ordinary call and every call site emits. Two things
  to get right: the strip decision is made when the **call site** compiles, not the declaration —
  which is only safe because scripting defines apply per-assembly, so verify the callers are not
  in an asmdef with a different define list. And key it off the SAME switch as whatever else the
  environment selects (backend URL, analytics target), so the log level can never disagree with
  the environment a build is actually talking to.

  Verify which way it resolved by reflection rather than by reading the source —
  `typeof(T).GetMethod("Log").GetCustomAttributes(typeof(ConditionalAttribute), false)` empty means
  "calls are emitted". Note an editor carve-out (`&& !UNITY_EDITOR`) makes the production branch
  unobservable from the editor by construction; that half is preprocessor-deterministic but only
  witnessable in a real production-config build, so say so rather than claiming it verified.

- **Before turning a stripped logger back ON, count the call sites and check for per-frame ones.**
  Re-enabling restores the interpolation cost the `Conditional` was removing. Grep the bodies of
  `Update`/`LateUpdate`/`FixedUpdate` (and any per-tick coroutine) for calls before flipping it;
  a logger that was free for a year can hide an allocation in a hot path that nobody has ever paid.

- **Never hard-cast a boxed value read back from `object[]` / a DB row.** Unboxing is EXACT-TYPE, so an
  `(int)` cast of a `long`-boxed value throws even though the numeric value fits. Read back via
  `Convert.ToInt64` / `ToDouble` / `ToInt32`, then cast. Bites at any runtime-typed marshalling boundary
  (SQLite, ADO, reflection) long after the write looked correct.
- **A comment claiming a field is "legacy / unused" is not evidence.** Verify a dead-code label against
  actual consumers before trusting it, and correct or delete the label in the same pass that changes the
  truth. (A field carrying `// legacy, unused` in two files was still consumed every frame — and the false
  label is precisely why nobody looked.)
- **A driver that composes MULTIPLICATIVE state must capture its baseline ONCE and write absolutely.**
  Any applier that composes a factor over an authored value (bone scale, tint stacks, offset accumulators)
  captures the authored baseline once per target, keyed and preserved across rebinds, and always writes
  `authored × factor`. Re-reading the LIVE value as "authored" converts an idempotent write into a
  compounding one — a UI slider fires dozens of change events per drag, each re-baseline multiplied the
  last, and the character exploded within a second.
- **Never end a namespace segment with `Editor` (CS0118).** A `…Terrain.Editor` namespace makes the bare
  `Editor` token ambiguous in sibling files, so `: Editor` fails — and the error lands in files you did not
  touch, naming a type you did not write, so it reads as a broken toolchain rather than a naming choice.
  Use `*.EditorTools`. (DOTS sibling: see ECS core mechanics, BAKER-CLASS-NAME-SHADOWS.)
- **Never gate a data override behind `#if UNITY_EDITOR` when both an editor preview and the runtime read
  it.** The `[Conditional]`/`#if` guidance above is about STRIPPING editor-only code; a guard around shared
  data silently forks it. An `#if UNITY_EDITOR`-gated catalog patch means the editor preview and the shipped
  build generate DIFFERENT worlds from the same seed, which reads as nondeterminism rather than as a compile
  guard. Apply on both paths or neither — prefer an authoring-only asset whose "Apply" writes the C# defaults
  back, so runtime divergence is impossible.
- **EDITOR-COMPILE-HIDES-PLAYER-BREAKS — an editor-green compile proves nothing about the player.** The
  Editor and the generated csprojs that offline `dotnet build` pre-checks read both define `UNITY_EDITOR`. So a runtime
  method that lands inside an `#if UNITY_EDITOR` block while runtime code still calls it compiles clean in
  both and fails EVERY player build (CS1061). Any change that adds, moves or widens an
  `#if UNITY_EDITOR` region in a runtime assembly compiles the PLAYER scripts before merge: in the Editor
  `PlayerBuildInterface.CompilePlayerScripts` for the target (no full build), or offline a pass whose runtime
  csprojs have `UNITY_EDITOR*` stripped from `DefineConstants`. Report which of the two ran; "compiles" alone means editor defines.
  The offline pass still links the Editor's NetCode dlls, whose generated serializers need their debug members,
  so it keeps `NETCODE_DEBUG` defined; it proves reachability only, not platform defines or stripping, so a real
  player build stays the final word.

> **Why this rule exists** (EDITOR-COMPILE-HIDES-PLAYER-BREAKS): A merge called "8/8 assemblies compile, sweeps clean" broke all player builds until a build lane found it.
> *Source: An active Unity project; caught by a player build lane.*
- **ASYNC-VOID-SWALLOWS-THE-CHAIN — `async void` is for UI event handlers only; never in a boot /
  load / restart chain.** An exception inside an `async void` method ends the chain with no error
  surfaced to the caller and no completion event: the game sits on the loading screen with a CLEAN
  console. Return `Task`, `await` it at one owner, and `try/catch` + report inside every loading
  task — never rely on the caller. (a tower-defense game: `LoadingManager.StartLoading` /
  `ExecuteLoadingTasks`, `GameManager.RestartGame`, `Unit.HandleOnDeath` all `async void`.)

- **STATICS-SURVIVE-AND-RESET — static registrations are remove-then-add (they STACK across domain
  reloads, and a stale one-time guard skips the real registration); a static set in EDIT mode
  before Play is wiped by the Play-mode domain reload, so set it from inside Play; process-static
  registries reset per world.** (a survival game)
- **APPLY-CARRIES-LIVE-FLAGS — a config apply that rebuilds the WHOLE struct carries forward
  live-only operator / debug flags; a seed may PRESERVE a flag, never set one.** `ToConfig`
  re-seeded `GodMode` from tuning defaults on every apply, including the automatic Play-start seed.
  Sibling: a per-item fault guard converts a systemic failure into an invisible one — log a distinct
  escalation once the failure rate crosses "most items". (a survival game)

- **LIFECYCLE-CLEANUP-RIDES-THE-BUS — session teardown is an event (`SessionEnding` before dispose,
  `SessionEnded` after) that every owner subscribes to; never bolt cleanup into `Disconnect` or
  `Start*`, and never save from `OnDestroy`.** A grab-bag disconnect grows a hidden list of
  everything it must remember to reset. (a project `docs/pitfalls/ui-login-flow.md:24`.)
- **CSHARP-THREE-API-MISREADS.** An indexer set through an `in` parameter is an upsert the compiler
  refuses (CS8332) — pass by value; `Dictionary.TryGetValue` ALWAYS writes its `out` (a miss yields
  `0`, a valid index — set a sentinel first); `Mathf.SmoothStep(a, b, t)` is a lerp with a 0..1
  interpolant, NOT HLSL `smoothstep(edge0, edge1, x)`. (a project `docs/pitfalls/building.md:72`, `world-authoring.md:67`,
  `combat-blood-vfx.md:5`.)

## USING-DOES-NOT-IMPORT-NESTED-NAMESPACES — copying engine code into your own namespace breaks its name lookup

- **`using N;` imports the TYPES in `N`, not its nested namespaces.** `Unity.Networking.Transport.Error.StatusCode`
  is not reachable as `Error.StatusCode` under `using Unity.Networking.Transport;` — you get
  `CS0103: The name 'Error' does not exist in the current context`. Add `using N.Sub;` (or fully qualify).
- **Why it ambushes you: package source declares its file INSIDE `namespace N`**, so `N.Sub` resolves by
  enclosing-namespace lookup and needs no using at all. Mirror that same code into your own namespace —
  a custom `INetworkPipelineStage`, a custom serializer, any "copy the shape of the built-in one" task —
  and identical code stops compiling. The engine file is not a portable template.
- Check every unqualified name you carried over, not just the one the compiler named; the first error
  usually hides the rest. (a space sandbox game byte-meter pipeline stage, UTP 2.x.)

## CALLER-COUNT-MISSES-METHOD-GROUPS - "exactly one caller" is a claim about your regex, not the code

Grepping `Foo` to enumerate call sites finds only the sites that call it with parentheses. It
silently misses every **method group** reference, and those are how event-driven code actually
wires itself:

- `evt += Foo;` / `evt -= Foo;`   - subscription, the big one
- `Action a = Foo;` / `Invoke(Foo)` / `list.Select(Foo)` - delegate conversion
- UnityEvent wiring serialised in `*.unity` / `*.prefab` YAML (`m_MethodName: Foo`) - no C# reference at all
- attribute entry points (`[RuntimeInitializeOnLoadMethod]`, `[MenuItem]`, `[UnityTest]`) - the runtime calls it, nothing does
- reflection / `SendMessage` by string name

So a `Foo` grep returning one hit supports "one **parenthesised** call site", never "one caller".
Enumerate with the **bare identifier** (`grep -rn "Foo"`) and classify the hits, and in Unity
also grep the scene/prefab YAML for `m_MethodName`. If the count decides anything, ask the compiler
(Find Usages / a type-load probe), not a regex.

**Corollary - do not instrument to learn a caller you can already read.** Unity's `Logs/Editor.log`
carries a full stack trace under every `Debug.Log`/`LogError`, so the real call chain of any logged
event is already on disk, from a run that already happened - no temporary logging, no domain reload,
no replay, and it works when another lane owns the editor. Read the log before adding a probe.


> **Why this rule exists** (CALLER-COUNT-MISSES-METHOD-GROUPS): Twice on one finding: a lane grepped `Initialize`, found the single call inside `RestartGame`, and reported "exactly one caller" - `GameManager.cs:122` subscribes it as `GlobalManager.OnPlayerTowerReady += Initialize`, a second entry point the pattern could not match. The wrong count turned a by-design Retry reset into a reported gameplay defect, and the head ruled a slice ahead of the queue for it before the Editor.log stack traces - `UI_UitkGameHud/<>c:<BuildGameOver>` under `RestartGame` under `Initialize` - closed it in one read.
> *Source: A tower-defense game in development.*

## OUT-PARAM-ALIASING-DESTROYS-THE-INPUT — `TryGet(x, …, out x)` has already zeroed `x` when it returns false

C# requires a method to definitely assign every `out` parameter before it returns, so the standard
`TryX` shape opens with `result = default;` and only then goes looking. Pass one variable as both an
input and the `out` and you have aliased them: on the **failure** path the callee's mandatory
assignment has overwritten the input you still needed, and the value is `0` / `null` / `default` in
the `else` branch written to handle exactly that case.

      if (!TryGetSupportedPosition(world, position, height, out position))   // aliased
      {
          Log("nothing under " + position);   // prints (0,0,0) - the request is gone
          QueueRetryAt(position);             // and the retry is aimed at the origin
      }

- Nothing warns. It is a legal alias, it compiles clean, and the **success** path is correct - which
  is the path a smoke test and a "does it come out the far end" check both take.
- `ref x` in the same argument list is the same bug, and `in x` does not save you: the aliasing is in
  the CALLER's frame, not in the parameter's modifier.
- Assume every `TryX` clears its `out` before it can fail, including the ones that look like they
  only write on success. It is the shape the language forces, not a callee choice you audit once.
- **Rule: an `out` argument never names a variable already passed to the same call.** Prefer
  `out var supported` - a fresh declaration cannot alias - and copy into the caller's variable AFTER
  the success check. Grep shape: `out (\w+)` where that same identifier appears earlier in the call.
- The failure branch is where the input matters most (log it, retry it, queue it, hold on it), so
  aliasing costs you precisely the branch you built. **Log the input on the failure path**: a value
  that reads all-zeros in a failure log is this bug's signature, and printing it is what exposes it.


> **Why this rule exists** (OUT-PARAM-ALIASING-DESTROYS-THE-INPUT): A server spawn gate called `TryGetStreamedSpawnPosition(collisionWorld, position, height, out position)`. The helper assigns `supportedPosition = default` before its raycast can fail, so on the deny path the hold branch created its temporary streaming observer at the ORIGIN instead of the spawn column. The chunk the hold waits for was therefore never requested, so no spawn away from the origin could ever resolve - every one ran out the 30 s reject. It compiled, 202 tests passed, and it stayed invisible until a play-verification read the reject log and found `float3(0f, 0f, 0f)` where a deliberately distinctive probe column should have been. The helper's other call sites each used a separate `out` variable; only the one that reused its input was wrong.
> *Source: A space sandbox game server, found in play verification.*

## SERVICE-WRITE-NEEDS-A-COMPLETION-SIGNAL — a remote write publishes "settled", and the UI follows it (HARD)

Developer ruling: *when dealing with service based logic we must always have completion
signals and have the UI properly follow those.*

An async write to a remote service (a session/lobby store, a save backend, a config service) is
usually written as `async void` behind a debounce: a caller queues a value, a timer coalesces the
edits, one request goes out, the result is logged and dropped. Nothing publishes that the write
finished. Three defects follow, and they are one defect wearing three faces:

- **The view cannot show a state it is never told about.** A control that edits remote state has
  three — settled, in flight, failed — and with no signal it can only ever paint the first.
- **The write's own echo overwrites newer local state.** A service that streams changes back
  delivers the echo to the writer too, and that echo is a snapshot of the value SENT, not the value
  now. A second edit made during the round trip is silently reverted when the first edit's echo
  lands after it. The symptom is a control that snaps back and forth under quick input, and it is
  read as "the service is slow" every time. It is not latency, it is ordering.
- **A failed write is indistinguishable from a slow one.** Both present as "nothing yet".

The rule: **the writer owns an in-flight flag and publishes every transition**
(`OnWriteStateChanged(bool inFlight)`) — raised when the value is queued, cleared in a `finally`
so a thrown request can never strand a view in "saving". Then every consumer follows it:

- the **view** disables the controls that feed the write and shows a busy affordance;
- the **model** ignores an inbound echo while its own write is outstanding, because a local value
  edited during the round trip is by definition newer than the value the echo carries.

Both halves ship together. A busy spinner alone is a bandage — it hides the snap-back without
fixing the ordering, and the moment a promotion, a retry or a second writer widens the window the
stale echo is back. The flag is what makes the affordance possible AND the echo suppressible.

Corollary: do not "fix" the snap-back by shortening the debounce. The debounce exists to protect a
shared rate bucket; shrinking it trades a visible bug for a throttled one.

## EDGE-FLAG-DIES-ON-RETRY — a first-time-only flag cannot be a consumer's only trigger (HARD)

A remote write often returns a one-shot edge beside its result (`unlocked: true` only on the call that
first flipped a state). On an endpoint the client RETRIES, the edge lies: the first attempt wrote the
state and then failed (a later write threw, a rate rail rejected, the response timed out), so the retry
finds the state already set and answers `false` to the very request that set it. If every attempt
fails, no response carries the flag at all.

- **A cache invalidated only by the edge is stranded** until an unrelated read, usually an app restart.
  Nothing errors, and the write it depends on really succeeded.
- **Invalidate on SETTLE, announce on the edge.** Follow the write's success AND failure signals
  (SERVICE-WRITE-NEEDS-A-COMPLETION-SIGNAL, above) and re-read while the cache still shows the
  pre-write state. The one-time moment (popup, toast, funnel event) stays on the edge, so a re-read
  never announces twice.
- **Gate the re-read on the cache, not the call** - one extra read per state change, not per write.
- **Server-side re-keying is half a fix.** Deriving the edge from the stored request id (a retry of
  the same request re-reports it) repairs the timeout and retry-success cases, not all-attempts-threw;
  only the consumer-side re-read covers all three.

Observed: a play-gated daily reward, stamped by a match-result endpoint before a rate rail that can
throw, stayed locked after a qualifying match whenever an attempt failed past the stamp.

## DERIVED-ID-NEVER-TAGS-A-BIT - a sub-id built by OR-ing a flag onto a full-width key collides with the key

A key that uses all 64 bits (a hashed or random `ulong` identity) has no spare bit. `key | 1L << 62` is
"the bank of this key" only while bit 62 happens to be clear; on the first key that has it set, the derived id
IS the key, and two containers share one row set. Found on a durable item store: a character id with bit 61
set made its swap-parking container equal to its backpack, so the crash-recovery path "moved the parked
stacks into the first free slot" - reading the whole backpack as parked - and the next write hit an item
mismatch that killed the writer for the session ("storage unavailable" on every later action).

- Derive with a **mixer** (splitmix64 finaliser over `key ^ salt`), one salt per derived kind, and loop while
  the result is 0, equals the key, or equals any sibling derived id. The loop is the proof that no key can alias.
- A tag bit is only valid when the key provably reserves it (a sub-64-bit id with documented headroom).
- The regression test enumerates the hostile keys by construction: `1UL << 61`, `1UL << 62`, `1UL << 63`,
  `ulong.MaxValue`, the id that failed, and a small one - every derived id differs from the key and from each other.
- Symptom to recognise in review: any `id | CONSTANT`, `id + CONSTANT` or `id ^ CONSTANT` that names a second
  namespace of the same id.

## WRAP-BREAKS-AFTER-A-PATH-SEPARATOR  - a space-only word wrap splits a path mid-name

A fixed-width text view (a server console, a log pane, a terminal status block) that wraps only at spaces
hard-breaks any token longer than the row, and a file path has no spaces: a 120-column console printed a save
path's file name as `save-log.json` on one row and `l` on the next.

- Break at the last space in the row (past the label, so a label never sits alone on its row), else just after
  the last `\` or `/`, else hard at the width. The directory then ends its row and the file name stays whole.
- Continuation rows keep the value's hang column; measure the width in terminal cells, not `string.Length`.
- Test a real long path at a narrow and a wide width: every row fits, the file name sits whole on one row,
  every break is at a space or after a separator, and the rows rejoin to the input dropping only spaces. Add a
  token with neither to prove the hard break still fits.
- Measured: 1.3 us per wrapped line against 0.8 us for the space-only rule, no allocation added; paid once per
  written line, never per frame.

## MULTICAST-EVENT-THROW-SKIPS-LATER-SUBSCRIBERS  - one handler's exception silently cancels the rest

- **A C# `event`/`Action` invokes its subscribers in order, and an exception in one aborts the invocation, so every later
  subscriber never runs.** The failure shows up far from the throw: a flow event fired, but the UI subscribed after the
  throwing handler never hid its countdown or showed its panel. Measured: 1 run in 3 was stuck, because a randomly offered
  content item carried an enum value outside the enum's range and a colour lookup threw in an earlier subscriber.
- Diagnose: when "the event fired but my handler didn't run", read the log for an exception at that moment before debugging
  your handler. Don't wrap every subscriber in try/catch as a bandage. Fix the thrower, and validate authored enum values
  at load (`Enum.IsDefined`) so bad data fails at the asset, not in a random run.

## TEARDOWN-NEVER-MASKS-THE-BODY-FAILURE

- A probe's `Dispose` threw a NullReferenceException while unwinding a `using` block. That exception replaced the body's
  real failure, an assertion about a purchase not updating the installed item, so the first error pointed at teardown
  code.
- Teardown null-guards everything setup may not have created. A probe or test harness catches the body's exception
  first, runs teardown in its own try, and reports the body failure as primary with any teardown failure attached as
  secondary.
- When a probe's first error is in teardown, read past it for the body failure before touching any code.
