Project

robot_wars

0.0
The project is in a healthy, maintained state
A turn-based game built on the RobotLab framework: each robot occupies a square on a 2D grid, all robots move simultaneously each turn, conflicts are resolved between turns, and the last robot remaining wins.
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
 Dependencies

Runtime

~> 0.2
 Project Readme

RobotWars

Note: This gem is still in development. It is waiting on the release of some of its dependencies before it can be published.

RobotWars is a component of the RobotLab multi-robot LLM orchestration project: a turn-based game in which RobotLab robots compete on a 2D grid board until only one remains.

RobotWars game board

Game Concept

A turn-based, last-robot-standing game. Robots move on a bounded grid, fight for squares, claim territory by conquest or occupation, shell each other blind from across the board, and die at 0 life points — or by having nowhere left to stand.

The complete numbered rule set lives in RULES.md; the design discussion behind each rule is logged in notes.md.

Status: phase 1 (headless engine) has a working core. RobotWars::Game runs full matches turn by turn — movement, the life-point economy, square conflicts (including cascading returns and displacement-or-death), ranged combat, death processing, and territory — end to end, with no graphics. The rwars CLI and an example runner exist (below). Still missing: the structured turn-event stream for phase 2 (the browser spectator view) to consume.

board = RobotWars::Board.new(width: 10, height: 10)
game = RobotWars::Game.start(board: board, robot_ids: %w[alpha bravo charlie delta])

until game.over?
  actions = game.alive_robots.to_h { |robot| [robot, your_strategy.decide(robot, game)] }
  game.play_turn(actions)
end

game.winner # => the last robot standing, or nil for a tie

See examples/01_random_match.rb for a runnable match (random actions, printed turn by turn) — bundle exec ruby examples/01_random_match.rb.

The rwars CLI — LLM-backed warriors

rwars runs a real match with each warrior's actions decided by an LLM, built from a human-authored prompt file (RobotLab's .md template format: YAML front matter + a personality body). One file per warrior; the filename (minus .md) becomes that warrior's id (files starting with _ are never warriors: shared partials, or the shipped _announcer.md announcer brain). A warrior file carries only its model choice and personality — the rules of the game are the gem's job: RobotWars::GameRules (shipped as lib/robot_wars/game_rules.md) is handed to every robot as its system prompt, so all warriors everywhere play by the same canonical rules.

Each warrior's .md front matter picks its own brain via the model: field, written as <provider>/<model id> — the RubyLLM provider name, a slash, then the model id as that provider knows it (e.g. apfel/apple-foundationmodel, ollama/llama3). Pass --model PROVIDER/MODEL to force every warrior onto the same one regardless of what its template says. Nothing is hardcoded: providers that ship outside ruby_llm (as ruby_llm-providers-<name> gems) are required on demand, and a template that names no model falls through to RobotLab's config cascade.

Most of the example warriors in examples/warriors/ pick Apfel — Apple's on-device Foundation Model, served locally by the apfel CLI. No API key, no cloud calls, no per-token cost:

brew install apfel   # once, per machine
apfel --serve        # start the local server (127.0.0.1:11434)

bin/rwars            # examples/warriors is the default roster

The gem ships its examples/ directory, so --warriors defaults to the installed gem's examples/warriors; point it at your own directory to field your own roster.

Apfel requires an Apple Silicon Mac on macOS 26+ with Apple Intelligence enabled. See the ruby_llm-providers-apfel gem for details.

Each turn, a warrior is sent a sensing report (RULES.md 33-35: its own position and life, and the full map of owned squares — never another robot's position) and must reply with exactly one line:

STAY
MOVE <north|northeast|east|southeast|south|southwest|west|northwest>
ATTACK <x>,<y> <points>
DEFEND <points>

A reply that doesn't parse — or one that commits more points than the warrior's current life — is treated as an illegal-move-style penalty (RULES.md 21, 41, 43) rather than crashing the match, and a pilot that fails to reply within the time limit is declared brain dead and removed from the match entirely (RULES.md 45). Run bin/rwars --help for all options, including --seed for a reproducible match, --timeout for the brain-dead limit, and --max-turns as a safety valve against stalemates.

Pass --announcer to put a radio play-by-play announcer in the booth: an LLM persona that narrates the lineup, every turn's recap, and the final result — and speaks each call aloud through the platform's text-to-speech (say on macOS, PowerShell's System.Speech on Windows, spd-say/espeak-ng/espeak on Linux). Its brain is a template editable exactly like a warrior's: the gem's shipped examples/warriors/_announcer.md by default (front matter picks lms/openai/gpt-oss-20b), or your own file anywhere via --announcer path/to/my_announcer.md. The match and the booth run as a broadcast pipeline: turn 1 waits for the opening call, and from then on the robots play turn N+1 while the announcer is calling turn N. Each turn's story lands on every channel at once — the terminal transcript, the browser update, and the start of the announcer's call — so what you see always matches what you hear, commentary never trails by more than one turn, and every turn gets its own call.

Pass --browser to watch the match live in a web browser: a spectator page served from a background thread (no extra dependencies) and opened in your default browser. Every resolved turn streams an update over Server-Sent Events — warriors move across the board wearing their life points, owned squares are shaded in their owner's color, the legend tracks life and position (the fallen keep a dimmed row), and a play-by-play panel scrolls the same lines the terminal prints. This is the referee's God view: it shows everything the warriors' own rule-33 sensing hides. When the match ends the final board stays on screen. Bare --browser picks a free port; --browser PORT fixes it.

examples/warriors/ has five ready-made brains with distinct personalities — a fight between them exercises very different play styles:

Warrior Strategy
warmonger.md Relentlessly aggressive; attacks almost every turn
oppressor.md Warmonger's clone on a different brain (lms/openai/gpt-oss-20b) — shows per-warrior model choice in action
sentinel.md Patient turtle; claims territory by occupation, rarely fights
opportunist.md Reactive and adaptive; picks its spots, varies its play
wanderer.md Pure expansionist; claims empty ground, avoids conflict entirely
bin/rwars --size 10x10 --announcer   # default roster, spoken play-by-play

Installation

Add the gem to your application's Gemfile:

bundle add robot_wars

Or install it directly:

gem install robot_wars

Usage

require "robot_wars"

Drive the headless engine directly (see the Game Concept snippet above and examples/01_random_match.rb), or run a full LLM-piloted match with bin/rwars. The complete rules are in RULES.md.

Development

After checking out the repo, run bundle install to install dependencies, then bundle exec rake test to run the tests.

During cross-gem development the Gemfile.local points at the sibling ../robot_lab checkout (selected via the project-root .envrc / asgard). Quality gates are run with asgard quality.

License

The gem is available as open source under the terms of the MIT License.