The Aeroduel Server API handles match creation, plane registration, game state management, and real-time communication between ESP32s and mobile apps.
Base URL: http://aeroduel.local:45045
WebSocket URL: ws://aeroduel.local:45046
Note: The WebSocket port is by default whatever the HTTP port is + 1. This means if you change the default HTTP port to 3000, the WebSocket port will be 3001.
Some endpoints are only accessible when inside the desktop application to prevent external tampering by accessing the browser version at http://aeroduel.local:45045/.
These endpoints require a server token. This token is accessible by importing getServerToken() from src/app/getAuth.ts and must be awaited.
Only the server and Electron app know the token, which prevents outside tampering via any other source, including the host device unless the request explicitly comes
from the Electron app itself. Not even the host device's browser can access the token. The server token is used for endpoints like POST /api/new-match or admin controls
like disqualifying planes during a match.
The onboard ESP32s also require an auth token for endpoints relating to the game (POST /api/hit for example). This auth token is assigned when calling POST /api/register.
Anyone can call api/register, so to prevent bad actors from registering fake planes in order to get an auth token, the user can kick unknown planes from the match lobby
and disqualify planes during the match (if, for example, the plane crashes or turns out to be a fake plane that registered and is causing trouble).
Each plane will be given a different auth token for each different session. A session starts when either the server first opens or when a new match begins, and ends
when a new match begins or the server shuts down.
The mobile app also requires an auth token for many of the endpoints it needs to use. This auth token is assigned when the users joins a match (POST /api/join-match) and
is reset when a new match begins.
Creates a new Aeroduel match in "waiting" state. Requires a server token for
authentication. Validates duration (30–1800 seconds) and maxPlayers (2–16)
and detects the local IP and constructs serverUrl, wsUrl, and qrCodeData
using mDNS hostname (fallback to local IP)
Request Body:
{
"serverToken": "some-unique-token",
"duration": 420, // Optional: Match duration in seconds (default: 420 (7 minutes))
"maxPlayers": 2 // Optional: Maximum players (default: 2)
}Validation:
serverToken: Must matchSERVER_TOKENenvironment variable, which is auto-generated and should not be set manuallyduration: 30-1800 seconds (30 seconds to 30 minutes)maxPlayers: 2-16 players (you probably shouldn't increase this limit, just to keep the game safe)
Success Response (200):
{
"success": true,
"match": {
"matchId": "a1b2c3d4e5f6...",
"status": "waiting",
"matchType": "timed",
"duration": 420,
"maxPlayers": 2,
"serverUrl": "http://aeroduel.local:45045",
"wsUrl": "ws://aeroduel.local:45046",
"matchPlanes": [],
"localIp": "192.168.1.5"
}
}Server Discovery:
- Prefers mDNS hostname (
aeroduel.local) for URLs - Falls back to local IP address when mDNS unavailable
- Port defaults to 45045 (configurable via
PORTenv var)
Error Responses:
400 - Invalid JSON
{
"error": "Invalid JSON"
}400 - Invalid duration
{
"error": "Duration must be a valid number between 30 seconds and 30 minutes (1800 seconds)"
}400 - Invalid maxPlayers
{
"error": "maxPlayers must be a valid number between 2 and 16"
}403 - Unauthorized
{
"error": "Unauthorized"
}409 - Match already exists
{
"error": "A match is already in progress",
"existingMatch": {
"matchId": "...",
"status": "waiting"
}
}500 - Network error
{
"error": "Could not detect local IP address. Ensure you're connected to WiFi."
}Notes:
- Only one match can exist on the server at a time
- Attempting to create a match while one is active (not "ended") returns 409
Registers that a plane is online. Called when a plane's ESP32 is powered on and
the LoRa connects to the WiFi network.
Registers/updates the plane in the in‑memory planes list. Generates and
returns the auth token the ESP32 will use for plane-specific requests.
Request Body:
{
"planeId": "uuid-of-plane",
"esp32Ip": "192.168.1.101",
"userId": "uuid-of-linked-user-account-or-null"
}Success Response (200):
{
"success": true,
"authToken": "some-authentication-token",
"matchId": "a1b2c3d4e5f6..."
}Adds a plane to the current match's waiting room.
Returns the auth token the mobile app will use for mobile-specific requests
during this match.
Ensures the match is still in waiting state and the requesting userId matches
the one associated with the given planeId and enforces the maxPlayers limit.
Request Body:
{
"planeId": "uuid-of-plane",
"playerName": "Foxtrot-4",
"userId": "uuid-of-user"
}Success Response (200):
{
"success": true,
"authToken": "some-authentication-token",
"matchId": "a1b2c3d4e5f6..."
}Begins the current match stored in memory if not started yet and if there are at least 2 players joined so far.
Request Body:
{
"serverToken": "some-server-token"
}Success Response (200):
{
"success": true,
"endsAt": "2025-11-21T12:08:00Z"
}Ends the current match stored in memory if currently active. Requires the server token.
Request Body:
{
"serverToken": "some-server-token"
}Success Response (200):
{
"success": true,
"match": {
"matchId": "a1b2c3d4e5f6...",
"status": "ended",
"createdAt": "2025-11-21T12:00:00Z",
"matchType": "timed",
"duration": 420,
"matchPlanes": [
"plane-id-1",
"plane-id-2"
],
...
},
"results": {
"winners": ["plane-uuid-2"],
"scores": [
{
"planeId": "plane-uuid-1",
"playerName": "Foxtrot-4",
"hits": 2,
"hitsTaken": 4,
"isDisqualified": false,
"isWinner": false
},
{
"planeId": "plane-uuid-2",
"playerName": "Delta-7",
"hits": 4,
"hitsTaken": 2,
"isDisqualified": false,
"isWinner": true
}
]
}
}Kicks or disqualifies a plane from the match. Can only be called by the Electron app, as enforced by the server token. If the current match's status is "waiting", it kicks the plane. If the current match's status is "active", it disqualifies it, which is the same thing as kicking it but makes a match event log of it. This admin control can be useful to get rid of cheating players, faked planes from hackers, disqualifying crashed planes, etc.
Request Body:
{
"planeId": "uuid-of-this-plane",
"serverToken": "some-server-token"
}Success Response (200):
{
"success": true
}ESP32 reports a hit event. Validates planeId and targetId are associated
with the current match and are not identical, adds a new "hit" Event to the
match's events list, and updates the Plane for the target plane with the new
hit count. Can only be called by the ESP32s, as enforced by the auth token.
Request Body:
{
"authToken": "some-authentication-token",
"planeId": "uuid-of-this-plane",
"targetId": "uuid-of-hit-plane"
}Success Response (200):
{
"success": true
}Returns a list of all registered planes, including their current match status and scores if applicable.
Authentication: None required (public endpoint)
Request: No parameters required
Success Response (200):
[
{
"planeId": "uuid-of-plane",
"userId": "uuid-of-user",
"esp32Ip": "192.168.1.101",
"playerName": "Foxtrot-4",
"registeredAt": "2025-11-28T12:00:00Z",
"hits": 3,
"hitsTaken": 1,
"isOnline": true,
"isJoined": true,
"isDisqualified": false
}
]Response Fields:
planeId- Unique identifier for the planeuserId- User account linked to this plane (nullable)esp32Ip- IP address of the plane's ESP32 (nullable)playerName- Display name for the player (nullable until joined)registeredAt- Timestamp when plane first registeredhits- Number of successful hits scored (default: 0)hitsTaken- Number of hits received (default: 0)isOnline- Whether the plane is currently connectedisJoined- Whether the plane has joined the current matchisDisqualified- Whether the plane has been disqualified
Notes:
- Returns all registered planes regardless of online/match status
- Sensitive data like auth tokens is excluded from response
- Scores (
hits,hitsTaken) only meaningful during active matches - Planes persist in memory until the server restarts
Returns the current match state including all match events, current status, joined plane IDs, and more. Useful for debugging.
Authentication: None required (public endpoint)
Request: No parameters required
Success Response (200):
{
"matchId": "a1b2c3d4e5f6...",
"status": "waiting",
"createdAt": "2025-11-21T12:00:00Z",
"matchType": "timed",
"duration": 420,
"matchPlanes": [
"plane-id-1",
"plane-id-2"
],
"maxPlayers": 2,
"serverUrl": "http://aeroduel.local:45045",
"wsUrl": "ws://aeroduel.local:45046",
"events": [
{
"type": "join",
"planeId": "plane-id-1",
"timestamp": "2025-11-21T12:01:00Z"
},
{
"type": "join",
"planeId": "plane-id-2",
"timestamp": "2025-11-21T12:02:00Z"
}
]
}Response Fields:
matchId- Unique identifier for this match instancestatus- Current match state: "waiting" (lobby), "active" (in progress), or "ended"createdAt- ISO timestamp when match was createdmatchType- Type of game mode (currently only "timed" bu may support more in future)duration- Match duration in secondsmatchPlanes- Array of planeIds that have joined this matchmaxPlayers- Maximum number of players allowed to joinserverUrl- HTTP URL for API endpointswsUrl- WebSocket URL for real-time updatesevents- Array of match events (joins, leaves, hits, etc.)
This is a joke endpoint that does not fire any shots; instead, it returns error 418: "I'm a Teapot."
Request Body:
{
"planeId": "uuid-of-this-plane",
"targetId": "uuid-of-hit-plane"
}Success Response (200): Impossible
Error Responses:
400 - Invalid JSON
{
"error": "Invalid JSON"
}418 - I'm a Teapot
{
"error": "I'm a server, not a fighter jet. You expect ME to fire at that plane? That's like asking a teapot to brew coffee!"
}Real-time communication for match updates.
This section has been moved to docs/WebSockets.md.
interface MatchState {
matchId: string;
status: "waiting" | "active" | "ended";
createdAt: Date;
matchType: "timed"; // future proof; may have multiple game modes in future
duration: number; // match duration in seconds
matchPlanes: string[]; // planeIds of planes that have joined the match
maxPlayers: number;
serverUrl: string;
wsUrl: string;
events: Event[];
}interface Plane {
/* Registration info */
esp32Ip?: string;
planeId: string;
userId: string;
icon: "BLACK" | "WHITE";
playerName?: string;
registeredAt: Date;
/* Match info */
hits?: number;
hitsTaken?: number;
/* Misc booleans */
isOnline: boolean;
isJoined: boolean;
isDisqualified: boolean;
}interface Event {
type: "join" | "leave" | "hit" | "disqualify";
planeId: string;
targetId?: string; // for hit events
timestamp: Date;
}- Create Match - Desktop app calls
/api/new-match - Registration - Players click the join button in the mobile app, which calls
/api/join-match - Start Match - When ready, desktop app calls
/api/start-match - Gameplay - ESP32s report hits via
/api/hit, server broadcasts updates via WebSocket - Admin Controls - In the event of a crash or cheating player, the desktop app can disqualify a player. It can also end a match early. Potential future controls are pausing a match, adjusting scores, and changing the time left.
- End Match - Timer expires, and the server calculates the winner and broadcasts results
- Each hit on another plane: +1 point
- Getting hit: no penalty (unless there's a tie)
- Winner: the plane with the most points when timer expires. If there's a tie, the plane that's taken the fewest hits wins. If there's still a tie, it's a draw.
All endpoints follow this error format:
{
"error": "Human-readable error message"
}Common HTTP status codes:
200- Success400- Bad Request (invalid input)401- Unauthenticated (missing or invalid auth token)403- Forbidden (Not authorized to perform this action from this device)404- Not Found409- Conflict (e.g., match already exists)410- Gone (e.g., match already ended)418- I'm a Teapot (not a coffee machine)500- Server Error
- Desktop Server, Mobile Apps, and ESP32s must be on the same WiFi network. This can be a mobile hotspot, so there's no need to bring a WiFi router to the airfield.
- Server auto-detects local IP address and publishes it as aeroduel.local via mDNS. Ensure mDNS is not blocked by your firewall.
- Default port:
45045(configurable viaPORTenv variable). If you change this, the ESP32s and mobile app must also be programmed to know the new port.
Server-only endpoints: Only the server's front-end can make requests to this endpoint via Electron app, and this is enforced.
Mobile-only endpoints: Only the mobile app can make requests to this endpoint, and this is enforced.
Arduino-only endpoints: Only the ESP32s can make requests to this endpoint, and this is enforced.
-
POST /api/new-match– Creates a new Aeroduel match in waiting state- Server-only endpoint
- Only one match allowed at a time (returns 409 if match already exists)
- INPUT:
{ serverToken, duration?, maxPlayers? } - OUTPUT:
{ success, match }(matchincludesmatchId, URLs, local IP, etc.)
-
POST /api/register– Registers that a plane is online, but doesn't associate it with the current match yet- Called by the plane's ESP32 once it is on Wi‑Fi
- Returns a per‑session auth token for that
planeId(used for plane‑specific requests) - INPUT:
{ planeId, esp32Ip?, userId? } - OUTPUT:
{ success, authToken, matchId } - Note: Since this is the endpoint that assigns the auth token used in arduino-only endpoints, this is not an arduino-only endpoint as that is impossible without a pre-existing auth token. It is a chicken or egg problem. The solution to this security issue is that the user running the server can kick or disconnect unknown "planes" from the system.
-
POST /api/join-match– Adds a plane to the current match's waiting room for a specific player- Called by the mobile app when a player hits the "join match" button
- Assigns an auth token for the mobile app for the duration of the match
- INPUT:
{ planeId, userId, playerName } - OUTPUT:
{ success, authToken, matchId } - Note: Since this is the endpoint that assigns the auth token used in mobile-only endpoints, this is not a mobile-only endpoint as that is impossible without a pre-existing auth token. It's yet again a chicken or egg problem. The solution to this security issue is that the user running the server can kick or disconnect unknown users from the match.
-
POST /api/start-match- Begins an Aeroduel match- Server-only endpoint
- Updates the match in memory to be active and sends WebSocket updates to ESP32s and mobile apps
- Only the sever's front-end can make requests to this endpoint, and this is enforced
- INPUT:
{ serverToken } - OUTPUT:
{ success, endsAt }
-
POST /api/end-match- Ends an Aeroduel match- Server-only endpoint
- Called by the server app when the match timer expires
- Updates the match in memory to be ended and sends WebSocket updates to ESP32s and mobile apps
- Only the sever's front-end can make requests to this endpoint, and this is enforced
- INPUT:
{ serverToken } - OUTPUT:
{ success, match, results }
-
POST /api/kick- Kicks or disqualifies a plane from the match- Server-only endpoint
- Called by the server app when a player hits the "kick" or "disqualify" button either while waiting for players to join or during the match
- INPUT:
{ planeId, serverToken } - OUTPUT:
{ success }
-
POST /api/hit- Registers a hit during the match- Arduino-only endpoint
- Called by the ESP32s when a plane is shot by another plane
- Only the ESP32s should make requests to this endpoint, as enforced by the auth token
- INPUT:
{ authToken, planeId, targetId } - OUTPUT:
{ success }
-
GET /api/planes- List all online planes- Public endpoint
- Returns a list of all online planes, including if they have joined the current match and their current score if the match is ongoing
- Anyone can make a request to this endpoint.
- INPUT: none
- OUTPUT:
[{ planeId, userId?, esp32Ip?, playerName?, registeredAt, hits, hitsTaken, isOnline, isJoined, isDisqualified }]
-
GET /api/match- Get current match state- Public endpoint
- Returns the current match state including all match events, current status, joined plane IDs, and more.
- Anyone can make a request to this endpoint.
- INPUT: none
- OUTPUT:
{ matchId, status, createdAt, matchType, duration, maxPlayers, serverUrl, wsUrl, localIp, matchPlanes, events }
-
POST /api/fire- Does not fire any shots and instead returns error 418: "I'm a Teapot"- Public endpoint
- This is a joke endpoint.
- INPUT:
{ planeId, targetId } - OUTPUT:
{ error: "I'm a server, not a fighter jet. You expect ME to fire at that plane? That's like asking a teapot to brew coffee!" }
POST /api/sign-in- registers that a user is online and issues a mobile-only auth tokenPOST /api/sign-out- registers that a user is offline and revokes the mobile-only auth token (mobile-only endpoint)POST /api/blacklist- blacklist a userId or planeId from joining matches until the server restarts (server-only endpoint)GET /api/match/:id/events- Get match events such as hits
- Currently supports one active match at a time
- Match state stored in-memory (resets on server restart)
- Database persistence possibly coming in future release
Documentation Created: November 21, 2025
Last Updated: December 9, 2025