Back to blog
Your FirstPart 9 of 9

Saturday, April 25, 2026

Your First Quest

cover

Starting Point

The series project can now:

  • persist player profiles
  • synchronize players into the world
  • run AI drones
  • resolve real combat

That is enough to add progression with actual gameplay events behind it.

This post builds a first working quest pipeline on the real Altruist quest runtime:

  • a vault-backed IQuestStateStore
  • a small QuestRuntime<GameQuestContext>
  • one state-machine quest
  • one combat-to-quest bridge

1. Persist Quest State in Vault

Create Persistence/QuestProgressVault.cs:

using Altruist;
using Altruist.UORM;

namespace SpaceGame.Persistence;

[Vault("quest_progress")]
public sealed class QuestProgressVault : VaultModel
{
    [VaultColumn("subject_id")]
    public string SubjectId { get; set; } = "";

    [VaultColumn("quest_id")]
    public string QuestId { get; set; } = "";

    [VaultColumn("state_json")]
    public string StateJson { get; set; } = "";
}

We keep it simple:

  • one row per player quest
  • JSON payload stores the internal QuestState

2. Create a Quest Context

Create Quests/GameQuestContext.cs:

using Altruist.Gaming.Questing;
using SpaceGame.World;

namespace SpaceGame.Quests;

public sealed class GameQuestContext : QuestContext
{
    public PlayerPrefab? Player { get; init; }
    public int Level { get; init; }
}

This is your project-specific layer on top of the generic QuestContext.

3. Add a Real State Store

Create Quests/VaultQuestStateStore.cs:

using Altruist;
using Altruist.Gaming.Questing;
using SpaceGame.Persistence;

namespace SpaceGame.Quests;

public sealed class VaultQuestStateStore : IQuestStateStore
{
    private readonly IVault<QuestProgressVault> _vault;

    public VaultQuestStateStore(IVault<QuestProgressVault> vault)
    {
        _vault = vault;
    }

    public async Task<Dictionary<string, QuestState>> LoadAsync(string subjectId)
    {
        var rows = await _vault
            .Where(x => x.SubjectId == subjectId)
            .ToListAsync();

        return rows.ToDictionary(
            x => x.QuestId,
            x => new QuestState(x.StateJson),
            StringComparer.Ordinal);
    }

    public async Task SaveDirtyAsync(string subjectId, IReadOnlyDictionary<string, QuestState> states)
    {
        foreach (var entry in states)
        {
            if (!entry.Value.IsDirty)
                continue;

            var rowId = $"{subjectId}:{entry.Key}";
            var row = await _vault
                .Where(x => x.StorageId == rowId)
                .FirstOrDefaultAsync()
                ?? new QuestProgressVault
                {
                    StorageId = rowId,
                    SubjectId = subjectId,
                    QuestId = entry.Key,
                };

            row.StateJson = entry.Value.Serialize();
            await _vault.SaveAsync(row);
            entry.Value.MarkClean();
        }
    }

    public async Task ResetAsync(string subjectId)
    {
        var rows = await _vault
            .Where(x => x.SubjectId == subjectId)
            .ToListAsync();

        foreach (var row in rows)
            await _vault.Where(x => x.StorageId == row.StorageId).DeleteAsync();
    }
}

4. Define a First Quest

Create Quests/FirstHuntQuest.cs:

using Altruist.Gaming.Questing;

namespace SpaceGame.Quests;

[Quest("main.first_hunt")]
[QuestName("First Hunt")]
[QuestKind(QuestKind.Main)]
[QuestRequirement("level", QuestOperator.GreaterOrEqual, 1)]
public sealed class FirstHuntQuest : QuestBehavior<GameQuestContext>
{
    [QuestState("hunt", Initial = true)]
    public string? Hunt(GameQuestContext ctx)
    {
        if (ctx.Trigger == QuestTrigger.Kill)
        {
            int kills = ctx.State.Increment("kills");
            if (kills >= ctx.State.Get("target", 3))
                return "completed";
        }

        return null;
    }

    [QuestStateEnter("hunt")]
    public void EnterHunt(GameQuestContext ctx)
    {
        if (!ctx.State.Has("target"))
            ctx.State.Set("target", 3);
    }

    [QuestStateEnter("completed")]
    public void EnterCompleted(GameQuestContext ctx)
    {
        ctx.State.Set("done", true);
    }

    [QuestState("completed")]
    public string? Completed(GameQuestContext ctx) => null;
}

This is small, but it already uses the real async state-machine runtime shape.

5. Publish Quest Updates to the Client

Create Quests/QuestUpdatePackets.cs:

using System.Text.Json.Serialization;
using Altruist;
using Altruist.Gaming.Questing;

namespace SpaceGame.Quests;

public sealed record QuestRequirementDto(string Key, bool Passed, string? Reason);

public struct QuestUpdatePacket : IPacketBase
{
    [JsonPropertyName("messageCode")] public uint MessageCode { get; set; }
    [JsonPropertyName("questId")] public string QuestId { get; set; }
    [JsonPropertyName("name")] public string Name { get; set; }
    [JsonPropertyName("status")] public string Status { get; set; }
    [JsonPropertyName("state")] public string State { get; set; }
    [JsonPropertyName("counterText")] public string CounterText { get; set; }
    [JsonPropertyName("requirements")] public QuestRequirementDto[] Requirements { get; set; }

    public QuestUpdatePacket()
    {
        MessageCode = 1300;
        QuestId = "";
        Name = "";
        Status = "";
        State = "";
        CounterText = "";
        Requirements = Array.Empty<QuestRequirementDto>();
    }
}

public sealed class PacketQuestUpdateSink : IQuestUpdateSink<GameQuestContext>
{
    private readonly IAltruistRouter _router;

    public PacketQuestUpdateSink(IAltruistRouter router)
    {
        _router = router;
    }

    public Task PublishAsync(GameQuestContext context, QuestUpdate update)
    {
        return _router.Client.SendAsync(context.SubjectId, new QuestUpdatePacket
        {
            QuestId = update.QuestId,
            Name = update.Name,
            Status = update.Status.ToString(),
            State = update.State,
            CounterText = update.CounterText,
            Requirements = update.Requirements
                .Select(x => new QuestRequirementDto(x.Key, x.Passed, x.Reason))
                .ToArray()
        });
    }
}

6. Wrap the Runtime in a Service

Create Services/QuestService.cs:

using Altruist;
using Altruist.Gaming.Questing;
using SpaceGame.Persistence;
using SpaceGame.Quests;
using SpaceGame.World;

namespace SpaceGame.Services;

[Service]
public sealed class QuestService
{
    private readonly QuestRuntime<GameQuestContext> _runtime;
    private readonly PacketQuestUpdateSink _sink;

    public QuestService(
        IVault<QuestProgressVault> questVault,
        IAltruistRouter router)
    {
        _sink = new PacketQuestUpdateSink(router);

        var store = new VaultQuestStateStore(questVault);
        var requirements = new QuestRequirementRegistry<GameQuestContext>()
            .Add("level", ctx =>
            {
                int requiredLevel = Convert.ToInt32(ctx.Requirement.Value);
                return ValueTask.FromResult(ctx.QuestContext.Level >= requiredLevel);
            });

        _runtime = new QuestRuntime<GameQuestContext>(
            store,
            _sink,
            requirements,
            CreateContext);

        _runtime.LoadFromAssembly(typeof(FirstHuntQuest).Assembly);
    }

    public async Task SyncOnJoinAsync(PlayerPrefab player)
    {
        var baseContext = CreateBaseContext(player);

        foreach (var update in await _runtime.BuildUpdatesAsync(baseContext))
            await _sink.PublishAsync(baseContext, update);

        await _runtime.ReconcileAsync(baseContext);
    }

    public Task OnKillAsync(PlayerPrefab player, uint targetEntityId)
    {
        return _runtime.FireAsync(
            CreateBaseContext(player),
            QuestTrigger.Kill,
            targetEntityId);
    }

    private static GameQuestContext CreateBaseContext(PlayerPrefab player)
    {
        return new GameQuestContext
        {
            SubjectId = player.ClientId,
            Subject = player,
            Player = player,
            Level = player.Level,
            Services = null,
        };
    }

    private static GameQuestContext CreateContext(
        GameQuestContext baseContext,
        QuestTrigger trigger,
        QuestDefinition<GameQuestContext> quest,
        long targetId,
        int value,
        QuestState state,
        IReadOnlyList<QuestRequirementResult> requirements)
    {
        return new GameQuestContext
        {
            SubjectId = baseContext.SubjectId,
            Subject = baseContext.Subject,
            Player = baseContext.Player,
            Level = baseContext.Level,
            Trigger = trigger,
            TargetId = targetId,
            Value = value,
            State = state,
            RequirementResults = requirements,
            Services = baseContext.Services,
        };
    }
}

7. Bridge Combat Into Quest Progress

Create Services/QuestCombatBridge.cs:

using Altruist;
using Altruist.Gaming.Combat;
using SpaceGame.World;

namespace SpaceGame.Services;

[Service]
public sealed class QuestCombatBridge
{
    public QuestCombatBridge(ICombatService combat, QuestService quests)
    {
        combat.OnDeath += e =>
        {
            if (e.Killer is PlayerPrefab player)
                _ = quests.OnKillAsync(player, e.Entity.VirtualId);
        };
    }
}

And when the player joins the world:

await _quests.SyncOnJoinAsync(player);

That is the whole first quest loop:

  1. the player enters
  2. the runtime reconciles current quest state
  3. combat kills fire QuestTrigger.Kill
  4. quest state changes
  5. the client receives a QuestUpdatePacket

Why This Is a Real Quest System

This is not a fake one-off quest counter.

It already uses the real moving parts:

  • requirement evaluation
  • persistent state
  • state-machine progression
  • clean update emission

That means the next quests are mostly content work, not architecture rewrites.

Continue the Series

Your First