Environmental Mechanics in a Fantasy World

I read a great article by @rplacroix and got the idea to implement environmental mechanics similar to the Divinity series of games: oil spills can be set on fire, fire can be put out with water, and poison explodes unexpectedly when exposed to fire. Here I will focus more on the implementation in code than on a faithful copy of the mechanics from the Divinity games. I will show some code snippets with explanations, and at the end there will be a small demo of a prototype game with this system.

Today I am programming in Raku. Raku — is a young language with a long history, a sister language to Perl. I want to demonstrate the strongest points of this language in the context of game prototyping and partially compare them with the original article, the implementation language of which was Python. Throughout the article, I will leave expandable blocks explaining certain features of the Raku language, if you are interested.

Concepts

First, let's conceptually define what moving parts will be present. For the environmental system itself, I found three components:

  • Elements are the essence of the effect. For example, the element of water, when affecting the ground, will simply fill it with a puddle, and when affecting the player, will make his clothes wet. Less obvious, blessing and curse are also defined as elements.

  • Effects are the player's current statuses. For example, if a player gets into water, he gets the “wet” status, and if he gets burned by fire, he gets the “burning” status.

  • Environmental effects are reactions in the outside world that affect an area rather than a single cell. For example, an explosion or the spread of a fire due to spilled oil.

The world of Divinity is divided into cells, which can contain an object, such as a barrel; a creature, such as the player; and something can be spilled on that cell, such as oil; or something can float in the air, such as steam. This is a very convenient system for implementing environmental interactions, because you don’t have to simulate full physics and, for example, calculate how much a puddle of water will spill based on the volume of the spilled water and the surface relief.

It is also worth considering that the game is played in a turn-based mode. The game has the concepts of “action points” and “movement points”, adapted from tabletop role-playing games. When moving and acting, these points are spent, and you need to finish the turn to restore them.

Code

Components of the environment

The elements are presented as follows:

class Element is export {}
class FireElement is Element is export {}
class WaterElement is Element is export {}
class IceElement is Element is export {}
class PoisonElement is Element is export {}
class OilElement is Element is export {}
class ElectricityElement is Element is export {}
class BlessElement is Element is export {}
class CurseElement is Element is export {}

This is the parent class. Element and inherited classes of specific elements. In this case, I don't need inheritance yet, so I can do it through simple numbering, using enum. I made classes so that I could add properties if needed. At this stage of the prototype I don't know yet what the most concise data representation will be, so I use classes because they are easy to extend.

This is how the effects are presented, with the duration value (duration):

class Effect is export {
    has Int $.duration is rw = 1;
}
class WetEffect is Effect is export {}
class BurningEffect is Effect is export {}
class ChilledEffect is Effect is export {}
class WarmEffect is Effect is export {}
class FrozenEffect is Effect is export {}
class MagicArmorEffect is Effect is export {}
class PoisonedEffect is Effect is export {}
Let's analyze the string with the duration property
  1. has — declaration of an object property.

  2. Int – optional typing, only integers are allowed here.

  3. $ — is a scalar value, not an array or a dictionary.

  4. . — create public methods for external access.

  5. duration — name of the property.

  6. is rw — the property can be changed from the outside, not just read.

  7. = 1 — the default value is 1.

  8. ; — you probably already guessed. At the end of expressions, a semicolon is placed, with some exceptions like the closing curly bracket.

Raku is very expressive!

And finally, the environmental effects, so far only the explosion effect:

class EnvironmentEffect is export {}
class ExplosionEnvironmentEffect is EnvironmentEffect is export {}

How field cells interact with the environment

The cells themselves are represented by a class Cell (rendering methods and interaction with clouds are omitted):

class Cell does OnBoard {
    has Surface:D $.surface is rw = EmptySurface.instance;
    has Cloud:D $.cloud is rw = EmptyCloud.instance;
    has Object $.object is rw;

    method apply (Element:D $e, World:D $w) {
        for $!surface.apply: $e {
            when Element { self.apply($_, $w) }
            when StateChange { self.apply-state-change($_) }
            when EnvironmentEffect { $w.apply-environment-effect($_, self) }
        }
    }

    method apply-state-change (StateChange:D $sc) {
        $!surface = $sc.to-surface;
        $!cloud = $sc.to-cloud;
    }
}
What's going on here
  • does OnBoard — this is a mixin, an extension, we mix in additional functionality without creating an inheritance hierarchy.

  • Surface:D — this is optional typing: class name Surface and suffix :D. In Raku it's just a type Surface can take as the class object itself Surfaceand its copy Surface.new. Suffix :D permits only instance of a class.

  • $!surface.apply: $e – this is a reference to the property declared above $.surfacemethod call apply and passing the argument $e. You've probably noticed that almost all variables in Raku require a “sigil”, a symbolic prefix (often a dollar sign $), to contact them.

  • for — a loop that iterates through all the elements of the array returned from $!surface.apply: $e.

  • when Element — compare each element of the array from for for class membership Element.

  • self.apply($_, $w)self.apply calls the method apply on itself (here it is defined in the child class). The same can be written in the form above: self.apply: $_, $w. It's just that here with brackets “fits better”, I'm an artist, that's how I see it.

  • $_ — is a “subject variable”. Every time we have a “current” variable, whether it's looping through an array with for or comparison using whenthis variable is written to $_. In fact, the expression itself when already uses this variable, it compares the theme variable that was assigned in the loop forwith class Element .

It took me two weeks just to get used to Raku's syntax. After that, it's easier.

Each cell can “apply” (apply) on itself an element, and depending on the result we will either get another element and apply it again, or change the surface or cloud of the cell (a spilled puddle will evaporate from the fire and become a cloud of steam), or cause some kind of environment effect (explosion!). Then this cell, firstly, can have some object, like a conventional barrel, or a player or an enemy, and secondly, it is a parent class for cell types, for example, “floor”, “wall”, “door” and so on. Here I chose to separate by classes, since most likely the behavior on these different types of cells will change greatly.

The cell surface is represented by the parent class Surface:

class Surface is export {
    has Numeric $.duration is rw = ∞;

    submethod new { ... }
    method draw { ... }
    method time-out { StateChange.surface(EmptySurface) }
    proto method apply (Element) { * }
    multi method apply (Element $e) {
        die "Calling {self.^name}#apply($e)";
    }
}
A bit of syntax
  • – infinity! ASCII alternative Inf. Raku has some variables and operators beyond ASCII from Unicode.

  • ... — this is usually used to denote a method that should be overridden in a child class or that should not be called. If this method is called, an error will occur.

  • submethod — a private method that is not accessible to child classes. We prohibit the creation of an instance of the parent class.

  • proto method apply (Element) { * } — we define a prototype for future multi-methods.

  • multi method apply (Element $e) – specialization in parent class Element “by default” so that all child elements are necessarily reassigned.

  • die – voluntary death of the program, crashes with the specified message.

A few words about multi-methods: you can define as many methods with the same name as you want, Raku will choose the multi-method with the strictest parameter constraints, including the number and types of parameters.

And then we can define child classes with different types of surfaces, for example, a surface where a fire is burning:

class FireSurface is Surface is export {
    has Numeric $.duration is rw = 3;

    method draw { "f" }

    method time-out { StateChange.cloud(SmokeCloud.new) }

    multi method apply (WaterElement) { StateChange.cloud(SteamCloud.new) }
    multi method apply (IceElement) { StateChange.cloud(SteamCloud.new) }
    multi method apply (PoisonElement) { [StateChange.empty, ExplosionEnvironmentEffect.new] }
    multi method apply (FireElement) {}
}

We define the duration as 3 rounds, after which the fire will burn out and only smoke will remain (method time-out). And at the same time interaction with other elements: water (WaterElement) or ice (IceElement) it will be extinguished and there will be a cloud of steam, and poison (PoisonElement) firstly it will make the surface clean, and secondly it will cause an explosion. For each next type of surface we will simply add another class, the system is very convenient in expansion.

How the player interacts with the environment

Here, when we say “player”, we mean any living or non-living creature, since they all belong to the parent class. Creature (drawing and handling of movement and action points are omitted):

class Creature is Object does EffectsOnCreature does Movable {
    has Int $.health;
    has Int $.move-points;
    has Int $.action-points;
    has Int $!max-health;
    has Int $!max-move-points;
    has Int $!max-action-points;

    submethod TWEAK (:$health, :$move-points, :$action-points) {
        $!max-health = $health;
        $!max-move-points = $move-points;
        $!max-action-points = $action-points;
    }

    proto method damage (Int :$) { * }
    multi method damage (:$fire!) {
        if self.find-effect(MagicArmorEffect) {
            say "Magic armor blocks $fire fire damage!";
            return;
        }

        say "Getting $fire fire damage!";
        $!health -= $fire;
    }
    multi method damage (:$poison!) {
        say "Getting poisoned by $poison points!";
        $!health -= $poison;
    }
    multi method damage (:$blast!) {
        say "Getting $blast points of damage from a blast!";
        $!health -= $blast;
    }
}
A couple of points

There are private properties for the class such as $!max-healthwhich need to be initialized, which is what the method does TWEAKit is called immediately after the class instance is created.

Syntax :$health in the method parameters means a named parameter, we can pass it as health => 10 or :health(10) — both forms are equivalent. And the exclamation mark at the end makes the parameter mandatory: :$fire!.

The creature has reserves of movement and action points, as well as health points. These health points can be used to take damage of different types, while fire damage can be blocked by magic armor.

This class Creature has an extension EffectsOnCreaturewhich determines how the player suffers from already acquired effects, and how they interact with the environment. In order, first the existing effects:

role EffectsOnCreature is export {
    proto method apply-effect (Effect:D) { * }
    multi method apply-effect (BurningEffect:D) { self.damage: :3fire }
    multi method apply-effect (PoisonedEffect:D) { self.damage: :3poison }
    multi method apply-effect (WarmEffect:D) {}
    multi method apply-effect (WetEffect:D) {}
    multi method apply-effect (ChilledEffect:D) {}
    multi method apply-effect (FrozenEffect:D) { self.exhaust-move-points }
    multi method apply-effect (MagicArmorEffect:D) {}

    # ...
}
What is :3fire

self.damage:– this is a method call damageon himself. A :3fire this is a way to pass a named variable, another option, equivalent fire => 10 or :fire(10).

Sometimes I get scared of the variety, but when you get used to it, then as the creator of your code you choose the most suitable option. If it is, for example, an open source project with exactly one contributor (the majority), then it does not make such a big difference if other people have a different sense of beauty.

When the player is on fire (BurningEffect) or poisoned (PoisonedEffect), starting his turn, he will receive three fire and poison damage respectively. If the player is frozen (FrozenEffect), then he loses all his movement points. In all other cases, nothing happens at the end of the turn, but these effects can be combined with other effects! For example, the effect of magic armor (MagicArmorEffect) protects against fire damage.

And now the influence of the cell surface, let's start with the burning cell (FireSurface):

role EffectsOnCreature is export {
    has Effect:D @.effects = [];

    multi method effect-from-surface (FireSurface:D $s) {
        self.add-effect: BurningEffect.new(:3duration);
        self.remove-effect: WetEffect;
        self.damage: :3fire;
        self.comment-on-effect: BurningEffect;
        self.exhaust-move-points: 10;
    }
}
Sigil @

Previously there was always a sigil $where did it go from the variable @.effects? The whole point is that the sigil $ it is not just an access to a variable, but an access to a variable as a scalar value, something “unique”. So, even an array can be accessed as a “unique” value, in which case this array is considered a black box, and we, for example, cannot go through its elements using for. A sigil comes to the rescue @which means that the variable contains an “ordinal” value, where there are several elements following one another.

When a player lands on a burning square, they gain the burning effect for three turns (BurningEffect.new(:3duration)), stops being wet (WetEffect), takes three points of fire damage, curses about something, and loses some movement points.

A more complicated situation is when the player is on a cell with water:

role EffectsOnCreature is export {
    has Effect:D @.effects = [];

    multi method effect-from-surface (WaterSurface:D $s) {
        with self.find-effect(BurningEffect) {
            self.remove-effect: BurningEffect;
            self.add-effect: WarmEffect.new;
            self.comment-on-effect: WarmEffect;
        } orwith self.find-effect(ChilledEffect) {
            self.remove-effect: ChilledEffect;
            self.add-effect: FrozenEffect.new(:2duration);
            self.comment-on-effect: ChilledEffect;
        } else {
            self.add-effect: WetEffect.new(:3duration);
            self.comment-on-effect: WetEffect;
        }
    }
}

If the player is on fire, he stops burning and instead just feels warm (WarmEffect), if the player is cold (ChilledEffect), then in water it freezes completely (FrozenEffect), in all other cases it simply becomes wet.

The effects work in a similar way, when a player is on a steam cell, he gets a bonus to dodge, but as soon as he leaves it, he loses this effect. This is another multi-method, effect-while-on-surfacewhich adds to a separate array all the effects that apply only to the current cell.

Curses and blessings

Let's assume that there is some characteristic – “magic enchantment”, and each element can be either neutral, or blessed, or cursed. At the same time, some interactions with magic enchantment change slightly (the probability increases), and some stop working altogether. If we follow the already beaten path and add a new class for each magic enchantment, for example, WaterSurface, CursedWaterSurface, BlessedWaterSurface (“blessed” – blessed, “cursed” – cursed), then there will inevitably be a lot of repetition between them.

In our mental gymnastics exercises, we can say that previously it was the “type” of the surface or element that changed, because spilled oil behaves very differently from spilled water. However, in the case of magical enchantment, only the “property” changes. All these mental exercises are conventions, but they help to get an idea of ​​how to structure the code.

enum MagicState <Cursed Neutral Blessed>;

role Enchantable {
    has MagicState $.magic-state = Neutral;

    method is-cursed { $!magic-state eqv Cursed }
    method is-blessed { $!magic-state eqv Blessed }
    method is-magical { $.is-cursed or $.is-blessed }
    method curse {
        given $!magic-state {
            when * eqv Cursed {}
            when * eqv Neutral { $!magic-state = Cursed }
            when * eqv Blessed { $!magic-state = Neutral }
        }
    }
    method bless_ {
        given $!magic-state {
            when * eqv Cursed { $!magic-state = Cursed }
            when * eqv Neutral { $!magic-state = Blessed }
            when * eqv Blessed {}
        }
    }
}
Interesting enum
  • Angle brackets < > denote a “string array”, no quotation marks are needed inside them. The result is an enumeration of three string values.

  • $.is-cursed — the same as self.is-cursed.

  • given/when — the same as case/when or switch in other languages.

  • With an asterisk * denotes the current element that is compared to the enumeration.

  • eqv – similar to equality comparison ==but compares under slightly different conditions.

  • bless_ but not blessbecause in Raku bless method already exists each class.

An interesting feature of mixins or extensions (declared using role) is that they can also carry properties in themselves, here magic-statewhich will merge with the main object.

Let's use the enumeration of all possible magical states of the object and, adding a piece of code to the object in Enchantablewe will get all the necessary interface for reading and writing the magical state of the object. This role will be applied to parent classes Element, Surface, Cloudso all more specific types will automatically have the ability to be imbued with magic.

Now let's add some magical enchantment to our fire surface:

class FireSurface is Surface is export {
    has Numeric $.duration is rw = 3;

    method draw { $.is-cursed ?? "F" !!  "f" }

    method time-out { StateChange.cloud(SmokeCloud.new: :magic-state($.magic-state)) }

    multi method apply (Element:D $e where $e ~~ WaterElement | IceElement) {
        if (self & $e).is-blessed {
            StateChange.cloud(SteamCloud.new: :magic-state(Blessed))
        } elsif (self & $e).is-cursed {
            StateChange.cloud(SteamCloud.new: :magic-state(Cursed))
        } elsif all(self, $e).is-magical or none(self, $e).is-magical {
            StateChange.cloud(SteamCloud.new)
        }
    }
    multi method apply (PoisonElement) { [StateChange.empty, ExplosionEnvironmentEffect.new] }
    multi method apply (FireElement) {}
}
Well, now there will be a dressing down
  • $.is-cursed ?? "F" !! "f" – this is the use of the ternary operator, in other languages ​​this is usually condition ? one : two.

  • Element:D $e where $e ~~ WaterElement | IceElement – and this is already interesting, we are on type Element:D variable $e we impose an additional condition (where), that this variable should be (~~) or class WaterElementor class IceElement.

  • where — allows you to perform any code for further type checking, you can even do HTTP requests.

  • ~~ – this is a “smart comparison”, here it understands that it is necessary to find out the belonging to a class.

  • | – and other comrades like &, ^, nonethis is the so-called junctions (crossings?). They allow the same operation to be carried out on several elements at once. | means that at least one comparison must succeed.
    $e ~~ WaterElement | IceElement
    equivalent
    $e ~~ WaterElement or $e ~~ IceElement.

  • (self & $e).is-blessed — we call the method at the same time is-blessed on itself and on the variable $e. After the call is-blessedeach of them will return a boolean value, and if both are successful, then the whole expression will return True. The expression is equivalent self.is-blessed and $e.is-blessed.

  • all/none — the same intersection constructors, in the context of the condition, work like this: either all conditions must be met, or none must be met.

For some, Raku is a blessing, for others, it's a curse. But this is the only way to code for blessings and curses, respectively.

Fire when burning out (time-out) will transfer its charm to the smoke, the smoke can be blessed or cursed. Fire can still be extinguished, but now it is much more difficult to do: magical fire requires magical water. Moreover, if both are blessed, then the steam is also blessed, the same with the curse, and if they are magical with different polarities, then they balance each other and turn into an ordinary mundane cloud of steam.

If specific effects occur when interacting with cursed substances, this can easily be added, for example for cursed flame:

role EffectsOnCreature is export {
    has Effect:D @.effects = [];

    multi method effect-from-surface (FireSurface:D $s) {
        # ...
    }
    multi method effect-from-surface (FireSurface:D $s where $s.is-cursed) {
        self.add-effect: CursedEffect.new(:3duration);
        nextsame;
    }
Multi-choice multi-methods

Raku tries to think smartly and choose the most narrowly designated type, here with the damned fire the lower multi-method works. At the same time there are mechanisms manage this choice, in this case nextsame means: choose the next suitable candidate and leave the current method. Since our damn fire is still fire, after the lower multi-method the upper one will work.

If the flame is cursed (where $s.is-cursed), then we first apply the curse effect (CursedEffect), and then we do the same as with a normal flame (nextsame).

How it all works together

In general, there is quite a lot of code for gluing, it is still a game, so I can only send you to the repository with the working code. You will only need to install Raku and a couple of libraries for it.

https://github.com/greenfork/denv.raku

Comparison with Python implementation

There is a great article by @rplacroix that implements a similar system in Python. I would like to point out a few interesting points (you don't need to read the original article to see this):

  • Instead of “escape from hardcoding”, where the realization of cell surfaces is represented as a set of properties “base substance”, “aggregate state” and “magic state” (for example, ice is water in a solid state), I run To hardcoding and declaring one class for each “base substance”/”state of matter” combination. I like hardcoding here because it doesn't create “impossible states” (once, two, three), for example, if “liquid” fire can still be attributed to lava, then with “solid” fire the game of interpretations begins.

  • The magical state in my version is applicable to all possible elements, while it does not differ strikingly different behavior, so here we are still running away from hardcoding.

  • Having multi-methods in a language helps a lot to avoid long methods with infinite if/else branches. By adding new behavior, we write new code without touching the working one.

  • Great article uses dataclass SurfaceSolution and in a separate file special classes are taken out like CursedVaporFire which in tandem help determine the special properties of some combinations of the base set (substance, aggregate, magical states). Although Raku is quite capable of performing such miracles, we were able to do without them, using where to create more specific subtypes for multi-methods.

Demonstration

Link to videoif it does not reproduce.

Conclusion

I tried to show all possible environmental mechanics that I could come up with, trying not to fall below the sophistication of details that the Divinity series games demonstrate. Raku was chosen as the implementation language for the prototype, as it is very flexible (few hacks) and expressive (few lines of code). For me, Raku is very convenient, but it is difficult to recommend it for game development yet, because there are no successful use cases and no ecosystem for games.

The hardest part of writing the game was deciding on the core concepts – what different types of objects exist and how they would interact. There was another interesting moment where I tried to implement the mechanics of blessings and curses in the following way: a) take the name of the current class; b) add/remove the string “Blessed”/”Cursed” to the beginning of it; c) pull out all the attributes of the current object with values; and d) create a new object of the modified class; – but I thought that would be too “cursed” an implementation, and abandoned it in favor of an enumeration enum. Also, until the very last moment I thought that the “elements” (like the water element) would not have properties, they could be made simply by listing in the real game, and then it turned out that they have the property “magic enchantment” – at such moments I thank fate that I write in the flexible Raku language, and that I did not close the paths for expansion at the very beginning.

I hope you enjoyed it and welcome your questions and thoughts.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *