Last Secutor is a 2D turn based RPG with ARPG mechanics such as an original skill tree system, expansive loot, and complex character progression; inspired by games like Path of Exile and Dragon Age: Origins. The core gameplay loop is similar to the original Diablo:

  • Fight in a designated "dungeon" area
  • Gain xp and resources
  • Return to town
  • Assign available skill points and spend resources
  • Repeat

Footage of the playable tech demo. Note: skill costs and cooldowns are disabled by default, only some skills have sound effects.

An Unfinished Game

I worked on this game for 8 years — first 5 years while in university, and 3 years full time. Unfortunately the project was ultimately too ambitious and it was not financially viable to finish the game. I had only been programming for about a year (Python/Java/C#) when I started developing Last Secutor and this was the first project I made without following tutorials. However, this project became an invaluable learning tool I used to teach myself game development. I learned to be autodidactic by always attempting to code things as low-level as possible, rather than using plugins or online solutions. I will usually compare what I come up with existing solutions afterwards to find advantages and disadvantages of different implementations. This learning process allowed me to build arbitrary game mechanics without outside help (other than documentation).

Oldest footage of this project I could find, from October 25 2015.

I experimented a lot over the years and learned to code common game systems from scratch, many of the implementations in Last Secutor have been completely rewritten multiple times. This experience allowed me to efficiently develop future projects by avoiding implementation pitfalls, improving object-oriented design, and adapting to the unique design and technical requirements of each project.

Several of my projects (including my two games released on Steam) directly benefitted from the experience gained from this project; particularly in the implementation of user saves (serialization), shaders (e.g. procedural noise effects and general HLSL experience), aspect ratio/resolution agnostic UI, and frameworks for arbitrary tooltips.

  • 8 years of RPG game development experience (5 years part time, 3 years full time)
    • Although this game is unfinished, systems used in my released games are based off of implementations developed in this project
    • See 4. Implementation for some code examples of game features that have been implemented
  • Created in the Unity engine (Built-in Render Pipeline). All game features, shaders and systems coded (C#/HLSL) and designed by myself with no additional plugins or frameworks with two exceptions:
    • Anima2D (now a base feature in Unity) for 2D animation rigging
    • xNode: a low-level framework to render interactable graphs in the Unity editor (adapted to easily create skill, dialogue, and quest trees within the Unity GUI)
  • Experience animating and rigging multi-part sprite characters/ragdolls
  • Game systems implementation designed for rapid high-level development (See video for full details)
    • Allows developers with no programming experience to easily create and tweak game content through the Unity editor
    • Core RPG elements are implemented with Unity scriptable objects (object oriented design) for seamless integration between modular game systems
  • Experimentation with various custom HLSL shaders to create interesting visual effects without the need of other artists
  • 2D Turn based combat that encourages methodical gameplay
    • Tile movement in combat for position management with a variety of skills that can push, pull, and trap characters
    • A reaction system that allows players to queue actions that can trigger during an opponents turn if the reaction skill's conditions are met
    • Implemented traditional RPG mechanics such as damage resistance, armor, dodge, status effects, mana/stamina costs, summons, stuns, healing and back hits
  • Original "build your own" modular skill tree mechanic
  • Additional RPG systems coded from scratch with C# including but not limited to:
    • Robust skill implementation allowing for skill attributes (e.g. damage ranges, crit chance, projectile style, buff/debuff application, etc.) to be tweaked and new archetypal skills to be created without the need to add or alter code
    • Skill, dialogue and quest tree framework/toolset that allows trees (graphs) to be built within the Unity editor (video demo)
    • Character stat system, that interacts with equipped items, buffs/debuffs, and skill trees during gameplay
    • 2D turn based (discrete tile movement) controls in combat and point and click continuous movement controls in town
    • Grid based inventory/stash supporting items of any size (behavior identical to Path of Exile), all serialized for player saves (video demo)
    • Equipment slots that alter different parts of character sprites
    • System to manage multi-layered character effects (i.e. freezing, fire, bloody, etc.), to render an arbitrary amount of custom shaders effects on the same object
    • AI framework built with Unity coroutines, to create a variety of enemy behaviors in the game's 2D turn based combat system (example AI with different combat styles is shown in the playable tech demo)
    • Custom RPG UI featuring draggable skill tree windows, character sheets, a game glossary with searchable key words, a quest journal, and skill bars that support any aspect ratio and resolutions
    • Robust tooltip system (for items, skills, skill trees, etc.) with item compare feature (See 5.2 Tooltip Framework in PHYSARUM for implementation details, it is mostly the same.)
    • A detailed "Battle Log" to keep track of all gameplay interactions during combat in real time (video demo)
    • Randomizable items with, tiered affix groups for rewards and enemy equipment

NOTE: I did not include every detail for this multi-year project. If you have any questions, feel free to contact me! Be sure to check out the two commentated videos for a detailed technical overview on most of the game features.

The majority of development time was spent building core systems and frameworks (see 4. Implementation), so most of the visual design, game balance and content (e.g. skills, equipment, skill trees) is work in progress. However, the following are examples of features and content that are fully designed and implemented.

The core mechanic of character progression is the unique skill tree system. Last Secutor’s skill tree is comprised of 4 swappable subtrees. Subtrees have outer “transition nodes” that allows players to connect to an adjacent subtree. Like other RPGs, players will gain a skill point when leveling to assign to their skill tree. Assigned nodes must have a path to at least one of the subtree’s root node.

Demo with commentary of skill tree system.

The intent of this skill tree design is to encourage players to experiment with subtrees that are compatible with their builds; while also trying to find the optimal path (least amount of skill points) to the desired skill tree nodes. Nodes are generally more powerful/desirable the farther they are from the subtree root nodes. This rewards players for interacting with the transition nodes mechanic to efficiently reach more powerful nodes in adjacent trees.

During a combat turn, Players and NPCs will take turns performing actions until they run out of resources. Skills costs can use any consumable resource, including health, armor, stamina and adrenaline. Adrenaline is a resource that is gained only if dealing and taking damage. There is also a stun bar for each character. Certain skills will deal “stun” damage, adding to this bar. Once the bar is filled, the character is forced to skip a turn and the stun bar is reset. The combat stage is divided into discrete tiles (internally called “cells” or “grid cells”). Character positioning and skill ranges are all quantized into these tiles.

Damage

Like other RPGs, Last Secutor provides various ways to attack and defend with character stats. The damage formula involves dodge chance, critical hit chance and accuracy. See below for a visualization of how it works. [Note: all internal nodes represents a percent chance]

Damage Formula Figure

Once damage is taken, damage mitigation is then calculated from character stats. Each damage type (e.g. ice, fire, physical, etc.) has a corresponding percent mitigation and flat mitigation stat. Skills have “tags” system similar to Path of Exile. Any damage mitigation stat that matches these tags will be used to reduce the damage taken. In the implementation, tags and damage types are all the same stat objects (see this video for full implementation details), this means every tag has corresponding damage mitigation stats in this game (e.g. “Projectile”, “AOE”, “Buff”, etc.).

Tooltip Example Skill tooltip: tags highlighted in red
(not highlighted in game).

After damage mitigation is calculated, it is finally time to apply the damage. In this game, armor and blocking acts as an additional layer of health. Armor simply acts as health that can’t be healed (certain skills can add armor). Block is a little different, block success is calculated based on a character’s block chance stat and damage is taken from the block bar as expected, however a character’s block bar fully replenishes at the start of their turn. This creates a powerful block mechanic but character must invest in both block chance and block bar for it to be effective. Damage is first taken from block, then armor, then health (unless stated otherwise).

Tooltip Example HUD display for character hit points. Health is red, block is yellow, and armor is represented by a grey overlay on top of health. Aesthetics are not polished, but this is the intended UI design.

Reactions

Last Secutor’s turn based combat features “reactions” — a special kind of skill that queues up actions that will trigger if certain conditions are met. This creates a way for players to try and gain an advantage during the enemy turn, at he cost of turn resources. NPCs combatant will use these skills as well. Players will be able to see how many queued up reactions the AI has, but will not be able to see what they are. See the AI vs AI video below for a demonstration.

Skills

Although not completely finished, many skills were designed to encourage combinations and deliberate positioning. All hit detection is done in real time with accurate colliders. This effects how certain skills behave depending on range, particularly with projectile skills. Some skills also rotate between different versions when used (internally called “multi skills”), encouraging resource management and future planning. See video below for examples.

Notable Timestamps:

0:57 and 5:28 — skill combos
2:57 and 6:50 — projectile hit detection
5:01 — multi skill example

Demonstration of most skills in the game.

Battle Log

The “battle log” is a feature that records all relevant combat events in an resizable window (press B in the demo to toggle). It is designed mostly for player curiosity, akin to combat logs in CRPGs, but it is also helpful when debugging.

Battle log demo.

AI Combat

All turn based combat in this game is against NPCs with various AI combat styles. The UI gives information on the limitations of AI actions. AI’s don’t use skill resources like players, instead, they have a set amount of skills and movement (in grid cell units) every turn. This design decision was made so it is more convenient for the player to gauge the number of moves the AI can do and react accordingly. Players can also press P in combat to view revealed NPC skills and stats.

AI UI Example AI actions displayed in UI.

There are two rows of icons (chess image placeholder) next to a number. The icons represent number of skills the AI will use and the number is the amount of movement. The top row shows the current turn’s values and the bottom row shows values in the next AI turn. This system also gives a convenient way to control AI difficulty. Each AI preset contains a max value for skill count and movement, these values are randomized from 1 to the max value each turn for gameplay variety.

Note: some AIs in the tech demo, including the ones in the video below ignores this system due to unfinished development. See 4.4 AI for full technical details.

AI vs AI combat demo (available in playable tech demo). Made for debugging only, “AI intention” UI only works for right side.

3.3 Story Plans

The game had extensive plans for a story lead by a mysterious interdimensional cult: The Unseen Adventists; a cult comprised of powerful members known to invade and destroy every world they visit. The cultists’ motivations remain unknown throughout the game, however they claim to have seen something that allowed them to understand the nature of the universe.

The player will visit the worlds, explore the town/fighting arena with individual questlines, and defeat the invading cult member to progress the story. The story explores themes of nihilism, morality, blind faith, and cosmological philosophy. Many of these concepts are inspired by lore from Warhammer 40k.

The cult represents a sci-fi interpretation of The Great Filter. Each world features a fatal societal flaw (e.g. overreliance of tradition, corrupt power segregation, etc.), introduced to the player by the invading cult members. The story eventually leads to the discovery of the cult leader — an ancient god-like being that exists between dimensions. Ultimately, the ancient god will show that the player exists in one of an infinite set of parallel universes (in reference to other players), and that the cult’s duty is to maintain the careful balance of energies at a cosmological scale.

Early in development, I decided to implement game systems in such a way that people with no programming experience could make content through the Unity GUI. This allows anyone to easily create new skill trees, skills, and enemies. This was originally done as a programming challenge, but it was also motivated by the prospect of hiring/inviting others to work on the project. These implementations also allowed myself to quickly prototype and tweak existing game content. All this was done with Unity scriptable objects, which are just object instances that can be conveniently edited in the Unity GUI. See the video below for a detailed explanation of this development feature.

In-depth overview of most of the RPG system implementations, with code examples and commentary.

As a solo programmer with no experience hand drawing 2D illustrations, obtaining art assets is often a road block in game development. At first, I used shaders found online to easily add visual flair, but this quickly became a hinderance as I could not make specific effects to my liking.

I soon decided (~2015) the best way to move forward was to learn how to write low-level HLSL shaders myself to create whatever effect I needed. Although daunting at first, this was one of the most valuable skills I learned from this project. This not only allowed me to create countless custom effects in my future projects, but also allowed me to work on my projects without the need to hire other artists. See video below for a summary of my early shader experiments as well as some more complex effects developed in this project.

Quick run through of my HLSL shaders, with code examples and commentary.

I focused on learning to writing shaders in HLSL as opposed to using popular visual programming tools (i.e. Shader Graph) because I wanted to learn a skill that would be easily transferrable to other engines/frameworks (including other shader graphs). This experience inspired me to pursue technical art further, by researching graphics rendering techniques and experimenting with my own implementations (e.g. my bloom attenuation project ).

The Character Material Stack

Every status effect in the game has an optional accompanying shader effect. Each time a status effect applies a new shader effect, a material instance is created for each body part (characters are comprised of multiple sprite objects for animation/ragdolls) and stored into a dictionary for the character. This is to ensure we don’t create a new instance every time we’re changing an existing effect (a Unity quirk if material is not cached), and only load the materials that are being used in the current battle. Particle effects specified by the status effect scriptable object are implemented similarly, the particles are randomly spawned on the sprite vertices.

Demonstration of multiple shader effects rendered on the same sprite.

I call this system the “character material stack”, however it is implemented as a queue, scheduled by a coroutine. This is required because the effects are quickly interpolated by a universal shader variable to ease in the effect. The queue ensures that the last interpolation of the same effect has completed, this is done only as a aesthetic choice. The status effect shaders are all alpha blended, allowing for an arbitrary amount of character effects to be visible at once. This combined with the flexible development of status effects creates a scalable system for any character effects added in the future.

The code for this system is straight forward and uninteresting, but I wanted to show the use of material property blocks. This function eases the visibility of the given shader effect (given by index) on the given renderer (character sprite part).

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
private IEnumerator ChangeFloatPropertyValue(Renderer renderer, int materialIndex, float newValue, bool isInstant = false) {
    // Get the material in materialIndex's current effect value
    MaterialPropertyBlock propBlock = new MaterialPropertyBlock();
    renderer.GetPropertyBlock(propBlock, materialIndex);
    float currentValue = propBlock.GetFloat(PROP_MAG_VAR);

    // Alter material block
    renderer.GetPropertyBlock(propBlock);
    if(!isInstant) {
        float timer = 0f;
        float duration = 0.3f;
        while(timer < duration) {
            propBlock.SetFloat(PROP_MAG_VAR, lerp(currentValue, newValue, timer / duration));
            renderer.SetPropertyBlock(propBlock, materialIndex);
            timer += Time.deltaTime;
            yield return null;
        }
    }
    
    propBlock.SetFloat(PROP_MAG_VAR, newValue);
    renderer.SetPropertyBlock(propBlock, materialIndex);
}

The drawback of having multiple materials is that there will be multiple draw calls. I decided to use Unity material property blocks, which batches all of the same materials in one call while changing a shader property. This was ultimately unnecessary premature optimization since there are very few characters using the same material in the 2D combat scenes (lesson learned once again). It’s possible that using a monolithic shader with shader keywords to conditionally calculate needed effects would be more time efficient. However, it would be significantly less convenient to add new status effects; which is a much more important feature.

The grid based inventory was one of the more tricky game systems I implemented. As a challenge, I wanted to replicate Path of Exile’s inventory UI functionality exactly.

Side by side Comparison with Path of Exile’s Inventory System

After an initial attempt with assumptions of how the UI worked, I found that my UI felt different and less comfortable than Path of Exile’s. This lead to a realization I never noticed while playing the game — which is that the grid highlighting (when hovering over grid space with an item on cursor) does not update when the cursor crosses a border of a grid square, but rather, when it crosses a midpoint of a grid square. To my surprise, this non-trivial difference changes the look and feel of the inventory system completely.

Find Half Square Index From Mouse Pointer Location
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
private void UpdateHighlightData() {
    if (isPointerInContainer && inventory.cursorItem) {
        Vector2 pointerLocalPos;
        RectTransformUtility.ScreenPointToLocalPointInRectangle(
            (RectTransform)transform,
            Input.mousePosition,
            null,
            out pointerLocalPos
        );

        // Index of mouse position in grid of all half squares in inventory
        int[] halfSquareIndex = new int[2];

        // Check if mouse is at the first or second half of container
        if (pointerLocalPos[1] < 0) {
            halfSquareIndex[0] = 2 * position[0] + 1;
        }
        if (pointerLocalPos[1] >= 0) {
            halfSquareIndex[0] = 2 * position[0];
        }

        if (pointerLocalPos[0] > 0) {
            halfSquareIndex[1] = 2 * position[1] + 1;

        }
        if (pointerLocalPos[0] <= 0) {
            halfSquareIndex[1] = 2 * position[1];
        }

        inventory.cursorItem.ResetHighlighted();
        inventory.ResetHighlighted();

        HighlightCalc(halfSquareIndex);
    }
}

Note: position[] is an array with two elements representing the grid position in the inventory (this function is a member of the grid element class). This function calculates halfSquareIndex[] — a two element array representing the index on the inventory grid with 2x the resolution (i.e. each grid square is split into 4 quadrants)

Development Note:
This code was written in 2018, the implementation is not very optimized and readability could be improved. I remember this algorithm being very difficult to figure out at the time, so I decided to share the original code here for posterity.

For example, UpdateHighlightData(...) creates a new array every frame, causing unnecessary strain on the GC (theoretically). Unity’s Vector2 type is a much better fit since it is a value type and it improves readability significantly. Regardless, I’m satisfied with this solution since I know it works and optimizing this early in development with no performance issues would almost definitely be a mistake.

Finding Which Inventory Squares to Highlight
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
private void HighlightCalc(int[] halfSquareIndex) {
    // Change inventory size to 0-index
    int[] inventorySize = new int[] { storage.inventorySize[0] - 1, storage.inventorySize[1] - 1 };

    /// Vertical Calc
    if (itemSize[0] == 1) {
        y = position[0];
    }
    else {
        bool inTopVerticalBorder = halfSquareIndex[0] <= itemSize[0];
        bool inBottomVerticalBorder = halfSquareIndex[0] >= (2 * inventorySize[0] + 1) - itemSize[0];
        // if we are IN the vertical borders
        if (inTopVerticalBorder || inBottomVerticalBorder) {
            if(inTopVerticalBorder) {
                y = 0;
            }
            if (inBottomVerticalBorder) {
                y = inventorySize[0] - itemSize[0] + 1;
            }
        }
        // OUT of vertical border
        else {
            // Vertical index we want moves every 2 half squares: 
            // find nearest multiple of 2 to the half square index (not including top border) to figure out final index
            y = FindNearestMultipleOfTwo(halfSquareIndex[0] - itemSize[0]) / 2;
        }
    }

    /// Horizontal Calc
    if (itemSize[1] == 1) {
        x = position[1];
    }
    else {
        bool inLeftHorizontalBorder = halfSquareIndex[1] <= itemSize[1];
        bool inRightHorizontalBorder = halfSquareIndex[1] >= (2 * inventorySize[1] + 1) - itemSize[1];
        // if we are IN the horizontal borders
        if (inLeftHorizontalBorder || inRightHorizontalBorder) {
            if (inLeftHorizontalBorder) {
                x = 0;
            }
            if (inRightHorizontalBorder) {
                x = inventorySize[1] - itemSize[1] + 1;
            }
        }
        // OUT of vertical border
        else {
            // Horizontal index we want moves every 2 half squares: 
            // find nearest multiple of 2 to the half square index (not including left border) to figure out final index
            x = FindNearestMultipleOfTwo(halfSquareIndex[1] - itemSize[1]) / 2;
        }
    }

    // Highlight item size with upper left corner (x,y) in inventory class
    inventory.Highlight(x, y, storage);
}

Note: indices are rounded to match Path of Exile’s inventory UI behavior

AI Framework

A comprehensive AI framework was developed to allow combat NPCs to perform any action players can, while abiding to the rules of the 2D combat system. This can be challenging, since animations must be interrupted properly and mechanics like knockback can happen during any given action. Spin locks within coroutines are often used to solve these issues, however a proper state machine would probably be more robust and reliable.

Below is the code for moving within a given range, this is usually called before using a chosen skill to hit something. Some movement skills can ignore obstacles (e.g. teleport and jump), these skills have a b_canMoveThroughEntities member that will adjust this algorithm accordingly. MoveInRange(...) is usually used to move within a queued skill’s range before triggering the skill. For other movement behavior such as moving behind the player, MoveTrigger(...) can be called directly within the individual AI algorithms.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
protected IEnumerator MoveInRange(int cellRange, Skill moveSkill, bool stayWithinRange = true, bool moveIntoEntities = false) {
    // Wait for previous AI action
    while (AIPause) {
        yield return null;
    }

    /// Sanity Checks
    // if cell range ends up being 0, default it to 1 so we don't have to worry about going behind the target
    if(cellRange == 0) {
        cellRange = 1;
    }

    if(!targetController) {
        if(defaultTarget) {
            targetController = defaultTarget;
        }
        else {
            Debug.Log("AI Movement: AI tried to move in range without target.");
            yield break;
        }
    }

    if(!(moveSkill is Movement)) {
        Debug.LogError("AI Movement: MOVEMENT skill must be used for \"MoveInRange()\".");
        yield break;
    }
    ///

    // cell index we are trying to move to
    int goalIndex = targetCellIndex;

    // Find closest entity in the way and make it the new target if this AI can not move through entities
    if (!(moveSkill as Movement).b_canMoveThroughEntities && !moveIntoEntities) {
        // scan for intermediate objects
        List<UserController> scannedObjects = CellObjectScan(CellIndex, targetCellIndex, targetTeamID, out UserController closest);
        // if there are more than one, select the one closest to this AI
        if (closest) {
            // target is now the intermediate object
            targetController = closest;
            // update goal index
            goalIndex = targetCellIndex;
        }
    }

    int direction = 1;
    if (goalIndex > cellIndex) {
        direction = -1;
    }

    if (stayWithinRange) {
        // if already within range,
        // do nothing
        if (CheckInRange(cellIndex, goalIndex, cellRange)) {
            //Debug.Log($"AI Movement: Already in Range ({cellRange})");
            yield break;
        }
    }

    // clamp cell range within stage
    int clampedIndex = GridSystem.ClampGridIndex(goalIndex + (cellRange * direction));

    // Check if clamped cell range is vacant, keep trying cells within range
    // do not check for obstacles if this AI can move into entities
    int i = clampedIndex;
    if(!moveIntoEntities) {
        Cell currentTargetCell = GridSystem.manager.GetCellFromIndex(i);
        while (currentTargetCell.cellObjectUserControllers.Count > 0 && !currentTargetCell.CellTraversableCheck(userController.cellObjectType)) {
            // no possible position, give up
            if (GridSystem.manager.GetCellFromIndex(i).cellObjectUserControllers.Contains(targetController) || i == cellIndex) {
                Debug.Log("AI Movement: " + userController.gameObject.name + ": No valid cell found for movement.");
                yield break;
            }

            if (goalIndex > i) {
                i--;
            }
            else {
                i++;
            }

            // out of grid, give up
            if (!GridSystem.IndexIsInGrid(i)) {
                Debug.Log("AI Movement: " + userController.gameObject.name + ": No valid cell found for movement.");
                yield break;
            }
        }
    }

    cellRange = Mathf.Abs(i - goalIndex) * (int)Mathf.Sign(cellRange);

    // Do move to closest vacant cell within range without range check
    yield return MoveTrigger(cellRange, moveSkill);
}

Note: targetController is the entity AI is currently trying to attack, targetCellIndex is the cell the AI is currently trying to move to

A lot of (mostly uninteresting) logistical helper functions were written to make sure AI actions abide to all the game’s rules and mechanics. Below are some examples:

Function to finds all character entities in a cell range (used in line 35 above):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
// Checks for all UserController GameObjects in cells between two cell positions on the stage (exclusive of start and end positions)
// that have the same teamID as "teamIDLookUp", if "teamIDLookUp" is -1, scan all UserControllers
// "closest" parameter: gameobject to cellPosition, null if none
// returns: list of all found GameObjects
protected List<UserController> CellObjectScan(int originIndex, int targetIndex, int teamIDLookUp, out UserController closest, int teamIDIgnore = -1, bool bIncludeOriginCell = false) {
    closest = null;
    List<UserController> ret = new List<UserController>();

    // find start and end index for the for loop
    int startIndex = GridSystem.ClampGridIndex(Mathf.Min(originIndex, targetIndex));
    int endIndex = GridSystem.ClampGridIndex(Mathf.Max(originIndex, targetIndex));

    // start in front of lower index for exclusive bound check
    for (int i = startIndex; i <= endIndex; i++) {
        // Check whether to include origin cell or not
        if (!bIncludeOriginCell && i == originIndex) {
            continue;
        }

        // if there is a cell object in these cells, add it to the return list
        Cell checkCell = GridSystem.manager.GetCellFromIndex(i);
        if (checkCell.cellObjectUserControllers.Count > 0) {
            foreach(UserController targetController in checkCell.cellObjectUserControllers) {
                // ignore self and unhittable users
                if (!targetController.isIgnoreHits && targetController != userController) {
                    // add found object if teamID requirements are met
                    if ((teamIDLookUp == -1 || targetController.teamID == teamIDLookUp) && (teamIDIgnore == -1 || teamIDIgnore != targetController.teamID)) {
                        ret.Add(targetController);
                    }
                }
            }

        }
    }

    // Get closest entity based on scan direction
    if(ret.Count > 0) {
        closest = (originIndex < targetIndex) ? ret[0] : ret[ret.Count - 1];
    }

    return ret;
}
Helper function to find closest character entity, with an alliance (team) filter:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// if "teamIDLookUp" is -1, scan all characters
protected UserController FindClosestEntity(int originIndex, int range, int teamIDLookUp, int teamIDIgnore = -1, bool bIncludeOriginCell = false) {
    // Check to the left and right of origin within range
    CellObjectScan(originIndex, originIndex - range, teamIDLookUp, out UserController closest_2, teamIDIgnore, bIncludeOriginCell);
    CellObjectScan(originIndex, originIndex + range, teamIDLookUp, out UserController closest_1, teamIDIgnore, bIncludeOriginCell);

    UserController ret = null;

    if(closest_1 && closest_2) {
        ret = Mathf.Abs(closest_1.CellIndex - CellIndex) < Mathf.Abs(closest_2.CellIndex - CellIndex) ? closest_1 : closest_2;
    }
    else {
        if(!closest_1 ^ !closest_2) {
            ret = closest_1 == null ? closest_2 : closest_1;
        }
    }

    return ret;
}

AI Behavior Scripting

Technical Note: I use the term “script” loosely here. Although the following code is compiled rather than interpreted, the term “scripting” here just implies “high level code using my AI framework”. Unity also refers to all MonoBehaviours as scripts.

Using this framework, individual AI behavior can be scripted conveniently by inheriting the AI base class (all code samples above are from this AI class). These AI scripts generally just consists of a series of game state condition checks (e.g. character health values) to pick the next skill to perform. This method is inspired by the Dragon Age: Origin “tactics” mechanic, because of the believable AI behavior that was possible using that simplistic system.

Finite State Machines (FSM)

I opted to not use a FSM solution at the time (~2019) because while researching and experimenting, I decided FSMs may be overkill for the limitations of my game’s combat. In practice, I also preferred my lower level approach since it was easier to iterate new ideas quickly and see what was going on at a glance of the code. Admittedly, I don’t have too much experience with FSMs, but it seemed harder to see what’s going on without seeing a visual graph of all the AI states — that could possibly span multiple classes. I didn’t end up implementing a full FSM workflow, but my scriptable object based graph visualizer (see timestamp 17:22) could have alleviated some of these concerns. This is something I intend to revisit in future projects because the high level abstraction can definitely build complex behaviors much more conveniently than my implementation. I would wager that the aforementioned Dragon Age: Origins “tactics” system was implemented by integrating it into a larger FSM AI framework.

New and Legacy AI System

Unfortunately, showing the working AI behavior scripts will be a little messy since it was still under development. The current AI action system was a relatively late addition to this game’s design so I had to convert some older working code. The old system used character resources (stamina, mana, etc.) just as the player would. I eventually decided that this made it too hard for the player to predict and react to the AI behavior, so I opted for the current more streamlined version. This conversion was never completely finished, but both workflows work in the latest build (the tech demo uses both types of AIs). For completeness I will show an example of both.

Legacy AI that summons wall obstacles in (featured in AI vs AI video)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
protected override IEnumerator StartAICoroutine() {
    targetController = defaultTarget;

    // if I am BELOW half health
    if (!CurrentLifePercent_GreaterThan(50)) {
        // 100 - current precent of life chance to do self preservation (e.g. 30% life => 70% chance to heal)
        if (RNG.PercentChanceRoll(100 - GetCurrentStatPercent(ResourceType.Life))) {
            yield return RetreatAndHeal();
        }
        else {
            if (RNG.PercentChanceRoll(50)) {
                yield return LightningCombo();
            }
            else {
                yield return KeepDistant();
            }
        }
    }
    // if I am ABOVE half health
    else {
        // if target's health is BELOW 50%
        if (currentTargetHealth < currentTargetMaxHealth * 0.5f) {

            if (RNG.PercentChanceRoll(50)) {
                yield return RandomCombo();
            }
            else {
                yield return LightningCombo();
            }

        }
        // target health ABOVE 50%
        else {
            yield return RandomCoroutine(RandomCombo(), KeepDistant(), DebuffTarget());
        }
    }

    yield return EndTurn();
}

private IEnumerator RetreatAndHeal() {

    // if there aren't already obstacles between target and AI, create icewall
    if(!ObstacleCheck()) {
        // summon icewall between me and target
        yield return TriggerSkill(summonIcewall, -1, true);
    }

    // make some space from target
    yield return MoveInRange(3, defaultWalk, false);

    // heal
    yield return TriggerSkill(rest);

}

public IEnumerator KeepDistant() {
    // Move to side of target that has more room
    if (CheckEmptySpaceFromTarget() > 0) {
        yield return MoveInRange(3, phase, false);
    }
    else {
        yield return MoveInRange(-3, phase, false);
    }

    // if there is an obstacle, set up traps
    if(ObstacleCheck()) {
        yield return TriggerSkill(fireBlast);
        yield return TriggerSkill(fireBlast);
        yield return TriggerSkill(RandomSkill(dodgeBack, elementalFocus, invigorate, dodgeForward));
    }
    else {
        // random range attacks
        yield return TriggerSkill(RandomSkill(iceGale, fireball));
        Skill randomSkill = RandomSkill(iceGale, elementalFocus, fireball);
        yield return MoveInRange(randomSkill.range, defaultWalk);
        yield return TriggerSkill(randomSkill);
    }

}

public IEnumerator RandomCombo() {
    Skill randomSkill;

    // 50/50 chance to keep buff going
    if(RNG.PercentChanceRoll(50)) {
        yield return TriggerSkill(elementalFocus);
    }

    // if there is an obstacle in front of target
    if (ObstacleCheck()) {
        if(RNG.PercentChanceRoll(50)) {
            yield return MoveInRange(-2, phase);
        }
        else {
            yield return TriggerSkill(elementalFocus);
        }
    }

    // if there is still an obstacle, set up traps
    if (ObstacleCheck()) {
        yield return TriggerSkill(fireBlast);
        yield return TriggerSkill(fireBlast);
        yield return TriggerSkill(RandomSkill(dodgeBack, elementalFocus, invigorate, dodgeForward, elementalConfusion));
    }
    else {
        // 50/50 to set up traps
        if (RNG.PercentChanceRoll(50)) {
            yield return MoveInRange(2, defaultWalk, false);
            yield return TriggerSkill(fireBlast);
        }
        else {
            randomSkill = RandomSkill(iceGale, fireball);
            yield return MoveInRange(randomSkill.range, defaultWalk);
            yield return TriggerSkill(randomSkill);
        }

        randomSkill = RandomSkill(iceGale, dodgeBack, dodgeForward, fireball, elementalConfusion);
        yield return MoveInRange(randomSkill.range, defaultWalk);
        yield return TriggerSkill(randomSkill);

        // 50/50 chance to move away
        if (RNG.PercentChanceRoll(50)) {
            yield return MoveInRange(2, defaultWalk, false);
        }
    }

}

public IEnumerator DebuffTarget() {
    // if there is an obstacle, phase behind target
    if (ObstacleCheck()) {
        yield return MoveInRange(-2, phase);
    }

    yield return MoveInRange(3, defaultWalk, false);

    yield return TriggerSkill(elementalFocus);
    yield return TriggerSkill(summonIcewall, -1);
    yield return TriggerSkill(RandomSkill(lowerFireRes, elementalConfusion));
}

public IEnumerator LightningCombo() {
    yield return TriggerSkill(elementalFocus);

    yield return MoveInRange(2, defaultWalk, false);
    yield return TriggerSkill(lightningStrike);
}

Note: “TriggerSkill” function signature: IEnumerator TriggerSkill(Skill skill, int cellOffset = 0, bool targetSelf = false, bool targetIntermediateObject = false)

Current AI System

Some of the boilerplate parts of the algorithm is extracted to the base class for the updated system. New AI scripts only determine the next skill to use and potential bonus action. Repositioning can still be done by choosing a movement skill.

New base StartAICoroutine() function:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
protected virtual IEnumerator StartAICoroutine() {
    targetController = defaultTarget;
    aiMoveDisplayController.UpdateAIToNextTurn(this);

    while(currentActionCount > 0) {
        if(GameManager.isEndOfGame) {
            yield break;
        }

        ChosenSkillData chosenSkillData = ChooseNextSkillInAILoop();
        Skill chosenSkill = chosenSkillData.skill;
        if(chosenSkill) {
            // Move into skill range
            if(chosenSkill.range > 0) {
                int targetRange = chosenSkill.range;

                // if chosen skill is a teleport skill, assume we are going behind the target
                if(chosenSkill is Movement movementSkill && movementSkill.isTeleport) {
                    targetRange = -1;
                    yield return MoveInRange(targetRange, chosenSkill, false);
                }
                // if chosen skill is not a movement skill, and there are remaining movement points,
                // walk into range
                else if(currentMovementCount > 0) {
                    // if target cell is more than current movement count, move as close as possible
                    if(GetTargetDistance() - targetRange > currentMovementCount) {
                        targetRange = GetTargetDistance() - currentMovementCount;
                    }

                    yield return MoveInRange(targetRange, defaultWalk, !chosenSkill.b_AlwaysMoveToExactRange);
                }
            }

            // Use non-movement skill
            if(!(chosenSkill is Movement)) {
                // only use skill if in range, otherwise action is wasted (think of something else?)
                // if a multi skill, use it anyway to not mess up sequence index
                if(CheckIfTargetInRange(chosenSkill.range + chosenSkill.radius) || chosenSkill.range == 0 || chosenSkill.nextSkill != null) {
                    yield return TriggerSkill(chosenSkill, cellOffset: chosenSkillData.offset);
                }
                else {
                    yield return ShowOutOfRangeMessage(chosenSkill.displayName);
                }
            }
        }
        else {
            Debug.LogWarning("AI: no skill chosen in loop", userController);
        }

        // End turn if skill choosing functions says so
        if(chosenSkillData.b_endTurn) {
            break;
        }

        currentActionCount--;
        aiMoveDisplayController.RemoveCurrentActionIcon();

        while(AIPause) {
            yield return null;
        }
    }

    // Bonus Action(s)
    yield return BonusActionAfterAILoop();

    yield return EndTurn();
}
AI script of a basic melee NPC using new system (used in line 10 above):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
protected override ChosenSkillData ChooseNextSkillInAILoop() {
    Skill chosenSkill = default;

    // Teleport to the side with more room
    if(RNG.PercentChanceRoll(65) && CheckIfMoreRoomBehindTarget()) {
        chosenSkill = teleportStrike;
    }
    // to pocket sand if target is adjacent
    else if(CheckIfTargetInRange(1) && RNG.PercentChanceRoll(50)) {
        chosenSkill = RandomSkill(pocketSand, spearStab);
    }
    else {
        // if currently healthy
        if(CurrentLifePercent_GreaterThan(60)) {
            // offensive combos
            if(RNG.PercentChanceRoll(80)) {
                chosenSkill = RandomSkill(spearStab, kick, grapple, groundSpike);
            }
            // defensive combos
            else {
                chosenSkill = RandomSkill(rest, dodgeBack, dodgeForward, invigorate);
            }
        }
        // not healthy
        else {
            // if target healthy
            if(TargetCurrentLifePercent_GreaterThan(80)) {
                chosenSkill = RandomSkill(spearStab, rest, dodgeBack, dodgeForward, invigorate, groundSpike);
            }
            // if target not healthy
            else {
                if(RNG.PercentChanceRoll(80)) {
                    chosenSkill = RandomSkill(spearStab, kick, grapple);
                }
                else {
                    chosenSkill = RandomSkill(rest, dodgeBack, dodgeForward, invigorate);
                }
            }
        }
    }

    return new ChosenSkillData(chosenSkill, 0);
}

protected override IEnumerator BonusActionAfterAILoop() {
    yield return TriggerBonusAction(RandomSkill(dodgeBack, dodgeForward, invigorate), 5);
}

A design goal I had early in development is to keep damage values and modifiers as low as possible. In my opinion, smaller numbers give a more satisfying sense of progression than very large ones. For example, upgrading a skill from 8 damage to 11 damage is much easier to parse than upgrading damage from 1,165,236 to 1,602,199 (same percentage increase). This is not always easy to control in a large game with long term support (i.e. power creep), but I believe it was obtainable for my relatively small single player game.

Since keeping track of the low numbers is very important for the sense of accomplishment in this game, it is mandatory that the damage numbers do not overlap. The aesthetic is inspired by Old School Runescape’s hitsplats, since it has similar design requirements with damage numbers. I call my UI elements “hit splashes”.

Many hit splashes occurring at once.

To avoid number overlaps, my algorithm starts from the center of the character, then searches for a vacant position by radially checking positions in a growing radius. This is done with the parametrized circle equation so the radius and angle steps can be controlled easily. I could have used randomization and collision checks with the built in Unity physics system, but I opted for this low-level implementation for more control and predictability.

Hit Splash Placement Without Overlap
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
    private IEnumerator CreateHitSplash(string text, Color color, int fontSize, Sprite spriteIcon = null) {
        // New hit splash is instantiated
        GameObject hitSplashInstance = Instantiate(HUDManager.manager.hitSplashPrefab, HitSplashParent.transform);
        RectTransform hitSplashInstanceTransform = hitSplashInstance.GetComponent<RectTransform>();
        HitSplashController hitSplashInstanceController = hitSplashInstance.GetComponent<HitSplashController>();
        RectTransform hitSplashTextTransform = hitSplashInstanceController.displayText.GetComponent<RectTransform>();

        hitSplashInstance.name = text;

        Text hitSplashText = hitSplashInstanceController.displayText;
        hitSplashText.text = text;
        hitSplashText.fontSize = fontSize;
        hitSplashText.color = color;

        if(spriteIcon) {
            hitSplashInstanceController.icon.gameObject.SetActive(true);
            hitSplashInstanceController.icon.sprite = spriteIcon;
        }

        // Add this hit splash to list of all active hit splashes
        activeHitSplashTextTransforms.Add(hitSplashTextTransform);

        // Calculate preferred width to update content size fitter
        LayoutRebuilder.ForceRebuildLayoutImmediate(hitSplashTextTransform);
        yield return new WaitForEndOfFrame();

        // increment for radius
        // Pick random increment to add some variability
        float radiusSearchIncrement = Random.Range(5f, 15f);

        // increment for parameter i.e. perimeter spacing
        int parameterSearchIncrement = 5;
        bool collision = false;

        // Search for a vacant position (no overlap) for new hit splash by parametrically looking through circles with increasing radius
        // max radius: 10
        for (float r = 0f; r <= 2000; r += radiusSearchIncrement) {
            for (int t = 0; t <= 360; t++) {
                // reset collision flag
                collision = false;

                // check every n parameters
                if (t % parameterSearchIncrement == 0) {
                    // Move hit splash 
                    hitSplashInstanceTransform.localPosition = new Vector2(r * Mathf.Cos(t * Mathf.Deg2Rad), r * Mathf.Sin(t * Mathf.Deg2Rad));

                    // check if there are no collisions for all active hit splashes, if there is one, break for loop and continue while loop
                    foreach (RectTransform existingHitSplashTextTransform in activeHitSplashTextTransforms) {
                        // do not check if instance is colliding with itself
                        if (existingHitSplashTextTransform == hitSplashTextTransform) {
                            continue;
                        }
                        if (RectOverlapCheck(hitSplashTextTransform, existingHitSplashTextTransform)) {
                            collision = true;
                            break;
                        }
                    }

                    // No collisions with any other active hit splashes, vacant position found
                    if (!collision) {
                        break;
                    }
                }

                // only check first position for r = 0 (origin)
                if (r == 0) {
                    break;
                }
            }

            // Position found, break radius loop
            if (!collision) {
                break;
            }

            // Contingency
            if(r > 2000) {
                Debug.Log("ERROR: vacant space for hit splash not found");
            }
        }

        // Start process of fading hit splash away
        StartCoroutine(FadeOutAndDestroy(hitSplashInstanceController));

        yield return null;
    }

I used a naive O(n^2) approach for overlap detection; each hit splash checks every other hit splash for collisions. This is possibly slower than using an out-of-the-box physics engine (with Dynamic AABB trees, etc.), but the Unity physics engine does not work well with it’s UI renderer (very slow, possibly from frequent canvas updates). Although my method is brute force, the algorithm can be constant time depending on hit frequency and the fade delay. Constant time can also be guaranteed by limiting the number of hit splashes on the screen (i.e. remove the oldest hit splashes when limit is reached).

The video above shows the intended upper limit of hit frequency. Based on anecdotal tests, it is clear that the algorithm runs well enough for this game. Run time performance is also aided by the tweakable fade timer (hit splashes are destroyed after delay) and radius/angle step size. Additionally, the hit splash objects are not object pooled in this implementation, a feature that can be trivially added if needed. Object instantiation and destruction is probably the most time costly part of the algorithm.

Removed Bonus Feature

With the code above, the first hit splash will be perfectly centered with a character model. A second immediate hit splash will always be placed directly to the right of the first one. I had the idea that it would be more aesthetically pleasing if the first 3 hit splashes are always centered by averaged position. This is done by shifting the whole canvas after finding a vacant spot with the method above. I ultimately scrapped this idea because it made keeping track of the hit splashes a little more difficult since the hit splashes would jump around instead of always being in the same spot.

Calculate Average Position and Shift Canvas
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// If 3 or less active splashes (do nothing if there is just 1),
// Calculate avg position of all hit splashes to center parent transform
if(activeHitSplashTextTransforms.Count <= 3 && activeHitSplashTextTransforms.Count > 1) {
    Vector3 avgPos = Vector3.zero;
    foreach(RectTransform hitSplashTransform in activeHitSplashTextTransforms) {
        avgPos += hitSplashTransform.parent.GetComponent<RectTransform>().localPosition;
        Debug.Log(hitSplashTransform.parent.GetComponent<RectTransform>().localPosition, hitSplashTransform);
    }
    avgPos /= activeHitSplashTextTransforms.Count;
    // Move origin to avg position of all hit splashes
    HitSplashParent.GetComponent<RectTransform>().localPosition = -avgPos;
}
else {
    HitSplashParent.GetComponent<RectTransform>().localPosition = Vector3.zero;
}

Note: This code would be inserted in line 80 in the CreateHitSplash(...) function above.