0
\$\begingroup\$

I am creating an achievement system for my game engine. I represent an achievement as a class takes a predicate function to determine if requirement for unlock has been met.

class Achievement {
    name: string
    description: string
    requirement: (event: GameEvent): bool

    constructor(name: string, description: string, requirement: (event: GameEvent): bool) {
        this.name = name
        this.description = description
        this.requirement = requirement
    }
}

An example achievement might look like

const firstItem = new Achievement(
    "First Item",
    "You picked up your first item!",
    (event: GameEvent) => { return event.label == "itemCollected" }
)

I'm wondeing what the best approaches are for storing and loading achievements. I could encode them in a JSON document

// assets/achievements.json
{
    "firstItem": {
        "name": "First Item",
        "description": "You picked up your first item!",
        "requirement": "event.label == 'itemCollected'"
    }
}

But it feels awkward to store the requirement code as a string, having to read it and pass it to eval()

class AchievementLoad {
    path: string

    constructor(path: string) {
        this.path = path
    }
    recursiveLoad(path: string): Achievement[] {
        const json = JSON.parse(path)
        const achievements = []
        for (const key in json) {
            const achievement = json[key]
            const requirement = eval(achievement.requirement)
            achievements.push(new Achievement(achievement.name, achievement.description, requirement))
        }
        return
    }
}

This introduces coupling between my source code and the asset save format, which seems to violate the Single-Responsibility Principle as changing the way one behaves may require me to change the other, and is further worsened by there being no type safety mechanism to alert me of incompatible changes. The ergonomics and safety suffer.

Alternatively I've considered storing the achievements in the source code itself, either as objects to be manually instantiated and exported

// src/achievements.ts
const firstItem = new Achievement(
    "First Item",
    "You picked up your first item!",
    (event: GameEvent) => { return event.label == "itemCollected" }
)
const firstQuest = new Achievement(
    "First Quest",
    "You completed your first quest!",
    (event: GameEvent) => { return event.label == "questCompleted" }
)
export { firstItem, firstQuest }

Or as classes themselves extending Achievement

// src/achievements/FirstItem.ts
class FirstItem extends Achievement {
    constructor() {
        super(
            "First Item",
            "You picked up your first item!",
            (event: GameEvent) => return event.label == "itemCollected"
        )
    }
}

export { FirstItem }

In either case, I'm not sure how professional this would be, especially given that I am going the JSON approach for other types of assets in my game.

What are the standard approaches and best practices here? Are there worthwhile alternatives I haven't considered or details I've overlooked?

\$\endgroup\$

1 Answer 1

2
\$\begingroup\$

The usual caution with JSON is that it's easy for end users to edit. That can be a plus for allowing flexible modding of your game, but it means you should treat the data as untrusted.

Never call eval() on untrusted data.

If this is just a player hacking their (local) game to unlock all the achievements just for walking 2 steps, or give themselves unlimited money, no biggie - as long as it's not impacting fairness in online multiplayer or leaderboards.

But a bad actor could release a community mod for your game that does something popular superficially, while smuggling spyware into the executable code of the achievement condition. You want to avoid exposing your players to a threat vector like this, in case your game gets popular and develops a thriving modding community that can attract hackers preying on less tech-savvy folks.


What I did when implementing something similar in a recent jam game was to establish a vocabulary of "types" of achievements that needed distinct behaviours, which I could then specialize into specific achievements by setting parameters.

In that example, I only needed two types:

  • Count - triggers when you've created enough blobs of a certain type (parameters: type, threshold count, and a flag to choose between simultaneous or lifetime total)
  • Connection - triggers when you have enough blobs of a certain type surrounding another type (parameters: center type, connected type, threshold count)

Individual achievements were then data objects that specified which behaviour type they needed, and the corresponding parameters.

On load I could then take those objects and put them into lists to be evaluated against the relevant events, using the (hard-coded) predicate function indicated by their type data.

In your example that JSON might look something like this:

{
    "firstItem" : {
        "name": "First Item",
        "description": "You picked up your first item!",
        "unlock-rule": "gameEventLabel",
        "match": "itemCollected"
    }
}

If you need more complex rules for your achievements and want to avoid making a unique code path for every one, you can compose them out of simpler rules, something like...

{
    "defeatGruntsWithFire": {
        "name": "Playing With Fire",
        "description": "You defeated 5 grunts using fire magic",
        "repeats": 5,
        "criteria": [
            {
                "rule": "gameEventLabel",
                "match": "defeatEnemy"
            },
            {
                "rule": "enemyType",
                "match": "grunt"
            },
            {
                "rule": "damageElement",
                "match": "fire"
            }
        ]
    }
}

Each achievement then gets an array of criteria to check, and only when they all match do you increment the achievement counter (for multi-step achievements) or award the achievement. Each criterion is an object with a parameter it's looking to match, and a rule for what it's matching against. As your vocabulary of criteria grows, you'll soon be able to make new achievements by composing existing criteria rather than writing new criterion types into the system.

In code, this looks like a switch statement in your loading routine, checking for the key strings (or using enums if your environment makes that convenient), and populating your new Achievement object with a new Criterion object of the correct subtype, or list thereof.

\$\endgroup\$
2
  • \$\begingroup\$ Thanks. Did you store user progress on certain achievements in a different location? As in, if a player had defeated 3 grunts using fire magic, but then quit the game before reaching 5, did you serialize that progress elsewhere, or right back into the same achievement json file? \$\endgroup\$ Commented Apr 17, 2023 at 4:27
  • 1
    \$\begingroup\$ In my case I didn't have any multi-stage achievements with saved progress. I'd recommend storing it separately though. \$\endgroup\$
    – DMGregory
    Commented Apr 17, 2023 at 4:30

You must log in to answer this question.

Not the answer you're looking for? Browse other questions tagged .