gamedev-skills/awesome-gamedev-agent-skills已通過檢查
SKILL DETAIL
unity-csharp-scripting
gamedev-skills/awesome-gamedev-agent-skills/unity-csharp-scripting
Write Unity 6.3 LTS C# gameplay scripts: the MonoBehaviour lifecycle (Awake/OnEnable/Start/Update/FixedUpdate/LateUpdate), GameObject and component access, coroutines, and Inspector serialization. Use when creating or editing .cs scripts in a Unity project, or when the user mentions MonoBehaviour, Start/Update, GetComponent, SerializeField, coroutines, or "Unity script".
安裝量 · 222查看來源
Installation
npx skills add https://github.com/gamedev-skills/awesome-gamedev-agent-skills --skill unity-csharp-scripting
技能檔案
SKILL.md
最近同步 · 2026年10月24日
references/lifecycle-and-coroutines.md›
# MonoBehaviour lifecycle & coroutines (Unity 6.3 LTS)
Depth for the `unity-csharp-scripting` skill: the full execution order and the coroutine
patterns that don't fit in the main playbook. Verified against the Unity Manual
"Event function execution order" page and `ScriptReference/MonoBehaviour`.
## Execution order (the parts that matter for gameplay)
Per object, the engine calls these in this order:
| Phase | Callback | Runs | Use for |
|-------|----------|------|---------|
| Load | `Awake` | once, when the object loads (even if disabled) | cache `GetComponent`, set up self |
| Enable | `OnEnable` | each time the object/script enables | subscribe to events, re-arm coroutines |
| Init | `Start` | once, before first `Update`, after every `Awake` | wiring that depends on other objects |
| Physics | `FixedUpdate` | every fixed step (default 0.02s) | rigidbody forces, `MovePosition` |
| Physics | `OnTriggerXXX` / `OnCollisionXXX` | during the physics step | collision/trigger response |
| Frame | `Update` | once per rendered frame | input polling, non-physics logic |
| Frame | `LateUpdate` | once per frame, after all `Update`s | camera follow, IK fix-up |
| Disable | `OnDisable` | each time the object/script disables | unsubscribe from events |
| Teardown | `OnDestroy` | once, when destroyed | release native handles, save |
Key consequences:
- `FixedUpdate` may run zero, one, or several times per frame depending on frame rate. Never
read raw "per frame" input there — read it in `Update`, consume it in `FixedUpdate`.
- All `Awake` calls finish before any `Start`. Order *among* `Awake`s (and among `Start`s) is
undefined unless you set a Script Execution Order in Project Settings.
- `OnEnable` runs after `Awake` on first enable, then again on every re-enable. Pair every
`OnEnable` subscription with an `OnDisable` unsubscription to avoid duplicate handlers.
## Coroutine yield instructions
```csharp
yield return null; // resume at the start of the next frame
yield return new WaitForSeconds(2f); // wait 2s of scaled game time (affected by Time.timeScale)
yield return new WaitForSecondsRealtime(2f);// wait 2s of real time (ignores timeScale; good for pause menus)
yield return new WaitForFixedUpdate(); // resume after the next physics step
yield return new WaitUntil(() => isReady); // resume once the predicate is true
yield return new WaitWhile(() => isLoading);// resume once the predicate is false
yield return StartCoroutine(OtherRoutine());// run a nested coroutine to completion first
```
## Stopping coroutines deterministically
Keep the handle so you can stop exactly the right routine:
```csharp
private Coroutine _spawnLoop;
private void OnEnable() => _spawnLoop = StartCoroutine(SpawnLoop());
private void OnDisable() { if (_spawnLoop != null) StopCoroutine(_spawnLoop); }
private System.Collections.IEnumerator SpawnLoop()
{
var wait = new WaitForSeconds(1f); // allocate once, reuse — avoids per-iteration GC
while (true)
{
Spawn();
yield return wait;
}
}
```
`StopAllCoroutines()` stops every coroutine on the MonoBehaviour — convenient but blunt;
prefer stopping by handle when multiple routines run on one object.
## Custom yield instruction
When you want a reusable wait condition:
```csharp
public class WaitForAnimationEnd : CustomYieldInstruction
{
private readonly Animator _a;
private readonly int _layer;
public WaitForAnimationEnd(Animator a, int layer = 0) { _a = a; _layer = layer; }
// keepWaiting == true means "keep yielding"; false resumes the coroutine.
public override bool keepWaiting => _a.GetCurrentAnimatorStateInfo(_layer).normalizedTime < 1f;
}
```
## Gotchas
- Coroutines are killed when the GameObject is **deactivated** (`SetActive(false)`) or the
script is destroyed — but *not* when only the script component is disabled while the object
stays active. Re-arm in `OnEnable` if needed.
- A coroutine started from `Awake` will not advance past its first `yield` until the object is
active and the script enabled. Prefer starting loops in `OnEnable`/`Start`.
- Allocating `new WaitForSeconds(x)` every loop iteration creates garbage; cache the instance.
scripts/check_unity_project.py›
#!/usr/bin/env python3
"""Check a Unity 6 project for broken .meta files and missing script references.
Run it after creating, moving, renaming or deleting files outside the Unity
Editor. Unity has not imported those changes yet, so this looks for the edits
that will break the project on the next import:
ERRORS (the project will really break)
duplicate-guid Two .meta files share a GUID (usually a copied .meta).
moved-without-meta A .meta was left behind while its asset moved or was
renamed. Unity would give the asset a new GUID and every
reference to it would break ("Missing script").
malformed-meta A .meta has no valid 32-hex-digit `guid:` line.
missing-script A text-serialized scene/prefab/asset has an
`m_Script: {fileID: 11500000, guid: X, type: 3}` reference
and no .meta in Assets/, Packages/ or the package cache
has GUID X. Reported as a warning instead when some
packages could not be read (for example when
Library/PackageCache does not exist yet).
WARNINGS
missing-meta An asset or folder has no .meta yet. This is normal for
a new file: Unity creates the .meta on the next import.
It does not make --strict fail.
orphan-meta A .meta whose asset or folder is gone.
meta-name-case A .meta matches its asset only when case is ignored.
class-name-mismatch A file declares a MonoBehaviour/ScriptableObject subclass
whose name differs from the file name, so Unity cannot
attach it.
Only Assets/ and the embedded packages in Packages/ are checked. Library/,
Temp/, Logs/ and similar folders are never walked, except that .meta GUIDs are
read from Library/PackageCache and from local `file:` packages so their scripts
count as present. Files Unity ignores during import (names starting with `.`
outside Assets/StreamingAssets, names ending with `~`, files and folders named
`cvs`, `.tmp` files, hidden folders) are skipped. Binary-serialized files (not
starting with `%YAML`) are skipped.
Standard library only. Read-only: nothing is ever written.
"""
from __future__ import annotations
import argparse
import json
import os
import re
import stat
import sys
from dataclasses import dataclass, field
from pathlib import Path
from typing import Callable
TOOL = "check_unity_project"
SCHEMA = 1
# Text-serialized Unity files that can hold MonoBehaviour/ScriptableObject
# documents with an m_Script reference.
YAML_EXTENSIONS = frozenset({".unity", ".prefab", ".asset", ".controller", ".playable"})
# fileID Unity uses for the MonoScript inside a .cs script asset. Scripts that
# live in DLLs use other fileIDs, which are not checked.
MONO_SCRIPT_FILE_ID = "11500000"
# Built-in resources use GUIDs such as 0000000000000000e000000000000000 and
# 0000000000000000f000000000000000; they have no .meta in the project.
BUILTIN_GUID_PREFIX = "0" * 16
GUID_LINE_RE = re.compile(r"^guid:\s*(\S*)\s*$")
GUID_RE = re.compile(r"^[0-9a-fA-F]{32}$")
FOLDER_LINE_RE = re.compile(r"^folderAsset:\s*yes\s*$")
YAML_DOC_RE = re.compile(r"^--- !u!(\d+) &(-?\d+)")
YAML_NAME_RE = re.compile(r"^ m_Name:\s*(.*?)\s*$")
YAML_GAMEOBJECT_RE = re.compile(r"^ m_GameObject:\s*\{\s*fileID:\s*(-?\d+)")
YAML_SCRIPT_RE = re.compile(
r"m_Script:\s*\{\s*fileID:\s*(-?\d+)\s*,\s*guid:\s*([0-9a-fA-F]{32})\s*,\s*type:\s*(\d+)\s*\}"
)
YAML_ANY_GUID_RE = re.compile(r"guid:\s*([0-9a-fA-F]{32})")
# Text assets can hold single lines of hundreds of MB (mesh or terrain data);
# they are read in pieces of this size so memory stays flat.
YAML_CHUNK_BYTES = 64 * 1024
# C# comments and string/char literals, removed before looking for classes.
CS_NOISE_RE = re.compile(
r"//[^\n]*"
r"|/\*.*?\*/"
r"|@\"(?:[^\"]|\"\")*\""
r"|\$?\"(?:\\.|[^\"\\\n])*\""
r"|'(?:\\.|[^'\\\n])*'",
re.S,
)
CS_CLASS_RE = re.compile(
r"(?P<mods>(?:\b(?:public|internal|private|protected|sealed|abstract|static|partial|unsafe|new|file)\s+)*)"
r"\bclass\s+@?(?P<name>[A-Za-z_]\w*)\s*(?P<generic><[^>{]*>)?\s*:\s*"
r"(?:global::)?(?:UnityEngine\s*\.\s*)?(?P<base>MonoBehaviour|ScriptableObject)\b"
)
CS_BRACE_RE = re.compile(r"\bnamespace\s+[\w.]+\s*\{|\{|\}")
CS_MAX_BYTES = 2 * 1024 * 1024
WINDOWS_HIDDEN = getattr(stat, "FILE_ATTRIBUTE_HIDDEN", 2)
@dataclass
class Issue:
level: str # "error" or "warning"
code: str
file: str
line: int | None
message: str
fix: str
def as_json(self) -> dict:
return {"code": self.code, "file": self.file, "line": self.line, "message": self.message, "fix": self.fix}
@dataclass
class Meta:
rel: str # posix path of the .meta, relative to the project root
dir_rel: str
asset_name: str
guid: str | None
guid_line: int | None
is_folder: bool
problem: str | None = None # set when the .meta is malformed
@dataclass
class Missing:
"""An asset or folder that has no .meta next to it."""
rel: str
dir_rel: str
name: str
is_dir: bool
reportable: bool # False when it sits inside a folder that is itself new
files_inside: int = 0
paired: bool = False
@dataclass
class Scan:
root: Path
files_checked: int = 0
metas: list[Meta] = field(default_factory=list)
orphans: list[tuple[Meta, bool]] = field(default_factory=list) # (meta, asset exists but is hidden)
missing: list[Missing] = field(default_factory=list)
yaml_files: list[Path] = field(default_factory=list)
cs_files: list[Path] = field(default_factory=list)
issues: list[Issue] = field(default_factory=list)
def rel(self, path: Path) -> str:
try:
return path.relative_to(self.root).as_posix()
except ValueError:
return Path(os.path.relpath(path, self.root)).as_posix()
def add(self, level: str, code: str, file: str, line: int | None, message: str, fix: str) -> None:
self.issues.append(Issue(level, code, file, line, message, fix))
# ---------------------------------------------------------------- file walking
def is_hidden(entry: os.DirEntry, in_streaming_assets: bool) -> bool:
"""Apply Unity's import rules for files and folders it ignores."""
name = entry.name
try:
is_dir = entry.is_dir()
except OSError:
return True
if name.endswith("~") or name.lower() == "cvs":
return True
if name.startswith(".") and not in_streaming_assets:
return True
if not is_dir and name.lower().endswith(".tmp"):
return True
if is_dir and os.name == "nt":
try:
if entry.stat().st_file_attributes & WINDOWS_HIDDEN: # type: ignore[attr-defined]
return True
except (OSError, AttributeError):
pass
return False
def list_dir(path: Path, in_streaming_assets: bool) -> tuple[list[str], list[str], set[str]]:
"""Return (visible files, visible folders, every name on disk), sorted."""
files: list[str] = []
dirs: list[str] = []
every: set[str] = set()
try:
entries = list(os.scandir(path))
except OSError:
return files, dirs, every
for entry in entries:
every.add(entry.name)
if is_hidden(entry, in_streaming_assets):
continue
try:
if entry.is_dir():
dirs.append(entry.name)
elif entry.is_file():
files.append(entry.name)
except OSError:
continue
return sorted(files), sorted(dirs), every
def first_visit(folder: Path, seen: set[str]) -> bool:
try:
key = os.path.realpath(folder)
except OSError:
return False
if key in seen:
return False
seen.add(key)
return True
def read_meta(path: Path) -> tuple[str | None, int | None, bool, str | None]:
"""Return (guid, guid line, is folder, problem) for one .meta file."""
guid = None
guid_line = None
is_folder = False
problem = None
try:
with path.open("r", encoding="utf-8-sig", errors="replace") as handle:
for number, raw in enumerate(handle, start=1):
line = raw.rstrip("\r\n")
if guid is None:
match = GUID_LINE_RE.match(line)
if match:
guid_line = number
value = match.group(1)
if not GUID_RE.match(value) or set(value) == {"0"}:
problem = f"invalid guid '{value}' (expected 32 hex digits)"
else:
guid = value.lower()
if FOLDER_LINE_RE.match(line):
is_folder = True
# The header (fileFormatVersion, guid, folderAsset) is at the top.
if guid_line is not None and number >= 4:
break
if number >= 40 and guid_line is None:
break
except OSError as exc:
return None, None, False, f"could not be read ({exc.strerror or exc})"
if guid_line is None:
problem = "has no 'guid:' line"
return guid, guid_line, is_folder, problem
def walk_linted(scan: Scan, top: Path) -> None:
"""Walk Assets/ or one embedded package and record metas and assets."""
streaming_root = (scan.root / "Assets" / "StreamingAssets").as_posix()
# Stack of (folder, top-most new folder above it, or None).
stack: list[tuple[Path, Missing | None]] = [(top, None)]
seen: set[str] = set()
while stack:
folder, new_parent = stack.pop()
if not first_visit(folder, seen):
continue # a symlink loop
folder_posix = folder.as_posix()
in_streaming = folder_posix == streaming_root or folder_posix.startswith(streaming_root + "/")
files, dirs, every = list_dir(folder, in_streaming)
dir_rel = scan.rel(folder)
scan.files_checked += len(files)
if new_parent is not None:
new_parent.files_inside += sum(1 for name in files if not name.endswith(".meta"))
metas = {name[: -len(".meta")]: name for name in files if name.endswith(".meta") and len(name) > 5}
assets = [(name, False) for name in files if not name.endswith(".meta")] + [(name, True) for name in dirs]
asset_names = {name for name, _ in assets}
missing_here: dict[str, Missing] = {}
for name, is_dir in assets:
if name in metas:
continue
item = Missing(
rel=f"{dir_rel}/{name}",
dir_rel=dir_rel,
name=name,
is_dir=is_dir,
reportable=new_parent is None,
)
missing_here[name] = item
scan.missing.append(item)
lowered_missing = {name.lower(): item for name, item in missing_here.items()}
for asset_name, meta_name in metas.items():
guid, guid_line, is_folder, problem = read_meta(folder / meta_name)
meta = Meta(
rel=f"{dir_rel}/{meta_name}",
dir_rel=dir_rel,
asset_name=asset_name,
guid=guid,
guid_line=guid_line,
is_folder=is_folder,
problem=problem,
)
scan.metas.append(meta)
if asset_name in asset_names:
continue
case_match = lowered_missing.get(asset_name.lower())
if case_match is not None and not case_match.paired:
case_match.paired = True
scan.add(
"warning",
"meta-name-case",
meta.rel,
None,
f"'{meta_name}' only matches '{case_match.name}' when case is ignored; "
"this works on Windows and macOS but the asset loses its GUID on case-sensitive file systems",
f"rename '{meta_name}' to '{case_match.name}.meta'",
)
continue
scan.orphans.append((meta, asset_name in every))
for name, path_name in ((n, folder / n) for n in files):
suffix = Path(name).suffix.lower()
if suffix in YAML_EXTENSIONS:
scan.yaml_files.append(path_name)
elif suffix == ".cs":
scan.cs_files.append(path_name)
for name in reversed(dirs):
child_new = new_parent
if child_new is None and name in missing_here:
child_new = missing_here[name]
stack.append((folder / name, child_new))
def collect_external_guids(path: Path, into: dict[str, str], root: Path) -> None:
"""Read GUIDs from a package that is not checked itself (cache or local folder)."""
stack = [path]
seen: set[str] = set()
while stack:
folder = stack.pop()
if not first_visit(folder, seen):
continue
files, dirs, _ = list_dir(folder, False)
for name in files:
if name.endswith(".meta"):
guid, _, _, _ = read_meta(folder / name)
if guid and guid not in into:
meta_path = folder / name
try:
into[guid] = meta_path.relative_to(root).as_posix()
except ValueError:
into[guid] = Path(os.path.relpath(meta_path, root)).as_posix()
stack.extend(folder / name for name in dirs)
# ------------------------------------------------------------------- packages
def load_json(path: Path) -> dict | None:
try:
with path.open("r", encoding="utf-8-sig") as handle:
data = json.load(handle)
except (OSError, ValueError):
return None
return data if isinstance(data, dict) else None
@dataclass
class Packages:
embedded: dict[str, Path] = field(default_factory=dict) # package name -> folder in Packages/
external_guids: dict[str, str] = field(default_factory=dict) # guid -> .meta path
unscanned: list[str] = field(default_factory=list)
note: str = ""
def find_packages(root: Path) -> Packages:
result = Packages()
packages_dir = root / "Packages"
if packages_dir.is_dir():
_, dirs, _ = list_dir(packages_dir, False)
for name in dirs:
package_json = packages_dir / name / "package.json"
if package_json.is_file():
data = load_json(package_json) or {}
package_name = data.get("name") if isinstance(data.get("name"), str) else name
result.embedded[package_name] = packages_dir / name
manifest = load_json(packages_dir / "manifest.json")
lock = load_json(packages_dir / "packages-lock.json")
wanted: dict[str, str] = {} # package name -> version or file: reference
if lock and isinstance(lock.get("dependencies"), dict):
for name, info in lock["dependencies"].items():
if isinstance(info, dict):
version = str(info.get("version", ""))
if info.get("source") == "embedded":
version = "embedded"
wanted[name] = version
if manifest and isinstance(manifest.get("dependencies"), dict):
for name, version in manifest["dependencies"].items():
wanted.setdefault(name, str(version))
if manifest is None:
result.note = "Packages/manifest.json is missing or unreadable, so package scripts could not be checked"
cache_dir = root / "Library" / "PackageCache"
cache_by_name: dict[str, list[Path]] = {}
if cache_dir.is_dir():
_, dirs, _ = list_dir(cache_dir, False)
for name in dirs:
cache_by_name.setdefault(name.split("@", 1)[0], []).append(cache_dir / name)
local_dirs: dict[str, Path] = {}
for name, version in sorted(wanted.items()):
if name.startswith("com.unity.modules."):
continue # built-in engine modules ship no script assets
if name in result.embedded or version == "embedded":
if name not in result.embedded:
result.unscanned.append(name)
continue
if version.startswith("file:"):
target = Path(version[len("file:"):])
if not target.is_absolute():
target = packages_dir / target
if target.is_dir() and (target / "package.json").is_file():
local_dirs[name] = target
continue
# A file: tarball (.tgz) is unpacked into Library/PackageCache.
if name not in cache_by_name:
result.unscanned.append(name)
for name, folders in sorted(cache_by_name.items()):
if name in result.embedded or name in local_dirs:
continue # a stale cache copy of an embedded or local package
for folder in folders:
collect_external_guids(folder, result.external_guids, root)
embedded_dirs = {os.path.realpath(folder) for folder in result.embedded.values()}
for name, folder in sorted(local_dirs.items()):
if os.path.realpath(folder) in embedded_dirs:
continue # already checked as an embedded package
collect_external_guids(folder, result.external_guids, root)
if result.unscanned and not result.note:
shown = ", ".join(result.unscanned[:5]) + (", ..." if len(result.unscanned) > 5 else "")
where = "Library/PackageCache is missing" if not cache_dir.is_dir() else "they are not in Library/PackageCache"
result.note = f"could not read packages {shown} ({where})"
return result
# --------------------------------------------------------------------- checks
def check_metas(scan: Scan, packages: Packages) -> tuple[set[str], dict[str, str]]:
"""Report malformed, duplicate, orphaned and moved metas.
Returns (GUIDs whose asset exists, orphan GUID -> orphan .meta path).
"""
live: dict[str, list[Meta]] = {}
orphan_metas = {meta.rel for meta, _ in scan.orphans}
for meta in scan.metas:
if meta.problem:
scan.add(
"error",
"malformed-meta",
meta.rel,
meta.guid_line,
f"'{Path(meta.rel).name}' {meta.problem}",
"restore this .meta from version control; if there is no good copy, delete it and Unity "
"creates a new one (references to the old GUID stay broken)",
)
continue
if meta.rel not in orphan_metas and meta.guid:
live.setdefault(meta.guid, []).append(meta)
for guid, group in sorted(live.items()):
group.sort(key=lambda m: m.rel)
first = group[0]
for other in group[1:]:
scan.add(
"error",
"duplicate-guid",
other.rel,
other.guid_line,
f"GUID {guid} is also used by '{first.rel}'; one of the two .meta files is a copy",
"delete the .meta of the asset that is the copy (keep the original's) so Unity gives the copy "
"a new GUID on import",
)
external = packages.external_guids.get(guid)
if external:
scan.add(
"error",
"duplicate-guid",
first.rel,
first.guid_line,
f"GUID {guid} is also used by package file '{external}'",
"this .meta was copied from the package; delete it so Unity gives the asset a new GUID",
)
by_dir: dict[str, list[Missing]] = {}
by_name: dict[str, list[Missing]] = {}
for item in scan.missing:
if item.paired:
continue
by_dir.setdefault(item.dir_rel, []).append(item)
by_name.setdefault(item.name, []).append(item)
orphan_guids: dict[str, str] = {}
for meta, asset_hidden in scan.orphans:
if meta.problem:
continue
if meta.guid:
orphan_guids.setdefault(meta.guid, meta.rel)
asset_rel = f"{meta.dir_rel}/{meta.asset_name}"
if asset_hidden:
scan.add(
"warning",
"orphan-meta",
meta.rel,
None,
f"'{meta.asset_name}' exists but Unity ignores it (hidden name), so this .meta is unused",
"delete this .meta, or rename the asset so Unity imports it",
)
continue
candidates = [
item
for item in by_dir.get(meta.dir_rel, [])
if item.is_dir == meta.is_folder
and (item.is_dir or Path(item.name).suffix.lower() == Path(meta.asset_name).suffix.lower())
]
candidates += [
item
for item in by_name.get(meta.asset_name, [])
if item.dir_rel != meta.dir_rel and item.is_dir == meta.is_folder
]
kind = "folder" if meta.is_folder else "asset"
if len(candidates) == 1:
target = candidates[0]
target.paired = True
scan.add(
"error",
"moved-without-meta",
target.rel,
None,
f"'{target.rel}' has no .meta, and '{meta.rel}' (GUID {meta.guid}) was left behind; "
f"this {kind} looks moved or renamed without its .meta, so Unity would give it a new GUID "
"and break every reference to it",
f"move '{meta.rel}' to '{target.rel}.meta' (do not create a new .meta)",
)
continue
if candidates:
names = ", ".join(f"'{item.rel}'" for item in candidates[:3])
more = f" and {len(candidates) - 3} more" if len(candidates) > 3 else ""
scan.add(
"error",
"moved-without-meta",
meta.rel,
None,
f"'{meta.rel}' (GUID {meta.guid}) has lost its {kind}, and {names}{more} have no .meta; "
"one of them looks moved or renamed without this .meta",
"move this .meta next to the file it belongs to and rename it to '<file name>.meta'; "
"if the old asset was deleted on purpose, delete this .meta",
)
continue
twin = live.get(meta.guid or "")
if twin:
scan.add(
"warning",
"orphan-meta",
meta.rel,
None,
f"'{meta.asset_name}' is gone and GUID {meta.guid} already belongs to '{twin[0].rel}'",
"delete this leftover .meta",
)
elif meta.is_folder:
scan.add(
"warning",
"orphan-meta",
meta.rel,
None,
f"folder '{asset_rel}' is gone but its .meta remains; Unity will recreate the folder empty",
"if you deleted the folder on purpose, delete this .meta too; if you moved or renamed it, "
"move this .meta next to the folder and rename it to match",
)
else:
scan.add(
"warning",
"orphan-meta",
meta.rel,
None,
f"asset '{asset_rel}' is gone but its .meta remains (GUID {meta.guid}); "
"Unity deletes it on the next import",
"if you deleted the asset on purpose, delete this .meta too; if you moved or renamed it, "
"move this .meta next to the asset and rename it to match",
)
for item in scan.missing:
if item.paired or not item.reportable:
continue
if item.is_dir:
count = item.files_inside
inside = f" ({count} file{'' if count == 1 else 's'} inside)" if count else ""
scan.add(
"warning",
"missing-meta",
item.rel,
None,
f"folder has no .meta yet{inside}; this is normal for a new folder, "
"Unity creates the .meta files on the next import",
"nothing to do for a new folder; if it was moved or renamed, move its old .meta next to it instead",
)
else:
scan.add(
"warning",
"missing-meta",
item.rel,
None,
"no .meta yet; this is normal for a new file, Unity creates it on the next import",
"nothing to do for a new file; if it was moved or renamed, move its old .meta next to it instead",
)
return set(live), orphan_guids
def is_yaml(path: Path) -> bool:
try:
with path.open("rb") as handle:
head = handle.read(8)
except OSError:
return False
if head.startswith(b"\xef\xbb\xbf"):
head = head[3:]
return head.startswith(b"%YAML")
def check_scripts(scan: Scan, known: set[str], orphan_guids: dict[str, str], packages: Packages) -> None:
"""Report m_Script references to MonoScript GUIDs that no .meta provides."""
for path in scan.yaml_files:
if not is_yaml(path):
continue # binary serialization
rel = scan.rel(path)
names: dict[str, str] = {} # GameObject fileID -> m_Name
found: dict[str, list] = {} # guid -> [first line, count, set of GameObject ids]
doc_class = ""
doc_id = ""
doc_gameobject = ""
try:
with path.open("rb") as handle:
number = 0
at_line_start = True
while True:
chunk = handle.readline(YAML_CHUNK_BYTES)
if not chunk:
break
starts_line = at_line_start
at_line_start = chunk.endswith(b"\n")
if not starts_line:
continue # the rest of a very long line, such as a mesh data blob
number += 1
raw = chunk.decode("utf-8", errors="replace")
if number == 1:
raw = raw.lstrip("\ufeff")
if raw.startswith("---"):
header = YAML_DOC_RE.match(raw)
doc_class, doc_id = (header.group(1), header.group(2)) if header else ("", "")
doc_gameobject = ""
continue
if doc_class == "1":
match = YAML_NAME_RE.match(raw.rstrip("\r\n"))
if match:
names[doc_id] = match.group(1).strip("'\"")
continue
if "m_GameObject:" in raw:
match = YAML_GAMEOBJECT_RE.match(raw)
if match:
doc_gameobject = match.group(1)
continue
if "m_Script:" not in raw:
continue
match = YAML_SCRIPT_RE.search(raw)
if not match:
continue
file_id, guid, ref_type = match.group(1), match.group(2).lower(), match.group(3)
if file_id != MONO_SCRIPT_FILE_ID or ref_type != "3":
continue
if guid.startswith(BUILTIN_GUID_PREFIX) or guid in known:
continue
entry = found.setdefault(guid, [number, 0, set()])
entry[1] += 1
if doc_gameobject and doc_gameobject != "0":
entry[2].add(doc_gameobject)
except OSError:
continue
for guid, (line, count, objects) in found.items():
owners = {names[o] for o in objects if o in names}
where = ""
if owners:
shown = ", ".join(f"'{n}'" for n in sorted(owners)[:3])
where = f" on {shown}"
times = f" ({count} components)" if count > 1 else ""
message = f"script reference{where}{times} points to GUID {guid}, which no .meta in the project has"
orphan = orphan_guids.get(guid)
if orphan:
message += f"; its .meta '{orphan}' is left over while the script file is gone"
fix = (
f"if the .cs file was moved or renamed, put its original .meta (guid: {guid}) back next to it; "
"otherwise restore the script, or remove the missing component in the Unity Editor"
)
if packages.note:
scan.add(
"warning",
"missing-script",
rel,
line,
f"{message} (unconfirmed: {packages.note})",
"open the project in Unity once so the packages are downloaded, then run this again; " + fix,
)
else:
scan.add("error", "missing-script", rel, line, message, fix)
def strip_cs_noise(text: str) -> str:
"""Blank out comments and string literals, keeping line numbers."""
return CS_NOISE_RE.sub(lambda m: "\n" * m.group(0).count("\n") + " ", text)
def top_level_positions(code: str) -> Callable[[int], bool]:
"""Return a function telling whether an offset is outside every class body."""
events: list[tuple[int, str]] = []
for match in CS_BRACE_RE.finditer(code):
token = match.group(0)
events.append((match.start(), "ns" if token.startswith("namespace") else token))
def is_top_level(offset: int) -> bool:
stack: list[str] = []
for position, token in events:
if position >= offset:
break
if token == "}":
if stack:
stack.pop()
else:
stack.append(token)
return all(item == "ns" for item in stack)
return is_top_level
def check_class_names(scan: Scan) -> None:
"""Warn when a MonoBehaviour/ScriptableObject class does not match its file name."""
for path in scan.cs_files:
try:
if path.stat().st_size > CS_MAX_BYTES:
continue
text = path.read_text(encoding="utf-8-sig", errors="replace")
except OSError:
continue
code = strip_cs_noise(text.replace("\r\n", "\n"))
stem = path.stem
is_top_level = top_level_positions(code)
candidates = []
matched = False
for match in CS_CLASS_RE.finditer(code):
if not is_top_level(match.start("name")):
continue
if match.group("name") == stem:
matched = True
break
mods = match.group("mods").split()
if "abstract" in mods or "partial" in mods or "static" in mods or match.group("generic"):
continue # cannot be attached, or may be split across files
candidates.append(match)
if matched or not candidates:
continue
match = candidates[0]
name, base = match.group("name"), match.group("base")
line = code.count("\n", 0, match.start("name")) + 1
scan.add(
"warning",
"class-name-mismatch",
scan.rel(path),
line,
f"class '{name}' derives from {base} but the file is '{path.name}'; "
"Unity only links the script to a class with the same name as the file",
f"rename the file to '{name}.cs' (and its .meta to '{name}.cs.meta'), or rename the class to '{stem}'",
)
# ------------------------------------------------------------------------ CLI
def find_project_root(start: Path) -> Path | None:
for candidate in (start, *start.parents):
if (candidate / "Assets").is_dir() and (candidate / "ProjectSettings").is_dir():
return candidate
return None
def run(root: Path) -> Scan:
scan = Scan(root=root)
packages = find_packages(root)
walk_linted(scan, root / "Assets")
for folder in sorted(packages.embedded.values()):
walk_linted(scan, folder)
known, orphan_guids = check_metas(scan, packages)
known |= set(packages.external_guids)
check_scripts(scan, known, orphan_guids, packages)
check_class_names(scan)
return scan
def sort_key(issue: Issue) -> tuple:
return (0 if issue.level == "error" else 1, issue.file, issue.line or 0, issue.code)
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="check_unity_project.py",
description=(
"Read-only check of a Unity 6 project for .meta mistakes made outside the Editor:\n"
"duplicate or malformed GUIDs, assets moved or renamed without their .meta, leftover\n"
".meta files, missing script references in text scenes/prefabs/assets, and\n"
"MonoBehaviour/ScriptableObject classes whose name differs from the file name."
),
epilog=(
"example:\n"
" python scripts/check_unity_project.py path/to/MyGame\n"
" python scripts/check_unity_project.py . --json --max-issues 200\n\n"
"exit codes: 0 no errors (warnings allowed), 1 errors found (or warnings with --strict),\n"
" 2 usage problem or not a Unity project"
),
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument(
"project_dir",
nargs="?",
default=".",
metavar="PROJECT_DIR",
help="Unity project folder (the one holding Assets/ and ProjectSettings/), or any folder inside it. "
"Default: current folder.",
)
parser.add_argument("--json", action="store_true", help="print one JSON object instead of text")
parser.add_argument(
"--strict",
action="store_true",
help="exit 1 when there are warnings too (a new file's missing .meta does not count)",
)
parser.add_argument(
"--max-issues",
type=int,
default=50,
metavar="N",
help="print at most N issues, errors first (default: 50; 0 = no limit)",
)
return parser
def main(argv: list[str] | None = None) -> int:
for stream in (sys.stdout, sys.stderr):
if hasattr(stream, "reconfigure"):
stream.reconfigure(errors="replace") # non-ASCII paths on a legacy Windows console
parser = build_parser()
args = parser.parse_args(argv)
if args.max_issues < 0:
parser.error("--max-issues must be 0 or more")
start = Path(args.project_dir).expanduser()
if not start.is_dir():
print(f"error: '{args.project_dir}' is not a folder", file=sys.stderr)
return 2
root = find_project_root(start.resolve())
if root is None:
print(
f"error: '{args.project_dir}' is not inside a Unity project "
"(no folder with both Assets/ and ProjectSettings/ found)",
file=sys.stderr,
)
return 2
scan = run(root)
issues = sorted(scan.issues, key=sort_key)
errors = [i for i in issues if i.level == "error"]
warnings = [i for i in issues if i.level == "warning"]
limit = args.max_issues or len(issues)
shown = issues[:limit]
truncated = len(shown) < len(issues)
if args.json:
payload = {
"tool": TOOL,
"schema": SCHEMA,
"root": root.as_posix(),
"files_checked": scan.files_checked,
"error_count": len(errors),
"warning_count": len(warnings),
"errors": [i.as_json() for i in shown if i.level == "error"],
"warnings": [i.as_json() for i in shown if i.level == "warning"],
"truncated": truncated,
}
print(json.dumps(payload, indent=2))
else:
for issue in shown:
location = f"{issue.file}:{issue.line}" if issue.line else issue.file
print(f"{issue.level.upper()} {location}: {issue.message}")
print(f" fix: {issue.fix}")
if truncated:
print(f"... and {len(issues) - len(shown)} more")
print(f"Summary: {len(errors)} errors, {len(warnings)} warnings in {scan.files_checked} files checked")
# A new file's missing .meta needs no action (Unity creates it), so it never fails --strict.
if errors or (args.strict and any(w.code != "missing-meta" for w in warnings)):
return 1
return 0
if __name__ == "__main__":
sys.exit(main())
SKILL.md›
---
name: unity-csharp-scripting
description: >
Write Unity 6.3 LTS C# gameplay scripts: the MonoBehaviour lifecycle
(Awake/OnEnable/Start/Update/FixedUpdate/LateUpdate), GameObject and component
access, coroutines, and Inspector serialization. Use when creating or editing .cs
scripts in a Unity project, or when the user mentions MonoBehaviour, Start/Update,
GetComponent, SerializeField, coroutines, or "Unity script".
---
# Unity C# Scripting (MonoBehaviour)
Write correct, idiomatic gameplay scripts in Unity 6. Get the lifecycle, component
access, serialization, and coroutines right so behaviour is deterministic and the
Inspector stays useful. Targets **Unity 6.3 LTS (6000.3)**, C# / .NET Standard 2.1.
## When to use
- Use when authoring or fixing a `MonoBehaviour`: choosing the right lifecycle callback,
reading/caching components, exposing fields to the Inspector, or running timed logic
with coroutines.
- Use when the project has `*.cs` files, an `Assembly-CSharp` or `*.asmdef`, and a
`ProjectSettings/` folder.
**When *not* to use:** moving rigidbodies / collision response → `unity-physics`; reading
player input → `unity-input-system`; shared data assets / config → `unity-scriptableobjects`;
Animator parameters → `unity-animation`. This skill owns the *script lifecycle and C#
plumbing*, not those subsystems.
## Core workflow
1. **Pick the callback by purpose, not habit.** `Awake` (cache references, runs once on
load), `OnEnable` (subscribe to events), `Start` (init that depends on other objects'
`Awake`), `Update` (per-frame logic/input polling), `FixedUpdate` (physics), `LateUpdate`
(camera follow after movement), `OnDisable`/`OnDestroy` (unsubscribe/cleanup).
2. **Cache component lookups in `Awake`** — never call `GetComponent` every frame.
3. **Expose tunables with `[SerializeField] private`**, not public fields, so other code
can't mutate them but designers can edit them in the Inspector.
4. **Scale per-frame values by `Time.deltaTime`** in `Update` (and `Time.fixedDeltaTime`
semantics are automatic in `FixedUpdate`).
5. **Use coroutines for time-sequenced logic** (delays, tweens, "do X then wait then Y");
start them with `StartCoroutine` and stop them deterministically.
6. **Verify in Play mode**: check the Console for null-reference exceptions, confirm values
in the Inspector update as expected, and watch the Profiler if `Update` is hot.
7. **Check `.meta` files before you finish.** When you create, move, rename or delete
scripts or assets outside the Editor, always move a file together with its `.meta`
(a new `.meta` means a new GUID and broken references). Then run
`python scripts/check_unity_project.py <project-dir>` (`--json` for machine-readable
output), fix every `ERROR` (duplicate or malformed GUIDs, files moved without their
`.meta`, missing script references) and re-run until it exits 0. A missing `.meta` on a
brand-new file is only a warning; Unity creates it on the next import. Run the script
path relative to this skill directory, or resolve the installed skill path first. It
needs Python 3.10+ and nothing else, reads only text-serialized assets, and does not
replace opening the project in Unity.
## Patterns
### 1. Lifecycle + cached components (the canonical skeleton)
```csharp
using UnityEngine;
[RequireComponent(typeof(Rigidbody))] // auto-adds the dependency, prevents null refs
public class PlayerController : MonoBehaviour
{
[SerializeField] private float moveSpeed = 6f; // editable in Inspector, private in code
private Rigidbody _rb; // cached, not fetched per frame
private void Awake() => _rb = GetComponent<Rigidbody>(); // cache once on load
private void Update()
{
// Per-frame, non-physics work. Scale by deltaTime so it is frame-rate independent.
transform.Rotate(0f, 90f * Time.deltaTime, 0f);
}
private void FixedUpdate()
{
// Physics work belongs here (fixed timestep). See the unity-physics skill.
_rb.MovePosition(_rb.position + transform.forward * moveSpeed * Time.fixedDeltaTime);
}
}
```
### 2. Safe component access with `TryGetComponent`
```csharp
// Avoids allocating a null and is clearer than GetComponent + null check.
if (other.TryGetComponent<Health>(out var health))
health.Apply(-10);
```
### 3. Serialization that shows up correctly in the Inspector
```csharp
[SerializeField, Range(0f, 1f)] private float volume = 0.8f; // slider
[SerializeField] private string playerName = "Hero"; // private but serialized
[System.Serializable] // REQUIRED for a plain class to serialize/show
public class Stats { public int hp = 100; public int mana = 50; }
[SerializeField] private Stats stats = new(); // nested struct-like data in the Inspector
```
### 4. Coroutines for time-sequenced logic
```csharp
private void Start() => StartCoroutine(FlashThenHide());
private System.Collections.IEnumerator FlashThenHide()
{
yield return new WaitForSeconds(0.5f); // wait half a second of game time
GetComponent<Renderer>().enabled = false;
yield return null; // resume next frame
}
```
## Pitfalls
- **`GetComponent` in `Update`** — it searches every frame and tanks performance. Cache the
reference in `Awake`/`Start`.
- **Physics in `Update`** — moving a `Rigidbody` with forces or `MovePosition` outside
`FixedUpdate` causes jitter and timestep-dependent behaviour. Read input in `Update`,
apply physics in `FixedUpdate`.
- **Relying on `Start` order across objects** — `Start` runs after *all* `Awake`s, but order
among `Start`s is undefined. Do cross-object wiring in `Start`, self-setup in `Awake`.
- **`public` fields just to show them in the Inspector** — that also lets any script mutate
them. Use `[SerializeField] private` instead.
- **`gameObject.tag == "Enemy"`** allocates a string and is slower; use
`gameObject.CompareTag("Enemy")`.
- **Coroutines stop when the GameObject is disabled** — a disabled object's coroutines are
killed; re-`StartCoroutine` in `OnEnable` if it must survive toggling.
- **`Update` never runs before `Start`, but the *first* `Update` can run on the same
frame as `Start`** — guard against not-yet-initialised fields if you split setup oddly.
## References
- For the full event-execution-order table and advanced coroutine patterns (custom
`CustomYieldInstruction`, stopping by handle, `WaitUntil`/`WaitWhile`), read
`references/lifecycle-and-coroutines.md`.
- `scripts/check_unity_project.py` — read-only `.meta`/GUID and missing-script checker;
`--help` lists every check and the exit codes.
- Primary docs: Unity Manual "Event function execution order"
(`https://docs.unity3d.com/Manual/execution-order.html`) and `ScriptReference/MonoBehaviour`.
## Related skills
- `unity-physics` — `Rigidbody`, collisions, and `FixedUpdate` motion.
- `unity-input-system` — reading player input into these scripts.
- `unity-scriptableobjects` — sharing data/config between scripts without singletons.