Skip to content

Writing Plugins

Thomas Jones edited this page Aug 17, 2026 · 1 revision

A plugin is a class inheriting minqlxtended.Plugin, in a .py file under qlx_pluginsPath, named after the file.

import minqlxtended

class my_plugin(minqlxtended.Plugin):

    @minqlxtended.hook("chat")
    def handle_chat(self, player, msg, channel, recipient):
        ...

    @minqlxtended.command(("sv_fps", "svfps"), permission=5, usage="<integer>")
    def cmd_svfps(self, player, msg, channel):
        ...

Registering

Decorators register when the plugin is constructed, and take the same arguments as add_hook and add_command:

Decorator Registers
@minqlxtended.hook(event, priority=Priority.NORMAL) An event handler
@minqlxtended.command(name, permission=0, channels=None, usage="") A chat/console command
@minqlxtended.vote(name, usage="", description="") A custom vote
minqlxtended.setting(name, default, type=str) A qlx_* cvar, as a class attribute

add_hook, add_command and add_vote still work and are interchangeable with the decorators. Use them from __init__ when the registration depends on something only known at runtime, like a cvar or the gametype. infectedmm.py in the plugins repository attaches and detaches its hooks as the gametype changes.

Stacking with @thread, @delay and @next_frame is supported; put the registration decorator outermost. A handler whose signature doesn't fit its event is refused at registration, not at first dispatch.

Threads

Anything on the game thread comes out of the frame budget, so blocking there makes players lag.

Decorator Runs
@minqlxtended.thread On a fresh thread. For I/O and anything slow
@minqlxtended.next_frame On the game thread, at the start of the next frame
@minqlxtended.delay(seconds) On the game thread, after the delay

Engine views are game-thread-only. Do the slow work in a @thread worker, then hand the engine part to @next_frame.

Never put @thread on a command handler. Its Return.USAGE never reaches the command invoker. Validate arguments on the calling thread and move the slow part into an inner threaded helper.

Work scheduled with @next_frame or @delay is dropped once the plugin is unloaded, the unload half of a !reload included. A @thread worker cannot be cancelled, so a loop that shouldn't outlive its plugin must check self.is_loaded.

Command arguments

Use Plugin.resolve_player() and Plugin.resolve_identifier() to take a <id> argument. Hand-rolling int() plus Plugin.player() answers None for an empty slot rather than raising, and an int outside [0, MAX_CLIENTS) falls through to a by-name search, so !slap 999 looks for a player called "999".

resolve_identifier() returns a named tuple of (steam_id, name, player), where player is None for someone not connected.

Replying

Each client has a 64-slot reliable command ring, and overflow is dropped rather than delayed. Use Plugin.reply_lines(recipient, lines) to send several lines as one command, and Plugin.tell_many(players, msg), which broadcasts when the recipients are everyone connected. A loop of tell() is what fills the ring.

Return values

Return Effect
None / Return.NONE Carry on to the next handler
Return.STOP Stop calling handlers
Return.STOP_EVENT Let the remaining handlers run, cancel the event
Return.STOP_ALL Stop the handlers and cancel the event
Return.USAGE Commands only: print the usage string

Return is not an IntEnum, so a bare False, True, 0 or 1 matches no member and is reported as a value the event didn't understand.

Clone this wiki locally