返回 Skills 目錄
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.