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 ??= neware 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 inAwake(or underDefaultExecutionOrder), consumers read fromOnEnable/Startor later. AnEnsurecall 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+AssetDatabaseinside 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,_boundthat 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.Xinside 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
HealthControllerfor 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.Valueor 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/Loadof 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/
ToStringin loops, boxing (value types toobject/interface, enum keys inDictionarywithout a comparer,paramsarrays),foreachover non-Listinterfaces, coroutineyield return new WaitForSecondseach time (cache it),GetComponents/FindObjectsOfTypearrays, physics non-NonAllocqueries,Camera.mainin old versions,UnityEventinvoke args,Debug.Login shipping paths. - Fixes: cache and reuse (
Clear, notnew), pools,NonAlloc/List-overload APIs,StringBuilderor cached strings,structwith generic constraints instead of interfaces,ArrayPool/NativeArrayfor scratch. - Check (1) Profiler: CPU module GC Alloc column at 0 B on gameplay frames, plus the
GC.Collect/GC.Allocmarkers 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)andProfiler.GetMonoUsedSizeLong/GC.GetTotalMemoryin 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, theGetComponents/TryGetComponentallocation 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, notclass. (Part of ZERO-ALLOCATION-BY-DEFAULT.) Avoids per-instance heap allocation and GC churn (matters most in hot paths / per-frame code). Reserveclassfor things with identity, shared reference semantics, or inheritance. - Local functions / lambdas inside a
structcannot capture instance fields (CS1673). Copythisor 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 toFoo(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
newon 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.) Anewon 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 andClearit each call instead ofnew-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 freshnewis for genuinely one-shot setup (constructor /Awake/OnCreate/ init) or when the object needs its own identity. (newon astructdoes NOT allocate — it's inline; this rule is about reference types.) The test before anynew: 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 systemsnew-ing aHashSetevery 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 ordocs/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 globalInstance— 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.Clearremoves 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-rolledIsLocalPlayerwas added whenCustomNetworkManager.LocalPlayerIdwas 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 — useUnityEngine.Object.(CS0104). Withusing System;andusing UnityEngine;both in scope (almost every Unity file), the bare type nameObjectis ambiguous betweenSystem.ObjectandUnityEngine.Object. Agents keep shippingObject.FindObjectsByType/Object.Destroy/Object.Instantiateand burning a compile cycle. Default: qualify Unity statics asUnityEngine.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 deletingusing System;— that just moves pain. Do not introduce runtimeFindObjectsByTypeto 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 — soLog($"seat {i} did {thing}")stops allocating the interpolated string entirely. A runtimeif (enabled)guard still pays for the interpolation on every call; onlyConditionalremoves it. (2) Commenting out a log that is the SOLE body of a bracelessif/else/forsilently 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. KeepLogWarning/LogErrorun-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_EDITORso they cannot ship, after checking no scene UnityEvent references them by name. -
[Conditional]cannot express "unless" — wrap the ATTRIBUTE in#if, not the body. MultipleConditionalattributes 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
Conditionalwas removing. Grep the bodies ofUpdate/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 along-boxed value throws even though the numeric value fits. Read back viaConvert.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, unusedin 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.Editornamespace makes the bareEditortoken ambiguous in sibling files, so: Editorfails — 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_EDITORwhen both an editor preview and the runtime read it. The[Conditional]/#ifguidance 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 buildpre-checks read both defineUNITY_EDITOR. So a runtime method that lands inside an#if UNITY_EDITORblock 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_EDITORregion in a runtime assembly compiles the PLAYER scripts before merge: in the EditorPlayerBuildInterface.CompilePlayerScriptsfor the target (no full build), or offline a pass whose runtime csprojs haveUNITY_EDITOR*stripped fromDefineConstants. 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 keepsNETCODE_DEBUGdefined; it proves reachability only, not platform defines or stripping, so a real player build stays the final word.
-
ASYNC-VOID-SWALLOWS-THE-CHAIN —
async voidis for UI event handlers only; never in a boot / load / restart chain. An exception inside anasync voidmethod 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. ReturnTask,awaitit at one owner, andtry/catch+ report inside every loading task — never rely on the caller. (a tower-defense game:LoadingManager.StartLoading/ExecuteLoadingTasks,GameManager.RestartGame,Unit.HandleOnDeathallasync 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.
ToConfigre-seededGodModefrom 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 (
SessionEndingbefore dispose,SessionEndedafter) that every owner subscribes to; never bolt cleanup intoDisconnectorStart*, and never save fromOnDestroy. A grab-bag disconnect grows a hidden list of everything it must remember to reset. (a projectdocs/pitfalls/ui-login-flow.md:24.) - CSHARP-THREE-API-MISREADS. An indexer set through an
inparameter is an upsert the compiler refuses (CS8332) — pass by value;Dictionary.TryGetValueALWAYS writes itsout(a miss yields0, a valid index — set a sentinel first);Mathf.SmoothStep(a, b, t)is a lerp with a 0..1 interpolant, NOT HLSLsmoothstep(edge0, edge1, x). (a projectdocs/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 inN, not its nested namespaces.Unity.Networking.Transport.Error.StatusCodeis not reachable asError.StatusCodeunderusing Unity.Networking.Transport;— you getCS0103: The name 'Error' does not exist in the current context. Addusing N.Sub;(or fully qualify).- Why it ambushes you: package source declares its file INSIDE
namespace N, soN.Subresolves by enclosing-namespace lookup and needs no using at all. Mirror that same code into your own namespace — a customINetworkPipelineStage, 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 oneAction a = Foo;/Invoke(Foo)/list.Select(Foo)- delegate conversion- UnityEvent wiring serialised in
*.unity/*.prefabYAML (m_MethodName: Foo) - no C# reference at all - attribute entry points (
[RuntimeInitializeOnLoadMethod],[MenuItem],[UnityTest]) - the runtime calls it, nothing does - reflection /
SendMessageby 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 xin the same argument list is the same bug, andin xdoes not save you: the aliasing is in the CALLER's frame, not in the parameter's modifier.- Assume every
TryXclears itsoutbefore 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
outargument never names a variable already passed to the same call. Preferout 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 + CONSTANTorid ^ CONSTANTthat 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/Actioninvokes 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
Disposethrew a NullReferenceException while unwinding ausingblock. 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.