Introduction
ByteC or bytec is a compiler from a tiny Rust-like language to bytecode-optimized Java, intended for use in Battlecode.
The language (also called "bytec" for lack of a better name) has a few features Java doesn't, like zero-overhead dynamic arrays, unrolled statically-sized arrays, and tuples, and it's fully interoperable with Java so it can be used for the performance-critical parts of a Java bot. The language itself is somewhere in between Rust and Java.
For an example of a Battlecode 2022 bot written in ByteC, see https://github.com/naalit/battlecode22
Usage and Project Structure
ByteC can be installed by cloning this repository and then running cargo install --path . (you'll need Rust installed to use cargo); make sure you have cargo's output directory on your PATH (this is ~/.cargo/bin on Linux). After that, you can use the bytec command to run the compiler.
The project structure for a ByteC-based Battlecode bot should look something like this (see an example Battlecode 2022 bot here):
battlecode22- the Git repository for this year's bots, a clone ofbattlecode22-scaffoldbytec- this is the directory that all ByteC source files go in; it would be calledsrc, but that must be the output directory because it's hardcoded into the Battlecode engine.common- modules in here will be shared between all bots.Common.btPaths.bt
bot_oneRobotPlayer.btSoldier.bt
bot_twoRobotPlayer.btMiner.bt
src- output Java files must go in this directory to be seen by the Battlecode client. This is probably in.gitignoreso you don't commit these files every time.- ...
- ...
Then, the CLI syntax for bytec looks like bytec source-path dest-path, so here it would be (note that files in bot_one can still use files in common, even though it's not in the command-line arguments):
bytec bytec/bot_one src/bot_one
This is intended to make it easy to test against alternate versions of your bot - simply compile to a certain output package (the names don't need to match, bytec will insert the necessary package declarations), make a change, and then compile it again to a different output package and run them against each other. The ability to override constants from the command line (-CModule::SOME_CONST=12, along the lines of C's -DSOME_CONST=12) also helps with that, especially with scripts that might test many possible parameters automatically.
VSCode extension
There's a VSCode extension for ByteC with syntax highlighting, errors as you type, and autocomplete for class members. The extension is not on the marketplace, unfortunately - to install it, you'll want to symlink the vscode folder at the top level of the bytec repository to [VSCODE-DIRECTIORY]/extensions/bytec-basic - the VSCode directory is ~/.vscode-oss on Linux. The extension assumes you've installed bytec to $HOME/.cargo/bin, which is the default on Linux (and I think also on Mac?).
Alternatively, if you open the bytec repository in VSCode and then click F5 (or "Launch Extension" in the Run and Debug menu), it should open a new VSCode window with the extension loaded, without needing to install the extension manually - but you'd need to do that every time you use it, so actually installing the extension is preferred.
Language Tour
ByteC is syntactically similar to Rust: functions are declared with fn and variables with let and type inference.
#![allow(unused)] fn main() { fn triple(x: i32): i32 { let y = x + x; x + y // the last statement in a block is returned automatically } }
Functions and variables declared on the top-level of a module (a source file) turn into static members in Java.
Functions can be written in a single-expression style as well:
#![allow(unused)] fn main() { fn triple2(x: i32): i32 = x * 3; }
ByteC has a few "primitive" types, which should be familiar from Rust, along with as to convert between number types:
#![allow(unused)] fn main() { let a: i32 = 12; let b: i64 = 1300000000; let c: str = "Hello, world!"; let d: bool = false; let e: () = {}; // The unit type, used for `void` functions let f: f32 = 9.2; let g: f64 = 0.9352891473 + a as f64; }
As well as tuples, which don't yet support destructuring but do support member access with dot syntax. These are of course lowered to separate variables.
#![allow(unused)] fn main() { let tup: (i32, i64) = (a, b); a += tup.0; b += tup.1; }
The == and != operators are automatically translated to .equals() when comparing objects, as long as neither side is null;
so you can just compare everything with == and it should never cause problems unless you specifically need reference equality for some reason.
Other operators are the same as Java, except that bitwise operators all have the same precedence and you should really just be using parentheses for those anyway.
All variables are immutable by default, but they can be declared mutable using let mut, and can then be reassigned and modified in the normal way:
#![allow(unused)] fn main() { let mut a = 3; a += 2; a = 4; }
However, since ByteC compiles to Java, objects are always mutable - if you have an immutable let variable referring to an object, you can still reassign fields of the object, but you can't reassign the variable to point to a new object.
if-else and match (the equivalent to Java switch) are expressions, and can return a value from each branch.
They don't require parentheses, but do require braces around the body:
#![allow(unused)] fn main() { fn do_stuff(cond: bool, ty: RobotType): i32 { let a = if cond { // this is just a normal block, we can put statements here too let tmp = 12 + 3; tmp + 4 } else { 22 }; let b = match ty { ARCHON => 27, MINER => 2 + { // a block can be used in expression position // this variable is unrelated to the last one, blocks create their own scopes let tmp = 9 + 8; tmp * 2 }, // this is the `default` case // it's not necessary if we handle all possible variants else => 0, }; a * b } }
Loops are not expressions, and there are about three types:
#![allow(unused)] fn main() { // The simplest kind of loop, loops infinitely until a `break` let mut a = 0; loop { a += 1; if a > 12 { break; } } // A while loop, this is just like other procedural languages while a < 24 { a += 1; } // There are actually three kinds of four loop: // A range for loop - this can be unrolled by adding the `unroll` keyword before the start of the range: for i in 0..10 { a += i; } // A loop over a static array - this is guaranteed to be unrolled: let sArr: [i32; 3] = [1, 2, 3]; for i in sArr { a += i; } // A loop over a dynamic array - this is never unrolled: let dArr: [i32] = [1, 2, 3]; for i in dArr { a += i; } }
ByteC does have null, which is a possible value of classes, enums, and strings (but not either kind of array).
There are currently no optional types or any type safety regarding null.
null is usually inferred as the correct type, but in some cases the compiler can't figure this out and a construction like this is needed:
#![allow(unused)] fn main() { fn getMapLocation(): MapLocation { let n: MapLocation = null; n } }
Arrays
The two types of arrays, static and dynamic, are a concept that doesn't exist in Java so they deserve some explanation.
A static array has a constant length known at compile time, and it becomes a bunch of separate variables in Java.
Any loop over a static array is unrolled, and indexing the array must either use an index that the compiler can figure out is constant, or use the inline keyword to be turned into a switch:
#![allow(unused)] fn main() { let arr: [i32; 16]; fn getArr(i: i32): i32 { arr[inline i] } }
A dynamic array, on the other hand, doesn't have a length known at compile time. In fact, the length of the array can change at any moment.
It's similar to a Java ArrayList, but all the logic is inlined, so any operations that normal Java arrays support (indexing, index assignment, looping over the array) are exactly as fast, and push and pop are pretty fast as well and don't involve method calls. Java arrays returned by extern functions are automatically converted to ByteC dynamic arrays. (Internally, dynamic arrays are represented as a Java array, which might have open space at the end, and a length.) Here's an example of the operations supported by dynamic arrays:
#![allow(unused)] fn main() { // This initializes an array of five zeros let x: [i32] = [; 5]; // The length of the array can be accessed with .len(); there is currently no way to access the capacity. // This is also supported by static arrays println("length: " + x.len()); // Note that there isn't actually bounds checking in array indexes for performance // So if ByteC has allocated 8 elements but the length of the array is only 5, accessing x[6] has an undefined result // (in practice it will return 0 or the last element to occupy that slot, or throw an exception if the space isn't allocated) x[0] = 2; let y = x[4]; // adds 12 to the end of the array, reallocating if there isn't enough space x.push(12); // pops the last element off the end of the array, leaving the space to be used by future push() calls let twelve = x.pop(); // clears all elements from the array, setting the length to 0 and leaving the capacity allocated // note that this only costs 2 bytecode, no matter the array capacity! x.clear(); }
Classes and Enums
Classes in bytec (which aren't marked extern - we'll cover extern classes in the Java interop section) are pretty simple. They can have fields and methods, but custom constructors aren't yet supported (and neither are static members).
class LocInfo {
let loc: MapLocation;
let rubble: i32;
// Fields can have an initial value, but must have an explicit type
let score: i32 = 0;
fn set(loc: MapLocation) throws GameActionException {
self.loc = loc;
self.rubble = rc.senseRubble(loc);
}
}
let x = LocInfo();
x.set(rc.getLocation());
Enums are a little further from Java enums, and closer to Rust ones. Each variant can have members, which essentially form a tuple:
#![allow(unused)] fn main() { enum Action { Attack(RobotInfo), Envision(AnomalyType), Move(Direction), Disintegrate; fn isGood(): bool { match self { Disintegrate => false, Envision(a) => a != AnomalyType::FURY || !isFriendlyArchonInRange(), _ => true, } } } let toTake = Action::Move(Direction::NORTH); if toTake.isGood() { rc.move(Direction::NORTH); } }
Modules
Using multiple source files ("modules") is generally a good idea.
Items from other files can be accessed with :: or with use (wildcards Mod::* are supported!), the same as Rust:
// One.bt let one = 1; fn main() = Two::doStuff();
#![allow(unused)] fn main() { // Two.bt use One::one; // One::* would also work extern fn println(x: str) = "System.out.println"; fn doStuff() { println("two: " + (one + one)); } }
Circular dependencies are allowed (at least, almost all the time).
Modules can be nested in directories, and module paths are defined relative to the root bytec directory that all your source files are in - this lets the compiler search the filesystem for a module when you use it for the first time.
So with the example layout from earlier:
byteccommonCommon.btPaths.bt
bot_oneRobotPlayer.btSoldier.bt
bot_twoRobotPlayer.btMiner.bt
We can use items like so:
#![allow(unused)] fn main() { // bot_one/RobotPlayer.bt use common::Common::RobotController; use bot_one::Soldier; fn turn(rc: RobotController) { Soldier::turn(rc); } }
Constants
Constants replace the old C-like system of defines that bytec used until Battlecode 2023, and are much easier to use (for instance, they're scoped like all other items!). They look like let definitions, but using const instead of let. The constant value is always inlined into use sites - this is the difference between a top-level let and const.
Warning! The value of a constant is essentially pasted at every use. Generally, a constant should have a value that is known at compile time; otherwise, it may be worse than it looks for performance, as the value is evaluated multiple times (just use let instead if computation is involved). Additionally, something like const C = getCurrentTime() is possible, and would have a different value each time it is used, which though convenient is not easily visible. Eventually the compiler is intended to check that constants have compile-time known values, but this has not yet been implemented, so be careful.
#![allow(unused)] fn main() { // Main.bt const SOME_FLAG = true; const SOME_CONST = 12; fn someFunction() { // This if will disappear, since the value of SOME_FLAG is known at compile time if SOME_FLAG { // SOME_CONST will be replaced with the value 12 during compilation println("SOME_CONST = " + SOME_CONST); } } }
You can also override constants from the command line with -C<module>::<constant>=<value> - for this example: -CMain::SOME_FLAG=false -CMain::SOME_CONST=42. (This is an analogue of C's -DSOME_CONST=52.)
This can be useful for testing different magic numbers and features in a systematic way.
Optimization Features
The primary purpose of ByteC is to enable high-level, maintanable programming that preserves maximum bytecode efficiency. To that end, it has a few features designed specifically for this.
Unrolled loops
Statically sized arrays were briefly mentioned in the language tour, but it's worth explaining them in more detail here. An array that looks like this:
#![allow(unused)] fn main() { let arr: [i32; 3] = [1, 2, 3]; arr[1] += 1; for i in arr { println("i = " + i); } }
Will generate Java code like this:
static int arr$0 = 1;
static int arr$1 = 2;
static int arr$2 = 3;
arr$1 += 1;
System.out.println("i = " + arr$0);
System.out.println("i = " + arr$1);
System.out.println("i = " + arr$2);
You can also unroll range for loops with the unroll keyword:
#![allow(unused)] fn main() { for i in unroll 0..3 { arr[i] += i; } }
Note that you can index static arrays with any expression that the compiler can figure out at compile time. For example, this reduces to a bunch of one-line assignments to local variables, and the indices and if statements are all resolved at compile time:
#![allow(unused)] fn main() { let cache: [i32; 81]; for x in unroll -4..5 { for y in unroll -4..5 { if x*x + y*y <= 20 { let i = (x + 4) * 9 + y + 4; cache[i] = rc.senseRubble(MapLocation(at.x + x, at.y + y)); } } } }
By default, indexing static arrays by non-constant expressions is an error (although the message isn't very helpful yet in this case).
If you really want to generate a switch over all possible indices, you can do that with the inline keyword:
#![allow(unused)] fn main() { let tx = target.x - at.x; let ty = target.y - at.y; if tx*tx + ty*ty <= 20 { let i = (tx + 4) * 9 + ty + 4; return cache[inline i]; } }
Note that these inline index expressions are currently not supported on the left-hand-side of an assignment, i.e. cache[inline i] = 12; doesn't work.
Inline functions
You can mark functions inline to make the compiler inline them:
#![allow(unused)] fn main() { fn inline manhattanDistance(a: MapLocation, b: MapLocation): i32 { abs(a.x - b.x) + abs(a.y - b.y) } }
If the arguments are constant, they will be propagated throughout the function body, so you can e.g. access static arrays with indices that depend on the function arguments as long as the function is only ever called with constant arguments (e.g. in unrolled loops is fine). Note that return is not allowed in inline functions.
Interacting with the Battlecode API
Overview of Java interop in general
Java interop is done with the extern keyword, which can be used in many contexts with roughly the same meaning of "the thing following this is a Java thing".
The simplest way is extern blocks, which can be at the top level (e.g. for imports) or in blocks, and can be multiline with braces or single line with quotes:
#![allow(unused)] fn main() { extern { import battlecode.common.*; } fn doStuff() { extern "System.out.print(\"Hello, \");"; extern { System.out.println("world!"); } } }
You can mark almost any ByteC name as pub to make it visible from Java, as long as it has a type that exists in Java:
#![allow(unused)] fn main() { fn pub makeGreeting(x: str): str = "Hello, " + x; fn greet(pub name: str) { extern { String greeting = makeGreeting(name); System.out.println(greeting); } } }
However, actually writing inline Java code is very rarely necessary. Usually, you'll define the Java API to interact with and then interact with it.
This uses extern classes, enums, and functions, which are generally written just like their non-extern variants, but without function bodies. There's also the constructor keyword for class constructor prototypes, although only one can be used for a given class. The names of extern functions can be changed by adding an = "<name>", which is especially important for static methods and overloading (neither of which is natively supported by ByteC).
#![allow(unused)] fn main() { extern class MapLocation { constructor(x: i32, y: i32); let x: i32; let y: i32; fn add(dir: Direction): MapLocation; fn distanceSquaredTo(loc: MapLocation): i32; } extern enum RobotType { ARCHON, BUILDER, LABORATORY, MINER, SAGE, SOLDIER, WATCHTOWER; let damage: i32; let health: i32; fn canAttack(): bool; fn canMine(): bool; } extern fn bytecodeNum(): i32 = "Clock.getBytecodeNum"; }
Interacting with the Battlecode API
You'll need to use the Battlecode API to do anything useful, and unfortunately this is a little bit harder than in Java, because ByteC isn't smart enough to find and read the API by itself. You'll probably have a file called something like Common.bt or API.bt shared by all your bots, which has a bunch of extern declarations for the entire Battlecode API - an example is at the end of this page. My API bindings were 300 lines of code in 2022, and automatically generating this from the Javadoc isn't too hard; I may write a dedicated script to do this and include it with ByteC at some point.
Also, most functions will probably require throws GameActionException. The throws clause is actually entirely ignored by ByteC and just passed on to the Java code, but the Java compiler will complain if you leave these out (that does mean you don't need them for inline functions, though). There's also a command-line flag -T to add a throws clause to every function - for example, -TGameActionException - but it's probably best to annotate individual functions.
You'll also need the RobotPlayer class, but this is actually easier than in Java, since every ByteC file ("module") turns into a Java class with static members. Just make sure you have a file called RobotPlayer.bt, with something like this:
#![allow(unused)] fn main() { extern { import battlecode.common.*; } use Common::*; let rc: RobotController; // This turns into a static function in Java. // Don't forget the `pub`, it's necessary for Java code to access this function! fn pub run(rc2: RobotController) { rc = rc2; loop { extern "try {"; match rc.getType() { ARCHON => Archon::turn(), MINER => Miner::turn(), SOLDIER => Soldier::turn(), BUILDER => Builder::turn(), WATCHTOWER => Watchtower::turn(), SAGE => Sage::turn(), LABORATORY => Laboratory::turn(), } extern "} catch (Exception e) { e.printStackTrace(); }"; clockYield(); } } }
Some of the API binding code that corresponds to this example would look like this:
#![allow(unused)] fn main() { extern { import battlecode.common.*; } extern fn clockYield() = "Clock.yield"; extern fn bytecodeNum(): i32 = "Clock.getBytecodeNum"; extern fn bytecodeLeft(): i32 = "Clock.getBytecodesLeft"; extern enum RobotType { ARCHON, BUILDER, LABORATORY, MINER, SAGE, SOLDIER, WATCHTOWER; let actionCooldown: i32; let actionRadiusSquared: i32; let buildCostGold: i32; let buildCostLead: i32; let bytecodeLimit: i32; let damage: i32; let health: i32; let movementCooldown: i32; let visionRadiusSquared: i32; fn canAttack(): bool; fn canMine(): bool; fn canBuild(t: RobotType): bool; fn canRepair(t: RobotType): bool; fn canMutate(t: RobotType): bool; fn isBuilding(): bool; // etc. } extern class MapLocation { constructor(x: i32, y: i32); let x: i32; let y: i32; fn add(dir: Direction): MapLocation; fn subtract(dir: Direction): MapLocation; fn directionTo(loc: MapLocation): Direction; fn distanceSquaredTo(loc: MapLocation): i32; fn isWithinDistanceSquared(loc: MapLocation, distanceSquared: i32): bool; fn translate(dx: i32, dy: i32): MapLocation; fn isAdjacentTo(loc: MapLocation): bool; } extern class RobotController { // Returns the location adjacent to current location in the given direction. fn adjacentLocation(dir: Direction): MapLocation; // Attack a given location. fn attack(loc: MapLocation); // Builds a robot of the given type in the given direction. fn buildRobot(type: RobotType, dir: Direction); // Note that ByteC doesn't support overloading, so these functions need to have different names but map to the same Java function. // Returns all robots within vision radius. fn senseNearbyRobots(): [RobotInfo]; // Returns all robots that can be sensed within a certain distance of this robot. fn senseNearbyRobotsR(radiusSquared: i32): [RobotInfo] = "senseNearbyRobots"; // Returns all robots of a given team that can be sensed within a certain distance of this robot. fn senseNearbyRobotsT(radiusSquared: i32, team: Team): [RobotInfo] = "senseNearbyRobots"; // Returns all robots of a given team that can be sensed within a certain radius of a specified location. fn senseNearbyRobotsAt(center: MapLocation, radiusSquared: i32, team: Team): [RobotInfo] = "senseNearbyRobots"; // etc. } }