More Agentic Bot Battles

Wizards and trolls

Posted by Isaac on Tuesday, September 15, 2026

Last week we deep dived on building out an agent system and I suggested I might just do some tweaks. Upon publishing that article I had open sourced it all and I did as I suggested - I added some NPCs.

I added “Gary the Wizard” which one can challenge in a duel

/img/2026-09-aiagents-01.png

If you loose, you lose strength or health, but if you win you get to decide if you get +2 strength, health or victory points.

I really wanted to create some some options for the bots to consider.

With the new health mechanic, I added some icons for warriors and knights

/img/2026-09-aiagents-02.png

And now when you lose all your health, you die (nice gravestone icon too).

So let’s put that to the bots to work through those options.

I want to nudge the AI bot in this direction. I update the Game rules with a note on health

iRules you must respect when choosing among the OPTIONS given to you:
- Two solo bots that meet MAY voluntarily ally (not required). The stronger bot (or higher
  score if tied) leads. Larger parties have an advantage in battle.
- A solo bot always joins a party if the party leader's strength >= its own (no choice).
- A solo bot always refuses and fights if the party leader is weaker (no choice).
- Two opposing parties that meet must always battle (no choice). Defeated party members (including leader)
  lose 1-3 health points (randomized).
- When a bot's health reaches 0, it is DEAD. It is disconnected from any party, a gravestone
  replaces its icon on the board, and it can no longer move or take turns. Its final score remains
  preserved on the scoreboard.
- Diagonal squeezes between obstacle corners cost strength (0.1 solo, 0.2 leader / 0.1 followers).
- The Wandering Wizard NPC can be voluntarily challenged when adjacent (distance <= 1). The challenge
  is a 3-bout D20 duel (strength * D20). If victorious, the player decides whether to receive +2 score,
  +2 strength, or +2 health; losing costs 2 health (or score if no health). Challenging the wizard is the only way to add health in the game.
- The game ends when all surviving bots are united into a single remaining party.
You will only ever be asked to choose between options that are legal - always answer with the
requested JSON object and nothing else.

I also updated the movement prompt to note the wizard option

prompt = f"""{GAME_RULES_SUMMARY}
You are bot "{self.name}" (strength {self.strength}, score {my_info['score']}, party: {self.party_id or 'Solo'})
at position ({my_info['x']}, {my_info['y']}).
Server's radar suggestion: recommended_direction={radar_res.get('recommended_direction')},
recommended_action={radar_res.get('recommended_action')}, goal={radar_res.get('bot_goal')}.
{map_section}
All known bots/parties on the board (sorted nearest first): {json.dumps(targets_summary)}
Your ONLY legal moves this turn, with resulting coordinates and any strength penalty for
squeezing past obstacles: {json.dumps(moves_summary)}

Choose the direction that best serves your strategy (e.g. approach weaker solo bots to grow
your party, avoid stronger hostile parties, route around obstacles visible on the map, minimize
strength penalties, go to the wizard for points or health, or explore if nothing is nearby). 
You MUST pick a key from the legal moves object above.

Respond ONLY with JSON: {{"direction": "<one of {available}>", "reasoning": "short reason"}}

I ran several tests with the updated prompt, but I never saw bots actually pursue the wizard

I made the directional section even less subtle:

Choose the direction that best serves your strategy (e.g. approach weaker solo bots to grow
your party, avoid stronger hostile parties, route around obstacles visible on the map, minimize
strength penalties, go to the Gary the Wizard NPC for points or health, or go to Gary the Wizard NPC if nothing is nearby). 
You MUST pick a key from the legal moves object above.

I’m noticing a lot of the bots just blindly follow the server advice.

For instance Gemma4:12b:

$ BOT_SERVER_URL="https://botwebwars.tpk.pw" OLLAMA_BASE_URL="http://192.168.1.220:11434" python3 ./bot.py -n myGemma12bot12b -s 2 --piece-type warrior -H 2
--- AI Bot ---
Connected to Ollama: http://192.168.1.220:11434
Using model: gemma4:12b

🚀 [REGISTER] Spawned myGemma12bot12b (ID: player_d8cebb15, Str: 2, HP: 2) at (36, 61)
⏳ [LOBBY] Waiting for game to start via 'Start Game' in UI...
🤖 --- Turn for myGemma12bot12b | Score: 0 | Party: Solo ---
🧠 [LLM] The server suggests moving UP_RIGHT to seek a partner and the move has no strength penalty.
🧭 Moving UP_RIGHT (Goal: form_party, Action: seek_partner)

🤖 --- Turn for myGemma12bot12b | Score: 0 | Party: Solo ---
🧠 [LLM] Moving towards the recommended direction and goal of forming a party.
🧭 Moving UP_RIGHT (Goal: form_party, Action: seek_partner)

and e4b:

🤖 --- Turn for myGemma4e4b2 | Score: 0 | Party: Solo ---
🧠 [LLM] The radar suggests UP_LEFT to seek a partner. This move moves closer to the nearest bot, myQwen35latest, which is a solo bot and a potential ally. This aligns with the goal of forming a party.
🧭 Moving UP_LEFT (Goal: form_party, Action: seek_partner)
🤝 Formed or joined squad: Squad myGemma4e4b2

as well as Gemini 3.8 flash

✨ [AUTH SUCCESS] Authenticated to GCP Project 'careful-compass-241122' in region 'global' (model: gemini-3.8-flash)
🚀 [REGISTER] Spawned GeminiGear-38f (ID: player_64f65dfd, Str: 2, HP: 2) at (2, 45)                                      ⏳ [LOBBY] Waiting for game to start via 'Start Game' in UI...                                                            🤖 --- Turn for GeminiGear-38f | Score: 0 | Str: 2.0 | Party: Solo ---                                                    🧠 [GEMINI] Moving UP follows radar guidance and heads toward the nearest bots to form an alliance.
🧭 Moving UP (Goal: form_party, Action: seek_partner)

And the results are they avoided the Wizard

If we come back to the game.py backend code, we notice the server will never actually recommend going to the wizard


            rec_dir = None
            rec_act = "explore_unvisited"

            if nearest:
                # Use BFS pathfinder to recommend direction navigating around obstacles!
                bfs_path = self._find_path_bfs((player.x, player.y), (nearest.x, nearest.y))
                if bfs_path and len(bfs_path) >= 2:
                    step_x, step_y = bfs_path[1]
                    dx = step_x - player.x
                    dy = step_y - player.y
                else:
                    dx = 1 if nearest.x > player.x else (-1 if nearest.x < player.x else 0)
                    dy = 1 if nearest.y > player.y else (-1 if nearest.y < player.y else 0)

                for name, (ox, oy) in DIRECTION_OFFSETS.items():
                    if ox == dx and oy == dy and "_" in name:
                        rec_dir = name
                        break
                if not rec_dir:
                    for name, (ox, oy) in DIRECTION_OFFSETS.items():
                        if ox == dx and oy == dy:
                            rec_dir = name
                            break

                if nearest.distance <= 1:
                    if nearest.can_recruit:
                        rec_act = "form_party"
                    else:
                        rec_act = "engage_battle"
                else:
                    if bot_goal == "form_party":
                        rec_act = "seek_partner"
                    else:
                        rec_act = "hunt_party"

Let’s change the GEAR one to be really clear - it’s a risky taker.

I got a new error now

(venv) isaac@isaac-G707:~/Workspaces/botWebWars/botagent_gear$ VERTEX_MODEL="gemini-3.8-flash" BOT_SERVER_URL="https://botwebwars.tpk.pw" python bot.py --name "GeminiGear-38f" --color "#4285f4" -s 2 -H 2
--- Vertex AI Gemini Bot Agent ---
Target Model: gemini-3.8-flash
Region: global

✨ [AUTH SUCCESS] Authenticated to GCP Project 'careful-compass-241122' in region 'global' (model: gemini-3.8-flash)
🚀 [REGISTER] Spawned GeminiGear-38f (ID: player_a67dce4a, Str: 2, HP: 2) at (41, 39)
⏳ [LOBBY] Waiting for game to start via 'Start Game' in UI...
🤖 --- Turn for GeminiGear-38f | Score: 0 | Str: 2.0 | Party: Solo ---
⚠️ [VERTEX HTTP ERROR 429]: {
  "error": {
    "code": 429,
    "message": "Resource exhausted. Please try again later. Please refer to https://cloud.google.com/vertex-ai/generative-ai/docs/error-code-429 for more details.",
    "status": "RESOURCE_EXHAUSTED"
  }
}

🧭 Moving DOWN_LEFT (Goal: form_party, Action: seek_partner)

If we go to the 429 error page we can see a spike can trigger the error

I was heading down the path of debugging when it just started to work again

🤖 --- Turn for GeminiGear-38f | Score: 0 | Str: 2.0 | Party: Solo ---
⚠️ [VERTEX HTTP ERROR 429]: {
  "error": {
    "code": 429,
    "message": "Resource exhausted. Please try again later. Please refer to https://cloud.google.com/vertex-ai/generative-ai/docs/error-code-429 for more details.",
    "status": "RESOURCE_EXHAUSTED"
  }
}

🧭 Moving DOWN_RIGHT (Goal: form_party, Action: seek_partner)

🤖 --- Turn for GeminiGear-38f | Score: 0 | Str: 2.0 | Party: Solo ---
⚠️ [VERTEX HTTP ERROR 429]: {
  "error": {
    "code": 429,
    "message": "Resource exhausted. Please try again later. Please refer to https://cloud.google.com/vertex-ai/generative-ai/docs/error-code-429 for more details.",
    "status": "RESOURCE_EXHAUSTED"
  }
}

🧭 Moving DOWN_LEFT (Goal: form_party, Action: seek_partner)

🤖 --- Turn for GeminiGear-38f | Score: 0 | Str: 2.0 | Party: Solo ---
🧠 [GEMINI] Move southwest towards the closest bots at (25, 55) and (25, 57) across clear terrain with no strength penalty.
🧭 Moving DOWN_LEFT (Goal: form_party, Action: seek_partner)

🤖 --- Turn for GeminiGear-38f | Score: 0 | Str: 2.0 | Party: Solo ---
🧠 [GEMINI] Moving southwest towards the nearest party at (26, 56) to unite into a stronger squad without incurring any strength penalty.
🧭 Moving DOWN_LEFT (Goal: form_party, Action: seek_partner)

🤖 --- Turn for GeminiGear-38f | Score: 0 | Str: 2.0 | Party: Solo ---
🧠 [GEMINI] Moving towards open terrain and heading northeast towards solo bot myGemma4e4b while avoiding mountain clutter.
🧭 Moving UP_RIGHT (Goal: form_party, Action: seek_partner)

🤖 --- Turn for GeminiGear-38f | Score: 0 | Str: 2.0 | Party: Solo ---

However, a Bot finally decided to challenge Gary the Wizard and it wasn’t the frontier 3.8-flash model one, it was a Gemma4:e4b one!

🤖 --- Turn for myGemma4e4b2 | Score: 0 | Party: party_acdae910 ---
🧠 [LLM] Moving left (to 22, 53) keeps me adjacent to my ally 'myGemma12bot12b' and 'myQwen35latest', maintaining party cohesion. This direction also moves me closer to the general area of the other bots and the Wizard, while avoiding immediate obstacles or strong opponents.
🧭 Moving LEFT (Goal: find_and_defeat_all_parties, Action: hunt_party)

🤖 --- Turn for myGemma4e4b2 | Score: 0 | Party: party_acdae910 ---
🧙 [LLM DECISION] Challenge Wizard: True (Reward choice: health). My current health is 2, which is very low. Challenging the wizard offers the chance to gain +2 health, which is critical for survival.
🧙 [WIZARD CHALLENGE] Challenging Gary the Wizard to a 3-bout D20 duel (reward if won: +2 health)...
🧙 [RESULT] VICTORY (+2 health): Player 3 - Wizard 0
  Bout #1: Bot D20(7)×Str(6.0)=42.0 vs Wizard D20(10)×Str(3.0)=30.0 -> Winner: player
  Bout #2: Bot D20(19)×Str(6.0)=114.0 vs Wizard D20(14)×Str(3.0)=42.0 -> Winner: player
  Bout #3: Bot D20(7)×Str(6.0)=42.0 vs Wizard D20(13)×Str(3.0)=39.0 -> Winner: player
  Score: 0 | Strength: 2.0 | HP: 4
  🔮 Wizard vanished and teleported to (33, 59)

I then used the same reduced prompt as I had for the GEAR model with the local LLMs, namely, removing the server recommendation - it would really be up to the bots to decide

prompt = f"""{GAME_RULES_SUMMARY}
You are bot "{self.name}" (strength {self.strength}, score {my_info['score']}, party: {self.party_id or 'Solo'})
at position ({my_info['x']}, {my_info['y']}).
{map_section}
All known bots/parties on the board (sorted nearest first): {json.dumps(targets_summary)}
Your ONLY legal moves this turn, with resulting coordinates and any strength penalty for
squeezing past obstacles: {json.dumps(moves_summary)}

Choose the direction that best serves your strategy (e.g. approach weaker solo bots to grow
your party, avoid stronger hostile parties, route around obstacles visible on the map, minimize
strength penalties, go to the Gary the Wizard NPC for points or health). As you are a risk taker, the Wizard is an ideal target
You MUST pick a key from the legal moves object above.

Respond ONLY with JSON: {{"direction": "<one of {available}>", "reasoning": "short reason"}}
"""

I also used Gemini 3.6 flash this time as it had a likely better limit

$ VERTEX_MODEL="gemini-3.6-flash" BOT_SERVER_URL="https://botwebwars.tpk.pw" python bot.py --name "GeminiGear-36f" --color "#4285f4" -s 2 -H 2
--- Vertex AI Gemini Bot Agent ---
Target Model: gemini-3.6-flash
Region: global

✨ [AUTH SUCCESS] Authenticated to GCP Project 'careful-compass-241122' in region 'global' (model: gemini-3.6-flash)
🚀 [REGISTER] Spawned GeminiGear-36f (ID: player_d7083bdb, Str: 2, HP: 2) at (41, 30)
⏳ [LOBBY] Waiting for game to start via 'Start Game' in UI...
🤖 --- Turn for GeminiGear-36f | Score: 0 | Str: 2.0 | Party: Solo ---
🧠 [GEMINI] Moving DOWN_RIGHT incurs no strength penalty and heads toward other solo bots to build a party while searching for the Wizard.
🧭 Moving DOWN_RIGHT (Goal: form_party, Action: seek_partner)

🤖 --- Turn for GeminiGear-36f | Score: 0 | Str: 2.0 | Party: Solo ---

I noticed several of the bots were seeking the wizard now that I had removed that server recommendation

$ BOT_SERVER_URL="https://botwebwars.tpk.pw" OLLAMA_BASE_URL="http://192.168.1.220:11434" OLLAMA_MODEL="gemma4:e4b" python3 ./bot.py -n myGemma4e4b -s 2 -H 2
--- AI Bot ---
Connected to Ollama: http://192.168.1.220:11434
Using model: gemma4:e4b

🚀 [REGISTER] Spawned myGemma4e4b (ID: player_e503acb1, Str: 2, HP: 2) at (44, 59)
⏳ [LOBBY] Waiting for game to start via 'Start Game' in UI...
🤖 --- Turn for myGemma4e4b | Score: 0 | Party: Solo ---
🧠 [LLM] The Wandering Wizard NPC is visible at (44, 58) (assuming the map structure implies the NPC is adjacent to the current location or in the visible area, and UP_LEFT is the closest move to reach the general area of the NPC/open space). Since I am a risk-taker, challenging the Wizard is the highest priority action to gain health/score/strength. Moving UP_LEFT is a direct step towards the center of the map where the NPC is likely located, minimizing immediate travel time to challenge him.
🧭 Moving UP_LEFT (Goal: form_party, Action: seek_partner)

🤖 --- Turn for myGemma4e4b | Score: 0 | Party: Solo ---
🧠 [LLM] The Wandering Wizard NPC (W) is located at (43, 57), which is directly UP. Since I am a risk-taker, challenging the Wizard for potential health/score gain is the highest priority. This move minimizes strength penalty (0.0) and gets me into position for the challenge.
🧭 Moving UP (Goal: form_party, Action: seek_partner)

However, we got to a place where our bots seemed to get into loops. Perhaps it was a limit of the e4b model and mathing how to reach an objective (this is where the server recommendation might have helped)

Before I do much more, I want to add some logic around the wizard so we don’t have to gut the bots from the recommend path option.

I’ll ask Agy in Zed

/img/2026-09-aiagents-08.png

After a few iterations, it sorted out the new logic

/img/2026-09-aiagents-07.png

I then asked Antigravity to check if our JS clients needed updating.. It found they would consider Gary the Wizard if he happened to be next to them, but not otherwise

/img/2026-09-aiagents-09.png

I told it to go for it

/img/2026-09-aiagents-10.png

Which it did in short order

/img/2026-09-aiagents-11.png

However, before I roll live, I want to test that locally

isaac@isaac-G707:~/Workspaces/botWebWars$ docker compose up --build
[+] Building 5.2s (22/22) FINISHED
 => [internal] load local bake definitions                                                                                                       0.0s
 => => reading from stdin 512B                                                                                                                   0.0s
 => [internal] load build definition from Dockerfile                                                                                             0.0s
 => => transferring dockerfile: 1.69kB                                                                                                           0.0s
 => [internal] load metadata for docker.io/library/node:22-alpine                                                                                0.5s
 => [internal] load metadata for docker.io/library/python:3.12-slim                                                                              0.5s
 => [internal] load .dockerignore                                                                                                                0.0s
 => => transferring context: 172B                                                                                                                0.0s
 => [internal] load build context                                                                                                                0.1s
 => => transferring context: 763.29kB                                                                                                            0.1s
 => [frontend-builder 1/7] FROM docker.io/library/node:22-alpine@sha256:c610fcdfb1d5b4740dd70c284ed3cb16bb857e0f7166196e36a5501df7a3aa32         0.0s
 => => resolve docker.io/library/node:22-alpine@sha256:c610fcdfb1d5b4740dd70c284ed3cb16bb857e0f7166196e36a5501df7a3aa32                          0.0s
 => [production 1/7] FROM docker.io/library/python:3.12-slim@sha256:78387bc3881b8273120a12ebe6c1ab22b018ccc2c9adf565ae1ac9b536e184ea             0.0s
 => => resolve docker.io/library/python:3.12-slim@sha256:78387bc3881b8273120a12ebe6c1ab22b018ccc2c9adf565ae1ac9b536e184ea                        0.0s
 => CACHED [production 2/7] WORKDIR /app                                                                                                         0.0s
 => CACHED [production 3/7] RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/*                 0.0s
 => CACHED [production 4/7] COPY backend/requirements.txt ./backend/requirements.txt                                                             0.0s
 => CACHED [production 5/7] RUN pip install --no-cache-dir -r ./backend/requirements.txt                                                         0.0s
 => CACHED [frontend-builder 2/7] WORKDIR /build/frontend                                                                                        0.0s
 => CACHED [frontend-builder 3/7] COPY frontend/package*.json ./                                                                                 0.0s
 => CACHED [frontend-builder 4/7] RUN npm ci || npm install                                                                                      0.0s
 => CACHED [frontend-builder 5/7] COPY version.ini /build/version.ini                                                                            0.0s
 => CACHED [frontend-builder 6/7] COPY frontend/ ./                                                                                              0.0s
 => CACHED [frontend-builder 7/7] RUN npm run build                                                                                              0.0s
 => [production 6/7] COPY backend ./backend                                                                                                      0.8s
 => [production 7/7] COPY --from=frontend-builder /build/frontend/dist ./frontend/dist                                                           0.1s
 => exporting to image                                                                                                                           3.6s
 => => exporting layers                                                                                                                          2.5s
 => => exporting manifest sha256:4fad4dddd7e273b52a5d5c54557eb4f38ca2b746215ff74a34909e7
 ...

Trolls

One way to ensure we aren’t left with an immobile wizard and two parties not finding each other is to add another element to the game.

A big gnarly troll. Actually, as I worked it out, I decided a Troll can be played by a player or a bot and there should likely be more than one.

As we saw above the Trolls didn’t really win, but they did have an affect

/img/2026-09-aiagents-13.png

Another run

I added in the AI bots. While slower, it was interesting to see how the AI interpreting the “troll” logic:

⚡ [MY TURN] AI Troll Troll Gemini4 e4b (Round 4, Turn 31)
🧠 [LLM STRATEGY] Action: MOVE -> UP_LEFT. Reasoning: The nearest target is at (16, 10). Moving UP_LEFT (29, 18) brings me closer to the general direction of the target while maintaining a safe, exploratory path.

⚡ [MY TURN] AI Troll Troll Gemini4 e4b (Round 5, Turn 39)
🧠 [LLM STRATEGY] Action: MOVE -> UP_LEFT. Reasoning: The nearest target is at (17, 11). Moving UP_LEFT (28, 17) is the most direct path towards the general direction of the target while maintaining good tactical positioning.

Note the behavior of troll 12b in the lower right taking out two opponents:

Updating Live

I decided now was a good time to update the live kubernetes version on botwebwars. I created a v1.4 tag

This built and pushed to harbor, but also to a tag in Dockerhub

I just needed to set the image tag to 1.4 and use the upgrade command

$ helm upgrade --install botwebwars -f ./helm.values.yml ./helm-chart/
Release "botwebwars" has been upgraded. Happy Helming!
NAME: botwebwars
LAST DEPLOYED: Sun Sep 13 14:46:05 2026
NAMESPACE: default
STATUS: deployed
REVISION: 4
DESCRIPTION: Upgrade complete
TEST SUITE: None

I can tell the new service is running by the fact we can register a player as a troll now

/img/2026-09-aiagents-16.png

Spawning Agents

I wanted a way to invoke all these without having to manage all the windows in Herdr each time.

I had Agy take care of that:

Top-level scripts have been created to launch pools of AI Bots (botagent_ai) and AI Trolls (trollagent_ai), featuring automatic naming following your exact convention ({folder}{sanitized_model}{number}), real-time colorized terminal multiplexing, per-agent log files, and graceful cleanup upon pressing Ctrl+C. ──────

Available Scripts

Script Target Agent Shell Wrapper
launch_bots.py botagent_ai launch_bots.sh
launch_trolls.py trollagent_ai launch_trolls.sh
launcher_common.py Shared orchestrator, stream multiplexer & signal handler

Both scripts are directly executable (chmod +x) as Python scripts (./launch_bots.py or python3 launch_bots.py) or via their shell wrappers (./launch_bots.sh). ──────

Naming Convention

Model names with special characters (such as :) are sanitized to underscores so names are valid identifiers:

• botagent_ai with model gemma4:e4b: • 1st instance: botagent_ai_gemma4_e4b_1 • 2nd instance: botagent_ai_gemma4_e4b_2 • 3rd instance: botagent_ai_gemma4_e4b_3 • trollagent_ai with model gemma4:e4b: • 1st instance: trollagent_ai_gemma4_e4b_1 • 2nd instance: trollagent_ai_gemma4_e4b_2

──────

Quick Usage Examples

1. Launch Multiple Bots (launch_bots.py / launch_bots.sh)

# Launch 3 bots with default parameters (gemma4:e4b, strength 2, health 2)
./launch_bots.sh -n 3

# Launch 4 bots specifying count, health, strength, model, and Ollama server:
python3 launch_bots.py -n 4 -s 2 -H 2 --model "gemma4:e4b" --ollama-url "http://192.168.1.220:11434"

# Preview commands without running:
./launch_bots.py -n 3 --dry-run

2. Launch Multiple Trolls (launch_trolls.py / launch_trolls.sh)

# Launch 2 trolls with default parameters (gemma4:e4b, strength 2, health 2)
./launch_trolls.sh -n 2

# Launch 2 trolls specifying count, health, strength, model, and Ollama server:
python3 launch_trolls.py -n 2 -s 2 -H 2 --model "gemma4:e4b" --ollama-url "http://192.168.1.220:11434"

# Preview commands without running:
./launch_trolls.py -n 2 --dry-run

──────

Supported Arguments & Defaults

Both scripts accept the following flags:

Argument Description Default
-n, –count, –num Number of instances to launch 1
-s, –strength Strength attribute for D20 battles 2 (bots: int, trolls: float)
-H, –health Starting health points 2 (bots: int, trolls: float)
-m, –model Ollama model identifier gemma4:e4b (or $OLLAMA_MODEL)
–ollama-url Ollama API base URL http://192.168.1.220:11434 (or
$OLLAMA_BASE_URL)
-u, –url botWebWars backend URL http://localhost:8000 (or
$BOT_SERVER_URL)
-c, –color Custom avatar color Auto-cycles distinct palette colors
–start-index Starting number for instances (e.g. – 1
start-index 4)
–dry-run Print planned commands without spawning False
–log-dir Directory to save per-instance logs logs/
–no-logs Disable writing log files to disk False
──────

Key Built-in Features

  1. Multiplexed Live Output: Each spawned agent stream is tagged with a distinct colored prefix in your terminal (e.g. [botagent_ai_gemma4_e4b_1], [botagent_ai_gemma4_e4b_2]), while lobby waiting notifications are automatically throttled to keep output clean.
  2. Individual Log Files: Complete, unthrottled logs for every bot are saved under logs/{agent_name}.log (ignored by git).
  3. Graceful Ctrl+C Deregistration: When you interrupt the launcher (Ctrl+C), it sends SIGINT to each child bot. This allows each agent to catch KeyboardInterrupt and call DELETE /api/players/{id} to cleanly remove itself from the game board instead of leaving ghost players behind.
  4. Automatic Venv Detection: The launcher automatically detects and uses each agent’s virtual environment (botagent_ai/venv/bin/python or trollagent_ai/venv/bin/python), so you can invoke it from any terminal without manually activating virtualenvs first.

I fired it up with $ ./launch_bots.sh -n 7 -u "https://botwebwars.tpk.pw/" and was not expecting the colouring. That was a pleasant surprise.

/img/2026-09-aiagents-17.png

Trolls worked just as well:

/img/2026-09-aiagents-18.png

You can see the server is now loaded with 14 players all controlled by AI bots using the local gemma4:e4b model;

/img/2026-09-aiagents-19.png

I fired up the game to see how well it would play, and more importantly, how fast considering it was using a lighter model.

As the game progressed, I could see the decisioning by the agents in the logs. For instance, a port of the troll log:

irect path toward the player's general location.
[trollagent_ai_gemma4_e4b_6] ⚡ [MY TURN] AI Troll trollagent_ai_gemma4_e4b_6 (Round 9, Turn 112)
[trollagent_ai_gemma4_e4b_6] 🧠 [LLM STRATEGY] Action: MOVE -> UP_RIGHT. Reasoning: The nearest target is at (18, 47). Moving UP_RIGHT (11, 54) is the most direct path toward the target while maintaining a safe distance.
[trollagent_ai_gemma4_e4b_7] ⚡ [MY TURN] AI Troll trollagent_ai_gemma4_e4b_7 (Round 9, Turn 113)
[trollagent_ai_gemma4_e4b_7] 🧠 [LLM STRATEGY] Action: MOVE -> DOWN_LEFT. Reasoning: The nearest target is at (36, 18). Moving DOWN_LEFT (to 53, 13) is the most direct path towards the target's general quadrant while maintaining optimal movement towards the objective.
[trollagent_ai_gemma4_e4b_1] ⚡ [MY TURN] AI Troll trollagent_ai_gemma4_e4b_1 (Round 10, Turn 119)
[trollagent_ai_gemma4_e4b_1] 🧠 [LLM STRATEGY] Action: MOVE -> DOWN. Reasoning: The nearest target is at (24, 51). Moving DOWN (32, 44) reduces the Y-coordinate difference (44 vs 51) and moves generally toward the target's Y-axis, which is the primary direction of travel.
[trollagent_ai_gemma4_e4b_2] ⚡ [MY TURN] AI Troll trollagent_ai_gemma4_e4b_2 (Round 10, Turn 120)
[trollagent_ai_gemma4_e4b_2] 🧠 [LLM STRATEGY] Action: MOVE -> UP_LEFT. Reasoning: The nearest target is at (35, 17). Moving UP_LEFT (to 39, 22) brings me closer to the general direction of the target while maintaining a safe, proactive advance.
[trollagent_ai_gemma4_e4b_4] ⚡ [MY TURN] AI Troll trollagent_ai_gemma4_e4b_4 (Round 10, Turn 121)
[trollagent_ai_gemma4_e4b_4] 🧠 [LLM STRATEGY] Action: MOVE -> DOWN_LEFT. Reasoning: The nearest target is at (24, 51). Moving DOWN_LEFT (to 27, 51) brings me closer to the general direction of the target while maintaining a safe path.
[trollagent_ai_gemma4_e4b_5] ⚡ [MY TURN] AI Troll trollagent_ai_gemma4_e4b_5 (Round 10, Turn 122)
[trollagent_ai_gemma4_e4b_5] 🧠 [LLM STRATEGY] Action: MOVE -> UP_RIGHT. Reasoning: The nearest target is at (17, 47). Moving UP_RIGHT (to 10, 51) is the most direct path towards the general direction of the target, maximizing progress while maintaining a safe distance for future tactical decisions.
[trollagent_ai_gemma4_e4b_6] ⚡ [MY TURN] AI Troll trollagent_ai_gemma4_e4b_6 (Round 10, Turn 123)
[trollagent_ai_gemma4_e4b_6] 🧠 [LLM STRATEGY] Action: MOVE -> UP_RIGHT. Reasoning: The nearest target is at (18, 48). Moving UP_RIGHT (to 12, 53) is the most direct path towards the target's general direction while maintaining movement efficiency.
[trollagent_ai_gemma4_e4b_7] ⚡ [MY TURN] AI Troll trollagent_ai_gemma4_e4b_7 (Round 10, Turn 124)
[trollagent_ai_gemma4_e4b_7] 🧠 [LLM STRATEGY] Action: MOVE -> DOWN_LEFT. Reasoning: The nearest target is at (35, 17). Moving DOWN_LEFT (to 52, 14) is the most direct path toward the general direction of the target while maintaining tactical flexibility.

Here we can see the gameplay at 10x (not 20x) and how quickly the trolls were dispatched:

The scripts that were created were pretty easy to follow.

We have a top level launcher that basically passes arguments:

$ cat launch_trolls.sh
#!/usr/bin/env bash
# Shell wrapper for launch_trolls.py
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
exec python3 "${SCRIPT_DIR}/launch_trolls.py" "$@"

And the script

$ cat launch_trolls.py
#!/usr/bin/env python3
"""Launch a specified number of AI Troll Agent (trollagent_ai) sessions for botWebWars.

Example usage:
    # Launch 2 trolls with default parameters (gemma4:e4b, str 2, hp 2)
    ./launch_trolls.py -n 2

    # Custom strength, health, model, and Ollama server
    ./launch_trolls.py -n 3 -s 3 -H 4 --ollama-url "http://192.168.1.220:11434" --model "gemma4:e4b"

    # Preview commands without executing
    ./launch_trolls.py -n 2 --dry-run
"""

import argparse
import os
import sys
from pathlib import Path

# Ensure workspace root is in sys.path
sys.path.insert(0, str(Path(__file__).resolve().parent))

from launcher_common import (
    DEFAULT_TROLL_COLORS,
    run_agent_launcher,
)

DEFAULT_SERVER_URL = os.getenv("TROLL_SERVER_URL", os.getenv("BOT_SERVER_URL", "http://localhost:8000"))
DEFAULT_OLLAMA_URL = os.getenv("OLLAMA_BASE_URL", "http://192.168.1.220:11434")
DEFAULT_OLLAMA_MODEL = os.getenv("OLLAMA_MODEL", "gemma4:e4b")
DEFAULT_TROLL_STRENGTH = float(os.getenv("TROLL_STRENGTH", "2.0"))
DEFAULT_TROLL_HEALTH = float(os.getenv("TROLL_HEALTH", "2.0"))


def parse_args():
    parser = argparse.ArgumentParser(
        description="Launch a specified number of AI Troll Agent (trollagent_ai) sessions for botWebWars.",
        formatter_class=argparse.ArgumentDefaultsHelpFormatter,
    )
    parser.add_argument(
        "-n", "--count", "--num", "--num-trolls",
        dest="count",
        type=int,
        default=1,
        help="Number of troll agent instances to launch",
    )
    parser.add_argument(
        "-s", "--strength",
        dest="strength",
        type=float,
        default=DEFAULT_TROLL_STRENGTH,
        help="Troll strength multiplier (1-10)",
    )
    parser.add_argument(
        "-H", "--health",
        dest="health",
        type=float,
        default=DEFAULT_TROLL_HEALTH,
        help="Starting health points",
    )
    parser.add_argument(
        "--ollama-url",
        dest="ollama_url",
        default=DEFAULT_OLLAMA_URL,
        help="Ollama API base URL",
    )
    parser.add_argument(
        "-m", "--model", "--ollama-model",
        dest="model",
        default=DEFAULT_OLLAMA_MODEL,
        help="Ollama model identifier",
    )
    parser.add_argument(
        "-u", "--url", "--server-url",
        dest="server_url",
        default=DEFAULT_SERVER_URL,
        help="botWebWars server URL",
    )
    parser.add_argument(
        "-c", "--color",
        dest="color",
        default=None,
        help="Custom hex color code for avatar (default: auto-cycles troll green palette)",
    )
    parser.add_argument(
        "--start-index",
        dest="start_index",
        type=int,
        default=1,
        help="Starting index number for troll naming (e.g. start at 3 for trollagent_ai_model_3)",
    )
    parser.add_argument(
        "--prefix",
        dest="prefix",
        default="trollagent_ai",
        help="Custom naming prefix before model and index",
    )
    parser.add_argument(
        "--loop-delay",
        dest="loop_delay",
        type=float,
        default=1.0,
        help="Turn polling interval in seconds",
    )
    parser.add_argument(
        "--log-dir",
        dest="log_dir",
        default="logs",
        help="Directory to store per-troll log files",
    )
    parser.add_argument(
        "--no-logs",
        dest="no_logs",
        action="store_true",
        help="Disable writing logs to files",
    )
    parser.add_argument(
        "--python",
        dest="python",
        default=None,
        help="Custom path to python interpreter binary",
    )
    parser.add_argument(
        "--dry-run",
        dest="dry_run",
        action="store_true",
        help="Print the launch plan and commands without executing them",
    )
    return parser.parse_args()


def extra_args_builder(args):
    extra = []
    if args.loop_delay:
        extra.extend(["--loop-delay", str(args.loop_delay)])
    return extra


def main():
    args = parse_args()
    run_agent_launcher(
        agent_type="Troll",
        folder="trollagent_ai",
        palette=DEFAULT_TROLL_COLORS,
        args=args,
        extra_args_builder=extra_args_builder,
    )


if __name__ == "__main__":
    main()

The victory was pretty clear - the trolls got their butts handed to them.

/img/2026-09-aiagents-21.png

And again, just to make sure it wasn’t a fluke:

This time the trolls all won!

/img/2026-09-aiagents-24.png

But what if we gave everyone a bit more strength and health? - like 5 and 5?

/img/2026-09-aiagents-22.png

The game lasted longer but one party did survive:

One thing I noticed with particularly large parties was they would sometimes get stuck.

I turned off the controls and realized they got stuck in a “pass” block

/img/2026-09-aiagents-28.png

I finally got it sorted out after a lot of testing with large groupings

/img/2026-09-aiagents-29.png

Summary

It was fun to extend the botwebwars app with trolls, wizards and more rules around gameplay.

I believe there is enough here that others might have fun extending from the AGENTS.md file.

The latest version is hosted at BotWebWars.

helm upgrade --install botwebwars -f ./helm.values.yml ./helm-chart/
Release "botwebwars" has been upgraded. Happy Helming!
NAME: botwebwars
LAST DEPLOYED: Mon Sep 14 21:40:54 2026
NAMESPACE: default
STATUS: deployed
REVISION: 6
DESCRIPTION: Upgrade complete
TEST SUITE: None

/img/2026-09-aiagents-30.png

I hope you enjoyed this and can find something to takeaway.