Back to list
henboffman

unity-2d-farm-rpg

by henboffman

1🍴 0📅 Jan 21, 2026

SKILL.md


name: unity-2d-farm-rpg description: Specialized guidance for developing 2D farming RPG games in Unity with C#. Use when working on farm simulation mechanics, crop systems, NPC interactions, inventory management, day/night cycles, seasonal systems, tile-based worlds, or any 2D farm RPG game development in Unity. Covers ScriptableObjects, state machines, save systems, UI patterns, and pixel-perfect rendering.

Unity 2D Farm RPG Development

Guide for building 2D farming RPG games in Unity using C#.

Project Architecture

Assets/
├── Scripts/
│   ├── Core/           # Managers, singletons, game state
│   ├── Player/         # Movement, interaction, stamina
│   ├── Farming/        # Crops, soil, tools, watering
│   ├── Inventory/      # Items, storage, equipment
│   ├── NPCs/           # Dialogue, relationships, schedules
│   ├── Time/           # Day/night, seasons, calendar
│   ├── UI/             # Menus, HUD, tooltips
│   ├── World/          # Tilemap, weather, environment
│   └── Save/           # Persistence, serialization
├── ScriptableObjects/
│   ├── Items/          # Item definitions
│   ├── Crops/          # Crop data
│   └── NPCs/           # Character data
├── Prefabs/
├── Sprites/
├── Tilemaps/
└── Resources/

Core Systems

ScriptableObject Pattern for Data

Use ScriptableObjects for all game data:

[CreateAssetMenu(fileName = "New Crop", menuName = "Farm/Crop Data")]
public class CropData : ScriptableObject
{
    public string cropName;
    public Sprite[] growthStages;
    public int daysToGrow;
    public Season[] validSeasons;
    public ItemData harvestItem;
    public int harvestYield = 1;
    public bool regrows;
    public int regrowDays;
}

[CreateAssetMenu(fileName = "New Item", menuName = "Farm/Item Data")]
public class ItemData : ScriptableObject
{
    public string itemName;
    public Sprite icon;
    public ItemType itemType;
    public int stackSize = 99;
    public int sellPrice;
    public int buyPrice;
    [TextArea] public string description;
}

Time Manager

public class TimeManager : MonoBehaviour
{
    public static TimeManager Instance { get; private set; }

    public event Action<int> OnHourChanged;
    public event Action OnDayChanged;
    public event Action<Season> OnSeasonChanged;

    [SerializeField] private float secondsPerGameMinute = 0.5f;

    public int CurrentHour { get; private set; }
    public int CurrentDay { get; private set; }
    public Season CurrentSeason { get; private set; }
    public int CurrentYear { get; private set; }

    private float minuteTimer;
    private int currentMinute;

    void Update()
    {
        minuteTimer += Time.deltaTime;
        if (minuteTimer >= secondsPerGameMinute)
        {
            minuteTimer = 0;
            AdvanceMinute();
        }
    }

    private void AdvanceMinute()
    {
        currentMinute++;
        if (currentMinute >= 60)
        {
            currentMinute = 0;
            CurrentHour++;
            OnHourChanged?.Invoke(CurrentHour);

            if (CurrentHour >= 24) AdvanceDay();
        }
    }

    private void AdvanceDay()
    {
        CurrentHour = 6; // Wake up time
        CurrentDay++;
        OnDayChanged?.Invoke();

        if (CurrentDay > 28)
        {
            CurrentDay = 1;
            AdvanceSeason();
        }
    }
}

Crop Growth System

public class CropTile : MonoBehaviour
{
    private CropData cropData;
    private int currentGrowthStage;
    private int daysSincePlanted;
    private bool isWatered;

    private SpriteRenderer spriteRenderer;

    public void Plant(CropData data)
    {
        cropData = data;
        currentGrowthStage = 0;
        daysSincePlanted = 0;
        UpdateSprite();
    }

    public void OnDayAdvance()
    {
        if (cropData == null) return;

        if (isWatered)
        {
            daysSincePlanted++;
            UpdateGrowthStage();
        }
        isWatered = false;
    }

    public void Water()
    {
        isWatered = true;
        // Visual feedback for watered soil
    }

    private void UpdateGrowthStage()
    {
        int stagesCount = cropData.growthStages.Length;
        int daysPerStage = cropData.daysToGrow / stagesCount;
        int newStage = Mathf.Min(daysSincePlanted / daysPerStage, stagesCount - 1);

        if (newStage != currentGrowthStage)
        {
            currentGrowthStage = newStage;
            UpdateSprite();
        }
    }

    public bool CanHarvest => currentGrowthStage >= cropData.growthStages.Length - 1;

    public ItemData Harvest()
    {
        if (!CanHarvest) return null;

        var item = cropData.harvestItem;

        if (cropData.regrows)
        {
            currentGrowthStage = cropData.growthStages.Length - 2;
            daysSincePlanted = cropData.daysToGrow - cropData.regrowDays;
        }
        else
        {
            cropData = null;
        }

        UpdateSprite();
        return item;
    }
}

Inventory System

[System.Serializable]
public class InventorySlot
{
    public ItemData item;
    public int quantity;

    public bool CanAddItem(ItemData newItem, int amount)
    {
        if (item == null) return true;
        return item == newItem && quantity + amount <= item.stackSize;
    }
}

public class InventoryManager : MonoBehaviour
{
    public static InventoryManager Instance { get; private set; }

    public event Action OnInventoryChanged;

    [SerializeField] private int inventorySize = 36;
    private InventorySlot[] slots;

    public bool AddItem(ItemData item, int quantity = 1)
    {
        // First try to stack with existing
        for (int i = 0; i < slots.Length; i++)
        {
            if (slots[i].item == item && slots[i].quantity < item.stackSize)
            {
                int canAdd = item.stackSize - slots[i].quantity;
                int toAdd = Mathf.Min(canAdd, quantity);
                slots[i].quantity += toAdd;
                quantity -= toAdd;

                if (quantity <= 0)
                {
                    OnInventoryChanged?.Invoke();
                    return true;
                }
            }
        }

        // Then find empty slots
        for (int i = 0; i < slots.Length; i++)
        {
            if (slots[i].item == null)
            {
                slots[i].item = item;
                slots[i].quantity = Mathf.Min(quantity, item.stackSize);
                quantity -= slots[i].quantity;

                if (quantity <= 0)
                {
                    OnInventoryChanged?.Invoke();
                    return true;
                }
            }
        }

        OnInventoryChanged?.Invoke();
        return quantity <= 0;
    }

    public bool RemoveItem(ItemData item, int quantity = 1)
    {
        int remaining = quantity;

        for (int i = slots.Length - 1; i >= 0 && remaining > 0; i--)
        {
            if (slots[i].item == item)
            {
                int toRemove = Mathf.Min(slots[i].quantity, remaining);
                slots[i].quantity -= toRemove;
                remaining -= toRemove;

                if (slots[i].quantity <= 0)
                    slots[i].item = null;
            }
        }

        OnInventoryChanged?.Invoke();
        return remaining <= 0;
    }
}

Player Systems

Grid-Based Movement

public class PlayerMovement : MonoBehaviour
{
    [SerializeField] private float moveSpeed = 5f;
    [SerializeField] private LayerMask collisionLayer;

    private Vector2 movement;
    private Rigidbody2D rb;
    private Animator animator;

    private Vector2 lastDirection = Vector2.down;

    void Update()
    {
        movement.x = Input.GetAxisRaw("Horizontal");
        movement.y = Input.GetAxisRaw("Vertical");

        if (movement != Vector2.zero)
            lastDirection = movement.normalized;

        UpdateAnimation();
    }

    void FixedUpdate()
    {
        rb.MovePosition(rb.position + movement.normalized * moveSpeed * Time.fixedDeltaTime);
    }

    private void UpdateAnimation()
    {
        animator.SetFloat("MoveX", movement.x);
        animator.SetFloat("MoveY", movement.y);
        animator.SetFloat("LastMoveX", lastDirection.x);
        animator.SetFloat("LastMoveY", lastDirection.y);
        animator.SetBool("IsMoving", movement.sqrMagnitude > 0);
    }

    public Vector3Int GetFacingTilePosition()
    {
        Vector3 facingPos = transform.position + (Vector3)lastDirection;
        return Vector3Int.FloorToInt(facingPos);
    }
}

Tool Usage System

public class ToolController : MonoBehaviour
{
    [SerializeField] private Tilemap soilTilemap;
    [SerializeField] private TileBase tilledSoilTile;

    private PlayerMovement playerMovement;
    private StaminaSystem stamina;

    public void UseTool(ToolType tool)
    {
        Vector3Int targetTile = playerMovement.GetFacingTilePosition();

        switch (tool)
        {
            case ToolType.Hoe:
                TillSoil(targetTile);
                break;
            case ToolType.WateringCan:
                WaterCrop(targetTile);
                break;
            case ToolType.Scythe:
                HarvestCrop(targetTile);
                break;
            case ToolType.Axe:
                ChopTree(targetTile);
                break;
            case ToolType.Pickaxe:
                BreakRock(targetTile);
                break;
        }
    }

    private void TillSoil(Vector3Int position)
    {
        if (!stamina.UseStamina(2)) return;

        // Check if tillable ground
        soilTilemap.SetTile(position, tilledSoilTile);
        // Play animation and sound
    }
}

NPC & Dialogue Systems

Dialogue System

[CreateAssetMenu(fileName = "New Dialogue", menuName = "Farm/Dialogue")]
public class DialogueData : ScriptableObject
{
    public string speakerName;
    public Sprite portrait;
    [TextArea(3, 5)] public string[] lines;
    public DialogueChoice[] choices; // Optional branching
}

[System.Serializable]
public class DialogueChoice
{
    public string choiceText;
    public DialogueData nextDialogue;
    public int friendshipChange;
}

public class DialogueManager : MonoBehaviour
{
    public static DialogueManager Instance { get; private set; }

    public event Action OnDialogueStart;
    public event Action OnDialogueEnd;

    private Queue<string> currentLines;
    private DialogueData currentDialogue;

    public void StartDialogue(DialogueData dialogue)
    {
        currentDialogue = dialogue;
        currentLines = new Queue<string>(dialogue.lines);
        OnDialogueStart?.Invoke();
        DisplayNextLine();
    }

    public void DisplayNextLine()
    {
        if (currentLines.Count == 0)
        {
            EndDialogue();
            return;
        }

        string line = currentLines.Dequeue();
        // Update UI with line
    }

    private void EndDialogue()
    {
        OnDialogueEnd?.Invoke();
    }
}

NPC Schedule System

[System.Serializable]
public class ScheduleEntry
{
    public int hour;
    public Vector3 destination;
    public string animation;
}

public class NPCSchedule : MonoBehaviour
{
    [SerializeField] private ScheduleEntry[] weekdaySchedule;
    [SerializeField] private ScheduleEntry[] weekendSchedule;

    private NPCMovement movement;

    void Start()
    {
        TimeManager.Instance.OnHourChanged += CheckSchedule;
    }

    private void CheckSchedule(int hour)
    {
        var schedule = IsWeekend() ? weekendSchedule : weekdaySchedule;

        foreach (var entry in schedule)
        {
            if (entry.hour == hour)
            {
                movement.MoveTo(entry.destination);
                break;
            }
        }
    }
}

Save System

Save Data Structure

[System.Serializable]
public class GameSaveData
{
    public PlayerSaveData player;
    public List<CropSaveData> crops;
    public List<InventorySlotSaveData> inventory;
    public TimeSaveData time;
    public Dictionary<string, int> npcFriendships;
}

[System.Serializable]
public class CropSaveData
{
    public Vector3Int position;
    public string cropId;
    public int growthStage;
    public int daysSincePlanted;
    public bool isWatered;
}

JSON Save Manager

public class SaveManager : MonoBehaviour
{
    private string SavePath => Path.Combine(Application.persistentDataPath, "save.json");

    public void SaveGame()
    {
        var data = new GameSaveData
        {
            player = GetPlayerData(),
            crops = GetCropData(),
            inventory = GetInventoryData(),
            time = GetTimeData(),
            npcFriendships = GetFriendshipData()
        };

        string json = JsonUtility.ToJson(data, true);
        File.WriteAllText(SavePath, json);
    }

    public void LoadGame()
    {
        if (!File.Exists(SavePath)) return;

        string json = File.ReadAllText(SavePath);
        var data = JsonUtility.FromJson<GameSaveData>(json);

        ApplyPlayerData(data.player);
        ApplyCropData(data.crops);
        ApplyInventoryData(data.inventory);
        ApplyTimeData(data.time);
        ApplyFriendshipData(data.npcFriendships);
    }
}

UI Patterns

Inventory UI with Drag & Drop

public class InventorySlotUI : MonoBehaviour, IPointerClickHandler,
    IBeginDragHandler, IDragHandler, IEndDragHandler, IDropHandler
{
    [SerializeField] private Image itemIcon;
    [SerializeField] private TextMeshProUGUI quantityText;

    private int slotIndex;
    private InventorySlot slotData;

    public void UpdateSlot(InventorySlot data, int index)
    {
        slotData = data;
        slotIndex = index;

        if (data.item != null)
        {
            itemIcon.sprite = data.item.icon;
            itemIcon.enabled = true;
            quantityText.text = data.quantity > 1 ? data.quantity.ToString() : "";
        }
        else
        {
            itemIcon.enabled = false;
            quantityText.text = "";
        }
    }

    public void OnBeginDrag(PointerEventData eventData)
    {
        if (slotData.item == null) return;
        DragManager.Instance.StartDrag(this, slotIndex);
    }

    public void OnDrop(PointerEventData eventData)
    {
        DragManager.Instance.TrySwap(slotIndex);
    }
}

Pixel-Perfect Setup

Camera Configuration

public class PixelPerfectSetup : MonoBehaviour
{
    void Start()
    {
        // Ensure PixelPerfectCamera component is attached
        var pixelPerfect = GetComponent<PixelPerfectCamera>();
        pixelPerfect.assetsPPU = 16; // Match your sprite PPU
        pixelPerfect.refResolutionX = 320;
        pixelPerfect.refResolutionY = 180;
        pixelPerfect.upscaleRT = true;
        pixelPerfect.pixelSnapping = true;
    }
}

Common Patterns

Event-Driven Architecture

Use UnityEvents or C# events for loose coupling:

// In TimeManager
public event Action<Season> OnSeasonChanged;

// In CropManager
void Start()
{
    TimeManager.Instance.OnSeasonChanged += HandleSeasonChange;
}

void OnDestroy()
{
    TimeManager.Instance.OnSeasonChanged -= HandleSeasonChange;
}

Object Pooling for Crops/Items

public class CropPool : MonoBehaviour
{
    [SerializeField] private GameObject cropPrefab;
    [SerializeField] private int initialPoolSize = 50;

    private Queue<GameObject> pool;

    public GameObject Get()
    {
        if (pool.Count == 0)
            return Instantiate(cropPrefab);

        var obj = pool.Dequeue();
        obj.SetActive(true);
        return obj;
    }

    public void Return(GameObject obj)
    {
        obj.SetActive(false);
        pool.Enqueue(obj);
    }
}

Reference Files

Score

Total Score

40/100

Based on repository quality metrics

SKILL.md

SKILL.mdファイルが含まれている

+20
LICENSE

ライセンスが設定されている

0/10
説明文

100文字以上の説明がある

0/10
人気

GitHub Stars 100以上

0/15
最近の活動

3ヶ月以内に更新がある

0/10
フォーク

10回以上フォークされている

0/5
Issue管理

オープンIssueが50未満

+5
言語

プログラミング言語が設定されている

+5
タグ

1つ以上のタグが設定されている

0/5

Reviews

💬

Reviews coming soon