Category Archives: Game Mechanics

Unit testing game logic

I’m currently rewriting Mazebert in Java with the goal to add multiplayer. In order to do this, I need to decouple all game logic code from client code. The game logic is then a separate module and runs a deterministic simulation in its own thread.

While being quite the opposite of what game programming was for me all these years – stuffing everything into the scene graph of the used rendering framework – it brings a lot of opportunities in terms of programming. One of them is the ability to easily unit test all game logic. When porting the nature towers of the game, I’ve written unit tests for almost every tower, and it’s been super fun. I was even able to understand and fix some problems of the old game:

  • Frog poison was doing less than the promised 100% DoT
  • Kills from frog DoT did not drop cards
  • Huli’s aura says all femal towers in range, but did not scale with range changing items
  • Balu’s cuddle did not scale with attack speed modifier

For example, here’s the unit test code for Holgar the Horrible:

class VikingTest extends SimTest {
    RandomPluginTrainer randomPluginTrainer = new RandomPluginTrainer();
    DamageSystemTrainer damageSystemTrainer;

    Wizard wizard;
    Viking viking;
    Creep creep;

    @BeforeEach
    void setUp() {
        simulationListeners = new SimulationListeners();
        randomPlugin = randomPluginTrainer;
        unitGateway = new UnitGateway();
        projectileGateway = new ProjectileGateway();
        damageSystem = damageSystemTrainer = new DamageSystemTrainer(simulationListeners);
        lootSystem = new LootSystemTrainer();

        commandExecutor = new CommandExecutor();
        commandExecutor.init();

        wizard = new Wizard();
        unitGateway.addUnit(wizard);

        viking = new Viking();
        viking.setWizard(wizard);
        unitGateway.addUnit(viking);

        creep = new Creep();
        unitGateway.addUnit(creep);
    }

    @Test
    void attack_canHitSameCreepWithTwoAxesAtOnce() {
        whenVikingAttacks();

        assertThat(creep.getHealth()).isEqualTo(80);
    }

    @Test
    void attack_canHitTwoCreeps() {
        Creep anotherCreep = new Creep();
        unitGateway.addUnit(anotherCreep);

        whenVikingAttacks();

        assertThat(creep.getHealth()).isEqualTo(90);
        assertThat(anotherCreep.getHealth()).isEqualTo(90);
    }

    @Test
    void kill_canDropMead_drop() {
        randomPluginTrainer.givenFloatAbs(0.0f);
        damageSystemTrainer.givenConstantDamage(1000);

        whenVikingAttacks();

        assertThat(wizard.potionStash.get(PotionType.Mead).getAmount()).isEqualTo(1);
    }

    @Test
    void kill_canDropMead_noDrop() {
        randomPluginTrainer.givenFloatAbs(0.9f);
        damageSystemTrainer.givenConstantDamage(1000);

        whenVikingAttacks();

        assertThat(wizard.potionStash.get(PotionType.Mead)).isNull();
    }

    @Test
    void kill_canDropMead_badDropChanceOfCreep_noDrop() {
        randomPluginTrainer.givenFloatAbs(0.0f);
        damageSystemTrainer.givenConstantDamage(1000);
        creep.setDropChance(0);

        whenVikingAttacks();

        assertThat(wizard.potionStash.get(PotionType.Mead)).isNull();
    }

    @Test
    void drinksMead() {
        givenMeadPotionIsDrank();
        givenMeadPotionIsDrank();
        givenMeadPotionIsDrank();

        CustomTowerBonus bonus = new CustomTowerBonus();
        viking.populateCustomTowerBonus(bonus);
        assertThat(bonus.title).isEqualTo("Mead:");
        assertThat(bonus.value).isEqualTo("3");
    }

    @Test
    void drinksNoMead() {
        CustomTowerBonus bonus = new CustomTowerBonus();
        viking.populateCustomTowerBonus(bonus);
        assertThat(bonus.title).isEqualTo("Mead:");
        assertThat(bonus.value).isEqualTo("0");
    }

    @Test
    void drinksMead_hasEffects() {
        givenMeadPotionIsDrank();
        assertThat(viking.getChanceToMiss()).isEqualTo(0.01f);
        givenMeadPotionIsDrank();
        assertThat(viking.getChanceToMiss()).isEqualTo(0.02f);
    }

    private void givenMeadPotionIsDrank() {
        wizard.potionStash.add(PotionType.Mead);
        DrinkPotionCommand command = new DrinkPotionCommand();
        command.potionType = PotionType.Mead;
        command.playerId = wizard.getPlayerId();
        commandExecutor.executeVoid(command);
    }

    private void whenVikingAttacks() {
        viking.simulate(3.0f); // attack
        projectileGateway.simulate(1.0f); // projectile spawning
        projectileGateway.simulate(1.0f); // projectile hitting
    }
}

And here is the production code of the tower:

public strictfp class Viking extends Tower {

    public Viking() {
        setBaseCooldown(3.0f);
        setBaseRange(2.0f);
        setAttackType(AttackType.Fal);
        setStrength(0.89f);
        setDamageSpread(0.16f);
        setGender(Gender.Male);
        setElement(Element.Nature);

        addAbility(new ProjectileDamageAbility(ProjectileViewType.Axe, 6));
        addAbility(new VikingMultishot());
        addAbility(new VikingMead());
    }

    @Override
    protected float getGoldCostFactor() {
        return 1.1f;
    }

    @Override
    public String getName() {
        return "Holgar the Horrible";
    }

    @Override
    public String getDescription() {
        return "Holgar is the terror of the seven seas with a slight alcohol problem.";
    }

    @Override
    public String getAuthor() {
        return "Holger Herbricht";
    }

    @Override
    public Rarity getRarity() {
        return Rarity.Rare;
    }

    @Override
    public int getItemLevel() {
        return 50;
    }

    @Override
    public String getSinceVersion() {
        return "0.3";
    }

    @Override
    public String getModelId() {
        return "viking";
    }

    @Override
    public int getImageOffsetOnCardY() {
        return 8;
    }

    @Override
    public void populateCustomTowerBonus(CustomTowerBonus bonus) {
        bonus.title = "Mead:";
        bonus.value = "" + getConsumedMeadCount();
    }

    private int getConsumedMeadCount() {
        MeadAbility meadPotionAbility = getAbility(MeadAbility.class);
        if (meadPotionAbility == null) {
           return 0;
        } else {
            return meadPotionAbility.getStackCount();
        }
    }
}

With the ability to attack two targets:

public strictfp class VikingMultishot extends AttackAbility {

    public VikingMultishot() {
        super(2, true);
    }

    @Override
    public boolean isVisibleToUser() {
        return true;
    }

    @Override
    public String getTitle() {
        return "Double Throw";
    }

    @Override
    public String getDescription() {
        return "Holgar throws two axes at once. He likes the sound of two cracking skulls.";
    }

    @Override
    public String getIconFile() {
        return "0026_axeattack2_512";
    }
}

And the ability to find mead:

public strictfp class VikingMead extends Ability<Tower> implements OnKillListener {

    private static final float CHANCE = 0.01f;

    private final LootSystem lootSystem = Sim.context().lootSystem;

    @Override
    protected void initialize(Tower unit) {
        super.initialize(unit);
        unit.onKill.add(this);
    }

    @Override
    protected void dispose(Tower unit) {
        unit.onKill.remove(this);
        super.dispose(unit);
    }

    @Override
    public void onKill(Creep target) {
        if (getUnit().isAbilityTriggered(CHANCE * StrictMath.min(1, target.getDropChance()))) {
            Wizard wizard = getUnit().getWizard();
            lootSystem.dropCard(wizard, target, wizard.potionStash, PotionType.Mead);
        }
    }

    @Override
    public boolean isVisibleToUser() {
        return true;
    }

    @Override
    public String getTitle() {
        return "Drink for Victory";
    }

    @Override
    public String getDescription() {
        return "Whenever Holgar kills a creep, there is a " + format.percent(CHANCE) + "% chance he finds a bottle of mead.";
    }

    @Override
    public String getIconFile() {
        return "9005_MeadPotion";
    }
}

Infinite daily quests fixed

During the last days quest creation was unlimited. A little refactoring on server side during Easter holidays caused the problem (I forgot to adjust the database layer to work with the change in business logic).

This moment I deployed a hotifx, so it’s 1 quest per day again.

Thanks Thierry and Hoid for reporting!

Reginn the Dvergr

reginn_option_my_cardsreginn_buy

Quite a few players complained about the RNG of the card forge. And I can feel the pain!

So what’s the problem with the current mechanic? For new players it is actually pretty good. Every foil card they get is probably new, and there is a fair chance to even pull a legendary out. For advanced players however it gets more and more unlikely to get one of the desired cards that are still missing in their collection. I think this is quite a frustrating experience, especially when new content updates are released with new legendaries.

I’m currently working on the option to forge cards directly. I adjusted the “my cards” screen, so that all cards are displayed. Non golden cards are rendered in a ghostly blue, indicating that they are still missing in your collection. For missing cards a buy button is enabled. Pressing that button leads you to the famous dwarf Reginn the Dvergr. He’s doing custom forgery for you, if you are willing to pay his price.

  • Common: 50
  • Uncommon: 100
  • Rare: 150
  • Unique: 200
  • Legendary: 400

Excited to hear your thoughts about this addition! Will you pay Reginn a visit?

How strong is Stonecutter’s Temple?

stonecuttersThis is a question to the pro-gamers out there.

How strong is the new tower Stonecutter’s Temple in your opinion. Do you make use of it?

It has the same damage bonus effect as Shadow, so I tried to keep things low. How is your experience with it? OP or do we need a buff to keep up with darkness?

Looking forward to your opinions on this topic!

Card dust – Notes from the developer

I introduced a new concept to the game: A Note from the Developer.

I noticed that even though I continuously add new features to the game, I don’t really tell the players about it. Of course, I post those features to the blog (which is good and I’ll keep doing that) but it doesn’t reach all the players.

For the upcoming card dust feature I do a little experiment.

There’s gonna be a new hidden quest, that completes as soon as you transmute two unique/legendary cards. If the quest is not completed yet, two A Note from the Developer cards will be added to your item stash. The notes explain the new feature and you can try it by swiping them up, resulting in your first card dust. And a completed hidden quest!

I believe this could be a great way to promote new features to all players.

Looking forward to your opinion on this!

A mini tutorial item added to your stash.

A Note from the Developer. What?

Quest for creating your first card dust.

This happens if you follow the instructions.

Card Dust!

Thanks for all the amazing community feedback about transmuting unique/legendary cards.

Most of you would like to transmute cards you won’t use in the current game, the ones that only consume space in the deck.

On the other hand there was a big concern about the reward being too powerful.

Card dust!

I think I found a very nice solution.

Card Dust, a new type of potion will be introduced. Transmute any two unique or legendary cards (doesn’t matter if they are potions, items or towers) and you will be rewarded with a piece of Card dust.

unique_transmute_1unique_transmute_2

Dust variations

There are three different kinds of Card Dust so far, and I’m up for more suggestions!

critical_dustxp_dustlucky-dust

New loot function

Another tweaking for 1.1: A completely reworked loot function!

The current function has several drawbacks:

  • It scales quite fast: There is a point in the game where you suddenly get flooded with lots of loot.
  • It has a hard cap at 400% item chance. Additional item chance boosters have no effect.

Have a look at the current function (yellow graph). It grows very quickly from 100% (initial item chance) to 400% and caps from there on.

(1)   \begin{equation*}    f(x) = \sqrt{\min(x,4)} \end{equation*}

Now, the new function (blue graph) is quite different. It grows moderate from 100%, but keeps growing indefinitely. Every item chance boost has an impact, however the higher the chance already is, the less impact of new boosts.

(2)   \begin{equation*}    f(x) = \frac{2x}{1+x} \end{equation*}

new-loot-function