Introduction
[!CAUTION] Note on the currency and accuracy of this documentation
I strive to keep this developer documentation as up to date as possible. Since I am simultaneously working on the actual project and maintaining the documentation on my own, it is not always possible to reflect every change immediately.
Please note the following:
- Marking of outdated chapters: Chapters that are out of date will be marked where possible. However, even unmarked sections may no longer reflect the current state of the code.
- Use of AI: Some text passages were created with the help of AI. I review them, but cannot guarantee that they are free of errors.
- General errors such as typos, inaccurate descriptions, or outdated code examples can occur – even in manually written and unmarked sections.
- Your help counts! If you notice incorrect or outdated chapters, feel free to report them via a GitHub Issue – this helps improve the documentation for everyone.
Treat this documentation as a helpful guide, but when documentation and source code contradict each other, the source code is always right.
Welcome to the developer documentation for the Streaming Tool – a project that connects TikTok events with Minecraft.
This documentation is aimed at developers who want to understand:
- How the system works internally
- How data flows (TikTok → Processing → Minecraft)
- How to extend the project (write your own plugins, customize features)
Where to Start?
| Profile | Recommended starting point |
|---|---|
| Basic Python knowledge | Start with Basic Concepts, then Setup |
| Advanced Python knowledge | System Overview → Python in this project |
| Extend & customize with Python | Go directly to Plugin Development or Custom $ Commands |
| Debugging / Troubleshooting | Debugging & Troubleshooting |
[!NOTE] If you have knowledge in programming languages other than Python, you can go directly to Create a Plugin without Python. However, you should still look at some chapters to better understand how the system works. This will help you even if you don't know Python. I also recommend looking at Create your own Plugin – even though it is primarily written for Python, there is important information there.
Scope & Focus
The project comprises roughly 3,000–4,000 lines of Python code. We don't analyze every single line – that would be pointless.
Instead, we focus on:
- Core architectural components – How is the system structured?
- Data flows – How does data move through the system?
- Patterns & best practices – What should you pay attention to?
- Practical application – How do I write my own plugin?
In the code itself, you will find additional comments that serve as signposts for specific details.
Prerequisites
[!NOTE] This documentation explains how the Streaming Tool system works – but not Python basics themselves.
You need the following prior knowledge:
- Basic Python programming concepts (functions, classes, loops)
- Command line / terminal navigation
- File system fundamentals
If these concepts are new to you: Complete a beginner Python course first, e.g. Python.org Tutorial or Codecademy Python Course. Since we assume these basics here, we save space for depth rather than repetition.
Additionally, you need:
- Python 3.12+
- Git (for cloning the repository)
- An editor or IDE (VS Code, PyCharm, etc.)
Everything required for setup is explained step by step in Setting Up Local Development.
Structure of the Documentation
00 FUNDAMENTALS
├─ Fundamentals & Concepts (What is this system?)
└─ Local Development Setup (How do I set this up?)
01 SYSTEM OVERVIEW
├─ How the system works together
├─ Receiving data from TikTok
├─ Processing data
└─ Sending data to Minecraft
02 PYTHON & EVENTS (Core logic)
├─ Python in this project
├─ The main.py file
├─ TikTok Client & Event Handler
│ └─ Gift Events, Follow Events, Like Events
└─ Threading & Queues
03 MINECRAFT INTEGRATION
├─ From event to command
├─ The actions.mca file
├─ Mapping logic
└─ mcfunction files
04 SYSTEM ARCHITECTURE
├─ Modular structure
├─ Control Methods (DCS vs. ICS)
├─ PLUGIN_REGISTRY
└─ Integration with streaming software
05 PLUGIN DEVELOPMENT
├─ Plugin structure & setup
├─ Events & Webhooks
├─ Config & data storage
├─ GUI with pywebview
├─ Inter-plugin communication
└─ Error handling & best practices
06 ADVANCED
└─ Debugging & Troubleshooting
APPENDIX
├─ Project structure
├─ Config details
├─ Update process
└─ Glossary (terms explained)
The documentation is progressively built: Each chapter builds on the previous ones. But you can always jump to topics that interest you.
Recommended reading order
Option 1: Complete walkthrough (best preparation)
- Basic concepts
- Setup
- System overview
- Event processing
- Minecraft integration
- System architecture
- Plugin development
Option 2: Quick start for experienced users
- Basic concepts (10 minutes)
- Plugin development
- Then: Specific chapters depending on your interest
The appendix
In addition to the main chapters, there is an Appendix. The appendix contains:
- Project structure: Files & folders in detail
- Config details: Understanding & extending the configuration file
- Update process: How updates work (for maintainers)
- Glossary: All technical terms explained
The appendix is a reference – you don't have to read it linearly.
How to best use this documentation
- Find your level: Beginner? Then start with Basic Concepts & Terms.
- Read progressively: Chapters build on each other.
- Don't skip too quickly: If something is unclear, go back to previous chapters.
- Use the glossary: Unfamiliar terms? Check the Glossary.
- Experiment: Reading is important, but writing code yourself is crucial.
Code examples in this documentation
Where we show code examples, we use this formatting:
# Example Python code
from TikTokLive import TikTokLiveClient
client = TikTokLiveClient(unique_id="my_account")
Info blocks:
[!TIP] Practical recommendation, trick, or best practice to make your work easier or better.
[!NOTE] Supplementary information or background knowledge. Not critical, but often helpful for a better understanding.
[!IMPORTANT] Mandatory information or hard prerequisite. Must be followed for things to work. Not optional.
[!WARNING] Pay close attention here! May lead to errors, problems, or unexpected behavior, but nothing is permanently damaged.
[!CAUTION] Critical notice! Incorrect use can lead to data loss, system errors, or irreversible damage.
Found an error? Questions?
This documentation is constantly being improved. If you:
- Find errors → Open a GitHub Issue
- Something is unclear → Ask an AI or other developers
- Have ideas → Provide feedback in the repository
Good luck!
You are ready. Let's start:
or
→ Setting Up Local Development
Basic Concepts & Terms
Before we get into the architecture and code, we need to understand the key ideas. This chapter only covers the essential concepts – everything else is explained in detail in the following chapters.
What Is This Streaming Tool?
The streaming tool connects TikTok events with Minecraft – in real time.
The process:
Viewers on TikTok
↓
send a gift / follow / like
↓
TikTok server notifies the tool
↓
The tool runs a Minecraft command
↓
Something happens in the Minecraft game
Practical example:
- Streamer is live on TikTok
- Viewer sends a gift
- Minecraft server receives:
/say "Danke für das Gift!" - All players see the message
Core Idea: Events → Actions
The whole system is based on a simple principle:
EVENT (Something happened)
↓
PROCESSING (What does that mean?)
↓
ACTION (What should happen?)
Three central concepts:
1. Events – The Input Signal
An event means: Something happened.
Examples:
- User sends a gift
- User follows the channel
- User likes the stream
[!NOTE] Events are structured data – they have properties such as "Who?", "When?", "What?", "How much?". More in How the system works.
2. Processing – "Understanding"
The program takes the event and asks:
- "What kind of event is this?" (Gift / Follow / Like?)
- "Who does it come from?"
- "Is this important?"
3. Queue – The Order
If 100 events arrive at the same time, they cannot all go to Minecraft at once. Instead:
Events arrive → are placed in the queue → processed one after the other
The queue prevents chaos and overload.
[!NOTE] Imagine the supermarket: all the customers line up at the checkout. One after the other is served – fair, orderly processing.
The 3 Phases (Overview)
The system works in 3 phases (details in How the system works):
| Phase | What happens | Result |
|---|---|---|
| 1. Receive | TikTok events are received | Structured event data |
| 2. Process | Events are classified | Clear categories (Gift/Follow/Like/...) |
| 3. Execute | Command is sent to Minecraft | Minecraft action is triggered |
Configuration vs. Code
An important idea: Configuration is separate from code.
This means:
- Code: The logic – "how does the tool work?"
- Configuration: The rules – "what should happen when X happens?"
You can add new actions without changing a single line of code – only by editing the configuration.
[!NOTE] Details about this in later chapters.
Summary: The Basic Idea
The system works according to this pattern:
EVENTs arrive
↓
Place in queue
↓
Process one at a time
↓
Execute the appropriate action
↓
Minecraft responds
That's the whole concept. Everything else is details and implementation.
Where to Go from Here?
Now that you know the basic idea:
- Next chapter: Setting up local development → Setup on your computer
- Then: How the system works together → Architecture in moderate detail
After that we will go deeper into code, configuration, and specific features.
[!NOTE] Don't worry if everything isn't clear right away. Each idea is explained later with examples and details!
Setting Up Local Development
In this chapter you will set up your local development environment. This is a one-time task – then you can start development straight away.
Requirements
Windows
- Windows 10 or 11
- Python 3.12+ (recommended: Python 3.12)
- Git (to clone the repository)
- PowerShell 7
Java & Minecraft Server
- Java Runtime Environment: The folder
tools/Java/must exist (either with Java files or your own Java installation). - Minecraft Server: The file
tools/server.jaris required (Minecraft server JAR file).
[!IMPORTANT] Make sure both the folder
tools/Java/and the filetools/server.jarare present in your project. Without these, some features (e.g. Minecraft integration) will not work!
macOS / Linux
- Python 3.12+ (recommended: Python 3.12)
- Git
- Bash or similar shell
[!WARNING] The project is mainly developed on Windows. Individual features may be restricted on macOS/Linux. However, macOS/Linux will be fully supported in future versions.
Step 1: Install Python
Windows
- Visit https://www.python.org/downloads/
- Download the latest Python 3.X (Windows x86-64)
- Important: In the installer, enable the option "Add Python to PATH"
- Click "Install Now"
Check: Open PowerShell and type:
python --version
You should see: Python 3.12.x (or your version)
macOS
brew install python@3.X
Linux (Ubuntu/Debian)
sudo apt update
sudo apt install python3.X python3.X-venv
Step 2: Install Git
Windows
- Visit https://git-scm.com/download/win or https://desktop.github.com/download/
- Download the installer
- Run the installer (default settings are OK)
Check:
git --version
macOS
brew install git
Linux
sudo apt install git
Step 3: Clone the Repository
The repository is your local project. You save your work there.
[!TIP] After cloning, check if the folder
tools/Java/and the filetools/server.jarexist. If not, you need to add them yourself (see README or project page for instructions).
There are two options:
Option 1: Clone with Git (recommended)
git clone https://github.com/TechnikLey/Streaming_Tool.git
cd Streaming_Tool
This takes a few seconds. After that you should have all files locally.
Advantage: You can download updates later with git pull.
Option 2: Download as a ZIP file
If you don't want to use Git:
-
Visit the repository: https://github.com/TechnikLey/Streaming_Tool
-
Click on the green "Code" button (top right)
-
Select "Download ZIP"
-
Unzip the ZIP file in a suitable location (e.g.
C:\Users\your_name\Streaming_Tool) -
Open PowerShell and navigate there:
cd C:\Users\your_name\Streaming_Tool
Note: With the ZIP method you have to manually download updates later (not ideal for development).
Step 4: Create a Python Virtual Environment
A virtual environment is like an isolated Python "container" for this project. This prevents conflicts with other Python projects on your system.
[!NOTE] Virtual environment is optional!
If you get started, get errors, or it's too complicated for you: you can also work directly without venv (see below). We recommend venv for more experienced developers, but it is not mandatory.
Option A: With virtual environment (recommended)
Windows
python -m venv venv
.\venv\Scripts\Activate.ps1
macOS / Linux
python3.12 -m venv venv
source venv/bin/activate
[!NOTE] If activation in PowerShell fails, first run:
Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser
Check: Your shell prompt should change to show (venv):
(venv) C:\Streaming_Tool>
This means you are in the virtual environment. ✓
Advantages:
- ✓ Clean isolation (no conflicts with other projects)
- ✓ Professional development
- ✓ Easy to uninstall (just delete the
venvfolder)
Disadvantages:
- ✗ A few extra steps during setup
Option B: Without virtual environment (faster but less clean)
If you want to skip venv, go directly to Step 5: Install dependencies.
Then run:
pip install -r requirements.txt
The packages will be installed globally on your system.
Advantages:
- ✓ Fast setup
- ✓ Less to understand
Disadvantages:
- ✗ Packages from different projects may conflict with each other
- ✗ Harder to uninstall/clean up
- ✗ Not ideal for multiple Python projects
Step 5: Install Dependencies
Now let's install all the Python packages the project needs:
[!NOTE] For Minecraft integration, you also need a Java runtime in
tools/Java/and the filetools/server.jar.
pip install --upgrade pip
pip install -r requirements.txt
This takes a few minutes. Example output:
Collecting TikTokLive==0.8.0
Using cached ...
Collecting Flask==3.0.0
Using cached ...
...
Successfully installed TikTokLive-0.8.0 Flask-3.0.0 ...
What will be installed?
- TikTokLive: The API for receiving TikTok events
- Flask: A web framework (for webhooks & GUIs)
- pywebview: For GUIs (desktop windows)
- PyYAML: For config files
- And more...
Step 6: Initialize Configuration
The system needs a config.yaml to start.
If it doesn't exist yet:
cp defaults/config.default.yaml config/config.yaml
This creates a default configuration.
Basic Settings
Open config/config.yaml in your editor (e.g. VS Code) and adjust:
# Your TikTok Live account name
tiktok_user: "your_tiktok_name"
# Ports for different modules
MinecraftServerAPI:
Enable: true
WebServerPort: 8888
Timer:
Enable: true
StartTime: 10
WinCounter:
Enable: true
DeathCounter:
Enable: true
[!TIP] You don't have to understand everything. The only important thing is:
tiktok_user: Your TikTok channel name- The ports must not be in use
Step 7: Create First Plugin (Optional)
If you want to quickly create a small test plugin:
# Windows
.\create_plugin.ps1
# macOS / Linux
bash create_plugin.ps1
The script will ask you for a plugin ID. Enter e.g. testplugin.
Afterwards you will find your plugin with boilerplate code under src/plugins/testplugin/.
Activating/Deactivating the Virtual Environment
Next Time (Reactivate Virtual Environment)
Windows:
.\venv\Scripts\Activate.ps1
macOS / Linux:
source venv/bin/activate
When You're Done (Exit Virtual Environment)
deactivate
Common Problems & Solutions
| Problem | Solution |
|---|---|
python: command not found | Python not in the PATH. Reinstall and enable "Add Python to PATH". |
ModuleNotFoundError: No module named 'TikTokLive' | pip install -r requirements.txt not yet run |
Port 8080 already in use | Another application is using the port. Choose a different port in config.yaml |
Permission denied (macOS/Linux) | Run chmod +x create_plugin.ps1 |
| venv does not activate (PowerShell) | Run Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser |
pip: The term 'pip' is not recognized as a name of a cmdlet, function, script file, or executable program. | Try python -m pip instead – this may help |
VS Code Setup (Recommended)
If you use VS Code (free, very popular), you can configure it like this:
-
Download VS Code: https://code.visualstudio.com/
-
Open the Streaming_Tool folder:
File → Open Folder -
Install these extensions:
- Python (Microsoft)
- Pylance (Microsoft)
-
Set the Python interpreter to your
venv:Ctrl+Shift+P→ "Python: Select Interpreter"- Choose
./venv/bin/python(or.\venv\Scripts\python.exeon Windows) - Alternatively, select Python directly if you don't use a
venv
That's it! Now you have syntax highlighting, autocomplete, and debugging.
[!TIP] If you encounter problems with any of the steps, check the Internet or YouTube for a solution.
Next Steps
You now have:
✓ Python installed
✓ The repository cloned
✓ Dependencies installed
✓ The config adjusted
✓ Basic tests completed
You're ready!
Next chapter: How the system works together
How the System Works Together
In this chapter you will understand the architecture of the streaming tool – i.e. how the individual components fit together and how data flows from TikTok to Minecraft.
The Big Picture: The Data Flow
The system works according to a clear, three-phase pattern:
┌─────────────────────────────────────────────────────────────────┐
│ THE 3 PHASES OF THE SYSTEM │
└─────────────────────────────────────────────────────────────────┘
Phase 1: RECEIVE
TikTok events
↓
TikTokLive API
↓
"User XY followed"
↓ ↓ ↓
Phase 2: PROCESS
Analyze event
↓
Filter and sort data
↓
"Is this a follow? From whom? When?"
↓ ↓ ↓
Phase 3: EXECUTE
Trigger action
↓
Send RCON command
↓
Minecraft executes
Each phase has a clear task:
| Phase | Task | Who does it? |
|---|---|---|
| 1. Receive | Collect data from TikTok servers | TikTokLive API (in our program) |
| 2. Process | Understand & document events | Python scripts analyze the raw data |
| 3. Execute | Send command to Minecraft | RCON via network connection |
Phase 1: Receive Data from TikTok
The problem: How do we even know when someone sends a gift on TikTok?
TikTok does not have an open official API for developers. That's why we use the TikTokLive library.
The program connects to TikTok's servers and listens.
As soon as an action happens (gift, follow, like), we receive a message. This message is structured as follows:
{
"Event": "Gift",
"From": "UserName",
"GiftID": "12345",
}
What happens after that? The data goes directly to the next phase → Process
[!NOTE] For beginners: Imagine you are reading a message. The text is not yet sorted – you must first understand who is writing, what is being written, and when it was written.
Phase 2: Process and Analyze Events
The problem: TikTok's raw data is unstructured. We have to classify and structure it before we can do anything with it.
The program takes the received event and asks:
- "What happened?" (Gift / Follow / Like / Etc.)
- "Who was it?" (username, user ID)
- "How much?" (number of gifts, size of gift)
- "What should happen now?" (Which Minecraft action does this trigger?)
The system categorizes events internally and stores important metadata. These are then placed in a queue so that they can be processed one after the other.
Why a queue?
If 100 viewers send gifts at the same time, not all events can go to Minecraft at once. That would overload the server. Instead, they are lined up and processed one after the other.
[!NOTE] For beginners: Think of the supermarket cashier. If 10 people come at the same time, you line up. One by one pays. The queue ensures order.
Phase 3: Send Data to Minecraft
The problem: How do we tell the Minecraft server what should happen?
Minecraft has a remote control system called RCON (Remote Console). This is like a remote control for the Minecraft server.
Through RCON we can send commands:
/say "Danke für das Gift!"/give @s diamond 5/function my_namespace:special_event
The program:
- Determines which command is necessary (based on the event)
- Sends it to Minecraft via RCON
- The Minecraft server executes the command
This all happens in real time – usually in milliseconds.
Connection: How Everything Fits Together
The most important thing: The 3 phases are interdependent:
(1) Receive → (2) Process → (3) Execute
↓ ↓ ↓
TikTok API Python logic RCON command
If one phase fails, the entire chain stops working:
| If phase fails | Consequence |
|---|---|
| 1 breaks | No events from TikTok → Nothing happens |
| 2 breaks | Events are not understood → Wrong action or none at all |
| 3 breaks | Command doesn't reach Minecraft → Game has no response |
Next Steps
Each of these 3 phases is explained in detail in this chapter:
- Receiving data from TikTok – How the API works
- Processing events – How the program analyzes data
- Sending data to Minecraft – How RCON works
Once you understand the basic idea, you can read the subsections to go deeper.
[!NOTE] Concepts: Basic ideas (events, queue, etc.) have already been briefly explained in Basic concepts & terms. This chapter shows the architecture.
Code & Details: How handlers work (
@client.on), howactions.mcais written, Control Methods (DCS/ICS), etc. – that comes in later chapters.
[!TIP] If the architecture isn't 100% clear yet: That's completely normal. Many concepts will become more concrete in the coming chapters.
Receiving Data from TikTok (Phase 1)
In this chapter you will understand how the system notices that something is happening on TikTok. This is the first component of the data chain.
The Problem: How Do We Listen to TikTok?
The challenge:
TikTok is a closed platform. There is no official, free API for streamers that delivers real-time events. We have to get creative.
So we use reverse engineering – we observe how the TikTok app itself communicates with the servers and build on that.
Solution: The TikTokLive API
We use the TikTokLive library (open source), which does exactly that: it imitates the TikTok app and receives events directly.
How Does TikTokLive Work?
TikTok server
↓
├─→ (sends events to millions of apps)
├─→ Official TikTok app
├─→ Other live tool apps
└─→ TikTokLive Library (our tool)
↓
We see the events LIVE
The library:
- Connects to TikTok servers (like the mobile app)
- Listens on the WebSocket stream
- Receives events (gifts, follows, likes) in real time
- Hands them over to our Python program
What Are Events?
An event is a structured message that TikTok sends:
Event type: "Gift"
User: "streamer_fan_123"
Gift count: 5
Gift value: 1000 coins
Each of these data points arrives. The program knows the structure and knows how to read it.
What Do We Connect With? WebSocket vs. HTTP
WebSocket (we use this)
┌─ Connection ──┐
│ open and │ Both sides can send
│ persistent │ data at any time. Perfect for real time.
└───────────────┘
Advantages:
- ✓ Real time (instant events)
- ✓ Efficient (always open, not constantly new connections)
- ✓ Bidirectional (we can also send data back)
Disadvantage:
- ✗ More complex than HTTP
HTTP (old alternative)
We ask: "Are there new events?"
Server: "Yes, here!"
We ask: "Are there new events?"
Server: "No"
We ask: "Are there new events?"
...
Problem: Constantly asking is inefficient. That's like constantly asking "Are you awake now?" instead of waiting for the person to call you.
The Internal Process: How Events Enter the Program
1. START: Program starts
↓
2. CONNECT: Program connects to TikTok via WebSocket
"Hello TikTok, it's your client"
↓
3. LISTEN: WebSocket remains open
Program awaits events
↓
4. EVENT ARRIVES: User sends a gift
TikTok sends: { "type": "gift", "user": "xyz", ... }
↓
5. EVENT RECEIVED: Our program accepts it
↓
6. EVENT FORWARDED: To Phase 2 (Processing)
Summary of Phase 1
What happens here:
- TikTokLive library connects to TikTok
- It receives events as structured data
- These are immediately forwarded to Phase 2
What you should know:
- Events come LIVE (WebSocket, not HTTP)
- An event has structure: type, user, data
- The concept of events has already been explained in Basic concepts & terms
What does not happen here:
- We do not analyze events (that is Phase 2)
- We don't send anything to Minecraft (that is Phase 3)
- We do not store events permanently
[!TIP] The TikTokLive library is not necessarily very reliable, but it is the best free option. You are welcome to look at alternatives yourself.
You will learn the code implementation of the TikTokLive library in Python in this project.
Next chapter: Processing events – Now let's see what the program does WITH the events.
Processing Events (Phase 2)
Now the raw data has arrived. But is it usable? In this chapter you will learn how the system "understands" events and prepares them for the next phase.
The Problem: Data Is Raw
From Phase 1 we get raw data from TikTok:
{
"type": "gift",
"user": { "id": 12345, "name": "xyz" },
"gift": { "id": 5, "count": 10, "repeatCount": 1 },
}
Questions we need to answer:
- ✓ "What kind of event is this?" (Gift / Follow / Like?)
- ✓ "Who does it come from?" (Which user?)
- ✓ "How much is it?" (Value, number, quantity?)
- ✓ "Is it important?" (Should the game react?)
- ✓ "What is the next action?" (Which command should be sent to Minecraft?)
Solution: The Event Processing System
Step 1: Classify
The program sorts the event into a category:
Raw data arrives
↓
"Is this a gift?"
↓
"Yes → This is the 'Gift' category"
↓
Event is registered as "GiftEvent"
The system knows different types:
| Type | Example |
|---|---|
| Gift | User sends 5x gifts |
| Follow | User follows the channel |
| Like | User likes a stream |
| Share | User shares the stream |
| Comment | User comments |
Step 2: Extract Data
The important information is extracted from the raw data:
Raw data: {
"type": "gift",
"user": { "id": 12345, "name": "streamer_fan_xyz", ... },
"gift": { "id": 5, "count": 3, ... }
}
↓ [EXTRACTED]
Structured data:
├─ Event type: "gift"
├─ User name: "streamer_fan_xyz"
├─ Gift count: 3
Only the data that is relevant is kept.
Step 3: Place in Queue
The problem: If 100 viewers send gifts at the same time, not all of them can go to Minecraft at once.
The solution: A queue – as already explained in Basic concepts.
The queue ensures that everything is processed in order – fairly and neatly.
The Internal Process: How Events Are Processed
1. EVENT ARRIVES from Phase 1
↓
2. CLASSIFY
"This is a gift"
↓
3. EXTRACT DATA
User = "xyz", count = 5
↓
4. ENQUEUE
Entry in the queue
↓
5. QUEUE PROCESSES
One event after another
↓
6. PASS TO PHASE 3
"Gift from xyz, 5x → Minecraft!"
Multiple Events at the Same Time (Concurrency)
The system has to handle many events at the same time. The queue is the solution.
The queue is usually very small – events are processed immediately. It is not storage, but a queue.
Special Filter: Not All Events Are Equal
The system can prioritize events – gifts are more important than likes, for example.
These priorities are set in the configuration, not in the code.
Error Scenarios: What Can Go Wrong?
| Problem | Consequence | Solution |
|---|---|---|
| Event structure unknown | Classification failed | Error log, event is discarded |
| Queue overflows | Memory problem (very rare) | Delete older events |
| Too many events per second | Backlog builds up | Minecraft takes longer to react |
| Event arrives damaged | Data parse error | Validation in the system, faulty events ignored |
Summary of Phase 2
What happens here:
- Raw events are classified
- Data is extracted and structured
- Events are placed in a queue
- The queue processes them one after the other
What you should know:
- The queue ensures order
- Multiple events are processed one after the other, not in parallel
What does not happen here:
- We do not write events to disk (optional)
- We don't send anything to Minecraft (next phase)
- We don't show anything in the GUI (done separately)
Next chapter: Sending data to Minecraft – Now it gets concrete: the command goes to the game!
Sending Data to Minecraft (Phase 3)
Now comes the action: the event is processed, and now Minecraft has to execute it. How does this communication work?
The Problem: How Do We Tell Minecraft What to Do?
Minecraft is a game on a server. We are a Python program. How do we communicate?
Options:
- ✗ Write directly into the game code? (Too complicated, thousands of lines of code required)
- ✗ Chat messages? (Doesn't work technically)
- ✓ Commands! (This is the solution)
Minecraft has a console where you can enter commands:
/say "Hallo Welt!"
/give @s diamond 5
/function my_namespace:special_event
/tp @a 100 64 200
We just need to run these commands remotely (from outside). That's what RCON is for.
Solution: RCON (Remote Console)
What Is RCON?
RCON = Remote Console – a kind of "remote control" for Minecraft servers.
┌─────────────────────────────┐
│ Our Python program │
│ "Run /say Thank you!" │
└──────────────┬──────────────┘
│ RCON command via network
↓
┌──────────────────────────────┐
│ Minecraft server │
│ Console receives command │
│ Executes /say │
│ Result: Chat shows "Thanks!"│
└──────────────────────────────┘
How RCON Works
-
Establish connection
Program → "I am admin, here is the password" Server → Authentication OK, connection open -
Send command
Program → "/say User XY followed!" Server → Command is executed (visible in game) -
Confirmation (optional)
Server → Short response (usually empty or "ok") Program → Command processed
[!NOTE] Simplified representation!
The above explanation is simplified for illustration. In reality:
- RCON does not send meaningful replies like "5 players received a message"
- The response is usually empty
- We don't know if anything was actually executed
- We don't know what error occurred if the command fails
This is a limitation of RCON. In practice: we send the command and hope it works.
RCON Uses TCP/IP
RCON uses the TCP/IP protocol – the same Internet protocol used by email, websites, etc.
Our program Network Minecraft server
(Port XYZ) ←------TCP/IP----→ (usually port 25575)
Important details:
- Default behavior: RCON normally connects for each command individually – open connection, send command, close connection.
- The streaming tool: Uses a persistent connection – the connection remains open. But this has to be set up specifically and is not the standard.
- Reliability: RCON is not guaranteed to be reliable. Commands can be lost and connections can drop. This is a known limitation.
[!WARNING] RCON has limitations:
- Not guaranteed reliable (commands may be lost)
- Persistent connections must be implemented manually
- No meaningful error responses
- When a command fails, we often don't know it
The streaming tool handles this through:
- Its own persistent connection management
- Logging and retry mechanisms
- Hoping it works
From Event to Minecraft Command
Example: User sends 5 gifts
1. PHASE 1: Event arrives
TikTok: "User 'Streamer_Fan_123' sent 5x gifts"
↓
2. PHASE 2: Event is processed
System: "This is a gift event, 5x"
Data extracted: User = "Streamer_Fan_123", Quantity = 5
Placed in queue → Waits for its turn
↓
3. PHASE 3: Command is sent to Minecraft
System: "Which command should be triggered for 5x gifts?"
(look up in `actions.mca`)
Command: /say "Thanks to Streamer_Fan_123 for 5x gifts!"
RCON: Send command to Minecraft
↓
4. RESULT: Minecraft executes
Server: Chat shows "Thanks to Streamer_Fan_123 for 5x gifts!"
All players see it
The Internal Process: How Commands Are Processed
1. TAKE EVENT FROM QUEUE
Gift event from Streamer_Fan_123
↓
2. LOOK UP ACTION
"What should happen when a gift is received?"
→ Check actions.mca file
→ Command: /say "Thanks for the gift!"
↓
3. CHECK RCON CONNECTION
Password: OK
Port 25575: Reachable
Server: Reachable
↓
4. SEND COMMAND
Command: /say "Thanks for the gift!"
↓
5. HOPE THE COMMAND ARRIVES
Server: ...
↓
6. LOGGING & COMPLETION
Log: "Gift event processed, command successful"
Event is done
Error Scenarios: What Can Go Wrong?
| Problem | Consequence | Solution |
|---|---|---|
| RCON server not reachable | Commands cannot be sent | Check Minecraft server, firewall settings |
| Incorrect password | Authentication failed | Check password in config.yaml |
| Wrong port | Connection fails | Default: 25575, check in config.yaml |
| Command syntactically incorrect | Minecraft rejects it | Check command in actions.mca |
| Too many commands per second | Minecraft can't handle them all | Backlog builds up, player sees delayed reaction |
| Minecraft server crashes | RCON connection breaks | Auto-reconnect after restart |
Summary of Phase 3
What happens here:
- Event is taken from the queue
- The matching Minecraft command is determined
- Command is sent to Minecraft via RCON
- Minecraft executes the command
- Commands are processed one after the other (not in parallel)
What does not happen here:
- We do not change the game code (that wouldn't work)
- We do not store events (that is a separate component)
- We do not show anything in the TikTok app (that is not possible)
[!NOTE] RCON is synchronous and blocking. Bottlenecks can occur with many commands per second. That's why some tools use Minecraft functions (.mcfunction) for batch operations. (The streaming tool also does this – we'll talk about it later.)
The Whole System Together
The 3 phases only work together:
Phase 1 Phase 2 Phase 3
RECEIVE → PROCESS → EXECUTE
↓ ↓ ↓
TikTokLive Queue system RCON commands
↓ ↓ ↓
Events in Sort events Minecraft reacts
If a phase fails:
- No Phase 1 → No events
- No Phase 2 → Commands are incorrect
- No Phase 3 → Minecraft does not respond
[!TIP] If you understand how these 3 phases are connected, you understand the whole system. You will learn the details (exactly how RCON works, how to write commands, etc.) in the next chapters.
Next chapter: Python in this project – Now let's look at how the code implements these 3 phases.
Create Your Own Plugin
[!WARNING] Currently all plugins use the
config.yaml. However, this will change in the future, as this file is only intended for the main program. The exact implementation will be introduced in future updates and this chapter will be adjusted accordingly. Keep this in mind and watch for changes.You can already create your own
config.yamlin the plugin folder and use it for settings. This saves you from having to make adjustments to the code later if the globalconfig.yamlcan no longer be used for plugins.
What Is a Plugin?
A plugin is an independent Python program that integrates with the streaming tool. Each plugin:
- Runs as a separate process (registered in the registry)
- Communicates via DCS (HTTP) with other modules
- Optionally has a GUI (ICS) with pywebview
- Is centrally configured in
config.yaml - Has access to events via webhook
Built-in plugins (examples):
- DeathCounter: Counts deaths, sends to Minecraft
- LikeGoal: Manages like goals
- Timer: Countdown timer
- WinCounter: Win counter
(All built-in plugins support ICS)
Plugin Lifecycle
1. Create plugin folder (with create_plugin.ps1) (plugins/myPlugin/)
↓
2. Write main.py (HTTP server, event handler)
↓
3. Register in registry.py (PLUGIN_REGISTRY)
↓
4. Edit config.yaml (user configuration)
↓
5. Load into start.py (start process)
↓
6. Events arrive via /webhook endpoint
↓
7. Plugin processes, sends commands
Roadmap of This Chapter
- Understand plugin structure (folders, files, config)
- Creating an HTTP server with Flask (receive events)
- Send Minecraft commands (RCON communication)
- Data storage & configuration (user data)
- GUI with pywebview (visual interface, optional)
- Communicate between plugins (HTTP + error handling)
- Best practices & error handling (production ready)
→ Start: Plugin structure & setup
- Logging in
logs/folder - Avoid typical mistakes
- Resource management (memory & thread leaks)
Programming Languages: Python or Something Else?
A plugin does not necessarily have to be written in Python. You can basically develop it in any programming language. You can find out more about this in the chapter Create plugin without Python.
But be careful: With Python you need about 20 lines of code for the base. With other languages (Java, C++, Rust, etc.) this can quickly add up to several hundred lines because you have to implement a lot of things yourself.
Additional programming languages may be directly supported in the future. However, Python remains the primary supported language: with the most help, integrations and regular updates.
Let's Go!
Are you ready? Then let's start with Plugin structure & setup.
After going through these chapters you will understand:
- How plugins are technically structured
- How events reach you and how you react to them
- How to manage configuration and data
- How to build GUIs with pywebview
- How plugins communicate with each other
- How to keep your plugin stable
The concrete implementation and creative use of these tools – that's your part!
Plugin Structure & Setup
Building a Plugin
Every plugin is an isolated Python program with a standard structure. Benefits:
- Boilerplate code already prepared
- Core modules for common tasks (config, logging, paths)
- Automatic registration in PLUGIN_REGISTRY
Folder Structure
Automatically created via PowerShell script (create_plugin.ps1):
src/plugins/
└── my_plugin/
├── main.py ← Plugin core
├── README.md
└── version.txt
Creating a Plugin: 2 Steps
If you use the PowerShell script create_plugin.ps1, it will ask you for the name of your plugin. It then automatically creates the complete folder structure for you. This then looks like this:
.
├── your_plugin_name
│ ├── main.py
│ ├── README.md
│ └── version.txt
The new folder will be created at src/plugins/ with the name you specified during creation.
The Individual Files
main.py – The Heart of Your Plugin
This is the most important file! This is where you write the actual logic of your plugin. If you create a plugin with create_plugin.ps1, you automatically get base code inserted. It looks something like this:
from core import load_config, parse_args, get_root_dir, get_base_dir, get_base_file, register_plugin, AppConfig
import sys
BASE_DIR = get_base_dir()
ROOT_DIR = get_root_dir()
CONFIG_FILE = ROOT_DIR / "config" / "config.yaml"
DATA_DIR = ROOT_DIR / "data"
MAIN_FILE = get_base_file()
args = parse_args()
cfg = load_config(CONFIG_FILE)
gui_hidden = args.gui_hidden
register_only = args.register_only
if register_only:
register_plugin(AppConfig(
name="test",
path=MAIN_FILE,
enable=True,
level=4,
ics=False
))
sys.exit(0)
[!TIP] If you want to use a
config.yamlfile directly in the plugin folder, replace:CONFIG_FILE = ROOT_DIR / "config" / "config.yaml"With this code:
CONFIG_FILE = BASE_DIR / "config.yaml" CONFIG_FILE.touch(exist_ok=True) # Creates the file if you haven't already done it yourself.
What Exactly Is Happening?
Imports
You import functions and classes from the core module. This saves you a lot of writing work:
load_config: Loads the configuration fileparse_args: Reads command-line argumentsget_root_dir,get_base_dir,get_base_file: Determine important directories and file pathsregister_plugin: Registers your pluginAppConfig: A class that stores the plugin configuration
Setting Up Important Paths
BASE_DIR = get_base_dir() # The base folder of the application
ROOT_DIR = get_root_dir() # The root path, two levels above BASE_DIR
CONFIG_FILE = ROOT_DIR / "config" / "config.yaml" # Path to configuration
DATA_DIR = ROOT_DIR / "data" # Folder for user data
MAIN_FILE = get_base_file() # The path to main.exe (main.py in the dev folder)
You will need these variables later in your code — for example to save files or load the config.
Reading Startup Arguments
args = parse_args()
gui_hidden = args.gui_hidden # Was the --gui-hidden flag set?
register_only = args.register_only # Was the --register-only flag set?
The program can start your plugin with certain flags:
--gui-hidden: The GUI is started hidden--register-only: The plugin is only registered but not executed
Registering the Plugin (if --register-only is set)
if register_only:
register_plugin(AppConfig(
name="test",
path=MAIN_FILE,
enable=True,
level=4,
ics=False
))
sys.exit(0)
If the plugin just needs to be registered, the following happens:
-
name: The name of your plugin (e.g. "test") -
path: The path to the executable file -
enable:True= Plugin is active,False= Plugin is deactivated
Tip: Instead of hardcodingTrue/False, you can also use config values:enable=cfg.get("custom_name", {}).get("enable", True)This is how users can turn your plugin on and off in the
config.yaml! -
level: Determines when the terminal is visible (depending on thelog_levelin theconfig.yaml):- Level 0: Disables everything (should not be used)
- Level 1: Terminal visible at
log_level: 1 - Level 2: Main programs (
log_level: 2) - Level 3: Background services (e.g. checks, listeners)
- Level 4: Debug/Development
- Level 5: Overrides other settings (should not be used)
-
ics: Interface Control System – indicates whether the GUI is supportedTrue= GUI is supportedFalse= GUI is NOT supported (Direct Control System / DCS)
After registration, the program ends with sys.exit(0).
[!WARNING] Plugin registration: order and time limit
The call to
register_plugin(...)must occur as early as possible in the program. Before registration, no executable code may be present — except:
- Imports
- Configuration and path definitions
- Argument parsing (e.g.
parse_args())Not allowed before registration:
- Logic with side effects
- Network access or file access
- Initializations with external dependencies
Background: The registration routine runs in a strictly limited environment and may otherwise fail.
Immediate exit required
After successfully calling
register_plugin(...), the program must be terminated immediately withsys.exit(0).if register_only: register_plugin(AppConfig( name="test", path=MAIN_FILE, enable=True, level=4, ics=False )) sys.exit(0)Without this immediate termination, you risk executing downstream code, which can corrupt or invalidate the registry.
Note the time limit
The registration process has a hard time limit of 5 seconds. If this is exceeded, the program is terminated externally.
Loading Configuration
cfg = load_config(CONFIG_FILE)
Here the config.yaml is loaded. It contains all the settings for your plugin. cfg is now a dictionary you can access:
# Example: Read out config value with default value
enable = cfg.get("custom_name", {}).get("enable", True)
This is what it should look like in the config.yaml:
custom_name:
enable: True
README.md – Document Your Plugin
This file is your chance to show other developers what your plugin does. Write here:
- What does the plugin do? – A short description
- Requirements – What requirements does the user have to meet?
- Configuration – What options are available in the
config.yaml? - Usage – How is the plugin used?
A good README makes things easier for yourself and others later!
version.txt – The Version Number
In this file you save the current version of your plugin. By default, when you create a new plugin it says:
v1.0.0
Important: Stick to this format! It follows the Semantic Versioning standard:
- v1.0.0 = Major.Minor.Patch
- Major: Breaking changes (big changes)
- Minor: New features (backwards compatible)
- Patch: Bug fixes
Examples:
- v1.0.0 → v1.0.1 (small bug fix)
- v1.0.1 → v1.1.0 (new feature added)
- v1.1.0 → v2.0.0 (major conversion, no longer compatible)
Plugins in Other Programming Languages
Can I also write my plugin in Java, C++, JavaScript etc.? Yes, but...
When you leave Python, you have to do a lot of things yourself that Python modules do for you. Just to give you an idea:
- Load config
- Read startup arguments
- Determine paths
- Register plugin
- Error handling
The basic structure can quickly require several hundred lines of code depending on the language — significantly more than the ~20 lines of Python above.
Rule of thumb: Python is the best place to start. If you need more performance later, you can always optimize performance-critical parts or create them in a different language.
Next chapter: Webhook events and Minecraft integration
Receive events: webhook system
Event data flow
When something happens in Minecraft (player dies, login, etc.), the game plugin sends an HTTP POST request to your plugin. That's called a Webhook.
Flow:
Minecraft Event (player_death)
↓
Minecraft plugin checks configServerAPI.yml
↓
Sends HTTP POST → http://localhost:PORT/webhook
↓
Your plugin (@app.route("/webhook")) receives
↓
Your plugin processes & responds
Available events
[!NOTE] A complete list of all events can be found in the
configServerAPI.ymlin the project. Here are a few examples:
player_deathplayer_respawnplayer_joinplayer_quitblock_breakentity_death
Webhook implementation: 3 steps
1. Start Flask server (in the main thread)
from flask import Flask, request
app = Flask(__name__)
def start_server():
app.run(host="127.0.0.1", port=8001, debug=False, threaded=True)
import threading
srv = threading.Thread(target=start_server, daemon=True)
srv.start()
2. Define /webhook endpoint
@app.route("/webhook", methods=['POST'])
def webhook():
try:
data = request.json
event_type = data.get("event")
if event_type == "player_death":
print(f"Player died: {data.get('player')}")
elif event_type == "block_break":
print(f"Block broken: {data.get('block')}")
return {"status": "ok"}, 200
except Exception as e:
print(f"Webhook error: {e}")
return {"status": "error"}, 500
3. Register in config.yaml
MyPlugin:
Enable: true
WebServerPort: 8001
and in the configServerAPI.yml:
urls:
- "http://localhost:7777/webhook"
- "http://localhost:7878/webhook"
- "http://localhost:7979/webhook"
- "http://localhost:8080/webhook"
- "http://localhost:8001/webhook" # Your webhook
Complete example: DeathCounter
from flask import Flask, request
import json
from pathlib import Path
app = Flask(__name__)
DATA_DIR = Path(".") / "data"
DEATHS_FILE = DATA_DIR / "deathcount.json"
# Load counter
if DEATHS_FILE.exists():
with open(DEATHS_FILE) as f:
death_count = json.load(f).get("count", 0)
else:
death_count = 0
@app.route("/")
def index():
return f"<h1>Death counter: {death_count}</h1>"
@app.route("/webhook", methods=['POST'])
def webhook():
global death_count
data = request.json
if data.get("event") == "player_death":
death_count += 1
# Save
with open(DEATHS_FILE, "w") as f:
json.dump({"count": death_count}, f)
print(f"[+] Deaths: {death_count}")
return {"status": "ok"}, 200
if __name__ == '__main__':
import threading
threading.Thread(target=lambda: app.run(port=8001), daemon=True).start()
input("Server is running. Enter to stop...")
Integrate webhook into your plugin
To receive webhooks, you need an HTTP server in your plugin. Flask is perfect for this:
from flask import Flask, request
app = Flask(__name__)
@app.route('/webhook', methods=['POST'])
def webhook():
event_data = request.json
event_type = event_data.get("event")
if event_type == "player_death":
print("Player has died!")
elif event_type == "player_respawn":
print("Player has respawned!")
return "OK", 200
That is the minimum. Your plugin must:
- Start Flask and listen on port X
- Provide the
/webhookendpoint - Process the POST request and react
Practical example: The timer
The timer plugin reacts to two events:
@app.route('/webhook', methods=['POST'])
def webhook():
ev = request.json.get("event")
if ev == "player_death":
window.evaluate_js("resetTimer(); setPaused(true);")
elif ev == "player_respawn":
window.evaluate_js("setPaused(false);")
return "OK"
If a player dies:
- Timer is reset
- Timer is paused
When a player respawns:
- Timer continues running
Understanding the event payload
When an event arrives, the request looks like this:
{
"load_type": "INGAME_GAMEPLAY",
"event": "player_death",
"message": "Player died from fall damage"
}
Depending on load_type you can program different behaviors:
INGAME_GAMEPLAY: The event is from the current gameSTARTUP: The event is at server startup- Others: see
configServerAPI.yml
Configure webhook URL
Your plugin must have a port setting in the config.yaml. The configServerAPI.yml then calls this URL:
# config.yaml
MinecraftServerAPI:
WebServerPortDeathCounter: 7979
WebServerPortTimer: 7878
The webhook URLs are then configured like this:
# configServerAPI.yml
webhooks:
urls:
- "http://localhost:7979/webhook" # DeathCounter
- "http://localhost:7878/webhook" # Timer
[!IMPORTANT] The port must be unique! No other plugin is allowed to use the same port.
Threading: Start Flask in the background
An important point: Your plugin continues to run after registration. If you call Flask's app.run() directly, it blocks everything afterwards.
The solution: Start Flask in a thread:
import threading
def start_flask_server():
app.run(host='127.0.0.1', port=7878, debug=False)
# In the main program:
flask_thread = threading.Thread(target=start_flask_server, daemon=True)
flask_thread.start()
# The rest of your code continues to run in parallel...
With daemon=True the thread will automatically end when your plugin exits.
Error handling
Everything doesn't always work. A few important points:
-
Webhook not working?
- Check that your port is set in
config.yaml - Check that the URL in
configServerAPI.ymlis correct - Look in your log file
- Check that your port is set in
-
Port already in use?
- Choose a different port or exit the other program
Summary
- Webhooks are HTTP POST requests from Minecraft to your plugin
- Flask makes implementation easy
- The
/webhookendpoint processes incoming events - The port must be synchronized in
config.yamlandconfigServerAPI.yml - Threading is important so that the Flask server doesn't block everything
Next chapter: Configuration and data storage
Configuration & data storage
Config + State separation
config.yaml (edited by user):
- Port numbers, enable flags, UI theme
- Human-readable YAML format
- Loaded when the plugin starts
DATA_DIR:
- Counter states, window positions, user data
- JSON format (structured)
- Remains available across updates
Architecture
config/
└── config.yaml
└── MyPlugin:
├── Enable: true
├── WebServerPort: 8001
└── Theme: "dark"
↓
[Plugin starts]
↓
data/
└── my_plugin_state.json
└── {
"counter": 42,
"window_x": 100,
"last_updated": 1234567890
}
Load config (3 steps)
Step 1: Open YAML
from pathlib import Path
import yaml
CONFIG_FILE = ROOT_DIR / "config" / "config.yaml"
# With error handling
try:
with open(CONFIG_FILE) as f:
config = yaml.safe_load(f) or {}
except Exception as e:
print(f"Config error: {e}")
config = {}
Step 2: Read out values with defaults
# With .get() you stay safe from KeyErrors
port = config.get("MyPlugin", {}).get("WebServerPort", 8001)
enabled = config.get("MyPlugin", {}).get("Enable", True)
theme = config.get("MyPlugin", {}).get("Theme", "light")
Step 3: Use in the plugin
if enabled:
app.run(port=port)
set_theme(theme)
Practical example: saving data
import json
from pathlib import Path
# Paths
DATA_DIR = ROOT_DIR / "data"
STATE_FILE = DATA_DIR / "myplugin_state.json"
# Load data (or default)
def load_state():
if STATE_FILE.exists():
with open(STATE_FILE) as f:
return json.load(f)
return {"counter": 0, "wins": 0}
# Save data
def save_state(state):
with open(STATE_FILE, "w") as f:
json.dump(state, f, indent=2)
# Usage
state = load_state()
state["counter"] += 1
save_state(state)
import yaml
from pathlib import Path
CONFIG_FILE = (ROOT_DIR / "config" / "config.yaml").resolve()
try:
with CONFIG_FILE.open("r", encoding="utf-8") as f:
cfg = yaml.safe_load(f) or {}
except Exception as e:
print(f"Config could not be loaded: {e}")
cfg = {}
# Read out value with default value
port = cfg.get("MyPlugin", {}).get("WebServerPort", 8000)
enable = cfg.get("MyPlugin", {}).get("Enable", True)
With the .get() method you can safely query values without your program crashing if the key is missing.
Data storage: Where do I store my data?
There are several options, depending on your use case:
1. DATA_DIR (recommended for plugin-specific data)
ROOT_DIR/
data/
window_state_timer.json
window_state_deathcounter.json
Usage: Persistent data such as counter readings, window positions, user preferences.
from pathlib import Path
DATA_DIR = ROOT_DIR / "data"
STATE_FILE = DATA_DIR / "my_plugin_state.json"
# Save data
import json
state = {"counter": 42, "width": 800}
with STATE_FILE.open("w") as f:
json.dump(state, f)
# Load data
if STATE_FILE.exists():
with STATE_FILE.open("r") as f:
state = json.load(f)
else:
state = {"counter": 0, "width": 800}
Advantage: Data folder is retained during updates.
2. In the plugin folder itself (alternative)
src/plugins/my_plugin/
main.py
README.md
version.txt
plugin_data.json ← save directly here
PLUGIN_DIR = get_base_dir()
MY_DATA_FILE = PLUGIN_DIR / "plugin_data.json"
3. runtime folder (not recommended)
build/release/core/runtime/
my_data.json
WARNING: The runtime folder is overwritten with every update! Use it only for temporary data that can be regenerated.
Practical example: Save window state
The Timer and DeathCounter save their window size/position:
# Load at startup
def load_win_size():
if STATE_FILE.exists():
try:
with STATE_FILE.open("r") as f:
return json.load(f)
except:
pass
return {"width": 400, "height": 200}
# Save when window changes
@app.route("/save_dims", methods=["POST"])
def save_dims():
with STATE_FILE.open("w") as f:
json.dump(request.json, f)
return "OK"
The front end calls /save_dims as soon as the user moves or enlarges the window.
YAML vs JSON
JSON: Faster to load, simple structure
import json
data = json.load(f)
YAML: Readable for humans, more complex structures
import yaml
data = yaml.safe_load(f)
Recommendation: Use JSON for plugin data (faster, fewer dependencies). YAML only for the main config.
Share data between plugins
Plugins can exchange files:
# Plugin A saves data
shared_data = {"wins": 10, "deaths": 3}
with (DATA_DIR / "shared_counter.json").open("w") as f:
json.dump(shared_data, f)
# Plugin B reads data
with (DATA_DIR / "shared_counter.json").open("r") as f:
data = json.load(f)
print(data["wins"])
But: Pay attention to race conditions! If both plugins write at the same time, data loss may occur. Use HTTP communication instead (see next chapter).
Summary
- Load config:
yaml.safe_load()fromconfig.yaml - Save data: JSON in
DATA_DIRfor persistence - Fallback values: Always use
.get()with defaults - Share: Via files (be careful of race conditions) or HTTP
- Runtime folder: Only for temporary data, will be overwritten
Next chapter: GUI with pywebview
GUI with Flask + pywebview
GUI = backend + frontend
Plugins with a user interface need two layers:
- Flask backend (Python) → HTTP server, processing, data
- HTML/CSS/JS frontend → User interface, visuals
pywebview: Opens a desktop window which then loads the Flask UI.
Architecture
┌───────────────────────────────┐
│ pywebview window │
│ ┌─────────────────────────┐ │
│ │ HTML/CSS/JavaScript │ │
│ │ (User sees this) │ │
│ └──────┬──────────────────┘ │
│ │ (HTTP GET/POST) │
│ ┌──────▼──────────────────┐ │
│ │ Flask Backend │ │
│ │ /api/status │ │
│ │ /webhook │ │
│ │ (data processing) │ │
│ └─────────────────────────┘ │
└───────────────────────────────┘
↓ localhost:PORT
Minimal GUI: Setup (3 steps)
Step 1: Define HTML as a string
HTML="""
<!DOCTYPE html>
<html>
<head>
<title>My Plugin</title>
<style>
body { background: #000; color: #0f0; font-size: 16px; }
#counter { font-size: 48px; text-align: center; }
button { padding: 10px 20px; margin: 10px; }
</style>
</head>
<body>
<h1>Counter: <span id="counter">0</span></h1>
<button onclick="increment()">+1</button>
<script>
let count = 0;
function increment() {
count++;
document.getElementById('counter').innerText = count;
fetch('/api/count', { method: 'POST',
body: JSON.stringify({value: count}),
headers: {'Content-Type': 'application/json'} });
}
</script>
</body>
</html>
"""
Step 2: Flask Route + HTML
from flask import Flask, render_template_string
app = Flask(__name__)
@app.route('/')
def index():
return render_template_string(HTML)
@app.route('/api/count', methods=['POST'])
def update_count():
data = request.json
print(f"Counter: {data.get('value')}")
return {"status": "ok"}
Step 3: Start pywebview
import webview
import threading
def start_server():
app.run(port=8001, debug=False)
# Start Flask in thread
flask_thread = threading.Thread(target=start_server, daemon=True)
flask_thread.start()
# pywebview opens window (shows localhost:8001)
webview.create_window('My Plugin', 'http://localhost:8001', width=600, height=400)
webview.start()
Complete example: Counter GUI
from flask import Flask, request, render_template_string
import webview
import threading
import json
from pathlib import Path
app = Flask(__name__)
DATA_DIR = Path("data")
DATA_DIR.mkdir(exist_ok=True)
state = {"counter": 0}
HTML_TEMPLATE = """
<html><head>
<title>Counter</title>
<style>
body { background: #222; color: #fff; font-family: Arial; text-align: center; padding: 20px; }
#display { font-size: 72px; font-weight: bold; margin: 20px 0; }
button { padding: 15px 30px; font-size: 18px; cursor: pointer; }
</style>
</head><body>
<h1>Counter GUI</h1>
<div id="display">0</div>
<button onclick="send('/inc')">Increment</button>
<button onclick="send('/dec')">Decrement</button>
<script>
function send(path) {
fetch(path).then(r => r.json()).then(d => {
document.getElementById('display').innerText = d.value;
});
}
setInterval(() => send('/get'), 500); // Sync every 500ms
</script>
</body></html>
"""
@app.route('/')
def index():
return render_template_string(HTML_TEMPLATE)
@app.route('/get')
def get_value():
return {"value": state["counter"]}
@app.route('/inc')
def increment():
state["counter"] += 1
save_state()
return {"value": state["counter"]}
@app.route('/dec')
def decrement():
state["counter"] = max(0, state["counter"] - 1)
save_state()
return {"value": state["counter"]}
def save_state():
with open(DATA_DIR / "counter.json", "w") as f:
json.dump(state, f)
if __name__ == '__main__':
# Flask in thread
threading.Thread(target=lambda: app.run(port=8001), daemon=True).start()
# pywebview
webview.create_window('Counter', 'http://localhost:8001', width=400, height=300)
webview.start()
┌─────────────────────────────┐
│ pywebview window │
│ (HTML/CSS/JS frontend) │
└──────────────┬──────────────┘
│ (JavaScript Bridge)
│
┌──────────────▼──────────────┐
│ Flask or FastAPI │
│ (Python backend) │
└─────────────────────────────┘
Open simple window
import webview
import threading
HTML="""
<html>
<body style="background: #000; color: #0f0; font-size: 24px;">
<h1>My Plugin</h1>
<p>This is a simple GUI</p>
</body>
</html>
"""
def start_gui():
webview.create_window('My Plugin', html=HTML)
webview.start()
# In the main program:
gui_thread = threading.Thread(target=start_gui, daemon=True)
gui_thread.start()
Combining Flask + pywebview
Most plugins combine Flask (for REST endpoints) and pywebview (for the GUI). The frontend and backend can then communicate:
from flask import Flask, render_template_string
import webview
import threading
app = Flask(__name__)
HTML_TEMPLATE = """
<!DOCTYPE html>
<html>
<head>
<style>
body { background: #000; color: #0f0; margin: 0; }
#counter { font-size: 72px; text-align: center; }
button { padding: 10px 20px; margin: 10px; }
</style>
</head>
<body>
<h1>Counter</h1>
<div id="counter">0</div>
<button onclick="add()">+1</button>
<script>
let count = 0;
function add() {
count++;
document.getElementById('counter').innerText = count;
fetch('/api/count?value=' + count);
}
</script>
</body>
</html>
"""
@app.route('/')
def index():
return render_template_string(HTML_TEMPLATE)
@app.route('/api/count')
def update_count():
value = request.args.get('value')
# Save or process the new value
return "OK"
def start_flask():
app.run(host='127.0.0.1', port=7777, debug=False)
def start_gui():
flask_thread = threading.Thread(target=start_flask, daemon=True)
flask_thread.start()
webview.create_window('Counter Plugin', 'http://127.0.0.1:7777')
webview.start()
# In the main program:
gui_thread = threading.Thread(target=start_gui, daemon=True)
gui_thread.start()
Practical example: DeathCounter
The DeathCounter shows the number of deaths in real time. This works via Server Sent Events (SSE):
@app.route('/stream')
def stream():
def event_stream():
while True:
yield f'data: {{"deaths": {death_manager.count}}}\n\n'
time.sleep(0.5)
return Response(stream(), mimetype='text/event-stream')
The frontend subscribes to the stream:
const es = new EventSource("/stream");
es.onmessage = (e) => {
const deaths = JSON.parse(e.data).deaths;
document.getElementById('counter').innerText = deaths;
};
So the GUI will automatically update when the number changes.
Save window position and size
Users like it when their windows appear in the same position again:
import json
STATE_FILE = DATA_DIR / "window_state.json"
def load_win_size():
if STATE_FILE.exists():
try:
with STATE_FILE.open("r") as f:
return json.load(f)
except:
pass
return {"x": 100, "y": 100, "width": 600, "height": 400}
@app.route('/save_dims', methods=['POST'])
def save_dims():
data = request.json
with STATE_FILE.open("w") as f:
json.dump(data, f)
return "OK"
The frontend saves after each size change:
window.addEventListener('resize', () => {
fetch('/save_dims', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
width: window.innerWidth,
height: window.innerHeight
})
});
});
Python ↔ JavaScript communication
With pywebview you can also call Python functions directly from JavaScript:
api = webview.api
class API:
def set_brightness(self, level):
print(f"Brightness set to {level}")
return f"OK: {level}"
webview.create_window('Plugin', 'index.html', js_api=API())
In the frontend:
async function changeBrightness() {
const result = await pywebview.api.set_brightness(50);
console.log(result); // "OK: 50"
}
CSS for streaming overlays
If your plugin is embedded in OBS (browser source), you need special CSS:
/* Transparent background */
body {
background: transparent !important;
margin: 0;
padding: 0;
}
/* Font for high resolutions */
* {
font-family: 'Inter', 'Segoe UI', sans-serif;
-webkit-font-smoothing: antialiased;
}
/* No frames/scrollbars */
::-webkit-scrollbar {
display: none;
}
Summary
- pywebview: GUI with HTML/CSS/JavaScript + Python
- Flask: REST API for frontend-backend communication
- Server Sent Events: For real-time updates from the backend
- Persist state: Save window position and size
- Threading: GUI runs in a separate thread
Next chapter: Plugins communicate with each other
Internal plugin communication
Plugins talk to each other
Plugins run in parallel. Sometimes one needs data from another:
- Timer asks DeathCounter: "How many deaths?"
- WinCounter triggers Timer: "Reset now"
Communication channels:
- HTTP requests (clean, async ready) RECOMMENDED
- File sharing (simple, but race conditions)
- WebSockets (real time, complex)
HTTP pattern: client-server
┌──────────────┐ POST /api/action ┌──────────────┐
│ Plugin A ├────────────────> │ Plugin B │
│ (Client) │ {action: "add"} │ (Server) │
│ Port 8001 │ │ Port 8002 │
│ │<─────────────────┤ │
│ │ {status: ok} │ │
└──────────────┘ └──────────────┘
HTTP request: 3 steps
Step 1: Server Plugin (WinCounter)
Scenario: Timer calls WinCounter
WinCounter (Server):
@app.route('/add', methods=['POST'])
@app.route('/add', methods=['GET'])
def add_wins():
amount = request.args.get('amount', 1, type=int)
global win_count
win_count += amount
return json.dumps({"wins": win_count})
Timer (Client):
import requests
WIN_PORT = cfg.get("WinCounter", {}).get("WebServerPort", 8080)
WIN_URL = f"http://localhost:{WIN_PORT}/add?amount=1"
try:
response = requests.post(WIN_URL, timeout=3)
if response.status_code == 200:
print("Win added!")
except requests.exceptions.Timeout:
print("WinCounter is not responding")
except Exception as e:
print(f"Error: {e}")
Important points
Define ports in config.yaml:
WinCounter:
Enable: true
WebServerPort: 8080
Timer:
Enable: true
WebServerPortTimer: 7878
Set timeout: If the other plugin doesn't load, you don't have to wait forever.
Error handling: The other plugin can be offline.
2. File-based communication
Plugins can exchange information via shared files – e.g. a JSON file with current data.
# Plugin A writes:
data = {"total_wins": 42, "timestamp": time.time()}
with (DATA_DIR / "shared_state.json").open("w") as f:
json.dump(data, f)
# Plugin B reads:
if (DATA_DIR / "shared_state.json").exists():
with (DATA_DIR / "shared_state.json").open("r") as f:
data = json.load(f)
print(data["total_wins"])
Advantage: Simple, no network dependencies.
Disadvantage: Race conditions possible! If both plugins write at the same time, a change will be lost.
Best practices: Only for rarely written data or read-only access.
3. WebSockets (for real-time communication)
If real-time data is required, plugins can communicate via WebSockets. But that is more complex.
# With python socketio
from socketio import Server
sio = Server(async_mode='threading')
@sio.event
def send_update(data):
print(f"Receive data: {data}")
# In another plugin:
import socketio
sio_client = socketio.Client()
sio_client.connect('http://localhost:9000')
sio_client.emit('send_update', {'kills': 5})
When to use? Only if true real-time synchronization is important.
4. Webhook communication
A plugin can notify another plugin of certain events by calling its webhook.
# Plugin A sends event to Plugin B:
requests.post("http://localhost:7777/webhook", json={
"event": "custom_event",
"data": {"some": "data"}
})
# Plugin B receives:
@app.route('/webhook', methods=['POST'])
def webhook():
event = request.json.get("event")
if event == "custom_event":
handle_custom_event(request.json.get("data"))
return "OK"
Advantage: Asynchronous, flexible. Disadvantage: More complex to debug.
Best Practice Patterns
Pattern 1: Request-Response (synchronous)
# Client waits for response
try:
r = requests.get(f"http://localhost:8080/stats", timeout=2)
stats = r.json()
except:
stats = {} # Fallback
Benefit: Simple, synchronous queries (counter readings, status, etc.)
Pattern 2: Fire-and-Forget (asynchronous)
# Client sends, does not wait for response
threading.Thread(
target=requests.post,
args=(f"http://localhost:8080/trigger", ),
daemon=True
).start()
Benefit: If the answer doesn't matter (e.g. trigger events).
Pattern 3: Polling (query regularly)
def poll_other_plugin():
while running:
try:
r = requests.get(f"http://localhost:8080/status")
process_status(r.json())
except:
pass
time.sleep(5) # Query every 5 seconds
threading.Thread(target=poll_other_plugin, daemon=True).start()
Benefit: Regular, non-permanent synchronization.
Error handling best practices
import requests
def call_other_plugin(url, data=None, timeout=3):
try:
if data:
r = requests.post(url, json=data, timeout=timeout)
else:
r = requests.get(url, timeout=timeout)
if r.status_code == 200:
return r.json()
else:
print(f"Server responded with {r.status_code}")
except requests.exceptions.ConnectTimeout:
print(f"Timeout: {url} not responding")
except requests.exceptions.ConnectionError:
print(f"Connection Error: {url} not reachable")
except Exception as e:
print(f"Unexpected error: {e}")
return None # Fallback on error
Summary
- HTTP requests: Standard for plugin communication (synchronous, reliable)
- Files: For persistent data, but beware of race conditions
- WebSockets: Only if true real-time sync is necessary
- Error handling: Always set timeout and catch errors
- Ports in config.yaml: Define centrally, don't hardcode
Next chapter: Error handling and best practices
Error Handling & Best Practices
You are responsible for yourself
[!WARNING] The main system does NOT take care of errors in your plugin.
Main streaming tool
├─ Plugin A (crashes → dead forever)
├─ Plugin B (runs normally)
└─ Plugin C (hangs → dark forever)
Consequence: Your plugin must have 100% own error handling.
Error handling strategies
| Phase | Error | Handling |
|---|---|---|
| Startup | Config is missing | Use defaults + log |
| Flask Server | Port already in use | Alternative port + error message |
| HTTP requests | Timeout/Connection | Retry logic + fallback |
| File I/O | Permission denied | Try-except + logging |
| Unknown | ??? | Global try-except + log + exit |
Error handling: layered model
Layer 1: Startup Protection
import sys
import logging
logging.basicConfig(
level=logging.INFO,
filename="logs/plugin.log",
format='%(asctime)s [%(levelname)s] %(message)s'
)
try:
# Load config
cfg = load_config()
except Exception as e:
logging.critical(f"Config error: {e}")
cfg = {} # Defaults!
try:
# Start Flask
threading.Thread(target=lambda: app.run(port=cfg.get("port", 8001)),
daemon=True).start()
except Exception as e:
logging.critical(f"Flask error: {e}")
sys.exit(1)
Layer 2: Route Protection
@app.route("/webhook", methods=['POST'])
def webhook():
try:
data = request.json
# Your logic
return {"status": "ok"}, 200
except Exception as e:
logging.error(f"Webhook error: {e}", exc_info=True)
return {"status": "error", "message": str(e)}, 500
@app.route("/api/add", methods=['POST'])
def add():
try:
amount = request.json.get("amount", 1)
# Logic
return {"result": result}
except Exception as e:
logging.error(f"Add error: {e}")
return {"status": "error"}, 500
Layer 3: Global Wrapper
def main():
try:
# Everything your plugin does
threading.Thread(target=start_flask, daemon=True).start()
webview.create_window(...)
webview.start()
except KeyboardInterrupt:
logging.info("Plugin stopped by user")
except Exception as e:
logging.critical(f"Plugin crashed: {e}", exc_info=True)
sys.exit(1)
if __name__ == '__main__':
main()
Logging Best Practices
import logging
from pathlib import Path
# Setup
LOGS_DIR = ROOT_DIR / "logs"
LOGS_DIR.mkdir(exist_ok=True)
logging.basicConfig(
level=logging.DEBUG,
handlers=[
logging.FileHandler(LOGS_DIR / "myplugin.log"),
logging.StreamHandler() # Also console
],
format='%(asctime)s [%(levelname)s] %(name)s: %(message)s'
)
logger = logging.getLogger(__name__)
# Usage:
logger.debug("Debug info for developers")
logger.info("Plugin started successfully")
logger.warning("Config missing, using default")
logger.error("HTTP request failed", exc_info=True)
logger.critical("Plugin cannot recover from this error!")
Common Errors + Fixes
| Error | Cause | Fix |
|---|---|---|
| Port already in use | Port 8001 occupied | Alternative port in config.yaml |
| Connection refused | Other plugin offline | try-except + fallback |
| Timeout | Request too slow | timeout=5 increase |
| JSON decode error | Malformed response | json.JSONDecodeError catch |
| FileNotFoundError | Config file is missing | .exists() check before reading |
Plugin Ready Checklist
- ☑ Global try-except wrapper around main()
- ☑ Logging to file + console
- ☑ All config keys with
.get()+ defaults - ☑ HTTP requests in threads
- ☑ HTTP requests with timeout + try-except
- ☑ Check all files with
.exists() - ☑ Graceful shutdown with Ctrl+C
Congratulations! You now know everything you need to build a production-ready plugin.
┌──────────────────────────────────────────────┐
│ Main Streaming Tool │
│ (does NOT care about crashes) │
└────────────┬─────────────────────────────────┘
│
│── Plugin A (crashes → ignored)
│── Plugin B (runs normally)
│── Plugin C (hangs → ignored)
If your plugin crashes, it is gone. The system does not restore it.
Global error handling
Wrap your entire main logic in try-except:
import logging
logging.basicConfig(
level=logging.INFO,
filename=ROOT_DIR / "logs" / "my_plugin.log",
format='%(asctime)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger(__name__)
try:
# Your entire plugin logic here
flask_thread = threading.Thread(target=start_flask)
flask_thread.start()
webview.create_window('Plugin', 'http://127.0.0.1:7777')
webview.start()
except Exception as e:
logger.critical(f"Plugin crashed: {e}", exc_info=True)
sys.exit(1)
This logs the error and exits the plugin cleanly.
Logging – your best friend
Logging is essential for debugging. Use the logs/ folder:
import logging
from pathlib import Path
LOGS_DIR = ROOT_DIR / "logs"
LOGS_DIR.mkdir(exist_ok=True)
logging.basicConfig(
level=logging.DEBUG,
handlers=[
logging.FileHandler(LOGS_DIR / "plugin.log"),
logging.StreamHandler() # Also in console
],
format='%(asctime)s [%(levelname)s] %(message)s'
)
logger = logging.getLogger(__name__)
# Usage:
logger.info("Plugin started")
logger.warning("This could be problematic")
logger.error("Critical error occurred")
logger.debug("Debug information")
Understanding log levels
# In your config.yaml for the main system
log_level: 2
# Your plugin:
# Level 1: ERROR/CRITICAL
# Level 2: WARNING
# Level 3: INFO
# Level 4: DEBUG
With level=4 your debug output is visible when registering,
as soon as log_level: 4 is set.
The log_level must be >= your registered level for the
terminal to be displayed.
Avoid typical mistakes
1. Hard-coded paths
WRONG:
cfg_file = "C:\\Users\\Admin\\Documents\\config.yaml"
CORRECT:
cfg_file = ROOT_DIR / "config" / "config.yaml"
Always use get_root_dir(), get_base_dir(), etc.
2. Encoding error when reading files
WRONG:
with open(cfg_file) as f: # Default encoding can be different
data = yaml.safe_load(f)
CORRECT:
with open(cfg_file, "r", encoding="utf-8") as f:
data = yaml.safe_load(f)
3. Blocking operations in the main loop
If you do a long operation (network request, file processing), everything after that blocks:
# WRONG:
requests.get("http://API.com/data") # May take 10 seconds
app.run() # Flask only starts after the request
CORRECT:
# Start in thread
def fetch_data():
requests.get("http://API.com/data")
threading.Thread(target=fetch_data, daemon=True).start()
app.run() # Flask runs in parallel
4. Not checking for configuration errors
# WRONG:
port = cfg["MyPlugin"]["port"] # KeyError if not present!
# CORRECT:
port = cfg.get("MyPlugin", {}).get("port", 8000) # With default
5. Race conditions for file access
# WRONG - two plugins write at the same time:
with STATE_FILE.open("w") as f:
json.dump(data, f)
# BETTER - Temporary file + rename:
import tempfile
with tempfile.NamedTemporaryFile(mode="w", dir=DATA_DIR, delete=False) as tmp:
json.dump(data, tmp)
tmp.flush()
tmp_path = tmp.name
import shutil
shutil.move(tmp_path, STATE_FILE) # Atomic operation
6. Forgotten timeout for network requests
# WRONG - hangs forever:
response = requests.get("http://localhost:9999")
# CORRECT:
try:
response = requests.get("http://localhost:9999", timeout=3)
except requests.Timeout:
logger.error("Request timed out")
Graceful Shutdown
If the user enters "exit" in the start file, your plugin will be terminated with SIGTERM. Use this:
import signal
def handle_shutdown(sig, frame):
logger.info("Plugin is closing...")
# Cleanup here
sys.exit(0)
signal.signal(signal.SIGTERM, handle_shutdown)
signal.signal(signal.SIGINT, handle_shutdown)
Monitoring and health checks
If other plugins communicate with you, it can be beneficial to have
a health check available:
@app.route('/health')
def health():
return json.dumps({"status": "ok", "version": "1.0.0"})
Other plugins can check whether you are still running:
try:
r = requests.get("http://localhost:7878/health", timeout=1)
if r.status_code == 200:
print("Plugin is running")
except:
print("Plugin not available")
Resource management
Avoid memory leaks
# WRONG - infinite list:
all_events = []
@app.route('/webhook', methods=['POST'])
def webhook():
all_events.append(request.json) # Getting bigger and bigger!
# CORRECT - limited queue:
from collections import deque
events = deque(maxlen=1000) # Max 1000 entries
@app.route('/webhook', methods=['POST'])
def webhook():
events.append(request.json) # Oldest entries are automatically deleted
Avoid thread leaks
# WRONG - new threads for each request:
@app.route('/process', methods=['POST'])
def process():
threading.Thread(target=heavy_work).start() # Memory leak!
# CORRECT - Thread Pool:
from concurrent.futures import ThreadPoolExecutor
executor = ThreadPoolExecutor(max_workers=5)
@app.route('/process', methods=['POST'])
def process():
executor.submit(heavy_work) # Max 5 parallel tasks
Testing before release
# test_plugin.py
import requests
import time
def test_basic():
# Plugin should run on port 7878
r = requests.get("http://localhost:7878/health")
assert r.status_code == 200
def test_webhook():
r = requests.post("http://localhost:7878/webhook", json={
"event": "player_death",
"message": "Test"
})
assert r.status_code == 200
time.sleep(1)
# Check if effect is visible...
if __name__ == "__main__":
test_basic()
test_webhook()
print("All tests passed!")
Then start tests:
python test_plugin.py
[!TIP] It is recommended to build the project with the
build.ps1script and then test it in thereleasefolder because certain plugins/main program dependencies are only properly present in the release folder.
Checklist before release
- Global try-except around main logic
- Logging at critical/error/info level
- All config accesses with
.get()+ fallback - Timeouts for all network requests
/healthendpoint- Paths with
get_root_dir()etc., not hardcoded - Encoding utf-8 for file operations
- Threading instead of blocking ops in main
- Memory + thread leaks minimized
- README documented and up to date
Summary
- Your plugin is alone – No automatic crash handling
- Logging: Use the
logs/folder, save debug info - Error handling: Try-except global + on every network op
- Timeouts: Always set for requests
- Threading: Outsource blocking ops to threads
- Testing: Test manually/automatically before release
That's it for the basics! From here on out, it's all about your creativity and the specific needs of your plugin. Good luck!
Mapping Between Events and Minecraft
The Big Mystery: How Does TikTok Connect to Minecraft?
You now know how events are processed in Python. But:
How does the program tell Minecraft what to do?
An event handler queued an action, but: What is an action? How does it become a Minecraft command?
The answer: A mapping system that translates events into Minecraft commands.
TikTok Event (Gift, Follow, Like)
↓
Event handler
↓
Queue: ("GIFT_ROSE", "anna_xyz")
↓
MAPPING SYSTEM (this chapter!)
↓
Run Minecraft Command
↓
Something happens in the game!
The Mapping Visualized
The mapping works like a large reference book:
TRIGGER (from event) → MINECRAFT COMMAND
─────────────────────────────────────────────────────
"GIFT_ROSE" → /give @s rose
"GIFT_DIAMOND" → /give @s diamond
"FOLLOW" → /say Welcome!
"LIKE_GOAL_100" → /summon firework_rocket
The file actions.mca: This is our mapping table! It defines what happens when a trigger arrives.
The Complete Process (Overview)
When someone follows you on TikTok, this happens:
1. TikTok sends: FollowEvent
2. Python handler: on_follow() called
3. Handler queues: ("FOLLOW", username)
4. Worker thread: `for trigger, user in trigger_queue.get():`
5. Worker thread: `action = ACTIONS_MAP["FOLLOW"]`
6. Worker thread: `send_command_to_minecraft(action)`
7. RCON protocol: Command is sent (via network)
8. Minecraft server: `/say Welcome, anna_xyz!`
9. **Result:** The message appears in the chat
This process can take < 100ms for a follow! From TikTok to Minecraft!
Important to Understand
3 things together make up the system:
- actions.mca – The file with all mappings (static)
- Code in main.py – Reads the file at startup
- RCON protocol – Sends commands to Minecraft (network)
Why this separation?
- ✓ Users can edit
actions.mcawithout changing code - ✓ Errors in the file are detected at startup
- ✓ Commands can be generated dynamically
After This Chapter You Will Understand:
- How a
.mcafile is structured - Why the validator is important
- How to add your own commands
- Why RCON is "insecure" (and why that's OK)
- When you need .mcfunction files
Continue to: → The actions.mca file
The File actions.mca
[!NOTE] In this and other files we use the term "plugin". There are two meanings: plugins for Minecraft and plugins for the streaming tool. Which plugin is meant depends on the context.
What Is actions.mca?
The actions.mca is a simple text file that specifies what happens in Minecraft when a specific TikTok event arrives. Each line is a mapping from a trigger to one or more commands:
TRIGGER:<TYPE>COMMAND xNUMBER
- TRIGGER = name or ID (e.g.
follow,8913) - TYPE = prefix:
/(vanilla),!(Plugin/Custom),$(special function) - COMMAND = The command to execute
- xNUMBER = Optional: Repeat command N times
You can find the full syntax reference in the next chapter → Syntax & Commands
Where Is the File?
| Path | Purpose |
|---|---|
defaults/actions.mca | Template with example mappings |
data/actions.mca | Actually loaded and used |
On first startup, defaults/actions.mca is copied to data/actions.mca. From then on, only data/actions.mca is used.
Validation: Errors Are Detected Early
At startup, generate_datapack() in main.py parses every line of the actions.mca:
# main.py - generate_datapack():
for line_num, original_line in enumerate(f, 1):
line = original_line.split("#", 1)[0].strip() # Remove comments
if not line or ":" not in line:
continue
trigger, full_cmd_line = map(str.strip, line.split(":", 1))
# ...
if not cmd.startswith(("/", "!", "$")):
print(f"[ERROR] Invalid command without prefix on line {line_num}: {cmd}")
Every command needs a prefix (/, ! or $). Lines without a valid prefix are skipped at startup with an error message.
A Real Example
From defaults/actions.mca (abbreviated):
# Basic Events
follow:/give @a minecraft:golden_apple 7
like_2:/clear @a *; /kill @a
likes:/execute at @a run summon minecraft:creeper ~ ~ ~ x2
# Gifts (TikTok gift IDs)
5655:!tnt 2 0.1 2 Notch
16111:/give @a minecraft:diamond
5487:/give @a minecraft:totem_of_undying
# Special ($random randomly selects another trigger)
16071:$random
# Complex (multiple command types mixed)
11046:/clear @a *; /execute at @a run summon minecraft:wither ~ ~ ~ x20; !tnt 20 0.1 2 Notch
What happens here:
follow→ Golden apples for all playerslike_2→ Empty inventory + kill all playerslikes→ 2 Creepers spawn (vanilla withx2)16071→ Random trigger ($random)11046→ Three commands in a row: Clear, 20 Wither, TNT
The Process: From Event to Minecraft Command
Event handler:
trigger_queue.put_nowait(
("follow", "anna_123")
) ← TRIGGER in queue!
↓
Worker thread:
trigger, user = trigger_queue.get()
→ "follow" is in valid_functions ← Trigger recognized!
↓
Datapack / RCON:
follow.mcfunction contains:
give @a minecraft:golden_apple 7 ← Execution!
↓
RESULT: Everyone gets golden apples
Summary
| Concept | Explanation |
|---|---|
| Format | TRIGGER:<TYPE>COMMAND xNUMBER |
| Files | defaults/actions.mca (template) → data/actions.mca (active) |
| Validation | generate_datapack() checks syntax at startup |
| Process | Event → Queue → Worker → RCON/Datapack → Minecraft |
→ Next chapter: Syntax & Commands
Syntax & Commands
Structure of a Line
TRIGGER:<TYPE>COMMAND xNUMBER
trigger_name:<type>command xnumber
│ │ │ │
│ │ │ └─→ Repeat (optional)
│ │ └───────→ The actual command
│ └───────────→ Prefix: /! or $
└─────────────→ The name that event handlers use
Every part is important:
| Part | Meaning | Example |
|---|---|---|
| TRIGGER | Unique name or ID | 8913, follow, likes |
| : | Separator | : (always required) |
| <TYPE> | Type of command | /, !, $ |
| COMMAND | What should happen? | give @a diamond, tnt 2 0.1 2 |
| xNUMBER | How often? (Optional) | x3, x10 (without x = 1×) |
Practical Examples with Explanations
Example 1: Simple gift with repetition
8913:/execute at @a run summon minecraft:evoker ~ ~ ~ x3
- 8913 = Gift ID (evoker gift)
- : = Separator
- / = Vanilla Minecraft Command
- execute at @a run summon minecraft:evoker ~ ~ ~ = What should happen
- x3 = This action 3 times in a row
Result: The command is executed 3 times = 3 Evokers spawn!
Example 2: Follow without repeat
follow:/give @a minecraft:golden_apple 7
- follow = Special trigger for follow events
- : = Separator
- / = Vanilla Command
- give @a minecraft:golden_apple 7 = What should happen
- (no x) = Only execute once
Result: All players receive 1x 7 golden apples.
Example 3: Custom Command (Plugin)
6267:!tnt 600 0.1 2 Notch
- 6267 = Gift ID (TNT gift)
- : = Separator
- ! = Custom/Plugin Command (not vanilla)
- tnt 600 0.1 2 Notch = What should happen (custom syntax!)
- (no x) = Execute 1x
Result: Plugin command !tnt is executed.
Example 4: Special function
16071:$random
- 16071 = Gift ID
- : = Separator
- $ = Special function (not a normal command!)
- random = Randomly choose another trigger
- (no x) = Execute 1x
Example 5: Overlay Output (On-Screen Text)
follow: >>New Follower!|{user} is now following you!|5
- follow = Special trigger for follow events
- : = Separator
- >> = Overlay output (text is shown on stream, not a Minecraft command!)
- New Follower!|{user} is now following you!|5 = Text, subtitle, and duration (seconds), separated by |
- x is not supported here
Result:
An overlay appears on stream with the text New Follower! and the subtitle {user} is now following you! for 5 seconds.
[!NOTE] The duration is optional, default is 3 seconds.
{user}is automatically replaced by the name of the person who triggered the action (for likes, this isCommunity).
Trigger Types Explained
1. Gift IDs (numbers)
5655:!tnt 2 0.1 2 Notch
8913:/execute at @a run summon minecraft:evoker ~ ~ ~ x3
Gift IDs are numerical and unique for each gift type on TikTok.
You can find the complete list of all gift IDs in core/gifts.json.
2. Special Trigger: follow
follow:/give @a minecraft:golden_apple 7
Reserved word for follow events. Is always written as follow (lowercase).
3. Like triggers
likes:/execute at @a run summon minecraft:creeper ~ ~ ~ x2
like_2:/clear @a *; /kill @a
Predefined triggers for like events:
likes= Standard like event (frequency configurable inconfig.yaml)like_2= Additional like trigger (e.g. for milestones)
Can be configured in config.yaml.
Command Types Explained
Type 1: Vanilla Commands (/)
/give @a minecraft:diamond
/execute at @a run summon minecraft:wither ~ ~ ~
/clear @a *
/say Welcome!
Starts with / → Standard Minecraft command. Is written into a .mcfunction file and executed via the datapack.
Type 2: Plugin Commands (!)
5655:!tnt 2 0.1 2 Notch
6267:!tnt 600 0.1 2 Notch
Starts with ! → Custom command. Is sent directly via RCON to the server, not written into a .mcfunction file.
→ Why the difference? → The benefits of .mcfunction files
Type 3: Special Functions ($)
16071:$random
Starts with $ → Internal special function of the streaming tool. Currently only $random is implemented.
$random chooses a random other trigger and executes it. This prevents endless loops: triggers with $random as well as basic triggers like likes, like_2 and follow are automatically excluded.
→ Details about $random → Function of the $random command
Repetitions: The xNUMBER System
x3 = 3 times in a row
x10 = 10× in a row
x100 = 100× in a row
Without x the command is executed exactly 1×.
8913:/execute at @a run summon minecraft:evoker ~ ~ ~ x3
Is equivalent to:
/execute at @a run summon minecraft:evoker ~ ~ ~
/execute at @a run summon minecraft:evoker ~ ~ ~
/execute at @a run summon minecraft:evoker ~ ~ ~
→ 3 Evokers spawn instead of 1.
Combine multiple commands with ;:
11046:/clear @a *; /execute at @a run summon minecraft:wither ~ ~ ~ x20; !tnt 20 0.1 2 Notch
Separate commands with ; → all are executed one after the other!
From left to right
Comments and Disabling
What comes after # is ignored:
#8913:/give @a minecraft:diamond
5655:/give @a minecraft:emerald
- Line 1 is commented out (is not read)
- Line 2 is active (is read)
Benefit: For disabling without deleting, or for writing notes
Valid and Invalid Syntax
Valid trigger names:
✓ Letters (a-z, A-Z)
✓ Numbers (0-9)
✓ Underscores (_)
✗ Spaces
✗ Special characters except _
✗ Umlauts (ä, ö, ü)
✓ CORRECT:
follow:/give @a minecraft:golden_apple 7
8913:/execute at @a run summon minecraft:evoker ~ ~ ~ x3
16071:$random
5655:!tnt 2 0.1 2 Notch
✗ WRONG:
follow /give @a diamond # ← Missing :
8913: /give @a diamond # ← Space after :
likes $random # ← Missing :
follow:/give @a diamond x # ← x without a number
Summary
| Concept | Explanation |
|---|---|
| Triggers | Gift ID, follow, likes, like_2 |
| Command types | / (Vanilla → mcfunction), ! (Plugin → RCON), $ (Special) |
| xNUMBER | Repeat command N times |
| Semicolon | Multiple commands in one line |
| Comments | # to disable/document |
→ Next chapter: Design decisions
Why Is the Format Structured This Way?
Design Decision
The format TRIGGER:<TYPE>COMMAND xREPEAT is not chosen at random. It's a compromise between:
- Simplicity (Users can understand it)
- Machine readability (Code can parse it)
- Flexibility (various command types)
Alternatives (and Why They Weren't Chosen)
Alternative 1: JSON format
{
"triggers": [
{
"id": "follow",
"command": "/give @a diamond",
"repeat": 1
}
]
}
Pro: Structured, easy to parse Con: Too strict, users make a lot of mistakes with brackets/commas
Alternative 2: Config-based (YAML)
actions:
follow:
command: /give @a diamond
repeat: 1
Pro: Readable Con: Too much setup
Alternative 3: SQL database
INSERT INTO actions VALUES ('follow', '/give @a diamond', 1);
Pro: Powerful, data persistent Con: Overkill, needs external tools
The Chosen Format: Why It Is Better
follow:/give @a diamond x1
Advantages:
- One line per action – Easy to understand
- Separation with
:andx– Clear structure without brackets - Minimal, concise – Beginners understand it quickly
- Not too strict (Optional:
xmay be missing) - Comment support (#) – Simply deactivate lines
- Compatible – Works in regular text editors
Parsing Is Easy
For the code:
# Example: "follow:/give @a diamond x3"
parts = line.split(":")
trigger = parts[0] # "follow"
cmd_with_repeat = parts[1] # "/give @a diamond x3"
# Extract repeat count
if "x" in cmd_with_repeat:
cmd, repeat = cmd_with_repeat.rsplit("x", 1)
repeat_count = int(repeat)
else:
cmd = cmd_with_repeat
repeat_count = 1
# Determine type
if cmd.startswith("/"):
kind = "vanilla"
elif cmd.startswith("!"):
kind = "plugin"
elif cmd.startswith("$"):
kind = "built_in"
Simple, efficient, robust!
Final Note
The format works because it has found a sweet spot between manual editing and machine processing. Not too flexible, not too strict – just right.
How Is the Format Processed?
The Process at Program Start
Processing of the actions.mca file takes place at startup:
1. Program start
↓
2. Load file "actions.mca"
↓
3. Validator checks for errors
↓
4. Parser parses each line:
- Read trigger
- Determine command type (/, !, $)
- Extract repeats (x)
↓
5. Build in-memory dictionary:
ACTIONS = {
"follow": [...],
"8913": [...],
"likes": [...]
}
↓
6. Done! File is loaded into RAM
↓
7. Worker thread: Lookup is very fast!
Code Example: Parser Logic
def parse_actions(filename):
actions = {}
with open(filename) as f:
for line_num, line in enumerate(f, 1):
line = line.strip()
# Skip comments & empty lines
if not line or line.startswith("#"):
continue
# Parse format: "trigger:command x repeat"
if ":" not in line:
print(f"[ERROR] Line {line_num} has no ':' separator")
continue
trigger, cmd_part = line.split(":", 1)
trigger = trigger.strip()
# Extract repetitions
repeat = 1
if " x" in cmd_part:
cmd_part, repeat_str = cmd_part.rsplit(" x", 1)
try:
repeat = int(repeat_str)
except ValueError:
print(f"[ERROR] Line {line_num}: no number after x")
continue
# Determine command type
cmd = cmd_part.strip()
if cmd.startswith("/"):
cmd_type = "vanilla"
body = cmd[1:].strip()
elif cmd.startswith("!"):
cmd_type = "plugin"
body = cmd[1:].strip()
elif cmd.startswith("$"):
cmd_type = "built_in"
body = cmd[1:].strip()
else:
print(f"[ERROR] Line {line_num}: Invalid prefix")
continue
# Store
if trigger not in actions:
actions[trigger] = []
actions[trigger].append({
"type": cmd_type,
"command": body,
"repeat": repeat
})
return actions
Command Type Differentiation
When parsing, a distinction is made between 3 types:
Type 1: Vanilla (/)
if cmd.startswith("/"):
kind = "vanilla"
body = cmd[1:] # Remove "/"
→ Is saved to a .mcfunction file
→ Can be run directly by the Minecraft server
Type 2: Plugin (!)
elif cmd.startswith("!"):
kind = "plugin"
body = cmd[1:] # Remove "!"
→ Sent to Minecraft via RCON → Is a custom/plugin command (not vanilla)
Type 3: Built-in ($)
elif cmd.startswith("$"):
kind = "built_in"
body = cmd[1:] # Remove "$"
→ Processed by the program itself
→ Example: $random chooses another trigger
Runtime: Lookup Is Very Fast
When an event occurs at runtime:
# Event handler sends trigger
trigger = "follow"
# Worker thread performs lookup:
if trigger in ACTIONS:
for action in ACTIONS[trigger]:
execute(action["command"], repeat=action["repeat"])
Super fast! Dictionary lookup is very fast.
Regardless of whether 10 or 10,000 actions are defined — the lookup is equally fast!
Why kind Is Important
# kind determines the processing:
if kind == "vanilla":
# Save to .mcfunction file
# Runs natively by the server
save_to_mcfunction(body)
elif kind == "plugin":
# Send directly to server via RCON
rcon_execute(body)
elif kind == "script":
# Interpreted by the program (e.g. $random)
execute_built_in(body, source_user)
The kind distinction determines how the command is executed!
Performance Note
All command types have the same performance!
The cost of an action depends on:
- What the command does (not which type)
- E.g.
/summontakes longer than/say - E.g.
!tnt 1000takes longer than!tnt 1
Type (/, !, $) doesn't matter from a performance perspective!
It depends on the command.
Summary
- Parser disassembles actions.mca once at startup
- In-memory dictionary is built
- Command types are classified (/, !, $)
- Runtime = fast dictionary lookups
- No parsing at runtime = more performance
Function of the $random Command
What Is $random?
$random is a built-in special command that randomly executes another trigger.
Example:
likes:$random
When a like event arrives → instead of always doing the same thing → choose a different trigger at random!
Practical Use Case
You like chaotic live streams? Then:
likes:$random # Every like event has a RANDOM effect!
Result: The stream is unpredictable and fun!
How Does $random Work Internally?
# 1. Parser sees: "likes:$random"
# → Saves: kind = "built_in", body = "random"
# 2. Like event occurs at runtime:
actions = ACTIONS["likes"]
for action in actions:
if action["kind"] == "script":
if action["body"] == "random":
# Collect all possible triggers
possible_triggers = get_all_triggers_except("likes")
# Pick ONE at random
chosen = random.choice(possible_triggers)
# Execute THIS one
execute_trigger(chosen)
Example: Random Trigger Pool
# Definition
follow:/say Welcome!
5655:/give @a diamond
8913:/summon minecraft:evoker
likes:$random ← Starts the random selection
# When likes:$random comes:
# 0% chance: /say Welcome!
# 50% chance: /give @a diamond
# 50% chance: /summon minecraft:evoker
# 0% chance: $random
[!NOTE] The command
/say Welcome!will never be executed, since allfollow,likeand the$randomtrigger itself are excluded from random selection.
Special Features
1. Self-recursion avoided
# $random will NOT be included in the list
possible_triggers = get_all_triggers() # Filters out $random itself!
Otherwise: likes:$random could choose $random again = endless loop!
2. All triggers are equally likely
chosen = random.choice(possible_triggers) # Uniform distribution
Every trigger has the same chance of being selected.
When Do You Need This?
- Chaos events on the stream
- Surprise effects at milestones
- Gameplay variability (not always the same)
- Mini games (random rewards)
Summary
$random is a meta command that:
- Randomly chooses another trigger
- Is evaluated at runtime (not at startup!)
[!NOTE] More
$commands are expected to be added in the future. However, these will no longer be described in the developer documentation, but only briefly mentioned in the user documentation. If you're interested in how they work, you'll have to read and understand the code yourself.
Next chapter: How do you write your own $ command?
Creating Your Own $ Commands
The Streaming Tool has an event hook system that lets developers write custom $ commands — without modifying main.py.
You create a .py file in the src/event_hooks/ folder, define a register(api) function inside it, and add the corresponding $ command to the actions.mca file. When the bot starts next, your hook is loaded automatically and ready to use.
[!WARNING] Custom imports are restricted.
Hook scripts run inside the bundled application (
app.exe). You are not allowed to import arbitrary modules — only the following are permitted:
randomtime- (more in the future)
All other functionality is available through the
apiobject (e.g. sending RCON commands, firing triggers, logging, reading config). Do not use your ownimportstatements for external packages likerequests,flask,aiohttp, etc. — they are not available inside hook scripts and will cause a load error.
How It Works
When the bot starts, all .py files in the event_hooks/ folder are automatically loaded and integrated into the running process. No separate process and no separate executable is started per hook — everything runs directly inside the main application.
[!NOTE] Development vs. Release: During development, the folder is located at
src/event_hooks/. The build script copies it toevent_hooks/in the release build. The bot always loads hooks from the release path (event_hooks/), not fromsrc/.
The loading sequence in detail:
- Parsing:
generate_datapack()readsactions.mcaand collects all$command names (e.g.$welcome_message→welcome_message) - Import: All
.pyfiles inevent_hooks/are dynamically imported viaimportlib - Registration: For each loaded module,
register(api)is called — this is where handlers are registered - Execution: When a TikTok event arrives and its trigger points to a
$command, the matching handler is called immediately
Creating a Hook
Step 1: Create the Hook Script
Create a new .py file in the src/event_hooks/ folder.
Example: src/event_hooks/welcome_message.py
def register(api):
def welcome_message(user, trigger, context):
api.rcon_enqueue([
f"say {user} just followed!",
"effect give @a minecraft:glowing 5 0 true",
])
api.register_action("welcome_message", welcome_message)
What's happening here?
register(api)is the mandatory function called by the system. Without it, the script is ignored.- Inside
register(), you define your handler as a closure — this gives it automatic access toapi. api.register_action("welcome_message", welcome_message)registers the handler under the namewelcome_message. This name must exactly match the$command inactions.mca.
Step 2: Add to actions.mca
The user (or you as a developer for testing) adds the command to actions.mca. The trigger key on the left side of : determines which TikTok event fires the hook:
follow: $welcome_message
In this example: every time someone follows on TikTok, the welcome_message hook is called.
Step 3: Start or Restart the Bot
Start the bot — or restart it if it's already running. The hook is loaded automatically and active immediately.
The register(api) Function
Every hook file must provide a register(api) function at the top level. If it's missing, the script is skipped during loading and an error message is printed.
register() is called exactly once when the bot starts. Define all your handlers inside this function as closures — this way they automatically have access to the api object without needing global variables.
def register(api):
def my_handler(user, trigger, context):
# Your logic here
api.log(f"{user} triggered {trigger}")
api.register_action("my_action", my_handler)
[!WARNING] Files without a
register()function are not loaded. The console will show:[HOOK] [ERROR] filename.py has no register() function — skipped.
Handler Signature
Every handler must accept exactly three arguments:
| Argument | Type | Description |
|---|---|---|
user | str | The TikTok username that triggered the event (e.g. "max_mustermann") |
trigger | str | The name of the $ command currently being executed (e.g. "welcome_message") |
context | dict | Reserved for future extensions — currently always an empty {} |
def my_handler(user, trigger, context):
api.rcon_enqueue([f"say Hello {user}, trigger was: {trigger}"])
Missing or extra arguments will cause the handler to throw an error at call time (but the bot stays stable — see Error Handling).
The HookAPI
The api object passed to register() is the only interface between your hook and the main system. It provides the following methods:
api.register_action(name, fn)
Registers a handler under the given name. The name must exactly match the $ command in actions.mca.
api.register_action("welcome_message", welcome_message)
[!WARNING] If the same name is registered twice (e.g. in two different hook files), the first registration wins. The second is ignored and a warning is printed:
[HOOK] [WARN] Duplicate action 'welcome_message' — first registration kept.
api.rcon_enqueue(commands)
Sends a list of Minecraft commands to the RCON queue. The commands are pushed into the queue in order and processed by the RCON worker from there.
api.rcon_enqueue([
"effect give @a minecraft:speed 10 2 true",
f"say {user} triggered a speed boost!",
])
Each entry is a complete command as a string — without a leading /.
[!NOTE] Both vanilla and plugin commands are allowed.
Since everything goes through RCON, you can send not only vanilla Minecraft commands but also commands from installed server plugins (e.g. Bukkit/Paper/Spigot plugins). The server receives them exactly as if you had typed them into the server console.
api.rcon_enqueue([ "tnt 2 0.1 2 Notch", # Example: plugin command "say Boost active!", # Vanilla command ])
api.enqueue_trigger(trigger, user)
Pushes a trigger into the trigger queue. The bot processes it exactly as if a TikTok event with that trigger had arrived — including all actions assigned to it in actions.mca (vanilla, RCON, overlay, further $ commands).
[!WARNING] The first argument is a trigger — not a
$command name.A trigger is what stands left of the
:inactions.mca. That includes gift IDs (5655), reserved event names (follow,likes), or custom triggers you defined yourself.What stands right of the
:after the$— i.e. the command name likewelcome_messageorsuperjump— is not a valid trigger.follow: $welcome_message │ │ │ └── $-command name (NOT a valid trigger) └──────────── trigger (this is what you pass to enqueue_trigger)
api.enqueue_trigger("welcome_message", user)does nothing — silently ignored, no error, no warning.api.enqueue_trigger("follow", user)works —followis on the left side ofactions.mca.
[!NOTE] The trigger is placed in the queue asynchronously — it is not processed immediately within the same handler call. The RCON commands from your current handler run first, then the forwarded trigger is picked up.
Variant A — Forwarding to an existing trigger
You can fire a trigger from inside a hook that is already registered in actions.mca for a different TikTok event.
actions.mca:
follow: $welcome_message; /give @a minecraft:golden_apple 7
5655: $big_gift
def register(api):
def big_gift(user, trigger, context):
api.rcon_enqueue([f"say Huge gift from {user}!"])
# Also fire the "follow" trigger.
# → The user gets the welcome message + golden apples on top.
api.enqueue_trigger("follow", user)
def welcome_message(user, trigger, context):
api.rcon_enqueue([f"say Welcome {user}!"])
api.register_action("big_gift", big_gift)
api.register_action("welcome_message", welcome_message)
What happens on gift 5655?
- TikTok reports gift
5655→ trigger5655is processed execute_global_command("5655", …)finds$big_gift→ calls your handler- Handler sends the RCON message and pushes
"follow"into the queue - Shortly after:
execute_global_command("follow", …)runs — executes$welcome_messageand/give …
This way a gift sender automatically gets the same treatment as a new follower, without duplicating the follow logic.
[!WARNING] Watch out for infinite loops!
Never forward to the trigger that fired your own handler:
follow: $welcome_messagedef welcome_message(user, trigger, context): api.enqueue_trigger("follow", user) # ← Loop!As soon as someone follows on TikTok, the
followtrigger fires. The handler pushesfollowback into the queue, the handler fires again, pushesfollowagain — and so on.The system detects this automatically at runtime. After 3 chain steps the trigger is blocked and permanently banned for the running session:
[HOOK] [ERROR] enqueue_trigger('follow') blocked — chain depth 4 exceeds maximum (3). Trigger 'follow' is now permanently banned for this session. Possible infinite loop.Every further
enqueue_trigger("follow", ...)call — from any hook — is then immediately rejected:[HOOK] [ERROR] enqueue_trigger('follow') permanently blocked — trigger was banned after loop detection.What still executes?
enqueue_triggerdoes not throw an exception — it simply does a silentreturnback to the caller. That means:
The rest of the handler continues normally. If
welcome_messagehas more code after theenqueue_triggercall (e.g. morercon_enqueuecalls, logging, etc.), it runs in full. Only that oneenqueue_triggercall is blocked.The remaining actions from the
actions.mcaline also run normally. Given:follow: $welcome_message; /give @a minecraft:golden_apple 7The handler
welcome_messageis called (including all code after the blocked call), and the/givecommand is executed afterwards as normal — the ban only affects theenqueue_triggercall, nothing else.
Variant B — Creating your own trigger
Triggers in actions.mca do not have to be real TikTok events. You can define your own triggers — they are just as valid as follow or a gift ID, but are never fired automatically by TikTok. They only fire when you push them via enqueue_trigger.
actions.mca:
5655: $small_gift
8913: $big_gift
thank_you: $thank_you
Here thank_you is a custom key. No TikTok event is named that — it exists purely as an internal chain step that your hooks call via enqueue_trigger.
def register(api):
def small_gift(user, trigger, context):
api.rcon_enqueue(["effect give @a minecraft:speed 5 1 true"])
api.enqueue_trigger("thank_you", user)
def big_gift(user, trigger, context):
api.rcon_enqueue(["effect give @a minecraft:speed 20 3 true"])
api.enqueue_trigger("thank_you", user)
def thank_you(user, trigger, context):
api.rcon_enqueue([f"say Thank you {user} for the gift!"])
api.register_action("small_gift", small_gift)
api.register_action("big_gift", big_gift)
api.register_action("thank_you", thank_you)
What happens on gift 5655?
execute_global_command("5655", …)→ finds$small_gift→ calls your handler- Handler gives the speed effect and pushes
"thank_you"into the queue execute_global_command("thank_you", …)→ finds$thank_you→ calls thethank_youhandler- Handler sends the thank-you message
On gift 8913 the same happens via big_gift, but the same thank_you action runs at the end. The logic is written only once.
Why create your own trigger?
A custom trigger is a full actions.mca entry. That means you can assign it not just a $ hook, but the entire palette of actions.mca syntax — vanilla commands, RCON, overlay text, all at once:
thank_you: $thank_you; /playsound minecraft:entity.player.levelup master @a; >>Thanks!|{user} just donated!|4
When a hook calls api.enqueue_trigger("thank_you", user), everything happens together: your Python handler runs, the sound plays via the datapack, and the overlay text appears in the stream.
The concrete benefits:
- Reusability: Any number of hooks can call the same trigger. The shared logic lives in one place — in the hook and/or in
actions.mca. - Separation of code and configuration: The streamer can add commands, sounds, or overlay text to the trigger in
actions.mcawithout touching the Python file. You write the logic, the streamer configures the rest. - Chaining: Triggers can fire other triggers — letting you build complex sequences from simple, testable building blocks.
- Extensible later: If the streamer wants to add a firework effect later, they change one line in
actions.mca— done. No code deployment needed.
[!TIP] Want to test if your triggers are working? Take a look at the GUIDE.md, chapter "Test Your Triggers Without TikTok". There you’ll find a test tool that lets you try out your triggers without any TikTok connection.
If you have Python installed, you can also run the file
tests/send_trigger.pydirectly in the development structure to test triggers conveniently from the console—without using the .exe. Even when testing withsend_trigger.py, the project must be built and all other components must be running in the release environment.
api.log(msg)
Prints a message to the console with an automatic [HOOK] prefix. Useful for debugging.
api.log("Hook loaded successfully")
# Output: [HOOK] Hook loaded successfully
api.config
Read-only access to the loaded values from config.yaml. Returns a nested dictionary.
port = api.config.get("RCON", {}).get("Port", 25575)
Using One Handler for Multiple $ Commands
Sometimes multiple $ commands should react similarly but with slight differences. In this case, register the same function under multiple names and use the trigger argument in the handler to distinguish which command is currently active.
def register(api):
def power_up(user, trigger, context):
effects = {
"superjump": "minecraft:jump_boost",
"superrun": "minecraft:speed",
"superheal": "minecraft:regeneration",
}
effect = effects.get(trigger)
if effect:
api.rcon_enqueue([f"effect give @a {effect} 10 5 true"])
api.register_action("superjump", power_up)
api.register_action("superrun", power_up)
api.register_action("superheal", power_up)
The user then adds to actions.mca:
5655: $superjump
16111: $superrun
7934: $superheal
This way you only need one handler for any number of related commands.
Multiple Actions in One File
A single .py file can register as many actions as needed. You are not limited to one handler per file:
def register(api):
def on_follow(user, trigger, context):
api.rcon_enqueue([f"say {user} is now following!"])
def on_big_gift(user, trigger, context):
api.rcon_enqueue([
"summon minecraft:firework_rocket ~ ~ ~",
f"say Thank you {user}!",
])
api.register_action("follow_effect", on_follow)
api.register_action("big_gift_effect", on_big_gift)
Error Handling
Errors inside a hook handler do not crash the bot. They are caught and printed to the console with a [HOOK] prefix:
[HOOK] [WARN] Error in action 'welcome_message': name 'undefined_var' is not defined
There is also a safety net during loading:
- Syntax error in a hook file → only that file is skipped, all others load normally
- Missing
register()function → file is skipped with an error message - Exception in
register()→ file is skipped, error is logged - Exception in handler at runtime → error is logged, bot keeps running
Built-in Commands Cannot Be Overridden
[!WARNING] Certain $-commands are built into the system and cannot be overridden by your own hooks.
Currently reserved names:
randomIf you try to register one of these names with
api.register_action("random", ...), you will see this error at load time:[HOOK] [ERROR] 'random' is a reserved built-in command — cannot be overridden by a hook.These commands are handled internally by main.py and are blocked for custom hooks.
→ Next Chapter: RCON and Its Limitations
RCON and Its Limitations
What Is RCON?
RCON = Remote Console – A protocol to send commands to Minecraft from outside.
It works over TCP/Port 25575 (by default). The Minecraft server must have enable-rcon=true in server.properties.
The Problem: Limited Bandwidth
Imagine RCON like a thin tube:
TikTok commands
↓ (many arrive)
RCON tube ← Limited capacity!
↓ (must come out in order)
Minecraft
The problem: If too many commands arrive at the same time → overload!
The solution: Queues – Process commands one after the other!
Queue Limits
trigger_queue = Queue(maxsize=10_000) # Max 10k incoming events
rcon_queue = Queue(maxsize=10_000) # Max 10k commands to Minecraft
like_queue = Queue() # ∞ (unlimited!)
Why no limits on like_queue?
Likes are small and come often → many in the queue is OK.
Like data is only delta (integer), not full commands!
Dynamic Throttling
The system adjusts the sending speed:
q_size = rcon_queue.qsize()
wait_time = THROTTLE_TIME
inner_pause = 0.01
if q_size > 100:
wait_time, inner_pause = 0.01, 0.001
elif q_size > 50:
wait_time, inner_pause = 0.05, 0.005
elif q_size > 20:
wait_time, inner_pause = 0.1, 0.01
Effect:
- If queue is large: process faster
- If queue is empty: send more slowly (save resources)
Limitations & Edge Cases
| Problem | Consequence | Solution |
|---|---|---|
| Queue full | Events are lost | put_nowait() with exception handling |
| Connection breaks down | No commands arrive | Auto-reconnect |
| Command too big | RCON error | Split command |
| Sending too quickly | Minecraft crash | Adjust throttling |
Best Practices
# DO: Execute commands one after the other
while True:
command = rcon_queue.get()
minecraft_server.execute(command)
time.sleep(0.05) # Short pause for stability
# DON'T: Execute commands in parallel (leads to instability!)
for command in large_command_batch:
minecraft_server.execute(command) # ← Too fast!
[!NOTE] This example is greatly simplified. Several hundred lines are necessary in the main program to operate RCON stably, catch errors cleanly, and reliably process all commands one after the other.
Summary
- RCON = Network protocol for commands
- Queued = So as not to overload
- Rate limiting = Dynamically adjusted
→ Next chapter: mcfunction files
The Benefits of mcfunction Files
The Problem: RCON Is the Bottleneck
If someone defines an extreme event:
7168:/execute at @a run summon minecraft:wither ~ ~ ~ x500
This means: 500 Withers spawn!
Without optimization:
- Send 500 individual RCON commands → Connection breaks down!
- With throttling: ~5 second delay for this event only
- Other events have to wait → Queue is full
The solution: .mcfunction files!
What Are .mcfunction Files?
.mcfunction files store a list of vanilla commands that the Minecraft server executes internally.
Instead of:
RCON: /summon wither x1
RCON: /summon wither x1
... (500x!)
Now:
# 7168.mcfunction (saved file)
/execute at @a run summon minecraft:wither ~ ~ ~
/execute at @a run summon minecraft:wither ~ ~ ~
... (500x as text!)
And then just:
RCON: function namespace:7168
→ One command instead of 500!
The Process
1. Program start
↓
2. actions.mca is read
↓
3. For every `/` command with `xN` (N > 1):
→ Write N lines to a .mcfunction file
↓
4. At runtime:
Event arrives
→ Send only: "function namespace:7168"
↓
5. Minecraft server:
→ Executes all 500 commands in one tick!
(1/20 second = super fast)
Advantages
| Aspect | Without .mcfunction | With .mcfunction |
|---|---|---|
| RCON load | 500 packets | 1 packet |
| Speed | 5+ seconds | ~1 tick |
| Queue load | High | Minimal |
| Data throughput | Huge | Tiny |
Limitations
1. Vanilla commands only
✓ /summon, /give, /execute (OK!)
✗ /mods-custom-command (Not OK!)
Plugin commands cannot be in files.
Solution: Use ! prefix instead of / to send via RCON directly.
2. Static generation (at startup)
# When starting the program:
for trigger, command in actions.mca:
write_to_mcfunction(trigger, command)
# Changes to actions.mca are NOT live!
# You have to restart to load changes!
Important: actions.mca changes will only become active after the next restart!
3. Server performance warning
x500 means: The server must execute 500 commands in 1 tick!
✓ x10, x50 = OK
⚠ x100+ = warning from the program
✗ x500+ = Probably server crash or severe lags
Don't overdo it!
Example
# Simple (RCON direct)
follow:/give @a diamond
# Complex (becomes .mcfunction)
7168:/summon minecraft:wither ~ ~ ~ x50
The program creates:
# 7168.mcfunction
/summon minecraft:wither ~ ~ ~
/summon minecraft:wither ~ ~ ~
...
(50x repeat)
When event 7168 fires:
- ONLY sends:
/function namespace:7168 - Server executes file (50 commands in one tick!)
Summary
- Vanilla commands (
/) withxN→ are stored in .mcfunction files - Plugin commands (
!) & Built-in ($) → sent directly via RCON - .mcfunction files are generated at startup, not updated live
- Performance:
xNshould be ≤ 100 in order not to overload the server
→ End! Now you understand: TikTok → Events → Minecraft Commands!
→ Next chapter: System architecture
System Modules and Integration
Modularity: The Secret to Scalability
The streaming tool is not a huge monolithic block, but consists of independent components:
Streaming Tool
│
├─ Core (Modules - Infrastructure)
│ ├─ validator.py (validation)
│ ├─ models.py (data types)
│ ├─ utils.py (helper functions)
│ ├─ paths.py (path management)
│ └─ cli.py (Command-Line Interface)
│
├─ Built-in Plugins (standard functions)
│ ├─ Timer (countdown tracker)
│ ├─ DeathCounter (death counter)
│ ├─ WinCounter (win counter)
│ ├─ LikeGoal (like milestone tracker)
│ └─ OverlayTxt (text overlay for OBS)
│
└─ Custom Plugins (user-defined)
└─ Your own plugins
The secret: Every plugin is independent of the others, but can connect with others via HTTP (DCS)!
Modules vs. Plugins
| Category | Location | Purpose | Examples |
|---|---|---|---|
| Modules (core) | src/core/ | Infrastructure & core logic | validator, models, utils, paths, cli |
| Built-in plugins | src/plugins/ | Standard functions | Timer, DeathCounter, WinCounter, LikeGoal, OverlayTxt |
| Custom plugins | plugins/ (user) | User-defined extensions | Your own plugins |
The Three Core Concepts
1. Registry (central administration)
All plugins register at startup via --register-only:
register_plugin(AppConfig(
name="Timer",
path=MAIN_FILE,
enable=True,
level=4,
ics=True
))
→ start.py knows which plugins are available and starts them
2. Control Methods
Modules can offer their functions to the outside world:
- DCS (Direct Control System) = HTTP-based (browser source in OBS)
- ICS (Interface Control System) = GUI window (pywebview, window capture in OBS)
→ Users can control modules from outside
3. Data Sharing
Plugins share data via:
- HTTP requests (DCS communication between plugins)
- Files (data/ directory, e.g. JSON files)
- Webhooks (events from Minecraft via HTTP POST)
→ No direct dependencies, just standardized protocols!
Architecture Principles
Autonomy: Each plugin works alone
↓
Registration: Register at startup (--register-only)
↓
Communication: Via HTTP (DCS) and webhooks
↓
Isolation: Crash of one plugin does not affect others
↓
Scalability: New plugins can be easily added
Why Modular?
Before (monolithic):
- One error → entire program broken
- New features → complete rewrite
- Scaling impossible
After (modular):
- Errors isolated ✓
- Plugin-based ✓
- Infinitely expandable ✓
→ Next chapter: Control Methods
Control Method: DCS vs. ICS
How Do Plugins Communicate with the Outside World?
Two systems:
DCS = Direct Control System (HTTP-based, browser source in OBS) ICS = Interface Control System (GUI window with pywebview, window capture in OBS)
# config.yaml
control_method: DCS # Or: ICS
DCS: Direct Control (Standard)
Plugin Streaming Software (OBS)
↓ ↓
Flask HTTP Server → Browser Source (http://localhost:PORT)
→ HTML/CSS is rendered directly in the browser
→ Live updates via Server-Sent Events (SSE)
How it works:
- Plugin starts a Flask server on
localhost:PORT - OBS loads the URL as a browser source
- Data is rendered directly in the browser (live updates via SSE)
Advantages:
- ✓ Fast and direct (no screen capture artifacts)
- ✓ Reliable
- ✓ Transparent background possible (for overlays)
Disadvantages:
- ✗ Streaming software must support browser source
ICS: Interface Control (Fallback)
Plugin Streaming Software (OBS)
↓ (pywebview window) ↓
GUI is displayed
↓ (Window Capture in OBS)
Overlay in stream
How it works:
- Plugin opens a pywebview window with the GUI
- User uses Window Capture in OBS to capture the window
- Result: Visual integration into the stream
Advantages:
- ✓ Works with any streaming software (including TikTok Live Studio)
- ✓ No browser source required
Disadvantages:
- ✗ Overhead due to screen capture
- ✗ Higher latency
- ✗ Loss of quality possible
When to Use Which System?
| Situation | Recommendation |
|---|---|
| OBS Studio with browser source | DCS |
| TikTok Live Studio (no browser source) | ICS |
| Custom streaming software | DCS |
| Local testing | DCS |
DCS vs. ICS in the Registry
When registering, each plugin defines whether it supports ICS:
register_plugin(AppConfig(
name="Timer",
path=MAIN_FILE,
enable=True,
level=4,
ics=True # Supports ICS (has pywebview GUI)
))
register_plugin(AppConfig(
name="App",
path=APP_EXE_PATH,
enable=True,
level=2,
ics=False # DCS only (no GUI window)
))
ics=True = Plugin has a pywebview GUI and supports window capture
ics=False = Plugin only runs as an HTTP server (DCS)
[!NOTE] All built-in plugins (Timer, DeathCounter, WinCounter, LikeGoal, OverlayTxt) have
ics=True.
At Runtime (start.py)
start.py checks the control_method from the config and the ics value of each plugin:
if app.ics and CONTROL_METHOD == "DCS" and app.enable:
# Plugin has GUI, but DCS is desired:
# → Start with --gui-hidden (Flask server only, no window)
start_exe(path=app.path, name=app.name,
hidden=get_visibility(app.level), gui_hidden=True)
elif app.enable:
# Normal start (with GUI if ics=True, without if ics=False)
start_exe(path=app.path, name=app.name,
hidden=get_visibility(app.level))
This means:
- With
control_method: DCS, GUI plugins are started with--gui-hidden→ Flask is running, but no window opens - With
control_method: ICS, GUI plugins are started normally → pywebview opens a window
Key Points
- DCS = HTTP server, browser source in OBS
- ICS = pywebview window, window capture in OBS
- DCS is the default (faster, more reliable)
- ICS is the fallback (if no browser source available)
- All built-in plugins support ICS (
ics=True)
The PLUGIN_REGISTRY: Central Plugin Registration
Concept: What Is the Registry?
The app can control multiple processes (core app, GUI, server, plugins). They must be centrally registered and configurable. There are two registries for this:
- BUILDIN_REGISTRY — core modules firmly defined in
start.py - PLUGIN_REGISTRY — plugins dynamically loaded from
PLUGIN_REGISTRY.json
The AppConfig Class
Each registry entry is an AppConfig instance (defined in core/models.py):
@dataclass(slots=True)
class AppConfig:
name: str # Unique name (e.g. "Timer")
path: Path # Absolute path to the EXE
enable: bool # Should the plugin start?
level: int # Log level for visibility
ics: bool # Has GUI? (Interface Control System)
The Five Parameters
| Parameter | Type | Example | Function |
|---|---|---|---|
name | str | "Timer" | Unique identity (logs, status) |
path | Path | Path("plugins/timer/main.exe") | Absolute path to EXE |
enable | bool | True | Start plugin at boot? |
level | int | 4 | Log level for terminal visibility |
ics | bool | True | Supports GUI window (pywebview)? |
[!IMPORTANT] All five parameters are mandatory. If one is missing or an unknown key is present, a
ValueErroris thrown.
Log Level Meaning
The level parameter controls the terminal visibility depending on log_level in the config.yaml:
| Level | Name | Description |
|---|---|---|
| 0 | Off | Hides everything, including GUI windows |
| 1 | Silent | Hides console windows, GUI remains active |
| 2 | Default | Shows only main programs |
| 3 | Advanced | Also shows background services |
| 4 | Debug | Shows all activated processes |
| 5 | Override | Shows every process, even if enable=False |
Logic: A plugin is visible if log_level >= plugin.level.
Level 0 and Level 5 are special cases:
- Level 0 hides everything and sets
gui_hidden=True - Level 5 overrides all
enablevalues and shows everything
BUILDIN_REGISTRY (Core Modules)
The core modules are defined directly in start.py:
BUILDIN_REGISTRY: list[AppConfig] = [
AppConfig(name="App", path=APP_EXE_PATH, enable=True, level=2, ics=False),
AppConfig(name="Minecraft Server", path=SERVER_EXE_PATH, enable=True, level=2, ics=False),
AppConfig(name="GUI", path=GUI_EXE_PATH, enable=GUI_ENABLED, level=2, ics=False),
]
These cannot be changed from outside — they are an integral part of the system.
PLUGIN_REGISTRY (Dynamic Plugins)
Plugins are stored in PLUGIN_REGISTRY.json. This file is automatically loaded at startup:
[
{
"name": "Timer",
"path": "C:\\...\\plugins\\timer\\main.exe",
"enable": true,
"level": 4,
"ics": true
},
{
"name": "Death Counter",
"path": "C:\\...\\plugins\\deathcounter\\main.exe",
"enable": true,
"level": 4,
"ics": true
}
]
How Plugins Register
Plugins register via the --register-only flag. The process:
1. registry.exe finds all main.exe in plugins/
↓
2. For each main.exe: starts with --register-only
↓
3. Plugin outputs AppConfig as JSON (REGISTER_PLUGIN: {...})
↓
4. registry.exe writes to PLUGIN_REGISTRY.json
↓
5. start.py reads PLUGIN_REGISTRY.json and starts plugins
In the plugin (main.py):
from core import parse_args, register_plugin, AppConfig, get_base_file
from core.utils import load_config
args = parse_args()
if args.register_only:
register_plugin(AppConfig(
name="Timer",
path=get_base_file(),
enable=cfg.get("Timer", {}).get("Enable", True),
level=4,
ics=True
))
sys.exit(0)
# ... rest of the plugin code
[!IMPORTANT] Time limit: The registration process has a hard limit of 5 seconds. Before
register_plugin()there must be no slow code (no network access, no I/O operations). Afterregister_plugin(),sys.exit(0)must follow immediately.
How start.py Processes the Registry
start.py goes through both registries and starts the plugins:
for registry in (BUILDIN_REGISTRY, PLUGIN_REGISTRY):
for app in registry:
if LOG_LEVEL == 0:
# Level 0: Hide everything
start_exe(path=app.path, name=app.name, hidden=True, gui_hidden=True)
elif LOG_LEVEL == 5:
# Level 5: Show everything
start_exe(path=app.path, name=app.name, hidden=False)
else:
if app.ics and CONTROL_METHOD == "DCS" and app.enable:
# GUI plugin in DCS mode: hide GUI, server only
start_exe(path=app.path, name=app.name,
hidden=get_visibility(app.level), gui_hidden=True)
elif app.enable:
# Normal start
start_exe(path=app.path, name=app.name,
hidden=get_visibility(app.level))
Scan Cache (Performance)
To speed up the registration process, registry.py uses a scan cache (plugin_registry_scan_cache.json). If a plugin EXE has not changed (same file size and modification time), the result from the cache is used instead of restarting the plugin.
Summary
| Component | File | Content |
|---|---|---|
| AppConfig | core/models.py | Dataclass with 5 mandatory fields |
| BUILDIN_REGISTRY | start.py | Firmly defined core modules |
| PLUGIN_REGISTRY | PLUGIN_REGISTRY.json | Dynamically registered plugins |
| Registration | registry.py | Scans plugins with --register-only |
| Scan cache | plugin_registry_scan_cache.json | Speeds up repeated scans |
→ Continue to GUI Architecture
GUI Architecture: pywebview + Flask Backend
Concept: GUI vs DCS
- DCS: Pure HTTP servers without GUI (port-based)
- GUI (ICS): Graphical interface in its own window + HTTP backend
- Advantage: Visually intuitive, easy configuration for users
- Technology: pywebview (Electron-like) + Flask (web framework)
Architecture Diagram
┌─────────────────────────────────────┐
│ Streaming Tool GUI │
│ ┌───────────────────────────────┐ │
│ │ pywebview Window │ │
│ │ ┌─────────────────────────┐ │ │
│ │ │ HTML/CSS/JavaScript │ │ │
│ │ │ (User Interface) │ │ │
│ │ └──────────┬──────────────┘ │ │
│ └─────────────┼─────────────────┘ │
│ │ (HTTP Requests) │
│ ┌─────────────▼──────────────────┐ │
│ │ Flask Server (Backend) │ │
│ │ • /api/config (GET/POST) │ │
│ │ • /api/status (GET) │ │
│ │ • /api/save (POST) │ │
│ │ • /stream (SSE) │ │
│ └────────────────────────────────┘ │
└─────────────────────────────────────┘
Structure of a GUI Plugin File
File structure of a GUI plugin:
gui_plugin.py
├── Initialize Flask app
├── Route @app.route("/"): Return HTML UI
├── Route @app.route("/api/config"): Load/save configuration
├── Route @app.route("/stream"): Server-Sent Events (SSE)
└── main(): Open pywebview window
Practical Example: Simple GUI Plugin
gui_example.py:
import os
import json
from flask import Flask, render_template_string, request
import webview
app = Flask(__name__)
CONFIG_FILE = "plugin_config.json"
HTML = """
<!DOCTYPE html>
<html><head>
<title>GUI Plugin</title>
<style>
body { font-family: Arial; background: #222; color: #fff; padding: 20px; }
button { padding: 10px 20px; background: #4CAF50; color: white; border: none; cursor: pointer; }
input { padding: 8px; width: 200px; }
</style>
</head>
<body>
<h2>Configuration</h2>
<input type="text" id="config_input" placeholder="Enter value">
<button onclick="fetchConfig()">Load</button>
<button onclick="saveConfig()">Save</button>
<div id="output"></div>
<script>
function fetchConfig() {
fetch('/api/config').then(r => r.json()).then(data => {
document.getElementById('output').innerHTML = JSON.stringify(data);
document.getElementById('config_input').value = data.setting || '';
});
}
function saveConfig() {
const value = document.getElementById('config_input').value;
fetch('/api/config', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({setting: value})
}).then(() => fetchConfig());
}
fetchConfig(); // Initial load
</script>
</body>
</html>
"""
@app.route("/")
def index():
return render_template_string(HTML)
@app.route("/api/config", methods=['GET', 'POST'])
def config_handler():
if request.method == 'GET':
try:
with open(CONFIG_FILE) as f:
return json.load(f)
except:
return {"setting": ""}
else: # POST
data = request.json
with open(CONFIG_FILE, 'w') as f:
json.dump(data, f)
return {"status": "saved"}
def main():
# Start Flask in background
from threading import Thread
Thread(target=lambda: app.run(port=5001, debug=False, use_reloader=False), daemon=True).start()
# Open pywebview window (shows localhost:5001)
webview.create_window('GUI Plugin', 'http://localhost:5001')
webview.start()
if __name__ == '__main__':
main()
Process:
- pywebview opens a window
- Window loads HTML from
http://localhost:5001 - JavaScript sends HTTP requests to Flask routes
- Backend processes, saves config, sends response
Critical Aspects
| Aspect | Meaning | Example |
|---|---|---|
| Port uniqueness | Every GUI plugin needs a unique port | GUI: 5000, Timer: 7878, LikeGoal: 9797 |
| Threading | Flask must run in a thread so the window doesn't block | daemon=True is important |
| SSE for live updates | Server-Sent Events for continuous data | /stream for like counters |
| CORS | For browser sources: Access-Control-Allow-Origin: * required | Streaming software browser source |
→ Continue to Communication & DCS
Communication and DCS (HTTP-based Inter-Plugin Communication)
Concept: Why HTTP Between Plugins?
Plugins work as separate processes in parallel. Communication between them occurs via:
- DCS (Direct Control System): HTTP requests between plugins (port-based)
- Webhooks: HTTP POST requests from external programs (e.g. Minecraft)
DCS is the universal communication method – all plugins support it.
Communication Pattern
┌──────────────┐ HTTP Request ┌──────────────┐
│ Timer ├───────────────────────────>│ WinCounter │
│ Port 7878 │ POST /add?amount=1 │ Port 8080 │
│ │ │ │
│ │ HTTP Response │ │
│ │<───────────────────────────┤ │
│ │ "OK" │ │
└──────────────┘ └──────────────┘
DCS Request-Response Workflow
Step by step:
- Source plugin sends HTTP request to
http://localhost:PORT/endpoint - Target plugin receives request, processes action
- Target responds with JSON or status
- Source plugin processes response (with error handling if necessary)
Important: Requests should be made in threads, otherwise the calling plugin will block!
Practical Example: Timer Calls WinCounter
In real code, this is exactly what happens: when the timer reaches 0, it sends an HTTP POST to the WinCounter to add a win.
WinCounter (server on port 8080):
@app.route("/add", methods=["POST"])
def add():
win_manager_instance.add_win(int(request.args.get('amount', 1)))
return "OK"
Timer (client):
WIN_PORT = cfg.get("WinCounter", {}).get("WebServerPort", 8080)
ADD_URL = f"http://localhost:{WIN_PORT}/add?amount=1"
class API:
def on_timer_end(self):
print(f"[ACTION] Timer reached 0. Sending POST to {ADD_URL}")
try:
requests.post(ADD_URL, timeout=2)
except Exception as e:
print(f"[ERROR] Could not reach counter: {e}")
Port Assignment in the Project
Each plugin has its own port, defined in config.yaml:
| Plugin | Port | Config key |
|---|---|---|
| GUI | 5000 | GUI.Port |
| OverlayTxt | 5005 | Overlaytxt.Port |
| MinecraftServerAPI | 7777 | MinecraftServerAPI.WebServerPort |
| Timer | 7878 | MinecraftServerAPI.WebServerPortTimer |
| DeathCounter | 7979 | MinecraftServerAPI.DEATHCOUNTER_PORT |
| WinCounter | 8080 | WinCounter.WebServerPort |
| LikeGoal | 9797 | Gifts.LIKE_GOAL_PORT |
[!IMPORTANT] Every port must be unique. If two plugins use the same port, startup will fail.
Avoiding Critical Errors
| Error | Problem | Solution |
|---|---|---|
| Synchronous requests in the main thread | GUI/Server blocked | Use threads or set timeout |
| Unreachable plugins | "Connection refused" | Check port, plugin may not be running yet |
| Timeout too short | Request aborts | Set at least timeout=2 |
| Request without error handling | Crash on error | Always use try/except |
Server-Sent Events (SSE): Live Updates to the Browser
Many plugins use Server-Sent Events to send their data to OBS (browser source) or the pywebview window in real time. The basic principle:
- Browser opens a persistent connection to
/stream - Server sends data via
yield(notreturn) - Browser automatically updates itself with new data
@app.route("/stream")
def stream():
q = Queue()
manager.listeners.append(q)
def event_stream():
yield f"data: {json.dumps(manager.get_data())}\n\n"
while True:
data = q.get()
yield f"data: {json.dumps(data)}\n\n"
return Response(event_stream(), mimetype="text/event-stream")
In the browser (JavaScript):
const es = new EventSource("/stream");
es.onmessage = (e) => {
const data = JSON.parse(e.data);
document.getElementById('counter').innerText = data.count;
};
This pattern is used by DeathCounter, WinCounter, LikeGoal and OverlayTxt.
Webhooks: Receiving Events from Minecraft
Plugins can receive events from Minecraft via a /webhook endpoint. The MinecraftServerAPI plugin on the Minecraft server sends HTTP POST requests to all configured URLs.
@app.route('/webhook', methods=['POST'])
def webhook():
data = request.json
event = data.get("event")
if event == "player_death":
death_manager.add_death()
elif event == "player_respawn":
# React to respawn
pass
return {"status": "ok"}, 200
The webhook URLs are configured in configServerAPI.yml:
webhooks:
urls:
- "http://localhost:7777/webhook" # Main App
- "http://localhost:7878/webhook" # Timer
- "http://localhost:7979/webhook" # DeathCounter
- "http://localhost:8080/webhook" # WinCounter
[!TIP] For detailed instructions on how to implement webhooks in your own plugins, see Chapter: Webhook Events and Minecraft Integration.
Summary
| Communication method | Direction | Example |
|---|---|---|
| DCS (HTTP requests) | Plugin → Plugin | Timer calls WinCounter /add |
| SSE (Server-Sent Events) | Plugin → Browser/OBS | DeathCounter updates overlay |
| Webhooks | Minecraft → Plugins | player_death event to DeathCounter |
- DCS = HTTP-based inter-plugin communication
- SSE = Live updates to browser source or pywebview
- Webhooks = Receive events from external programs
- Ports must be unique and configured in
config.yaml - Error handling with
try/exceptandtimeoutis mandatory
→ Continue to Integrating modules into streaming software
Integrating Modules into Streaming Software
Concept: Two Ways of Integration
Streaming tools (OBS, Streamlabs, etc.) need access to your modules:
- ICS (GUI modules): Window capture → The GUI window is shown as a video layer
- DCS (HTTP modules): Browser source → Browser renders HTML from HTTP server
Comparison: ICS vs DCS
| Aspect | ICS | DCS |
|---|---|---|
| Integration | Window Capture | Browser Source |
| Technology | Window capture | HTTP + HTML/CSS |
| Scaling | Native window size | Flexibly configurable |
| Latency | Higher (frame-to-frame) | Lower (direct rendering) |
| Error handling | Window must be visible | Port must be online |
| Best for | Desktop GUI tools | Live data (like counter, timer) |
ICS Integration: Window Capture
In OBS:
Source→+→Window Captureadd- Dropdown: Select GUI application (e.g. "GUI Module [gui_module.exe]")
- Adjust size/position
- Done – GUI will be added live to the stream
Requirement: Plugin must be registered with ics=True in the registry. The plugin itself opens the pywebview window — start.py just starts the process.
DCS Integration: Browser Source
In OBS:
Source→+→Browser Sourceadd- Enter URL:
http://localhost:PORT(e.g.http://localhost:9797) - Set width/height (e.g. 1280×720)
- Refresh rate: 60 FPS
- Done – browser renders your HTML UI live
Practical example:
The like counter runs on port 9797:
# likegoal.py
@app.route("/")
def index():
return f"""
<html>
<style>
body {{ background: transparent; color: #fff; font-size: 48px; text-align: center; padding: 20px; }}
</style>
<body>
<h1 id="count">Loading...</h1>
<script>
setInterval(() => {{
fetch('/api/like_count')
.then(r => r.json())
.then(d => document.getElementById('count').innerText = d.count);
}}, 500);
</script>
</body>
</html>
"""
@app.route("/api/like_count")
def get_like_count():
return {"count": current_likes}
# Start Flask in thread
Thread(target=lambda: app.run(port=9797, debug=False, use_reloader=False), daemon=True).start()
In OBS: Browser source with URL http://localhost:9797 → Live Like Counter overlay!
Common Problems & Solutions
| Problem | Cause | Solution |
|---|---|---|
| URL not reachable | Port blocked/incorrect | Check with netstat -ano, open firewall |
| Browser source shows blank | CORS error / HTML not loading | Inspect browser console (F12) |
| Window capture doesn't work | Module not registered with ics=True | Check registry and level |
| Latency, delay | Server too slow | Optimize server rendering, compress images |
| Only browser source but my module has ics=True | That's OK | ics=True means "also supports ICS", not "must use ICS" |
Integration Checklist
For DCS (HTTP):
- ☑ Flask/Server runs in the background
- ☑ Port in registry correct
- ☑ HTML with
transparentbackground (if overlay) - ☑ Browser source in OBS with correct URL
- ☑ Refresh rate 30-60 FPS
For ICS (Window Capture):
- ☑ pywebview opens window
- ☑ Registry:
ics=True - ☑
log_level>= modulelevel - ☑ Window title clear
- ☑ OBS selects via dropdown
Next step: Create your own plugin
Python in This Project
Python is the central language of this project. In this chapter you will learn how the project is organized and which parts work together.
Why Python?
Python was chosen for this project because it:
- Is quick to develop with (little boilerplate code)
- Has a good ecosystem for web (Flask), async (asyncio), and APIs (TikTokLive)
- Remains readable and maintainable, even when it becomes complex
- Works cross-platform (Windows, macOS, Linux)
The Main Components
1. main.py – The Heart
# Simplified scheme
def main():
1. Load config
2. Set up TikTok client
3. Register event handlers
4. Connect client (parallel)
5. Start startup services (server, GUI, plugins)
6. Process event queue (main loop)
This file connects TikTok to the rest of the system.
2. core/ – Reusable Modules
What's inside:
| Module | Purpose |
|---|---|
models.py | Data structures (AppConfig, PluginInfo, etc.) |
cli.py | Command-line argument parsing |
paths.py | Path functions (ROOT_DIR, BASE_DIR, etc.) |
utils.py | Helper functions (sanitize strings, etc.) |
validator.py | Validation logic |
You can import these modules anywhere in the project:
from core import load_config, register_plugin, get_root_dir
3. server.py – Minecraft Server Starter
Starts the Minecraft server as a subprocess:
config.yaml
↓ (Xms, Xmx, Port, RCON)
server.py
↓ java -jar server.jar
Minecraft server is running
[!NOTE] The webhook endpoint (
/webhook) is located in main.py, not in server.py.
4. registry.py – Plugin Management
Loads and manages all plugins:
# Simplified
PLUGIN_REGISTRY = [
{"name": "App", "path": ..., "enable": True, ...},
{"name": "Timer", "path": ..., "enable": True, ...},
# More plugins...
]
5. plugins/ – Freely Expandable
Here you write your own plugins:
src/plugins/
├── timer/
│ ├── main.py # Timer logic
│ ├── README.md
│ └── version.txt
│
├── my_custom_plugin/ # Your plugin!
│ ├── main.py
│ ├── README.md
│ └── version.txt
The Data Flow (Simplified)
TikTok Live Stream
↓
TikTokLive API (WebSocket)
↓
main.py (receives events)
↓
Event handlers registered (e.g. on_gift, on_follow)
↓
Find trigger + place in queue
↓
Main loop processes queue
↓
RCON → Minecraft server
↓
Minecraft server runs command
Important: This is NOT synchronous. Events wait in a queue until they can be processed.
Understanding Imports
When you open Python files in the project, you will see imports like:
from TikTokLive import TikTokLiveClient, TikTokLiveConnection
from TikTokLive.events import GiftEvent, FollowEvent, LikeEvent
These are external libraries (not built into Python):
| Library | Purpose |
|---|---|
TikTokLive | Connection to TikTok Live |
Flask | Web framework for webhooks |
pywebview | Desktop GUI windows |
pyyaml | Reading config files |
asyncio | Asynchronous programming |
All are listed in requirements.txt.
Threading & Asynchrony (Brief Overview)
The project uses threading in several places:
TikTok event received (Thread 1)
↓
Fill queue
↓
Main loop processes (Thread 2)
↓
Send Minecraft command
Why? Because TikTok events can't wait. If the main loop is currently serving Minecraft, new events must still be able to arrive.
Threading can be complicated, so we'll cover that in more detail later in Threading and Queues.
Which Files Do You Need to Understand?
We'll focus on these files:
- main.py – How data comes in
- server.py – How to start the Minecraft server
- registry.py – How plugins are loaded
- core/ – Helper functions
For plugin development:
- src/plugins/timer/main.py – Good example
- config.yaml – Plugin configuration
Not relevant for initial understanding:
- Build scripts (build.ps1, upload.ps1)
- Migration code for config
- Template files
Next Step
Now you understand the rough structure. The next part delves deeper.
There we see how the main file is structured and what tasks it performs.
The main.py File
main.py is the centerpiece of the project. It is responsible for staying connected to TikTok and receiving all events.
What Does main.py Do?
When you start the program, main.py performs these steps (simplified):
1. Load configuration (config.yaml)
↓
2. Set up TikTok client
↓
3. Register event handlers ("Listen for gifts, follows, likes")
↓
4. Connect to TikTok (stay connected)
↓
5. Receive events (continuously)
↓
6. Process events & queue them
↓
7. [While step 6 is running: Main loop processes queue]
This is not linear. Steps 5, 6, and 7 run at the same time (in parallel).
Structure of main.py (High Level)
# 1. IMPORTS
from TikTokLive import TikTokLiveClient
from TikTokLive.events import GiftEvent, FollowEvent, LikeEvent
from core.validator import validate_file, print_diagnostics
from core.paths import get_base_dir
# 2. GLOBAL VARIABLES
MAIN_LOOP = ... # Reference to the main loop
trigger_queue = Queue() # Queue for triggers
like_queue = Queue() # Queue for likes
# 3. CLIENT CREATION FUNCTIONS
def create_client(user):
client = TikTokLiveClient(unique_id=user)
@client.on(GiftEvent)
def on_gift(event):
# Respond to gift
pass
# Similar: on_follow, on_like, etc.
return client
# 4. MAIN FUNCTION
def main():
# Load config
cfg = load_config(...)
# Start client
client = create_client(cfg["tiktok_user"])
# Start other services (server, GUI, plugins)
# ...
# Main loop (processes queue)
while True:
event = trigger_queue.get() # Get next event
process_trigger(event) # Process
The Role of Imports
At the beginning of main.py you see:
from TikTokLive import TikTokLiveClient
from TikTokLive.events import GiftEvent, FollowEvent, ConnectEvent, LikeEvent
This means:
- TikTokLiveClient: An object that connects to TikTok
- GiftEvent: Triggers when a gift is received
- FollowEvent: Fires when someone follows
- LikeEvent: Triggered when likes arrive
These will be used later to register event handlers.
Further imports:
from core.validator import validate_file, print_diagnostics
from core.paths import get_base_dir
These are core modules (from the project itself), not external libraries.
Step 1: Load Configuration
CONFIG_FILE = get_root_dir() / "config" / "config.yaml"
try:
with open(CONFIG_FILE, "r", encoding="utf-8") as f:
cfg = yaml.safe_load(f)
except Exception as e:
print(f"ERROR: Config could not be loaded: {e}")
sys.exit(1) # Exit program
This reads the config.yaml:
tiktok_user: "a_tiktoker"
Timer:
Enable: true
StartTime: 10
If that fails, the program aborts (because it doesn't work without config).
Step 2 & 3: Create Client & Register Handlers
def create_client(user):
"""Create a TikTok Live client for the specified user"""
client = TikTokLiveClient(unique_id=user)
# Now let's register event handlers
# Handler = "Functions that are executed when an event occurs"
@client.on(GiftEvent)
def on_gift(event: GiftEvent):
# This function is called EVERY TIME a gift arrives
pass # Logic comes later
@client.on(FollowEvent)
def on_follow(event: FollowEvent):
# This function is called when someone follows
pass
@client.on(LikeEvent)
def on_like(event: LikeEvent):
# This function is called when likes arrive
pass
return client # Return the configured client
The @client.on(...) is a decorator – a Python construct that says: "Call this function when this event occurs."
Step 4: Connect to TikTok
client = create_client(cfg["tiktok_user"])
# Start connection (asynchronous)
asyncio.run(client.connect())
This connects to the TikTok stream and stays connected. When an event comes, the client automatically calls the corresponding handler.
Why Is main.py Complex?
If you open the real main.py, you'll see a lot more code than explained here:
# Real main.py also has:
- Error handling (what if an error occurs?)
- Combo gifts (repeated gifts)
- Race conditions (multi-threading)
- Streams (video events)
- and much more...
This makes the code complicated. But the core idea remains the same:
- Create client
- Register handlers
- Connect
- Process events
What's in the Event Handlers?
The actual magic happens in the event handlers:
@client.on(GiftEvent)
def on_gift(event: GiftEvent):
# 1. Read gift details
gift_name = event.gift.name
user = event.user.nickname
# 2. Check if this gift is configured
if gift_name in valid_functions:
# 3. Queue trigger
MAIN_LOOP.call_soon_threadsafe(
trigger_queue.put_nowait,
(gift_name, user)
)
But this will be discussed in detail later.
The Role of the Main Program
main.py is not the only file that runs. There are also:
- server.py: Starts the Minecraft server (Java subprocess, RCON configuration, server.properties)
- registry.py: Loads and starts all plugins
- gui.py: Shows an admin interface
Summary
main.py does:
✓ Load configuration
✓ Create TikTok client
✓ Register event handlers
✓ Connect with TikTok
✓ Receive and process events
✓ Place in queue
Everything runs in parallel – not one after the other.
Next Step
Now you understand the structure. The next step is to understand the imports more precisely.
→ Imports
There you will see what is done with the imported modules.
Imports
Before we start TikTok processing, we need to understand which parts of the project we use and where.
from TikTokLive import TikTokLiveClient
from TikTokLive.events import GiftEvent, FollowEvent, ConnectEvent, LikeEvent
What Does That Mean?
In Python:
from ... import ...→ "Get certain things from a library"
A library is simply a collection of pre-written code that we can use instead of writing everything ourselves.
More about it here: https://en.wikipedia.org/wiki/Library_(computing)
Why Do We Only Import Certain Parts?
You usually do not import the complete library, but only the parts that you actually need. This has several advantages:
- The code remains clearer
- Less unnecessary code is loaded
- It is easier to see what is used in the project
In our case we import:
TikTokLiveClient→ establishes the connection to TikTokGiftEvent→ is triggered when a gift is sentFollowEvent→ is triggered when someone followsConnectEvent→ is triggered when the connection is establishedLikeEvent→ is triggered when likes are received
The names are relatively self-explanatory and help you quickly understand what they are responsible for.
[!IMPORTANT] Before you can use an external library (i.e. one that does not come standard with Python), you have to install it.
For this project you will use:
pip install TikTokLive
If that doesn't work, you can alternatively use:
python -m pip install TikTokLive
TikTok Client & Event Handler
This is the centerpiece of event processing. Here we create the connection to TikTok and register functions that react to events.
How It Works
1. Create TikTok client
↓
2. Register handlers for specific events
↓
3. Handler is called AUTOMATICALLY when event arrives
This is not "polling" (constantly asking "is something going on?"), but rather event-driven (the system tells you when something happens).
Visualized:
TikTok Live Stream is running...
↓
↓ [Event arrives: someone sends a gift]
↓
TikTokLive API automatically calls: on_gift(event)
↓
on_gift() is executed
↓
We process the gift
Step 1: Create Client
from TikTokLive import TikTokLiveClient
from TikTokLive.events import GiftEvent, FollowEvent, LikeEvent
def create_client(user):
"""Create a TikTok Live client"""
client = TikTokLiveClient(unique_id=user)
return client
What happens:
TikTokLiveClient(unique_id=user)connects to a specific TikTok account- The client listens for all events from this stream
- No handlers registered yet – that's coming next
Step 2: Register Handlers
A handler is simply a function that responds to an event. We use the decorator approach:
@client.on(GiftEvent)
def on_gift(event: GiftEvent):
print(f"Gift received: {event.gift.name}")
The @client.on(...) decorator says: "Call this function when a GiftEvent arrives."
Similar for other events:
@client.on(FollowEvent)
def on_follow(event: FollowEvent):
print(f"New follow: {event.user.nickname}")
@client.on(LikeEvent)
def on_like(event: LikeEvent):
print(f"Total Likes: {event.total}")
Step 3: Fill Handlers with Logic
This is where things get interesting. The handler must:
- Read event data – What's in the event?
- Validate – Is it a valid event?
- Find triggers – Which action should be triggered?
- Place in queue – Don't execute immediately, but wait in line!
Reason for queue: Events come very quickly. If we process them immediately, the next event processing could block or the RCON connection breaks.
Simplified example:
@client.on(GiftEvent)
def on_gift(event: GiftEvent):
# 1. Read event data
gift_name = event.gift.name # e.g. "Rose"
gift_id = event.gift.id # e.g. 1001
repeat_count = event.repeat_count # e.g. 3 (combo)
user = event.user.nickname # e.g. "Streamer123"
# 2. Validate (is everything OK?)
if not gift_name or not user:
logger.warning("Invalid gift data")
return
# 3. Find triggers (is there an action for this gift?)
trigger = None
if gift_name in VALID_ACTIONS:
trigger = gift_name
elif str(gift_id) in VALID_ACTIONS:
trigger = str(gift_id)
if not trigger:
logger.debug(f"No action configured for gift: {gift_name}")
return
# 4. Place in queue (don't run immediately!)
for _ in range(repeat_count):
MAIN_LOOP.call_soon_threadsafe(
trigger_queue.put_nowait,
(trigger, user)
)
logger.info(f"Gift queued: {gift_name} from {user}")
Step 4: Error Handling
Events can fail – we have to catch it:
@client.on(GiftEvent)
def on_gift(event: GiftEvent):
try:
# Your code here
gift_name = event.gift.name
# ... rest of the logic
except AttributeError as e:
logger.error(f"Gift data is incomplete: {e}")
except Exception as e:
logger.error(f"Unexpected error in on_gift: {e}", exc_info=True)
# Important: exc_info=True shows the complete error stack
Why important:
- Event handlers must not crash the entire program
- Other events should continue to be processed
- Errors should be logged (for debugging)
The Real Implementation (Production Code)
The real code is more complex because it has to deal with edge cases:
@client.on(GiftEvent)
def on_gift(event: GiftEvent):
try:
# Combo gifts can be sent multiple times
if event.gift.combo:
# Check for "streak" (repeated combo)
if getattr(event, 'streaking', False):
try:
if event.streaking:
return # Ignore streak intermediate events
except AttributeError:
pass
repeat_count = event.repeat_count
else:
repeat_count = 1 # Non-combo = once
# Make gift data safe
gift_name = sanitize_filename(event.gift.name)
gift_id = str(event.gift.id)
# Execute extra action (e.g. sound)
execute_gift_action(gift_id)
# Find triggers
target = None
if gift_name in valid_functions:
target = gift_name
elif gift_id in valid_functions:
target = gift_id
if not target:
return
username = get_safe_username(event.user)
# Place in queue multiple times (for combos)
for _ in range(repeat_count):
MAIN_LOOP.call_soon_threadsafe(
trigger_queue.put_nowait,
(target, username)
)
except Exception:
logger.error("ERROR in on_gift handler:", exc_info=True)
Understanding the Complexity
The real code is more complex because:
| Complexity | Reason |
|---|---|
if event.gift.combo | Some gifts can be repeated multiple times |
getattr(event, 'streaking', False) | Attributes may not exist – check defensively |
sanitize_filename() | Usernames might contain special characters |
call_soon_threadsafe() | Thread-safe queue operation |
| Try-except | Events must not crash the entire system |
Summary
A TikTok client handler:
- ✓ Listens for events
- ✓ Receives event data
- ✓ Validates the data
- ✓ Finds suitable action
- ✓ Places in queue (does not execute immediately!)
- ✓ Catches errors (does not crash)
Next Step
Now you understand how handlers work. The next step is to understand what is in the event itself.
→ Understanding the Event System
There we look at what data each event has and how we use it.
Understanding the Event System
Before we analyze specific event types (gifts, follows, likes), we need to understand how events are structured and how they flow.
Event Structure
An event is not just "something happened" – it contains data about what happened.
GiftEvent Example
# A real GiftEvent has this structure:
{
"user": {
"id": "123456789",
"nickname": "Streamer123",
"signature": "I love Minecraft",
# ... more user data
},
"gift": {
"id": 1001,
"name": "Rose",
"repeat_count": 3, # How many times was this gift sent?
"combo": True, # Can this gift be combined?
"description": "A beautiful rose"
},
"total_count": 5, # Total gifts from this user
}
You access it with:
def on_gift(event: GiftEvent):
event.user.nickname # "Streamer123"
event.gift.name # "Rose"
event.gift.repeat_count # 3
event.gift.combo # True/False
event.total_count # 5
Event Objects vs. Dictionaries
The events are not simple dictionaries (like {"name": "Rose"}), but objects:
# Object (what we use)
event.gift.name # ✓ Works, IDE gives autocomplete
# Dictionary (wouldn't work)
event["gift"]["name"] # ✗ More complicated, no IDE help
Why objects are better:
- IDE can provide autocomplete (e.g.
event.gift.<suggestion>) - Type-safety (Python knows that
event.gift.nameis a string) - Fewer errors (wrong keys → immediate error instead of silent fail)
Event Categories
Events are divided into multiple categories:
| Category | Examples | Purpose |
|---|---|---|
| User events | Follow, Gift, Like | Action of a viewer |
| System events | Connect, Disconnect | System status |
| Stream events | StreamStart, StreamEnd | Stream lifecycle |
For this documentation we will focus on:
- ✓ GiftEvent (viewer sends a gift)
- ✓ FollowEvent (viewer follows)
- ✓ LikeEvent (viewer likes)
Event Handler Workflow
When an event arrives, the following happens:
1. TikTok Live Stream (something happened)
↓
2. TikTokLive API receives event (via WebSocket)
↓
3. Client looks for a matching handler (@client.on(...))
↓
4. Handler function is called with event data
↓
5. Handler processes event
↓
6. Next event can be received
Timing: This all happens in milliseconds!
Validating Event Data
Not all event data is guaranteed to be available. We have to program defensively:
@client.on(GiftEvent)
def on_gift(event: GiftEvent):
# ✓ Safe: with fallback
gift_name = getattr(event.gift, "name", "Unknown")
# ✓ Safe: try-except
try:
user_id = event.user.id
except AttributeError:
user_id = None
# ✗ Unsafe: could be None
# repeat_count = event.repeat_count # What if attribute is missing?
Rule: Always assume data can be missing or None.
Event Modifications & Flags
Some events have additional flags or modifiers:
Combo Flag
if event.gift.combo: # Can this gift be combined?
# Yes: The viewer can send the same gift multiple times
# → event.repeat_count says how often
count = event.repeat_count # e.g. 5
else:
# No: only once
count = 1
Streaking Flag (Advanced)
# Combo gifts can "streak" for several seconds
if getattr(event, "streaking", False):
# This is an intermediate event (not the last)
# We can skip if we only want final events
return
Errors in Events (Exception Handling)
Events can be problematic. We have to intercept:
@client.on(GiftEvent)
def on_gift(event: GiftEvent):
try:
# Read event data
gift_name = event.gift.name
user = event.user.nickname
# Execute logic
# ...
except AttributeError:
# Data is missing
logger.error(f"Gift event missing data: {event}")
return
except Exception as e:
# Unexpected error
logger.error(f"Error processing gift: {e}", exc_info=True)
return
Important: A faulty event must not crash the whole program. Other events must continue.
Saving & Processing Event Data
Sometimes we want to process event data later (e.g. in the queue):
@client.on(GiftEvent)
def on_gift(event: GiftEvent):
# Do not process immediately – save!
event_data = {
"type": "gift",
"gift_name": event.gift.name,
"user": event.user.nickname,
"count": event.repeat_count,
"timestamp": event.created_at
}
# Place in queue (will be processed later)
trigger_queue.put_nowait(event_data)
Why? Because different threads work at the same time:
Thread 1: Receive TikTok events (quickly!)
Thread 2: Process events (slower!)
If Thread 2 is slow, events will pile up in the queue.
That's OK – that's the point of the queue.
Summary: Event System
✓ Events are objects with structured data
✓ Different event types (gift, follow, like, etc.)
✓ Handlers are called automatically
✓ Data must be validated
✓ Errors must be caught
✓ Events are not processed immediately, but buffered (queue)
Next Step
Now you understand the system. The next step is to look at specific event types.
There we see how gift events work specifically (with combos, repeats, etc.).
Gift Events
What's Special About Gifts: Combos and Streaks
Gifts are not simple like follows. A gift can arrive in three different ways:
| Situation | What happens | How often is the handler called? |
|---|---|---|
| Single gift | Viewer sends gift 1x | 1x (immediately) |
| Combo gift | Viewer sends the same gift multiple times in quick succession | Multiple times (repeat_count) |
| Streaking | TikTok sends notifications about the current status of the combo | Multiple times (but: we want to SKIP these) |
This is important to understand before we write code:
Viewer sends 5 roses in a row:
00:00 - Event: Gift='Rose', repeat_count=1, streaking=False
00:01 - Event: Gift='Rose', repeat_count=2, streaking=False
00:02 - Event: Gift='Rose', repeat_count=3, streaking=False
00:03 - Event: Gift='Rose', repeat_count=4, streaking=False
00:04 - Event: Gift='Rose', repeat_count=5, streaking=True ← Streak end!
TikTok also sends status updates:
00:01 - Event: Gift='Rose', repeat_count=2, streaking=True ← IGNORE!
00:02 - Event: Gift='Rose', repeat_count=3, streaking=True ← IGNORE!
00:03 - Event: Gift='Rose', repeat_count=4, streaking=True ← IGNORE!
Why is this important? If we processed every status update, we would queue the same action 3–5x too many times.
Gift Event Structure: What Can We Read from a Gift?
A GiftEvent contains this most important information:
event.gift.name # Gift name: "Rose", "Diamond", etc.
event.gift.id # Gift ID: 1, 2, 3 (numeric)
event.gift.combo # Can this gift be comboed? True/False
event.repeat_count # How many times was the gift sent in total? 1, 2, 3, 4, 5...
event.streaking # Is this a status update of a running combo? True/False
event.user.nickname # Viewer name: "anna_123"
event.user.user_id # Viewer ID (numeric)
event.gift_type # Type of gift (usually: "gift")
event.description # Detailed description (e.g. "Sent Rose x5")
The practical meaning:
- We need
repeat_countto know how often the action should be carried out - We need
streakingto know whether we should ignore this event - We need
gift.nameORgift.idto find which action matches - We need
user.nicknameto record who sent the gift
Gift Event Processing: The 5-Step Process
When a gift event arrives, the following happens:
1. ARRIVE
Event arrives → is it streaking? YES → STOP, ignore
2. COUNT
Is it a combo gift? YES → count = event.repeat_count
NO → count = 1
3. IDENTIFY
Read gift name: "Rose"
Sanitize (make safe): "Rose" → OK
Read username: "anna_123"
4. MATCH
Does "Rose" match an action? Check valid_functions
If yes → that is our `target`
If no → ignore event
5. QUEUE
for i in range(count): # 5x, because repeat_count=5
Queue: (target, username)
Now the action is processed asynchronously
Visual:
TikTok sends: Gift Event (Rose, repeat_count=5, streaking=False)
↓
[STEP 1] streaking==False? ✓ Continue
↓
[STEP 2] combo==True? repeat_count=5 → count=5
↓
[STEP 3] name="Rose", user="anna_123" (sanitized)
↓
[STEP 4] "Rose" in valid_functions? ✓ target="GIFT_ROSE"
↓
[STEP 5] 5x in queue: ("GIFT_ROSE", "anna_123")
↓
Worker thread processes all 5 in sequence
Special: Streaking Flag
The streaking flag is important because TikTok sends status updates for long combo sequences:
# What TikTok sends for a 5-combo:
Event 1: {gift: "Rose", repeat_count: 1, streaking: False} ✓ Process
Event 2: {gift: "Rose", repeat_count: 2, streaking: False} ✓ Process
Event 3: {gift: "Rose", repeat_count: 3, streaking: False} ✓ Process
Event 4: {gift: "Rose", repeat_count: 4, streaking: False} ✓ Process
Event 5: {gift: "Rose", repeat_count: 5, streaking: True} ✗ SKIP!
Why ignore the streaking=True event?
If we processed it, we would queue the action 5x = 5x incorrectly!
The streaking=True event is just a message from TikTok: "The combo is now complete."
How do we handle this?
if hasattr(event, 'streaking') and event.streaking:
return # Ignore, do not process!
Error Handling for Gift Events
Gift handlers need to be robust because several things can go wrong:
| Problem | What can happen? | How do we protect ourselves? |
|---|---|---|
Event has no gift property | AttributeError | getattr() with fallback |
Event has no user property | AttributeError | get_safe_username() with fallback |
| Gift name/ID does not match any action | Gift is ignored | if not target: return |
| Username contains invalid characters | Queue error | sanitize_filename() cleans it up |
| Queue is full (very rare) | put_nowait() exception | Try-except around queue operation |
Solution: Wrap everything in try-except:
try:
# Gift handler code here
except AttributeError as e:
logger.error(f"Gift event has invalid structure: {e}")
except Exception as e:
logger.error(f"Error in gift handler: {e}", exc_info=True)
Practical Example: A Complete Gift Handler
Here is a real, working gift handler with all safety features:
@client.on(GiftEvent)
def on_gift(event: GiftEvent):
"""
Handles gift events from TikTok.
- Handles combos (multiple times in a row)
- Ignores streaking events (status updates)
- Queues actions for asynchronous processing
"""
try:
# STEP 1: Ignore streaking event?
if hasattr(event, 'streaking') and event.streaking:
logger.debug(f"Ignoring streaking event: {event.gift.name}")
return
# STEP 2: How often should we perform the action?
if event.gift.combo:
count = event.repeat_count # e.g. 5 for a 5-combo
else:
count = 1 # Single gift = 1x
# STEP 3: Read gift data safely
gift_name = event.gift.name # "Rose", "Diamond", etc.
gift_id = str(event.gift.id) # "1", "2", etc.
username = get_safe_username(event.user) # "anna_123" (sanitized)
logger.info(
f"Gift received: {gift_name} (ID: {gift_id}) "
f"from {username} (x{count})"
)
# STEP 4: Find the right trigger
# First search by name, then by ID
target = None
if gift_name in TRIGGERS:
target = gift_name
elif gift_id in TRIGGERS:
target = gift_id
if not target:
logger.warning(f"No trigger defined for gift '{gift_name}'")
return
# STEP 5: Place action in queue (count times)
for _ in range(count):
try:
MAIN_LOOP.call_soon_threadsafe(
trigger_queue.put_nowait,
(target, username)
)
except Exception as e:
logger.error(
f"Error queuing gift action: {e}",
exc_info=True
)
logger.debug(f"✓ {count}x action '{target}' queued")
except AttributeError as e:
logger.error(
f"Gift event is incomplete (missing property): {e}",
exc_info=True
)
except Exception as e:
logger.error(
f"Unexpected error in gift handler: {e}",
exc_info=True
)
What does this code do?
- Ignore streaking – Only process real gift events
- Determine count – 1x or multiple times?
- Read data – Gift name, ID, username
- Logger info – Visible feedback for debugging
- Find triggers – By name, then by ID
- Queue operation – Thread-safe with
call_soon_threadsafe - Error handling – Everything is protected with try-except
Even Simpler: Minimal Example
If the above handler is too long for you, here is a minimal example that also works:
@client.on(GiftEvent)
def on_gift(event: GiftEvent):
# Ignore streaking events
if getattr(event, 'streaking', False):
return
# How often?
count = event.repeat_count if event.gift.combo else 1
# Which trigger?
target = event.gift.name # or: event.gift.id
# Queue (count times)
for _ in range(count):
MAIN_LOOP.call_soon_threadsafe(
trigger_queue.put_nowait,
(target, event.user.nickname)
)
This is significantly shorter and does the same thing – but without explicit error handling.
Summary & Next Step
What you know now:
| Concept | Explanation |
|---|---|
| Combo gifts | Same gift multiple times = repeat_count increases |
| Streaking | TikTok sends status updates = we ignore them |
| Trigger matching | Gift name or ID → to action (TRIGGERS dictionary) |
| Asynchronous queue | call_soon_threadsafe makes it thread-safe |
| Error handling | Try-except protects against unexpected structures |
What happens AFTER the gift handler?
The queued action is later processed by the worker thread.
[!TIP] If you want to write your own gift handler, use the minimal example above and then add error handling as needed.
Follow Events
What's Special About Follows: Simple and Direct
Follows are much simpler than gifts, because:
| Feature | Gifts | Follows |
|---|---|---|
| Multiple processing | Combo possible (5x, 10x, etc.) | Always only 1x |
| Status updates | Streaking: Multiple notifications | No notifications |
| Trigger management | Name AND ID possible | A single trigger: "follow" |
| Error complexity | High (combos, streaking, race conditions) | Low (linear flow) |
The good news: Follow handlers are perfect for learning, because they show the basic structure without much complexity.
Follow Event Structure: What Can We Read?
A FollowEvent contains this information:
event.user.nickname # Viewer name: "anna_xyz"
event.user.user_id # Viewer ID (numeric)
event.follow_user.nickname # The account that was followed
event.follow_user.user_id # ID of the followed account
event.timestamp # Timestamp of the event
event.event_type # Type of event (usually: "follow")
In practice: We mainly need event.user.nickname to know who followed.
Follow Event Processing: The 3-Step Flow
When a follow event arrives, the flow is very simple:
1. RECEIVE EVENT
FollowEvent arrives
2. READ & SANITIZE USERNAME
username = get_safe_username(event.user)
e.g.: "anna_xyz"
3. PLACE TRIGGER IN QUEUE
There is only one trigger: "follow"
→ Execute action or ignore
Visual:
Viewer follows stream
↓
TikTok sends: FollowEvent
↓
[STEP 1] Event received ✓
↓
[STEP 2] Username = "anna_xyz" ✓
↓
[STEP 3] Trigger "follow" defined?
YES → Queue: ("follow", "anna_xyz")
NO → Ignore
↓
Worker thread processes
Follow Data Structure in Code
If you want to debug in the follow handler, you can use these properties:
@client.on(FollowEvent)
def on_follow(event: FollowEvent):
# Who followed?
follower_name = event.user.nickname
follower_id = event.user.user_id
# Who was followed?
followed_user = event.follow_user.nickname
followed_id = event.follow_user.user_id
# When?
timestamp = event.timestamp
# Debug:
print(f"{follower_name} follows {followed_user} at {timestamp}")
Note: In most cases we only care about event.user.nickname, because we want to know who followed.
Simple Error Handling
Since follow events are simple, we need less error handling:
try:
username = get_safe_username(event.user)
# ... rest of the code
except AttributeError:
logger.error("Follow event is incomplete", exc_info=True)
except Exception as e:
logger.error(f"Error in follow handler: {e}", exc_info=True)
Main risks:
event.userdoesn't exist? →get_safe_username()protects against thisget_safe_username()returns an empty string? → OK, gets queued anyway- Queue is full? → Very rare, try-except is sufficient
Practical Example: A Complete Follow Handler
Here is the standard follow handler with best practices:
@client.on(FollowEvent)
def on_follow(event: FollowEvent):
"""
Processes follow events from TikTok.
- Simple structure (no combos)
- Works directly with trigger 'follow'
"""
try:
# STEP 1: Read username
username = get_safe_username(event.user)
# STEP 2: Logging
logger.info(f"Follow received from: {username}")
# STEP 3: Check if follow trigger is defined
if "follow" not in TRIGGERS:
logger.warning("No 'follow' trigger defined, ignoring event")
return
# STEP 4: Place follow action in queue
try:
MAIN_LOOP.call_soon_threadsafe(
trigger_queue.put_nowait,
("follow", username)
)
logger.debug(f"✓ Follow action queued for: {username}")
except Exception as e:
logger.error(
f"Error queuing follow action: {e}",
exc_info=True
)
except AttributeError as e:
logger.error(
f"Follow event is incomplete: {e}",
exc_info=True
)
except Exception as e:
logger.error(
f"Unexpected error in follow handler: {e}",
exc_info=True
)
What does this code do?
- Read & sanitize username – With
get_safe_username() - Logging – We see in the log who follows
- Check trigger – Does the "follow" trigger exist?
- Queue – With
call_soon_threadsafe() - Error handling – Everything is protected
Even Simpler: Minimal Example
The absolute minimal handler (also works!):
@client.on(FollowEvent)
def on_follow(event: FollowEvent):
username = get_safe_username(event.user)
MAIN_LOOP.call_soon_threadsafe(
trigger_queue.put_nowait,
("follow", username)
)
That's it! Three lines, does exactly the same thing.
Difference from Gifts (Comparison)
To understand why follow handlers are so simple:
Gift handler:
if streaking: return # ← Check streaking
count = repeat_count or 1 # ← Multiple processing
for _ in range(count): # ← Loop!
queue.put(...)
Follow handler:
queue.put(...) # ← Direct, no loop needed!
That's the main difference!
Edge Cases (When Things Go Wrong)
What can go wrong with follows?
| Scenario | Consequence | Solution |
|---|---|---|
event.user is None | AttributeError | get_safe_username() raises exception |
Username is empty string "" | Gets queued as-is | Normal, no problem |
| "follow" trigger doesn't exist | Event is ignored | Early return |
| Queue full (extremely rare) | put_nowait() exception | Try-except catches it |
| TikTok sends follow 2x quickly | Two events in succession | Both are processed (intended!) |
Conclusion: Follow handlers are very robust – there's little that can go wrong.
Summary & Next Step
What you know now:
| Concept | Explanation |
|---|---|
| Follow = Simple | No combos, no streaking, only 1 trigger |
| 3-step flow | Username → Check → Queue |
| Trigger "follow" | The only trigger for follow events |
| Error handling | Minimal needed, get_safe_username() covers a lot |
| Best practice pattern | Same as gifts, just much shorter |
What happens AFTER the follow handler?
The queued "follow" action is later processed by the worker thread (e.g. execute Minecraft command).
[!TIP] Follow events are perfect for experimenting. Try extending the minimal handler (e.g. special handling for certain usernames).
Like Events
What's Special About Likes: Continuous Counting
Like events are completely different from gifts and follows:
| Feature | Gifts | Follows | Likes |
|---|---|---|---|
| Event type | Discrete events ("gift sent") | Discrete events ("followed") | Continuous counting |
| Frequency | Rare (user sends gift) | Rare (user follows) | VERY FREQUENT |
| Usernames | Yes, visible | Yes, visible | Rarely visible |
| Trigger logic | "When gift" | "When follow" | "When counter reaches e.g. 100 mark" |
| Threading problem | No, simple | No, simple | YES, race conditions! |
The core problem: Like events arrive so quickly that multiple threads can access the same data simultaneously. This leads to race conditions if we're not careful.
The Race Condition Problem Explained
Imagine two like events arrive at the same time:
Thread 1: Reads like counter: 100
Thread 2: Reads like counter: 100
↓
Thread 1: Calculates: 100 > last_blocks? YES → Trigger!
Thread 2: Calculates: 100 > last_blocks? YES → Trigger!
↓
Thread 1: Writes: last_blocks = 100
Thread 2: Writes: last_blocks = 100
↓
RESULT: Trigger fired 2x instead of 1x!
Solution: Lock (Mutex)
A lock ensures that only one thread at a time executes the critical code:
Thread 1: Waiting for lock... ⏳
Thread 2: GETS LOCK ✓
Reads, calculates, writes
RELEASES LOCK
↓
Thread 1: GETS LOCK ✓
Reads, calculates, writes (with updated data!)
RELEASES LOCK
↓
RESULT: Trigger fired 1x (+ 1x, correctly sequential) ✓
Like Counting Visualized: The Difference from Other Events
GIFTS/FOLLOWS (Discrete):
00:00 - Event "Gift Rose" → Trigger: "GIFT_ROSE"
00:05 - Event "Follow" → Trigger: "FOLLOW"
00:10 - (nothing)
00:15 - Event "Gift Diamond" → Trigger: "GIFT_DIAMOND"
LIKES (Continuous):
00:00 - LikeEvent: total=1000
00:01 - LikeEvent: total=1000
00:02 - LikeEvent: total=1000
00:03 - LikeEvent: total=1005 ← +5 likes!
00:04 - LikeEvent: total=1012 ← +7 likes!
If we want to trigger every 10-mark:
1000-1009: no triggers
1010+ : 1 trigger
1020+ : 1 trigger
1030+ : 1 trigger
etc.
With our code:
current_blocks = 1012 // 10 = 101
last_blocks = 100
diff = 101 - 100 = 1
→ Trigger fired 1x ✓
LikeEvent Structure
A LikeEvent contains this information:
event.total # Total like count so far: 1005, 1010, 1025 etc.
event.likeCount # Likes in this session/streak: 5, 7, 15 etc.
event.user.nickname # Username (sometimes not available)
event.timestamp # Timestamp of the event
Like Event Processing: The 6-Step Flow
When like events arrive, the following happens:
1. FIRST EVENT?
Is start_likes still None? YES → Initialize, return
2. CALCULATE DELTA
Likes since start: current_total - start_likes
e.g.: 1025 - 1000 = 25
3. ACQUIRE LOCK
Wait until no other thread is active
4. CHECK RULES
For each like rule:
- Read interval ("every": 100)
- Calculate how many intervals have been reached
- Check if new intervals since last check
5. QUEUE TRIGGERS
For each new interval:
- Place action in queue
6. RELEASE LOCK
Next thread can now proceed
Interval Calculation Explained
This is the core logic for like counting:
every = 100 # Trigger every 100 likes
# Scenario 1: 1010 likes total
current_blocks = 1010 // 100 # = 10 (tenth 100-mark)
last_blocks = 9 # (we were at 900)
diff = 10 - 9 = 1 # → 1 trigger
# Scenario 2: 1025 likes total
current_blocks = 1025 // 100 # = 10 (still the tenth mark!)
last_blocks = 10 # (we already know about mark 10)
diff = 10 - 10 = 0 # → No new trigger
# Scenario 3: 1200 likes total
current_blocks = 1200 // 100 # = 12 (twelfth mark)
last_blocks = 10 # (old mark)
diff = 12 - 10 = 2 # → 2 triggers in succession!
The // is important! This is integer division (whole number). It is the key to the block calculation.
Error Handling for Like Events
Like handlers need special error handling because of the lock:
like_lock = threading.Lock()
try:
with like_lock: # ← Python: Automatic lock/unlock
# Critical code here
except Exception as e:
logger.error(f"Error in like handler: {e}", exc_info=True)
# Lock is AUTOMATICALLY released, even if an error occurs!
Why use with like_lock?
Because Python automatically always releases the lock, even if an error occurs. This is important – otherwise the lock would "hang" and all other threads would wait forever!
Practical Example: A Complete Like Handler
Here is a real, working like handler:
import threading
# Initialize globally
like_lock = threading.Lock()
start_likes = None
last_overlay_sent = 0
last_overlay_time = 0
LIKE_TRIGGERS = [
{"id": "goal_100", "every": 100, "last_blocks": 0, "function": "LIKE_GOAL_100"},
{"id": "goal_500", "every": 500, "last_blocks": 0, "function": "LIKE_GOAL_500"},
]
def initialize_likes(total):
"""Set starting value on first event"""
global start_likes
start_likes = total
logger.info(f"Like tracking initialized with: {total} likes")
@client.on(LikeEvent)
def on_like(event: LikeEvent):
"""
Processes like events from TikTok.
- Continuous counting instead of individual events
- Thread-safe with locks
- Triggers when like milestones are reached (100, 500, 1000, etc.)
"""
global start_likes, last_overlay_sent, last_overlay_time
try:
# STEP 1: First initialization?
if start_likes is None:
initialize_likes(event.total)
return
# STEP 2: Calculate likes since start
total_since_start = event.total - start_likes
logger.debug(f"Like event: {event.total} total, "
f"{total_since_start} since start")
# STEP 3: Acquire lock (thread safety!)
with like_lock:
# STEP 4: Check each like rule
for rule in LIKE_TRIGGERS:
every = rule["every"]
# Skip invalid rules
if every <= 0:
continue
# STEP 5: Calculate current and last block number
current_blocks = total_since_start // every
last_blocks = rule["last_blocks"]
# New blocks reached?
if current_blocks > last_blocks:
diff = current_blocks - last_blocks
rule["last_blocks"] = current_blocks
logger.info(
f"Like trigger '{rule['id']}': "
f"{current_blocks} milestones reached (+{diff})"
)
# STEP 6: For each new block: queue action
for _ in range(diff):
try:
MAIN_LOOP.call_soon_threadsafe(
trigger_queue.put_nowait,
(rule["function"], {})
)
except Exception as e:
logger.error(
f"Error queuing like action: {e}",
exc_info=True
)
# (Lock is automatically released here)
except Exception as e:
logger.error(
f"Unexpected error in like handler: {e}",
exc_info=True
)
What does this code do?
- Initialize – Set starting value on first event
- Calculate delta – How many likes are new?
- Acquire lock – Activate thread safety
- Check rules – For each like milestone (100, 500, etc.)
- Calculate blocks – With integer division
// - Queue – Queue an action for each new milestone
Even Simpler: Minimal Example
The absolute minimum (also works, but requires manual lock management):
like_lock = threading.Lock()
start_likes = None
@client.on(LikeEvent)
def on_like(event: LikeEvent):
global start_likes
if start_likes is None:
start_likes = event.total
return
delta = event.total - start_likes
with like_lock:
# When delta reaches 100: trigger
if delta >= 100 and delta - 100 < 1: # First 100-mark
MAIN_LOOP.call_soon_threadsafe(
trigger_queue.put_nowait,
("LIKE_GOAL_100", {})
)
This is much shorter, but also less flexible. The complete handler above is better!
Difference Between Gifts/Follows and Likes
To understand why like handlers are more complex:
Gifts/Follows:
# Event arrives → Process immediately → Done
@client.on(GiftEvent)
def on_gift(event):
queue.put(...)
Likes:
# Events arrive VERY OFTEN → Track how many → Trigger per interval
@client.on(LikeEvent)
def on_like(event):
# Count: How many likes since start?
delta = event.total - start_likes
# Calculate: How many 100-marks?
blocks = delta // 100
# Compare: New marks since last time?
if blocks > last_blocks:
# THEN: Trigger!
queue.put(...)
The difference: Aggregation instead of direct forwarding!
Edge Cases for Like Events
What can go wrong?
| Scenario | Problem | Solution |
|---|---|---|
| Like event before initialization | start_likes is None | if start_likes is None: initialize() |
| Two events simultaneously | Race condition | with like_lock protects |
| Interval is 0 | Division by zero | if every <= 0: continue |
| Very fast like flood | Many events/sec | Blocks are correctly aggregated |
| Lock hangs | Thread blocked forever | with like_lock auto-releases |
Conclusion: Like handlers require the most error handling, especially because of the lock.
Summary & Next Step
What you know now:
| Concept | Explanation |
|---|---|
| Likes ≠ Gifts | Continuous counting instead of individual events |
| Race conditions | Multiple threads access simultaneously → lock required |
| Block calculation | blocks = total_likes // interval |
| Initialization | Set starting value on first event |
| Lock pattern | with threading.Lock() for thread safety |
| Error handling | Lock is released even on errors (with does this) |
What happens AFTER the like handler?
The queued like actions are later processed by the worker thread (e.g. "Congratulations on 100 likes!").
→ Next chapter: Threading & Queues
[!NOTE] Like handlers show you the real complexity of multi-threading. It's not easy, but super important for performant systems!
Threading & Queues: Asynchronous Processing
Why Can't We Execute Events Directly?
Imagine if an event handler would execute things directly:
# WRONG – execute directly:
@client.on(GiftEvent)
def on_gift(event):
execute_minecraft_command(...) # ← Blocks!
wait_for_response(...) # ← Takes a long time!
update_overlay(...) # ← Even longer!
# Meanwhile: New events are piling up
The problem: While we wait for the Minecraft response, no new TikTok events can be processed. The TikTok connection "hangs" and we lose events!
The solution: Place events in a queue and process them asynchronously!
# ✓ CORRECT – place in queue:
@client.on(GiftEvent)
def on_gift(event):
trigger_queue.put_nowait((target, username)) # ← Very fast!
# Done! Event handler returns immediately
# Another thread processes the queue:
while True:
target, username = trigger_queue.get() # ← Wait for next action
execute_minecraft_command(...) # ← No problem if it takes time
The Queue Architecture Visualized
TIKTOK CONNECTION
(very fast, must not block)
↓
Event handler
(also fast!)
↓
Trigger queue
(buffer)
[GIFT_ROSE]
[FOLLOW]
[LIKE_GOAL_100]
[GIFT_DIAMOND]
↓
Worker thread
(can also be slow)
↓
Minecraft commands
(can take a long time)
The advantage: The TikTok connection is never blocked, no matter how overloaded the worker thread is!
Queue Operations: put, get, put_nowait
import queue
from threading import Thread
trigger_queue = queue.Queue(maxsize=1000)
# Operation 1: PUT (with waiting)
trigger_queue.put((target, username))
# If queue is full: Wait until space becomes free
# Operation 2: PUT_NOWAIT (without waiting)
trigger_queue.put_nowait((target, username))
# If queue is full: Exception (QueueFull)
# → That's fine! We catch it if something goes wrong
# Operation 3: GET (with waiting)
item = trigger_queue.get()
# If queue is empty: Wait until item arrives
# → BLOCKS the worker thread until something needs to be done
# Operation 4: GET_NOWAIT (without waiting)
try:
item = trigger_queue.get_nowait()
except queue.Empty:
# Queue was empty, do something else
call_soon_threadsafe: Thread-Safe Calls
In our streaming tool we use call_soon_threadsafe instead of normal put:
# Normal put() – unsafe if MainLoop is active:
trigger_queue.put_nowait((target, username)) # Could cause a race condition
# Better: call_soon_threadsafe
MAIN_LOOP.call_soon_threadsafe(
trigger_queue.put_nowait,
(target, username)
) # ✓ Thread-safe!
Why? call_soon_threadsafe ensures that the operation is executed in the MainLoop thread, not in the event handler thread. This avoids race conditions!
Race Conditions and Locks (Recap)
A race condition occurs when two threads access the same data at the same time:
# Race condition:
counter = 0
Thread 1: counter = counter + 1 # Reads 0, writes 1
↓ (interrupt!)
Thread 2: counter = counter + 1 # Reads 0, writes 1
RESULT: counter = 1 (but should be 2!)
# ✓ With lock:
counter = 0
lock = threading.Lock()
Thread 1: with lock: # Acquires lock
counter = counter + 1 # Reads 0, writes 1
# Lock released
↓
Thread 2: with lock: # Waits for lock
counter = counter + 1 # Reads 1, writes 2
# Lock released
RESULT: counter = 2 ✓
Pattern: Always use with threading.Lock() for critical data!
Practical Example: Worker Thread Implementation
The worker thread reads events from the queue and processes them:
import threading
import queue
trigger_queue = queue.Queue()
def worker_thread():
"""This thread processes triggers from the queue"""
while True:
try:
# Wait for next action
target, username = trigger_queue.get(timeout=1)
# Process action
logger.info(f"Processing: {target} for {username}")
try:
execute_trigger(target, username)
except Exception as e:
logger.error(f"Error executing trigger {target}: {e}")
# Mark as "done"
trigger_queue.task_done()
except queue.Empty:
# Timeout: Nothing in queue, continue
continue
except Exception as e:
logger.error(f"Worker thread error: {e}")
# Start worker thread (as daemon, runs in background)
worker = threading.Thread(target=worker_thread, daemon=True)
worker.start()
Overlay Updates: A Practical Use Case
Overlay updates for like counters also use the queue:
# Separate queue for overlay updates
like_queue = queue.Queue()
@client.on(LikeEvent)
def on_like(event):
global start_likes, last_overlay_sent, last_overlay_time
if start_likes is None:
start_likes = event.total
return
# Calculate new likes
delta = event.total - start_likes
# Send update to overlay (but not too often!)
now = time.time()
if delta > 0 and (now - last_overlay_time) >= 0.5: # Max 2x per second
try:
MAIN_LOOP.call_soon_threadsafe(
like_queue.put_nowait,
delta # Only send the difference
)
last_overlay_sent = delta
last_overlay_time = now
except queue.Full:
logger.warning("Like queue is full, update skipped")
This is important: Don't send every overlay update! With OVERLAY_INTERVAL (e.g. 0.5 seconds) we limit the updates. This saves bandwidth!
Timing & Throttling: Don't Let Events Come Too Quickly
Sometimes events arrive SO quickly that we have to throttle them:
import time
last_event_time = 0
THROTTLE_INTERVAL = 0.1 # At least 100ms between events
@client.on(LikeEvent)
def on_like(event):
global last_event_time
# Ignore events that are too close together
now = time.time()
if now - last_event_time < THROTTLE_INTERVAL:
return # Too fast! Skip.
last_event_time = now
# ... rest of the event handler ...
Why? If like events arrive every 50ms, we can't process them all. With throttling we deliberately slow down processing.
Final Note
The most important thing to understand:
Events are not directly = action.
Instead:
TikTok event → handler → queue → worker thread → action
(fast) (buffer) (can be slow)
This makes the system:
- ✓ Stable (events are not lost)
- ✓ Scalable (many events at the same time)
- ✓ Maintainable (action logic is separated)
Debugging & troubleshooting
Have you written your plugin but it doesn't work as expected? Here you will learn to find, understand and fix errors.
Types of errors
Before we debug, we should know what error classes there are:
| Error type | Symptom | Example |
|---|---|---|
| Syntax error | Program doesn't start at all | def foo( – missing bracket |
| Import error | ModuleNotFoundError | Dependency not installed |
| Runtime error | Program crashes during execution | Division by 0 |
| Logic error | Program runs, but does the wrong thing | if x = 5: instead of if x == 5: |
| Configuration error | Settings are wrong | config.yaml has invalid YAML |
Tool 1: Logs (The most important!)
Where are the logs?
build/release/logs/
├── debug.log # General debug logs
├── error.log # Errors only
├── plugin_timer.log # Plugin-specific logs
└── ...
Understanding log levels
In the config.yaml:
log_level: 2
[!TIP] For development, set the level to
4:
log_level: 4
Log output in your plugin
import logging
logger = logging.getLogger(__name__)
logger.debug("Debug info for developers")
logger.info("General information")
logger.warning("Warning – could cause problems")
logger.error("An error has occurred")
logger.critical("CRITICAL error – program might crash")
Example:
@app.route("/webhook", methods=["POST"])
def webhook():
logger.info(f"Webhook received: {request.json}")
try:
process_event(request.json)
logger.info("Event processed successfully")
except Exception as e:
logger.error(f"Error in event processing: {e}", exc_info=True)
return {"error": str(e)}, 500
Tool 2: Print Debugging
For quick tests you can also use print():
def on_gift(event):
print(f"[DEBUG] Gift received: {event.gift.name}")
print(f"[DEBUG] User: {event.user.nickname}")
print(f"[DEBUG] Count: {event.repeat_count}")
But be careful: print() is not ready for production. Use logging for real applications.
Tool 3: Try-Except Blocks
Catching and understanding errors:
try:
result = 10 / number # Could be division-by-zero
except ZeroDivisionError:
print("ERROR: Division by 0!")
return None
except Exception as e:
print(f"Unexpected error: {e}")
return None
Using traceback() for details:
import traceback
try:
process_data(data)
except Exception as e:
print(f"ERROR: {e}")
traceback.print_exc() # Shows the complete error stack
logger.error(f"Error: {e}", exc_info=True)
Tool 4: The Debugger (VS Code)
Visual Studio Code has a built-in debugger:
Set breakpoints
- Open your Python file
- Click to the left of the line → red dot (breakpoint)
- Start the program with
F5(Debug mode) - When the line is reached → program pauses
- Inspect variables, step through the code
Debug controls
F10– Next line (Step Over)F11– Go into function (Step Into)Shift+F11– Go out of functionF5– Continue to the next breakpointShift+F5– Stop debugging
Watch variables
On the right in the debug panel:
VARIABLES
├─ request
│ ├─ method: "POST"
│ ├─ json: {...}
│ └─ ...
├─ event
│ ├─ gift: {...}
│ └─ ...
You can inspect variables here without typing!
Common Errors & Solutions
1. "ModuleNotFoundError: No module named 'TikTokLive'"
Cause: Dependency not installed.
Solution:
pip install -r requirements.txt
or
pip install TikTokLive Flask pywebview pyyaml
2. "Config error while loading"
Cause: configuration.yaml is not valid YAML.
Test:
python -c "import yaml; yaml.safe_load(open('config/config.yaml'))"
If errors → check YAML syntax (indentation, colons, etc.)
3. "Port already in use"
Error: Address already in use :8080
Cause: Another program is using the port.
Solution:
Windows:
netstat -ano | findstr :8080
taskkill /PID <pid_nummer> /F
macOS/Linux:
lsof -i :8080
kill -9 <pid>
Or: Change port in config.yaml:
Timer:
WebServerPort: 8081 # Instead of 8080
4. "TikTok connection fails"
Error: Client cannot connect to TikTok.
Diagnostics:
# Test in main.py
client = TikTokLiveClient(unique_id="my_username")
try:
asyncio.run(client.connect())
print("✓ Connection successful!")
except Exception as e:
print(f"✗ Connection failed: {e}")
Common Causes:
- TikTok user does not exist (misspelled)
- Internet down
- TikTok API has changed
5. "Plugin won't load"
Error: Plugin is in src/plugins/ but is not used.
Debugging:
-
Check: Plugin registered in
PLUGIN_REGISTRY?# In start.py / registry.py {"name": "MyPlugin", "path": ..., "enable": True, ...} -
Check: Plugin has
main.py?src/plugins/my_plugin/ ├── main.py # Must exist! ├── README.md └── version.txt -
Check: Plugin can import?
python src/plugins/my_plugin/main.py --register-onlyIf error → check imports
6. "Webhook is not received"
Error: Minecraft sends event but your plugin doesn't receive it.
Debugging:
@app.route("/webhook", methods=["POST"])
def webhook():
logger.info(f"Webhook received: {request.json}")
print(f"[WEBHOOK] {request.json}") # Additionally print
return {"success": True}, 200
Checks:
-
Is Flask running?
curl http://localhost:7878/webhook -X POST -d "{}" -
Firewall allows port? Port must be open.
-
Config is correct? Port in
config.yamlmust match Flask port.
7. "Queue overflowing" or "Performance problems"
Error: Many events → System becomes slow.
Debugging:
import asyncio
# In main loop
while True:
size = trigger_queue.qsize()
if size > 100:
logger.warning(f"Queue size: {size} – could be tight!")
event = trigger_queue.get()
process(event)
Optimization:
- Use batch processing (process multiple events at once)
- Use threading (several workers per queue)
- Filter events (do not process all of them)
8. "Thread safety error" / "Race condition"
Error: Sporadic, non-reproducible errors (sometimes it works, sometimes it doesn't).
Cause: Two threads change the data at the same time.
Solution – Use Lock:
from threading import Lock
counter_lock = Lock()
counter = 0
def increment():
global counter
with counter_lock: # Only one thread at a time!
counter += 1
logger.debug(f"Counter: {counter}")
Performance profiling
If the program is slow:
1. Where is the bottleneck?
import time
start = time.time()
result = process_large_data()
elapsed = time.time() - start
logger.info(f"process_large_data() took {elapsed:.2f}s")
2. Use Profiler
python -m cProfile -s cumtime main.py
This shows which functions consume the most time.
Debugging checklist
If something doesn't work:
- Are the logs readable?
- Try-except blocks around critical parts?
-
Imports correct? (
pip installall dependencies?) - Config valid? (YAML, ports, etc.)
- Breakpoints set and ran through the code?
- Environment variables correct?
- Other processes block resources? (ports, files)
- Data type error? (String instead of integer, etc.)
- Off-by-one error? (index error)
- Race conditions? (threading problems)
Get help
1. Describe your problem precisely
Bad:
"My plugin doesn't work!"
Good:
"My plugin timer doesn't start. Error:
ModuleNotFoundError: No module named 'requests'. I have executedpip install -r requirements.txt, but it doesn't work."
2. Share code snippet
# My on_gift handler
@client.on(GiftEvent)
def on_gift(event):
trigger_queue.put_nowait((event.gift.name, event.user.nickname))
# Error here?
3. Share logs
[ERROR] Error in event handler:
Traceback (most recent call last):
File "main.py", line 123, in on_gift
...
KeyError: 'gift_id'
4. Describe your environment
- OS: Windows / macOS / Linux
- Python: 3.8 / 3.9 / 3.10 / 3.11 / 3.12
- Streaming Tool Version: v1.0 / dev / etc.
Summary
Good debugging follows this flow:
Notice error
↓
Check logs (Tool 1)
↓
Print debugging (Tool 2)
↓
Use try-except (Tool 3)
↓
VS Code Debugger (Tool 4)
↓
Error found!
↓
Implement fix
With a little practice you will be able to find errors quickly.
Appendix
The appendix is a reference for topics that would go too deep in the main chapters or that provide additional context.
Here you will find:
- Project structure – Understanding files & folders
- Configuration – Config details & migration
- Update process – How updates work
- Glossary – All technical terms explained (very important!)
What’s in the Appendix?
Plugins without Python (main.exe required)
How to write plugins in languages other than Python, what you need to keep in mind for registration, and why main.exe is mandatory. Also how you can call other files/scripts from main.exe.
Glossary START HERE if you’re unsure
This is your reference. If you don’t understand a term:
- Event – What is that?
- Queue – How does a queue work?
- DCS/ICS – What is the difference?
- Threading – Why is this important?
- And 50+ more terms!
Core Modules of the Infrastructure
For advanced developers: Understand the technical infrastructure:
- paths.py – Path management
- utils.py – Load configuration
- models.py – Data structures (AppConfig)
- validator.py – Syntax validation
- cli.py – Command Line Arguments
Here you will learn how the core modules work together and how plugins use them.
Project Structure
Understand how the project is organized:
- src/ – Source code
- defaults/ – Template configurations
- config/ – User settings
- data/ – Persistently stored data
- build/release/ – Finished distribution
Important: Difference between development structure and release structure.
Configuration
Details about config.yaml:
- How do you load configuration in code?
- What is
config_version? - How does config migration work?
- Where do the values go?
Update Process
For maintainers & advanced developers:
- How are updates downloaded?
- What is overwritten and what is not?
- How does the updater update itself?
- Which files are safe?
When Should I Use the Appendix?
| Situation | Appendix Chapter |
|---|---|
| "I don’t understand this term" | → Glossary |
| "How does the infrastructure work?" | → Core Modules |
| "Where is the config.yaml file?" | → Project Structure |
| "What config keys are there?" | → Configuration |
| "How do we test updates?" | → Update Process |
| "I’m writing a maintainer guide" | → All of the above |
The Appendix Is Constantly Being Expanded
If you are missing topics that should be here:
- Database schema
- Performance Optimization Guide
- API documentation
- Migration Guides
...then give feedback or write it yourself!
See also
- Glossary – The most important reference
- Debugging & Troubleshooting – When something doesn’t work
- Main Documentation – Back to the table of contents
Glossary
A complete reference of all technical terms and concepts. If you come across an unfamiliar term, you will find a quick explanation here.
A
Action
An operation that the system performs (e.g. "send command to Minecraft"). Actions are triggered by events and configured in actions.mca.
Example: The gift event "Rose" triggers the action "play_sound".
Async / Asynchronous Programming
Programming in which multiple tasks run concurrently rather than sequentially. One process does not have to wait for another to finish.
In this project: TikTok events arrive (Async 1) while the main loop processes Minecraft commands (Async 2).
API (Application Programming Interface)
An interface through which two programs communicate with each other.
In this project: We use the TikTokLive API (to receive events) and the RCON API (to send commands to Minecraft).
C
Combo Gift
A gift that can be sent multiple times in a row. The viewer sees an animation showing the total number of gifts.
Example: Someone sends the same rose 5 times → the viewer sees "Rose ×5".
In code: event.gift.combo == True and event.repeat_count == 5
CORS (Cross-Origin Resource Sharing)
A security mechanism in web servers that determines which external websites are allowed to send requests.
In this project: Our Flask server uses CORS to give plugin GUIs access to the APIs.
Command-Line Argument / Flag
A value passed when starting a program.
Example:
python main.py --gui-hidden --register-only
Here, --gui-hidden and --register-only are flags.
D
DCS (Direct Control System)
A communication protocol in which data is transmitted directly via HTTP.
Advantage: Fast & reliable.
Disadvantage: Requires open HTTP ports.
Alternative: ICS (Interface Control System).
Decorator
A Python feature (@...) that "decorates" a function with additional behavior.
Example:
@client.on(GiftEvent)
def handle_gift(event):
pass
The @client.on(...) decorator registers this function as an event handler.
Dependency
An external package that your project needs.
In this project:
TikTokLive– dependencyFlask– dependency- All are listed in
requirements.txt.
E
Event
An occurrence that happens in the system.
Examples:
- Someone sends a gift
- Someone follows
- A player dies in Minecraft
- The server starts
All events have properties (data), e.g. event.user, event.gift.name.
Event Handler
A function that reacts to an event.
Example:
@client.on(GiftEvent)
def on_gift(event):
print(f"Gift received: {event.gift.name}")
on_gift is the event handler for GiftEvents.
F
Flask
A Python web framework for building web servers.
Usage in our project:
- Webhooks (receiving Minecraft events)
- GUIs (pywebview uses Flask for the backend API)
Not to be confused with: Django (different framework), FastAPI (newer standard).
Function
See Handler / Function.
G
Glossary
This document! A reference of technical terms.
H
Handler
See Event Handler.
HTTP / HTTPS
Network protocols for transferring data over the internet / network.
HTTP = unencrypted (but faster)
HTTPS = encrypted (but more complex)
In this project: We use HTTP locally (not over the internet).
I
ICS (Interface Control System)
A communication protocol in which data is transmitted via GUI / screen capture.
Advantage: Works everywhere (including TikTok Live Studio).
Disadvantage: Slower & more complex.
Alternative: DCS (Direct Control System).
Import
Loading external code into your program.
Example:
from core import load_config
This loads the load_config function from the core module.
J
JSON
A data format for storing and transmitting structured data.
Format:
{
"name": "John",
"age": 30,
"gifts": ["Rose", "Diamond"]
}
In this project: Used for configurations, window states, and data.
L
Logging / Logs
Recording program operations to a file or as output.
Purpose: Debugging & monitoring.
Example:
logging.info("Gift received")
logging.error("Connection failed")
Logs end up in logs/debug.log.
M
main.py
The main program file of the project. It connects TikTok to the rest of the system.
Middleware
Software that mediates between two other systems.
In our project: main.py contains the webhook endpoint and acts as middleware between Minecraft and the plugins. server.py, on the other hand, starts the Minecraft server itself.
Migration
The process of transferring data from an old format to a new one.
In this project:
- Config migration: Old
config.yamlis updated to match the new structure - See Config for details.
Module
A reusable code building block that other files/projects can use.
In this project:
core/models.py– data structures modulecore/paths.py– path module- Always located in the
src/core/folder.
O
Overlay
A visual element that is overlaid on the stream / screen.
Example: A counter on the screen shows "Deaths: 5", "Likes: 200".
In this project: Plugins use overlays to display data visually.
P
Parameter
A value passed to a function.
Example:
def create_client(user): # 'user' is the parameter
...
create_client("my_streamer") # 'my_streamer' is passed
Path
A file path in the file system.
Windows: C:\Users\...\config\config.yaml
Linux: /home/.../config/config.yaml
Plugin
An independent program that integrates into the Streaming Tool.
Examples:
- Timer (countdown timer)
- DeathCounter (counts deaths)
- Your custom plugin
Port
A virtual "port" through which a server accepts connections.
Example: http://localhost:8080 – the port is 8080.
Config: All ports are configurable in config.yaml.
Pseudo Code
Simplified code that is not syntactically correct but illustrates the logic.
Example:
1. When gift arrives
2. Find configuration
3. Execute action
Q
Queue
A data structure that stores elements in the order in which they were added (FIFO: First-In-First-Out).
In our project:
trigger_queue: Stores the triggers to be processedlike_queue: Stores like updates for the overlay
R
Race Condition
A bug that occurs when two threads access the same resource at the same time.
Example: Thread 1 reads event.total, Thread 2 changes it → inconsistency!
Solution: Lock (Mutex) – only one thread may access at a time.
Registry
A central management file or system.
In this project: PLUGIN_REGISTRY registers all modules & plugins with their settings.
RCON (Remote Console)
A protocol through which you can send Minecraft commands remotely.
Example: Instead of typing directly in the Minecraft console, the tool sends the command /say "Hey!" via RCON.
Reverse Engineering
Reconstructing a system by observing its behavior from the outside.
In this project: The TikTokLive API is based on reverse engineering (not officially provided by TikTok).
S
Streak
A combo that is triggered multiple times in a row.
Example: The same rose is sent 5 times in a row → "Streak: 5".
SQL / Database
A system for structured data storage (not centrally relevant in this project).
T
Thread / Threading
An independent execution flow within a program.
Analogy: A program with 2 threads is like a streamer with 2 microphones – both can speak at the same time.
Danger: Race conditions (two threads modifying data in parallel).
Trigger
A condition that causes an action to execute.
Example:
gift_1001is a trigger (when this gift arrives)followis a trigger (when someone follows)- When a trigger fires → the action is executed
TikTokLive API
The external library through which we access TikTok live streams.
Based on: Reverse engineering (unofficial).
Usage: from TikTokLive import TikTokLiveClient
U
Update
A new release / version of the project.
Process:
- New version is uploaded to GitHub
- User starts the program
- Update script downloads the new version
- Old data is retained (
config/,data/are not overwritten)
Details: See Update.
V
Validator
A module that checks whether data is correct.
Example: validator.py checks whether config.yaml is valid YAML.
Variable
A named container for data.
Example: gift_name = event.gift.name – gift_name is the variable.
W
Webhook
A mechanism where one system automatically sends data to another when something happens.
In this project:
- Minecraft server sends a webhook to our tool (when a player dies, spawns, etc.)
- Our tool then processes the webhook
Format: Typically HTTP POST with JSON data.
X
(no common terms)
Y
YAML
A data format for storing structured data (similar to JSON, but more human-readable).
Format:
timer:
enable: true
start_time: 10
max_time: 60
In this project: config.yaml is written in YAML.
Z
Centralization
The concept of managing everything in one place.
In this project: PLUGIN_REGISTRY is the central management system for all modules.
Quick Index by Category
Event System
- Event, Event Handler, Trigger, Action
- Webhook, RCON
Technology
- API, HTTP/HTTPS, Port
- Thread/Threading, Race Condition
- Async, Middleware
Python & Code
- Import, Module, Decorator
- Parameter, Variable, Handler
- Function, Logging
Data Formats
- JSON, YAML, Database
Structure & Organization
- Registry, Migration
- Queue
- Path
Control & Communication
- DCS, ICS, Flask
- CORS
Debugging & Development
- Logging, Validator
- Pseudo Code, Reverse Engineering
"I still don't understand term XYZ!"
That's OK. Here are your options:
- Re-read: Simply read it again
- Context: Search for the term in the documentation – context helps
- Read code: Look at how the term is used in actual code
- Ask: Other developers or an AI for help
Core Modules and Infrastructure
[!NOTE] This overview is aimed at advanced developers who want to understand how the system's infrastructure is structured. Intermediate Python knowledge is assumed.
Overview
The core modules are located in src/core/ and form the infrastructure of the system. They are not directly visible in the stream – but every plugin uses them in the background.
| Module | Purpose |
|---|---|
| paths.py | Directory management & paths |
| utils.py | Load configuration, helper functions |
| models.py | Data structures (AppConfig) |
| validator.py | Syntax validation (actions.mca) |
| cli.py | Command-line arguments |
paths.py – Path Management
What Is It?
paths.py handles where everything is in the file system.
Short Examples
from core.paths import get_root_dir, get_config_file
# Where is the project?
root = get_root_dir()
# → "C:\Users\User\Streaming_Tool" or ".\build\release"
# Where is config.yaml?
config = get_config_file()
# → "C:\Users\User\Streaming_Tool\config\config.yaml"
Important Functions
get_root_dir()– Project rootget_config_file()– Path to config.yamlget_base_dir()– Base directory (distinguishes frozen vs. development)
What do you need this for?
So that plugins don't have to hard-code C:\...\config.yaml, but can simply call get_config_file().
utils.py – Configuration & Helper Functions
What Is It?
utils.py loads and parses the config.yaml file. This is the central configuration file of the system.
Short Examples
from core.utils import load_config
from core.paths import get_config_file
# Load config
config = load_config(get_config_file())
# Access values
plugins = config["plugins"]
log_level = config["settings"]["log_level"]
What Does load_config() Do?
- Checks whether the file exists
- Parses YAML
- Returns a dictionary
- On error: Terminates the program with a meaningful error message
What do you need this for?
Every plugin needs to read config.yaml. load_config() does this reliably – with error handling.
models.py – Data Structures
What Is It?
models.py defines AppConfig – the data structure that describes a plugin (how it is registered in the registry).
The AppConfig Structure
@dataclass
class AppConfig:
name: str # Name of the plugin
path: Path # Where is the plugin located?
enable: bool # Is it enabled?
level: int # Priority / log level
ics: bool # Does it have a GUI window?
Short Examples
from core.models import AppConfig
from pathlib import Path
# Define a plugin
timer = AppConfig(
name="Timer",
path=Path("src/plugins/timer"),
enable=True,
level=1,
ics=True # Has GUI
)
# As dictionary for config.yaml
plugin_dict = timer.to_dict()
# → {"name": "Timer", "path": "src/plugins/timer", ...}
# Back from dictionary
timer2 = AppConfig.from_dict(plugin_dict)
What do you need this for? The PLUGIN_REGISTRY manages all plugins as AppConfig objects. This keeps management structured and validated.
validator.py – Syntax Validation
What Is It?
validator.py checks the actions.mca file for errors. It detects:
- Missing colons
- Invalid syntax
- Duplicate triggers
- Formatting errors
Short Examples
from core.validator import validate_text
text = """
5655:!tnt 2 0.1 2 Notch
follow:/give @a minecraft:golden_apple 7
invalid_line_without_colon
"""
diags = validate_text(text) # List of errors
for diag in diags:
print(f"[{diag.severity}] Line {diag.line}: {diag.message}")
# → [ERROR] Line 3: Missing colon
What Is Validated?
✓ Every line must have the TRIGGER:... format
✓ No spaces immediately after :
✓ No duplicate triggers
✓ Correct command format
What do you need this for? So that users can quickly see errors in their actions.mca – with exact line numbers and error codes.
cli.py – Command-Line Arguments
What Is It?
cli.py parses command-line arguments at startup:
python main.py --gui-hidden --register-only
Available Arguments
| Argument | Effect |
|---|---|
--gui-hidden | Start without GUI window (headless) |
--register-only | Only register plugins, then exit |
Short Examples
from core.cli import parse_args
args = parse_args()
if args.gui_hidden:
print("Starting in headless mode")
if args.register_only:
print("Only updating registry, then exit")
# ... plugin registry update ...
sys.exit(0)
What do you need this for? It enables different start modes (for testing, automation, maintenance).
Summary
The core modules are the infrastructure layer:
| Module | Benefit |
|---|---|
| paths.py | Finding the correct paths (dev vs. release) |
| utils.py | Loading config reliably |
| models.py | Managing plugin metadata |
| validator.py | Finding and reporting errors |
| cli.py | Different start modes |
Practical for developers:
- Plugin developers: Mainly use
paths.pyandutils.py - System developers (core): Use all modules
- The system itself: Uses everything together for registration & management
Project Structure: Development vs Release
[!WARNING] This part of the documentation is only lightly maintained. Content may be out of date or may have been partially generated by AI and could therefore contain errors.
Two Different Structures
The project has two completely different folder structures:
- Development structure (for working) → Source code, tests, docs
- Release structure (for users) → Compiled EXEs, finished config
[!WARNING] You MUST always use the correct structure for the situation. Wrong paths = errors!
Development Structure (for Development)
Streaming_Tool/ ← Root (this is where you work)
├── src/ ← SOURCE CODE
│ ├── python/ ← Python files
│ ├── plugins/ ← Plugin source code
│ └── core/ ← Core modules
├── defaults/ ← Template files
│ ├── config.default.yaml ← Config template
│ ├── actions.mca ← Command template
│ └── ...
├── docs/ ← Documentation (here!)
├── build.ps1 ← Build script
├── build/ ← Build intermediates
│ └── cache/ ← Cached EXEs
└── tests/ ← Unit tests
Release Structure (What Users Get)
build/release/ ← READY TO RUN
├── core/ ← Compiled EXEs
│ ├── app.exe ← Main program
│ ├── update.exe ← Updater
│ └── runtime/ ← .NET Runtime
├── config/ ← User settings
│ ├── config.default.yaml ← Template (do not edit!)
│ └── config.yaml ← User copies this!
├── data/ ← Persistent data
│ ├── plugin_state.json ← State storage
│ └── logs/ ← Error logs
├── plugins/ ← Finished plugin EXEs
│ ├── deathcounter.exe
│ ├── timer.exe
│ └── ...
├── version.txt ← Version & Updater version
├── README.md ← Info for users
└── LICENSE ← License
Important Differences
| Aspect | Development | Release |
|---|---|---|
| Path in code | src/python/ | ./ (EXE runs from release root) |
| Load config | defaults/config.yaml | config/config.yaml |
| Save data | PROJECT_ROOT/data | ./data/ (relative to EXE) |
| Logs | .../logs/ | ./data/logs/ |
| Generated by | (you write) | build.ps1 generates |
Critical Rules
- ☑ Always use release paths in code (as soon as it is deployed)
- ☑ Config NEVER in the source code (always relative
./config/) - ☑ Data persists across updates (
./data/is not deleted) - ☑ Develop docs in the dev folder (
docs/dev-book-de/src/) - ☑ After changes: test build.ps1 (checks paths)
[!WARNING] Make sure that for file paths and other file-related operations, you always use the release structure. The development structure differs significantly from the release structure. If you accidentally use paths from the development structure, this can lead to errors because files in the release are located elsewhere or are structured differently.
Pay particular attention to the following points:
- Always use the paths from the release structure in code, scripts, and documentation when referring to the finished project.
- Do not rely on development files being present in the release.
- For new files or folders, check whether they are correctly included in the release by
build.ps1.- When changing configurations or runtime data, think carefully about whether they belong in
config/,data/,core/, orruntime/.- Always test the build after making changes so that no missing paths or broken references are overlooked.
- Make sure updates do not overwrite important data, especially in the
data/andconfig/directories.
Development Structure
The development structure contains everything needed to build the project.
.
├── assets
│ └── gifts_picture
├── build
│ ├── exe_cache
│ └── release
├── build.ps1
├── defaults
│ ├── actions.mca
│ ├── config.default.yaml
│ ├── config.yaml
│ ├── configServerAPI.yml
│ ├── DelayedTNTconfig.yml
│ ├── gifts.json
│ ├── http_actions.txt
│ └── pack.mcmeta
├── docs
│ └── dev-book
├── LICENSE
├── README.md
├── src
│ └── python
├── static
│ └── css
├── templates
│ ├── db.html
│ └── index.html
├── tests
├── tools
│ ├── DelayedTNT.jar
│ ├── Java
│ ├── MinecraftServerAPI-1.21.x.jar
│ └── server.jar
└── upload.ps1
assets/
This is where the project's visual resources are located.
The subfolder gifts_picture/ contains images for the gifts.
build/
This folder is used by the build process. It does not contain any development logic — only intermediate results and the finished output.
exe_cache/stores EXE files so they don't have to be regenerated every time.release/is the destination folder for the finished project.- Hash files can also be found there. They tell the program whether it needs to build a new EXE or whether it can be copied from the
exe_cachefolder.
build.ps1
The build script is the central engine of the project. It collects the individual components, processes them, and creates the finished version.
If you make changes to the project, make sure to also update the build.ps1 script so that everything is built correctly.
Unless, of course, you want to build manually — that's possible too.
defaults/
This folder contains the default files for the project. They serve as the starting point for configurations, data, and project values.
Important files include:
config.default.yamlconfig.yamlconfigServerAPI.ymlDelayedTNTconfig.ymlgifts.jsonactions.mcahttp_actions.txtpack.mcmeta
This folder is particularly important because it defines the initial state. When the project is set up fresh, these files serve as the baseline.
docs/
Additional documentation, notes, and technical information can be found here.
docs/dev-book/
The technical documentation is located here as an mdBook project.
book/contains the generated documentationsrc/contains the actual chapter texts
This is the written companion to the project.
src/
The actual source code lives here.
python/contains the Python part of the application
This is where logic is created, modified, and extended.
static/
This folder contains static resources for appearance and user interface.
css/for stylesheets and layout
Everything that can be served directly without dynamic computation belongs here.
templates/
This folder contains HTML templates.
index.htmldb.html
These templates define the structure for pages and interfaces that are later populated with data.
tests/
The test area is used to verify the project's behavior. Tests, verification runs, and automated checks can be carried out here.
tools/
This folder contains additional tools and external dependencies.
DelayedTNT.jarMinecraftServerAPI-1.21.x.jarserver.jarJava/
These files and folders are important for features that rely on external components.
LICENSE
The license file defines the legal framework for use.
README.md
The README is the first point of contact for anyone who wants to understand or start the project. It provides an overview of the content, purpose, and basic usage.
upload.ps1
Another PowerShell script that is automatically generated by the build.ps1 file.
It is used to upload the project directly to GitHub with one click.
Release Structure
After the build process, the finished project version is created in the build/release/ folder.
.
├── config
│ ├── config.default.yaml
│ └── config.yaml
├── core
│ ├── app.exe
│ ├── assets
│ ├── gifts.json
│ ├── gui.exe
│ ├── lib
│ ├── LikeGoal.exe
│ ├── mcServerAPI.exe
│ ├── Overlaytxt.exe
│ ├── PortChecker.exe
│ ├── runtime
│ ├── static
│ ├── templates
│ ├── timer.exe
│ ├── validation.exe
│ ├── WinCounter.exe
│ └── window.exe
├── data
│ ├── actions.mca
│ └── http_actions.txt
├── LICENSE
├── logs
├── README.md
├── scripts
├── server
│ ├── java
│ └── mc
├── server.exe
├── start.exe
├── update.exe
└── version.txt
config/
The runtime-relevant configuration files are located here.
config.default.yamlconfig.yaml
This means both a default and a customized configuration are available.
core/
This is the heart of the finished application. The executable software and the resources needed at runtime are located here.
Included are, among others:
app.exegui.exeLikeGoal.exemcServerAPI.exeOverlaytxt.exePortChecker.exetimer.exevalidation.exeWinCounter.exewindow.exe
Additionally:
assets/gifts.jsonlib/runtime/static/templates/
This folder bundles the actual functional core of the release.
The runtime/ folder is particularly useful for data needed at runtime that is not relevant to the user.
[!CAUTION] When the program is updated, the entire
core/folder is overwritten. If you have data that should not be overwritten, use thedata/folder.
data/
Data needed at runtime or for certain features is stored here.
Unlike the runtime/ folder, data/ is never overwritten.
actions.mcahttp_actions.txt
logs/
The log folder stores logs and traces. This is important for tracing errors, events, and the behavior of the application.
scripts/
This folder contains additional scripts for the finished version. Helper functions or maintenance routines can be located here.
server/
Server-related components are located here.
java/mc/
server.exe
An executable for starting the server.
start.exe
The main entry point for users. Anyone launching the release typically starts here.
update.exe
This program handles updates. It can be used to install new versions or update existing installations.
version.txt
A small but important file containing the build version information. It also contains the version of the updater.
LICENSE and README.md
These files are also included in the release. This ensures that terms of use and a short introduction are always bundled.
Summary
The project is structured so that everything stays neatly organized and the individual parts can be managed well. Take your time to explore the project structure and go through the folders one by one. This will give you a quick feel for where each piece of data belongs.
The build.ps1 script is particularly important. It's your best friend because it builds everything automatically.
If you had to do this manually, it would take a very long time. It's therefore worth knowing the build file well and adapting it when changes are made to the project.
Configuration: config.yaml in Detail
[!WARNING] This part of the documentation is only lightly maintained. Content may be out of date or may have been partially generated by AI and could therefore contain errors.
Two-File System
The Streaming Tool strictly separates template and user configuration:
- config.default.yaml (Template) → overwritten during updates
- config.yaml (User) → persistent, never overwritten
Why? Updates can introduce new config keys without deleting user data.
The System
Update starts
↓
Checks config_version
↓
default > user?
↙ ↘
Yes No
↓ ↓
Migration No change
(Merge Keys) (User values remain)
Migration: Step by Step
1. Check version
# config.default.yaml
config_version: 2
# config.yaml (user)
config_version: 1 ← older!
2. System detects: Migration needed
3. Perform merge:
- Adopt new keys from default → into user
- Preserve user values (do not overwrite!)
- Delete old keys from user →
- Preserve comments (if before keys)
4. config.yaml rewritten with version: 2
Code: Loading Config with Fallbacks
import yaml
import sys
CONFIG_FILE = "config/config.yaml"
CONFIG_DEFAULT = "config/config.default.yaml"
def load_config():
"""Load config with error handling."""
try:
with open(CONFIG_FILE, "r", encoding="utf-8") as f:
cfg = yaml.safe_load(f)
if cfg is None:
cfg = {}
return cfg
except FileNotFoundError:
print("config.yaml not found! Using defaults.")
return load_default_config()
except Exception as e:
print(f"Config error: {e}")
return {}
def load_default_config():
"""Fallback to config.default.yaml."""
try:
with open(CONFIG_DEFAULT) as f:
return yaml.safe_load(f) or {}
except:
return {}
# Read values with defaults
cfg = load_config()
port = cfg.get("WebServer", {}).get("Port", 5000)
enabled = cfg.get("MyPlugin", {}).get("Enable", True)
Config Value Access (Best Practices)
# CORRECT: With .get() + defaults
log_level = cfg.get("Log", {}).get("Level", "INFO")
# INCORRECT: Direct access
log_level = cfg["Log"]["Level"] # KeyError risk!
# Deep get
db_host = cfg.get("Database", {}).get("Host", "localhost")
db_port = cfg.get("Database", {}).get("Port", 5432)
# Entire section with default
timer_cfg = cfg.get("Timer", {})
Checklist for Config Changes
- ☑ New keys → add to
config.default.yaml - ☑
config_versionincremented? - ☑ Comments BEFORE the first key are preserved
- ☑ Code uses
.get()with defaults - ☑ Test: Does migration work?
Whether a config migration is necessary is controlled via config_version. It is located at the beginning of the files:
config_version: 1
- Data type: Integers only.
- Logic: A migration is only triggered if the
config_versioninconfig.default.yamlis greater than the one in the currentconfig.yaml.
Migration Process
The migration process runs recursively through all levels of the configuration file. The following rules apply:
- Preserving user values: Values that the user has customized in their
config.yamlwill not be overwritten. - Cleanup: Keys that no longer exist in the new
config.default.yamlare removed fromconfig.yaml. - Completeness: New keys from the template are adopted into the user config.
Everything before the first key in config.default.yaml is not copied into config.yaml.
In this case, that means all comments above config_version are not copied.
Keep this in mind when making modifications.
# -------------------------------------------------------------------------
# STREAMING TOOL CONFIGURATION TEMPLATE
# -------------------------------------------------------------------------
# This file is a template.
# Personal settings should be changed in 'config.yaml' only.
# -------------------------------------------------------------------------
config_version: 1
Disabling Migration
In config.yaml, the automatic configuration update can be disabled:
auto_update_config: true
If this value is set to false, no comparison with the default file takes place.
[!WARNING] Disabling this option requires fully manual maintenance of the configuration. There is no CLI command to trigger the migration afterwards. An outdated structure can lead to errors or program crashes.
Reading Values from the Config
To use configuration values in code, config.yaml is loaded into a dictionary (here cfg). Values are then accessed via the corresponding keys.
Loading the Configuration
The following block shows the standard way of reading the file. It ensures that the program terminates in a controlled manner if a read error occurs:
import yaml
import sys
try:
with open(CONFIG_FILE, "r", encoding="utf-8") as f:
cfg = yaml.safe_load(f)
except Exception as e:
print(f"Error loading config: {e}")
input("Press Enter to exit...")
sys.exit(1)
Usage in Code
Once the variable cfg is populated, values can be accessed. Since the migration also supports nested structures, deeper levels are accessed via multiple keys:
# Access a top-level key
auto_update = cfg.get("auto_update_config", True)
# Accessing nested values (example)
# Suppose the config has a structure like:
# database:
# host: "localhost"
db_host = cfg.get("database", {}).get("host", "127.0.0.1")
# Using config_version for logic checks
if cfg.get("config_version", 0) < 2:
# Specific logic for older config versions
pass
[!TIP] Working with Dictionaries
Since the configuration is a standard Python dictionary after loading, you should familiarize yourself with advanced methods for data manipulation. This saves lines of code and prevents runtime errors.
Particularly relevant are:
- Safe access (
.get()): AvoidKeyErrorcrashes by defining default values directly when reading.- Nested structures: Learn how to access deeper levels efficiently (e.g. via
cfg['database']['host']or safer chaining).- Type hinting: Look into how to use type hints so your IDE can assist you while coding and you know exactly whether a value should be an
int,bool, orstr.- Exceptions: Understand how to catch specific errors when parsing YAML files to show the end user helpful error messages instead of cryptic tracebacks.
Update Process: How Is the Software Updated?
[!WARNING] This part of the documentation is only lightly maintained. Content may be out of date or may have been partially generated by AI and could therefore contain errors.
Concept: Automatic Updates Without Data Loss
The updater downloads new versions from GitHub without deleting user data:
User clicks "Check for Updates"
↓
Updater checks GitHub Release API
↓
New version found?
↙ ↘
Yes No
↓ ↓
Download No change
+ Extract
↓
Migration:
New keys → config
User values remain
Old keys → delete
↓
Restart → Done!
Updater Architecture
GitHub Release (Tag = Version)
│
├─ version.txt (Tool Version + Updater Version)
├─ update.exe (self-updatable updater)
├─ app.exe (main program)
├─ core/ (plugins, runtimes)
├─ config.default.yaml
└─ README.md
│
↓ (Download + Extract)
_update_tmp/ (temporary)
│
↓ (version check)
Updater self-update?
│
↓ (No or Done)
Copy files to ./core/ (with whitelist)
↓
config/config.yaml migration
↓
Update version.txt
↓
Restart
Update Flow: 3 Scenarios
Scenario 1: Normal Update
1. Download release (.zip from GitHub)
2. Extract to _update_tmp/
3. Check: read version.txt
4. If updater itself is new: copy update.exe → update_new.exe
5. Start update_new.exe with --resume _update_tmp/
6. (Updater restarts itself)
7. Then update app.exe
Scenario 2: Updater Needs to Update Itself
1. Check: UpdaterVersion in release > local version?
2. Yes → update.exe → update_new.exe
3. Run update_new.exe with --resume
4. Old process ends
5. New process continues
6. (Prevents conflict when updating the running .exe)
Scenario 3: Config Migration
1. Check: config_version in default
2. Is it greater than in user?
3. Merge new keys from default
4. User values are retained
5. Old keys are deleted
Version Format
# version.txt
ToolVersion: 1.2.3
UpdaterVersion: 1.0.2
# Regex pattern (accepted):
# 1.2.3
# 1.2.3-beta
# 1.2.3-alpha
# 1.2
# Comparison: "1.2.3" > "1.2.0" = True
# Comparison: "1.2.3-beta" > "1.2.3" = False (pre-release is older)
What Gets Overwritten?
WILL be overwritten (safe):
core/(EXEs, runtime)config/config.default.yaml(it's just a template)- Plugin EXEs in
core/ - Documentation (README)
WILL NOT be overwritten (user data!):
config/config.yaml(your settings)data/(counters, logs, states)plugins/(user plugins)
Checklist: Update-Safe
- ☑ Important user data in
./data/ - ☑ Configuration only in
config/config.yaml - ☑
version.txtupdated after release? - ☑ GitHub release tagged correctly?
- ☑ Beta releases work with confirmation?
The updater downloads the current version from GitHub, unpacks the release package, and copies the released files to the target directory.
The following basic rules apply:
- User data must not be overwritten.
- The updater reads the local version from
version.txt. - The configuration is loaded from
config/config.yamlat startup. - The updater works within the directory where the EXE is located.
- The release package is downloaded as a
.zipfrom GitHub. - The update logic differentiates between tool version and updater version.
Expected Files and Paths
The updater uses these local paths:
version.txtconfig/config.default.yamlconfig/config.yaml_update_tmp/as a temporary working directory
Additionally, it uses the GitHub Release API for this repository:
TechnikLey/Streaming_Tool
GitHub is accessed via the latest release API.
For API and asset requests, an optional GITHUB_TOKEN can be loaded from the environment.
Version Management
The file version.txt contains two entries:
ToolVersionUpdaterVersion
The updater reads both values and compares them with the versions from the current GitHub release.
Version evaluation uses a regex pattern that detects versions in these formats:
1.2.31.21.2.3-beta1.2.3-alpha
If the online tool version is not newer than the local tool version, the updater exits without making any changes.
Update Process
1. Normal Start
If no --resume flag has been passed, the following happens:
- The updater checks the current GitHub release.
- The tag version is read from
tag_name. - If the release has a
betatag, a confirmation prompt appears. - The
.zipasset from the release is downloaded. - The archive is extracted to
_update_tmp/. - If the archive contains only a single root folder, that folder is used as the base.
2. Resume Mode
If --resume <path> has been passed, the updater directly uses the already extracted files from that path.
In this case, the entire download step is skipped.
Self-Update of the Updater
The updater first checks whether a newer UpdaterVersion is included in the release than is installed locally.
If so:
update.exefrom the unpacked release is copied to the base directory asupdate_new.exe.- The new updater version is saved in
version.txt. - The current process is replaced via
os.execv(...)byupdate_new.exe. - The new process continues with
--resume <extracted_root>.
This means:
- The updater updates itself before the actual tool update.
- The tool update only continues in the new process.
- The running
update.exeis not overwritten during the running process.
Tool Update
After the self-update (or if no new update.exe is needed), the actual tool update follows.
Before copying, a signal file is written:
update_signal.tmp
This file is created in the current working directory. The updater then waits briefly so that the start process has time to finish.
The updater then copies the files from the release to the target directory.
What Gets Copied
The updater works with a whitelist.
Allowed Directories
Only these top-level directories are processed:
corescriptsserverconfig
Allowed Root Files
Only these top-level files are copied:
version.txtREADME.mdLICENSEupdate.exeserver.exestart.exe
Additional Rules
update.exeis skipped during normal copying.config.yamlis skipped during normal copying.- All other files outside the whitelist are ignored.
The update therefore only affects areas that have been explicitly whitelisted.
WHITELIST_DIRS = {"core", "scripts", "server", "config"}
WHITELIST_FILES = {"version.txt", "README.md", "LICENSE", "update.exe", "server.exe", "start.exe"}
Configuration Migration
If auto_update_config is enabled in the loaded configuration, the updater performs a configuration migration.
Migration Process
config/config.default.yamlis loaded as a template.config/config.yamlis loaded as user data.- If
config/config.yamlis missing, it is recreated from the default file. - A backup is created before migration:
config/config.yaml.bak
- The structure of the template is preserved.
- Only values that exist in the template are copied from the user version.
- Keys that only exist in the old user version are removed.
config_versionis set to the version of the template.
Important Property
Migration is strict:
- The structure comes from the default file.
- User values are only adopted where the template has a matching key.
- Additional old keys are not retained.
Create a Plugin without Python
[!WARNING] No guarantee for the code examples.
The code examples in this chapter (Rust, C++) were generated by an AI. I have little knowledge of these languages myself and cannot guarantee that they are correct, complete, or error-free. Use them as a starting point and review them carefully before using them in production.
Overview
You can write a plugin in any language that produces a native Windows .exe. The system exclusively starts the file main.exe in your plugin folder — how it is produced (MSVC compiler, Rust/Cargo, MinGW, etc.) is irrelevant.
[!IMPORTANT]
main.exeis the only required file of the plugin system.
Python plugins work exactly the same way:main.pyis compiled tomain.exevia PyInstaller. From the system's perspective there is no difference.
What you need to implement yourself (Python handles this automatically via the core module):
- Registration protocol (
--register-only) - Argument parsing (
--gui-hidden) - HTTP server for
/webhook - Reading configuration (YAML / JSON)
- Data persistence
Folder Structure
src/plugins/
└── myplugin/
├── main.exe ← started by the system (compiled from your code)
├── README.md
└── version.txt
During the build, the entire src/plugins/myplugin/ folder is copied to build/release/plugins/myplugin/.
How the Registry Scanner Works
Before the main program starts any plugins, registry.exe runs first. It searches for all main.exe files in the plugins/ folder and executes each one with --register-only:
registry.exe
├── finds: plugins/myplugin/main.exe
├── calls: main.exe --register-only (cwd = plugins/myplugin/)
├── reads stdout, parses first valid JSON object
└── saves metadata to PLUGIN_REGISTRY.json
start.py then reads PLUGIN_REGISTRY.json and starts every enabled main.exe (this time without --register-only).
[!NOTE] The scanner caches registration results based on file size and modification time. If you recompile
main.exe, it will automatically be rescanned on the next start.
The Registration Protocol
Required Format
When your plugin is started with --register-only, it must print a single line to stdout in the following format and then exit with code 0:
REGISTER_PLUGIN: {"name":"MyPlugin","path":"C:\\absolute\\path\\to\\main.exe","enable":true,"level":4,"ics":false}
The REGISTER_PLUGIN: prefix is recommended (exactly as Python outputs it), but the scanner also accepts a line that is directly parseable as a JSON object.
Required Fields
| Field | Type | Description |
|---|---|---|
name | string | Unique name of the plugin |
path | string | Absolute path to main.exe |
enable | bool | Whether the plugin is started on launch |
level | int | Visibility level (see below) |
ics | bool | Does the plugin have a GUI window? |
Visibility Level (level)
Controls whether the plugin's console window is shown, depending on log_level in config.yaml:
| Level | Meaning |
|---|---|
| 0 | Forbidden – overrides all visibility rules, never use! |
| 1 | For very important output only |
| 2 | Main programs |
| 3 | Background services |
| 4 | Debug/Development ← recommended for custom plugins |
| 5 | Forbidden – overrides all visibility rules, never use! |
[!NOTE] Level 0 and 5 must not be used. When
log_level = 0orlog_level = 5is set inconfig.yaml, these values override all visibility rules for all programs and plugins. No plugin or program may set level 0 or 5.
ICS vs. DCS
ics: false→ Plugin without GUI (HTTP server running in the background only). This is the default.ics: true→ Plugin opens a window. Ifcontrol_methodinconfig.yamlis set toDCS, your plugin will be started with--gui-hidden(do not open a window).
The path Value
The path value must be the absolute path to main.exe. Since the scanner calls your plugin using its full absolute path, you can derive it from the process itself:
- Rust:
std::env::current_exe()– the most reliable method - C++: Win32 API
GetModuleFileNameA(NULL, buf, MAX_PATH)orstd::filesystem::absolute(argv[0])
Argument Handling
Your plugin must recognize at least two arguments:
| Argument | Behavior |
|---|---|
--register-only | Print JSON, exit immediately |
--gui-hidden | Do not open a window (only relevant if ics: true) |
Implementing the Webhook Server
The Minecraft plugin sends HTTP POST requests to all configured URLs when events occur. Your plugin can start an HTTP server and provide the /webhook endpoint.
Event Payload
{
"load_type": "INGAME_GAMEPLAY",
"event": "player_death",
"message": "Player died from fall damage"
}
load_type can be e.g. INGAME_GAMEPLAY or STARTUP. event corresponds to the event name from configServerAPI.yml.
Common Events
| Event | Active by default |
|---|---|
player_death | ✓ |
player_respawn | ✓ |
player_join | – |
player_quit | – |
block_break | – |
entity_death | – |
The full list can be found in configServerAPI.yml.
Configuring the Port
Add a port entry for your plugin in config.yaml:
MyPlugin:
Enable: true
WebServerPort: 8888
Then add the webhook URL to configServerAPI.yml (Minecraft plugin config):
webhooks:
urls:
- "http://localhost:7777/webhook"
- "http://localhost:7878/webhook"
- "http://localhost:7979/webhook"
- "http://localhost:8080/webhook"
- "http://localhost:8888/webhook" # your plugin
[!IMPORTANT] Every port number in the system must be unique. Never use a port that is already taken.
Paths at Runtime
When main.exe is running, all important directories can be derived from its own path:
build/release/
├── config/
│ └── config.yaml ← configuration
├── data/ ← persistent data
├── logs/ ← log files
└── plugins/
└── myplugin/
└── main.exe ← your plugin
| Variable | Calculation | Example |
|---|---|---|
BASE_DIR | Directory of main.exe | …/plugins/myplugin/ |
ROOT_DIR | BASE_DIR/../.. | …/build/release/ |
CONFIG_FILE | ROOT_DIR/config/config.yaml | |
DATA_DIR | ROOT_DIR/data/ | |
LOGS_DIR | ROOT_DIR/logs/ |
Rust:
#![allow(unused)] fn main() { let exe_path = std::env::current_exe().unwrap(); let base_dir = exe_path.parent().unwrap(); // plugins/myplugin/ let root_dir = base_dir.parent().unwrap() .parent().unwrap(); // build/release/ let config_file = root_dir.join("config").join("config.yaml"); let data_dir = root_dir.join("data"); let logs_dir = root_dir.join("logs"); }
C++:
#include <filesystem>
namespace fs = std::filesystem;
char buf[MAX_PATH];
GetModuleFileNameA(NULL, buf, MAX_PATH);
fs::path base_dir = fs::path(buf).parent_path(); // plugins/myplugin/
fs::path root_dir = base_dir.parent_path().parent_path(); // build/release/
fs::path config_file = root_dir / "config" / "config.yaml";
fs::path data_dir = root_dir / "data";
fs::path logs_dir = root_dir / "logs";
Reading Configuration
config.yaml is a YAML file. Read it when your plugin starts:
Rust (with serde_yaml):
#![allow(unused)] fn main() { let content = std::fs::read_to_string(&config_file).unwrap_or_default(); let cfg: serde_yaml::Value = serde_yaml::from_str(&content).unwrap_or(serde_yaml::Value::Null); let port = cfg["MyPlugin"]["WebServerPort"].as_u64().unwrap_or(8888) as u16; let enabled = cfg["MyPlugin"]["Enable"].as_bool().unwrap_or(true); }
C++ (with yaml-cpp):
YAML::Node cfg = YAML::LoadFile(config_file.string());
int port = cfg["MyPlugin"]["WebServerPort"].as<int>(8888);
bool enabled = cfg["MyPlugin"]["Enable"].as<bool>(true);
If the file is missing or a key is absent, always fall back to a default value — the plugin must never crash because of a missing config entry.
Data Persistence
Store persistent data (counters, state, window size) as a JSON file in DATA_DIR:
build/release/data/myplugin_state.json
Write atomically (first to .tmp, then rename) to avoid data loss on unexpected shutdown.
Communication with Other Plugins
Plugins communicate via HTTP on localhost. Ports are defined in config.yaml:
WinCounter:
WebServerPort: 8080
Rust (with ureq):
#![allow(unused)] fn main() { // Fire-and-forget (no waiting for response) std::thread::spawn(|| { let _ = ureq::post("http://localhost:8080/add?amount=1").call(); }); }
C++ (with cpp-httplib):
httplib::Client cli("localhost", 8080);
cli.set_connection_timeout(2);
auto res = cli.Post("/add?amount=1");
if (!res || res->status != 200) {
// Plugin unreachable – log error, do not crash
}
[!NOTE] The other plugin may be offline or not yet started. Always set a timeout and handle errors.
Full Example – Rust
Demonstrates all required parts: registration, webhook server, config reading, data persistence.
Dependencies (Cargo.toml):
[dependencies]
tiny_http = "0.12"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
serde_yaml = "0.9"
src/main.rs:
use std::env; use std::fs; use std::io::Read; use std::path::PathBuf; use std::sync::{Arc, Mutex}; use tiny_http::{Response, Server}; // --------------------------------------------------------------------------- // Paths // --------------------------------------------------------------------------- fn exe_path() -> PathBuf { env::current_exe().expect("Cannot determine exe path") } fn base_dir() -> PathBuf { exe_path().parent().unwrap().to_path_buf() } fn root_dir() -> PathBuf { base_dir().parent().unwrap().parent().unwrap().to_path_buf() } // --------------------------------------------------------------------------- // State // --------------------------------------------------------------------------- #[derive(serde::Serialize, serde::Deserialize, Default)] struct State { count: u64, } fn load_state(path: &PathBuf) -> State { if path.exists() { let s = fs::read_to_string(path).unwrap_or_default(); serde_json::from_str(&s).unwrap_or_default() } else { State::default() } } fn save_state(path: &PathBuf, state: &State) { let tmp = path.with_extension("tmp"); fs::write(&tmp, serde_json::to_string_pretty(state).unwrap()).ok(); fs::rename(&tmp, path).ok(); } // --------------------------------------------------------------------------- // Main // --------------------------------------------------------------------------- fn main() { let args: Vec<String> = env::args().collect(); // --- Registration --- if args.iter().any(|a| a == "--register-only") { let exe = exe_path().to_string_lossy().replace('\\', "\\\\"); let root = root_dir(); let config_file = root.join("config").join("config.yaml"); let content = fs::read_to_string(&config_file).unwrap_or_default(); let cfg: serde_yaml::Value = serde_yaml::from_str(&content).unwrap_or(serde_yaml::Value::Null); let enabled = cfg["MyPlugin"]["Enable"].as_bool().unwrap_or(true); println!( r#"REGISTER_PLUGIN: {{"name":"MyPlugin","path":"{exe}","enable":{enabled},"level":4,"ics":false}}"# ); std::process::exit(0); } let gui_hidden = args.iter().any(|a| a == "--gui-hidden"); // gui_hidden is not needed here since ics=false (no window) let _ = gui_hidden; // --- Load configuration --- let root = root_dir(); let config_file = root.join("config").join("config.yaml"); let content = fs::read_to_string(&config_file).unwrap_or_default(); let cfg: serde_yaml::Value = serde_yaml::from_str(&content).unwrap_or(serde_yaml::Value::Null); let port: u16 = cfg["MyPlugin"]["WebServerPort"] .as_u64() .unwrap_or(8888) as u16; // --- Load state --- let data_dir = root.join("data"); fs::create_dir_all(&data_dir).ok(); let state_file = data_dir.join("myplugin_state.json"); let state = Arc::new(Mutex::new(load_state(&state_file))); // --- Start HTTP server --- let server = Server::http(format!("127.0.0.1:{port}")) .expect("Could not start HTTP server"); println!("[MyPlugin] running on port {port}"); for mut request in server.incoming_requests() { let url = request.url().to_string(); let method = request.method().as_str().to_string(); if url == "/webhook" && method == "POST" { let mut body = String::new(); request.as_reader().read_to_string(&mut body).ok(); if let Ok(json) = serde_json::from_str::<serde_json::Value>(&body) { let event = json["event"].as_str().unwrap_or(""); if event == "player_death" { let mut s = state.lock().unwrap(); s.count += 1; println!("[MyPlugin] Deaths: {}", s.count); save_state(&state_file, &s); } } let response = Response::from_string(r#"{"status":"ok"}"#) .with_header("Content-Type: application/json".parse().unwrap()); request.respond(response).ok(); } else if url == "/" && method == "GET" { let s = state.lock().unwrap(); let body = format!(r#"{{"count":{}}}"#, s.count); let response = Response::from_string(body) .with_header("Content-Type: application/json".parse().unwrap()); request.respond(response).ok(); } else { request.respond(Response::from_string("Not Found").with_status_code(404)).ok(); } } }
Full Example – C++
Uses cpp-httplib (single-header) and nlohmann/json (single-header).
// Build (MSVC):
// cl /std:c++17 /EHsc main.cpp /Fe:main.exe
// Build (MinGW):
// g++ -std=c++17 -O2 main.cpp -o main.exe -lws2_32
#define CPPHTTPLIB_OPENSSL_SUPPORT 0
#include "httplib.h" // https://github.com/yhirose/cpp-httplib
#include "json.hpp" // https://github.com/nlohmann/json
#include <windows.h>
#include <filesystem>
#include <fstream>
#include <iostream>
#include <mutex>
#include <string>
namespace fs = std::filesystem;
using json = nlohmann::json;
// ---------------------------------------------------------------------------
// Paths
// ---------------------------------------------------------------------------
fs::path get_exe_path() {
char buf[MAX_PATH];
GetModuleFileNameA(NULL, buf, MAX_PATH);
return fs::path(buf);
}
fs::path base_dir() { return get_exe_path().parent_path(); }
fs::path root_dir() { return base_dir().parent_path().parent_path(); }
// ---------------------------------------------------------------------------
// State
// ---------------------------------------------------------------------------
struct State { uint64_t count = 0; };
std::mutex state_mutex;
State g_state;
void save_state(const fs::path& path) {
fs::path tmp = path;
tmp.replace_extension(".tmp");
std::ofstream f(tmp);
f << json{{"count", g_state.count}}.dump(2);
f.close();
fs::rename(tmp, path);
}
void load_state(const fs::path& path) {
if (!fs::exists(path)) return;
std::ifstream f(path);
try {
json j; f >> j;
g_state.count = j.value("count", 0ULL);
} catch (...) {}
}
// ---------------------------------------------------------------------------
// Config reading (simplified – no yaml-cpp dependency required)
// For full YAML support consider yaml-cpp: https://github.com/jbeder/yaml-cpp
// ---------------------------------------------------------------------------
uint16_t read_port(const fs::path& config_file, uint16_t default_port) {
if (!fs::exists(config_file)) return default_port;
std::ifstream f(config_file);
std::string line;
bool in_section = false;
while (std::getline(f, line)) {
if (!line.empty() && line[0] != ' ' && line[0] != '#')
in_section = (line.find("MyPlugin:") != std::string::npos);
if (in_section) {
auto pos = line.find("WebServerPort:");
if (pos != std::string::npos) {
try { return static_cast<uint16_t>(std::stoi(line.substr(pos + 14))); }
catch (...) {}
}
}
}
return default_port;
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
int main(int argc, char* argv[]) {
// --- Registration ---
for (int i = 1; i < argc; ++i) {
if (std::string(argv[i]) == "--register-only") {
std::string exe = get_exe_path().string();
std::string escaped;
for (char c : exe) {
if (c == '\\') escaped += "\\\\";
else escaped += c;
}
std::cout << "REGISTER_PLUGIN: "
<< "{\"name\":\"MyPlugin\","
<< "\"path\":\"" << escaped << "\","
<< "\"enable\":true,"
<< "\"level\":4,"
<< "\"ics\":false}"
<< std::endl;
return 0;
}
}
bool gui_hidden = false;
for (int i = 1; i < argc; ++i)
if (std::string(argv[i]) == "--gui-hidden") gui_hidden = true;
(void)gui_hidden; // ics=false, not relevant
// --- Paths & configuration ---
fs::path root = root_dir();
fs::path config_file = root / "config" / "config.yaml";
fs::path data_dir = root / "data";
fs::create_directories(data_dir);
fs::path state_file = data_dir / "myplugin_state.json";
uint16_t port = read_port(config_file, 8888);
// --- Load state ---
load_state(state_file);
// --- HTTP server ---
httplib::Server svr;
svr.set_error_handler([](const auto&, auto& res) {
res.set_content(R"({"status":"error"})", "application/json");
res.status = 500;
});
svr.Get("/", [&](const httplib::Request&, httplib::Response& res) {
std::lock_guard<std::mutex> lock(state_mutex);
res.set_content("{\"count\":" + std::to_string(g_state.count) + "}",
"application/json");
});
svr.Post("/webhook", [&](const httplib::Request& req, httplib::Response& res) {
try {
auto j = json::parse(req.body);
std::string event = j.value("event", "");
if (event == "player_death") {
std::lock_guard<std::mutex> lock(state_mutex);
g_state.count++;
std::cout << "[MyPlugin] Deaths: " << g_state.count << "\n";
save_state(state_file);
}
} catch (const std::exception& e) {
std::cerr << "[MyPlugin] Webhook error: " << e.what() << "\n";
}
res.set_content(R"({"status":"ok"})", "application/json");
});
std::cout << "[MyPlugin] running on port " << port << "\n";
svr.listen("127.0.0.1", port);
return 0;
}
Error Handling & Best Practices
[!WARNING] The system does not automatically restart a crashed plugin.
| Situation | What to do |
|---|---|
| Config file missing | Use default values, do not crash |
| Port already in use | Print error message, exit cleanly |
| HTTP request does not return | Always set a timeout |
| Unhandled crash | Top-level exception handler with log output |
| JSON parse error in webhook | try/catch, still return HTTP 200 |
Logging
Write logs to ROOT_DIR/logs/myplugin.log. Use append mode and log at minimum:
- Plugin start with port number
- Each received event type
- Every error with a timestamp