BATTLE CODE ARENAFIELD MANUAL / BCAS

BUILD → CODE → BATTLE → UNDERSTAND

Write the decisions.
Learn BCAS.

A practical route into Battle Code Arena Script, followed by the complete language reference, worked programs and help when something goes wrong.

BCAS 1.0 / bcas-1.0ENGINE 0.1.0RULESET v0.4REFERENCE / 10 OCTOBER 2026

Current scope: Bronze is the only class in ruleset v0.4. Higher-class sections describe the language's structure for future progression. Equipment statistics and class budgets are ruleset values; check the Garage and app reference for your build.

Your vehicle has the hardware. Your program gives it the decisions. This walkthrough takes you from a moving vehicle to a program that remembers, searches and fires deliberately.

BCAS looks familiar if you have used Python, but it is its own small language. Use this guide's commands and rules rather than assuming that Python features will work.

Battle Code Arena Script (BCAS) is the language in which a vehicle's program is written. This manual defines it: what a program may contain, what each construct means, the libraries a program can call, and the limits it runs within. It describes BCAS as implemented in engine 0.1.0 with ruleset v0.4.

From 1.0, a program that is valid stays valid, and means the same thing. Section 15 says what that covers; the BCAS compatibility policy has the rules the language is held to.

Equipment statistics (ranges, costs, reload times) and the limits a program runs within (operations, memory) belong to the ruleset, not to the language. They are shown in the Garage and in the app's command reference.

1. Make your first move

Fit a drive in the Garage. In Program, use this complete Bronze section:

COMPLETE BRONZE SECTIONBCAS 1.0
from vehicle import drive

def update(state, memory):
    drive.set(throttle=0.5, turn=0.1)

The import makes the fitted drive's library available. update is the function the game runs ten times per simulated second. The indented line asks for half forward effort and a gentle clockwise turn. Throttle is effort, not a target speed.

Run a battle and watch the replay. Your vehicle will move and turn until a collision or another game rule affects it. Nobody steers during the battle.

Try it: change turn=0.1 to turn=-0.1, then run the same seed again. The negative value asks for an anticlockwise turn. Change one value at a time so you can see what it does.

Keep the shape: imports first, then exactly def update(state, memory):. Use four spaces for each indentation level. No tabs, no loops and no extra functions.

2. Make a decision with state

state describes your own vehicle at the start of this update. Use an if to choose a command:

COMPLETE BRONZE SECTIONBCAS 1.0
from vehicle import drive

def update(state, memory):
    if state.blocked:
        drive.set(throttle=0.4, turn=1)
    else:
        drive.set(throttle=0.5, turn=0)

state.blocked tells you whether the vehicle touched a wall, obstacle or the other vehicle since the previous update. This program turns clockwise after contact; otherwise it asks to go straight. It is a simple experiment, not a complete wall-avoidance strategy.

Try it: add debug to the import and put debug.log("Turning after contact") inside the first branch. Look for the message on the Report timeline. It tells you which branch ran; it does not prove the vehicle escaped the obstacle.

3. Remember between updates

Local variables disappear at the end of each update. A field in memory survives until the battle ends. Unwritten fields read as None, so initialise them before doing arithmetic.

COMPLETE BRONZE SECTIONBCAS 1.0
from vehicle import drive, radar

def update(state, memory):
    if memory.scan_angle == None:
        memory.scan_angle = 0

    radar.scan(memory.scan_angle)
    memory.scan_angle = wrap(memory.scan_angle + 15)
    drive.set(throttle=0.5, turn=0.1)

Fit both a drive and a radar. The first update asks for a scan at 0 degrees, then stores 15 for next time. The second asks for 15 and stores 30. wrap keeps the angle in the range from 0 up to, but not including, 360.

There is no loop here. The game calling update again is what advances the search. A call asks for a scan; it does not guarantee that the radar can carry it out. Cooldown and power can cause a request to be rejected.

Try it: pause the replay and open MEMORY. Step forward and watch scan_angle change. Start another battle: the memory starts empty again.

4. Read yesterday's scan

A scan is a request, not an immediate result. Both programs run first; the game then resolves their scans. Read the result from the next update onwards:

COMPLETE BRONZE SECTIONBCAS 1.0
from vehicle import drive, radar, debug

def update(state, memory):
    contact = radar.contact()

    if contact and state.time - contact.seen_at <= 0.2:
        debug.log(contact.range)
        radar.scan(contact.bearing)
    else:
        if memory.scan_angle == None:
            memory.scan_angle = 0
        radar.scan(memory.scan_angle)
        memory.scan_angle = wrap(memory.scan_angle + 15)

    drive.set(throttle=0.5, turn=0.1)

radar.contact() returns a record or None. The contact and ... condition checks that a record exists before reading its fields. It also checks its age: a rejected scan leaves the previous result in place.

A scan requested at 0.0 seconds is first readable at 0.1 seconds. If it saw a target, seen_at is 0.0. A completed scan that finds nothing clears the old result.

Try it: inspect radar overlays and the event timeline. Compare the time of a scan with the time when your program can use its result. Your program cannot read the opponent's true position from state.

5. Aim, wait, then fire

Fit a drive, radar and missile launcher. Add this complete program when you are ready to connect searching with shooting:

COMPLETE BRONZE SECTIONBCAS 1.0
from vehicle import drive, radar, missiles, debug

def update(state, memory):
    if memory.scan_angle == None:
        memory.scan_angle = 0

    contact = radar.contact()

    if contact and state.time - contact.seen_at <= 0.2:
        radar.scan(contact.bearing)
        missiles.aim(contact.bearing)
        on_target = abs(angle_diff(contact.bearing, missiles.turret())) <= 3

        if on_target and missiles.ready() and state.energy > 20:
            missiles.fire()
            debug.log("Shot requested")
    else:
        radar.scan(memory.scan_angle)
        memory.scan_angle = wrap(memory.scan_angle + 15)

    drive.set(throttle=0.5, turn=0.1)

missiles.aim sets a bearing to turn towards. It does not move the turret instantly. missiles.turret() reports its actual bearing at the start of the update. angle_diff finds the shortest signed difference; abs lets us ask whether its size is at most 3 degrees.

missiles.ready() checks the launcher's condition, but it says nothing about aim and reserves no energy. The additional energy check leaves a margin; it is not a guarantee that the shot will be accepted, because scans are resolved first. Read launches and rejections on the timeline.

This program aims at the last observed position. It does not predict movement, lead a shot or avoid walls. The full Search and fire example adds more deliberate movement.

Try it: compare two battles with the same seed, changing only the aim tolerance from 3 to 6 degrees. Look at shots, misses and hits rather than assuming that firing more often is better.

6. Make the replay your debugger

  1. Run your program against a training opponent on a fixed seed.
  2. Pause at a surprising moment. Turn on radar overlays and inspect MEMORY.
  3. Read the Report timeline. Was the command rejected, did the update fault, or did an accepted action simply miss?
  4. Change one decision and repeat with the same seed. Then try more seeds to see whether your improvement holds up.

An invalid value is different from a valid request that cannot be carried out. drive.set(0.5, 90) is an argument error: turn must be from -1 to 1. missiles.fire() while reloading is a rejected request: the rest of that update still stands.

If an update faults, its new commands, memory changes and debug messages are all discarded. Previously committed settings continue, and the game calls the program again on the next update. A bare return is different: it ends the current section normally, keeping the work already done.

7. Keep these rules beside your editor

RememberWhy it matters
Four spaces per indentation levelBlocks are part of the syntax
Locals last one update; memory lasts one battlePut long-lived decisions in memory
Scan now; read the result next updateradar.scan() returns None
Check a contact exists and is freshA previous result can persist after a rejected scan
Aim and fire are separateThe turret needs time to turn
Bearings are degrees; turn is -1 to 1An absolute bearing is not a steering input
Equipment must be fitted before importing its libraryCode and hardware belong together
Bronze is the only class in ruleset v0.4Higher sections describe how the language can grow
BCAS is not PythonNo loops, lists, user functions or chained comparisons

You now have the pieces to explore the complete programs and look up an exact rule in the reference. Keep a copy of a working vehicle before experimenting, and let each replay give you your next question.

1. Overview

A BCAS program is a list of library imports followed by one function, update. The engine calls update ten times in every second of a battle. Each call receives the vehicle's own state and its private memory, and may issue commands through the libraries of the equipment fitted. Nobody steers a vehicle during a battle: the program is the driver.

COMPLETE BRONZE SECTIONBCAS 1.0
from vehicle import drive, radar, missiles

def update(state, memory):
    radar.scan(90)
    drive.set(throttle=0.5, turn=0.1)

A vehicle's program has one such section for each class the vehicle has reached. Every vehicle starts in Bronze, with one. A vehicle that is promoted gains a section for its new class, and runs both (section 7).

BCAS is deliberately small. It has numbers, strings, booleans and None; local variables; persistent memory fields; arithmetic, comparison and boolean operators; if, elif and else; and calls to built-in and library functions. It has no loops and no user-defined functions, so every call of update finishes in a bounded number of steps.

2. Lexical structure

2.1 Source text

A section of a program (section 7) is UTF-8 text of at most 64 KiB. Lines end with LF or CRLF. Outside strings and comments, only printable ASCII characters and spaces may appear.

2.2 Lines and indentation

Each statement occupies one line. There is no statement separator and no line continuation.

Blocks are marked by indentation. One level of indentation is exactly four spaces, and a block is exactly one level deeper than the line that opens it. Tab characters are not allowed anywhere outside a string. Blank lines and lines holding only a comment are ignored, whatever their indentation.

2.3 Comments

A comment starts with # and runs to the end of the line.

2.4 Identifiers

An identifier is a sequence of ASCII letters, digits and underscores that does not begin with a digit. Identifiers are case-sensitive. An identifier may not begin with two underscores.

2.5 Keywords and reserved names

The keywords are:

SYNTAX / SNIPPETBCAS 1.0
from  import  def  if  elif  else  return  pass  and  or  not  True  False  None

The following words are reserved and may not be used at all: while, for, break, continue, class, lambda, try, except, finally, raise, with, as, global, nonlocal, del, assert, yield, async, await, in, is.

The names state, memory, vehicle and update, the names of the built-in functions (section 9) and the names of imported libraries cannot be assigned to.

A library's name is reserved only in a program that imports it. A program that does not import shield may have a variable called shield. This is what lets libraries be added to BCAS without breaking a program that happened to use the name.

2.6 Literals

LiteralFormExamples
NumberDecimal digits, optionally followed by . and more digits12, 0.75, 360
StringText between matching single or double quotes, on one line"Searching", 'hit'
BooleanTrue or False
NoneNone

A number literal has no sign: a negative number is written with the unary minus operator, as in -3. A number must begin and end with a digit (0.5, not .5; 5.0, not 5.). Exponent and hexadecimal notations are not part of BCAS.

Within a string, a backslash introduces an escape: \\ (backslash), \" or \' (the quote that delimits the string), \n (newline) and \t (tab). No other escapes exist. A string literal holds at most 256 characters.

2.7 Operators and delimiters

SYNTAX / SNIPPETBCAS 1.0
+  -  *  /  %  ==  !=  <  <=  >  >=  =  (  )  ,  .  :

3. Values and types

BCAS has five types of value.

TypeValuesNotes
NumberFinite floating-point numbersOne numeric type; there is no separate integer
BooleanTrue, FalseNot a number: cannot be used in arithmetic
NoneNoneThe absence of a value
StringUp to 256 charactersImmutable
Recordstate, or a radar contactSupplied by the engine; read-only

A record is a fixed set of named fields. A program cannot create a record, change one, or store one in memory.

3.1 Truth

Where a condition is needed (if, elif, and, or, not), a value counts as false if it is False, None, the number zero or the empty string. Every other value, including every record, counts as true.

4. Variables and memory

4.1 Local variables

A local variable is created by assigning to a name:

SYNTAX / SNIPPETBCAS 1.0
target_angle = 90

Local variables exist for one call of update only. They are all discarded when the call ends, and none exist when the next call begins. A local can hold a value of any type. A section may use at most 128 different local names.

Each section's update has its own local variables: one section cannot read another's. To hand a value from one section to the next, write it to memory.

Reading a local that has not been assigned earlier in the same call is an error. If the name is never assigned anywhere in the section, the error is reported before the battle; otherwise it is reported when the read happens.

4.2 Memory

memory is the program's private storage. A field is written and read by name:

SYNTAX / SNIPPETBCAS 1.0
memory.last_bearing = contact.bearing
angle = memory.last_bearing
  • Memory fields persist from one call of update to the next, for the length of the battle.
  • Memory is empty at the start of every battle, including each battle of a tournament.
  • A field that has never been written reads as None.
  • A field can hold a number, a string, a boolean or None. It cannot hold a record.
  • Field names are written literally. There is no way to compute a field name.
  • Memory is limited in fields and in total size by the class the vehicle is in (section 13): 64 fields and 16 KiB in Bronze.

The opponent cannot read or change a program's memory.

A replay records every change a program makes to its memory. In the Arena, MEMORY shows what each field held at the moment on view, for either vehicle; with debug.log (section 10.7) it is how a program's reasoning is followed after the battle. Values are shown as debug.log would print them.

4.3 State

state is a record describing the program's own vehicle at the moment update is called (section 10.2). It is read-only.

5. Expressions

5.1 Precedence

Operators are listed from the most tightly binding to the least. Operators on one line have equal precedence and group from left to right.

LevelOperatorsMeaning
1( ), x.field, f(args)Grouping, field access, call
2+x, -xUnary plus and minus
3*, /, %Multiplication, division, modulo
4+, -Addition, subtraction; + also joins strings
5==, !=, <, <=, >, >=Comparison
6notBoolean negation
7andBoolean and
8orBoolean or

An expression may be nested at most 32 levels deep.

5.2 Arithmetic

+, -, *, /, % and the unary operators take numbers. Applying them to any other type is a type error, with one exception: + applied to two strings joins them.

  • / is true division: 7 / 2 is 3.5.
  • % is floor-based modulo, and its result takes the sign of the divisor: (-10) % 360 is 350.
  • Dividing, or taking a modulo, by zero is a runtime error.
  • A result that is not a finite number is a runtime error.
  • Numbers are never converted to strings automatically.

5.3 Comparison

== and != accept any two values. Values of different types are unequal. A record may be compared only with None; comparing two records is a type error.

<, <=, > and >= require two numbers or two strings. Strings are ordered by character code.

Comparisons do not chain. 0 < x < 10 is not valid; write x > 0 and x < 10.

5.4 Boolean operators

not x is True if x counts as false, and False otherwise.

x and y and x or y evaluate x first and evaluate y only if it is needed. Their result is always a boolean, True or False, never one of the operands. Because the right-hand side is skipped when it is not needed, a value can be tested before it is used:

SYNTAX / SNIPPETBCAS 1.0
if contact and contact.range < 200:

5.5 Field access

A field is read with a dot. Three forms exist:

FormReads
state.nameA field of the vehicle's state
memory.nameA memory field, or None if it was never written
local.nameA field of the record held in a local variable

Reading a field of a local that does not hold a record is a type error. Reading a field a record does not have is a name error. Field access cannot be chained or applied to the result of a call: store the result in a local first.

SYNTAX / SNIPPETBCAS 1.0
contact = radar.contact()
if contact:
    bearing = contact.bearing

5.6 Calls

A built-in function is called by name with positional arguments:

SYNTAX / SNIPPETBCAS 1.0
turn = clamp(error / 45, -1, 1)

A library function is called through its library, with positional or named arguments in the documented parameter order. Positional arguments come before named ones.

SYNTAX / SNIPPETBCAS 1.0
drive.set(0.8, -0.25)
drive.set(throttle=0.8, turn=-0.25)

A missing argument, an extra argument, an unknown parameter name or a parameter given twice is an error, reported before the battle. Functions are not values: a function can only be called.

A parameter may be optional. Optional parameters come last, are shown as name=... in the command reference, and may be left out: the function then uses its own default, and the missing argument costs no operations. No function of BCAS 1.0 has one; they are how a function may gain a parameter later without changing what an existing call means.

6. Statements

6.1 Assignment

SYNTAX / SNIPPETBCAS 1.0
name = expression
memory.field = expression

The first form assigns a local variable; the second writes a memory field. Nothing else can be assigned to. There are no augmented assignments: write x = x + 1.

6.2 Call statement

A call may stand by itself as a statement. Any other expression standing alone is an error.

SYNTAX / SNIPPETBCAS 1.0
radar.scan(90)

6.3 if, elif, else

SYNTAX / SNIPPETBCAS 1.0
if state.energy > 60:
    shield.set_level(0.5)
elif state.energy > 30:
    shield.set_level(0.2)
else:
    shield.set_level(0)

The conditions are evaluated in order, and the block of the first one that counts as true is executed. If none does, the else block is executed, if there is one. Any number of elif clauses may follow an if; else is optional and comes last. Blocks may be nested at most 32 levels deep.

6.4 return

return ends the current call of update: that is, the rest of its own section. It takes no value. Everything done before it stands, and the sections after it still run.

6.5 pass

pass does nothing. Every block must contain at least one statement; pass is the statement for a block that is meant to be empty.

7. Program structure

7.1 A section

A section consists of zero or more import lines followed by exactly one function definition:

COMPLETE BRONZE SECTIONBCAS 1.0
from vehicle import drive, radar, missiles, shield, debug

def update(state, memory):
    ...
  • The only module is vehicle. An import line names one or more of its libraries.
  • A library must be imported before any of its functions is called.
  • The function must be named update and take exactly the parameters state and memory.
  • Nothing else may appear at the top level, and no other function may be defined.

7.2 A vehicle's sections

A vehicle's program has a section for each class the vehicle has reached. The classes are, from the lowest: bronze, silver, gold, platinum, elite. Ruleset v0.4 has only Bronze, so every program today is one section.

Each section is a program in its own right, written as in 7.1 and kept as its own piece of text. Which class a section belongs to is not written in it: the vehicle knows, and the editor shows each section under a heading with the class's name. A heading can be folded to put its section out of the way.

Every vehicle starts in Bronze. A vehicle moves up a class only by promotion, earned in competition; it is never chosen. Promoted, it has a new, empty section for its owner to fill.

At every update the vehicle runs each section up to its own class, in order from Bronze's (section 8.1):

  • A later section's command replaces an earlier section's command of the same kind, by the rule of section 10.1. A higher section refines what the lower ones decided.
  • Each section has its own local variables. They share memory.
  • A library imported by one section may be used by those after it, without importing it again.
  • A section above Bronze's may be empty. Bronze's may not.

An error in a section is reported with the section it is in, and the line within that section.

7.3 Relegation and copies

A section may lean on the sections below it, and never on those above. So when the sections above some class are left out, what remains is a complete program for that class.

When a vehicle is relegated, the sections above its new class are kept exactly as they are, and are not run. The editor shows them as set aside. They are not checked, so a mistake in one does not stop the vehicle from fighting, and they are run again as they stand if the vehicle is promoted back.

A copy of a vehicle starts in Bronze, whatever class the original is in, and so does a vehicle that is imported. Its sections are kept in the same way.

The vehicle's equipment is not changed. If it is over the lower class's build budget or mass allowance, the vehicle cannot fight until its owner refits it in the Garage.

8. Execution model

8.1 Updates

A battle advances in physics ticks, 30 to the second. Every third tick, before that tick's physics, the engine performs a control update:

  1. It records what each vehicle observes. Both observe the same instant.
  2. It runs each program: the update of every section up to the vehicle's class, in order from bronze. Every section is given the same state, and each sees what the ones before it wrote to memory.
  3. It commits what each program did, and resolves the one-off requests of both vehicles in a fixed order: every radar scan, then every missile shot. Then the physics ticks follow, in which vehicles move and turrets turn.

Neither program can see anything the other did in the same update. The order in which the two programs run has no effect on the result.

update is therefore called at simulated times 0.0, 0.1, 0.2 and so on. Playback speed, pausing and scrubbing in a replay do not affect a battle: they happen afterwards.

8.2 Transactions

An update either takes effect as a whole or not at all, however many sections it ran. If any of them ends with an error:

  • the memory changes it made are discarded;
  • the commands and debug messages it issued are discarded;
  • a fault is recorded on the battle's timeline, with its line number;
  • settings committed by earlier updates (drive and shield) continue;
  • the sections after the one that failed are not run;
  • the program is run again, as usual, at the next update.

Repeated faults do not forfeit the battle.

8.3 The operation budget

Each call of update may perform a limited number of operations: 2,000 in Bronze. The number is a rule of the class the vehicle is in (section 13), and it is one budget for all the sections the vehicle runs. Exceeding it is a limit error, handled as in 8.2.

How operations are counted is part of the language and does not change:

CountedOperations
Each statement executed1
Each expression evaluated: a literal, a variable or field read, an operator, a call1
Each call of a built-in or library function, in addition10

A branch that is not taken, and the right-hand side of an and or or that is not needed, cost nothing. For example, memory.a = 1 + 2 costs 4 (the statement, the +, and two literals), and radar.scan(90) costs 13.

8.4 Determinism

A program has no access to the clock, to random numbers, or to anything outside the engine. The same seed, vehicles and programs always produce the same battle.

8.5 Energy, and running out of it

Energy recovers all the time, at the rate of the chassis, and everything a vehicle does spends it: driving, scanning, firing, shielding. What each costs belongs to the ruleset and is shown in the Garage.

A vehicle that runs out loses power. If a physics tick begins with less than 1 energy:

  • the drive, the shield, the radar and the launcher are offline: the vehicle coasts to a stop, scans and shots are rejected, and missiles.ready() is False;
  • state.power_out is True, and the timeline records the failure;
  • the program goes on being run, and what it has set (throttle, turn, shield level, the turret's aim) is kept;
  • energy goes on recovering, with nothing spending it.

Power comes back by itself once energy has recovered to 20, which takes about two seconds, and everything carries on as it was set. The two figures are the chassis's, in the ruleset.

A vehicle that stays above the floor keeps its power, however little it has. So a program manages its energy as it manages its heat: by leaving a margin. The vehicle a player starts with never runs low as it comes; fitting a faster drive or a wider radar is what makes energy something to manage. Of the training opponents, SENTRY and EVADER fire only with more than 20 in hand, and EVADER eases off the throttle when it runs low. HUNTER spends more than it has, and loses power in a long fight.

9. Built-in functions

Built-in functions are always available and need no import. They take numbers, positionally.

The set is closed: these seven are all there will be. A new built-in would take a name that some program already uses for a variable, so new functions are added to libraries instead.

FunctionResult
abs(value)The absolute value
min(a, b)The smaller of two numbers
max(a, b)The larger of two numbers
clamp(value, low, high)value kept within low to high inclusive. It is an error for low to exceed high
wrap(angle)The angle normalised to 0 up to, but not including, 360
angle_diff(target, current)The shortest signed turn from current to target, from -180 up to, but not including, 180. Positive is clockwise. Exactly opposite headings give -180
bearing_to(x1, y1, x2, y2)The absolute bearing from the first point to the second. Identical points give 0

angle_diff(10, 350) is 20.

10. The vehicle libraries

10.1 Libraries and equipment

Each library belongs to a mounting on the vehicle (the radar, the launcher, the shield generator, the drive) and exists only while something is fitted there. Importing a library whose equipment is not fitted is an error reported before the battle. Alternative equipment for a mounting provides the same library; only its performance differs.

What a piece of equipment knows about itself is asked of its library: where the turret points, how much ammunition is left, what the shield is set to. It is not in state. A vehicle with a second launcher would have a second library with the same functions, and each would answer for its own.

LibraryEquipment requiredFunctions
driveA drive unitset
radarA radarscan, contact
missilesA missile launcheraim, ready, fire, turret, ammo
shieldA shield generatorset_level, level
debugNone: always availablelog

Commands are of two kinds. A setting persists until it is changed. A request is acted on once, at the update in which it is made. If the same command is issued more than once in one update, only the last counts.

10.2 state

state is passed to update and needs no import. All values are those at the start of the update: issuing a command does not change them.

FieldType and unitMeaning
state.timeNumber, secondsSimulated time at this update
state.tickNumberThe physics tick, starting at 0
state.dtNumber, secondsTime between updates: 0.1
state.x, state.yNumber, metresThe centre of the vehicle
state.headingNumber, degreesThe vehicle's absolute heading
state.speedNumber, m/sForward speed. There is no reverse
state.healthNumber, 0 to 100Remaining health, as a percentage
state.energyNumberEnergy available
state.energy_maxNumberEnergy capacity
state.heatNumber, 0 to 100Current heat
state.overheatedBooleanTrue during thermal lockout, when weapons and shield are offline
state.power_outBooleanTrue during a power failure, when drive, shield, radar and launcher are offline (section 8.5)
state.blockedBooleanTrue if the vehicle touched a wall, an obstacle or the other vehicle since the last update
state.arena_width, state.arena_heightNumber, metresThe size of the arena

state describes the program's own vehicle as a whole, and the arena. Nothing in it describes the opponent, and nothing in it belongs to one piece of equipment: for the turret, the ammunition and the shield's level see missiles.turret(), missiles.ammo() (section 10.5) and shield.level() (section 10.6). A reference to a field that state does not have is an error reported before the battle.

10.3 drive

**drive.set(throttle, turn)** — setting. Returns None.

ParameterRangeMeaning
throttle0 to 1The fraction of the drive's forward effort
turn-1 to 1The fraction of the drive's turn rate. Positive is clockwise

Throttle requests effort, not speed: the vehicle accelerates and brakes at the rates of the drive fitted. Both settings are zero until first set. The drive uses energy in proportion to the effort asked of it. During a power failure (section 8.5) it does nothing; the settings are kept, and it resumes by itself.

10.4 radar

**radar.scan(bearing)** — request. Returns None.

Asks for one scan along an absolute bearing, in degrees. The scan detects a target if its centre lies within the radar's range and within its cone about the bearing (edges included), and no obstacle lies on the straight line between the two centres. In a battle of two vehicles the only target is the opponent.

scan does not return the result. The scan is resolved after both programs have run, and its result is read with radar.contact() from the next update onwards.

A scan is rejected, and has no effect, during a power failure (section 8.5), if the radar is still cooling down from its previous scan, or if there is not enough energy. (With the radars of ruleset v0.4 the last cannot arise: a scan costs less than the energy below which the power fails.)

**radar.contact()** — returns a contact record, or None.

Gives the result of the most recently completed scan: a contact for the nearest target that scan detected, None if it detected nothing, and None if no scan has completed yet. It issues no command.

"The nearest target detected" is the definition, though with one opponent there is only ever one. It is stated now so that it still holds when a scan can find more than one thing.

FieldType and unitMeaning
contact.bearingNumber, degreesThe absolute bearing from the vehicle to the target
contact.rangeNumber, metresThe distance between the two centres
contact.x, contact.yNumber, metresThe target's position, worked out from the same bearing and range
contact.seen_atNumber, secondsThe simulated time at which the scan observed the target

Bearing and range are rounded to the precision of the radar fitted, so a contact is an estimate. A contact gives no identity, health, heading or speed.

Timing of a scan:

TimeWhat happens
0.0 scontact() returns None. The program calls scan(90)
After both programs have runThe scan is resolved against the world as it was at 0.0 s
0.1 scontact() returns the result. If the target was seen, seen_at is 0.0
0.2 sIf no further scan has completed, contact() returns the same result, now older

A completed scan that finds nothing replaces the previous contact with None. A rejected scan leaves the previous result in place, so a contact may be stale: compare contact.seen_at with state.time before acting on it. Radar neither sweeps nor tracks by itself.

10.5 missiles

The launcher is carried on a turret, which turns independently of the hull. A missile is launched along the turret. A program therefore aims first, waits for the turret to come round, and then fires. The radar is separate from the turret and can look in another direction.

**missiles.aim(bearing)** — setting. Returns None.

Tells the turret to turn to an absolute bearing, in degrees. The turret does not jump there: every tick it turns towards the bearing by the shorter way round, at the traverse rate of the launcher fitted, and stops when it reaches it. The hull's own turning does not move the turret.

At the start of a battle the turret points along the hull, and stays there until aim is called. The bearing aimed at persists until aim is called again.

**missiles.turret()** — returns a number, in degrees.

The absolute bearing along which the turret points at the start of the update. It issues no command. To tell whether the turret is on a target:

SYNTAX / SNIPPETBCAS 1.0
missiles.aim(contact.bearing)
on_target = abs(angle_diff(contact.bearing, missiles.turret())) <= 3

**missiles.ammo()** — returns a number.

The missiles left in the launcher at the start of the update.

**missiles.ready()** — returns True or False.

True if, as of the start of this update, the launcher has ammunition, is not reloading, is not in thermal lockout, has power, and there is energy for one shot. It does not say what the shot would leave: a shot that takes the last of the energy is followed by a power failure (section 8.5). It says nothing about where the turret points. It is advisory. It reserves nothing, and a scan resolved earlier in the same update can use the energy a shot needed.

**missiles.fire()** — request. Takes no arguments. Returns None.

Asks for one missile to be launched along the turret as it points at this update: that is, along missiles.turret(). Calling aim in the same update does not change where that missile goes.

A shot is rejected for the first of these reasons that applies: no ammunition, power failure, overheated, weapon reloading, insufficient energy. A rejected shot uses no ammunition and no energy, and the timeline records the reason. An accepted shot uses one missile and its energy, adds heat, and starts the reload.

Missiles are unguided. A missile flies along its bearing at the launcher's missile speed until it meets the opponent, an obstacle or a wall, or its fuel runs out. It cannot hit the vehicle that fired it. Between the radar's observation, the turret's turn and the missile's flight, the target will usually have moved.

10.6 shield

**shield.set_level(level)** — setting. Returns None.

ParameterRangeMeaning
level0 to 10 is off; 1 is full operation

While it is powered, the shield absorbs a share of each hit in proportion to its level, and uses energy and adds heat in proportion to its level. It is offline during a power failure (section 8.5) and during thermal lockout; the setting is kept, and the shield resumes by itself. The level is zero until first set.

**shield.level()** — returns a number, 0 to 1.

The shield setting last committed, as of the start of the update. It is the setting, not whether the shield has power: a shield set to 1 with no energy still reports 1.

10.7 debug

**debug.log(value)** — returns None.

Attaches a message to the battle's timeline at the current simulated time. The value may be a number, a string, a boolean or None; logging a record is a type error. Whole numbers are shown without decimals and other numbers to at most four decimal places.

The first four messages of an update are kept. Any others are dropped, and the timeline records how many.

A message records what the program wrote, not what then happened: debug.log("Shot requested") is not evidence that a missile was launched. The timeline records launches and rejections itself.

10.8 Argument checking

An argument of the wrong type, or outside its documented range, is an error and ends the update (section 8.2). Values are not silently clamped: use clamp where a value may stray. Bearings are the exception: any number is accepted as a bearing, by radar.scan and missiles.aim, and normalised to 0 up to 360.

A valid command that cannot be carried out, such as firing while reloading, is not an error. It is rejected, the timeline records why, and the rest of the update stands.

11. Units and coordinates

N / 0°E90°S / 180°W270°

Bearings are absolute, measured clockwise from north. Steering is different: turn is a fraction from -1 to 1. Positive means clockwise. Arena coordinates start at the top-left; x grows right and y grows down.

QuantityUnit
DistanceMetres
TimeSimulated seconds from the start of the battle
AngleDegrees
SpeedMetres per second

The origin of the arena is its top-left corner. x increases to the right and y increases downwards.

Bearings and headings are absolute and measured clockwise: 0 is north (up), 90 east (right), 180 south (down) and 270 west (left). The vehicle's heading, the turret's bearing, contact bearings, scan bearings and aiming bearings all use this convention. A positive turn is clockwise.

12. Diagnostics

Every error has a category, a message in plain language and, where there is one, a suggestion. Errors in the program text carry a line and column.

CategoryMeaning
syntaxThe text is not a valid program
importA problem with a library import
nameAn unknown or misused name
typeA value of the wrong type
argumentA wrong argument to a function
runtimeAn invalid operation, such as division by zero
limitA limit of section 13 was exceeded

12.1 Errors reported before a battle

A vehicle with any of these errors, in any section it runs, cannot start a battle. A section above the vehicle's class is not checked.

Checking does not stop at the first error. A statement that cannot be read is passed over, together with the block beneath it and any elif or else that belonged to it, and checking carries on from the next statement. The errors are reported in line order, at most one for a line and twenty for a section. Each says which section it is in. A variable that is read but never assigned is reported only when nothing else is wrong, because the statement passed over may have been its assignment.

  • Any error of syntax, including a reserved word, a tab, wrong indentation and an unclosed string
  • An import of anything but a library of vehicle; an unknown library
  • An import of a library whose equipment is not fitted
  • A call of a library that has not been imported; an unknown library function
  • A call of an unknown function
  • The wrong number of arguments; an unknown or repeated parameter name
  • A reference to a field state does not have (for turret, ammo and shield_level, which BCAS 0.2 had there, the error says which library function to call)
  • An assignment to state, to a record's field, or to a reserved name
  • A local variable that is read but never assigned
  • A section over 64 KiB, or with more than 128 locals; nesting over 32 levels; a string literal over 256 characters

12.2 Errors during a battle

These end the update in which they occur (section 8.2) and appear on the timeline as faults.

  • Reading a local before it is assigned in the current update
  • An operator or function applied to a value of the wrong type
  • Reading a field of a value that is not a record, or a field a contact does not have
  • Division or modulo by zero; a result that is not finite
  • An argument outside its range
  • Storing a record in memory
  • Exceeding the operation budget, the memory limits or the string length

13. Limits

Limits of the language, the same for every program:

LimitValue
Size of a section64 KiB of UTF-8
Additional cost of a call10 operations
Local variables in a section128 names
String length256 characters
Debug messages per update, from all sections together4
Nesting of expressions, and of blocks32 levels

Limits of the class a vehicle is entered in, set by the ruleset. Each is for the vehicle's whole program, all its sections together:

LimitBronze
Operations per update2,000
Memory fields64
Memory size16 KiB

Bronze is the first class, and the only one in ruleset v0.4. A higher class may allow more of each, never less, so a program that runs in one class runs in every class above it. The app's command reference shows the limits of the class in use.

14. Grammar

This is the grammar of one section (section 7.1). NEWLINE, INDENT and DEDENT are produced from line ends and indentation as described in section 2.2. Quoted items are literal text.

GRAMMARBCAS 1.0
program     = { import_stmt } update_def EOF
import_stmt = "from" "vehicle" "import" NAME { "," NAME } NEWLINE
update_def  = "def" "update" "(" "state" "," "memory" ")" ":" suite
suite       = NEWLINE INDENT statement { statement } DEDENT
statement   = simple_stmt NEWLINE | if_stmt
simple_stmt = assignment | call | "return" | "pass"
assignment  = ( NAME | "memory" "." NAME ) "=" expression
if_stmt     = "if" expression ":" suite
              { "elif" expression ":" suite } [ "else" ":" suite ]

expression  = or_expr
or_expr     = and_expr { "or" and_expr }
and_expr    = not_expr { "and" not_expr }
not_expr    = "not" not_expr | comparison
comparison  = sum [ ( "==" | "!=" | "<" | "<=" | ">" | ">=" ) sum ]
sum         = term { ( "+" | "-" ) term }
term        = unary { ( "*" | "/" | "%" ) unary }
unary       = ( "+" | "-" ) unary | primary
primary     = NUMBER | STRING | "True" | "False" | "None"
            | "(" expression ")"
            | NAME
            | NAME "." NAME
            | call
call        = NAME "(" [ args ] ")"
            | NAME "." NAME "(" [ args ] ")"
args        = arg { "," arg }
arg         = expression | NAME "=" expression

15. Compatibility

From BCAS 1.0, a program that is valid stays valid, and means the same thing, in every later version. "Means the same" is about the language: given the same ruleset, the same vehicles and the same seed, the battle is the same. A change to the ruleset (a missile made faster, a radar's range shortened) changes how a battle goes without changing what any program means.

To keep that promise the language only grows, and only in these ways:

May be addedWhy it breaks nothing
A libraryIts name is reserved only in a program that imports it (section 2.5)
A function in a libraryOnly a program that calls it is affected
A field of state or of a contactOnly a program that reads it is affected
An optional parameter, after the existing onesA call that leaves it out means what it did (section 5.6)
A statement or operator spelt with a word already reserved (section 2.5)No valid program contains the word
A class with higher limitsA program within the old limits is within the new
A class above the highestNo vehicle has a section for it

And these do not happen:

  • A built-in function is not added, removed or changed (section 9).
  • A function, parameter or field is not removed or renamed, and parameters are not reordered.
  • The meaning of an operator, a value's truth, or how numbers are printed does not change.
  • How operations are counted does not change, and no class's limits are lowered.
  • A word is not newly reserved.

The programs in Appendix A, the training opponents and one program that uses every part of the language are kept with the engine's tests. Every build must still accept them and run them through a battle without a fault.

Appendix A: Example programs

A.1 Search and fire

Requires a drive, a radar and a missile launcher. This is the program a new vehicle starts with. It chooses a course and steers onto it: straight on while it searches, to one side of a contact while it closes in, round the contact once it is within 90 m, back towards the middle of the arena near a wall, and off to one side when something is in the way. It sweeps its radar in 15-degree steps; with a fresh contact it scans the same bearing again, turns its turret to it, and fires once the turret is within 3 degrees. It does not lead a moving target.

COMPLETE BRONZE SECTIONBCAS 1.0
from vehicle import drive, radar, missiles, debug

def update(state, memory):
    if memory.scan_angle == None:
        memory.scan_angle = 0

    # With nothing to chase, carry straight on.
    course = state.heading
    throttle = 0.6

    contact = radar.contact()

    if contact and state.time - contact.seen_at <= 0.2:
        radar.scan(contact.bearing)
        missiles.aim(contact.bearing)

        # Close in to one side of the contact, not straight at it.
        course = contact.bearing + 35
        throttle = 0.9

        # Near enough: go round it instead.
        if contact.range < 90:
            course = contact.bearing + 90

        # Fire only once the turret has come round to the contact.
        on_target = abs(angle_diff(contact.bearing, missiles.turret())) <= 3

        if on_target and missiles.ready():
            missiles.fire()
            debug.log("Shot requested")
    else:
        memory.scan_angle = (memory.scan_angle + 15) % 360
        radar.scan(memory.scan_angle)

    # Near a wall, turn back towards the middle of the arena.
    margin = 40
    near_x = state.x < margin or state.x > state.arena_width - margin
    near_y = state.y < margin or state.y > state.arena_height - margin

    if near_x or near_y:
        course = bearing_to(state.x, state.y, state.arena_width / 2, state.arena_height / 2)

    # Up against something: turn off to one side until it is clear.
    if state.blocked:
        course = state.heading + 90

    # Steer onto the course: the further off it is, the harder the turn.
    turn = clamp(angle_diff(course, state.heading) / 45, -1, 1)
    drive.set(throttle=throttle, turn=turn)

A.2 Energy-aware hunter

Requires a drive, a radar, a missile launcher and a shield generator. The vehicle turns towards a fresh contact and slows when close. It lowers its shield when energy is short or heat is high. Steering uses a relative angle; the radar and the turret use absolute bearings.

COMPLETE BRONZE SECTIONBCAS 1.0
from vehicle import drive, radar, missiles, shield

def update(state, memory):
    if memory.scan_angle == None:
        memory.scan_angle = state.heading

    if state.energy > 60 and state.heat < 70:
        shield.set_level(0.5)
    else:
        shield.set_level(0)

    contact = radar.contact()

    if contact and state.time - contact.seen_at <= 0.2:
        error = angle_diff(contact.bearing, state.heading)
        turn = clamp(error / 45, -1, 1)
        throttle = 0.7

        if contact.range < 80:
            throttle = 0.2

        drive.set(throttle=throttle, turn=turn)
        radar.scan(contact.bearing)
        missiles.aim(contact.bearing)
        memory.scan_angle = contact.bearing

        on_target = abs(angle_diff(contact.bearing, missiles.turret())) <= 3

        if on_target and missiles.ready() and state.heat < 80:
            missiles.fire()
    else:
        memory.scan_angle = wrap(memory.scan_angle + 20)
        radar.scan(memory.scan_angle)
        drive.set(throttle=0.4, turn=0.35)

A.3 Scheduling with memory

Requires a drive and a radar. The vehicle scans every 0.3 seconds, keeping its timetable in memory. Advancing the timetable records that a scan was requested, not that it was accepted.

COMPLETE BRONZE SECTIONBCAS 1.0
from vehicle import drive, radar

def update(state, memory):
    if memory.next_scan == None:
        memory.next_scan = 0
        memory.scan_angle = 0

    drive.set(throttle=0.5, turn=0.2)

    if state.time >= memory.next_scan:
        radar.scan(memory.scan_angle)
        memory.scan_angle = wrap(memory.scan_angle + 30)
        memory.next_scan = state.time + 0.3

Appendix B: Common mistakes

MistakeWhy it failsInstead
contact = radar.scan(90)scan returns NoneCall scan, then read radar.contact() on the next update
memory.angle = memory.angle + 10 on the first updateAn unwritten field is NoneTest for None and give the field a value first
contact.bearing with no test of contactcontact() may return NoneGuard with if contact:
Acting on any contact returnedThe last result persists and may be oldCompare contact.seen_at with state.time
state.enemy_xstate has nothing about the opponentFind the opponent with radar
drive.set(1, 90)turn is a fraction, not an angleUse a value from -1 to 1
missiles.fire(contact.bearing)fire takes no bearing: the missile leaves along the turretmissiles.aim(contact.bearing), then missiles.fire()
Firing in the update in which you aimThe turret takes time to turnCompare missiles.turret() with the bearing aimed at before firing
state.turret, state.ammo, state.shield_levelEquipment answers for itself; these are not in statemissiles.turret(), missiles.ammo(), shield.level()
A variable named after a built-in, such as min = 5Built-in names cannot be assigned toChoose another name. A library's name is free unless you import it
Firing, scanning and shielding until state.energy is nearly nothingAn empty tank is a power failure: everything stops until 20 is backKeep a reserve: fire only if state.energy > 20, and ease off the throttle when it runs low
Treating ready() as a guaranteeIt says nothing of the turret, and a scan may use the energy firstCheck the turret yourself; read the launches and rejections on the timeline
Importing shield with no generator fittedThe library belongs to the equipmentFit the generator, or remove the import and its calls
Keeping a value in a local between updatesLocals last for one updateKeep it in memory
Logging "hit" after fireA request is not a launch, and a launch is not a hitRead the hits on the timeline

Appendix C: What BCAS does not have

Each of these is reported as an error before a battle, with a suggestion.

Not availableUse instead
LoopsMemory, and the next update
Functions and classes of the program's ownOne update in each section, written out in full
Lists, dictionaries, tuples, sets, indexingSeparate memory fields
Augmented assignment (+= and the like)x = x + 1
Exponentiation, floor division, bitwise operatorsMultiplication; /
Chained comparison (0 < x < 10)x > 0 and x < 10
String methods, formatting, multi-line strings+ to join strings; debug.log takes numbers as they are
Conversion between numbers and stringsNone
Other modules, files, network, clock, random numbersThe vehicle libraries and the built-in functions
Exception handlingTest values with if before using them
Returning a value from updateBare return

Appendix D: Revision history

DateChange
6 October 20260.1 written as a proposal, before the engine existed
8 October 20260.1 revised to describe BCAS as built in engine 0.1.0, and rearranged as a reference manual. Added state.blocked, state.arena_width and state.arena_height. Several errors the proposal left to run time (unknown state fields, wrong arguments, a variable never assigned) are reported before the battle
8 October 20260.2: the turret. Added missiles.aim(bearing) and state.turret. missiles.fire() no longer takes a bearing: a missile leaves along the turret. Programs written for 0.1 that pass a bearing to fire must be changed
8 October 2026No change to the language. Every error in a program is now reported, not only the first (section 12.1). A replay records each program's memory (section 4.2). Appendix A.1 is the new starter program, which steers onto a course and keeps off the walls
10 October 20261.0, the first version with a promise of compatibility (section 15). state.turret, state.ammo and state.shield_level are replaced by missiles.turret(), missiles.ammo() and shield.level(): programs written for 0.2 that read them must be changed, and the error says how. The built-in functions are closed. A library's name is reserved only once imported, wherever in the program it is used. Parameters may be optional. The operation budget and the memory limits are rules of the vehicle's class, not of the language; their values are unchanged. radar.contact() is defined as the nearest target detected. A vehicle's program has a section for each class it has reached (section 7), each written as a program always was; a program of 0.2 is a Bronze section as it stands. Relegation and copying leave the higher sections as they are, and do not run them
10 October 2026No change to the language beyond one field. Ruleset v0.4 makes an empty tank a power failure (section 8.5), and state.power_out says when

Need a hand? Tell us what you expected, what the replay showed and which error appeared. Contact support@inuvera.com.

Battle Code Arena / BCAS learning guide

Reference dated 10 October 2026 · Back to top ↑