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).
PlayerEventsis 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 matchingIsXstate, logs (under thePLAYER_EVENT_LOGdefine) and invokes the event. - Consumers subscribe to the
eventinOnEnableand unsubscribe inOnDisable. - Never reach across systems with
FindObjectOfTypeor hard references to read state that the bus already exposes. Add a new event/state toPlayerEventsinstead — except per-player networked state, which belongs on aNetworkBehaviour(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 destructiveAwakesingleton guard (it would destroy spawned remote players). Set theInstanceonly for the owner, inOnStartNetwork(if (base.Owner.IsLocalClient) Instance = this;), clear it inOnStopNetwork. - Owner simulates locally; replicate a lightweight snapshot via SyncVar (server writes,
pushed from the owner through a
[ServerRpc], throttled + immediate on key events). - SyncVar
OnChangeupdates remote copies' local state only — guardif (base.IsOwner) return;and never touchPlayerEventsthere.
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_Seasonshader 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 theAshwild.<Folder>rule — its namespace isAshwild.EditorTools, notAshwild.Editor, because a namespace segment namedEditorshadows theUnityEditor.Editorbase type that every custom inspector derives from (CS0118).- Never put
using Ashwild.EditorTools;in a runtime (non-Editor/) script. Editor code compiles into the separateAssembly-CSharp-Editorassembly, which runtime code cannot reference (CS0234). Editor scripts may freelyusingruntime 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
privatefields 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/OnDisableoverStart/OnDestroyfor subscription symmetry. - No magic numbers in logic that designers tune — promote them to serialized fields.
- Don't add
FindObjectOfTypein hot paths (Update/FixedUpdate); cache references. - Keep
Update/FixedUpdatecheap; cache expensive lookups (seePlayerAudio'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
MonoBehaviourthat subscribes? VerifyOnEnable/OnDisablestay paired. - Touched a player system? Confirm it's owner-gated (in
ownerOnlyBehaviours) and that a remote copy never drivesPlayerEventsor simulates itself (see §3). - New networked prefab? Add a
NetworkObjectand register it inDefaultPrefabObjects. - 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/.