Tier 2 · C# programming

C# style & conventions

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

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 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).
  • 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.
  • 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.
  • 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.

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.

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.