Tier 2 · Content pipelines and provenance
Content production audit
Census dead content, keep authoring surfaces the source of truth, ship features with UI.
Census dead content, keep authoring surfaces the source of truth, ship features with UI.
The production bar for authored content: censusing what is dead, keeping the authoring surface the source of truth, and never shipping a picker/upgrade/registry that only looks finished.
-
CONTENT-RENAME-SWEEP — a bulk content rename is TWO disjoint passes (display strings vs file-stem tokens), and the display pass must never run inside asset-name string literals. Renaming authored content at scale (IP purge, product rename) touches four coupled layers: DisplayName/description values, asset FILE names (+ name-paired icon files), seed/generator string literals, and C# identifiers. Traps measured on a 75-name / 890-file sweep: (1) a display replace applied blindly inside code literals corrupts asset-name strings ("Ability_Orc_Ensnare" -> "Ability_Orc_Snare Nets") and seed file-suffix args ("Permafrost" -> "Grave Chill") — the seed then LoadAssetAtPath-misses and mints DUPLICATE assets with the old names on its next run; scan for spaced tokens inside
(Upgrade|Ability|Unit|...)_literals after any sweep. (2) Rename files viaAssetDatabase.MoveAsset(GUID + .meta + m_Name all survive; on-disk renames orphan metas), and move name-paired icon pngs in the same batch when an assigner binds by{asset.name}.png. (3) Names shared by two owners (two races' "Slow Poison"; an academy track and a hall spell both "Raise Dead") need SCOPED rules — a flat old->new table silently merges them into one wrong name. (4) Verify with a reverse audit: every shipped display name substring-searched against the source corpus (word-boundary, NUL- stripped binaries), plus a boundary-check that no NEW name exists in the corpus — and expect substring false positives ("Zeal" in "Zealot", "Void Wave" in "avoid wave"): confirm hits with(?<![a-z])name(?![a-z])before renaming on their account. (a mobile strategy game W3 IP purge.) -
A value meant to be tuned lives on the relevant system's tuning surface, never buried as a
constin a system. Gameplay/feel/balance numbers — ranges, durations, rates, thresholds, footprints, cooldowns, costs — belong in the domain's tuning home so they're discoverable and adjustable in ONE place, not scattered as magicconsts (and NEVER the same number duplicated as aconstin two systems — that's a desync bug waiting to happen). Homes, in project order of preference: (1) the established static tuning class pattern (*Tuningwith namedconstdefaults, PlayerPrefs-backed -
a
Version+ aSubsystemRegistrationreset when it needs live nudging — seeClaimInfluenceTuning/BuildingUpkeepTuning); (2) a per-instance authoring field when the value is geometry/config that varies per prefab (footprint size, slot count); (3) server config (ServerConfigStore) when it must be server-authoritative / per-shard. Reserve the player Settings panel for actual player preferences, not balance. This does NOT mean extract every literal — epsilons, FNV seeds, buffer capacities, and other implementation constants stay put. The test: would a designer ever want to change this to tune how the game feels/balances? If yes, it's a tuning value — surface it. (Burst caveat: a static tuning class read from a Burst job won't compile / is unsafe; read it on the main thread or bake it into a component the job reads.) -
New authored values ride the project's EXISTING authoring surface in the same slice — a literal typed into creation code that a designer can't rediscover from the Control Panel is a defect, not a convenience. When a project has a designer console / authoring tab owning an asset category (defs, materials, looks, tuning), any new content you land must be fully editable there before the slice is done: (1) values seeded through the same SO the tab already edits are fine — they're just defaults; (2) if your new content introduces fields the tab does NOT cover (a new SO field, an exposed shader/material property), EXTEND the tab in the same slice — as a GENERIC section over the category (e.g. enumerate a material's exposed shader properties for ANY projectile visual), never a per-instance section named after your feature; (3) inspector-only tuning on a material/prefab does not count as authored when the project's convention is panel authoring. The tell: you typed a color/speed/scale literal into an editor script or left exposed material properties reachable only via the raw Inspector.
-
MATCH-KICKOFF-AUTHORING (HARD) — start gold / base income / seat mana / match economy kickoff are ScriptableObject data, never live
const/ magic numbers in systems. Systems may keep compile-time fallback values only for fail-soft when the SO is unwired (and mustLogErrorwhen falling back). Day-to-day balance edits happen on the SO + Control Panel tab. Bootstrap reads the SO (or a baked singleton stamped from it). PuttingStartingGold = 100in a static class as the real path is a production bar failure — same class of bug as hardcoding shop offers in C#. (Seen in a mobile strategy game: SC starts 300 gold; runtime usedEconomyTuning.StartingGold = 100const untilEconomyRulesAsseton the content bridge.) -
NO-OPTIONAL-WIRE-ASK (HARD) — never ask the developer “want me to wire that?” for an obvious production / authoring-surface gap. If the project bar is data-driven and you found a hardcoded match-critical knob, wire the SO + Control Panel + bootstrap path in the same slice. Asking is not a plan; it is stalling. Same posture as AUTHORING-SURFACE-SAME-SLICE and global-promote: clear general footguns get fixed without permission theater.
-
SELECTION-PANEL-CATALOG (HARD) — a cosmetic picker (avatar, emote, card back, skin, banner, title) is not a panel; it is a CATALOGUE + an EQUIPPED STATE + a RESOLVER. Ship all three in the same slice or you shipped a mock. Every picker in this family has the same spine, and the panel is the least of it: 1. Catalogue SO in the project's authoring folder — one row per item carrying a stable id, the asset ref (sprite / material / prefab), display name, unlock rule, sort order. Row order may be presentation; the id is the identity. 2. Authoring tab on the existing console, so a new cosmetic is a row the developer adds — never a C# edit and never a hand-built scene cell (AUTHORING-SURFACE-SAME-SLICE, DC-AUTHOR). 3. Resolver + persisted equipped state: ONE
id -> recordlookup every consumer goes through, the equipped id(s) saved server-side, and — for anything other players SEE — replicated to the table. A cosmetic nobody else sees is a bug, not a scope cut.
The failure mode is that a hardcoded picker looks finished. It renders, cells highlight, the
demo is flawless, and it is a no-op: nothing persists, nothing unlocks, no opponent ever sees the
choice. Cells populated by walking a scene hierarchy, lockLevel typed as a literal, "equipped"
living in the panel's own field, and N OF M counts hand-written into TMP strings are all the
same defect. Never persist or transmit the array index — inserting one item at the top
silently re-skins every player who already chose. Same family as PAID-NO-OP-CONTENT: names and
icons land first, so the dead rows look shipped. The cheap tell during review: grep the panel for
the catalogue type — if the picker never references it, there isn't one. (Seen in a mobile card game: four profile pickers — avatar, emoji, card back, animations — built 1:1 from the CSS mock
and fully interactive, with zero catalogue, zero persistence, and unlock levels frozen to the
mock's LV2 snapshot.)
- NO-CONTENT-SEED-OVERWRITE (HARD) — never re-run content seeds or edit balance via seed code;
add new authoring assets so the developer can edit them by hand. Once a project has content SOs
(upgrades, units, buildings, shops, abilities, audio banks, bot profiles…), those assets are
developer-owned. Agents must not:
1. Call seed/bootstrap actions that rewrite existing SOs or shop lists (
Seed Human + Orc, "re-seed",Upsert*that overwrites authored fields, import scripts that force values back onto live assets) unless the user explicitly says to re-seed / reset content. 2. Change gameplay numbers by editing seed C# (UnitModelContentSeed,*ContentSeed, ladder generators) and re-running them — that clobber hand-tuned Control Panel work. 3. "Fix content" by regenerating the whole pack.
Do instead: create new ScriptableObject entries under the project's Data folders (or
Control Panel → New), wire them into the existing catalog / building ShopOffers[] /
race lists as additive references, leave defaults reasonable, and stop — the developer edits
costs, effects, and prereqs in the authoring surface. Seed code stays for first-time empty
project bootstrap and for schema migration only when the user asks. Companion to
AUTHORING-SURFACE-SAME-SLICE: new knobs must be panel-editable; this rule forbids bulk-overwrite
as the delivery vehicle.
-
GENERATED-REGISTRY-OUTLIVES-ITS-SOURCE — a registry/catalog built by an editor generator keeps shipping rows whose SOURCE was deleted, and every one of them drags its whole asset closure into the build. The shape: a generator walks a source-of-truth catalog and writes a flat
id -> prefablist into a subscene/SO; a baker then doesGetEntity(prefab)on every row. When someone later trims the source (cuts a faction, drops a unit), the generator is not re-run — so the registry still holds rows nothing can reach, the baker still pulls their prefabs, and their meshes and textures still ship. Nothing errors, nothing warns, and the rows look completely legitimate in the Inspector. Measured (a mobile strategy game): 213 registry entries against a 6-race catalog that could only reach 169 — the 45 orphans were 4 whole races cut months earlier, and they were a large slice of a 729 MB APK. Audit by REACHABILITY DIFF, not by eye: recompute the id set the current source can reach (mirror the generator's own traversal), diff it against the registry's keys, and treat every unreachable key as a defect. That one query is the whole check, and it is worth running on any generated id-map after content is cut. Fix the GENERATOR, never the generated output. Hand-deleting rows is undone the next time anyone runs the action — the same time-bomb shape as POISON-FILE-DELETE-AND-IGNORE. Re-running the generator is also what makes the fix self-maintaining: when the faction ships, add it back to the source catalog and re-run. Two corollaries: (1) confirm the generator derives its rows from the source catalog rather than a folder scan before trusting a re-run to drop anything — aFindAssetsscan re-adds everything on disk; (2) a full regenerate is usually destructive (deletes and rebuilds every generated prefab, rewires scenes), so it needs explicit developer consent and a note that the AUTHORED source assets survive — only generated artifacts are rebuilt. -
DEAD-AUTHORING-FIELD + MEASURE-THE-INPUT — before you "fix" a look, grep the CONSUMER of every knob the authoring surface exposes, and measure the variance of any signal a procedural technique keys on. Two paired traps from one terrain investigation, both of which make a system look broken when it is merely incomplete or misapplied. (1) An authored field that nothing reads is worse than a missing feature: a per-layer
NormalMapslot sat in the catalog SO and in the designer tab for months while the bake contained zero references to it and the shader had no normal sampler at all — so the terrain was albedo-only, the developer could assign a normal map and watch nothing happen, and the natural conclusion was "the procedural shader isn't being used" when the stochastic anti-tiling was in fact running perfectly. Auditing a look starts by proving each authored field reaches a consumer (grep the field name outside its declaring type; zero hits = dead knob), not by re-deriving the pipeline. (2) A procedural kernel is only as good as the variance of what it samples: a height/slope auto-splat proposed for an arena measured 1.2 m of total relief with 95.8% of the surface under 5° — 4 bands collapsing to 1, which would have erased every painted lane on an RTS board. Measure first (TerrainData.GetHeightsrange + aGetSteepnesshistogram) and let the number decide between full replacement and modulation (noise/slope redistributing painted weights, with per-layer opt-in so gameplay-critical layers keep paint absolute). The modulated form is usually the right answer on authored boards: it adds variation without ever taking authority away from the thing the player must read. Related numeric tell from the same session: a splat tile size of 1–3 m per tile on a 160 m board (~64–160 repeats) reads as fine noise no matter how good the anti-tiling — check the tiling scale against the shader's own authored defaults before blaming the sampling. (a mobile strategy game.) -
PAID-NO-OP-CONTENT - census every purchasable/researchable asset for an EMPTY effect payload before shipping a tech tree; a row with no effect is a shop item that takes the player's gold and does nothing. Content generated from a source design (an imported tech tree, a spreadsheet) lands names, icons, costs and prereq chains first and effects last - so the half-authored rows look completely finished in the authoring UI and in game: real name, real icon, real cost, real research bar, real "researched" tick. Nothing errors. Only a data census finds them. The check is one pass over the catalog:
effects.length == 0 && grantedAbility == null && primaryEffectKind == Noneis dead by construction. Run it as an audit, not by eye - on one 7-race sweep this surfaced 75 dead research rows (7 of 9 tracks on one race's tower academy, 8 of 9 on another) that had been buyable for months at 500-1500 gold each. Pair it with the DEAD-AUTHORING-FIELD grep (a knob nothing reads) -
same class: authored, shipped, unconsumed. When you fill them, fill ONLY the provably-dead rows so hand-tuned siblings survive the pass. (a mobile strategy game.)
-
STAT-SCALE-LINEAR-ONLY - when porting a balance table from a reference game under a global scale factor, scale the LINEAR stats only; unitless/diminishing stats keep their source values, and the port is not done until you re-assert the ORDERING it was supposed to preserve. Deriving content from a reference title (a W3 map extract, a spreadsheet, a competitor's numbers) usually fixes one ratio - e.g.
SC_HP = W3_HP x (350/1500)- and then that factor gets applied down the whole stat block by reflex. It is only valid for quantities measured in the same unit as the anchor. Armor points, resistance percentages, crit chance, cooldown reduction, and any stat fed through a diminishing curve (1 - ka/(1+ka)) are NOT in that unit - halving them does not "halve durability", it silently moves the entity to a different band of a nonlinear curve. The failure is invisible in review because every number still looks plausible in isolation; it surfaces only as a FEEL complaint ("X dies way too fast"). Two mechanical guards, both cheap: (1) after any derived-table pass, print min/max of each stat per CATEGORY and assert the intended ordering still holds (defenders >= attackers, elites >= trash); (2) audit the counter/resistance table cell-by-cell against the source rather than trusting the summary row - a scaled-down defensive stat and a couple of over-generous table cells compound MULTIPLICATIVELY, so each edit looks minor while the product lands ~2x off. Corollary: an armor/resistance column owned by exactly one entity class (a "Fortified" = buildings-only column) is a fully scoped balance lever - census the authored assets to prove the exclusivity, then retune that whole matchup without touching any other pairing. (Seen in a mobile strategy game: the HP factor was applied correctly, but building armor shipped at 2/3/5 against a unit band of 0-6, so a barracks was softer than the cavalry sieging it, while Normal-vs-Fortified sat at 100% (W3: 70%) and Siege-vs-Fortified at 220% (W3: 150%). Structures folded to a plain wave for months; restoring the W3 values gave ~1.7x building TTK with no HP change.) -
DEAD-OPTIMIZATION-NEVER-RUN — a complete, correct, fully-wired LOD/culling system does NOTHING if the data it consumes was never generated, and nothing in the codebase says so. Sibling of DEAD-AUTHORING-FIELD (a knob nothing reads); this is the inverse — a CONSUMER whose input is empty. The shape: a renderer takes
lodMeshes/impostors/simplified colliders, falls back to "full detail" when the array is empty (correct, and the right default), and the editor action that FILLS those arrays is a menu item someone has to remember to run. Reading the code proves nothing: the renderer is right, the bake is right, the switch thresholds are right, and the game still draws every triangle at full detail forever. It survives review precisely because every part is individually correct. Only a data census finds it — enumerate the live content and count how many entries actually carry the optimized payload (batches with lodMeshes > 0vs total). On one arena that census read 0 of 488 batches, i.e. 15.6M triangles of trees with a fully-built LOD system attached and idle; the shipped presentation PREFAB separately sat at 119 of 262, so a partial earlier run had made it look done. Census the shipped artifact, not the dev scene — scatter/detail data commonly lives on a presentation prefab while the dev scene holds a stale copy, and fixing only the scene ships nothing. Guards: (1) make the bake re-runnable and additive so a census-driven re-run is always safe; (2)LogWarningat load when a field registers with zero LOD coverage, so "never run" is loud rather than invisible; (3) treat "we built an optimization system" and "the optimization is active in the build" as two separate claims needing two separate pieces of evidence. (Seen in a mobile strategy game: 23 fps at full RTS zoom-out; the detail LOD system had shipped months earlier and had never been baked for the live map.) -
SO capture
Count > 0still wipes when child lists are empty (HARD). A workbench ScriptableObject / scene object often holds shells (laneRoutes.Count == 3withpointsWorld = []). Saving that into a package that already has authored data is a silent wipe — match load then falls back to parametric mid / default geometry and every unit marches the wrong path. Guard capture on payload (HasPoints, spline sample count), not list Count. Log and keep the package copy when the workbench is empty. The same trap exists for any nestedList<T>whose items carry the real arrays. (Seen in a mobile strategy game: Control Panel Save map package after shrine placement replaced 12 authored ring routes with 3 empty shells. Waves went mid.) -
IMPORT-BINDS-EVERY-EXPORTED-MAP — exported-textures MINUS bound-textures is the finding. A missing normal or roughness map renders plausibly; a binder that only wires albedo dropped normals + roughness across 99.6 % of a map with nothing on screen to say so. Check the material's slot count against what was exported; exported is not used. (a survival game)
- MISSING-DIFF-INTERSECTS-QUARANTINE — a what's-missing diff cannot tell never-imported from
deliberately rejected; intersect against every exclusion list FIRST or the diff resurrects
quarantined content. An importer that mutates prefabs another lane also mutates takes a keys
filter (a full
ImportAllsilently wiped grafted saddles), and a fix pass must be able to UNDO its own earlier output — emit explicit clear directives and clear only what is currently bound. (a survival game) - VALIDATOR-COVERS-EVERY-SOURCE — an aggregating validator that only LOADS a domain's assets is
not validating them; give each domain its own
ValidateDefinitionloop. A next-free-stable-id allocator scans EVERY source that can claim an id in the band, not just the code factory catalog (reserved seam ids lived only in a starter catalog); never reuse retired ids, never use display names as ids. (a survival game) -
ROUNDED-CONVEX-HULL-IS-MORE-SUPPORT-PLANES — a bevelled / rounded convex stone is still one convex cell: express the rounding as extra half-space planes, never as post-hoc mesh surgery. Recipe (one shipped project
RockMeshGenerator.Facets.cs, the Mirza Beig "stylized faceted rock"): N random cut planes, each MIRRORED with an independent distance (bounded stone, never symmetric); push every plane in by the bevel radius r to get the core; roll a sphere of radius r around the core = the original planes put back plus one plane per core EDGE at each slerp step between its two face normals (distance = edge·n + r) and one per core VERTEX at the mean normal (+ spoke midpoints for finer arcs). Clip a big cube by that plane list and you get flat facets, chamfers or rounded bevels from one knob, and the result stays a convex polytope so any BSP fracture, vein/ore assignment or per-face paint downstream consumes it unchanged. Cost is the clipper on a cell that grows to ~plane-count faces — 16 mirrored planes / 2 arc steps ≈ 560 planes ≈ 70 ms, 3 steps ≈ 300 ms — so bake, never per frame, and give pebbles/satellites half the planes and no bevel. Corner slider = corner-sphere reachlerp(1.25r, r): above r the corner planes only graze the pyramid the arcs leave (pointy), at r they trim it flush. Paint flat per facet — a smooth probe normal is meaningless on vertices that sit off the unit sphere. (one shipped project.) -
MODE-SCALE-MULTIPLIES-THE-RESULT, NEVER THE AUTHORED VALUE — halving a tuned input does not halve the output, because the floor underneath it eats the difference. A per-mode / per-difficulty / per-tier speed or intensity multiplier is applied to the RESULT of the tuning pipeline, not by editing the authored numbers the pipeline reads. Editing the source is the obvious move and it silently under-delivers: measured on a three-band timing table (2 / 3 / 4s) shaped by a personality weight and a per-instance jitter and then clamped to a 1.6s floor, halving the BANDS to 1 / 1.5 / 2 produced ~1.6s flat — about 20% faster instead of 50%, and every low-band instance identical again, re-introducing the fixed-wait feel the jitter existed to remove. Multiplying the pipeline's OUTPUT by 0.5 delivered exactly half on every band (2.02/3.00/4.00 → 1.01/1.50/2.00) because a positive scalar commutes with
min/max, so one multiply after the clamps is provably identical to scaling the value, the floor AND the ceiling together, and it is one line rather than N authored edits that drift apart. Then classify every floor the multiply passes over — this is the part that is a judgement, not maths. A PACING floor (stop this reading inhumanly fast) scales with the mode and belongs ABOVE the multiply. A HUMAN-REACTION floor (a window someone must notice and click inside) is a UX constraint, not a feel knob, and scaling it can make the interaction unusable while every number still looks right — decide per floor and write down which side it sits on. A SAFETY RAIL clamped against a real deadline (a timer that expires, a budget that overruns) protects an invariant, never a feel: it sits BELOW the multiply and is never scaled, or a fast mode can hand back a value its own deadline forbids. The authoring surface ships the resulting per-mode readout, because "the band says 4s" stops being true the moment any mode is paced. -
SCATTER-FOOTPRINT-MUST-FIT-THE-RELIEF — a scattered instance is seated by one position and one rotation, so the gap under it is set by its own footprint, never by how accurate the normal is. Measured on a kit's ground cover: meshes 0.32 m, 2.47 m and 6.89 m wide standing 0.26 m, 0.70 m and 0.69 m tall, plus a 2.16 m mat only 0.11 m tall. On ground tilting 15 degrees the far corner of the 6.89 m mesh rises
footprintRadius * sin(tilt)= 1.38 m, twice its own height, and the 0.11 m mat rises 0.43 m, four times its height. Aligning to the ground normal redistributes that gap and cannot remove it: radial up floats the downhill edge byr * tan(theta), normal up lifts the far corner byr * sin(theta), and both are metres onceris metres. So read the mesh bounds BEFORE authoring a scatter layer and accept only instances smaller than the distance over which the ground stays planar; a rigid mesh wider than the relief is an art defect that no placement code fixes. Two traps in the same measurement: a kit pivot can sit off the footprint centre (0.95 m measured), so height, slope and acceptance are all evaluated about a metre from where the mesh actually lands; and a global slope-probe baseline is wrong for every species at once — a fixed 2 m baseline over-smooths a 0.32 m clump by 6x and under-measures a 6.89 m mat by 3x, so the baseline is the instance's own ground-contact radius (the trunk for a tree, the whole spread for ground cover). The oversized instance is also the draw-cost defect: one candidate per 1 m cell carrying 4.14 m-radius meshes is 7-8x overdraw, so the footprint fix and the fill fix are the same edit.
FEATURE-SHIPS-WITH-ITS-UI
, and the same day: "we also need a pass to all UI we currently have so its redone to fit landscape android well".
- Every new feature ships, in the same slice, with polished UI: the player-facing screen and its authoring surface (a designer-console section with a visual preview, clear status and pass/fail readouts, labelled lists). A raw property field plus a text label is not done.
- Scope the UI work next to the feature work when the slice is cut. Reuse the existing preview element, theme classes and UI kit before building new ones, and route player-facing UI to whoever owns the UI system.
- Every screen, new or existing, fits the shipping form factor (for a mobile game: landscape phones and tablets) at each target aspect.