How to Write a Mod System for a Game

So, hello everyone.

I think that almost everyone who has been programming in python for a long time wanted to make it so that any third-party developer could add functionality to your application without changing its source code. So, I want to make a guide for all beginners – how to make a system of plugins for a program. Let's begin.

We'll start by installing importlib into your virtual environment.

pip install importlib

If the installation was successful, we move on.

Now let's write a small application that will read and execute the user's command:

import importlib

while True:
    command = str(input(">>> "))

    if command == "hello":
        print("Hello, world!")
    elif command == "help":
        print("hello - displays Hello, world!")
    else:
        print(f"Unknown command: {command}")
        

If we run the script and execute a few commands, it will look something like this;

>>> help
hello - displays Hello, world!
>>> hello
Hello, world!
>>> wewewewew
Unknown command: wewewewew
>>> 

Great, everything works. Now let's dive into the theory a little and think about how this system can work. I came up with this option:

  • The program searches for all folders in the plugins folder

  • The program tries to open the file with information about the plugin from each folder in turn.

  • After reading the configuration file, the program will try to import the specified class from the specified file.

We've sorted this out. Now let's try to apply the theory in practice. Let's iterate over all the folders in the plugins folder before the main loop. This can be done with a list expression:

import os

plugins_dirs =  [name for name in os.listdir("./plugins") if os.path.isdir(os.path.join("./plugins", name))]

or using a simple loop (which is basically the same thing):

import os


plugins_dirs = []

for name in os.listdir("./plugins"):
    if os.path.isdir(os.path.join("./plugins", name)):
        plugins_dirs.append(name)

print(plugins_dirs)

I will choose the second option, because it is more readable and understandable for a person.

Let's change the loop a little so that it doesn't add folders to the list, but reads the configuration file in each folder (if it's there, of course) and prints the result:

import os
import json


for name in os.listdir("./plugins"):
    if os.path.isdir(os.path.join("./plugins", name)):
        with open(f"./plugins/{name}/metadata.json") as f:
            plugin_data = json.load(f)
            print(plugin_data)

For now, we will not run the code, but create our first plugin. Just create any folder in the plugins folder. I called it Test_plugin. The structure in it should be as follows:

+ Test_plugin
|
+---- metadata.json
|
+---- plugin.py

The contents of plugin.py are empty for now, and metadata.json is:

{
  "Plugin_name": "TestPlugin",
  "Plugin_file": "plugin",
  "Plugin_main_class": "Plugin_class"
}

This file will store the following information about the plugin:

  • Plugin_name – Plugin name. Can be any

  • Plugin_file – Path to the main plugin file (Without .py at the end)

  • Plugin_main_class – The main class of the plugin, contained in the Plugin_file

Now we can run our main script and see something like this:

{'Plugin_name': 'TestPlugin', 'Plugin_file': 'plugin', 'Plugin_main_class': 'Plugin_class'}
>>> 

Great, this means we have successfully loaded the plugin data into the application.

This is where the difficulties begin

Now we need to somehow import a class from a file with a plugin. How can we do this if we have no idea what plugins the user will load? This is where importlib comes to our aid. Now the code should look something like this:

import importlib
import os
import json


for name in os.listdir("./plugins"):
    if os.path.isdir(os.path.join("./plugins", name)):
        with open(f"./plugins/{name}/metadata.json") as f:
            plugin_data = json.load(f)
            print(plugin_data)

while True:
    command = str(input(">>> "))

    if command == "hello":
        print("Hello, world!")
    elif command == "help":
        print("hello - displays Hello, world!")
    else:
        print(f"Unknown command: {command}")

Let's replace the first line with

from importlib import __import__

This function will help import files whose name and path we do not know at the application development stage. Let's add the line to our plugin iteration cycle

imported = __import__(f"plugins.{name}.{plugin_data['Plugin_file']}")

It means that we need to import the file plugins.Plugin_folder_name.Class_file_name_from_config !(MUST BE SEPARATED WITH DOTS)! Let's see what we imported by wrapping it in print(dir()).

print(dir(imported))

Let's add the following code to plugin.py:

class Plugin_class:
    def __init__(self):
        pass

    def test_func(self, a, b):
        return a + b

Once we do this and run the main file, we get something like this:

['Test_plugin', '__doc__', '__file__', '__loader__', '__name__', '__package__', '__path__', '__spec__']

If you did everything correctly, then somewhere in the list there will be the name of your folder with the plugin. Let's try to get its attribute with the plugin file using:

print(dir(getattr(imported, name)))

We will see the following:

['__doc__', '__file__', '__loader__', '__name__', '__package__', '__path__', '__spec__', 'plugin']

And to finally get to the coveted class with the plugin, let's add a few more letters:

print(dir(getattr(getattr(getattr(imported, name), plugin_data['Plugin_file']), plugin_data['Plugin_main_class'])))

Yes – yes, I know there are more than 121 characters per line, so what are you going to do to me? After running this code, we will see all the functions of our class. I have this:

['__class__', '__delattr__', '__dict__', '__dir__', '__doc__', '__eq__', '__format__', '__ge__', '__getattribute__', '__gt__', '__hash__', '__init__', '__init_subclass__', '__le__', '__lt__', '__module__', '__ne__', '__new__', '__reduce__', '__reduce_ex__', '__repr__', '__setattr__', '__sizeof__', '__str__', '__subclasshook__', '__weakref__', 'test_func']

Here we see __init__ and test_func, which, as we remember, we defined in the Plugin_class class. You can ignore the rest – these are built-in functions and all Python classes have them by default. Let's try to simply initialize this class instead of print and dir and call test_func on it, writing the value it returns to var. This is done like this:

var = (getattr(getattr(getattr(imported, name), plugin_data['Plugin_file']), plugin_data['Plugin_main_class']))().test_func(150, 150)
print(var)

We see the following conclusion and enjoy life:

300
>>>

This is all well and good, but how do we give the user the ability to interact with the plugin? It's pretty simple: we just need to rewrite the function handler a little bit. Right now it looks like this

from importlib import __import__
import os
import json


for name in os.listdir("./plugins"):
    if os.path.isdir(os.path.join("./plugins", name)):
        with open(f"./plugins/{name}/metadata.json") as f:
            plugin_data = json.load(f)
            print(plugin_data)
            var = (getattr(getattr(getattr(imported, name), plugin_data['Plugin_file']), plugin_data['Plugin_main_class']))().test_func(150, 150)
            print(var)

while True:
    command = str(input(">>> "))

    if command == "hello":
        print("Hello, world!")
    elif command == "help":
        print("hello - displays Hello, world!")
    else:
        print(f"Unknown command: {command}")

We will do it this way:

from importlib import __import__
import os
import json


compiled_plugins = {}

for name in os.listdir("./plugins"):
    if os.path.isdir(os.path.join("./plugins", name)):
        with open(f"./plugins/{name}/metadata.json") as f:
            plugin_data = json.load(f)
            print(plugin_data)
            imported = __import__(f"plugins.{name}.{plugin_data['Plugin_file']}")
            compiled_plugins[plugin_data['Plugin_name']] = (getattr(getattr(getattr(imported, name), plugin_data['Plugin_file']), plugin_data['Plugin_main_class']))()

while True:
    command = str(input(">>> "))

    if command == "hello":
        print("Hello, world!")
    elif command == "help":
        print("hello - displays Hello, world!")
    elif command.split()[0] in compiled_plugins.keys():
        worker = compiled_plugins[command.split()[0]]
        if len(command.split()) > 1:
            worker.execute(command.split()[1:])
        else:
            print("Syntax error")
    else:
        print(f"Unknown command: {command}")
        

And let's add the execute method to our plugin class:

    def execute(self, com):
        if com[0] == "test":
            print("Hello from your first plugin!")
        else:
            print(f"Unknown options: {com}")

Well, I want to congratulate you: Having launched the program now and written, substituting the name specified in json instead of TestPlugin, in the terminal this:

TestPlugin test

You will get this:

Hello from your first plugin!

In conclusion, I want to say that what we have just written is a very bad system:

There are many rules of good code (pep-8) broken, there is no exception handling when loading and using, there is no provision for plugin name conflicts, in general, if you want to make something at least a little bit working, then take all these factors into account when writing code. I hope you liked my article and you will rate it. Good luck to everyone!

Similar Posts

Leave a Reply

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