Files
Emberwild/.claude/CLAUDE.md
T
2026-07-07 16:43:51 +02:00

22 KiB

CLAUDE.md

Guidance for working in this Unity project. These rules are mandatory — follow them exactly when reading, writing, or refactoring any C# script.


1. Project overview

First-person survival/exploration game built in Unity 6000.3.8f1 with URP. The game is online co-op multiplayer (a single-player build is just a host with no clients — there is no separate offline mode). Core systems already in place: first-person locomotion, camera, stats (health/hunger/thirst), inventory + hotbar, crafting, cooking, harvesting/tools, procedural scatter placement, audio (footsteps by surface + music), seasons, and a full UI/menu/settings stack.

Networking stack (see §3):

  • FishNet 4.7.2 (in Assets/FishNet/) — the netcode.
  • Steamworks.NET + FishySteamworks transport — Steam relay (SteamNetworkingSockets, no port forwarding). Sessions are joined by a shareable code (host SteamID64) or Steam invite.

Context for current work:

  • This is final, production-quality code — not a prototype. Aim for clean, well-named, well-architected code that respects the patterns in this document. No quick hacks, no "temporary" shortcuts; if something is worth doing, do it properly.
  • Multiplayer co-op is a hard constraint — when touching any player, world or shared system, assume two+ players exist (see §3).
  • Player body is split by viewpoint — the local owner sees a first-person arms viewmodel (his own hands/tools); remote players are drawn as a full third-person body. Each client renders its own arms and everyone else's full body. The owner's own third-person body is hidden from his first-person camera (owner-only vs remote-only gating lives in PlayerNetworkController — see §3). HUD/audio feedback still applies on top.
  • Animations use DOTween (tweens, not Animator) wherever possible.
  • Shaders are hand-written HLSL/ShaderLab — do not propose Shader Graph.

2. Architecture — the golden rule

Everything talks through the static event bus PlayerEvents (Assets/GAME/Script/Player/Events/PlayerEvents.cs).

  • PlayerEvents is the source of truth for the local player's cross-system state (IsDead, IsGrounded, IsInventoryOpen, IsPaused, InputLocked, …). Here "shared" means shared between systems on one client — never between players (see §3).
  • Producers call a Raise* method; the raiser updates the matching IsX state, logs (under the PLAYER_EVENT_LOG define) and invokes the event.
  • Consumers subscribe to the event in OnEnable and unsubscribe in OnDisable.
  • Never reach across systems with FindObjectOfType or hard references to read state that the bus already exposes. Add a new event/state to PlayerEvents instead — except per-player networked state, which belongs on a NetworkBehaviour (SyncVar/RPC), not the bus (see §3).

Each script owns one concern — but "concern" means one reason to change, not one tiny action. A script reads its own inputs/state from the bus, does its job, and raises events for others. Do not let a script mutate another system's internal data directly. If two systems need to coordinate, route it through PlayerEvents.

Group by cohesion, not by a per-action counter. Splitting a feature into many micro-scripts is its own anti-pattern (scattered logic, high coupling, inspector-wiring noise) just as much as a god-object is. Keep together what changes together and is used together; split only at a real seam — a genuinely different reason to change, a different collaborator set, or a file growing unwieldy. The seam that always matters: local player UX vs shared networked state (see §3) — never fuse across it. Reference: the build system splits into BuildManager (all the local build UX — catalog, menu, placement) and BuildRegistry (the networked side — committed structures + the ghost NetworkObject), one clean local/network seam, not a script per sub-responsibility.

Data that is authored in the editor lives in ScriptableObjects (ItemData, CraftingRecipe, SurfaceSoundProfile, ScatterProfile, MusicPlaylist, ToolDropTable, …). Logic lives in MonoBehaviours. Keep them separate.

Singletons exist where a single instance is genuinely global (PlayerInventory.Instance, PlayerStats.Instance, …); guard every access for null — they may not exist yet or in every scene.

UI is a dumb view — a manager owns the data and populates it. A UI component (a panel, a card, a list row, …) must never reach for domain data itself: no catalog, no ScriptableObject list, no scanning a database/model to decide what to show. It exposes a Populate(...) / Set(...) method that takes a lightweight view payload (plain icon / label / number data — never the authored ScriptableObject) plus a selection callback, and renders exactly what it is handed. A dedicated manager owns the real data (the catalog, the model), builds the view payload, pushes it into the UI, and handles the callback — i.e. it decides what a click does. The view reports interactions back by index or token, never by resolving the underlying data. Data flows one way (manager → view), interactions flow back one way (view → manager via callback), and views stay swappable and data-agnostic. Reference implementation: BuildManager (owns the catalog, populates + handles picks) + BuildMenuUI / BuildMenuCardUI (pure views) + BuildCardView (the view payload).


3. Multiplayer — the second golden rule

The game runs on FishNet (co-op, client-authoritative — friendly play, not anti-cheat). Networking code lives in Assets/GAME/Script/Network/. When you write or change anything that involves a player, the world, or shared state, obey these rules.

PlayerEvents is the LOCAL player's bus only

The static PlayerEvents (state + events) represents the local owned player, never remote players. Only owner-side code may call Raise*. Remote player copies must never drive the static bus — they only update their own instance state (e.g. via SyncVar hooks). Per-player networked state (health, position, …) belongs on the player's NetworkBehaviour (SyncVar / RPC), not in PlayerEvents.

Owner gating

PlayerNetworkController (on the player root) disables every owner-only component (input, locomotion, camera, tools, stats, inventory, hotbar, interactor, audio, lifecycle) and owner-only object (cameras, audio listener, tool holder) on non-owners, then raises LocalPlayerSpawned. Any new player component that simulates or reads local input must be added to that ownerOnlyBehaviours list. Scene/UI code must bind to the local player — get it from PlayerInventory.Instance / PlayerStats.Instance (owner-only singletons), never FindObjectOfType<PlayerX>(), which also matches disabled remote puppets (their root GameObject stays active) and targets the wrong player.

Keep systems OFF the player — it holds only its own intrinsic state

Default to a scene object/registry, not the player prefab. The player carries only what is intrinsically the local player itself: its owner-only local simulation (input, locomotion, camera, tools, animation driver) and its own per-player networked state (health/stats, inventory, the body). Everything that touches the world or shared state — and anything that does not need per-player networking — lives OUTSIDE the player. This is logical: a build, a placed structure, a shared station, even a per-player preview of a world action (the build ghost), is world-facing, so it belongs to a scene NetworkBehaviour (a SyncList<…> registry, or a NetworkObject it spawns), never bolted onto the player. Do not reflexively put per-player networked state on the player just because it is "per player" — ask first "is this the player, or is it the world?". If it is world/shared, it is a scene object. Reference implementations: WorldObjectRegistry, BuildRegistry (committed structures + the spawned ghost preview), CookingStation. The player-hosted pattern below is only for state that genuinely is the player.

Visible body — first-person arms (owner) + third-person body (remotes)

Every player is drawn twice, split by viewpoint (see §1):

  • First-person arms — the owner's own hands/tools, seen only by the owner. The arms mesh goes in PlayerNetworkController.ownerOnlyObjects (hidden on remotes); it is never networked because no one else renders it.
  • Third-person body — the full character everyone else sees. It stays active on all clients so its Animator keeps running. PlayerBodyVisibility (a NetworkBehaviour on the player root) relayers the owner's own body onto a LocalBody layer that the owner's first-person camera excludes from its culling mask — so the owner never sees his own body clip the view, yet every remote still renders it. Layers are local and never networked, so hiding your body from yourself does not hide it from anyone else.

Animation is a bus consumer, replicated by NetworkAnimator. PlayerAnimatorDriver (Player/Animation/) drives an Animator purely from PlayerEvents (VelocityChanged, GroundedChanged, Jumped, AttackSwung, CrouchChanged, …). The same component drives both rigs from that one shared source, so arms and body stay in lockstep. Each instance is owner-only local simulation → add every PlayerAnimatorDriver to ownerOnlyBehaviours. On the body, a sibling FishNet NetworkAnimator replicates the owner's animation to remotes (whose driver is disabled and whose body is animated by the replicated parameters instead). Need a new animation cue? Add an event to PlayerEvents and have the driver subscribe — never poll another system's internals.

Networking a player system — the canonical pattern (mirror PlayerInventory/PlayerStats)

  • Make it a NetworkBehaviour. No destructive Awake singleton guard (it would destroy spawned remote players). Set the Instance only for the owner, in OnStartNetwork (if (base.Owner.IsLocalClient) Instance = this;), clear it in OnStopNetwork.
  • Owner simulates locally; replicate a lightweight snapshot via SyncVar (server writes, pushed from the owner through a [ServerRpc], throttled + immediate on key events).
  • SyncVar OnChange updates remote copies' local state only — guard if (base.IsOwner) return; and never touch PlayerEvents there.

World state

World objects (pickables, harvestables) are not NetworkObjects. They use the WorldObject + WorldObjectRegistry pattern: one networked registry per type per scene owns a synced set of inactive ids + server-side authority (health, loot, drops) via RPC. Add new harvestable/pickable-like world state here, not as per-object NetworkObjects. ItemData cannot cross the wire — map items to ids through ItemDatabase (Tools ▸ Ashwild ▸ Rebuild Item Database after adding items). BuildRegistry + BuildableDatabase mirror this for placed structures (which also accumulate to high counts).

This "no NetworkObjects" rule targets high-count world state (thousands of scatter objects, hundreds of builds) — making those NetworkObjects would flood the network. It is not absolute: a transient, low-count object that moves and needs smooth replication — the build ghost, one per player — is the case where a real NetworkObject + client-authoritative NetworkTransform is the better tool, and BuildRegistry spawns it as such. Pick by count and lifetime, not dogma.

Shared interactables (cooking stations, future chests/benches)

Anything two players can use at once needs server-authoritative state + RPCs — local-only MonoBehaviour state will let players clobber each other. CookingStation is the reference: the server owns the simulation (fuel burn + cooking timers), replicates a compact view (SyncList<SlotView> + a SyncVar<int> log count), and mutates only through [ServerRpc(RequireOwnership = false)] requests that refund the item on failure via PlayerInventory.GrantItemFromServer. Mirror it for chests/benches.

Not yet networked — do not rely on these in co-op

These still run locally and will desync between clients until migrated:

  • GrassClearer — modifies the terrain detail layer locally, no RPC → others keep the grass.
  • SeasonManager — the global _Season shader value is not synced → visual desync.

Reference implementations

Copy these patterns rather than inventing new ones: PlayerInventory.cs and PlayerStats.cs (networked player system), HarvestableRegistry.cs / WorldObjectRegistry.cs (world state), CookingStation.cs (server-authoritative shared interactable), PlayerNetworkController.cs (owner gating).


4. Folder map (Assets/GAME/Script/)

Folder Namespace Responsibility
Player/Events/ Ashwild.Player PlayerEvents — the central bus. Start here to understand the game.
Player/Input/ Ashwild.Player PlayerInputRouter — the only Input System receiver.
Player/Locomotion/ Ashwild.Player Movement, ground/slope checks, jump, slide, crouch.
Player/Camera/ Ashwild.Player First-person camera, look, head height.
Player/Tools/ Ashwild.Player Tool equip/swing logic and tool holder.
Player/ (root) Ashwild.Player PlayerStats, PlayerInteractor, PlayerLifecycle, PlayerAudio.
Network/ Ashwild.Network FishNet layer — session host/join, player spawn, owner gating, WorldObject/registry world-state sync.
Inventory/ Ashwild.Inventory Inventory model, slots, hotbar, pickables, inventory UI.
Crafting/ Ashwild.Crafting Recipes, ingredients, crafting manager and its UI.
Cooking/ Ashwild.Cooking Cooking stations (food + fuel → cooked results).
Harvesting/ Ashwild.Harvesting Harvestables, tools, resource drops, scatter placement.
Audio/ Ashwild.Audio Music manager/playlist, surface sound profiles.
Environment/ Ashwild.Environment Season manager.
GrassClearer/ Ashwild.GrassClearer Detail/grass removal around placed objects.
Interaction/ Ashwild.Interaction IInteractable and interaction contracts.
Settings/ Ashwild.Settings Settings manager, panels and sub-panels, brightness.
UI/ Ashwild.UI HUD, panels, notifications, crosshair, death/splash/pause screens.
Integrations/ Ashwild.Integrations Third-party integrations (e.g. Discord Rich Presence).
Editor/ Ashwild.EditorTools Custom inspectors, property drawers, tooling (editor-only).

Namespaces — one per top-level system folder

Every script lives in a namespace Ashwild.<TopLevelFolder> — flat, one per top-level system folder. Sub-folders do not get their own namespace: everything under Player/ (incl. Player/Camera/, Player/Input/, Player/Tools/, Player/HeldItems/) is Ashwild.Player. This flatness is deliberate — it keeps cross-references short and avoids a sub-namespace shadowing a Unity type (a namespace Ashwild.Player.Camera would mask UnityEngine.Camera inside it).

Rules when adding or moving a script:

  • New file → wrap it in the namespace of its top-level folder. Add using Ashwild.X; for every other Ashwild namespace whose types it references.
  • Editor/ is the one exception to the Ashwild.<Folder> rule — its namespace is Ashwild.EditorTools, not Ashwild.Editor, because a namespace segment named Editor shadows the UnityEditor.Editor base type that every custom inspector derives from (CS0118).
  • Never put using Ashwild.EditorTools; in a runtime (non-Editor/) script. Editor code compiles into the separate Assembly-CSharp-Editor assembly, which runtime code cannot reference (CS0234). Editor scripts may freely using runtime namespaces, not the reverse.

5. Code conventions — non-negotiable

5.1 XML summaries

Every class, method, and non-obvious public member gets a /// <summary> written in English, describing intent (the why), not restating the signature.

/// <summary>
/// Adds an item to the inventory, stacking into existing slots first.
/// Returns false (and logs) when the item is null or no room remains.
/// </summary>
public bool AddItem(ItemData item, int quantity = 1) { ... }

Use the 3-line form — opening /// <summary>, the text, and the closing /// </summary> each on their own line; never collapse a summary onto one line.

Scope (this matches the real codebase — see PlayerInventory/PlayerStats):

  • Always: every class, every method (incl. Unity lifecycle and event handlers), and any public member whose intent isn't obvious from its name.
  • Not required: trivial getters / => passthroughs and [SerializeField] fields — group the latter under [Header("...")] and clarify individual ones with [Tooltip("...")] (or a summary) only when the name alone doesn't convey intent.

5.1b No in-body comments — explain in the summary above the method

Never write explanatory comments inside a method body. All rationale — the why, the gotchas, the edge cases — goes in the /// <summary> above the method (extend it with as much prose as needed). The body stays pure code. If a block feels like it needs an inline comment to be understood, that's a signal to either name things better or extract it into its own well-summarized method. The only inline text allowed in a body is a // TODO: / // HACK: marker for tracked future work — not an explanation of what the current code does.

// ❌ wrong — explanation lives inside the body
private void Step()
{
    // Subtract instead of zeroing so the leftover carries into the next step.
    distanceTravelled -= stepDistance;
}

/// <summary>
/// Fires a footstep and carries the leftover distance into the next step. Subtracting
/// (instead of zeroing) keeps the cadence even at sprint speed, where the per-frame
/// overshoot would otherwise randomly stretch steps and drift out of sync.
/// </summary>
private void Step()
{
    distanceTravelled -= stepDistance;
}

5.2 Specific #regions — never one generic blob

Group members into named, specific regions reflecting their role. Do not dump everything under a single #region Methods. Typical regions for a MonoBehaviour:

#region Serialized Fields
#region State
#region Unity Lifecycle      // Awake / OnEnable / OnDisable / Start / Update
#region Event Handlers       // bus callbacks
#region Public API
#region Internal Helpers
#endregion

Pick regions that match what the script actually does (e.g. #region Ground Check, #region Surface Detection, #region Playback). The goal: a reader scans the region names and knows the script's shape instantly.

5.3 Event subscription lifecycle

Subscribe in OnEnable, unsubscribe in OnDisable — always paired, same order. Never subscribe without a matching unsubscribe (leaks + double-fire after reload).

5.4 DOTween hygiene

Cache tweeners in fields and Kill() them in OnDestroy (and before restarting them). A tween targeting a destroyed object throws — never leave one running on teardown.

5.5 Defensive logging instead of crashes

Guard nullable inputs and log a clear, prefixed error with the context object so clicking the console message selects the culprit in the hierarchy:

if (itemData == null)
{
    Debug.LogError($"[Pickable] '{name}' has no ItemData assigned — cannot pick up.", this);
    return;
}

Prefix logs with [ClassName]. Pass this as the second arg whenever possible.

5.6 Input naming — avoid the On<Action> trap

The PlayerInput component uses Send Messages, so Unity invokes On<ActionName> methods by reflection on the player's GameObject and its components. Only PlayerInputRouter may declare On<Action> methods. Any other component with a method named like an input action (e.g. OnHotbarScroll) will be invoked by the Input System and crash on a signature mismatch. Name bus handlers Handle<Thing> instead.

5.7 General practices

  • private fields exposed to the inspector use [SerializeField] private, grouped under [Header("...")]. No public fields just to show them in the inspector.
  • One class per file; file name matches the type.
  • Prefer OnEnable/OnDisable over Start/OnDestroy for subscription symmetry.
  • No magic numbers in logic that designers tune — promote them to serialized fields.
  • Don't add FindObjectOfType in hot paths (Update/FixedUpdate); cache references.
  • Keep Update/FixedUpdate cheap; cache expensive lookups (see PlayerAudio's surface cache for the pattern).

6. Canonical script template

using UnityEngine;

/// <summary>
/// One-sentence description of this component's single responsibility.
/// </summary>
[DisallowMultipleComponent]
public class ExampleSystem : MonoBehaviour
{
    #region Serialized Fields

    [Header("Tuning")]
    [SerializeField] private float duration = 1f;

    #endregion

    #region State

    private bool isActive;

    #endregion

    #region Unity Lifecycle

    /// <summary>
    /// Subscribes to the bus events this system reacts to.
    /// </summary>
    private void OnEnable()
    {
        PlayerEvents.Died += HandleDied;
    }

    /// <summary>
    /// Unsubscribes — must mirror OnEnable exactly.
    /// </summary>
    private void OnDisable()
    {
        PlayerEvents.Died -= HandleDied;
    }

    #endregion

    #region Event Handlers

    /// <summary>
    /// Resets the system when the player dies.
    /// </summary>
    private void HandleDied() => isActive = false;

    #endregion

    #region Public API

    /// <summary>
    /// Starts the effect; ignored if already running.
    /// </summary>
    public void Activate()
    {
        if (isActive) return;
        isActive = true;
    }

    #endregion
}

7. Before you finish

  • New cross-system communication → add an event to PlayerEvents, don't bypass the bus.
  • Touched a MonoBehaviour that subscribes? Verify OnEnable/OnDisable stay paired.
  • Touched a player system? Confirm it's owner-gated (in ownerOnlyBehaviours) and that a remote copy never drives PlayerEvents or simulates itself (see §3).
  • New networked prefab? Add a NetworkObject and register it in DefaultPrefabObjects.
  • Added a tween? Verify it's killed on teardown.
  • The project must compile with zero errors; treat new warnings as something to fix or justify.
  • Editor-only code stays under Editor/.