By Rob Evans • 11/6/2025
This script defines the ESP-NOW multiplayer protocol used by BattleCore, the system that lets players create and join games, start, pause, end sessions, and broadcast game activity as they are playing.
/*
This file defines a communications protocol and is written in BattleScript. Protocols
allow the BattleCore system to conditionally interpret and transcode messages based on
byte data. Use cases are not limited to over-the-air data such as esp-now and lora,
but can also define protocols for hard-wired transports such as via UART. This allows
new hardware to be connected to the BattleCore motherboard's unused serial connector and
data to be sent and received with that device even though our BattleCore firmware had
no prior knowledge of the device or how to communicate with it.
This allows new, custom peripherals to be plugged into your BattleCore device and
interacted with as if they were part of the original electronics design!
*/
program {
type "protocol";
name "P2P";
author "Irrelon Software Limited";
version "1.0.0";
}
// Let's define our protocol's message types. We are going to support commands,
// requests and responses. These are completely arbitrary and your protocol may not
// even have them or need them.
// TIP: Enums in BattleScript (this language) are not like most language enums because
// although they are defined in a similar way, their values are contiguous uint32_t.
// This means that MessageType.UNKNOWN is 0, MessageType.COMMAND is 1, MessageType.REQUEST is 2,
// MessageType.RESPONSE is 3 and in the next enum CommandType.UNKNOWN is 4. All enum values
// follow on from the last used value, regardless of if they are new enum names or not.
// This allows all enums to be guaranteed globally unique uint values, up to uint32_t max.
// The only exception to this is if an enum has an explicit value defined in the enum definition,
// in which case that value will be used instead of the next available value.
//
// COMMAND - One-way message (but can signal they must have an acknowledgement).
// REQUEST - Like commands but they expect a response back.
// RESPONSE - Messages sent back to answer a request.
enum MessageType {
UNKNOWN, // 0
COMMAND, // 1
REQUEST, // 2
RESPONSE, // 3
}
// Here we define the command types we support in our multiplayer game. The markers next to
// each command document the type of transmission etc:
// (P2B) - Peer to broadcast, this sends to ALL ESP-NOW capable devices in range
// (P2P) - Peer to peer, this sends to a specific ESP-NOW device by MAC address
// (P2PL) - Peer to peer list, this sends to a list of specific ESP-NOW devices by MAC address (usually the devices we know are part of our game)
// (ACK) - A reminder that when receiving this message from the sender, we should send a command of type ACKNOWLEDGEMENT back
enum CommandType {
UNKNOWN, // 4
// GENERAL
ACKNOWLEDGEMENT, // 5 (P2P) Sent from a recipient when receiving a command that needs an ACK response
// LOBBY
CLIENT_LOBBY_PING, // 6 (P2B) Sent when client goes to join game screen, hosts respond with lobby beacon
HOST_LOBBY_BEACON, // 7 (P2B) Regular beacon signal advertising lobby
HOST_LOBBY_DESTROYED, // 8 (P2B) When host destroys lobby, clients should remove the game
CLIENT_REQUEST_JOIN_LOBBY, // 9 (P2P) Request for client to join lobby
HOST_RESPONSE_JOIN_LOBBY, // 10 (P2P) Host response to client request to join lobby
CLIENT_RESPONSE_JOINED_LOBBY, // 11 (P2B) Client tells all players that they have joined the lobby
CLIENT_COMMAND_SIGNAL_READY_STATE, // 12 (P2PL) (ACK) Client requests to set their ready state to 1 or 0
CLIENT_COMMAND_LEAVE_LOBBY, // 13 (P2P) (ACK) Client tells host they are leaving the lobby
HOST_COMMAND_LEAVE_LOBBY, // 14 (P2P) Host tells client they have been booted from the lobby
HOST_COMMAND_PLAYER_LEFT_LOBBY, // 15 (P2B) Host tells all players that a player has left the lobby
HOST_COMMAND_GAME_SETTINGS_UPDATE, // 16 (P2PL) Host telling clients about a game settings update
HOST_COMMAND_GAME_PRE_START, // 17 (P2PL) (ACK) Host telling clients to move to the pre-start state where final game data will be transferred
CLIENT_GAME_PRE_START_COMPLETE, // 18 (P2P) (ACK) Client telling host they have completed pre-start preparations
HOST_COMMAND_GAME_PRE_START_CANCEL, // 19 (P2PL) (ACK) Host telling clients the game start countdown has been cancelled
HOST_COMMAND_GAME_START, // 20 (P2B) Host telling clients to start the game now
// GAME ADMIN
HOST_COMMAND_GAME_PAUSED_STATE, // 21 (P2PL) (ACK) Host telling players the game pause state (1 paused, 0 resume)
CLIENT_COMMAND_GAME_PAUSE, // 22 (P2P) (ACK) Client requests a game pause
HOST_COMMAND_GAME_ENDED, // 23 (P2PL) (ACK) Host telling players the game is over
// GAME DATA
PLAYER_COMMAND_HIT_CONFIRMED, // 24 (P2P) Player sends to player they were hit by, confirming damage or healing was taken
PLAYER_COMMAND_DOWN_CONFIRMED, // 25 (P2PL) Player broadcasts the are in a downed state (can be revived for a set time period before completely losing life)
PLAYER_COMMAND_REVIVE_CONFIRMED, // 26 (P2PL) Player broadcasts they were revived from a downed state
PLAYER_COMMAND_KILL_CONFIRMED, // 27 (P2PL) (ACK) Player broadcasts they were killed by other player, the player that killed them sends an ACK
PLAYER_COMMAND_RESPAWNED, // 28 (P2PL) Player broadcasts they just respawned
PLAYER_COMMAND_PERMA_DEATH, // 29 (P2PL) Player broadcasts they have lost all lives and are out of the game completely
// PLAYER SYNC
HOST_BROADCAST_PLAYER_LIST, // 30 (P2P/P2B) Host sends complete player list (on join or periodic sync)
HOST_BROADCAST_PLAYER_UPDATE, // 31 (P2B) Host broadcasts single player state update (delta)
}
// Tell the interpreter that we are defining a new protocol and we are calling it "p2p"
// although the name can be anything we want, "p2p" is fitting as it's our esp-now
// message protocol so we are only handling peer-to-peer messages with it.
//
// The "for" parameter tells BattleCore that we only want to use this protocol for messages
// using "esp-now" as the transport.
//
// Currently available values for transport are the comms systems built into BattleCore:
// esp-now - Peer-to-peer comms over a wifi-like network, without the need for a router
// uart - Hard-wired serial connection
// wifi - Standard WiFi when connected to an access point / WiFi router
// lora - Lo(ng)-Ra(nge) comms, send and receive up to 10 kilometres away!
// ble - Bluetooth Low Energy, connect with other BLE devices and swap realtime data
// ir - Infrared, the primary system for firing shots at each other in laser tag
protocol "p2p" for "esp-now" {
// Each protocol is made up of commands and steps. A step defines any conditionals that must
// be true before the step is executed, and a list of commands to execute for the step.
// A step with no conditions (`require` statements) will always be executed.
// A step with `require` statements will evaluate each require statement in order. Each
// `require` statement must evaluate to true before the next is evaluated.
// Once all `require` statements are evaluated true, a step can add `define` statements
// that describe the data (byte size) to use in the byte stream.
// If any `require` statement evaluates to false, the step is skipped and the next step
// is processed
//
// A `define` statement is made up of a data type and the variable name to store it in.
// Available types are:
// string - An indeterminate number of bytes followed by a string terminator byte
//
// Unsigned (negative numbers not allowed):
// uint<8> - An unsigned integer value between 0 and 255
// uint<16> - An unsigned integer value between 0 and 65,535
// uint<32> - An unsigned integer value between 0 and 4,294,967,295
//
// Signed (negative and positive numbers allowed):
// int<8> - A signed integer value between -128 and 127
// int<16> - A signed integer value between −32,768 and 32,767
// int<32> - A signed integer value between −2,147,483,648 and 2,147,483,647
//
// Any uint or int can have an arbitrary number of bits up to 32 e.g. uint<5>.
// This allows you to read only 5 bits from the byte stream rather than a
// conventional 8 bit byte.
// Before anything else, we read the first byte of data from the incoming byte stream
// into the variable "startByte" which we will examine in the next step.
define uint<8> "startByte";
// A require statement will check its condition and if it is false, the block will be
// exited. If the block is the protocol itself rather than inside a step, the whole
// protocol will be skipped.
// This allows you to exit processing further protocol commands and steps if a condition
// is not met. In this case, if the startByte read in the previous define command is not
// equal to 23, we can skip the rest of the protocol entirely, otherwise we can continue
// processing the rest of the protocol and read the next defined data which is a 1 byte
// messageType.
require "startByte" == 23;
define uint<8> "messageType";
step {
// A require statement inside a step block will only exit the current step if
// the condition is unmet. In this case, we want to check if the messageType is
// a COMMAND and if so, we know that command-type messages have a 1 byte shouldAcknowledge
// field and a 1 byte commandId field.
require "messageType" == MessageType.COMMAND;
define uint<8> "shouldAcknowledge";
define uint<8> "commandId";
}
// Define data for all request-type messages
step {
// When the messageType is a REQUEST
require "messageType" == MessageType.REQUEST;
// Read a byte and store in requestId
define uint<8> "requestId";
// Read a byte and store in commandId
define uint<8> "commandId";
}
// Define data for all response-type messages
step {
// When the messageType is a RESPONSE
require "messageType" == MessageType.RESPONSE;
// Read a byte and store in requestId
define uint<8> "requestId"; // This is the original request's requestId allowing the receiver (the original request sender) to know what request was responded to
// Read a byte and store in commandId
define uint<8> "commandId";
}
// Define data for specific command messages
step {
// P2B multicast
// Client is requesting that all current hosts send a beacon so the client knows what games are available
require "commandId" == CommandType.CLIENT_LOBBY_PING;
// An end statement tells the interpreter that we want to exit all further operations
// because we've completed our protocol process. The true or false at the end tells
// the interpreter if this should be considered success or not. If it's successful, the
// interpreter will not try to use any other defined protocols to process the data.
end true;
}
step {
// P2P unicast
// Sent from a recipient when receiving a command that needs an ACK response
require "commandId" == CommandType.ACKNOWLEDGEMENT;
define uint<8> "gameId"; // The game this ACK relates to
define uint<8> "ackCommandId"; // The command type being acknowledged
end true;
}
step {
// P2B multicast
// Host responds to lobby ping with a beacon to tell clients that their game is available to join
// This message is also sent when a host first creates their game so that if all players are already
// in the JOIN_GAME state (and already sent a beacon request), this host wouldn't have received them
// since the game wasn't available yet.
require "commandId" == CommandType.HOST_LOBBY_BEACON;
define uint<8> "gameId"; // A unique id for the game being played, differentiate between other games being played
define uint<8> "playerCount"; // The number of players currently in the lobby
define uint<32> "time"; // The current host timestamp, used for clock synchronisation
define string "name"; // The name of the host player, e.g. "Amelia" or "Bob"
end true;
}
step {
// P2B multicast
// Host has destroyed the lobby, clients should remove the game and go back to the JOIN_GAME state
require "commandId" == CommandType.HOST_LOBBY_DESTROYED;
define uint<8> "gameId"; // The game id of the host's game from 0 to 255
end true;
}
step {
// P2P unicast
// Client is requesting to join a lobby, sent directly to the host MAC address
require "commandId" == CommandType.CLIENT_REQUEST_JOIN_LOBBY;
define string "name"; // The name of the player joining the lobby, shown to all players e.g. "Amelia Earhart"
end true;
}
step {
// P2P unicast
// Host responds to a CLIENT_REQUEST_JOIN_LOBBY and if result is 1, client can join
require "commandId" == CommandType.HOST_RESPONSE_JOIN_LOBBY;
define uint<8> "result"; // Either 1 for successfully joined or 0 for failed to join
define uint<8> "id"; // The host assigned player id number from 0 to 255
end true;
}
step {
// P2B multicast
// Client tells all players that they have joined the lobby (all clients receive and update their UI)
require "commandId" == CommandType.CLIENT_RESPONSE_JOINED_LOBBY;
define uint<8> "gameId"; // The game id of the game the player is joining from 0 to 255
define uint<8> "id"; // The host assigned player id number from 0 to 255
define string "name"; // The name of the player joining the lobby, shown to all players e.g. "Amelia Earhart"
end true;
}
step {
// P2B multicast
// Client tells all players in the game that their ready state has changed
require "commandId" == CommandType.CLIENT_COMMAND_SIGNAL_READY_STATE;
define uint<8> "gameId"; // The game id of the game the player is part of from 0 to 255
define uint<8> "id"; // The host assigned player id number from 0 to 255
define uint<8> "state"; // Either 1 for ready or 0 for not ready
end true;
}
step {
// P2B multicast
// Client tells all players in the game that they have left the lobby
require "commandId" == CommandType.CLIENT_COMMAND_LEAVE_LOBBY;
define uint<8> "gameId"; // The game id of the game the player is part of from 0 to 255
define uint<8> "id"; // The host assigned player id number from 0 to 255
end true;
}
step {
// P2P unicast
// The host tells the client that they have been booted from the lobby
require "commandId" == CommandType.HOST_COMMAND_LEAVE_LOBBY;
define uint<8> "reason"; // An enum integer indicating one of the set reasons for booting the player
end true;
}
step {
// P2B multicast
// The host tells all players in the game that a player has left the lobby
require "commandId" == CommandType.HOST_COMMAND_PLAYER_LEFT_LOBBY;
define uint<8> "gameId"; // The game id of the game the player is part of from 0 to 255
define uint<8> "id"; // The host assigned player id number from 0 to 255
end true;
}
step {
// P2B multicast
// The host tells all players about the latest updates to the game's settings
require "commandId" == CommandType.HOST_COMMAND_GAME_SETTINGS_UPDATE;
define uint<8> "gameId"; // The game id of the game the player is part of from 0 to 255
define string "bstId"; // The ID of the BattleScript game script being played
define string "gameName"; // Name of the game type e.g. "Slayer Classic"
define uint<8> "isTeamBased"; // 0 = all against all, 1 = team based - some games will lock this if their battlescript code doesn't allow one type or another
define uint<8> "gameDurationMins"; // How long the game will last in minutes
define uint<8> "radarType"; // 0 = off, 1 = movement-based, 2 = permanent
define uint<8> "mapType"; // 0 = unlimited, 1 = limited - if limited, a defined polygon play-area will need to be setup by sending all players out to the edges of the play area
define uint<8> "playerLives"; // Number of lives players start with
end true;
}
step {
require "commandId" == CommandType.HOST_COMMAND_GAME_PRE_START;
define uint<8> "gameId"; // The game id of the game the player is part of from 0 to 255
define uint<8> "eventTime"; // The timestamp of when the countdown started from for timing synchronisation,
define uint<8> "countdownSecs"; // The number of seconds before the game will auto-start
end true;
}
step {
require "commandId" == CommandType.CLIENT_GAME_PRE_START_COMPLETE;
// No data, this is a message with no payload
end true;
}
step {
require "commandId" == CommandType.HOST_COMMAND_GAME_PRE_START_CANCEL;
define uint<8> "gameId"; // The game id of the game the player is part of from 0 to 255
end true;
}
step {
// Host tells all players to start the game now
require "commandId" == CommandType.HOST_COMMAND_GAME_START;
define uint<8> "gameId"; // The game id of the game the player is part of from 0 to 255
end true;
}
step {
require "commandId" == CommandType.HOST_COMMAND_GAME_PAUSED_STATE;
define uint<8> "gameId"; // The game id of the game the player is part of from 0 to 255
define uint<8> "state"; // 1 = paused, 0 = resumed
end true;
}
step {
require "commandId" == CommandType.CLIENT_COMMAND_GAME_PAUSE;
define uint<8> "gameId"; // The game id of the game the player is part of from 0 to 255
end true;
}
step {
require "commandId" == CommandType.HOST_COMMAND_GAME_ENDED;
define uint<8> "gameId"; // The game id of the game the player is part of from 0 to 255
define uint<8> "endType"; // 0 = score (player or team reached winning score), 1 = time (time ran out), 2 = condition (everyone died etc), 3 = forced (host forced game to end)
end true;
}
step {
require "commandId" == CommandType.PLAYER_COMMAND_HIT_CONFIRMED;
define uint<8> "gameId"; // The game id of the game the player is part of from 0 to 255
define uint<8> "type"; // 0 = damage, 1 = healing
define uint<8> "amount"; // The amount of damage or healing taken
define uint<8> "byId"; // The id of the player that caused the event
end true;
}
step {
require "commandId" == CommandType.PLAYER_COMMAND_DOWN_CONFIRMED;
define uint<8> "gameId"; // The game id of the game the player is part of from 0 to 255
define uint<8> "byId"; // The id of the player that caused the event
end true;
}
step {
require "commandId" == CommandType.PLAYER_COMMAND_REVIVE_CONFIRMED;
define uint<8> "gameId"; // The game id of the game the player is part of from 0 to 255
define uint<8> "byId"; // The id of the player that caused the event
end true;
}
step {
require "commandId" == CommandType.PLAYER_COMMAND_KILL_CONFIRMED;
define uint<8> "gameId"; // The game id of the game the player is part of from 0 to 255
define uint<8> "byId"; // The id of the player that caused the event
end true;
}
step {
require "commandId" == CommandType.PLAYER_COMMAND_RESPAWNED;
define uint<8> "gameId"; // The game id of the game the player is part of from 0 to 255
// No data, this is a message with no payload
end true;
}
step {
require "commandId" == CommandType.PLAYER_COMMAND_PERMA_DEATH;
define uint<8> "gameId"; // The game id of the game the player is part of from 0 to 255
// No data, this is a message with no payload
end true;
}
step {
// P2P unicast or P2B multicast
// Host sends complete player list for sync (on join or periodic)
require "commandId" == CommandType.HOST_BROADCAST_PLAYER_LIST;
define uint<8> "gameId"; // The game id of the game the player is part of from 0 to 255
define uint<8> "playerCount"; // Number of players in the list
// TODO: Define how to read multiple player records based on playerCount
end true;
}
step {
// P2B multicast
// Host broadcasts a single player's state update (delta sync)
require "commandId" == CommandType.HOST_BROADCAST_PLAYER_UPDATE;
define uint<8> "gameId"; // The game id of the game the player is part of from 0 to 255
define uint<8> "playerId"; // The player ID being updated
define uint<48> "macAddress"; // Player's MAC address (6 bytes)
define string "playerName"; // Player's display name
define uint<8> "teamId"; // Team assignment
define uint<8> "livesRemaining"; // Current lives
define uint<8> "isReady"; // 1 = ready, 0 = not ready
define uint<8> "isConnected"; // 1 = connected, 0 = disconnected
define uint<8> "killCount"; // Number of kills
define uint<8> "deathCount"; // Number of deaths
define uint<16> "points"; // Player's points
define uint<32> "lastSeenTimestamp"; // Last seen time in milliseconds
end true;
}
end false;
}