In 2020, the Minetest Discord community ran a mod-making competition with “combat” as the theme. Participants had one week to create a mod with all the code written within the time, but pre-existing art was allowed. I made a Real-Time Strategy (RTS) minigame called Conquer; it received first place.
In this article, I will discuss some of the interesting challenges that Conquer needed to solve. I believe that Conquer is a great example to learn from as it demonstrates best practices for Minetest modding; it is well-structured and unit-tested.
What is Conquer? #
Conquer is a mod that adds RTS gameplay. It allows players to start Conquer mini-games, where they can place buildings, train units, and fight other players.
Whilst I created Conquer as a mod, it was designed with plans to eventually convert it into a game, with custom map generation and more in-depth gameplay.
Players select units by left-clicking them. To issue commands, the player uses the scroll wheel to select a command and then right-click to perform it. Commands include move, melee attack, and ranged attack. Archers automatically aim at nearby enemies, and all units will defend themselves from melee attacks.
Units can damage buildings using melee or ranged attacks. When a player’s keep is destroyed, that player is out of the game. The appearance of a building reflects how much damage it has taken.
My Process #
I used GitLab projects to organise my tasks. I decided what the Minimal Viable Product (MVP) was and created issues for the core features. During development, I created new issues to break down high-level goals into smaller steps.
I only had seven days to make quite a complicated mod. I needed to cut a lot of features that would have been desirable, such as squad movement, walls, and siege engines. I pushed features that weren’t needed in the MVP to the bottom of the backlog and resisted implementing them.
Unit AI Behaviour #
Units need to know how to do some tasks without being micromanaged by the player. They need to understand how to navigate the world, melee attack other units, and arrows at moving targets. To tell the units how to do these things, I chose to use Behaviour Trees.
Behaviour Trees #
Explaining Behavior Trees in full is a bit out of scope for this article; I highly recommend reading “Behavior trees for AI: How they work” by Chris Simpson. However, I will try to explain the basics.
Behaviour trees allow you to control an NPC’s decision-making by combining reusable nodes rather than writing code for specific states. They work best when the nodes are small and specific, for example, you might have nodes to check conditions or walk to a position.
Behaviour trees are basically a programming language in themselves, but for game AI. Execution starts at the top of the tree, and then works downwards based on the rules of different nodes. A node is either running, succeeded, or failed.
Using behaviour trees for Conquer unit AI was probably overkill. Behaviour trees are great for structuring complex behaviour, but Conquer’s unit AI is fairly simple. I decided to use behaviour trees as I wanted to make a Lua implementation to use in other projects anyway - I ended up using it in another project to create worker NPCs that can build.
The nodes implemented in Conquer include:
- Selector: run one child after another until one succeeds
- Sequence: run all children in order, stop if one fails
- FollowPath: cause the unit to follow a path
- MoveToNearTarget: cause the unit to move to be near a target
- MeleeAttack and RangedAttack.
Aiming arrows #
Archers need to know what direction to fire an arrow to hit a moving target. I originally tried to derive the maths for this myself, but ultimately found an equation on Wikipedia that worked. This was by far the thing I found most challenging whilst creating Conquer, it took a while to get it to work as expected.
Here’s the final code. It’s just the equation converted to Lua.
local function calculate_projectile_direction(from, to, initial_speed, gravity)
-- Turn this into a 2D problem by considering just the plane
local delta = to - from
local x = math.sqrt(delta.x*delta.x + delta.z*delta.z)
local y = delta.y
local x2 = x*x
local v2 = initial_speed*initial_speed
-- If there's an imaginary number, no solution is possible
local square = v2*v2 - gravity*(gravity*x2 + 2*y*v2)
if square <= 0 then
return nil
end
local t_x = gravity * x
local t_y = v2 - math.sqrt(square)
local factor = t_x / x
local u_y = t_y
return vector.normalize(vector.new(delta.x * factor, u_y, delta.z * factor))
end
The above function returns the direction to fire an arrow to hit a stationary target. To hit a moving target, I predict the target’s position at a set time in the future:
local guessed_time = vector.distance(from, target) * 0.11
local offset_target = target + target_velocity * guessed_time
This is a simple approach that works a lot of the time but is flawed as the way it estimates the flight time using distance isn’t accurate. It’s good enough for my purposes, however; archers occasionally missing could be considered a feature!
Buildings and NodeObjectRef #
In Conquer, barracks produce new units, farms produce food, and the keep must be defended at all costs. The appearance of each building changes based on its health. Buildings regenerate health when no enemy units are nearby.
Node ObjectRef #
An object in Minetest is a moving thing with a position and velocity. For example, both players and Conquer units are objects in the world. In contrast, a node is a static thing in the voxel grid. Grass, keeps, and barracks are examples of nodes.
To interact with an object using the Lua API, you use an ObjectRef. ObjectRefs have methods giving you access to the position, velocity, and hp. Nodes do not have ObjectRefs as they are not objects.
Conquer units need to be able to attack enemy units and nodes. Whilst I could have implemented these abilities separately, I decided instead to create a Node ObjectRef, a bit of Lua code that allows interacting with a building node as if it were an object.
This is a nice abstraction that vastly simplifies the code. I intentionally only
implemented a subset of the ObjectRef interface: get_pos
, get_velocity
,
get_hp
, and punch
.
local node_object = conquer.create_target(vector.new(3, 4, 5))
-- Can now interact with the node like an object
node_object:get_pos()
node_object:get_velocity()
node_object:punch(unit_entity, 0.5, caps)
Conclusion #
The mod jam received 16 entries. After the deadline, the organisers set up a server to play all the mods - it was chaos and a lot of fun. I ran a server just for Conquer as a flat area was needed to play on. I also didn’t get around to implementing persistence before the jam deadline, so other mods crashing the shared server would have caused ongoing sessions to be lost.
I continued to develop Conquer after the jam, adding multi-select and other quality-of-life features. The next big thing to implement is squad movement, allowing a selection of units to move as one and avoid occupying the same position.
Thanks to GreenXenith and others for organising the mod jam. It was a pretty cool experience and had a good atmosphere to it. It was a lot more successful than I expected. You can find other jam entries on ContentDB.
After the success of the mod jam, GreenXenith and others arranged game jams in 2021 and 2022. These were open to the wider Minetest community and proved even more successful. I was a judge on the game jams and didn’t enter.
Comments