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.
Short experiments that build your understanding.
02 / LOOK IT UPThe complete referenceExact rules, commands, values and limits.
03 / TRY A STRATEGYExamples & fixesWorking programs and common mistakes.
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:
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:
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.
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:
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:
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
- Run your program against a training opponent on a fixed seed.
- Pause at a surprising moment. Turn on radar overlays and inspect MEMORY.
- Read the Report timeline. Was the command rejected, did the update fault, or did an accepted action simply miss?
- 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
| Remember | Why it matters |
|---|---|
| Four spaces per indentation level | Blocks are part of the syntax |
| Locals last one update; memory lasts one battle | Put long-lived decisions in memory |
| Scan now; read the result next update | radar.scan() returns None |
| Check a contact exists and is fresh | A previous result can persist after a rejected scan |
| Aim and fire are separate | The turret needs time to turn |
| Bearings are degrees; turn is -1 to 1 | An absolute bearing is not a steering input |
| Equipment must be fitted before importing its library | Code and hardware belong together |
| Bronze is the only class in ruleset v0.4 | Higher sections describe how the language can grow |
| BCAS is not Python | No 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.
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:
from import def if elif else return pass and or not True False NoneThe 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
| Literal | Form | Examples |
|---|---|---|
| Number | Decimal digits, optionally followed by . and more digits | 12, 0.75, 360 |
| String | Text between matching single or double quotes, on one line | "Searching", 'hit' |
| Boolean | True or False | |
| None | None |
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
+ - * / % == != < <= > >= = ( ) , . :3. Values and types
BCAS has five types of value.
| Type | Values | Notes |
|---|---|---|
| Number | Finite floating-point numbers | One numeric type; there is no separate integer |
| Boolean | True, False | Not a number: cannot be used in arithmetic |
| None | None | The absence of a value |
| String | Up to 256 characters | Immutable |
| Record | state, or a radar contact | Supplied 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:
target_angle = 90Local 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:
memory.last_bearing = contact.bearing
angle = memory.last_bearing- Memory fields persist from one call of
updateto 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.
| Level | Operators | Meaning |
|---|---|---|
| 1 | ( ), x.field, f(args) | Grouping, field access, call |
| 2 | +x, -x | Unary plus and minus |
| 3 | *, /, % | Multiplication, division, modulo |
| 4 | +, - | Addition, subtraction; + also joins strings |
| 5 | ==, !=, <, <=, >, >= | Comparison |
| 6 | not | Boolean negation |
| 7 | and | Boolean and |
| 8 | or | Boolean 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 / 2is3.5.%is floor-based modulo, and its result takes the sign of the divisor:(-10) % 360is350.- 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:
if contact and contact.range < 200:5.5 Field access
A field is read with a dot. Three forms exist:
| Form | Reads |
|---|---|
state.name | A field of the vehicle's state |
memory.name | A memory field, or None if it was never written |
local.name | A 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.
contact = radar.contact()
if contact:
bearing = contact.bearing5.6 Calls
A built-in function is called by name with positional arguments:
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.
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
name = expression
memory.field = expressionThe 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.
radar.scan(90)6.3 if, elif, else
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:
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
updateand take exactly the parametersstateandmemory. - 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:
- It records what each vehicle observes. Both observe the same instant.
- It runs each program: the
updateof every section up to the vehicle's class, in order frombronze. Every section is given the samestate, and each sees what the ones before it wrote tomemory. - 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:
| Counted | Operations |
|---|---|
| Each statement executed | 1 |
| Each expression evaluated: a literal, a variable or field read, an operator, a call | 1 |
| Each call of a built-in or library function, in addition | 10 |
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()isFalse; state.power_outisTrue, 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.
| Function | Result |
|---|---|
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.
| Library | Equipment required | Functions |
|---|---|---|
drive | A drive unit | set |
radar | A radar | scan, contact |
missiles | A missile launcher | aim, ready, fire, turret, ammo |
shield | A shield generator | set_level, level |
debug | None: always available | log |
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.
| Field | Type and unit | Meaning |
|---|---|---|
state.time | Number, seconds | Simulated time at this update |
state.tick | Number | The physics tick, starting at 0 |
state.dt | Number, seconds | Time between updates: 0.1 |
state.x, state.y | Number, metres | The centre of the vehicle |
state.heading | Number, degrees | The vehicle's absolute heading |
state.speed | Number, m/s | Forward speed. There is no reverse |
state.health | Number, 0 to 100 | Remaining health, as a percentage |
state.energy | Number | Energy available |
state.energy_max | Number | Energy capacity |
state.heat | Number, 0 to 100 | Current heat |
state.overheated | Boolean | True during thermal lockout, when weapons and shield are offline |
state.power_out | Boolean | True during a power failure, when drive, shield, radar and launcher are offline (section 8.5) |
state.blocked | Boolean | True if the vehicle touched a wall, an obstacle or the other vehicle since the last update |
state.arena_width, state.arena_height | Number, metres | The 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.
| Parameter | Range | Meaning |
|---|---|---|
throttle | 0 to 1 | The fraction of the drive's forward effort |
turn | -1 to 1 | The 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.
| Field | Type and unit | Meaning |
|---|---|---|
contact.bearing | Number, degrees | The absolute bearing from the vehicle to the target |
contact.range | Number, metres | The distance between the two centres |
contact.x, contact.y | Number, metres | The target's position, worked out from the same bearing and range |
contact.seen_at | Number, seconds | The 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:
| Time | What happens |
|---|---|
| 0.0 s | contact() returns None. The program calls scan(90) |
| After both programs have run | The scan is resolved against the world as it was at 0.0 s |
| 0.1 s | contact() returns the result. If the target was seen, seen_at is 0.0 |
| 0.2 s | If 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:
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.
| Parameter | Range | Meaning |
|---|---|---|
level | 0 to 1 | 0 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
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.
| Quantity | Unit |
|---|---|
| Distance | Metres |
| Time | Simulated seconds from the start of the battle |
| Angle | Degrees |
| Speed | Metres 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.
| Category | Meaning |
|---|---|
syntax | The text is not a valid program |
import | A problem with a library import |
name | An unknown or misused name |
type | A value of the wrong type |
argument | A wrong argument to a function |
runtime | An invalid operation, such as division by zero |
limit | A 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
statedoes not have (forturret,ammoandshield_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:
| Limit | Value |
|---|---|
| Size of a section | 64 KiB of UTF-8 |
| Additional cost of a call | 10 operations |
| Local variables in a section | 128 names |
| String length | 256 characters |
| Debug messages per update, from all sections together | 4 |
| Nesting of expressions, and of blocks | 32 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:
| Limit | Bronze |
|---|---|
| Operations per update | 2,000 |
| Memory fields | 64 |
| Memory size | 16 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.
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 "=" expression15. 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 added | Why it breaks nothing |
|---|---|
| A library | Its name is reserved only in a program that imports it (section 2.5) |
| A function in a library | Only a program that calls it is affected |
A field of state or of a contact | Only a program that reads it is affected |
| An optional parameter, after the existing ones | A 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 limits | A program within the old limits is within the new |
| A class above the highest | No 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.
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.
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.
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.3Appendix B: Common mistakes
| Mistake | Why it fails | Instead |
|---|---|---|
contact = radar.scan(90) | scan returns None | Call scan, then read radar.contact() on the next update |
memory.angle = memory.angle + 10 on the first update | An unwritten field is None | Test for None and give the field a value first |
contact.bearing with no test of contact | contact() may return None | Guard with if contact: |
| Acting on any contact returned | The last result persists and may be old | Compare contact.seen_at with state.time |
state.enemy_x | state has nothing about the opponent | Find the opponent with radar |
drive.set(1, 90) | turn is a fraction, not an angle | Use a value from -1 to 1 |
missiles.fire(contact.bearing) | fire takes no bearing: the missile leaves along the turret | missiles.aim(contact.bearing), then missiles.fire() |
| Firing in the update in which you aim | The turret takes time to turn | Compare missiles.turret() with the bearing aimed at before firing |
state.turret, state.ammo, state.shield_level | Equipment answers for itself; these are not in state | missiles.turret(), missiles.ammo(), shield.level() |
A variable named after a built-in, such as min = 5 | Built-in names cannot be assigned to | Choose another name. A library's name is free unless you import it |
Firing, scanning and shielding until state.energy is nearly nothing | An empty tank is a power failure: everything stops until 20 is back | Keep a reserve: fire only if state.energy > 20, and ease off the throttle when it runs low |
Treating ready() as a guarantee | It says nothing of the turret, and a scan may use the energy first | Check the turret yourself; read the launches and rejections on the timeline |
Importing shield with no generator fitted | The library belongs to the equipment | Fit the generator, or remove the import and its calls |
| Keeping a value in a local between updates | Locals last for one update | Keep it in memory |
Logging "hit" after fire | A request is not a launch, and a launch is not a hit | Read 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 available | Use instead |
|---|---|
| Loops | Memory, and the next update |
| Functions and classes of the program's own | One update in each section, written out in full |
| Lists, dictionaries, tuples, sets, indexing | Separate memory fields |
Augmented assignment (+= and the like) | x = x + 1 |
| Exponentiation, floor division, bitwise operators | Multiplication; / |
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 strings | None |
| Other modules, files, network, clock, random numbers | The vehicle libraries and the built-in functions |
| Exception handling | Test values with if before using them |
Returning a value from update | Bare return |
Appendix D: Revision history
| Date | Change |
|---|---|
| 6 October 2026 | 0.1 written as a proposal, before the engine existed |
| 8 October 2026 | 0.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 2026 | 0.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 2026 | No 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 2026 | 1.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 2026 | No 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.