We want to make Argentum Age a game which is easy to mod. A game where players can try to design and make their own cards, and submit them to us, to get involved in development of the game. As such, we are making the engine as flexible as possible, and in this article I’m going to talk about how a card is defined in Argentum Age.

Let’s start by looking at one of Argentum Age’s creature cards, the Acolyte:

Acolyte Card

 

This is a fairly common foot soldier for the Sapienza school of magic. Sapienza is all about knowledge and wisdom, and so drawing cards is something they do. Naturally when you
summon an Acolyte, her studies reap their reward for you as she lets you draw another spell from your library.

Let’s have a look at the code it took to add this card to Argentum Age’s engine. We define cards in JSON, and this is the entry which represents the Acolyte:

{
name: "Acolyte",
set: "core",
type: "creature",
portrait: "acolyte.png",
cost: 3,
school: "@eval SAPIENZA",
rules: "When you cast Acolyte, draw a card.",
creature: {
attack: 3,
life: 3,
move: 1,
tags: ['Human'],
portrait_y_offset: 210,
portrait_scale: 0.6,
}

on_play: “def(class game game, class message.play_card info) ->commands
game.players[game.current_player].draw_cards(game, 1)
“,
}

That’s all there is to it, that is the full definition of the Acolyte card. Now I think most of this is pretty explanatory, but let’s step through it. First we have the basic card info:


name: "Acolyte",
set: "core",
type: "creature",
portrait: "acolyte.png",
cost: 3,
school: "@eval SAPIENZA",
rules: "When you cast Acolyte, draw a card.",

This is all the basic information that any card will have. The name of the card, the set it is in, (so far we have just one, the ‘core’ set). The card type — there are creatures, invocations, and lands — the portrait it uses, how much it costs to cast, the school it’s from and the rules text as shown to the player.

Then, since this card is a creature card, it has a creature section which defines the creature being summoned:


creature: {
attack: 3,
life: 3,
move: 1,
tags: ['Human'],
portrait_y_offset: 210,
portrait_scale: 0.6,
}

First we have the creature’s basic stats — its attack, life, and movement. Most creatures have 1 movement, though attack and life tend to vary a lot. Some creatures will have additional stats here like range or armor, but they are optional and default to 0.

Then we have the creature’s tags. They specify all the subtypes this creature is. In the Acolyte’s case it is a human, and this is what causes Creature&emdash;Human to be displayed on the card. By specifying tags like this it will allow us to implement cards that have effects like Deal 4 damage to all humans by matching against creatures that have the Human tag.

Finally we have portrait_y_offset and portrait_scale. These are needed to tell the engine how to display the creature’s portrait on the token the creature gets after it’s summoned by telling it what the most interesting part of the image is:

Acolyte Token

Now, for the final part of our card definition. When we cast Acolyte, we draw a card. To make this happen, we handle the play event, which is triggered when the card is put into play. We define a formula which tells us exactly what should happen when this card is played:


on_play: "def(class game game, class message.play_card info) ->commands
game.players[game.current_player].draw_cards(game, 1)
",

This defines a function which is called whenever the card is played, the function takes the current game state, as well as information about how the card is being played (for instance which location on the board the creature is being summoned to), and then produces commands which will modify the game state in some way — in this case by drawing cards for the current player.

By using a full-fledged language like this to handle events, we can easily add cards which have all sorts of interesting behavior. The language used is the Frogatto Formula Language (FFL), which we developed for one of our other projects, Frogatto. We have some articles on how FFL works, here and here so I won’t get into the nitty gritty here. However for most things in Argentum Age someone should be able to get by easily enough just copying snippets from existing cards and modifying them to make new cards.

Let’s look at one more card, the Hypothermia card, a nice brutal card from the Entropia school:

Hypothermia Card

Entropia focuses on how all things come to an end, and what better to meet your demise than in the chilly grip of hypothermia?

One interesting thing to note is that Hypothermia has a loyalty cost. This is shown by the single Entropia icon by the cost, meaning it has a loyalty cost of one. This is Argentum Age’s mechanic for encouraging players to specialize in one or two schools. If you haven’t already cast an Entropia spell in the game, you will have to pay an additional cost to cast Hypothermia.

Anyhow, here is what the card’s definition looks like:


{
name: "Hypothermia",
set: "core",
type: "invocation",
school: "@eval ENTROPIA",
portrait: "hypothermia.png",
cost: 2,
loyalty_cost: 1,
is_response: true,
rules: "Target creature gets -4 life this turn.",
flavor_text: "Hundreds were were slain by the sword, but
thousands perished in the chill winds of winter.",

possible_targets: “all_creatures_as_possible_targets”,

on_play: “def(class game game, class message.play_card info) ->commands
game.creature_at_loc_or_die(info.targets[0])
.apply_effect_until_end_of_turn(‘life’, -4)
asserting size(info.targets) = 1”,
}

Most of this is fairly straight forward, and we’ve even seen on_play before. We can see that for this card, on_play looks for the card’s target — info.targets[0] will give us the first location the card is targeted at — and the creature at that location will be given a -4 to its life for the turn.

Casting Hypothermia

Casting Hypothermia

The interesting new part about this card is the possible_targets field. By default, creature cards have exactly one target, the tile that you are summoning the creature onto, but spells can have no targets, one target, or multiple targets depending on the spell.

The targets a spell can have is defined by the possible_targets field. This is probably the most complex field when defining a card, since there are all sorts of rules about when something can or cannot be targeted. For instance, a creature might have the Cover ability which means enemy spells can’t target it.

However, lots of shortcuts are made for common cases, and Hypothermia is a fairly common case. It can target exactly one creature, and it can target any creature (friend or foe, though it’s pretty unlikely you’d willingly cast it on a friend), with the exception of course of things like creatures with the Cover ability.

That is what all_creatures_as_possible_targets does — it’s a pre-defined formula which implements this behavior. Here is how it is actually defined:


all_creatures_as_possible_targets: "def(class game game, int nplayer, [Loc] targets) ->[Loc]|null
if(targets = [], [creature.loc | creature

It defines a function which gives a list of all the locations that the spell may target, this includes an empty list if the spell has no valid targets, or null if the spell needs no targets. Note that it takes a list of targets that have been chosen so far. Because spells like Hypothermia must have exactly one target, we see there is a requirement that targets = [] otherwise we return null, meaning no more targets are needed or allowed.

Now, when a spell it cast in Argentum Age, the game requires that the player selects a list of legal targets, as defined by possible_targets. The UI requires it and the server also checks it. Then the spell is displayed and the player’s opponent gets a chance to respond to it by playing a response spell. After that it’s time for the spell to resolve. But, first, the game checks that after any response the opponent has made, the spell’s targets are still valid, so the spell’s targets are checked again.

Note that spells in Argentum Age are targeted by location. This means that if I had cast Hypothermia on one of your creatures, but then you managed to cast a spell that switched one of my creatures with one of yours, my Hypothermia would now be targeting my own creature. This is a legal target, and the spell would resolve and harm my own creature. If, however, you cast a spell which simply moved your creature to a different tile, leaving the tile I targeted as vacant, then the game would check if the targets are still valid, and find they are not. This means that the spell would fizzle and the on_play formula would not be evaluated at all.

Because of this, in on_play we can be confident that there will be exactly one target for the spell and this target will be a tile with a creature on it. So we do things like game.creature_at_loc_or_die(info.targets[0]) — which will make the game terminate with an error if there is no creature at the location — and asserting size(info.targets) = 1 inside the on_play formula.

Hopefully this is a good overview of the basics of how cards work in Argentum Age and gives you a feel for what adding one might be like. Please check back for more updates on Argentum Age’s progress in the future!