Writing an IR protocol

A tagger fires a shot as a burst of infrared light. The IR protocol decides what that light looks like: how long each flash is, how long each gap is, and what the bits inside mean. Every protocol a tagger uses comes from a BattleScript file, so you can write your own.

This guide covers the firmware that ships today and the next one. Anything markedComing in the next firmware does not work on a tagger yet. Its name and behaviour are what the firmware plan sets out.

Contents

What a protocol file does

A protocol file is ordinary BattleScript, compiled to a .bin like a game. Its program block says type "protocol". The tagger loads it and runs it. It has two parts:

  • Native calls at the top level set up the infrared hardware: the timings of each flash and gap, the lead-in, the tolerance and the carrier. They run once, when the file loads.
  • A protocol block describes the bits: how many a frame has, the checks a frame must pass, and the fields it carries. The tagger uses it to build each outgoing shot and to read each incoming one. Its syntax is on the Protocols page.

A frame read off a sensor goes through these steps. The lead-in is checked and removed. The system fills bitLength with the number of symbols left (one symbol is one mark and the gap after it). Then the protocol block checks the frame and reads its fields into the IR / IN event that the game script handles.

Today a tagger always loads one fixed protocol file. In the next firmware, the game chooses the protocolComing in the next firmware:

  • The game's config names it, as "irProtocol": "battlecore2". battlecore2 (two bits a symbol, below) is the default, and battlecore(pulse distance) is shipped beside it as the fallback, until range and two shooters on one sensor are measured in the field.
  • Every tagger in a game uses the same protocol. It travels with the game's settings, with a checksum, and a tagger that lacks it is sent a copy, as with games.
  • Built-in protocols live in the tagger's own storage. Your own go on the audio board's SD card, as /protocols/<id>/protocol.bin, installed by the phone app. Its checksum is the SHA-256 of the compiled file.
  • A headset has no card: its tagger sends it the protocol over Bluetooth, and a lobby waits until the headset can decode with it.

The natives

A protocol file sets up the hardware with the battlecore::infrared natives. Include their declarations with include "./battlecore/infrared.bst";. All durations are in microseconds (µs). Call install() last.

In the firmware today

NativeWhat it does
setEncodingStyle(style)Picks how a bit is encoded, from the IrEncodingStyle enum. Today only PULSE_WIDTH is accepted. PULSE_DISTANCE is refused until the next firmware. MANCHESTER is not supported.
setTolerance(us)How far a measured timing may be from the one you set and still match, either way. 250 means ±250 µs.
setMarkDuration(zeroUs, oneUs)The length of the mark (the flash) for a 0 bit and for a 1 bit.
setSpaceDuration(zeroUs, oneUs)The length of the space (the gap after the mark) for a 0 bit and for a 1 bit.
setLeadIn(markUs, spaceUs)The mark and space that start every frame. A frame that does not start with them is not this protocol. They are not counted in bitLength.
install()Says the protocol is set up. Today each setting takes effect as soon as it is made. In the next firmware the carrier and the new settings below are applied here.
uninstall()Not supported: it returns an error.

Each one returns true when it worked. Bits go out and are read most significant first.

Coming in the next firmware

NativeWhat it will do
setCarrierFrequency(hz)The carrier the LED flashes at during a mark, from 30 kHz to 56 kHz. Today it is fixed at 38 kHz. Match it to the receivers (see the receiver's frequency).
setCarrierDuty(percent)The share of each carrier cycle the LED is lit, at full range. The default is 33, and a larger value is refused or capped (see the LED's limit). A weapon with a shorter shot range lights it for less, scaled down from this.
setTrailer(markUs)A stop mark after the last bit, so the last space can be measured. 0 means none. Not counted in bitLength. Pulse distance needs one.
setBitOrder(order)MSB_FIRST (the default) or LSB_FIRST.
setNearMissMinSymbols(n)How many symbols in a row must have this protocol's timings for a frame with no lead-in to count as a near miss. 0 turns near misses off.
setTeamCodes(n)How many teams the protocol's team field carries, 1 to 16: teams 0 to n-1 go out and come in as themselves. A shot from a team it cannot carry is not sent. A team code past n reads as no team. The default is 16.
setMaxPlayers(n)How many players the protocol's shots tell apart, 1 to 256: usually what its player id field holds (256 for 8 bits, 128 for 7). A lobby playing with it takes no more players, and the protocol picker in the phone app shows it beside the carrier and the team count. A protocol that does not say puts no limit on the lobby.
setTeamNone(code)The team code that means "no team" in this protocol, or -1 for none (the default). A shot with no team sends it, or 0 when there is none, and a received one reads as no team. On a BattleCore tagger no team is 255, which no IR field carries: it is always mapped.

Two more changes come with these. A symbol that matches neither a 0 nor a 1 makes the frame invalid, where today it reads as a 0. And the receiver's end of frame comes from your protocol: a gap longer than your longest space, plus a margin, ends the frame.

Pulse width and pulse distance

A mark is a time the LED is flashing at the carrier frequency. The receiver sees it as one burst. A space is a time the LED is off. Each bit is one mark and the space after it. The two styles differ in which of them carries the bit. In the diagrams below, the numbers are microseconds.

Pulse width

The bit is in the length of the mark. Every space is the same. This is SwapTX: a 500 µs mark for a 0, a 1000 µs mark for a 1, and a 500 µs space after each.

  ┌────────┐          ┌──────────────────┐          ┌────────┐
  │  500   │   500    │       1000       │   500    │        │
──┘        └──────────┘                  └──────────┘        └──
  ├──────── 0 ────────┼───────────── 1 ─────────────┤
  • The receiver stretches or shortens each mark by up to −3 to +3.5 carrier cycles. In pulse width that error lands directly on the bit.
  • The LED is on for longer when a frame has more 1 bits, so each shot costs a different amount of power.
  • The receiver cuts the last space short, because the end of the frame is a long gap. Today the last bit is read from its mark and its space, so a final 1 reads as 0. SwapTX ends on a 0 bit for that reason. In the next firmware the last bit is judged on its mark aloneComing in the next firmware.

Pulse distanceComing in the next firmware

Every mark is the same length. The bit is in the space after it. This is the BattleCore protocol: a 500 µs mark, then a 500 µs space for a 0 or a 1000 µs space for a 1.

  ┌────────┐          ┌────────┐                    ┌────────┐
  │  500   │   500    │  500   │        1000        │        │
──┘        └──────────┘        └────────────────────┘        └──
  ├──── 0: 1000 µs ───┼───────── 1: 1500 µs ────────┤
  • Each bit is measured from the start of its mark to the start of the next mark. The receiver delays both mark starts by about the same amount, so the period stays accurate even when a mark is stretched.
  • The LED is on for the same time in every frame, whatever the data.
  • The last bit has no next mark to measure to, so a pulse-distance protocol ends with a trailer: a stop mark set with setTrailer.

The receiver's limits

Each sensor is a Vishay TSOP4138-style receiver (the "AGC1", short-burst kind). It only passes on light flashing at its own frequency, and only in the shapes below. A protocol outside these limits may work on the bench and fail in a game. Lengths are given in carrier cycles: at 38 kHz one cycle is about 26.3 µs, and at 56 kHz about 17.9 µs.

LimitAt 38 kHzWhat it means for your protocol
Each burst is at least 6 cycles158 µsNo mark, lead-in or trailer shorter than this.
A burst of 6 to 68 cycles is followed by a gap of at least 6 cycles158 µsNo space shorter than this.
A burst over 68 cycles needs a gap longer than itself somewhere in the streamOver 1.8 msA long lead-in is allowed. The gap between shots is long enough to cover it. SwapTX's 2500 µs lead-in is one.
At most 2500 short bursts a second2500 a secondCount the marks in a frame and divide by its length. BattleCore sends about 800 a second during a frame.
The receiver's output is the burst length −3 to +3.5 cyclesAbout −80 to +90 µsSet the tolerance wider than this. Keep the 0 and 1 timings at least twice the tolerance apart, or one symbol could match both.

The maximum carrier-on share

The carrier-on share is how much of the time the LED is flashing: a mark divided by the mark and its space. The longer the bursts, the more the receiver tolerates, until they pass 68 cycles.

Burst lengthMaximum carrier-on share
10 cyclesAbout 0.55
19 cyclesAbout 0.74
38 cyclesAbout 0.85
60 to 68 cyclesAbout 0.9
Over 68 cyclesAbout 0.5

For example, a BattleCore 0 bit is a 19-cycle mark and an equal space, a share of 0.5. A 1 bit has twice the space, a share of 0.33. Both are under the 0.74 allowed at 19 cycles.

The receiver's frequency

  • The standard receivers are 38 kHz. 56 kHz receivers are an option when the equipment is ordered.
  • A 38 kHz receiver still hears a 56 kHz carrier, but only about a fifth as strongly, which is roughly half the range.
  • So set your carrier to the receivers your players have.

The LED's limit

  • The emitter LED may be lit for at most 33% of each carrier cycle. That is the default, and setCarrierDuty will not go above it.
  • The total mark time in a frame decides how hot the LED gets. BattleCore lights it for 13.5 ms a shot, about 8 mJ. At 10 shots a second that is about 80 mW, against the LED's 190 mW.
  • Longer marks heat the LED in proportion. From the next firmware, a tagger never fires faster than one shot every 100 msComing in the next firmware.

The two shipped protocols

SwapTX: pulse width

The protocol taggers load today. Its timings copy the SwapTX commercial taggers, so early prototypes could be tested against them. It carries 19 data bits and a 3-bit parity field, 22 bits in all.

PartMarkSpace
Lead-in2500 µs (95 cycles: a long burst)500 µs
0 bit500 µs (19 cycles)500 µs
1 bit1000 µs (38 cycles)500 µs

A frame is about 30 ms. The LED is on for 13.5 to 24.5 ms, depending on how many 1 bits it has.

include "./battlecore/infrared.bst";

program {
	type "protocol";
	name "SwapTX Infrared";
	author "Irrelon Software Limited";
	version "1.0.0";
}

// Pulse width: the bit is in the length of the mark.
battlecore::infrared.setEncodingStyle(IrEncodingStyle::PULSE_WIDTH);
battlecore::infrared.setTolerance(250);

// A 500 µs mark for a 0, 1000 µs for a 1
battlecore::infrared.setMarkDuration(500, 1000);

// Every space is 500 µs, whatever the bit
battlecore::infrared.setSpaceDuration(500, 500);

// The lead-in: a 2500 µs mark and a 500 µs space. A frame that does not
// start with it is not this protocol.
battlecore::infrared.setLeadIn(2500, 500);

battlecore::infrared.install();

protocol "swaptx" for "ir" {
	step decode {
		// 19 data bits and a 3-bit parity field. The lead-in is not counted.
		require terminate "bitLength" == 22;
	}

	parity {
		offset 0;
		xor bits<19> "parityBit";
		// Written as 100 or 010, so the frame always ends on a 0 bit
		rule uint<3> "parityValue" = "parityBit" == 0 ? 4 : 2;
		offset 0;
	}

	define uint<4> "bulletTypeId";
	define uint<4> "playerId";
	define uint<2> "teamId";
	define uint<8> "hitValue";
	define uint<1> "isCritical";
}

BattleCore two bits a symbol: the defaultComing in the next firmware

battlecore2 sends the same 29-bit shot as battlecore below, plus a checked 0 bit, in about half the time: each symbol is a gap and then a mark, each one or two 375 µs units, so it carries two bits (IrEncodingStyle.PAIR_2BIT andsetSymbolUnit(375)). A 1500 µs lead-in mark and a 750 µs gap start the frame, and it ends on a mark, so it needs no trailer. About 19 ms a shot against 39, with the LED on about 3.3 ms against 5.4. Measuring each mark on its own makes it more sensitive at the edge of range, which is why battlecore stays shipped.

Symbol (gap, then mark)UnitsBits
short gap, short mark200
long gap, short mark301
short gap, long mark310
long gap, long mark411

BattleCore: pulse distance with a CRC-4, the fallbackComing in the next firmware

BattleCore's own protocol, at 38 kHz with the LED lit 33%. It carries 256 players and 16 teams: 25 data bits, then a 4-bit CRC in place of a parity bit, 29 bits in all.

PartMarkSpace
Lead-in1500 µs (57 cycles, under 68)750 µs
0 bit500 µs (19 cycles)500 µs (a 1000 µs period)
1 bit500 µs1000 µs (a 1500 µs period)
Trailer500 µsThe end of the frame
FieldBits
bulletTypeId4
playerId8
teamId4
hitValue8
isCritical1
CRC-4/ITU (polynomial x⁴ + x + 1) over the 25 bits above4
  • The tolerance is ±200 µs on each bit's period. A 0 and a 1 are 500 µs apart.
  • A frame is about 39 ms on average (31.75 ms with every bit 0, 46.25 ms with every bit 1). The LED is on for 16.5 ms whatever the data, and its marks are as long as SwapTX's shortest, so the range is the same.
  • A frame whose CRC fails is not a hit. It is a near miss.

The file will look like this:

include "./battlecore/infrared.bst";

program {
	type "protocol";
	name "BattleCore Infrared";
	author "Irrelon Software Limited";
	version "2.0.0";
}

// Pulse distance: every mark is the same, the bit is in the space.
battlecore::infrared.setEncodingStyle(IrEncodingStyle::PULSE_DISTANCE);
battlecore::infrared.setCarrierFrequency(38000);
battlecore::infrared.setCarrierDuty(33);

// ±200 µs on each bit's period (mark start to the next mark start)
battlecore::infrared.setTolerance(200);

// Every mark is 500 µs (19 carrier cycles at 38 kHz)
battlecore::infrared.setMarkDuration(500, 500);

// A 500 µs space for a 0, 1000 µs for a 1
battlecore::infrared.setSpaceDuration(500, 1000);

// A 1500 µs mark (57 cycles, under the 68-cycle long-burst limit) and a 750 µs space
battlecore::infrared.setLeadIn(1500, 750);

// A 500 µs stop mark after the last bit, so the last space can be measured
battlecore::infrared.setTrailer(500);

// Four protocol-shaped symbols in a row, with no lead-in, count as a near miss
battlecore::infrared.setNearMissMinSymbols(4);

// Teams 0-15 straight through, and no code for "no team": a game without
// teams sends 0 and every tagger ignores it
battlecore::infrared.setTeamCodes(16);
battlecore::infrared.setTeamNone(-1);

// An 8-bit player id: 256 players tell apart
battlecore::infrared.setMaxPlayers(256);

battlecore::infrared.install();

protocol "battlecore" for "ir" {
	step decode {
		// 25 data bits and a 4-bit CRC. The lead-in and the trailer are not counted.
		require terminate "bitLength" == 29;
	}

	// The last 4 bits are a CRC-4/ITU (x^4 + x + 1) over the 25 data bits.
	// A frame that fails it is a near miss, not a hit.
	parity {
		offset 0;
		crc<4> bits<25> "crc";
		offset 0;
	}

	define uint<4> "bulletTypeId";
	define uint<8> "playerId"; // 0-255
	define uint<4> "teamId";   // 0-15
	define uint<8> "hitValue";
	define uint<1> "isCritical";
}

Near missesComing in the next firmware

A shot can reach a sensor without arriving cleanly: at the edge of the beam or of its range, partly blocked by an arm, or bounced off a wall. The receiver sees bursts with roughly the right timing, but the frame is incomplete or garbled. Such a frame is never a hit. The firmware tells the game about it instead, as a near miss.

Each captured frame is sorted into one of three:

  1. A hit. It decodes and passes its checks. The game gets IR / IN, as now.
  2. A near miss. It is plainly this protocol, but it fails. Either the lead-in matched and then a symbol was invalid, the length was wrong, or the check failed. Or there was no lead-in, but at least setNearMissMinSymbols(n)symbols in a row had this protocol's timings. The game gets IR / NEAR.
  3. Noise. Anything else, such as single blips or a TV remote. It is ignored.

setNearMissMinSymbols

  • It is how many protocol-shaped symbols in a row a frame without a lead-in needs to count as a near miss. That count is what separates a near miss from noise.
  • About 4 is the starting point. A protocol with short frames may want fewer.
  • A higher number gives fewer false near misses, and misses more real ones.
  • 0 turns near misses off for the protocol.

The event

FieldWhat it holds
sensorIdThe sensor that saw it.
headsetModuleThe headband module that saw it, 0 to 3, or -1 for the tagger itself.
reasonnoLeadIn, shortFrame, badSymbol or badCheck.
symbolsHow many symbols matched the protocol.
confidence0 to 100.

It has no player or team: a frame that failed its checks cannot be trusted to say who fired. The firmware also filters what reaches the game:

  • One near miss per shot, however many sensors saw it.
  • A hit wins. If any sensor decodes the same shot as a hit, the near miss is dropped. So a near miss arrives about 50 ms after the shot.
  • A tagger ignores its own shots, and the reflections just after them.
  • At most one about every 250 ms, so a noisy room cannot flood the game.

The game script decides what a near miss does: a ricochet sound, a flicker on the screen, or nothing. The shipped games play a quiet ricochet, with a setting to turn it off. The after-action report counts each player's near misses as close calls, and the most earns the Lucky title.

// Add NEAR = "NEAR" to the game's EventEnum, beside IN.
state StateEnum.IN_GAME {
	module ModuleEnum.IR {
		event EventEnum.NEAR (EventData evt) => {
			// A quiet ricochet. The firmware cannot say whose shot it was,
			// so a team game decides for itself whether to play it.
			battlecore::audio.trackStart("ricochet-1.wav", 120, 1);
		}
	}
}

MilesTag 2 and LaserWarComing in the next firmware

Two protocol files let BattleCore taggers play with real MilesTag 2 and LaserWar gear: protocol_mt2_ir and protocol_laserwar_ir. A game picks one with its irProtocolsetting. Both are pulse width, with a 2400 µs header mark, at 56 kHz. On the taggers' 38 kHz receivers that header is twice what the receiver takes, so a game with real gear wants 56 kHz receivers. A game's irCarrierHz setting can run either protocol at 38 kHz when every tagger is BattleCore.

  • Shots arrive as IR/IN, like any other.
  • Messages arrive as IR/MT2_MESSAGE {messageId, data}. These are a referee gun's, medic's or ammo box's commands.
  • System data arrives as IR/MT2_SYSTEM. The firmware handles it:
    • a clone heard in the host's lobby becomes the game's settings;
    • a gun's score data heard after the game goes into the report.
  • Real gear on the board.A shooter with no BattleCore tagger behind it is listed on the board and in the report as "MT2 #37" or "Red LW #50". A game's foreignIds setting keeps the ids real gear uses away from BattleCore players. A LaserWar game never gives anyone id 0.

The handler includes

battlecore/mt2.bst and battlecore/laserwar.bst (which includes it) carry out what real gear asks of a player. That means health, rounds, full ammo, stun, disarm and the boxes. For everything else, they tell the game what was asked as a GearActionEnum, and the game applies its own rules:

  • DIE: an admin kill or explode.
  • RESPAWN.
  • REVIVE: Start game or New game while the game is running.
  • OUT: the end of this player's game.

Game-wide commands, and LaserWar's commands addressed to a team or a player, go round the mesh as script events. With the game's foreignGameCommands setting at relay, every tagger acts once.

include "./battlecore/laserwar.bst"; // or mt2.bst

state _ {
	module ModuleEnum.IR {
		event EventEnum.MT2_MESSAGE (EventData evt) => {
			gearDo(gearMessage(evt.data.messageId, evt.data.data));
		}
	}
	module ModuleEnum.NET {
		event EventEnum.MT2_GAME (EventData evt) => {
			gearDo(gearGameWide(evt.data.command));
		}
		event EventEnum.LW_ADDRESSED (EventData evt) => {
			gearDo(gearAddressed(evt.data.messageId, evt.data.data));
		}
	}
}

gearDois the game's own function: what a death, a respawn, a revive and being out mean in it. The shipped games each have one. For example, Infection turns a survivor who dies into a zombie.

battlecore::weapon.setShotField("lwZombie", 1)sends a zombie's shots as LaserWar's own zombie, player 0 on Green, so real LaserWar guns turn by their own rule. Other protocols ignore the field.

A checklist

  • Every mark, lead-in and trailer is at least 6 carrier cycles, and every space at least 6.
  • Any mark over 68 cycles is the lead-in, not a data bit.
  • The carrier-on share of each symbol is under the limit for its burst length.
  • Fewer than 2500 marks a second during a frame.
  • The tolerance is wider than the receiver's −3/+3.5-cycle error, and the 0 and 1 timings are at least twice the tolerance apart.
  • The carrier matches the receivers, and the carrier duty is 33% or less.
  • The total mark time per frame is close to BattleCore's 13.5 ms.
  • Pulse width: the frame ends on a 0 bit (until the next firmware). Pulse distance: it has a trailer.
  • The bitLength check in the protocol block matches the number of bits you send.

Related Topics