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?

ProfileRecommended starting point
Basic Python knowledgeStart with Basic Concepts, then Setup
Advanced Python knowledgeSystem OverviewPython in this project
Extend & customize with PythonGo directly to Plugin Development or Custom $ Commands
Debugging / TroubleshootingDebugging & 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.


Option 1: Complete walkthrough (best preparation)

  1. Basic concepts
  2. Setup
  3. System overview
  4. Event processing
  5. Minecraft integration
  6. System architecture
  7. Plugin development

Option 2: Quick start for experienced users

  1. Basic concepts (10 minutes)
  2. Plugin development
  3. 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

  1. Find your level: Beginner? Then start with Basic Concepts & Terms.
  2. Read progressively: Chapters build on each other.
  3. Don't skip too quickly: If something is unclear, go back to previous chapters.
  4. Use the glossary: Unfamiliar terms? Check the Glossary.
  5. 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:

Basic Concepts & Terms

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):

PhaseWhat happensResult
1. ReceiveTikTok events are receivedStructured event data
2. ProcessEvents are classifiedClear categories (Gift/Follow/Like/...)
3. ExecuteCommand is sent to MinecraftMinecraft 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:

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.jar is required (Minecraft server JAR file).

[!IMPORTANT] Make sure both the folder tools/Java/ and the file tools/server.jar are 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

  1. Visit https://www.python.org/downloads/
  2. Download the latest Python 3.X (Windows x86-64)
  3. Important: In the installer, enable the option "Add Python to PATH"
  4. 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

  1. Visit https://git-scm.com/download/win or https://desktop.github.com/download/
  2. Download the installer
  3. 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 file tools/server.jar exist. If not, you need to add them yourself (see README or project page for instructions).

There are two options:

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:

  1. Visit the repository: https://github.com/TechnikLey/Streaming_Tool

  2. Click on the green "Code" button (top right)

  3. Select "Download ZIP"

  4. Unzip the ZIP file in a suitable location (e.g. C:\Users\your_name\Streaming_Tool)

  5. 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.


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 venv folder)

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 file tools/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:

  1. tiktok_user: Your TikTok channel name
  2. 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

ProblemSolution
python: command not foundPython 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 useAnother 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

If you use VS Code (free, very popular), you can configure it like this:

  1. Download VS Code: https://code.visualstudio.com/

  2. Open the Streaming_Tool folder: File → Open Folder

  3. Install these extensions:

    • Python (Microsoft)
    • Pylance (Microsoft)
  4. Set the Python interpreter to your venv:

    • Ctrl+Shift+P → "Python: Select Interpreter"
    • Choose ./venv/bin/python (or .\venv\Scripts\python.exe on 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:

PhaseTaskWho does it?
1. ReceiveCollect data from TikTok serversTikTokLive API (in our program)
2. ProcessUnderstand & document eventsPython scripts analyze the raw data
3. ExecuteSend command to MinecraftRCON 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:

  1. Determines which command is necessary (based on the event)
  2. Sends it to Minecraft via RCON
  3. 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 failsConsequence
1 breaksNo events from TikTok → Nothing happens
2 breaksEvents are not understood → Wrong action or none at all
3 breaksCommand doesn't reach Minecraft → Game has no response

Next Steps

Each of these 3 phases is explained in detail in this chapter:

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), how actions.mca is 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:

  1. Connects to TikTok servers (like the mobile app)
  2. Listens on the WebSocket stream
  3. Receives events (gifts, follows, likes) in real time
  4. 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:

TypeExample
GiftUser sends 5x gifts
FollowUser follows the channel
LikeUser likes a stream
ShareUser shares the stream
CommentUser 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?

ProblemConsequenceSolution
Event structure unknownClassification failedError log, event is discarded
Queue overflowsMemory problem (very rare)Delete older events
Too many events per secondBacklog builds upMinecraft takes longer to react
Event arrives damagedData parse errorValidation 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

  1. Establish connection

    Program → "I am admin, here is the password"
    Server → Authentication OK, connection open
    
  2. Send command

    Program → "/say User XY followed!"
    Server → Command is executed (visible in game)
    
  3. 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?

ProblemConsequenceSolution
RCON server not reachableCommands cannot be sentCheck Minecraft server, firewall settings
Incorrect passwordAuthentication failedCheck password in config.yaml
Wrong portConnection failsDefault: 25575, check in config.yaml
Command syntactically incorrectMinecraft rejects itCheck command in actions.mca
Too many commands per secondMinecraft can't handle them allBacklog builds up, player sees delayed reaction
Minecraft server crashesRCON connection breaksAuto-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.yaml in the plugin folder and use it for settings. This saves you from having to make adjustments to the code later if the global config.yaml can 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

  1. Understand plugin structure (folders, files, config)
  2. Creating an HTTP server with Flask (receive events)
  3. Send Minecraft commands (RCON communication)
  4. Data storage & configuration (user data)
  5. GUI with pywebview (visual interface, optional)
  6. Communicate between plugins (HTTP + error handling)
  7. 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.yaml file 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 file
  • parse_args: Reads command-line arguments
  • get_root_dir, get_base_dir, get_base_file: Determine important directories and file paths
  • register_plugin: Registers your plugin
  • AppConfig: 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 hardcoding True/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 the log_level in the config.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 supported

    • True = GUI is supported
    • False = 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
  • print output or other I/O operations

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 with sys.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.yml in the project. Here are a few examples:

  • player_death
  • player_respawn
  • player_join
  • player_quit
  • block_break
  • entity_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:

  1. Start Flask and listen on port X
  2. Provide the /webhook endpoint
  3. 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 game
  • STARTUP: 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:

  1. Webhook not working?

    • Check that your port is set in config.yaml
    • Check that the URL in configServerAPI.yml is correct
    • Look in your log file
  2. 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 /webhook endpoint processes incoming events
  • The port must be synchronized in config.yaml and configServerAPI.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:

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"
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() from config.yaml
  • Save data: JSON in DATA_DIR for 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:

  1. Flask backend (Python) → HTTP server, processing, data
  2. 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:

  1. HTTP requests (clean, async ready) RECOMMENDED
  2. File sharing (simple, but race conditions)
  3. 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

PhaseErrorHandling
StartupConfig is missingUse defaults + log
Flask ServerPort already in useAlternative port + error message
HTTP requestsTimeout/ConnectionRetry logic + fallback
File I/OPermission deniedTry-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

ErrorCauseFix
Port already in usePort 8001 occupiedAlternative port in config.yaml
Connection refusedOther plugin offlinetry-except + fallback
TimeoutRequest too slowtimeout=5 increase
JSON decode errorMalformed responsejson.JSONDecodeError catch
FileNotFoundErrorConfig 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.ps1 script and then test it in the release folder 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
  • /health endpoint
  • 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:

  1. actions.mca – The file with all mappings (static)
  2. Code in main.py – Reads the file at startup
  3. RCON protocol – Sends commands to Minecraft (network)

Why this separation?

  • ✓ Users can edit actions.mca without changing code
  • ✓ Errors in the file are detected at startup
  • ✓ Commands can be generated dynamically

After This Chapter You Will Understand:

  • How a .mca file 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?

PathPurpose
defaults/actions.mcaTemplate with example mappings
data/actions.mcaActually 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 players
  • like_2 → Empty inventory + kill all players
  • likes → 2 Creepers spawn (vanilla with x2)
  • 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

ConceptExplanation
FormatTRIGGER:<TYPE>COMMAND xNUMBER
Filesdefaults/actions.mca (template) → data/actions.mca (active)
Validationgenerate_datapack() checks syntax at startup
ProcessEvent → 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:

PartMeaningExample
TRIGGERUnique name or ID8913, follow, likes
:Separator: (always required)
<TYPE>Type of command/, !, $
COMMANDWhat should happen?give @a diamond, tnt 2 0.1 2
xNUMBERHow 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 is Community).


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 in config.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 .

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

ConceptExplanation
TriggersGift ID, follow, likes, like_2
Command types/ (Vanilla → mcfunction), ! (Plugin → RCON), $ (Special)
xNUMBERRepeat command N times
SemicolonMultiple 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:

  1. One line per action – Easy to understand
  2. Separation with : and x – Clear structure without brackets
  3. Minimal, concise – Beginners understand it quickly
  4. Not too strict (Optional: x may be missing)
  5. Comment support (#) – Simply deactivate lines
  6. 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. /summon takes longer than /say
  • E.g. !tnt 1000 takes longer than !tnt 1

Type (/, !, $) doesn't matter from a performance perspective!
It depends on the command.


Summary

  1. Parser disassembles actions.mca once at startup
  2. In-memory dictionary is built
  3. Command types are classified (/, !, $)
  4. Runtime = fast dictionary lookups
  5. 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 all follow, like and the $random trigger 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?

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:

  • random
  • time
  • (more in the future)

All other functionality is available through the api object (e.g. sending RCON commands, firing triggers, logging, reading config). Do not use your own import statements for external packages like requests, 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 to event_hooks/ in the release build. The bot always loads hooks from the release path (event_hooks/), not from src/.

The loading sequence in detail:

  1. Parsing: generate_datapack() reads actions.mca and collects all $ command names (e.g. $welcome_messagewelcome_message)
  2. Import: All .py files in event_hooks/ are dynamically imported via importlib
  3. Registration: For each loaded module, register(api) is called — this is where handlers are registered
  4. 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 to api.
  • api.register_action("welcome_message", welcome_message) registers the handler under the name welcome_message. This name must exactly match the $ command in actions.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:

ArgumentTypeDescription
userstrThe TikTok username that triggered the event (e.g. "max_mustermann")
triggerstrThe name of the $ command currently being executed (e.g. "welcome_message")
contextdictReserved 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 : in actions.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 like welcome_message or superjump — 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 — follow is on the left side of actions.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?

  1. TikTok reports gift 5655 → trigger 5655 is processed
  2. execute_global_command("5655", …) finds $big_gift → calls your handler
  3. Handler sends the RCON message and pushes "follow" into the queue
  4. Shortly after: execute_global_command("follow", …) runs — executes $welcome_message and /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_message
def welcome_message(user, trigger, context):
    api.enqueue_trigger("follow", user)  # ← Loop!

As soon as someone follows on TikTok, the follow trigger fires. The handler pushes follow back into the queue, the handler fires again, pushes follow again — 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_trigger does not throw an exception — it simply does a silent return back to the caller. That means:

  • The rest of the handler continues normally. If welcome_message has more code after the enqueue_trigger call (e.g. more rcon_enqueue calls, logging, etc.), it runs in full. Only that one enqueue_trigger call is blocked.

  • The remaining actions from the actions.mca line also run normally. Given:

    follow: $welcome_message; /give @a minecraft:golden_apple 7
    

    The handler welcome_message is called (including all code after the blocked call), and the /give command is executed afterwards as normal — the ban only affects the enqueue_trigger call, 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?

  1. execute_global_command("5655", …) → finds $small_gift → calls your handler
  2. Handler gives the speed effect and pushes "thank_you" into the queue
  3. execute_global_command("thank_you", …) → finds $thank_you → calls the thank_you handler
  4. 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.mca without 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.py directly in the development structure to test triggers conveniently from the console—without using the .exe. Even when testing with send_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:

  • random

If 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

ProblemConsequenceSolution
Queue fullEvents are lostput_nowait() with exception handling
Connection breaks downNo commands arriveAuto-reconnect
Command too bigRCON errorSplit command
Sending too quicklyMinecraft crashAdjust 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

AspectWithout .mcfunctionWith .mcfunction
RCON load500 packets1 packet
Speed5+ seconds~1 tick
Queue loadHighMinimal
Data throughputHugeTiny

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 (/) with xN → are stored in .mcfunction files
  • Plugin commands (!) & Built-in ($) → sent directly via RCON
  • .mcfunction files are generated at startup, not updated live
  • Performance: xN should 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

CategoryLocationPurposeExamples
Modules (core)src/core/Infrastructure & core logicvalidator, models, utils, paths, cli
Built-in pluginssrc/plugins/Standard functionsTimer, DeathCounter, WinCounter, LikeGoal, OverlayTxt
Custom pluginsplugins/ (user)User-defined extensionsYour 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?

SituationRecommendation
OBS Studio with browser sourceDCS
TikTok Live Studio (no browser source)ICS
Custom streaming softwareDCS
Local testingDCS

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)

Next: PLUGIN_REGISTRY

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:

  1. BUILDIN_REGISTRY — core modules firmly defined in start.py
  2. 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

ParameterTypeExampleFunction
namestr"Timer"Unique identity (logs, status)
pathPathPath("plugins/timer/main.exe")Absolute path to EXE
enableboolTrueStart plugin at boot?
levelint4Log level for terminal visibility
icsboolTrueSupports GUI window (pywebview)?

[!IMPORTANT] All five parameters are mandatory. If one is missing or an unknown key is present, a ValueError is thrown.

Log Level Meaning

The level parameter controls the terminal visibility depending on log_level in the config.yaml:

LevelNameDescription
0OffHides everything, including GUI windows
1SilentHides console windows, GUI remains active
2DefaultShows only main programs
3AdvancedAlso shows background services
4DebugShows all activated processes
5OverrideShows 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 enable values 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). After register_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

ComponentFileContent
AppConfigcore/models.pyDataclass with 5 mandatory fields
BUILDIN_REGISTRYstart.pyFirmly defined core modules
PLUGIN_REGISTRYPLUGIN_REGISTRY.jsonDynamically registered plugins
Registrationregistry.pyScans plugins with --register-only
Scan cacheplugin_registry_scan_cache.jsonSpeeds 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:

  1. pywebview opens a window
  2. Window loads HTML from http://localhost:5001
  3. JavaScript sends HTTP requests to Flask routes
  4. Backend processes, saves config, sends response

Critical Aspects

AspectMeaningExample
Port uniquenessEvery GUI plugin needs a unique portGUI: 5000, Timer: 7878, LikeGoal: 9797
ThreadingFlask must run in a thread so the window doesn't blockdaemon=True is important
SSE for live updatesServer-Sent Events for continuous data/stream for like counters
CORSFor browser sources: Access-Control-Allow-Origin: * requiredStreaming 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:

  1. Source plugin sends HTTP request to http://localhost:PORT/endpoint
  2. Target plugin receives request, processes action
  3. Target responds with JSON or status
  4. 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:

PluginPortConfig key
GUI5000GUI.Port
OverlayTxt5005Overlaytxt.Port
MinecraftServerAPI7777MinecraftServerAPI.WebServerPort
Timer7878MinecraftServerAPI.WebServerPortTimer
DeathCounter7979MinecraftServerAPI.DEATHCOUNTER_PORT
WinCounter8080WinCounter.WebServerPort
LikeGoal9797Gifts.LIKE_GOAL_PORT

[!IMPORTANT] Every port must be unique. If two plugins use the same port, startup will fail.

Avoiding Critical Errors

ErrorProblemSolution
Synchronous requests in the main threadGUI/Server blockedUse threads or set timeout
Unreachable plugins"Connection refused"Check port, plugin may not be running yet
Timeout too shortRequest abortsSet at least timeout=2
Request without error handlingCrash on errorAlways 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:

  1. Browser opens a persistent connection to /stream
  2. Server sends data via yield (not return)
  3. 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 methodDirectionExample
DCS (HTTP requests)Plugin → PluginTimer calls WinCounter /add
SSE (Server-Sent Events)Plugin → Browser/OBSDeathCounter updates overlay
WebhooksMinecraft → Pluginsplayer_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/except and timeout is 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:

  1. ICS (GUI modules): Window capture → The GUI window is shown as a video layer
  2. DCS (HTTP modules): Browser source → Browser renders HTML from HTTP server

Comparison: ICS vs DCS

AspectICSDCS
IntegrationWindow CaptureBrowser Source
TechnologyWindow captureHTTP + HTML/CSS
ScalingNative window sizeFlexibly configurable
LatencyHigher (frame-to-frame)Lower (direct rendering)
Error handlingWindow must be visiblePort must be online
Best forDesktop GUI toolsLive data (like counter, timer)

ICS Integration: Window Capture

In OBS:

  1. Source+Window Capture add
  2. Dropdown: Select GUI application (e.g. "GUI Module [gui_module.exe]")
  3. Adjust size/position
  4. 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:

  1. Source+Browser Source add
  2. Enter URL: http://localhost:PORT (e.g. http://localhost:9797)
  3. Set width/height (e.g. 1280×720)
  4. Refresh rate: 60 FPS
  5. 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

ProblemCauseSolution
URL not reachablePort blocked/incorrectCheck with netstat -ano, open firewall
Browser source shows blankCORS error / HTML not loadingInspect browser console (F12)
Window capture doesn't workModule not registered with ics=TrueCheck registry and level
Latency, delayServer too slowOptimize server rendering, compress images
Only browser source but my module has ics=TrueThat's OKics=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 transparent background (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 >= module level
  • ☑ 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:

ModulePurpose
models.pyData structures (AppConfig, PluginInfo, etc.)
cli.pyCommand-line argument parsing
paths.pyPath functions (ROOT_DIR, BASE_DIR, etc.)
utils.pyHelper functions (sanitize strings, etc.)
validator.pyValidation 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):

LibraryPurpose
TikTokLiveConnection to TikTok Live
FlaskWeb framework for webhooks
pywebviewDesktop GUI windows
pyyamlReading config files
asyncioAsynchronous 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:

  1. main.py – How data comes in
  2. server.py – How to start the Minecraft server
  3. registry.py – How plugins are loaded
  4. 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.

The main.py file

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:

  1. Create client
  2. Register handlers
  3. Connect
  4. 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 TikTok
  • GiftEvent → is triggered when a gift is sent
  • FollowEvent → is triggered when someone follows
  • ConnectEvent → is triggered when the connection is established
  • LikeEvent → 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:

  1. Read event data – What's in the event?
  2. Validate – Is it a valid event?
  3. Find triggers – Which action should be triggered?
  4. 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:

ComplexityReason
if event.gift.comboSome 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-exceptEvents must not crash the entire system

Summary

A TikTok client handler:

  1. ✓ Listens for events
  2. ✓ Receives event data
  3. ✓ Validates the data
  4. ✓ Finds suitable action
  5. ✓ Places in queue (does not execute immediately!)
  6. ✓ 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.name is a string)
  • Fewer errors (wrong keys → immediate error instead of silent fail)

Event Categories

Events are divided into multiple categories:

CategoryExamplesPurpose
User eventsFollow, Gift, LikeAction of a viewer
System eventsConnect, DisconnectSystem status
Stream eventsStreamStart, StreamEndStream 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.

Gift Events

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:

SituationWhat happensHow often is the handler called?
Single giftViewer sends gift 1x1x (immediately)
Combo giftViewer sends the same gift multiple times in quick successionMultiple times (repeat_count)
StreakingTikTok sends notifications about the current status of the comboMultiple 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_count to know how often the action should be carried out
  • We need streaking to know whether we should ignore this event
  • We need gift.name OR gift.id to find which action matches
  • We need user.nickname to 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:

ProblemWhat can happen?How do we protect ourselves?
Event has no gift propertyAttributeErrorgetattr() with fallback
Event has no user propertyAttributeErrorget_safe_username() with fallback
Gift name/ID does not match any actionGift is ignoredif not target: return
Username contains invalid charactersQueue errorsanitize_filename() cleans it up
Queue is full (very rare)put_nowait() exceptionTry-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?

  1. Ignore streaking – Only process real gift events
  2. Determine count – 1x or multiple times?
  3. Read data – Gift name, ID, username
  4. Logger info – Visible feedback for debugging
  5. Find triggers – By name, then by ID
  6. Queue operation – Thread-safe with call_soon_threadsafe
  7. 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:

ConceptExplanation
Combo giftsSame gift multiple times = repeat_count increases
StreakingTikTok sends status updates = we ignore them
Trigger matchingGift name or ID → to action (TRIGGERS dictionary)
Asynchronous queuecall_soon_threadsafe makes it thread-safe
Error handlingTry-except protects against unexpected structures

What happens AFTER the gift handler?

The queued action is later processed by the worker thread.

Next chapter: Follow events


[!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:

FeatureGiftsFollows
Multiple processingCombo possible (5x, 10x, etc.)Always only 1x
Status updatesStreaking: Multiple notificationsNo notifications
Trigger managementName AND ID possibleA single trigger: "follow"
Error complexityHigh (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.user doesn't exist? → get_safe_username() protects against this
  • get_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?

  1. Read & sanitize username – With get_safe_username()
  2. Logging – We see in the log who follows
  3. Check trigger – Does the "follow" trigger exist?
  4. Queue – With call_soon_threadsafe()
  5. 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?

ScenarioConsequenceSolution
event.user is NoneAttributeErrorget_safe_username() raises exception
Username is empty string ""Gets queued as-isNormal, no problem
"follow" trigger doesn't existEvent is ignoredEarly return
Queue full (extremely rare)put_nowait() exceptionTry-except catches it
TikTok sends follow 2x quicklyTwo events in successionBoth are processed (intended!)

Conclusion: Follow handlers are very robust – there's little that can go wrong.


Summary & Next Step

What you know now:

ConceptExplanation
Follow = SimpleNo combos, no streaking, only 1 trigger
3-step flowUsername → Check → Queue
Trigger "follow"The only trigger for follow events
Error handlingMinimal needed, get_safe_username() covers a lot
Best practice patternSame 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).

Next chapter: Like events


[!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:

FeatureGiftsFollowsLikes
Event typeDiscrete events ("gift sent")Discrete events ("followed")Continuous counting
FrequencyRare (user sends gift)Rare (user follows)VERY FREQUENT
UsernamesYes, visibleYes, visibleRarely visible
Trigger logic"When gift""When follow""When counter reaches e.g. 100 mark"
Threading problemNo, simpleNo, simpleYES, 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?

  1. Initialize – Set starting value on first event
  2. Calculate delta – How many likes are new?
  3. Acquire lock – Activate thread safety
  4. Check rules – For each like milestone (100, 500, etc.)
  5. Calculate blocks – With integer division //
  6. 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?

ScenarioProblemSolution
Like event before initializationstart_likes is Noneif start_likes is None: initialize()
Two events simultaneouslyRace conditionwith like_lock protects
Interval is 0Division by zeroif every <= 0: continue
Very fast like floodMany events/secBlocks are correctly aggregated
Lock hangsThread blocked foreverwith like_lock auto-releases

Conclusion: Like handlers require the most error handling, especially because of the lock.


Summary & Next Step

What you know now:

ConceptExplanation
Likes ≠ GiftsContinuous counting instead of individual events
Race conditionsMultiple threads access simultaneously → lock required
Block calculationblocks = total_likes // interval
InitializationSet starting value on first event
Lock patternwith threading.Lock() for thread safety
Error handlingLock 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 typeSymptomExample
Syntax errorProgram doesn't start at alldef foo( – missing bracket
Import errorModuleNotFoundErrorDependency not installed
Runtime errorProgram crashes during executionDivision by 0
Logic errorProgram runs, but does the wrong thingif x = 5: instead of if x == 5:
Configuration errorSettings are wrongconfig.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

  1. Open your Python file
  2. Click to the left of the line → red dot (breakpoint)
  3. Start the program with F5 (Debug mode)
  4. When the line is reached → program pauses
  5. Inspect variables, step through the code

Debug controls

  • F10 – Next line (Step Over)
  • F11 – Go into function (Step Into)
  • Shift+F11 – Go out of function
  • F5 – Continue to the next breakpoint
  • Shift+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:

  1. Check: Plugin registered in PLUGIN_REGISTRY?

    # In start.py / registry.py
    {"name": "MyPlugin", "path": ..., "enable": True, ...}
    
  2. Check: Plugin has main.py?

    src/plugins/my_plugin/
    ├── main.py       # Must exist!
    ├── README.md
    └── version.txt
    
  3. Check: Plugin can import?

    python src/plugins/my_plugin/main.py --register-only
    

    If 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:

  1. Is Flask running?

    curl http://localhost:7878/webhook -X POST -d "{}"
    
  2. Firewall allows port? Port must be open.

  3. Config is correct? Port in config.yaml must 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 install all 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 executed pip 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.

Open chapter


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!

Open glossary


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.

Open core modules


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.

Open Project 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?

Open Config Details


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?

Open Update Process


When Should I Use the Appendix?

SituationAppendix 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

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 – dependency
  • Flask – 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.yaml is 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 module
  • core/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 processed
  • like_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_1001 is a trigger (when this gift arrives)
  • follow is 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:

  1. New version is uploaded to GitHub
  2. User starts the program
  3. Update script downloads the new version
  4. 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.namegift_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:

  1. Re-read: Simply read it again
  2. Context: Search for the term in the documentation – context helps
  3. Read code: Look at how the term is used in actual code
  4. 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.

ModulePurpose
paths.pyDirectory management & paths
utils.pyLoad configuration, helper functions
models.pyData structures (AppConfig)
validator.pySyntax validation (actions.mca)
cli.pyCommand-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 root
  • get_config_file() – Path to config.yaml
  • get_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?

  1. Checks whether the file exists
  2. Parses YAML
  3. Returns a dictionary
  4. 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

ArgumentEffect
--gui-hiddenStart without GUI window (headless)
--register-onlyOnly 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:

ModuleBenefit
paths.pyFinding the correct paths (dev vs. release)
utils.pyLoading config reliably
models.pyManaging plugin metadata
validator.pyFinding and reporting errors
cli.pyDifferent start modes

Practical for developers:

  • Plugin developers: Mainly use paths.py and utils.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:

  1. Development structure (for working) → Source code, tests, docs
  2. 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

AspectDevelopmentRelease
Path in codesrc/python/./ (EXE runs from release root)
Load configdefaults/config.yamlconfig/config.yaml
Save dataPROJECT_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)

Back to Appendix

[!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/, or runtime/.
  • 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/ and config/ 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_cache folder.

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.yaml
  • config.yaml
  • configServerAPI.yml
  • DelayedTNTconfig.yml
  • gifts.json
  • actions.mca
  • http_actions.txt
  • pack.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 documentation
  • src/ 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.html
  • db.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.jar
  • MinecraftServerAPI-1.21.x.jar
  • server.jar
  • Java/

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.yaml
  • config.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.exe
  • gui.exe
  • LikeGoal.exe
  • mcServerAPI.exe
  • Overlaytxt.exe
  • PortChecker.exe
  • timer.exe
  • validation.exe
  • WinCounter.exe
  • window.exe

Additionally:

  • assets/
  • gifts.json
  • lib/
  • 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 the data/ folder.

data/

Data needed at runtime or for certain features is stored here. Unlike the runtime/ folder, data/ is never overwritten.

  • actions.mca
  • http_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_version incremented?
  • ☑ Comments BEFORE the first key are preserved
  • ☑ Code uses .get() with defaults
  • ☑ Test: Does migration work?

Back to Appendix

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_version in config.default.yaml is greater than the one in the current config.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.yaml will not be overwritten.
  • Cleanup: Keys that no longer exist in the new config.default.yaml are removed from config.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()): Avoid KeyError crashes 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, or str.
  • 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.txt updated after release?
  • ☑ GitHub release tagged correctly?
  • ☑ Beta releases work with confirmation?

Back to Appendix

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.yaml at startup.
  • The updater works within the directory where the EXE is located.
  • The release package is downloaded as a .zip from GitHub.
  • The update logic differentiates between tool version and updater version.

Expected Files and Paths

The updater uses these local paths:

  • version.txt
  • config/config.default.yaml
  • config/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:

  • ToolVersion
  • UpdaterVersion

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.3
  • 1.2
  • 1.2.3-beta
  • 1.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:

  1. The updater checks the current GitHub release.
  2. The tag version is read from tag_name.
  3. If the release has a beta tag, a confirmation prompt appears.
  4. The .zip asset from the release is downloaded.
  5. The archive is extracted to _update_tmp/.
  6. 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:

  1. update.exe from the unpacked release is copied to the base directory as update_new.exe.
  2. The new updater version is saved in version.txt.
  3. The current process is replaced via os.execv(...) by update_new.exe.
  4. 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.exe is 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:

  • core
  • scripts
  • server
  • config

Allowed Root Files

Only these top-level files are copied:

  • version.txt
  • README.md
  • LICENSE
  • update.exe
  • server.exe
  • start.exe

Additional Rules

  • update.exe is skipped during normal copying.
  • config.yaml is 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

  1. config/config.default.yaml is loaded as a template.
  2. config/config.yaml is loaded as user data.
  3. If config/config.yaml is missing, it is recreated from the default file.
  4. A backup is created before migration:
config/config.yaml.bak
  1. The structure of the template is preserved.
  2. Only values that exist in the template are copied from the user version.
  3. Keys that only exist in the old user version are removed.
  4. config_version is 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.exe is the only required file of the plugin system.
Python plugins work exactly the same way: main.py is compiled to main.exe via 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

FieldTypeDescription
namestringUnique name of the plugin
pathstringAbsolute path to main.exe
enableboolWhether the plugin is started on launch
levelintVisibility level (see below)
icsboolDoes 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:

LevelMeaning
0Forbidden – overrides all visibility rules, never use!
1For very important output only
2Main programs
3Background services
4Debug/Development ← recommended for custom plugins
5Forbidden – overrides all visibility rules, never use!

[!NOTE] Level 0 and 5 must not be used. When log_level = 0 or log_level = 5 is set in config.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. If control_method in config.yaml is set to DCS, 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) or std::filesystem::absolute(argv[0])

Argument Handling

Your plugin must recognize at least two arguments:

ArgumentBehavior
--register-onlyPrint JSON, exit immediately
--gui-hiddenDo 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

EventActive 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
VariableCalculationExample
BASE_DIRDirectory of main.exe…/plugins/myplugin/
ROOT_DIRBASE_DIR/../..…/build/release/
CONFIG_FILEROOT_DIR/config/config.yaml
DATA_DIRROOT_DIR/data/
LOGS_DIRROOT_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.

SituationWhat to do
Config file missingUse default values, do not crash
Port already in usePrint error message, exit cleanly
HTTP request does not returnAlways set a timeout
Unhandled crashTop-level exception handler with log output
JSON parse error in webhooktry/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