Skip to content

Battle Runner API

The Battle Runner API lets you run Robocode Tank Royale battles programmatically from any JVM application — no GUI required. Use it for automated testing, benchmarking, tournament systems, or any scenario where you need headless battle execution.

Java/JVM only

The Battle Runner is currently available for Java and Kotlin (any JVM language) only. Python and C# ports do not exist yet. If you need support for another platform, please open a feature request on GitHub.

Installation

Use the current release version from the repository VERSION file in the dependency coordinates below.

kotlin
dependencies {
    implementation("dev.robocode.tankroyale:robocode-tankroyale-runner:{VERSION}")
}
groovy
dependencies {
    implementation 'dev.robocode.tankroyale:robocode-tankroyale-runner:{VERSION}'
}
xml
<dependency>
    <groupId>dev.robocode.tankroyale</groupId>
    <artifactId>robocode-tankroyale-runner</artifactId>
    <version>{VERSION}</version>
</dependency>

Quick Start

kotlin
import dev.robocode.tankroyale.runner.*

BattleRunner.create { embeddedServer() }.use { runner ->
    val results = runner.runBattle(
        setup = BattleSetup.classic { numberOfRounds = 5 },
        bots  = listOf(BotEntry.of("/path/to/MyBot"), BotEntry.of("/path/to/EnemyBot"))
    )
    results.results.forEach { bot ->
        println("#${bot.rank} ${bot.name} - ${bot.totalScore} pts")
    }
}
java
import dev.robocode.tankroyale.runner.*;
import java.util.List;

try (var runner = BattleRunner.create(b -> b.embeddedServer())) {
    var results = runner.runBattle(
        BattleSetup.classic(s -> s.setNumberOfRounds(5)),
        List.of(BotEntry.of("/path/to/MyBot"), BotEntry.of("/path/to/EnemyBot"))
    );
    for (var bot : results.getResults()) {
        System.out.printf("#%d %s - %d pts%n", bot.getRank(), bot.getName(), bot.getTotalScore());
    }
}

Features

  • Embedded or external server — start a managed server automatically, or connect to an existing one
  • Game type presetsclassic, melee, 1v1, custom with full parameter overrides
  • Synchronous and async APIs — block until results, or stream real-time events
  • Battle recording — write .battle.gz replay files (same format as the Recorder module)
  • Intent diagnostics — capture raw bot-intent messages per bot per turn via an opt-in WebSocket proxy
  • Multi-battle reuse — run thousands of battles on the same BattleRunner instance without server restarts
  • Identity-based bot matching — bots are matched by name+version from their config files; stray bots and duplicate instances are handled correctly
  • Team bot support — team directories are expanded into one identity per member; member directories are validated at battle-start time
  • Configurable boot timeout — set how long to wait for bots to connect via botConnectTimeout(Duration)
  • Boot progress events — subscribe to onBootProgress on BattleHandle for real-time connection status

Creating a BattleRunner

The BattleRunner is the main entry point. It manages the server lifecycle, WebSocket connections, and bot processes. Always use it with use {} (Kotlin) or try-with-resources (Java) to ensure proper cleanup.

Embedded Server (default)

The runner starts and manages its own server process:

kotlin
// Dynamic port (recommended)
val runner = BattleRunner.create { embeddedServer() }

// Specific port
val runner = BattleRunner.create { embeddedServer(port = 7654) }
java
// Dynamic port (recommended)
var runner = BattleRunner.create(b -> b.embeddedServer());

// Specific port
var runner = BattleRunner.create(b -> b.embeddedServer(7654));

External Server

Connect to a pre-started server:

kotlin
val runner = BattleRunner.create { externalServer("ws://192.168.1.100:7654") }
java
var runner = BattleRunner.create(b -> b.externalServer("ws://192.168.1.100:7654"));

Battle Setup

Battle configuration starts from a game type preset. Individual parameters can be overridden.

PresetArenaMin BotsMax Bots
classic800×6002
melee1000×100010
oneVsOne800×60022
custom800×6002
kotlin
val setup = BattleSetup.classic()
val setup = BattleSetup.classic { numberOfRounds = 10 }
val setup = BattleSetup.melee { arenaWidth = 1200; arenaHeight = 1200 }
val setup = BattleSetup.oneVsOne()
val setup = BattleSetup.custom {
    arenaWidth = 500
    arenaHeight = 500
    numberOfRounds = 3
    gunCoolingRate = 0.2
}
java
var setup = BattleSetup.classic();
var setup = BattleSetup.classic(s -> s.setNumberOfRounds(10));
var setup = BattleSetup.melee(s -> { s.setArenaWidth(1200); s.setArenaHeight(1200); });
var setup = BattleSetup.oneVsOne();
var setup = BattleSetup.custom(s -> {
    s.setArenaWidth(500);
    s.setArenaHeight(500);
    s.setNumberOfRounds(3);
    s.setGunCoolingRate(0.2);
});

Configurable Parameters

ParameterDescription
arenaWidthArena width in pixels
arenaHeightArena height in pixels
minNumberOfParticipantsMinimum bots required to start
maxNumberOfParticipantsMaximum bots allowed (null = unlimited)
numberOfRoundsNumber of rounds to play
gunCoolingRateRate at which gun heat decreases per turn
maxInactivityTurnsMax consecutive turns without activity
turnTimeoutMicrosPer-turn deadline for bot intents (µs)
readyTimeoutMicrosDeadline for bots to signal ready (µs)

Bot Selection

Bots are identified by their directory path. Each directory must contain a bot configuration file named <directory-name>.json.

kotlin
val bots = listOf(
    BotEntry.of("/home/user/bots/MyBot"),
    BotEntry.of("/home/user/bots/EnemyBot"),
    BotEntry.of(Path.of("/home/user/bots/AnotherBot")),
)
java
var bots = List.of(
    BotEntry.of("/home/user/bots/MyBot"),
    BotEntry.of("/home/user/bots/EnemyBot"),
    BotEntry.of(Path.of("/home/user/bots/AnotherBot"))
);

Running Battles

Synchronous (blocking)

The simplest approach — blocks until the battle completes and returns results:

kotlin
val results = runner.runBattle(setup, bots)
println("Winner: ${results.results.first().name}")
java
var results = runner.runBattle(setup, bots);
System.out.println("Winner: " + results.getResults().get(0).getName());

Asynchronous (event-driven)

For real-time event streaming and battle control:

kotlin
val owner = Any()

runner.startBattleAsync(setup, bots).use { handle ->
    handle.onTickEvent.on(owner) { tick ->
        println("Turn ${tick.turnNumber}: ${tick.botStates.size} bots alive")
    }
    handle.onRoundEnded.on(owner) { round ->
        println("Round ${round.roundNumber} ended")
    }

    val results = handle.awaitResults()
}
java
var owner = new Object();

try (var handle = runner.startBattleAsync(setup, bots)) {
    handle.getOnRoundStarted().on(owner, event ->
            System.out.printf("Round %d started%n", event.getRoundNumber()));
    handle.getOnRoundEnded().on(owner, event ->
            System.out.printf("Round %d ended (turn %d)%n",
                    event.getRoundNumber(), event.getTurnNumber()));

    var results = handle.awaitResults();
}

Battle Handle Events

EventDescription
onTickEventFires each turn with full game state
onRoundStartedFires when a new round begins
onRoundEndedFires when a round ends
onGameStartedFires when the game starts (all bots ready)
onGameEndedFires when the game ends with final results
onGameAbortedFires when the game is aborted
onGamePaused / onGameResumedFires on pause/resume
onBotListUpdateFires when the connected bot list changes
onBootProgressFires during bot connection phase with identity-aware progress

Battle Handle Controls

MethodDescription
pause()Pauses the battle
resume()Resumes a paused battle
stop()Stops the battle
nextTurn()Advances one turn while paused (single-step debugging)

Use the event-driven pattern to react to pause confirmation before stepping — this avoids sending nextTurn before the server has processed the pause:

kotlin
val owner = Any()
val controlled = AtomicBoolean()

runner.startBattleAsync(setup, bots).use { handle ->
    // Pause at turn 5 (once)
    handle.onTickEvent.on(owner) { tick ->
        if (tick.turnNumber == 5 && controlled.compareAndSet(false, true)) {
            handle.pause()
        }
    }

    // When paused: step 3 turns, then resume
    handle.onGamePaused.on(owner) { _ ->
        println("  Battle paused - stepping 3 turns manually...")
        handle.nextTurn()
        handle.nextTurn()
        handle.nextTurn()
        println("  Resuming...")
        handle.resume()
    }

    val results = handle.awaitResults()
}
java
var owner = new Object();
var controlled = new AtomicBoolean();

try (var handle = runner.startBattleAsync(setup, bots)) {
    // Pause at turn 5 (once)
    handle.getOnTickEvent().on(owner, tick -> {
        if (tick.getTurnNumber() == 5 && controlled.compareAndSet(false, true)) {
            handle.pause();
        }
    });

    // When paused: step 3 turns, then resume
    handle.getOnGamePaused().on(owner, event -> {
        System.out.println("  Battle paused - stepping 3 turns manually...");
        handle.nextTurn();
        handle.nextTurn();
        handle.nextTurn();
        System.out.println("  Resuming...");
        handle.resume();
    });

    var results = handle.awaitResults();
}

Results

BattleResults contains per-bot scores ordered by final ranking:

kotlin
val results = runner.runBattle(setup, bots)
println("Rounds played: ${results.numberOfRounds}")
for (bot in results.results) {
    println("#${bot.rank} ${bot.name} v${bot.version}")
    println("  Total: ${bot.totalScore}, Survival: ${bot.survival}")
    println("  Bullet damage: ${bot.bulletDamage}, Ram damage: ${bot.ramDamage}")
    println("  1st places: ${bot.firstPlaces}")
}
java
var results = runner.runBattle(setup, bots);
System.out.println("Rounds played: " + results.getNumberOfRounds());
for (var bot : results.getResults()) {
    System.out.printf("#%d %s v%s%n", bot.getRank(), bot.getName(), bot.getVersion());
    System.out.printf("  Total: %d, Survival: %d%n", bot.getTotalScore(), bot.getSurvival());
    System.out.printf("  Bullet damage: %d, Ram damage: %d%n", bot.getBulletDamage(), bot.getRamDamage());
    System.out.printf("  1st places: %d%n", bot.getFirstPlaces());
}

Battle Recording

Record battles to .battle.gz files that can be replayed in the GUI:

kotlin
BattleRunner.create {
    embeddedServer()
    enableRecording(Path.of("recordings"))
}.use { runner ->
    runner.runBattle(BattleSetup.classic(), bots)
    // Recording saved to recordings/game-<timestamp>.battle.gz
}
java
try (var runner = BattleRunner.create(b -> {
    b.embeddedServer();
    b.enableRecording(Path.of("recordings"));
})) {
    runner.runBattle(BattleSetup.classic(), bots);
}

Intent Diagnostics

Enable the transparent WebSocket proxy to capture raw bot-intent messages for debugging:

kotlin
BattleRunner.create {
    embeddedServer()
    enableIntentDiagnostics()
}.use { runner ->
    runner.runBattle(setup, bots)
    val store = runner.intentDiagnostics
    // Query captured intents per bot per turn
}
java
try (var runner = BattleRunner.create(b -> {
    b.embeddedServer();
    b.enableIntentDiagnostics();
})) {
    runner.runBattle(setup, bots);
    var store = runner.getIntentDiagnostics();
}

After the battle completes, query the captured intents from the store:

kotlin
val store = runner.intentDiagnostics ?: return
for (botName in store.botNames()) {
    val intents = store.getIntentsForBot(botName)
    println("$botName — ${intents.size} intents")
    for (ci in intents.take(5)) {
        println("  round=${ci.roundNumber} turn=${ci.turnNumber}" +
                " speed=${ci.intent.targetSpeed} fire=${ci.intent.firepower}")
    }
}
java
var store = runner.getIntentDiagnostics();
if (store == null) return;
for (var botName : store.botNames()) {
    var intents = store.getIntentsForBot(botName);
    System.out.printf("%s — %d intents%n", botName, intents.size());
    for (var ci : intents.subList(0, Math.min(5, intents.size()))) {
        System.out.printf("  round=%d turn=%d speed=%s fire=%s%n",
                ci.getRoundNumber(), ci.getTurnNumber(),
                ci.getIntent().getTargetSpeed(), ci.getIntent().getFirepower());
    }
}

WARNING

Intent diagnostics adds an extra network hop between bots and server. Only enable when needed for debugging.

Identity-Based Bot Matching

Bots are matched by name and version read from their <dir>.json config files. This means:

  • Stray bots that connect but were not requested are ignored
  • If the same bot directory is listed twice, two connections with that identity are required
  • Team directories are expanded: each teamMembers entry becomes one expected identity
  • Member directories are validated at battle-start time — a missing member directory throws BattleException immediately

Team Bot Entries

A team directory contains a <dir>.json with a teamMembers array listing member bot names. Each member must have its own sibling directory with a matching config file. Add the team directory as a single BotEntry — the runner expands it automatically:

kotlin
val bots = listOf(
    BotEntry.of("/path/to/MyTeam"),   // expands to one entry per team member
    BotEntry.of("/path/to/EnemyBot")
)
java
var bots = List.of(
    BotEntry.of("/path/to/MyTeam"),   // expands to one entry per team member
    BotEntry.of("/path/to/EnemyBot")
);

Configurable Boot Timeout

By default the runner waits 30 seconds for all bots to connect. Override this with botConnectTimeout(Duration):

kotlin
BattleRunner.create {
    embeddedServer()
    botConnectTimeout(Duration.ofSeconds(60))
}.use { runner ->
    runner.runBattle(BattleSetup.classic(), bots)
}
java
try (var runner = BattleRunner.create(b -> {
    b.embeddedServer();
    b.botConnectTimeout(Duration.ofSeconds(60));
})) {
    runner.runBattle(BattleSetup.classic(), bots);
}

Boot Progress Reporting

Subscribe to onBootProgress on the BattleHandle to receive real-time connection status while bots are starting up. The event fires on every bot-list update and every 500 ms:

kotlin
val owner = Any()
runner.startBattleAsync(setup, bots).use { handle ->
    handle.onBootProgress.on(owner) { progress ->
        println("Connected ${progress.totalConnected}/${progress.totalExpected}" +
                " (${progress.elapsedMs}ms / ${progress.timeoutMs}ms)")
        if (progress.pending.isNotEmpty()) {
            println("  Pending: ${progress.pending}")
        }
    }
    handle.awaitResults()
}
java
var owner = new Object();
try (var handle = runner.startBattleAsync(setup, bots)) {
    handle.getOnBootProgress().on(owner, progress -> {
        System.out.printf("Connected %d/%d (%dms / %dms)%n",
                progress.getTotalConnected(),
                progress.getTotalExpected(),
                progress.getElapsedMs(),
                progress.getTimeoutMs());
    });
    handle.awaitResults();
}

BootProgress fields

FieldTypeDescription
expectedMap<BotIdentity, Int>Expected identities and their required counts
connectedMap<BotIdentity, Int>Currently connected identities and counts
pendingMap<BotIdentity, Int>Identities still waiting to connect
elapsedMsLongMilliseconds elapsed since boot started
timeoutMsLongConfigured timeout in milliseconds
totalExpectedIntTotal number of expected bot connections
totalConnectedIntTotal number of connected bots so far

Multi-Battle Usage

The runner reuses the server across battles — only bot processes are recycled:

kotlin
BattleRunner.create { embeddedServer() }.use { runner ->
    repeat(1000) { i ->
        val results = runner.runBattle(
            BattleSetup.classic { numberOfRounds = 10 },
            bots
        )
        println("Battle $i: winner = ${results.results.first().name}")
    }
}
java
try (var runner = BattleRunner.create(b -> b.embeddedServer())) {
    for (int i = 0; i < 1000; i++) {
        var results = runner.runBattle(
            BattleSetup.classic(s -> s.setNumberOfRounds(10)),
            bots
        );
        System.out.printf("Battle %d: winner = %s%n", i, results.getResults().get(0).getName());
    }
}

Error Handling

The runner throws BattleException for all battle-related failures:

  • Bot path does not contain a valid configuration file
  • Bot config file is missing required name or version fields
  • Team member directory not found (validated at battle-start time)
  • Not enough bots to meet the minimum participant count
  • Server unreachable (external mode) or failed to start (embedded mode)
  • Battle aborted (not enough bots ready)
  • Timeout waiting for bots to connect or game to start (message includes pending identities)
  • Connection lost during battle

Runnable Examples

Ready-to-run Java examples are in runner/examples/. See the examples README for setup instructions.

ExampleDescription
RunBattle.javaSynchronous battle — blocks until done, prints a results table
AsyncBattle.javaAsynchronous battle — streams round start/end events in real time
RecordBattle.javaRecords a battle to a .battle.gz replay file
IntentDiagnosticsBattle.javaCaptures per-turn bot intents and prints a turn-by-turn table
ControlBattle.javaPauses at turn 5, steps 3 turns manually, then resumes
BootProgressBattle.javaDemonstrates boot progress reporting via onBootProgress
TeamBattle.javaDemonstrates running a battle with a team bot entry

API Reference

Released under the Apache License 2.0.