DEV Community

Caleb Anthony
Caleb Anthony

Posted on • Originally published at cardboardmachete.com

Designing a flexible and reusable quest structure for a browser game

The quest system for Elethor is a flexible and powerful system, but it grew organically (read: morphed into a monster) over the months in an unfortunately ugly and difficult-to-maintain way.

Some of its pain points included difficulty adding more types of tasks to complete or rewards to hand out and having to design one-and-done quests that players will complete and never touch again (ouch).

I saw Trounced (my new kingdom-builder browser game) as an opportunity to take lessons learned and apply them to a new and improved system, complete with its own bugs and shortcomings!

This is the system that I ended up with, and I'm pretty happy with (so far).

Quest Design Criteria

Starting with any big hairy feature like quests, it's critical to sit down and define your parameters first. Luckily I had some experience (failures) with Elethor, so it wasn't too hard to put together a list.

1. Easily Extensible

I want to be able to add new quest types, rewards, and tasks easily. As the game grows, there will (and should) be more combinations of these things to create new quests.

2. Reusable & Schedulable

Instead of designing quests as a one-and-done experience (like most MMOs), I wanted to design them as repeatable events. So each quest needs to make sense as something that can be done over and over, as well as have a mechanism for easily starting, ending, and rescheduling.

3. Scalable

A noob might struggle to put together a straggling army of 100 soldiers while an established vet will be amassing thousands of elite units with little effort. Each quest task needs to be able to define its own criteria and rewards on a per-kingdom basis.

4. with a smattering of magic

There are always edge cases and alternate quest structures I'll want to handle. Adding some flexible magic into the quest system will let me handle some of these edge cases without keeping every quest as cookie-cutter as the last one.

You'll see.

Getting Started aka Database Schema

I tend to put as much data and configuration into the database as possible.

I don't know where I learned this habit, but as my games have grown in complexity, this has bitten me. I end up querying across 5-6 tables for some basic data that could (and should) have lived in a config file.

So this time I decided to overcorrect and opted to put most of the quest configuration and logic into classes.

This means I only ended up needing 2 tables. One for tracking each individual quest step (tied to an entity) and one for handling the scheduling of quests.

I added a bunch of comments to this schema so you can digest it as you go.

Schema::create('quests', function (Blueprint $table) {
    $table->id();
    // Quests belong to a realm for tracking hiscores
    $table->integer('realm_id');
    // Questable can be either a Kingdom, Alliance, or Realm
    // This is just a polymorphic relationship in Eloquent
    $table->integer('questable_id');
    $table->string('questable_type');
    // The collection of quests, e.g. "Daily" or "Rebuilding"
    $table->string('group');
    // The name of the specific quest step, like "Kill 300 enemy soldiers"
    $table->string('name');
    // An enum of possible tasks. This is a string because we want
    // to add more quest tasks later on without dealing with DB schema.
    $table->string('task');
    // Pretty self-explanatory...
    $table->integer('quantity_required');
    $table->integer('current_quantity')->default(0);
    // An array of reward objects.
    // Each reward is an item name, quantity, and rarity (for the UI)
    $table->jsonb('rewards');
    // An enum for how frequently the quest should reset (if at all)
    $table->string('reset_frequency')->nullable();
    // Pretty self-explanatory...
    $table->timestamp('started_at')->nullable();
    $table->timestamp('ended_at')->nullable();
    $table->timestamp('completed_at')->nullable();
    $table->timestamps();
});
Enter fullscreen mode Exit fullscreen mode

For the quest scheduling, I wanted to keep all the quest groups and their start / end dates in the database. Then I have a queued job that runs daily to start / stop quests and run their custom start / stop logic.

Pretty self-explanatory. The quest_group has the same value as the group column in the above schema.

Schema::create('quest_schedules', function (Blueprint $table): void {
    $table->id();
    $table->integer('realm_id');
    $table->string('quest_group');
    $table->timestamp('started_at');
    $table->timestamp('ended_at');
    $table->timestamps();
});
Enter fullscreen mode Exit fullscreen mode

Then I have models for each of these database tables and added relationships for all the polymorphic relations. That makes it easy to do something like $kingdom->quests and get all the quests for that specific kingdom.

Quest Groups

For each quest group, I have a dedicated class that handles the logic / setup / teardown.

Here's the abstract class that all quest groups extend:

abstract class QuestGroup
{
    public string $group;
    public string $description;

    // The meat of the class. Give the class an entity and then it builds out
    // all the steps of the quest for that specific entity.
    abstract public function buildFor(Alliance | Realm | Kingdom $questable): void;

    // Gets called by the scheduled job when it's time to
    // start this quest.
    abstract public function start(int $realmId): void;

    // Gets called by the scheduled job when the quest
    // is completed. Cleanup, hiscore rewards, rescheduling.
    // Most quests define when they want to start next and create a new
    // QuestSchedule object here, which creates a nice infinite loop of
    // scheduled quests over time.
    abstract public function end(int $realmId): void;
}
Enter fullscreen mode Exit fullscreen mode

In the buildFor method, I get an entity (Kingdom, Alliance or Realm) and build all the steps of a quest against that entity.

This gives me flexibility to adjust the requirements for each step to the entity. Maybe a 10-kingdom alliance only has to attack 20 times, where a 50-person alliance should attack 50 times.

When starting a scheduled quest for an entire Realm, I often do something like this on the quest group class:

public function start(int $realmId): void
{
    Kingdom::where('realm_id', $realmId)
        ->cursor()
        ->each(function (Kingdom $kingdom): void {
            $this->buildFor($kingdom);
        });
}
Enter fullscreen mode Exit fullscreen mode

I have a scheduled job that runs once a day and looks for quests that wanna start soon, then calls this start method on each group.

Then in the buildFor method, I dynamically build out each step.

For the following examples, we're going to be looking at a quest group named 'Claim to Vengeance' which rewards players for attacking one another and killing troops.

So how do I add tasks to this quest group that the player can complete?

Quest Tasks

Each task is an entry in the quests table that we made earlier.

An example from the Claim to Vengeance (CtV) quest looks like this. This is code in the buildFor method on the CtV group class, so we have access to the $kingdom variable.

Lots of comments just for you. :)

Quest::create([
    'realm_id' => $kingdom->realm_id,
    // Set the polymorphic relation details with the kingdom, since
    // we want this quest to be kingdom-specific. This could easily
    // be an alliance or realm if we wanted to do it that way instead.
    'questable_id' => $kingdom->id,
    'questable_type' => $kingdom->getMorphClass(),
    'group' => $this->group,
    'reset_frequency' => null,
    // We want the quest to start right now, and it will last for 3 days.
    'started_at' => now(),
    'ended_at' => now()->addDays(3),
    // We build the `$name` variable by calculating the kills required
    // for this kingdom based on their current acreage size.
    'name' => "Reach {$name}k points killing troops",
    // The task for this quest. I'll show you the tracker for this
    // specific task later on.
    'task' => QuestTask::TroopsKilled,
    // Dynamically calculated number of kills based on the
    // size of the kingdom.
    'quantity_required' => $killsRequired,
    // The rewards array is nested an extra "layer" deep
    // for reasons I'll explain down in the 'Meta' section.
    'rewards' => [
        [
            // Each `name` is exactly the name of an item in the database
            // which we look up when handing out rewards upon completion.
            ['name' => 'Hero Exp', 'qty' => 50, 'rarity' => 'Rare'],
            ['name' => '200 Soldiers', 'qty' => 1, 'rarity' => 'Uncommon'],
            ['name' => '50k Gold', 'qty' => 1, 'rarity' => 'Uncommon'],
        ],
    ],
]);
Enter fullscreen mode Exit fullscreen mode

I utilized this logic to build out 6-7 steps for this quest, all with slightly different requirements, tasks, and rewards.

It's absolutely verbose (and I clean it up a bit in my actual class), but it lets me do whatever I want in code. Plus I can easily understand everything whenever I come back to check out this quest.

Which I like.

Quest Tracking

Now we have all our quest tasks built out, and a scheduler that can handle starting / stopping quests based on whatever criteria we want.

How do I handle actually tracking and updating the current_quantity for all these quests?

Quest trackers!

Each quest task enum has a corresponding tracker. I have abstracted almost all of this logic to a base class, so creating a new tracker is super easy.

Seriously, here's the tracker for the QuestTask::TroopsKilled enum you saw in the earlier quest task:

class TroopsKilledTracker extends BaseTaskTracker
{
    public function __construct()
    {
        $this->task = QuestTask::TroopsKilled;
        parent::__construct(...func_get_args());
    }
}
Enter fullscreen mode Exit fullscreen mode

and then I just plop it in my code wherever I want.

Like so:

TroopsKilledTracker::dispatch($kingdom, $points);
Enter fullscreen mode Exit fullscreen mode

The points are calculated based on the type of unit you kill. For example, soldiers (simple units) are only worth 1 point each, while elites (the strongest units) are worth 4 points each.

Here's another example of a dispatching a tracker that cares about how often you cast a certain spell. The second argument (quantity) defaults to 1, so we don't even have to add that here.

AlchemyCastTracker::dispatch($kingdom);
Enter fullscreen mode Exit fullscreen mode

That's literally it.

Super simple.

But there's a ton of work in that BaseTaskTracker that we can uncover.

Brace yourself!

The ugly underbelly of task tracking

We'll break this down a little at a time.

First, you may have noticed that I didn't new up the tracker, I called the static dispatch method. This is a static method that returns the class constructor but gives me a Laravel-like API for creating trackers easily on the fly.

The real constructor looks like this.

public function __construct(Kingdom $kingdom, int $quantity = 1, ?string $group = null)
{
    if ($this->task === null) {
        throw new Exception("The QuestTask was not set on this Tracker.");
    }
    if ($this->task === QuestTask::Meta && $group === null) {
        throw new Exception("Meta tasks require a quest group to be set.");
    }

    $this->kingdom = $kingdom;
    $this->quantity = $quantity;
    $this->group = $group;

    $this->handle();
}
Enter fullscreen mode Exit fullscreen mode

You'll recall that we can create task trackers for any entity (kingdoms, alliances, realms) but this constructor only accepts a kingdom.

That's because kingdoms are the only entities that can perform actions on their own. A player only ever controls a kingdom, and the alliance / realm tasks are automatically updated based on kingdom actions.

We do a bit of safety-checking at the start to make sure that all the parameters are properly set on the underlying tracker and then kick off the handle method.

public function handle(): void
{
    $quests = $this->validQuests();

    if ($this->group !== null) {
        $quests->where('group', $this->group);
    }

    if ($this->task === QuestTask::Meta) {
        $quest = Quest::where([
            ['task', QuestTask::Meta],
            ['group', $this->group],
        ])->first();

        (new QuestCompleter)->forMeta($this->kingdom, $quest, $this->quantity);
    }

    $quests->increment('current_quantity', $this->quantity);
}
Enter fullscreen mode Exit fullscreen mode

This builds out a juicy query using Laravel's excellent query builder.

The validQuests method is 40 lines of nasty logic but basically makes these crucial checks:

  1. Quests that aren't completed yet.
  2. Quests that match the kingdom's ID, realm ID, or alliance ID.
  3. Quests that have already started (or don't have a start date).
  4. Quests that aren't ended yet (or don't have an end date).
  5. Quests that match this task's tracker.
  6. Quests where the quantity_required is -1 (unlimited) or still less than the quantity_required.

Quests don't always require a group to be set, as they can just be one-off tasks (like for new accounts). But if the group is set, we filter to only include quests that match the group provided.

If the task is a 'Meta' task, we add some extra logic. I'll go into those some more later, but essentially as you rack up points for meta quests, you automatically earn some bonus rewards.

Then we just increment the quest current_quantity by whatever the tracker said we should!

This structure is super flexible to allow me to:

  • Add more quest tasks with no problem.
  • Add more trackers super easily and throw them wherever I want in the code.

Brilliant!

Completing Quests

Once a quest has current_quantity >= quantity_required, it's completeable and no longer gets updated.

This just means on the front-end we pop a little button that the player can click to claim the rewards and update the completed_at timestamp.

Completeable Quest

If I wanted, later on I could also add a step before a quest closed or reset to automatically check all quests that could be completeable and auto-complete them for the player and hand out rewards.

I'm not doing that right now because half the fun of finishing quests is clicking 'Complete' and seeing all the juicy rewards flow into your inventory. If you automate your whole game away, there's nothing left for the players to do!

Plus seeing a bunch of completed quests is satisfying...

Completed Quest

Meta Quests

Lots of games have collections of quests, and in that collection is a progress bar. This shows your overall progress through the whole quest line. As you progress through all the quests, you maybe even unlock rewards at certain points.

Meta Example

That's the purpose of a meta quest.

As an example, the Daily quest gives you a ton of tasks that you can do, and each task also rewards a small number of 'Meta Points'. This is a "magic" item as far as the code is concerned, which just increments the current_quantity on all quests in the same group with the Meta task.

When you reach certain breakpoints, you get a little chunk of rewards from that meta quest. This encourages players to be completionists, since you can give a bonus reward at the end of a quest line for completing every step.

You saw an earlier quest reward structure with an odd nested array. The reason is so we can accommodate the reward structure of a meta quest, which looks like this:

'rewards' => [
    25 => [
        ['name' => '2k Mana', 'qty' => 1, 'rarity' => 'Common'],
        ['name' => '20k Gold', 'qty' => 1, 'rarity' => 'Common'],
    ],
    50 => [
        ['name' => '20k Gold', 'qty' => 1, 'rarity' => 'Common'],
        ['name' => '5k Mana', 'qty' => 1, 'rarity' => 'Uncommon'],
    ],
    75 => [
        ['name' => '50k Gold', 'qty' => 1, 'rarity' => 'Uncommon'],
        ['name' => '200 Soldiers', 'qty' => 1, 'rarity' => 'Uncommon'],
        ['name' => '200 Acres', 'qty' => 1, 'rarity' => 'Rare'],
        ['name' => 'Hero Exp', 'qty' => 50, 'rarity' => 'Rare'],
    ],
    100 => [
        ['name' => 'Hero Exp', 'qty' => 100, 'rarity' => 'Rare'],
        ['name' => '50 Elites', 'qty' => 1, 'rarity' => 'Rare'],
        ['name' => 'Low Alchemy', 'qty' => 1, 'rarity' => 'Epic'],
        ['name' => 'Credit', 'qty' => 25, 'rarity' => 'Legendary'],
    ],
],
Enter fullscreen mode Exit fullscreen mode

It's probably pretty self-explanatory. Once you break the 50 Meta Point breakpoint, you earn some gold and mana items.

On the front end, you get a nice little progress bar like this.

Meta In Progress

Oh, and because this quest is built specifically for each entity, you could change where those reward breakpoints are. Like letting kingdoms invest in a boost that makes it easier to achieve certain quest benchmarks, or power up certain rewards.

The possibilities are endless, and all contained neatly in that quest's group class.

BONUS: Flexible UI

The UI for almost all quest steps looks pretty much the same, but because of the flexibility of my back-end, I wanted some flexibility for the front-end too.

Here's an example of the 'Daily' quest vs the 'Sign-In' quest using the exact same back-end quest structure.

Daily Quest

Sign-In Quest

I wrote a slightly different component on the front-end for all quests that belong to the Sign-In group, and everything works beautifully!

The way the sign-in quest is set up is a bit different, but it uses the same exact system.

  • It's scheduled to reset once per month.
  • Each step has 0 quantity_required, making it immediately completeable.
  • Each step is scheduled to only be available for a 24h window.
  • Each step gives 1 Meta Point.
  • It utilizes a meta quest that gives super strong rewards for checking in 28 days during the month.

It's 100% using my existing quest system with no additional logic or special checks. But it functions differently from a player perspective.

This was a quest structure that I came up with after designing my quest system, and I was pleasantly surprised to see that it fit perfectly into my existing architecture.

That's always a sign you're on the right track!

Closing Thoughts

No system is perfect, and as this quest system rolls into production and ages like milk, I'm sure I'll be writing more posts about lessons learned.

However, the power and speed of development this system has already given me in creating new content is impressive.

I'm not often impressed by my own code!

Top comments (0)