A much more exciting system was to be found in the text-based MMO "Revelation: Lands of Kaldana". Items known as "tinctures" would sometimes drop from high-level enemies. Applying the tinctures to your equipment would either give it a permanent stat increase or cause the equipment to be destroyed in an explosion. Besides being a more exciting system, the system also allowed for theoretically infinite but pragmatically finite progression due to the exponential difficulty of applying multiple tinctures to an item. It gave players a reason to farm bosses after obtaining the boss' gear since players could use the extras for tinctures. The system also prevents the unimmersive feeling that comes from selling the Epic Sword of Total Domination to the local grocer since, again, the extras are used for tinctures.
While there are no doubt Minetest mods which add systems similar to Minecraft's enchantment system, I figured it'd be much more fun to create a mod with Revelation's tincture system instead. I could also have a bit of fun creating a 3D explosion, too.
In Revelation, different colors of tinctures affect different stats. Since the default Minetest game isn't a fully-fledged RPG there aren't nearly as many stats to increase, but there a few. The first stat to implement would be mining speed & damage ("power") from red tinctures.
As for actually increasing the stats, some digging showed that tools had a number of fields known as capabilities. The problem was that these capabilities were determined at registration time, which was when the game would be initially loaded. Due to the combinatorial explosion of different tinctures that could be applied to even a single tool, it would be impractical to pre-register the needed combinations ahead of time. A gross but possibly workable solution would be to create a pool of pre-registered tools to draw from and update dynamically with override_item, but experimentation showed that this appeared to update the server but not the client. Registration was a dead end.
Thankfully, further searching revealed the get/set_tool_capabilities() methods. Now, the actual layout of capabilities is rather complex, involving "groupcaps" (capabilities depending on the type of material being mined), levels (of the material being mined), and actual mining time. For my purpose it was sufficient to find every groupcap and level and decrease its dig time by 10%. However, it wouldn't be sufficient to just decrease dig times by 10% and call it good; how would subsequent tincture applications be handled? I didn't want to compound tinctures on top of each other, so I needed a way to keep track of tincture counts, plus the original mining times for mathematical convenience. Helpfully, items had a "meta" object which could contain entries for dynamically-defined keys. Keeping track of tincture counts and original mining times was then simply a matter of defining and using the appropriate metadata fields at tincture use time.
Once I had this basic functionality implemented and proven working the next steps were to fix up the implementation a bit. One mistake I blundered into was assuming that all 3 "ratings" for a groupcap would be defined when in fact items like the wood pickaxe had only one level defined, meaning they could not mine lower-rated (more difficult to dig) nodes like obsidian; this was easily fixed with a dynamic loop. As for identifying any type of tool and not just pickaxes, I looked to the get_tool_capabilities() method; unfortunately, the method didn't actually identify whether or not the item was a tool or not, but I observed that non-tools had an oddly_breakable_by_hand capability, so I checked for absence of the capability in order to determine that the item was a tool (later on I learned about the minetest.registered_tools global). Now, in order to verify that tincture application was working as intended, I had been automatically logging the capabilities before and after tincture application. While this brute-force logging worked for development, I imagined that server admins wouldn't appreciate the noise in their logs and the messages were only helpful when applying tinctures and not, say, a few weeks later when I once again found time and energy for more development work. The capability values weren't something succinct enough for the item description (plus I hadn't gotten to that point in development yet) and were not something an average player would want to be exposed to, only a power user would. So I used the obvious feature for power users: the command line; I added a command named tinc_caps which would send the capabilities of the player's currently wielded item to said player via minetest.chat_send_player and removed the ad hoc server log messages. I also cleaned up the other player-specific messages to use minetest.chat_send_player instead of the server log as well.
Adding brown (durability) tinctures used mostly the same logic as with red tinctures, except each capability had an overall (instead of per-rating) uses value that I needed to increase, in this case by 100%; a bit of boilerplate for the registration and the new tincture type was easily added!
The effect looked just as I'd hoped it would!
One idea I played around with was the notion of "carry" damage. The idea was that sub-integer portions of damage would be "stored" in the weapon and then applied when they crossed the integer threshold. For example, consider a weapon that did 3 damage and had one power tincture applied; rather than doing 3.3 damage per strike, which did not appear to be possible, at each strike the weapon would have done damage while "storing" sub-integer portions of the damage in it:
Naturally, a few things made this not as straightforward. First, in order to check that the item dug was an ore, I initially used the minetest.registered_ores map. However, it turned out that, in addition to what I considered ores, nodes like dirt, sand, and mese blocks were also registered as "ores"; this apparently had to do with how blobs of them were spawned underground in the map. The problem with this is that these "ores" drop themselves, meaning that a player could use a “place, dig, and repeat” cycle in order to generate an infinite supply of ores! There didn't appear to be any way to differentiate between these "ores" and actual ores based on their ore registration, so I had to figure out some other method in order to identify which dug nodes were ore. Short of a hardcoded list (eww), the only thing I found consistent with actual ores was that their names were all prefixed as stone_with_, so, I checked if the dug node's name matched this prefix, and, if so, considered the dug node an ore. Not pretty, but it worked. Next, there was the matter of how to give the extra ore to the player; while I could insert the ore into the player's inventory, that might not work if the player's inventory was already full. Searching the docs showed that Minetest helpfully provided the inventory:room_for_items() method that I could use in order to check whether or not the player's inventory would be able to hold the item; I then used this method in order to determine if the module would call inventory:add_item() in order to add the item to the player's inventory, or if the module would instead call minetest.add_item() in order to drop the item at the player's location. Last, when determining exactly which extra ore to generate, I used the minetest.get_node_drops() method, but one thing that wasn't clear from the method was how probability-based drops were handled. Would each call run the probability check so that multiple calls might return different results? Was the function run once before the dignode hooks so that the return values would be the same while the hooks were run? I'd guess the former, but I didn't try and find out and instead relied blindly on the provided return value. Good enough for now; the implementation worked and the efficiency tincture was implemented!
Adding damage was easy: call the method player:get_hp() in order to get the player's current health, subtract the damage done, then set the player's current health to the difference with player:set_hp(). Nothing in programming is ever that easy, though, and there was a slight (read: "major") problem with this. The on_use() callback returned an ItemStack which replaced the player's ItemStack; nominally, this meant subtracting a tincture from the ItemStack and returning the result. This worked, except, if the player died as a result of player:set_hp() called from on_use(), then the player would first drop all of their items and then on_use() would return the ItemStack into the player's inventory; this meant that if a player had 10 tinctures and died as a result of using one then there would be 10 tinctures left where the player had died and 9 on the dead player. Infinite tinctures! A few solutions such as spawning a delayed and external damage source or manually reaching into the player's inventory, removing a tincture, and then returning nothing from on_use() might have worked, but they were all relatively complex so I instead decided to leave the player (barely) alive for now and focus on the other work first.
For dramatic effect it was also absolutely necessary to knock the player back on a tinctured explosion. This would require rigorous and strenuous testing, though, by which I mean setting an artificially high tincture level and then having my character fly halfway across the map when the inevitable tincture failure happened. I first needed a way to set an artificially high tincture level, though, and a new chat command was the way to go. I'd already familiarized myself with the minetest.register_chatcommand() method in order to display tool capabilities, so I re-used this knowledge in order to create various tinc_set_EFFECT commands. For these commands I also added a parameter for the desired tincture level and required the debug privilege in order to invoke the command, but wasn't particularly satisfied with use of the debug privilege since it was normally just for viewing debugging data and not modifying the world; it was good enough for now, though. After my, ahem, testing infrastructure was in place, it was time to actually knock the player back. I decided to knock the player a little bit upwards and also a little bit sideways, adding a slight variation to each; 10 to 15 degrees vertical and up to 15 degrees to either side. The Minetest engine provided a convenient player:add_velocity() method in order to actually knock the player back, but first I had to calculate what velocity to apply to the player! I first used the player:get_look_horizontal() method in order to get the yaw in radians, then added somewhere between -15 and 15 degrees using a hardcoded conversion. Since the actual velocity would be in 3D, I next used minetest.yaw_to_dir() in order to convert to Minetest's helpful Spatial Vector class. In order to launch the user upwards I then used the vector:rotate() method, followed by vector:multiply() in order to increase the knock back based on tincture count, and finally called player:add_velocity() in order to actually knock the player back. After some playing around with vector magnitudes, I eventually got the knock back to where I wanted; a slight nudge for barely-tinctured items and a bit of oomph for highly-tinctured items. Improbably-tinctured items (1,000+) hilariously send the player flying across the map until they hit a load boundary and come to a dead stop before falling to their death on solid ground or a long swim home from the middle of the ocean.
Next up was to add a sound for the explosion. I first grabbed the explosion sound from the TNT included in the default game and then added a call to minetest.sound_play() in order to play the sound at the player's location. This worked as expected, but there was a problem; the explosion sounded too... "bassy". It just didn't work. So I went to Freesound in search of explosion sounds. After much searching, sampling, and testing I eventually settled on #473941: impact_.wav. I liked #625114: EXPLDsgn_Powerful Explosion 2.1_EM_(28lrs).wav, too, but it was for non-commercial use only and I didn't want to needlessly cripple my mod that way. A few others which I tested were:
An explosion wouldn't be any fun if it didn't also leave a crater, so the next step was to figure out how to remove nodes in order to leave a crater. Admittedly, I got really lazy with this code. The way to remove a node without destroying it is to "dig" it, so I called minetest.dig_node() on the nodes which I wanted to remove. In order to actually find the nodes I needed, well... I added two to the overall radius of the explosion, then iterated in a cube (!) over each node and removed ones that were within the explosion radius. Not very efficient. Nodes which were less than one node away from the radius were removed with a linear probability based on how close they were to one node away; a better implementation might determine a set number of nodes to remove based on the volume of the explosion which doesn't cleanly fall into its radius and then consistently remove that number of nodes. I made the volume of the explosion increase by 10% per tincture, meaning the increase in radius would be inverse cubic. As for the "dug" nodes from the explosion, I left them fall to the ground for now, though it might be fun to scatter them away from the explosion in the future...
Finally, I wanted some kind of debris effect from the exploding tool. As I had almost zero artistic talent, this would not be so easy. Naturally, I first tried the smoke from the TNT mod but the particulate matter felt excessive; however, the implementation of the smoke gave me a clue that a particle spawner might do the trick. It would make sense that chunks of an exploding tool would rapidly scatter away from the player, so I used minetest.add_particlespawner() in order to spawn particles radiating outwards from the explosion location. As for the particulate matter itself, the particle spawner had a node parameter which would create particles based on the node texture; the problem for me was that tools weren't nodes. Attempting to use the inventory image in the texture parameter resulted in "particles" which consisted of the entire tool image! There also didn't appear to be any way to specify random pixels from an image (or at all) without registering an entire node. Well, that wasn't ideal, so what I did was to shrink the image down to a small size so it both resembled the weapon and particulate matter; it didn't really make sense that a tool would explode into 64 smaller versions of itself, but one had to squint to see it!
With the damage, knockback, crater, sound, and particle spawner implemented, the explosion was complete! Below is an example of an explosion from a failed single tincture level:
For fun, here is an example from an improbable 1,001 tincture level: Ridiculous! Now that destruction was implemented, the next step was a bit of a thematic opposite as it was related to preservation: letting a player keep their tinctures after a weapon breaks from wear rather than losing them with the weapon.
Unfortunately for me, there didn't seem to be any kind of on_break() method which I could override. There was after_use(), but that would require overriding every item during game load time. By using the minetest.registered_tools table I could iterate over the registered tools, but that was empty by default! It turned out that what was in the registered tools table was dependent on the module load order. The way to ensure that the default module's tools were in the table was to add a dependency on the default module by editing mod.conf and adding the optional dependency with optional_depends = default. This was hardly ideal since any module that registered tools would have to be declared as an optional dependency in order for tinctures to work, but that was okay for now. Even so, how exactly was I to override after_use()? If I let the "wear" reach zero then the tool would be instantly destroyed along with its tinctures; if I left the wear above zero then the player could continue to use the tool as before even though it should have been broken. I needed some kind of "inert" state for the tool.
After much contemplation, I decided to have the broken tool drop what was essentially a "memory" of its previous state. The memory would contain the tinctures applied to the tool as well as exactly what tool the tinctures had been applied to. I would call this memory an "essence", and it would be a regular item for players to use by combining it with the same kind of tool in order to regain the previous tool's tinctures. Generating the essence on breakage in after_use() was actually a little tricky. Since I was overriding the default usage behavior, I had to manually call the itemstack:add_wear() method in order to apply wear. If the wear fell to zero then the tool's metadata, which included the tincture metadata, would no longer be available, so I had to first extract an essence before calling the method to add wear from digging; after calling the method, I'd check the wear of the tool and, if non-zero (tool not broken), would return the tool, otherwise I'd return the previously-extracted essence for the tool.
Once I'd gotten the essence-generation working successfully, the next step was figuring out how to let players actually apply it to their tools. Rather than re-use the on_use() hack with tincture application in order to affect the first item in the player's inventory I decided to see if I could use the regular crafting grid. Creating the crafting recipe turned out to be a simple matter of using the minetest:register_craft() method in order to associate each essence with its corresponding tool, but this wasn't sufficient to actually apply any tinctures because by default it would just craft an untinctured tool. The actual application of tinctures would have to be done in minetest:register_on_craft() and its sister function minetest:register_on_craft_predict(). Inconveniently, the callback definition did not include the invoking recipe, so the first step of the callback implementation was to determine if the recipe was an essence recipe and the location of the tool and the essence by iterating through the crafting ingredients; after that it was simple enough to apply the essence to the tool and return the tinctured tool.
That worked for the simple case. What if the player tried applying an essence to an already-tinctured tool? The existing tinctures would have been replaced by the new tinctures, which is probably not what the player would want, especially if they had accidentally applied a lesser essence to their best tool. I tried to see if it was possible to block a recipe in the callback, but the only options I could find were to continue with the recipe unmodified (by returning nil) or modifying the result. My second guess was to just use the essence representing the most tinctures and to destroy the other essence, but after some reflection that seemed like just another landmine for players. Eventually I came to the conclusion that applying an essence to an already-tinctured tool would have to generate a new essence from the old tinctures. This would lead to the weird behavior that a player could hot-swap essences on a single tool, but I couldn't think of any major advantage this would offer since the essence would take up the same space as an actual tool anyways. Since the new essence wasn't a registered result of the craft recipe I had to manually add it to the player's inventory in the callback. I also had to take care to only manually add the new essence during actual crafting and not during craft prediction. This took care of already-tinctured tools, but one more challenged remained: tool repair.
The toolrepair recipe type in Minetest was... interesting. The recipe would combine two tools of the same type into a single tool, reducing the wear such that the new wear represented the remaining uses of the two tools combined, plus a little extra reduction in wear for a few extra uses. This recipe raised a number of interesting cases for the tincture crafting callback. The first two cases were obvious: two tools without tinctures should return a repaired tool without tinctures, and one tool with tinctures and one without should return a repaired tool with tinctures. How to handle two tinctured tools, though? As before, I wanted to prevent players from accidentally destroying their tinctured tools, but there was no realistic way to combine two tinctures into one tool. Taking a cue from applying essences to already-tinctured tools, I decided to have one of the essences apply to the repaired tool and place the other essence in the player's inventory. Which essence would apply depended on its position in the recipe, making the recipe not-quite-actually "shapeless", but, whatever (the code to determine it was a repair recipe was enough of a pain): a player could just subsequently apply the other essence to the repaired tool if they made a mistake. With tool repair finished, tinctures were properly handled in all crafting recipes!
While the essence functionality was correct, I'd been lazy and copied the essence image from the tool icon, making it impossible to visually distinguish an essence from a tool without hovering over the tool in order to view the tooltip. I needed a way to visually distinguish essences from tools in the tool's icon. After some digging I found this really neat feature called texture modifiers that allowed me to generate textures at runtime through my mod's code. What I wanted to do was create a sort of transparent and shimmering image of the original tool in order to both clearly identity which tool the essence applied to while also clearly distinguishing the essence from a usable tool. For the glow, I used the venerable GIMP's supernova effect, but at first I thought that transparency wouldn't work at all given the icons I was seeing:
![]() |
As a final piece of clean-up, I noticed in testing that replacing the broken tool with an essence prevented the item breakage sound from playing. The workaround was simple enough: when the would-be broken tool is replaced with an essence, check the item definition for a breakage sound and, if it exists, play it. Perhaps someday the engine behavior will change and the sound will begin playing twice, perhaps this will manifest in a weird echo from a playtime offset or perhaps it will just sound louder than it should. Regardless, at long last I had a solution to handle item repair and breakage; it was tricky and a bit hacky, but functional. This was the last of the functionality that I had any real concerns about being able to implement, so having it all working was a big relief. Next it was time to pay off some technical debt, and then, finally, prepare for the first release.
An unexpected benefit of my previous work from coding essences was that I could use my knowledge of texture modifiers when designing the new tincture graphics rather than naively creating a separate graphic for each color; it also meant that I could break the images into separate files and combine them in the Minetest engine rather than creating separate layers in GIMP. Since the vial would be static and the fluid inside it would vary depending on the type of tincture, I split the two into separate images so that I could apply color to one without affecting the other. This time I made the vial really small and also dithered the colors a bit in order to make the icon feel more natural and less artificial. In order to emphasize the magic nature of the tinctures I also used GIMP to generate a glow effect via Filters -> Light and Shadow -> Gradient Flare (Select: Default, X: 7, Y: 8, Vector length: 1, Angle: 249, Radius 3, 4, and 5 (dull, bright, and brilliant, respectively)) followed by a Colors -> Desaturate -> Desaturate. Thus I had the following set of images:
|
![]() |
Migrating away from GitHub is one of those things I ought to get around to doing in my copious free time. Especially since it was acquired by Micro$oft. Well, a full migration might take a while, but this seemed like a good opportunity to try out another service provider. GitLab seemed a decent choice, but it's ridiculously JS-reliant and feels cumbersome. Codeberg, not-a-big, and disroot all seemed like FOSS-friendly options. There's also gitgud.io, but being based on GitLab it needed JS, the infra seemed a little buggy, and the Discord contact made this a no. In the end I decided on using Codeberg for the repo.
Sometime while working on the next three commits (detailed in the following subsections), I noticed something strange happening when my player died, or, well, should have died. Instead of dying, the camera would switch to some weird 3rd person view, I would be able to move but not interact with anything, and I'd be confined to my current load boundaries. There was no respawn option, but if I logged out and logged in again then I'd be able to respawn. After some brief, ad hoc bisecting it turned out that closing all formspecs also closed the respawn formspec! Oops. So, the next question was, how could I close just the inventory formspec? I could use the player's get_inventory() method in order to get the inventory as an InvRef, but there was no clear way to get a form name from that. After some digging, I found that the default player inventory was the sfinv mod; maybe it would work to create a setting that closes a specific form name and have that form name default to the form name created by the sfinv mod? Well, the problem with that was that I couldn't even get it to work with sfinv! I tried sfinv:crafting, main, player:main... no luck. After some more searching I found the minetest.register_on_player_receive_fields hook which I eagerly utilized in order to get the following result:
2025-02-22 13:15:45: WARNING[Server]: player: 'userdata: 0xffff7c061948', formname: ”, fields: 'table: 0xffff7c284a20'
A truly l33t solution would involve traversing each node and recursing through its recipes for all possible paths leading to item duplication, properly taking into account loops, probabilistic drops, and whatnot. I went with an explicit whitelist instead. The whitelist was a setting named efficiency_items and would take an array of itemstrings which the efficiency tincture would apply to. Actually, the Settings object had no support for arrays so the actual argument was one giant string with each item separated by whitespace. In order to prevent needing to manually fill in the list for the default game, I also added an efficiency_defaults boolean option which, when true, would populate the whitelist based on the original, name-matching logic. It wasn't fancy, but it worked.
I made sure to remove both the description and re-add the not_in_creative_inventory group before committing.
...and does a much better job informing the player of the risks.
With this done, I'd completed all of the major polish items that I wasn't able to include in the v0.1 release.