Files
2026-06-22 16:11:12 +02:00

17 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).
  • No player arms / viewmodel — feedback comes from camera, audio and UI only.
  • 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. 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.

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.


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.

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).

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/.