If you have played any RPG with a quest log, you already know the shape: a list of quests, each with trackable objectives. The UI that helps players review what they are doing and what they have finished is usually called a quest journal.

In this six-part series, we’ll build one from raw data to a polished UI using a practical, production-minded architecture for Unity. We’ll target Unity 6.3 and lean on modern C# features up to C# 9.0 where Unity supports them.

By the end of this article, you’ll understand what records buy you, the small Unity workaround required to use them, and you’ll have clean quest and objective definitions we’ll reuse throughout the series. From there, each post moves us closer to the full system: persist state with SQLite, model it with records, and present it with a clean MVP pattern.

The problem

In a data-driven system, start by defining strict data containers for the base state. For our quest journal, a quest needs an ID, title, description, and completion state, so your first instinct might be to reach for a simple class:

[Serializable]
public class QuestData
{
    public int id;
    public string title;
    public string description;
    public bool isComplete

[Serializable]
public class QuestData
{
    public int id;
    public string title;
    public string description;
    public bool isComplete

[Serializable]
public class QuestData
{
    public int id;
    public string title;
    public string description;
    public bool isComplete

[Serializable]
public class QuestData
{
    public int id;
    public string title;
    public string description;
    public bool isComplete

This looks good, but since it's data, we want it to satisfy two strict requirements:

  • Immutability: once created, it never changes.

  • Value equality: two instances with the same contents compare as equal.

Doing this has a lot of benefits:

Immutability

  • Data can be trusted at all times, since it will not change when passed to other methods.

  • Debugging becomes easier, because if data is wrong, it happened at creation time.

  • It is safe to share between systems, since the data remains the same everywhere.

  • Since it doesn’t change, it is thread-safe by design.

Value equality

  • Collection lookups work correctly because equality is based on content, not instance identity.

  • Changes in data are easy to detect, since even one differing value makes two instances unequal.

  • Testing is straightforward, as results based on data are predictable and easy to compare.

We can expand QuestData to meet these two requirements, but the class balloons quickly:

using System;
using UnityEngine;

[Serializable]
public class QuestData : IEquatable<QuestData>
{
    [SerializeField] private int id;
    [SerializeField] private string title;
    [SerializeField] private string description;
    [SerializeField] private bool isComplete;

    public int Id => id;
    public string Title => title;
    public string Description => description;
    public bool IsComplete => isComplete;

    public QuestData(int id, string title, string description, bool isComplete)
    {
        this.id = id;
        this.title = title;
        this.description = description;
        this.isComplete = isComplete;
    }

    public bool Equals(QuestData other)
    {
        if (other is null) return false;
        if (ReferenceEquals(this, other)) return true;

        return id == other.id
            && title == other.title
            && description == other.description
            && isComplete == other.isComplete;
    }

    public override bool Equals(object obj)
    {
        if (obj is null) return false;
        if (ReferenceEquals(this, obj)) return true;
        if (obj.GetType() != GetType()) return false;

        return Equals((QuestData)obj);
    }

    public override int GetHashCode()
    {
        return HashCode.Combine(id, title, description, isComplete);
    }

    public static bool operator ==(QuestData left, QuestData right)
    {
        if (left is null) return right is null;
        return left.Equals(right);
    }

    public static bool operator !=(QuestData left, QuestData right)
    {
        return !(left == right);
    }

    public override string ToString()
    {
        return $"QuestData: {id}, {title}, {description}, {isComplete}"

using System;
using UnityEngine;

[Serializable]
public class QuestData : IEquatable<QuestData>
{
    [SerializeField] private int id;
    [SerializeField] private string title;
    [SerializeField] private string description;
    [SerializeField] private bool isComplete;

    public int Id => id;
    public string Title => title;
    public string Description => description;
    public bool IsComplete => isComplete;

    public QuestData(int id, string title, string description, bool isComplete)
    {
        this.id = id;
        this.title = title;
        this.description = description;
        this.isComplete = isComplete;
    }

    public bool Equals(QuestData other)
    {
        if (other is null) return false;
        if (ReferenceEquals(this, other)) return true;

        return id == other.id
            && title == other.title
            && description == other.description
            && isComplete == other.isComplete;
    }

    public override bool Equals(object obj)
    {
        if (obj is null) return false;
        if (ReferenceEquals(this, obj)) return true;
        if (obj.GetType() != GetType()) return false;

        return Equals((QuestData)obj);
    }

    public override int GetHashCode()
    {
        return HashCode.Combine(id, title, description, isComplete);
    }

    public static bool operator ==(QuestData left, QuestData right)
    {
        if (left is null) return right is null;
        return left.Equals(right);
    }

    public static bool operator !=(QuestData left, QuestData right)
    {
        return !(left == right);
    }

    public override string ToString()
    {
        return $"QuestData: {id}, {title}, {description}, {isComplete}"

using System;
using UnityEngine;

[Serializable]
public class QuestData : IEquatable<QuestData>
{
    [SerializeField] private int id;
    [SerializeField] private string title;
    [SerializeField] private string description;
    [SerializeField] private bool isComplete;

    public int Id => id;
    public string Title => title;
    public string Description => description;
    public bool IsComplete => isComplete;

    public QuestData(int id, string title, string description, bool isComplete)
    {
        this.id = id;
        this.title = title;
        this.description = description;
        this.isComplete = isComplete;
    }

    public bool Equals(QuestData other)
    {
        if (other is null) return false;
        if (ReferenceEquals(this, other)) return true;

        return id == other.id
            && title == other.title
            && description == other.description
            && isComplete == other.isComplete;
    }

    public override bool Equals(object obj)
    {
        if (obj is null) return false;
        if (ReferenceEquals(this, obj)) return true;
        if (obj.GetType() != GetType()) return false;

        return Equals((QuestData)obj);
    }

    public override int GetHashCode()
    {
        return HashCode.Combine(id, title, description, isComplete);
    }

    public static bool operator ==(QuestData left, QuestData right)
    {
        if (left is null) return right is null;
        return left.Equals(right);
    }

    public static bool operator !=(QuestData left, QuestData right)
    {
        return !(left == right);
    }

    public override string ToString()
    {
        return $"QuestData: {id}, {title}, {description}, {isComplete}"

using System;
using UnityEngine;

[Serializable]
public class QuestData : IEquatable<QuestData>
{
    [SerializeField] private int id;
    [SerializeField] private string title;
    [SerializeField] private string description;
    [SerializeField] private bool isComplete;

    public int Id => id;
    public string Title => title;
    public string Description => description;
    public bool IsComplete => isComplete;

    public QuestData(int id, string title, string description, bool isComplete)
    {
        this.id = id;
        this.title = title;
        this.description = description;
        this.isComplete = isComplete;
    }

    public bool Equals(QuestData other)
    {
        if (other is null) return false;
        if (ReferenceEquals(this, other)) return true;

        return id == other.id
            && title == other.title
            && description == other.description
            && isComplete == other.isComplete;
    }

    public override bool Equals(object obj)
    {
        if (obj is null) return false;
        if (ReferenceEquals(this, obj)) return true;
        if (obj.GetType() != GetType()) return false;

        return Equals((QuestData)obj);
    }

    public override int GetHashCode()
    {
        return HashCode.Combine(id, title, description, isComplete);
    }

    public static bool operator ==(QuestData left, QuestData right)
    {
        if (left is null) return right is null;
        return left.Equals(right);
    }

    public static bool operator !=(QuestData left, QuestData right)
    {
        return !(left == right);
    }

    public override string ToString()
    {
        return $"QuestData: {id}, {title}, {description}, {isComplete}"

That’s a lot of boilerplate, and the intent isn’t obvious at a glance. It’s a classic code smell.

And there's another wrinkle: IsComplete is a value we will eventually want to modify as the player progresses. Once we add methods to update specific fields, the class grows again.

That’s painful to maintain. As soon as you introduce more data types, you’re stuck repeating the same copy-paste scaffolding over and over just to enforce the same rules.

Luckily for us, we have a solution: records.

Records: the one-line solution

C# records offer a much cleaner path for our data implementation.

  • They are reference types with value equality: heap allocated, but equal when their data is equal.

  • They are immutable by default, which sounds limiting until you meet the with expression for creating modified copies.

  • They automatically generate the boilerplate we had to write in our previous QuestData: constructors, equality members, and a readable ToString.

That means we can now do this:

public record QuestData(int Id, string Title, string Description, bool IsComplete
public record QuestData(int Id, string Title, string Description, bool IsComplete
public record QuestData(int Id, string Title, string Description, bool IsComplete
public record QuestData(int Id, string Title, string Description, bool IsComplete

…and immediately get an immutable type with value equality and a helpful ToString().

QuestData questData = new QuestData(0, "Example", "A Good example", false);

Debug.Log(questData.ToString());
// Prints: QuestData { Id = 0, Title = Example, Description = A Good example, IsComplete = False}
QuestData questData = new QuestData(0, "Example", "A Good example", false);

Debug.Log(questData.ToString());
// Prints: QuestData { Id = 0, Title = Example, Description = A Good example, IsComplete = False}
QuestData questData = new QuestData(0, "Example", "A Good example", false);

Debug.Log(questData.ToString());
// Prints: QuestData { Id = 0, Title = Example, Description = A Good example, IsComplete = False}
QuestData questData = new QuestData(0, "Example", "A Good example", false);

Debug.Log(questData.ToString());
// Prints: QuestData { Id = 0, Title = Example, Description = A Good example, IsComplete = False}

This is powerful. We’ve compressed a 60+ line class down to one line, with the same guarantees, and it becomes much easier to expand as the system grows.

So how do we change the value of IsComplete? The trick is to use non-destructive mutation with the with expression: create a new copy with one field changed.

var quest = new QuestData(0, "The Lost Artifact", "Find the ancient relic", false);
var completed = quest with { IsComplete = true };

Debug.Log(quest == completed); // false
Debug.Log(quest.Id == completed.Id); // true
var quest = new QuestData(0, "The Lost Artifact", "Find the ancient relic", false);
var completed = quest with { IsComplete = true };

Debug.Log(quest == completed); // false
Debug.Log(quest.Id == completed.Id); // true
var quest = new QuestData(0, "The Lost Artifact", "Find the ancient relic", false);
var completed = quest with { IsComplete = true };

Debug.Log(quest == completed); // false
Debug.Log(quest.Id == completed.Id); // true
var quest = new QuestData(0, "The Lost Artifact", "Find the ancient relic", false);
var completed = quest with { IsComplete = true };

Debug.Log(quest == completed); // false
Debug.Log(quest.Id == completed.Id); // true

Here, completed is a new object with the same values as quest, except IsComplete is now true. The original quest never changes. And because records override ==, quest == completed compares their contents, not their reference identity.

At this point, it should be clear why records are such a good fit for data types. But before we get too comfortable, there’s a practical catch: as written, this won’t compile in Unity without a small workaround.

A Unity workaround for records

If you have not seen this syntax before, this one-line form is a positional record. The parameters are required to construct an instance, and the compiler also generates public properties from them (often called positional properties). That is why they use UpperCamelCase.

Internally, those generated properties use init accessors, which makes them effectively read-only after construction:

/// Id, Title, Description and IsComplete are positional properties
public record QuestData(int Id, string Title, string Description, bool IsComplete

/// Id, Title, Description and IsComplete are positional properties
public record QuestData(int Id, string Title, string Description, bool IsComplete

/// Id, Title, Description and IsComplete are positional properties
public record QuestData(int Id, string Title, string Description, bool IsComplete

/// Id, Title, Description and IsComplete are positional properties
public record QuestData(int Id, string Title, string Description, bool IsComplete

The catch is that init relies on a compiler-known type named System.Runtime.CompilerServices.IsExternalInit. It exists in .NET 5+, but Unity’s base class library does not include it, so record support in Unity has this extra step.

Fortunately, the compiler only needs the type to exist. It does not need any implementation. You can add a tiny stub yourself:

// Place anywhere. Like in Runtime/Utilities/IsExternalInit.cs
namespace System.Runtime.CompilerServices
{
    internal class IsExternalInit

// Place anywhere. Like in Runtime/Utilities/IsExternalInit.cs
namespace System.Runtime.CompilerServices
{
    internal class IsExternalInit

// Place anywhere. Like in Runtime/Utilities/IsExternalInit.cs
namespace System.Runtime.CompilerServices
{
    internal class IsExternalInit

// Place anywhere. Like in Runtime/Utilities/IsExternalInit.cs
namespace System.Runtime.CompilerServices
{
    internal class IsExternalInit

With that stub in place, positional records compile normally because the compiler can resolve IsExternalInit. Unity’s own documentation calls out this exact workaround, and it is the standard fix when targeting frameworks that are missing the type.

With records now working in Unity, we can move on to the next topic we have been avoiding: Unity serialization.

Unity serialization of records

Unity’s serialization system does not work with records. Even if you try something like this:

[Serializable]
public record QuestData(
    [field: SerializeField] int Id,
    [field: SerializeField] string Title,
    [field: SerializeField] string Description,
    [field: SerializeField] bool IsComplete

[Serializable]
public record QuestData(
    [field: SerializeField] int Id,
    [field: SerializeField] string Title,
    [field: SerializeField] string Description,
    [field: SerializeField] bool IsComplete

[Serializable]
public record QuestData(
    [field: SerializeField] int Id,
    [field: SerializeField] string Title,
    [field: SerializeField] string Description,
    [field: SerializeField] bool IsComplete

[Serializable]
public record QuestData(
    [field: SerializeField] int Id,
    [field: SerializeField] string Title,
    [field: SerializeField] string Description,
    [field: SerializeField] bool IsComplete

Unity still won’t reliably serialize them into the Inspector. Records don’t match what Unity’s serializer is built around, like simple mutable fields, a parameterless constructor, and a predictable internal layout. Dictionaries are a well-known example of similar limitations.

You can work around this with a custom PropertyDrawer or tools like Odin Inspector, but for our use case it’s perfectly fine. At this stage we are defining the base data layer. Records are the transport layer between SQLite and the runtime, not something we want Unity to serialize:

This also means our persistence lives in SQLite, not in Unity. That may feel like a drawback at first, but as the architecture takes shape, you’ll see why it’s a win. With serialization off the table, our records become pure data contracts: they define shape, not storage. So let’s properly define the data that will serve as the foundation of the system. After all, a quest needs objectives.

Main data of a Quest Journal

We can define the required data as follows:

  • Quest: has a unique identity, descriptive data, an activation state to know whether the player has started it, and completion data.

  • Objective: has a unique identity, belongs to a quest, includes descriptive data, and tracks progress toward a target.

This is a one-to-many relationship: a quest has multiple objectives, but an objective belongs to a single quest. Expressed as records, that looks like:

namespace QuestJournal.SQLData
{
    public record QuestData(
        int Id,
        string Title,
        string Description,
        bool IsActive = false,
        bool IsComplete = false
    );

    public record ObjectiveData(
        int Id,
        int QuestId,
        string Description,
        int CurrentProgress,
        int TargetProgress
    )
    {
        public bool IsComplete => CurrentProgress >= TargetProgress

namespace QuestJournal.SQLData
{
    public record QuestData(
        int Id,
        string Title,
        string Description,
        bool IsActive = false,
        bool IsComplete = false
    );

    public record ObjectiveData(
        int Id,
        int QuestId,
        string Description,
        int CurrentProgress,
        int TargetProgress
    )
    {
        public bool IsComplete => CurrentProgress >= TargetProgress

namespace QuestJournal.SQLData
{
    public record QuestData(
        int Id,
        string Title,
        string Description,
        bool IsActive = false,
        bool IsComplete = false
    );

    public record ObjectiveData(
        int Id,
        int QuestId,
        string Description,
        int CurrentProgress,
        int TargetProgress
    )
    {
        public bool IsComplete => CurrentProgress >= TargetProgress

namespace QuestJournal.SQLData
{
    public record QuestData(
        int Id,
        string Title,
        string Description,
        bool IsActive = false,
        bool IsComplete = false
    );

    public record ObjectiveData(
        int Id,
        int QuestId,
        string Description,
        int CurrentProgress,
        int TargetProgress
    )
    {
        public bool IsComplete => CurrentProgress >= TargetProgress

There is a lot to unpack here, so let’s go from top to bottom.

We use integer IDs because these structures will eventually represent SQLite data, and integer primary keys are the traditional database approach. This maps directly to the database layer and gives us all of SQLite’s flexibility. Strings would be more readable, but at this base data layer we can safely skip that without concern.

You may notice that IsActive and IsComplete in QuestData have default values. This is simply to show that positional properties support defaults as well.

ObjectiveData, on the other hand, includes a QuestId field that links it to its parent quest. When we later query quest information, this will allow us to determine which objectives belong to which quest.

Finally, ObjectiveData defines an IsComplete property inside the record body. This property is derived from CurrentProgress and TargetProgress. Importantly: derived data should not be stored redundantly. If we precomputed this value and later changed either progress field, the data could become inconsistent. Also note that, for this reason, IsComplete does not participate in equality comparisons between ObjectiveData instances.

With these two simple records, we can start building the quest journal. That said, there is one final topic to address before closing the records chapter, and it is probably already forming in the back of your mind.

How do we manage a constantly changing game state with immutable data?

Immutable data vs mutable game state

This is one of the biggest challenges of the architecture we are building. Games are inherently mutable: their state changes constantly as the player and other systems interact with the world. Our data, on the other hand, is immutable.

When an object or quest is updated during gameplay, our naive approach might look like this:

// Player accepts a quest
var quest = new QuestData(0, "The Lost Artifact", "Find the ancient relic", true, false);

// Store it somewhere
activeQuests.Add(quest);

// ...... time passes, player completes the quest

// Create the completed version
var completedQuest = quest with { IsComplete = true

// Player accepts a quest
var quest = new QuestData(0, "The Lost Artifact", "Find the ancient relic", true, false);

// Store it somewhere
activeQuests.Add(quest);

// ...... time passes, player completes the quest

// Create the completed version
var completedQuest = quest with { IsComplete = true

// Player accepts a quest
var quest = new QuestData(0, "The Lost Artifact", "Find the ancient relic", true, false);

// Store it somewhere
activeQuests.Add(quest);

// ...... time passes, player completes the quest

// Create the completed version
var completedQuest = quest with { IsComplete = true

// Player accepts a quest
var quest = new QuestData(0, "The Lost Artifact", "Find the ancient relic", true, false);

// Store it somewhere
activeQuests.Add(quest);

// ...... time passes, player completes the quest

// Create the completed version
var completedQuest = quest with { IsComplete = true

But now what do we do with completedQuest?

activeQuests still holds a reference to the old QuestData, and so do the UI and SQLite. We need a way to update the game’s state with this new data so that changes propagate correctly and can be persisted.

Here lies the central challenge of the entire series. For now, I am only planting the seed. With each post, we will get closer to a proper solution, and by the fourth entry we should be able to answer this question convincingly.

Bottom line

In this first entry, we locked in our core data contracts: quest and objective. Using records let us cut a lot of boilerplate while still getting immutability and value-based equality.

Unity can be a little awkward here since these types won’t serialize out of the box, but that’s not a dealbreaker. If anything, it reinforces the idea that records are best used as simple data transfer objects (DTOs).

You could make a case for structs, but at our scale they add more complexity than value. And while readonly record struct would be a nice middle ground, it’s a C# 10 feature, so it’s not on the table for Unity 6.3.

Next up, we’ll get SQLite running in Unity so these records have somewhere to live.

Esteban Gaete Flores

Senior Unity Engineer

Share