Lucid Programming Competition

LPC 2026

Collect resources, build bases, and construct workers. Your program sends one action per worker each turn, then receives the new state of the world. The team with the most gold in its bank after 480 turns wins.

Download help as Markdown Back to match page

Your team

You start with one base, one worker nearby, and 15 kg of iron in your bank. All your workers and bases share that bank.

The default map is 50 by 50 tiles. Choose an action for each worker every turn.

A base, a worker, and a nearby gold deposit. Team bank: 0 kg gold, 15 kg iron

Move

Move one tile in any of eight directions, including diagonals.

Entering a mountain or crater puts the worker on cooldown for 2 additional turns. Actions will be accepted but not executed during this travel time.

Before

After

The worker waits two more turns before it can act.

A mountain move includes entry and two additional turns of travel.

Mine

Stand on a gold or iron deposit to mine it. One mine action moves 1 kg into the worker's inventory. A worker can carry 8 kg in total.

Before

Deposit: 6 kg goldWorker: 0 kg gold

After

Deposit: 5 kg goldWorker: 1 kg gold

One mining action takes 1 kg from the deposit.

Deposit

Move onto one of your bases, then deposit. This moves all the worker's gold and iron into your shared bank.

Only banked gold counts toward your score.

Before

Worker: 4 kg gold, 2 kg ironBank: 0 kg gold, 15 kg iron

After

Worker: 0 kg gold, 0 kg ironBank: 4 kg gold, 17 kg iron

Every friendly base connects to the same bank.

Trade

A worker can trade 3 kg of carried iron for 1 kg of gold. It can also trade 3 kg of carried gold for 1 kg of iron.

Trade wherever the worker stands. It uses the worker's inventory, not your bank.

3 kg of carried iron trades for 1 kg of gold.

Build a worker

Build a worker on the acting worker's tile. You do not need a base.

The first new worker costs 5 kg of banked iron. The next costs 6 kg, then 11, 17, 28, and so on. Each price is the sum of the previous two.

Before

Bank: 15 kg iron

After

Bank: 10 kg iron

The new worker starts on the same tile.

Build a base

A worker can build a base for 6 kg of banked iron. Stand alone on a plain tile with no base or resource deposit, outside enemy territory.

The new base gives your workers another place to deposit resources and claims nearby ore.

Before

Bank: 15 kg iron

After

Bank: 9 kg iron

The builder stays on the new base.

Pass

Do nothing for this turn.

The worker stays in place and keeps its inventory.

More about the rules

PvP practice

Run your bot to join an assigned match or, when enabled, the PvP waiting queue. Keep it running while you wait. Empty seats are filled with practice bots after the waiting period.

If your bot disconnects during a match, restart it to resume. After the match ends, run it again to join another match. Shared PvP matches cannot be aborted from contestant setup.

Claim nearby ore

A base claims ore within Manhattan distance 2. Your workers can mine ore in your territory or in unclaimed territory.

All teams that claim a deposit can mine it. Claims do not restrict movement.

One base

Four bases

Territories can overlap.

Explore the map

Your workers and bases reveal tiles within Manhattan distance 3. Mountains and craters do not block vision.

Manhattan distance counts horizontal and vertical steps. A diagonal tile is two steps away. Other teams' inventories and bank balances stay private.

Tiles within Manhattan distance 3 are visible.

Find gold and iron

Gold deposits outside the center start with 6 to 9 kg. Center deposits hold 12 to 18 kg. Iron deposits hold 2 to 4 kg. The number on a deposit is the amount left in kilograms.

When a deposit runs out, a new deposit of the same resource appears on an empty tile next turn if there is room.

The number on each deposit is its remaining ore in kilograms.

The rich center

The default map has a 16 by 16 center with richer gold deposits. It starts with 35% of the map's gold deposits.

Each new gold deposit has a 35% chance to appear in the center. It can appear there even if the old deposit was outside it. If the chosen area is full, the new deposit goes in the other area. If no empty tile is available, it waits. Match settings can change the amounts and map size shown here.

A small example map with a 3 by 3 rich center. The default map is 50 by 50 with a 16 by 16 center.

Conflicts and resolution

When workers want the same ore

Several workers can mine the same deposit in one turn. If there is not enough ore for everyone, the worker with the longest current run of eligible mine actions on that tile mines first. Next comes the worker that has stood on the tile longest. A stable random order breaks any remaining tie.

A worker keeps its mining run if it tries to mine but another worker takes the last ore. Passing, choosing another action, or trying to mine when the worker is not eligible ends the run. Leaving the tile resets both the mining run and time spent standing there.

What happens during a turn

Each worker performs only its submitted action. The game handles movement before building, mining, trading, and depositing. A new base can claim ore before workers mine that turn. Iron deposited this turn can pay for construction on a later turn.

The match seed chooses an initial team order for construction. After each turn, the first team moves to the back. Teams keep their place even when they do not build. When opposing base sites are within Manhattan distance 2, the earlier team builds first.

Within your team, worker and base construction spend bank resources in the order the server accepted the actions. Worker ID breaks equal acceptance times. Failed construction spends nothing.

  1. Replace depleted deposits that are due to return.
  2. Move workers.
  3. Build workers and bases.
  4. Mine ore.
  5. Trade carried resources.
  6. Deposit carried resources.

When teams finish with the same gold

The team with more banked gold places higher. If banked gold is equal, compare banked iron, carried gold, carried iron, base count, then worker count. Use each value only if the previous values are equal.

Teams still tied share a place. Two teams sharing first place means the next team is third.

Set up your bot

Choose a language. Download its files and follow the steps in that card.

Create a practice match

Log in on the match page with your team key. Choose a seed and opponents, then select Start game. Zero opponents lets you test alone. Download all the files for your language below and run the command for your shell.

The starter connects to your current match. It does not create one. For ranked play, the organizers assign your lobby. Run one client per team and restart it for each new match.

  1. Edit the strategy in your chosen language. The starter passes for each of your workers each turn. Write your strategy in the strategy function.
  2. Watch your workers in the browser. After the match finishes, select it under Replay or current match.

JavaScript

Install Node.js 22.14 or newer. Check with node --version.

  1. Save bot_template.js, bot_client.js, and package.json in the same folder.
  2. Write your strategy in calculateActions in bot_template.js. Use identity.teamId to find your workers.
  3. Game helpers are below the helper banner. Give state helpers the LPC view and give pathTo its map. The starter includes editor types for state, actions, and helpers.
Command shell

Run command

API_KEY='your-team-key' LUCID_API_BASE_URL='https://lpc.sh' npm start
View bot_template.js

bot_template.js

#!/usr/bin/env node

import { pathToFileURL } from 'node:url';
import { runBot, isMarsView } from './bot_client.js';

/** @import {BotState, Identity, ActionsByEntity, Coordinate, CoordinateGrid, TerritoryState, MovementState, BaseLocation, Resource} from './bot_client.js' */

/** @param {BotState} state @param {Identity} identity @returns {ActionsByEntity} */
export function calculateActions(state, identity) {
  /** @type {ActionsByEntity} */
  const actions = {};
  if (!isMarsView(state.view)) return actions;

  for (const worker of state.view.units) {
    if (worker.teamId !== identity.teamId) continue;

    // Strategy goes here.

    actions[worker.unitId] = { type: 'pass' };
  }

  return actions;
}

// Game helpers. Feel free to use, discard, or modify.
// Fill in the strategy section above.
/** @param {CoordinateGrid} map @param {Coordinate} p */
function cellAt(map, p) {
  if (
    !Number.isInteger(p.x) ||
    !Number.isInteger(p.y) ||
    p.x < 0 ||
    p.y < 0 ||
    p.x >= map.width ||
    p.y >= map.height
  )
    return undefined;

  const rowStart = p.y * map.width;
  return map.cells[rowStart + p.x];
}
/** All visible claims; overlapping territories can belong to more than one team.
 * @param {TerritoryState} state @param {Coordinate} p */
export function isClaimed(state, p) {
  return [
    ...new Set(
      state.bases
        .filter((base) => Math.abs(base.position.x - p.x) + Math.abs(base.position.y - p.y) <= 2)
        .map((base) => base.teamId)
    )
  ];
}
/** @param {TerritoryState} state @param {Coordinate} p */
export function isBase(state, p) {
  return (
    state.bases.find((base) => base.position.x === p.x && base.position.y === p.y)?.teamId ?? null
  );
}
/** @param {{map: CoordinateGrid}} state @param {Coordinate} p @returns {Resource | null} */
export function isOre(state, p) {
  const cell = cellAt(state.map, p);
  return cell && (cell.type === 'gold' || cell.type === 'iron') && (cell.amount ?? 0) > 0
    ? cell.type
    : null;
}
/** Unknown cells are excluded. Obstacles are traversable; workers do not block.
 * @param {MovementState} state @param {Coordinate} p @param {string} [teamId] */
export function canMoveOnto(state, p, teamId) {
  const cell = cellAt(state.map, p);
  const base = isBase(state, p);
  return !!cell && !['hidden', 'void'].includes(cell.type) && (base === null || base === teamId);
}
/** Lowest visible cost until ready at the destination. Start must be ready; null means no route.
 * @param {CoordinateGrid} map
 * @param {Coordinate} start
 * @param {Coordinate} end
 * @param {{bases?: readonly BaseLocation[], teamId?: string, obstacleCost?: number}} [options]
 * @returns {Coordinate[] | null} */
export function pathTo(map, start, end, options = {}) {
  const obstacleCost = options.obstacleCost ?? 3;
  if (!Number.isFinite(obstacleCost) || obstacleCost < 1) return null;
  const state = { map, bases: options.bases ?? [] };
  if (!canMoveOnto(state, start, options.teamId) || !canMoveOnto(state, end, options.teamId))
    return null;
  const key = (/** @type {Coordinate} */ point) => `${point.x},${point.y}`;
  const distance = new Map([[key(start), 0]]);
  /** @type {Map<string, Coordinate>} */
  const previous = new Map();
  const queue = [start];
  while (queue.length > 0) {
    queue.sort((a, b) => (distance.get(key(a)) ?? Infinity) - (distance.get(key(b)) ?? Infinity));
    const current = queue.shift();
    if (!current) break;
    if (key(current) === key(end)) break;
    for (let dy = -1; dy <= 1; dy++) {
      for (let dx = -1; dx <= 1; dx++) {
        if (dx === 0 && dy === 0) continue;
        const next = { x: current.x + dx, y: current.y + dy };
        if (!canMoveOnto(state, next, options.teamId)) continue;
        const terrain = cellAt(map, next)?.type;
        const stepCost = terrain === 'mountain' || terrain === 'crater' ? obstacleCost : 1;
        const cost = (distance.get(key(current)) ?? Infinity) + stepCost;
        if (cost >= (distance.get(key(next)) ?? Infinity)) continue;
        distance.set(key(next), cost);
        previous.set(key(next), current);
        if (!queue.some((point) => key(point) === key(next))) queue.push(next);
      }
    }
  }
  if (!distance.has(key(end))) return null;
  const path = [];
  let current = end;
  while (key(current) !== key(start)) {
    path.push(current);
    const parent = previous.get(key(current));
    if (!parent) return null;
    current = parent;
  }
  return path.reverse();
}

if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
  await runBot(calculateActions).catch((error) => {
    console.error(error instanceof Error ? error.message : String(error));
    process.exitCode = 1;
  });
}
View bot_client.js

bot_client.js

import { randomUUID } from 'node:crypto';

/** @typedef {{x: number, y: number}} Coordinate */
/** @typedef {'gold' | 'iron'} Resource */
/** @typedef {{gold: number, iron: number}} Inventory */
/** @typedef {'north' | 'east' | 'south' | 'west' | 'north-east' | 'south-east' | 'south-west' | 'north-west'} Direction */
/** @typedef {'Mercury' | 'Venus' | 'Earth' | 'Mars' | 'Jupiter' | 'Saturn' | 'Uranus' | 'Neptune' | 'Pluto'} PlanetName */
/** @typedef {{type: 'empty' | 'mountain' | 'hidden' | 'void'} | {type: 'crater', formation?: {x: number, y: number, size: number}} | {type: 'gold' | 'iron', amount: number} | {type: 'unit', unitId: string, teamId: string}} MapCell */
/** @typedef {{width: number, height: number, cells: MapCell[]}} GameMap */
/** @typedef {{width: number, height: number, cells: readonly {type: string, amount?: number}[]}} CoordinateGrid */
/** @typedef {{teamId: string, position: Coordinate}} BaseLocation */
/** @typedef {BaseLocation & {baseId: string}} Base */
/** @typedef {{bases: readonly BaseLocation[]}} TerritoryState */
/** @typedef {TerritoryState & {map: CoordinateGrid}} MovementState */
/** @typedef {{unitId: string, teamId: string, position: Coordinate, inventory?: Inventory, inventoryCapacity: number, readyOnTurn: number}} Unit */
/** @typedef {{entityId: string, error: 'movement_interrupted', note: string}} Notice */
/** @typedef {{type: 'bonk', coordinate: Coordinate, entityIds: string[], note: string} | {type: 'resource_deposit', coordinate: Coordinate, baseId: string, resource: Resource, amount: number, total: number}} Effect */
/** @typedef {{width: number, height: number, min: number, max: number, spawnPercent: number}} HighGoldSettings */
/** @typedef {{turn: number, map: GameMap, units: Unit[], bases: Base[], scores: Record<string, number>, teamResources: Record<string, Inventory>, baseResourceDeposits?: Record<string, Inventory>, highGoldArea: HighGoldSettings, warnings: Notice[], info: Notice[], effects: Effect[]}} MarsView */
/** @typedef {{name: PlanetName, position: Coordinate, orbitRadius: number}} SolarPlanet */
/** @typedef {{teamId: string, position: Coordinate, previousPosition: Coordinate, heading: number, target: PlanetName | null, targetPosition: Coordinate | null, orbit: {planet: PlanetName, angle: number} | null}} SolarRocket */
/** @typedef {{turn: number, planets: SolarPlanet[], rockets: SolarRocket[], entityId?: string}} SolarView */
/** @typedef {{type: 'move', direction: Direction} | {type: 'mine' | 'pass' | 'deposit' | 'spawn' | 'build-base'} | {type: 'trade', from: Resource}} MarsAction */
/** @typedef {{type: PlanetName} | {type: 'move', x: number, y: number}} SolarAction */
/** @typedef {MarsAction | SolarAction} Action */

/** @param {MarsView | SolarView | undefined} view @returns {view is MarsView} */
export function isMarsView(view) {
  return view !== undefined && 'units' in view;
}

/** @param {MarsView | SolarView | undefined} view @returns {view is SolarView} */
export function isSolarView(view) {
  return view !== undefined && 'rockets' in view;
}

/**
 * @typedef {Object} TeamDescriptor
 * @property {string} id
 * @property {string} displayName
 * @property {false | string} botType
 */

/**
 * @typedef {Object} MatchSummary
 * @property {string} [cancellationReason]
 * @property {TeamDescriptor[]} teams
 * @property {string} matchId
 * @property {string} status
 * @property {number} turn
 * @property {number} maxTurns
 * @property {string} [turnAdvanceMode]
 * @property {string} [rulesVersion]
 */

/**
 * @typedef {Object} BotState
 * @property {MatchSummary} match
 * @property {Record<string, number>} [actionRevisions]
 * @property {MarsView | SolarView} [view]
 * @property {number} serverTimeMs
 * @property {number} [turnEndsAtMs]
 */

/**
 * @typedef {Object} Identity
 * @property {string} teamId
 * @property {string} [displayName]
 */

/** @typedef {Record<string, Action>} ActionsByEntity */

/**
 * @typedef {Object} TurnEvent
 * @property {number} turn
 * @property {MarsView | SolarView} view
 * @property {number} serverTimeMs
 * @property {number} [turnEndsAtMs]
 */

/**
 * @typedef {Object} MatchStartingEvent
 * @property {string} message
 * @property {MatchSummary} match
 * @property {number} serverTimeMs
 * @property {number} startsAtMs
 */

/**
 * @typedef {(
 *   | { name: 'turn', data: TurnEvent }
 *   | { name: 'match_starting', data: MatchStartingEvent }
 *   | { name: 'match_ended', data: { match: MatchSummary, serverTimeMs: number } }
 *   | { name: 'message', data: unknown }
 * )} ServerEvent
 */

const API_BASE_URL = process.env.LUCID_API_BASE_URL ?? 'http://localhost:5173';
const API_KEY = process.env.API_KEY ?? 'replace-with-api-key';
const USING_DEFAULT_URL = process.env.LUCID_API_BASE_URL === undefined;
/** @type {string | undefined} */
let botSessionToken;
/** @type {string | undefined} */
let botSessionRevision;
/** @type {string | undefined} */
let botSessionMatchId;

class ApiError extends Error {
  /** @param {number} status @param {string} reason @param {string} body */
  constructor(status, reason, body) {
    let message = `${status} ${reason}`;
    try {
      message = JSON.parse(body).error?.message ?? message;
    } catch {
      /* Use the HTTP status when the server did not send JSON. */
    }
    if (status === 401 || status === 403) {
      message =
        message === 'Team is not in an active match.'
          ? 'No current match. Join a match on the website, then run the bot again.'
          : 'Invalid API key. Set API_KEY to the key from your team page.';
    }
    if (status === 404)
      message = `Cannot reach the bot API at ${API_BASE_URL}. Check LUCID_API_BASE_URL.`;
    super(message);
    this.status = status;
    this.body = body;
  }
}

/** @param {unknown} error */
function isPvpDisabled(error) {
  if (!(error instanceof ApiError) || error.status !== 409) return false;
  try {
    return JSON.parse(error.body)?.error?.code === 'pvp_disabled';
  } catch {
    return false;
  }
}

/** @param {unknown} error */
function isMatchUnavailable(error) {
  if (!(error instanceof ApiError) || error.status !== 403) return false;
  try {
    const detail = JSON.parse(error.body).error;
    return detail?.code === 'forbidden' && detail.message === 'Team is not in an active match.';
  } catch {
    return false;
  }
}

/** @param {unknown} error */
function isAuthError(error) {
  if (!(error instanceof ApiError)) return false;
  if (error.status === 401 || error.status === 403) return true;
  if (error.status !== 409) return false;
  try {
    return JSON.parse(error.body).error?.code === 'bot_session_conflict';
  } catch {
    return false;
  }
}

/** @param {BotState} state */
function isTerminal(state) {
  return state.match.status === 'completed' || state.match.status === 'cancelled';
}

const REQUEST_TIMEOUT_MS = 5000;
const STREAM_IDLE_TIMEOUT_MS = 10000;
const MAX_ATTEMPTS = 3;
const RETRY_DELAY_MS = 250;

/** @param {unknown} error */
function isTransient(error) {
  return error instanceof ApiError
    ? [408, 429, 500, 502, 503, 504].includes(error.status)
    : error instanceof TypeError ||
        (error instanceof Error && ['AbortError', 'TimeoutError'].includes(error.name));
}

/** @param {string} path @param {RequestInit} [options] */
async function request(path, options = {}) {
  const method = options.method ?? 'GET';
  const keyedWrite =
    method === 'PUT' &&
    !path.includes('/current/') &&
    typeof options.body === 'string' &&
    !!JSON.parse(options.body).submissionId;
  const headers = new Headers(options.headers);
  const boundRegistration =
    path === '/api/team/matches/current/register' &&
    headers.has('x-bot-match-id') &&
    headers.has('x-bot-session');
  const attempts =
    boundRegistration || method === 'GET' || path === '/api/auth/api-key' || keyedWrite
      ? MAX_ATTEMPTS
      : 1;
  let recoveredSession = false;
  for (let attempt = 0; attempt < attempts; attempt++) {
    const controller = new AbortController();
    const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
    try {
      const response = await fetch(`${API_BASE_URL}${path}`, {
        ...options,
        signal: controller.signal,
        headers: {
          'x-api-key': API_KEY,
          'content-type': 'application/json',
          ...(botSessionToken ? { 'x-bot-session': botSessionToken } : {}),
          ...options.headers
        }
      });
      if (!response.ok) {
        const body = await response.text();
        let missingSession = false;
        try { missingSession = JSON.parse(body).error?.code === 'bot_session_missing'; } catch { /* non-JSON error */ }
        if (missingSession && !recoveredSession && botSessionToken && botSessionMatchId && path !== '/api/team/matches/current/register') {
          recoveredSession = true;
          await request('/api/team/matches/current/register', {
            method: 'POST', headers: { 'x-bot-match-id': botSessionMatchId, 'x-bot-session': botSessionToken, 'x-bot-session-recover': '1' }
          });
          attempt--;
          continue;
        }
        throw new ApiError(response.status, response.statusText, body);
      }
      const revision = response.headers.get('x-bot-session-revision');
      if (revision) botSessionRevision = revision;
      if (path === '/api/team/matches/current/register')
        botSessionToken = response.headers.get('x-bot-session') ?? undefined;
      try {
        return await response.json();
      } catch (error) {
        if (error instanceof SyntaxError)
          throw new Error(
            `The server at ${API_BASE_URL} did not return bot API JSON. Check LUCID_API_BASE_URL.`,
            { cause: error }
          );
        throw error;
      }
    } catch (error) {
      if (!isTransient(error)) throw error;
      if (attempt + 1 === attempts) {
        throw new Error(
          `Cannot reach ${API_BASE_URL} after ${attempts} attempt(s). Check LUCID_API_BASE_URL and your connection.`,
          { cause: error }
        );
      }
    } finally {
      clearTimeout(timeout);
    }
    await sleep(RETRY_DELAY_MS * 2 ** attempt);
  }
}

async function getServerTime() {
  const state = await request('/api/team/matches/current/state');
  return state.serverTimeMs;
}

/** @param {string} [matchId] @returns {Promise<BotState>} */
async function getCurrentState(matchId) {
  return request(`/api/team/matches/${matchId ? encodeURIComponent(matchId) : 'current'}/state`);
}

/** @returns {Promise<Identity>} */
async function validateApiKey() {
  if (!API_KEY.trim() || API_KEY === 'replace-with-api-key')
    throw new Error('Set API_KEY to the key from your team page before running the bot.');
  return request('/api/auth/api-key', {
    method: 'POST'
  });
}
/** @returns {Promise<BotState>} */
async function registerTeamToMatch() {
  botSessionToken = undefined;
  let selected;
  try {
    selected = await getCurrentState();
  } catch (error) {
    if (!isMatchUnavailable(error)) throw error;
    let announced = '';
    for (;;) {
      const response = await request('/api/team/matches/current/register', {method:'POST'});
      if ('match' in response) {
        selected = await getCurrentState();
        break;
      }
      if (!('queue' in response)) throw new Error('Invalid registration response.', {cause:error});
      const waiting = response.queue.autoQueue === false
        ? 'Waiting for a private practice match. Create one on the website; keep this bot running.'
        : 'Waiting in PvP queue; keep this bot running.';
      if (announced !== waiting) { console.log(waiting); announced = waiting; }
      await sleep(1000);
    }
  }
  botSessionMatchId = selected.match.matchId;
  const claimToken = randomUUID();
  const state = await request('/api/team/matches/current/register', {
    method: 'POST',
    headers: { 'x-bot-match-id': selected.match.matchId, 'x-bot-session': claimToken, ...(botSessionRevision ? { 'x-bot-session-revision': botSessionRevision } : {}) }
  });

  console.log(`registered for match ${state.match.matchId}`);

  if (state.match.status === 'pending') {
    console.log('waiting for all teams to join');
    return state;
  }

  console.log('All players joined, starting match');
  return state;
}

/** @param {BotState} state @returns {Promise<BotState>} */
async function waitForMatchStart(state) {
  return waitForProgress(state, true);
}

/** @type {{ matchId: string, events: AsyncGenerator<ServerEvent> } | undefined} */
let eventStream;

async function closeEventStream() {
  const current = eventStream;
  eventStream = undefined;
  await current?.events.return(undefined);
}

/** @param {BotState} state @param {boolean} starting @returns {Promise<BotState>} */
async function waitForProgress(state, starting) {
  for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
    let idle = false;
    try {
      if (eventStream?.matchId !== state.match.matchId) {
        await closeEventStream();
        eventStream = {
          matchId: state.match.matchId,
          events: streamEvents(
            `/api/events/matches/${encodeURIComponent(state.match.matchId)}?afterTurn=${state.match.turn}`
          )
        };
      }
      for (;;) {
        const next = await eventStream.events.next();
        if (next.done) break;
        const event = next.value;
        if (event.name === 'match_ended') {
          await closeEventStream();
          return { ...state, match: event.data.match, serverTimeMs: event.data.serverTimeMs };
        }
        if (event.name === 'match_starting' && event.data.match.turn >= state.match.turn) {
          state = applyMatchStartingEvent(state, event.data);
          if (starting) return state;
        }
        if (event.name === 'turn' && event.data.turn > state.match.turn) {
          const nextState = stateFromTurnEvent(state, event.data);
          if (isTerminal(nextState)) await closeEventStream();
          return nextState;
        }
      }
    } catch (error) {
      await closeEventStream();
      if (!isTransient(error)) throw error;
      idle = error instanceof Error && error.name === 'AbortError';
    }
    await closeEventStream();
    const latest = await getCurrentState(state.match.matchId);
    if (
      isTerminal(latest) ||
      latest.match.turn > state.match.turn ||
      (starting && latest.match.status !== 'pending')
    )
      return latest;
    state = latest;
    // A quiet pending lobby is healthy when its state endpoint still responds.
    if (idle && starting && state.match.status === 'pending') attempt = -1;
    if (attempt + 1 < MAX_ATTEMPTS) await sleep(RETRY_DELAY_MS * 2 ** Math.max(attempt, 0));
  }
  throw new Error(
    `Match ${state.match.matchId} event stream made no progress after ${MAX_ATTEMPTS} attempts.`
  );
}

/** @param {string} path @returns {AsyncGenerator<ServerEvent>} */
async function* streamEvents(path) {
  const controller = new AbortController();
  let timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
  /** @type {ReadableStreamDefaultReader<Uint8Array> | undefined} */
  let reader;
  try {
    const response = await fetch(`${API_BASE_URL}${path}`, {
      signal: controller.signal,
      headers: {
        'x-api-key': API_KEY,
        accept: 'text/event-stream',
        ...(botSessionToken ? { 'x-bot-session': botSessionToken } : {})
      }
    });
    if (!response.ok)
      throw new ApiError(response.status, response.statusText, await response.text());
    if (!response.body) throw new Error('Event stream response did not include a body.');
    reader = response.body.getReader();
    const decoder = new TextDecoder();
    let buffer = '';
    for (;;) {
      clearTimeout(timeout);
      timeout = setTimeout(() => controller.abort(), STREAM_IDLE_TIMEOUT_MS);
      const next = await reader.read();
      clearTimeout(timeout);
      if (next.done) break;
      buffer += decoder.decode(next.value, { stream: true });
      const parsed = readServerSentEvents(buffer);
      buffer = parsed.remaining;
      for (const event of parsed.events) yield event;
    }
    // An unterminated frame after disconnect is incomplete, not a valid event.
  } finally {
    clearTimeout(timeout);
    controller.abort();
    try {
      await reader?.cancel();
    } catch {
      /* Preserve the original transport/parse error. */
    }
    reader?.releaseLock();
  }
}

/** @param {string} buffer */
function readServerSentEvents(buffer) {
  let remaining = buffer;
  const events = [];
  let boundary = /\r?\n\r?\n/.exec(remaining);

  while (boundary) {
    const rawEvent = remaining.slice(0, boundary.index);
    remaining = remaining.slice(boundary.index + boundary[0].length);
    const event = parseServerSentEvent(rawEvent);

    if (event) {
      events.push(event);
    }

    boundary = /\r?\n\r?\n/.exec(remaining);
  }

  return {
    events,
    remaining
  };
}

/** @param {string} rawEvent @returns {ServerEvent | null} */
function parseServerSentEvent(rawEvent) {
  let name = 'message';
  const dataLines = [];

  for (const line of rawEvent.split(/\r?\n/)) {
    if (!line || line.startsWith(':')) {
      continue;
    }

    if (line.startsWith('event:')) {
      name = line.slice('event:'.length).trim();
    } else if (line.startsWith('data:')) {
      dataLines.push(line.slice('data:'.length).trim());
    }
  }

  if (dataLines.length === 0) {
    return null;
  }

  return /** @type {ServerEvent} */ ({
    name,
    data: JSON.parse(dataLines.join('\n'))
  });
}

/** @param {string} entityId @param {number} revision @param {unknown} action */
async function replaceEntityAction(entityId, revision, action) {
  return request(`/api/team/matches/current/entities/${encodeURIComponent(entityId)}/action`, {
    method: 'PUT',
    body: JSON.stringify({ submissionId: randomUUID(), revision, action })
  });
}

/** @param {ActionsByEntity} actionsByEntityId @param {Map<string, number>} revisions @param {string} [matchId] */
async function replaceActions(actionsByEntityId, revisions, matchId) {
  const submissions = Object.entries(actionsByEntityId).map(([entityId, action]) => {
    const revision = (revisions.get(entityId) ?? 0) + 1;
    revisions.set(entityId, revision);

    return {
      entityId,
      revision,
      action
    };
  });

  return request(
    matchId
      ? `/api/team/matches/${encodeURIComponent(matchId)}/action`
      : '/api/team/matches/current/actions',
    {
      method: 'PUT',
      body: JSON.stringify({ submissionId: randomUUID(), submissions })
    }
  );
}

/** @param {string} [matchId] */
async function advanceActions(matchId) {
  return request(
    matchId
      ? `/api/team/matches/${encodeURIComponent(matchId)}/action`
      : '/api/team/matches/current/actions',
    {
      method: 'PUT',
      body: JSON.stringify({ submissionId: randomUUID(), advanceOnly: true })
    }
  );
}

/** @param {BotState} state @returns {Promise<BotState>} */
async function waitForNextTurn(state) {
  return waitForProgress(state, false);
}

/** @param {BotState} state @param {MatchStartingEvent} event @returns {BotState} */
function applyMatchStartingEvent(state, event) {
  return {
    ...state,
    match: event.match,
    serverTimeMs: event.serverTimeMs,
    turnEndsAtMs: event.startsAtMs
  };
}

/** @param {BotState} state @param {TurnEvent} event @returns {BotState} */
function stateFromTurnEvent(state, event) {
  return {
    match: {
      ...state.match,
      status: event.turn >= state.match.maxTurns ? 'completed' : 'active',
      turn: event.turn
    },
    view: event.view,
    serverTimeMs: event.serverTimeMs,
    turnEndsAtMs: event.turnEndsAtMs
  };
}

/** @param {(state: BotState, identity: Identity) => ActionsByEntity | null | undefined | Promise<ActionsByEntity | null | undefined>} [calculateActions] */
export async function runBot(calculateActions = () => ({})) {
  const identity = await validateApiKey();
  if (USING_DEFAULT_URL)
    console.log(`Using ${API_BASE_URL}. Set LUCID_API_BASE_URL to connect to another server.`);
  /** @type {BotState} */
  let state;
  try {
    state = await registerTeamToMatch();
  } catch (error) {
    if (!isPvpDisabled(error)) throw error;
    console.error(
      'No match assigned. PvP practice is currently disabled.\nCreate a practice match on the website or ask an organizer to assign one,\nthen run your bot again.'
    );
    process.exitCode = 1;
    return;
  }
  const revisions = new Map(Object.entries(state.actionRevisions ?? {}));
  // One run belongs to this match, even if another becomes current.

  try {
    if (state.match.status === 'pending') {
      state = await waitForMatchStart(state);
    }

    const isClientActionMatch = state.match.turnAdvanceMode === 'client-action';

    let useLatestState = false;
    let needsRefresh = false;
    while (!isTerminal(state)) {
      try {
        if (needsRefresh) {
          const previousTurn = state.match.turn;
          state = await getCurrentState(state.match.matchId);
          useLatestState = state.match.turn > previousTurn;
          needsRefresh = false;
          if (isTerminal(state)) break;
        }
        if (!isClientActionMatch && !useLatestState) {
          state = await waitForNextTurn(state);
        }
        useLatestState = false;
        if (state.match.status !== 'active') break;
        const receivedAtMs = Date.now();
        /** @type {ActionsByEntity} */
        let actions = {};
        try {
          actions = (await calculateActions(state, identity)) ?? {};
        } catch (error) {
          console.error(
            `Strategy failed on turn ${state.match.turn}: ${error instanceof Error ? error.message : String(error)}. Passing this turn.`
          );
        }
        let late = false;
        try {
          const result = await replaceActions(actions, revisions, state.match.matchId);
          late = (result.accepted ?? []).some(
            (/** @type {{acceptedForTurn: number}} */ action) =>
              action.acceptedForTurn > state.match.turn + 1
          );
          console.log(`submitted turn ${state.match.turn}, actions: ${JSON.stringify(actions)}`);
        } catch (error) {
          if (isMatchUnavailable(error)) {
            state = await getCurrentState(state.match.matchId);
            if (isTerminal(state)) break;
            throw error;
          }
          if (isAuthError(error)) throw error;
          console.error(
            `Could not submit turn ${state.match.turn}: ${error instanceof Error ? error.message : String(error)}. Passing this turn.`
          );
          try {
            await replaceActions({}, revisions, state.match.matchId);
          } catch (fallbackError) {
            if (isAuthError(fallbackError)) throw fallbackError;
            console.error(
              `Could not submit a pass: ${fallbackError instanceof Error ? fallbackError.message : String(fallbackError)}`
            );
            await sleep(RETRY_DELAY_MS);
          }
        }
        late ||=
          state.turnEndsAtMs !== undefined &&
          state.serverTimeMs + Date.now() - receivedAtMs >= state.turnEndsAtMs;
        if (late && !isClientActionMatch) {
          console.warn(
            `Turn ${state.match.turn} missed its deadline; using the newest available state.`
          );
          const latest = await getCurrentState(state.match.matchId);
          useLatestState = latest.match.turn > state.match.turn;
          state = latest;
        } else if (isClientActionMatch) {
          state = await getCurrentState(state.match.matchId);
        }
      } catch (error) {
        if (isAuthError(error)) throw error;
        console.error(
          `Waiting for the match: ${error instanceof Error ? error.message : String(error)}`
        );
        needsRefresh = true;
        await sleep(RETRY_DELAY_MS);
      }
    }
    console.log(state.match.cancellationReason ?? `match ${state.match.matchId} is ${state.match.status}; stopping bot`);
  } finally {
    await closeEventStream();
  }
}

export {
  closeEventStream,
  advanceActions,
  getCurrentState,
  getServerTime,
  registerTeamToMatch,
  replaceEntityAction,
  replaceActions,
  validateApiKey,
  waitForNextTurn
};

/** @param {number} ms */
function sleep(ms) {
  return new Promise((resolve) => setTimeout(resolve, ms));
}
View package.json

package.json

{
  "name": "lpc-bot",
  "private": true,
  "type": "module",
  "scripts": {
    "start": "node bot_template.js",
    "prestart": "node -e \"const [major, minor] = process.versions.node.split('.').map(Number); if (major < 22 || (major === 22 && minor < 14)) { console.error('Use Node.js 22.14 or newer.'); process.exit(1); }\""
  },
  "engines": {
    "node": ">=22.14.0"
  }
}

TypeScript

Install Node.js 22.14 or newer. Check with node --version. Run npm install in the client folder to install the type checker.

  1. Save bot_template.ts, bot_client.ts, package.json, and tsconfig.json in the same folder.
  2. Write your strategy in calculateActions in bot_template.ts. Use import type for types and keep .ts extensions in imports. Use TypeScript syntax that Node can strip. Async strategies are supported.
  3. Game helpers are below the helper banner. They accept gameState.view and its map, or your own objects with the fields each helper needs.
Command shell

Run command

API_KEY='your-team-key' LUCID_API_BASE_URL='https://lpc.sh' npm start

Check types

npm run check
View bot_template.ts

bot_template.ts

import { pathToFileURL } from 'node:url';
import type {
  ActionsByEntity,
  Base,
  Coordinate,
  GameMap,
  GameState,
  Identity,
  Resource
} from './bot_client.ts';
import { runBot } from './bot_client.ts';

export function calculateActions(state: GameState, identity: Identity): ActionsByEntity {
  const actions: ActionsByEntity = {};

  for (const worker of state.view.units) {
    if (worker.teamId !== identity.teamId) continue;

    // Strategy goes here.

    actions[worker.unitId] = { type: 'pass' };
  }

  return actions;
}

// Game helpers. Feel free to use, discard, or modify.
// Fill in the strategy section above.
export type CoordinateGrid = Pick<GameMap, 'width' | 'height'> & {
  cells: readonly { type: string; amount?: number }[];
};
export type TerritoryState = {
  bases: readonly Pick<Base, 'teamId' | 'position'>[];
};
export type MovementState = TerritoryState & { map: CoordinateGrid };

function cellAt(map: CoordinateGrid, p: Coordinate) {
  if (
    !Number.isInteger(p.x) ||
    !Number.isInteger(p.y) ||
    p.x < 0 ||
    p.y < 0 ||
    p.x >= map.width ||
    p.y >= map.height
  )
    return undefined;

  const rowStart = p.y * map.width;
  return map.cells[rowStart + p.x];
}

/** All visible claims; overlapping territories can belong to more than one team. */
export function isClaimed(state: TerritoryState, p: Coordinate): string[] {
  return [
    ...new Set(
      state.bases
        .filter((base) => Math.abs(base.position.x - p.x) + Math.abs(base.position.y - p.y) <= 2)
        .map((base) => base.teamId)
    )
  ];
}

export function isBase(state: TerritoryState, p: Coordinate): string | null {
  return (
    state.bases.find((base) => base.position.x === p.x && base.position.y === p.y)?.teamId ?? null
  );
}

export function isOre(state: Pick<MovementState, 'map'>, p: Coordinate): Resource | null {
  const cell = cellAt(state.map, p);
  return cell && (cell.type === 'gold' || cell.type === 'iron') && (cell.amount ?? 0) > 0
    ? cell.type
    : null;
}

/** Unknown cells are excluded. Obstacles are traversable; workers do not block. */
export function canMoveOnto(state: MovementState, p: Coordinate, teamId?: string): boolean {
  const cell = cellAt(state.map, p);
  const base = isBase(state, p);
  return !!cell && !['hidden', 'void'].includes(cell.type) && (base === null || base === teamId);
}

/** Lowest visible cost until ready at the destination. Start must be ready; null means no route. */
export function pathTo(
  map: CoordinateGrid,
  start: Coordinate,
  end: Coordinate,
  options: Partial<TerritoryState> & {
    teamId?: string;
    obstacleCost?: number;
  } = {}
): Coordinate[] | null {
  const obstacleCost = options.obstacleCost ?? 3;
  if (!Number.isFinite(obstacleCost) || obstacleCost < 1) return null;

  const state = { map, bases: options.bases ?? [] };
  if (!canMoveOnto(state, start, options.teamId) || !canMoveOnto(state, end, options.teamId))
    return null;

  const key = (point: Coordinate) => `${point.x},${point.y}`;
  const distance = new Map<string, number>([[key(start), 0]]);
  const previous = new Map<string, Coordinate>();
  const queue: Coordinate[] = [start];

  while (queue.length > 0) {
    queue.sort((a, b) => (distance.get(key(a)) ?? Infinity) - (distance.get(key(b)) ?? Infinity));

    const current = queue.shift()!;
    if (!current) break;
    if (key(current) === key(end)) break;

    for (let dy = -1; dy <= 1; dy++) {
      for (let dx = -1; dx <= 1; dx++) {
        if (dx === 0 && dy === 0) continue;
        const next = { x: current.x + dx, y: current.y + dy };
        if (!canMoveOnto(state, next, options.teamId)) continue;

        const terrain = cellAt(map, next)?.type;
        const stepCost = terrain === 'mountain' || terrain === 'crater' ? obstacleCost : 1;
        const cost = (distance.get(key(current)) ?? Infinity) + stepCost;
        if (cost >= (distance.get(key(next)) ?? Infinity)) continue;

        distance.set(key(next), cost);
        previous.set(key(next), current);
        if (!queue.some((point) => key(point) === key(next))) queue.push(next);
      }
    }
  }

  if (!distance.has(key(end))) return null;

  const path: Coordinate[] = [];
  let current = end;
  while (key(current) !== key(start)) {
    path.push(current);
    const parent = previous.get(key(current));
    if (!parent) return null;
    current = parent;
  }

  return path.reverse();
}

if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
  await runBot((state, identity) => {
    if (state.match.rulesVersion !== 'lpc2026' || !state.view) {
      throw new Error('Select LPC 2026 before running this TypeScript starter.');
    }
    return calculateActions({ ...state, view: state.view }, identity);
  }).catch((error: unknown) => {
    console.error(error instanceof Error ? error.message : String(error));
    process.exitCode = 1;
  });
}
View bot_client.ts

bot_client.ts

import { randomUUID } from 'node:crypto';

// This package is standalone. Types describe trusted server JSON, not runtime validation.
export type Identity = { teamId: string; displayName?: string };
export type TeamDescriptor = {
  id: string;
  displayName: string;
  botType: false | string;
};
export type MatchSummary = {
  cancellationReason?: string;
  teams: TeamDescriptor[];
  matchId: string;
  status: string;
  turn: number;
  maxTurns: number;
  turnAdvanceMode?: string;
  rulesVersion?: string;
};
export type BotState = {
  match: MatchSummary;
  actionRevisions?: Record<string, number>;
  view?: PlayerView;
  serverTimeMs: number;
  turnEndsAtMs?: number;
};
export type GameState = BotState & { view: PlayerView };
export type Strategy = (
  state: GameState,
  identity: Identity
) => ActionsByEntity | Promise<ActionsByEntity>;
type TurnEvent = { turn: number; view: PlayerView; serverTimeMs: number; turnEndsAtMs?: number };
type MatchStartingEvent = {
  message: string;
  match: MatchSummary;
  serverTimeMs: number;
  startsAtMs: number;
};
type ServerEvent =
  | { name: 'turn'; data: TurnEvent }
  | { name: 'match_starting'; data: MatchStartingEvent }
  | { name: 'match_ended'; data: { match: MatchSummary; serverTimeMs: number } }
  | { name: 'message'; data: unknown };

export type Coordinate = { x: number; y: number };
export type Resource = 'gold' | 'iron';
export type Inventory = Record<Resource, number>;
export type Direction =
  'north' | 'east' | 'south' | 'west' | 'north-east' | 'south-east' | 'south-west' | 'north-west';

export type Action =
  | { type: 'move'; direction: Direction }
  | { type: 'mine' }
  | { type: 'pass' }
  | { type: 'deposit' }
  | { type: 'spawn' }
  | { type: 'build-base' }
  | { type: 'trade'; from: Resource };

export type ActionsByEntity = Record<string, Action>;

export type MapCell =
  | { type: 'empty' }
  | { type: 'mountain' }
  | { type: 'crater'; formation?: { x: number; y: number; size: number } }
  | { type: 'gold'; amount: number }
  | { type: 'iron'; amount: number }
  | { type: 'hidden' }
  | { type: 'void' }
  | { type: 'unit'; unitId: string; teamId: string };

export type GameMap = {
  width: number;
  height: number;
  /** Row-major cells: index = y * width + x. Hidden cells are unknown; void cells are outside the playable map. */
  cells: MapCell[];
};

export type Unit = {
  unitId: string;
  teamId: string;
  position: Coordinate;
  /** Opponent inventories are hidden. Check before reading. */
  inventory?: Inventory;
  inventoryCapacity: number;
  readyOnTurn: number;
};

export type Base = { baseId: string; teamId: string; position: Coordinate };
export type Notice = { entityId: string; error: 'movement_interrupted'; note: string };
export type Effect =
  | { type: 'bonk'; coordinate: Coordinate; entityIds: string[]; note: string }
  | {
      type: 'resource_deposit';
      coordinate: Coordinate;
      baseId: string;
      resource: Resource;
      amount: number;
      total: number;
    };

export type HighGoldSettings = {
  width: number;
  height: number;
  min: number;
  max: number;
  spawnPercent: number;
};

export type PlayerView = {
  highGoldArea: HighGoldSettings;
  turn: number;
  map: GameMap;
  units: Unit[];
  bases: Base[];
  scores: Record<string, number>;
  teamResources: Record<string, Inventory>;
  /** Own bases live; all bases in completed full replays. */
  baseResourceDeposits?: Record<string, Inventory>;
  warnings: Notice[];
  info: Notice[];
  effects: Effect[];
};

const API_BASE_URL = process.env.LUCID_API_BASE_URL ?? 'http://localhost:5173';
const API_KEY = process.env.API_KEY ?? 'replace-with-api-key';
const USING_DEFAULT_URL = process.env.LUCID_API_BASE_URL === undefined;
let botSessionToken: string | undefined;
let botSessionRevision: string | undefined;
let botSessionMatchId: string | undefined;

class ApiError extends Error {
  status: number;
  body: string;
  constructor(status: number, reason: string, body: string) {
    let message = `${status} ${reason}`;
    try {
      message = JSON.parse(body).error?.message ?? message;
    } catch {
      /* Use the HTTP status when the server did not send JSON. */
    }
    if (status === 401 || status === 403) {
      message =
        message === 'Team is not in an active match.'
          ? 'No current match. Join a match on the website, then run the bot again.'
          : 'Invalid API key. Set API_KEY to the key from your team page.';
    }
    if (status === 404)
      message = `Cannot reach the bot API at ${API_BASE_URL}. Check LUCID_API_BASE_URL.`;
    super(message);
    this.status = status;
    this.body = body;
  }
}

function isPvpDisabled(error: unknown) {
  if (!(error instanceof ApiError) || error.status !== 409) return false;
  try {
    return JSON.parse(error.body)?.error?.code === 'pvp_disabled';
  } catch {
    return false;
  }
}

function isMatchUnavailable(error: unknown) {
  if (!(error instanceof ApiError) || error.status !== 403) return false;
  try {
    const detail = JSON.parse(error.body).error;
    return detail?.code === 'forbidden' && detail.message === 'Team is not in an active match.';
  } catch {
    return false;
  }
}

function isAuthError(error: unknown): boolean {
  if (!(error instanceof ApiError)) return false;
  if (error.status === 401 || error.status === 403) return true;
  if (error.status !== 409) return false;
  try {
    return JSON.parse(error.body).error?.code === 'bot_session_conflict';
  } catch {
    return false;
  }
}

function isTerminal(state: BotState) {
  return state.match.status === 'completed' || state.match.status === 'cancelled';
}

const REQUEST_TIMEOUT_MS = 5000;
const STREAM_IDLE_TIMEOUT_MS = 10000;
const MAX_ATTEMPTS = 3;
const RETRY_DELAY_MS = 250;

function isTransient(error: unknown) {
  return error instanceof ApiError
    ? [408, 429, 500, 502, 503, 504].includes(error.status)
    : error instanceof TypeError ||
        (error instanceof Error && ['AbortError', 'TimeoutError'].includes(error.name));
}

async function request<T = unknown>(path: string, options: RequestInit = {}): Promise<T> {
  const method = options.method ?? 'GET';
  const keyedWrite =
    method === 'PUT' &&
    !path.includes('/current/') &&
    typeof options.body === 'string' &&
    !!JSON.parse(options.body).submissionId;
  const headers = new Headers(options.headers);
  const boundRegistration =
    path === '/api/team/matches/current/register' &&
    headers.has('x-bot-match-id') &&
    headers.has('x-bot-session');
  const attempts =
    boundRegistration || method === 'GET' || path === '/api/auth/api-key' || keyedWrite
      ? MAX_ATTEMPTS
      : 1;
  let recoveredSession = false;
  for (let attempt = 0; attempt < attempts; attempt++) {
    const controller = new AbortController();
    const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
    try {
      const response = await fetch(`${API_BASE_URL}${path}`, {
        ...options,
        signal: controller.signal,
        headers: {
          'x-api-key': API_KEY,
          'content-type': 'application/json',
          ...(botSessionToken ? { 'x-bot-session': botSessionToken } : {}),
          ...options.headers
        }
      });
      if (!response.ok) {
        const body = await response.text();
        let missingSession = false;
        try { missingSession = JSON.parse(body).error?.code === 'bot_session_missing'; } catch { /* non-JSON error */ }
        if (missingSession && !recoveredSession && botSessionToken && botSessionMatchId && path !== '/api/team/matches/current/register') {
          recoveredSession = true;
          await request('/api/team/matches/current/register', {
            method: 'POST', headers: { 'x-bot-match-id': botSessionMatchId, 'x-bot-session': botSessionToken, 'x-bot-session-recover': '1' }
          });
          attempt--;
          continue;
        }
        throw new ApiError(response.status, response.statusText, body);
      }
      const revision = response.headers.get('x-bot-session-revision');
      if (revision) botSessionRevision = revision;
      if (path === '/api/team/matches/current/register')
        botSessionToken = response.headers.get('x-bot-session') ?? undefined;
      try {
        return (await response.json()) as T;
      } catch (error) {
        if (error instanceof SyntaxError)
          throw new Error(
            `The server at ${API_BASE_URL} did not return bot API JSON. Check LUCID_API_BASE_URL.`,
            { cause: error }
          );
        throw error;
      }
    } catch (error) {
      if (!isTransient(error)) throw error;
      if (attempt + 1 === attempts) {
        throw new Error(
          `Cannot reach ${API_BASE_URL} after ${attempts} attempt(s). Check LUCID_API_BASE_URL and your connection.`,
          { cause: error }
        );
      }
    } finally {
      clearTimeout(timeout);
    }
    await sleep(RETRY_DELAY_MS * 2 ** attempt);
  }
  throw new Error('Request attempts exhausted.');
}

async function getServerTime() {
  const state = await getCurrentState();
  return state.serverTimeMs;
}

async function getCurrentState(matchId?: string): Promise<BotState> {
  return request(`/api/team/matches/${matchId ? encodeURIComponent(matchId) : 'current'}/state`);
}

async function validateApiKey(): Promise<Identity> {
  if (!API_KEY.trim() || API_KEY === 'replace-with-api-key')
    throw new Error('Set API_KEY to the key from your team page before running the bot.');
  return request('/api/auth/api-key', {
    method: 'POST'
  });
}
async function registerTeamToMatch(): Promise<BotState> {
  botSessionToken = undefined;
  let selected;
  try {
    selected = await getCurrentState();
  } catch (error) {
    if (!isMatchUnavailable(error)) throw error;
    let announced = '';
    for (;;) {
      const response = await request<BotState | { queue: { status: string; autoQueue?: boolean } }>('/api/team/matches/current/register', {method:'POST'});
      if ('match' in response) {
        selected = await getCurrentState();
        break;
      }
      if (!('queue' in response)) throw new Error('Invalid registration response.', {cause:error});
      const waiting = response.queue.autoQueue === false
        ? 'Waiting for a private practice match. Create one on the website; keep this bot running.'
        : 'Waiting in PvP queue; keep this bot running.';
      if (announced !== waiting) { console.log(waiting); announced = waiting; }
      await sleep(1000);
    }
  }
  botSessionMatchId = selected.match.matchId;
  const claimToken = randomUUID();
  const state = await request<BotState>('/api/team/matches/current/register', {
    method: 'POST',
    headers: { 'x-bot-match-id': selected.match.matchId, 'x-bot-session': claimToken, ...(botSessionRevision ? { 'x-bot-session-revision': botSessionRevision } : {}) }
  });

  console.log(`registered for match ${state.match.matchId}`);

  if (state.match.status === 'pending') {
    console.log('waiting for all teams to join');
    return state;
  }

  console.log('All players joined, starting match');
  return state;
}

async function waitForMatchStart(state: BotState): Promise<BotState> {
  return waitForProgress(state, true);
}

let eventStream: { matchId: string; events: AsyncGenerator<ServerEvent> } | undefined;

async function closeEventStream() {
  const current = eventStream;
  eventStream = undefined;
  await current?.events.return(undefined);
}

async function waitForProgress(state: BotState, starting: boolean): Promise<BotState> {
  for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
    let idle = false;
    try {
      if (eventStream?.matchId !== state.match.matchId) {
        await closeEventStream();
        eventStream = {
          matchId: state.match.matchId,
          events: streamEvents(
            `/api/events/matches/${encodeURIComponent(state.match.matchId)}?afterTurn=${state.match.turn}`
          )
        };
      }
      for (;;) {
        const next = await eventStream.events.next();
        if (next.done) break;
        const event = next.value;
        if (event.name === 'match_ended') {
          await closeEventStream();
          return { ...state, match: event.data.match, serverTimeMs: event.data.serverTimeMs };
        }
        if (event.name === 'match_starting' && event.data.match.turn >= state.match.turn) {
          state = applyMatchStartingEvent(state, event.data);
          if (starting) return state;
        }
        if (event.name === 'turn' && event.data.turn > state.match.turn) {
          const nextState = stateFromTurnEvent(state, event.data);
          if (isTerminal(nextState)) await closeEventStream();
          return nextState;
        }
      }
    } catch (error) {
      await closeEventStream();
      if (!isTransient(error)) throw error;
      idle = error instanceof Error && error.name === 'AbortError';
    }
    await closeEventStream();
    const latest = await getCurrentState(state.match.matchId);
    if (
      isTerminal(latest) ||
      latest.match.turn > state.match.turn ||
      (starting && latest.match.status !== 'pending')
    )
      return latest;
    state = latest;
    // A quiet pending lobby is healthy when its state endpoint still responds.
    if (idle && starting && state.match.status === 'pending') attempt = -1;
    if (attempt + 1 < MAX_ATTEMPTS) await sleep(RETRY_DELAY_MS * 2 ** Math.max(attempt, 0));
  }
  throw new Error(
    `Match ${state.match.matchId} event stream made no progress after ${MAX_ATTEMPTS} attempts.`
  );
}

async function* streamEvents(path: string): AsyncGenerator<ServerEvent> {
  const controller = new AbortController();
  let timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
  let reader: ReadableStreamDefaultReader<Uint8Array> | undefined;
  try {
    const response = await fetch(`${API_BASE_URL}${path}`, {
      signal: controller.signal,
      headers: {
        'x-api-key': API_KEY,
        accept: 'text/event-stream',
        ...(botSessionToken ? { 'x-bot-session': botSessionToken } : {})
      }
    });
    if (!response.ok)
      throw new ApiError(response.status, response.statusText, await response.text());
    if (!response.body) throw new Error('Event stream response did not include a body.');
    reader = response.body.getReader();
    const decoder = new TextDecoder();
    let buffer = '';
    for (;;) {
      clearTimeout(timeout);
      timeout = setTimeout(() => controller.abort(), STREAM_IDLE_TIMEOUT_MS);
      const next = await reader.read();
      clearTimeout(timeout);
      if (next.done) break;
      buffer += decoder.decode(next.value, { stream: true });
      const parsed = readServerSentEvents(buffer);
      buffer = parsed.remaining;
      for (const event of parsed.events) yield event;
    }
    // An unterminated frame after disconnect is incomplete, not a valid event.
  } finally {
    clearTimeout(timeout);
    controller.abort();
    try {
      await reader?.cancel();
    } catch {
      /* Preserve the original transport/parse error. */
    }
    reader?.releaseLock();
  }
}

function readServerSentEvents(buffer: string) {
  let remaining = buffer;
  const events = [];
  let boundary = /\r?\n\r?\n/.exec(remaining);

  while (boundary) {
    const rawEvent = remaining.slice(0, boundary.index);
    remaining = remaining.slice(boundary.index + boundary[0].length);
    const event = parseServerSentEvent(rawEvent);

    if (event) {
      events.push(event);
    }

    boundary = /\r?\n\r?\n/.exec(remaining);
  }

  return {
    events,
    remaining
  };
}

function parseServerSentEvent(rawEvent: string): ServerEvent | null {
  let name = 'message';
  const dataLines = [];

  for (const line of rawEvent.split(/\r?\n/)) {
    if (!line || line.startsWith(':')) {
      continue;
    }

    if (line.startsWith('event:')) {
      name = line.slice('event:'.length).trim();
    } else if (line.startsWith('data:')) {
      dataLines.push(line.slice('data:'.length).trim());
    }
  }

  if (dataLines.length === 0) {
    return null;
  }

  // The stream payload comes from the server's event protocol.
  return {
    name,
    data: JSON.parse(dataLines.join('\n'))
  } as ServerEvent;
}

async function replaceEntityAction(entityId: string, revision: number, action: Action) {
  return request(`/api/team/matches/current/entities/${encodeURIComponent(entityId)}/action`, {
    method: 'PUT',
    body: JSON.stringify({ submissionId: randomUUID(), revision, action })
  });
}

async function replaceActions(
  actionsByEntityId: ActionsByEntity,
  revisions: Map<string, number>,
  matchId?: string
) {
  const submissions = Object.entries(actionsByEntityId).map(([entityId, action]) => {
    const revision = (revisions.get(entityId) ?? 0) + 1;
    revisions.set(entityId, revision);

    return {
      entityId,
      revision,
      action
    };
  });

  return request<{ accepted?: { acceptedForTurn: number }[] }>(
    matchId
      ? `/api/team/matches/${encodeURIComponent(matchId)}/action`
      : '/api/team/matches/current/actions',
    {
      method: 'PUT',
      body: JSON.stringify({ submissionId: randomUUID(), submissions })
    }
  );
}

async function advanceActions(matchId?: string) {
  return request(
    matchId
      ? `/api/team/matches/${encodeURIComponent(matchId)}/action`
      : '/api/team/matches/current/actions',
    {
      method: 'PUT',
      body: JSON.stringify({ submissionId: randomUUID(), advanceOnly: true })
    }
  );
}

async function waitForNextTurn(state: BotState): Promise<BotState> {
  return waitForProgress(state, false);
}

function applyMatchStartingEvent(state: BotState, event: MatchStartingEvent): BotState {
  return {
    ...state,
    match: event.match,
    serverTimeMs: event.serverTimeMs,
    turnEndsAtMs: event.startsAtMs
  };
}

function stateFromTurnEvent(state: BotState, event: TurnEvent): BotState {
  return {
    match: {
      ...state.match,
      status: event.turn >= state.match.maxTurns ? 'completed' : 'active',
      turn: event.turn
    },
    view: event.view,
    serverTimeMs: event.serverTimeMs,
    turnEndsAtMs: event.turnEndsAtMs
  };
}

export async function runBot(
  calculateActions: (
    state: BotState,
    identity: Identity
  ) => ActionsByEntity | null | undefined | Promise<ActionsByEntity | null | undefined> = () => ({})
) {
  const identity = await validateApiKey();
  if (USING_DEFAULT_URL)
    console.log(`Using ${API_BASE_URL}. Set LUCID_API_BASE_URL to connect to another server.`);
  let state: BotState;
  try {
    state = await registerTeamToMatch();
  } catch (error) {
    if (!isPvpDisabled(error)) throw error;
    console.error(
      'No match assigned. PvP practice is currently disabled.\nCreate a practice match on the website or ask an organizer to assign one,\nthen run your bot again.'
    );
    process.exitCode = 1;
    return;
  }
  const revisions = new Map(Object.entries(state.actionRevisions ?? {}));
  // One run belongs to this match, even if another becomes current.

  try {
    if (state.match.status === 'pending') {
      state = await waitForMatchStart(state);
    }

    const isClientActionMatch = state.match.turnAdvanceMode === 'client-action';

    let useLatestState = false;
    let needsRefresh = false;
    while (!isTerminal(state)) {
      try {
        if (needsRefresh) {
          const previousTurn = state.match.turn;
          state = await getCurrentState(state.match.matchId);
          useLatestState = state.match.turn > previousTurn;
          needsRefresh = false;
          if (isTerminal(state)) break;
        }
        if (!isClientActionMatch && !useLatestState) {
          state = await waitForNextTurn(state);
        }
        useLatestState = false;
        if (state.match.status !== 'active') break;
        const receivedAtMs = Date.now();
        let actions: ActionsByEntity = {};
        try {
          actions = (await calculateActions(state, identity)) ?? {};
        } catch (error) {
          console.error(
            `Strategy failed on turn ${state.match.turn}: ${error instanceof Error ? error.message : String(error)}. Passing this turn.`
          );
        }
        let late = false;
        try {
          const result = await replaceActions(actions, revisions, state.match.matchId);
          late = (result.accepted ?? []).some(
            (action: { acceptedForTurn: number }) => action.acceptedForTurn > state.match.turn + 1
          );
          console.log(`submitted turn ${state.match.turn}, actions: ${JSON.stringify(actions)}`);
        } catch (error) {
          if (isMatchUnavailable(error)) {
            state = await getCurrentState(state.match.matchId);
            if (isTerminal(state)) break;
            throw error;
          }
          if (isAuthError(error)) throw error;
          console.error(
            `Could not submit turn ${state.match.turn}: ${error instanceof Error ? error.message : String(error)}. Passing this turn.`
          );
          try {
            await replaceActions({}, revisions, state.match.matchId);
          } catch (fallbackError) {
            if (isAuthError(fallbackError)) throw fallbackError;
            console.error(
              `Could not submit a pass: ${fallbackError instanceof Error ? fallbackError.message : String(fallbackError)}`
            );
            await sleep(RETRY_DELAY_MS);
          }
        }
        late ||=
          state.turnEndsAtMs !== undefined &&
          state.serverTimeMs + Date.now() - receivedAtMs >= state.turnEndsAtMs;
        if (late && !isClientActionMatch) {
          console.warn(
            `Turn ${state.match.turn} missed its deadline; using the newest available state.`
          );
          const latest = await getCurrentState(state.match.matchId);
          useLatestState = latest.match.turn > state.match.turn;
          state = latest;
        } else if (isClientActionMatch) {
          state = await getCurrentState(state.match.matchId);
        }
      } catch (error) {
        if (isAuthError(error)) throw error;
        console.error(
          `Waiting for the match: ${error instanceof Error ? error.message : String(error)}`
        );
        needsRefresh = true;
        await sleep(RETRY_DELAY_MS);
      }
    }
    console.log(state.match.cancellationReason ?? `match ${state.match.matchId} is ${state.match.status}; stopping bot`);
  } finally {
    await closeEventStream();
  }
}

export {
  closeEventStream,
  advanceActions,
  getCurrentState,
  getServerTime,
  registerTeamToMatch,
  replaceEntityAction,
  replaceActions,
  validateApiKey,
  waitForNextTurn
};

function sleep(ms: number) {
  return new Promise<void>((resolve) => setTimeout(resolve, ms));
}
View package.json

package.json

{
  "name": "lpc-bot",
  "private": true,
  "type": "module",
  "scripts": {
    "start": "node --experimental-strip-types bot_template.ts",
    "check": "tsc --noEmit",
    "prestart": "node -e \"const [major, minor] = process.versions.node.split('.').map(Number); if (major < 22 || (major === 22 && minor < 14)) { console.error('Use Node.js 22.14 or newer.'); process.exit(1); }\""
  },
  "devDependencies": {
    "@types/node": "^22.0.0",
    "typescript": "6.0.3"
  },
  "engines": {
    "node": ">=22.14.0"
  }
}
View tsconfig.json

tsconfig.json

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "strict": true,
    "noEmit": true,
    "verbatimModuleSyntax": true,
    "erasableSyntaxOnly": true,
    "allowImportingTsExtensions": true,
    "types": ["node"]
  },
  "include": ["*.ts"]
}

Python

Install Python 3.9 or newer. You do not need extra packages.

  1. Save bot_template.py and bot_client.py in the same folder.
  2. Write your strategy in calculate_actions in bot_template.py. Use identity['teamId'] to find your workers.
  3. Game helpers are below the helper banner. Use mars_view to get the typed LPC view, then give state helpers that view and give path_to its map.
Command shell

Run command

API_KEY='your-team-key' LUCID_API_BASE_URL='https://lpc.sh' python3 bot_template.py
View bot_template.py

bot_template.py

#!/usr/bin/env python3
from __future__ import annotations

import os
from heapq import heappop, heappush
from collections.abc import Sequence
from typing import Optional
from bot_client import (
    ActionsByEntity,
    BaseLocation,
    BotClient,
    BotState,
    Coordinate,
    CoordinateGrid,
    GameMap,
    Identity,
    MarsView,
    MovementState,
    OreState,
    Resource,
    TerritoryState,
    mars_view,
)

# Make sure to set your team token!
API_KEY = os.environ.get("API_KEY", "replace-with-api-key")
API_BASE_URL = os.environ.get("LUCID_API_BASE_URL", "http://localhost:5173")


def calculate_actions(gameState: BotState, identity: Identity) -> ActionsByEntity:
    view = mars_view(gameState)
    if view is None:
        return {}

    # Strategy goes here.

    return {
        worker["unitId"]: {"type": "pass"}
        for worker in view["units"]
        if worker["teamId"] == identity["teamId"]
    }


# Game helpers. Feel free to use, discard, or modify.
# Fill in the strategy section above.

def _is_integer(value: object) -> bool:
    return isinstance(value, int) and not isinstance(value, bool)


def _cell(grid: CoordinateGrid | GameMap, point: Coordinate):
    x, y = point["x"], point["y"]
    if not _is_integer(x) or not _is_integer(y):
        return None
    if not (0 <= x < grid["width"] and 0 <= y < grid["height"]):
        return None

    row_start = y * grid["width"]
    return grid["cells"][row_start + x]


def is_claimed(state: TerritoryState | MarsView, coordinate: Coordinate) -> list[str]:
    """Return all known claiming teams, including overlapping claims."""
    teams: list[str] = []

    for base in state["bases"]:
        position = base["position"]
        distance = abs(position["x"] - coordinate["x"]) + abs(position["y"] - coordinate["y"])
        if distance <= 2 and base["teamId"] not in teams:
            teams.append(base["teamId"])

    return teams


def is_base(state: TerritoryState | MarsView, coordinate: Coordinate) -> Optional[str]:
    for base in state["bases"]:
        if base["position"] == coordinate:
            return base["teamId"]
    return None


def is_ore(state: OreState | MarsView, coordinate: Coordinate) -> Optional[Resource]:
    cell = _cell(state["map"], coordinate)
    if cell and cell["type"] in ("gold", "iron") and cell.get("amount", 0) > 0:
        return cell["type"]
    return None


def can_move_onto(
    state: MovementState | MarsView,
    coordinate: Coordinate,
    team_id: Optional[str] = None,
) -> bool:
    """Unknown terrain and enemy bases are excluded; workers do not block."""
    cell = _cell(state["map"], coordinate)
    if cell is None or cell["type"] in ("hidden", "void"):
        return False

    base_owner = is_base(state, coordinate)
    return base_owner is None or base_owner == team_id


def path_to(
    grid: CoordinateGrid | GameMap,
    start: Coordinate,
    end: Coordinate,
    *,
    bases: Sequence[BaseLocation] = (),
    team_id: Optional[str] = None,
    obstacle_cost: int = 3,
) -> Optional[list[Coordinate]]:
    """Cost until ready at destination; start must be ready; None if unreachable."""
    if not _is_integer(obstacle_cost) or obstacle_cost < 1:
        return None

    state: MovementState = {"map": grid, "bases": bases}
    if not can_move_onto(state, start, team_id) or not can_move_onto(state, end, team_id):
        return None

    origin = (start["x"], start["y"])
    target = (end["x"], end["y"])
    distances = {origin: 0}
    previous: dict[tuple[int, int], tuple[int, int]] = {}
    queue = [(0, origin)]

    while queue:
        distance, position = heappop(queue)
        if distance != distances[position]:
            continue
        if position == target:
            break

        x, y = position
        for dy in range(-1, 2):
            for dx in range(-1, 2):
                if not dx and not dy:
                    continue
                point: Coordinate = {"x": x + dx, "y": y + dy}
                if not can_move_onto(state, point, team_id):
                    continue

                neighbor = (point["x"], point["y"])
                cell = _cell(grid, point)
                movement_cost = obstacle_cost if cell and cell["type"] in ("mountain", "crater") else 1
                candidate = distance + movement_cost
                if candidate < distances.get(neighbor, float("inf")):
                    distances[neighbor] = candidate
                    previous[neighbor] = position
                    heappush(queue, (candidate, neighbor))

    if target not in distances:
        return None

    path: list[Coordinate] = []
    position = target
    while position != origin:
        path.append({"x": position[0], "y": position[1]})
        position = previous[position]

    return list(reversed(path))


if __name__ == "__main__":
    print("Edit calculate_actions to choose your workers' actions.")
    if "LUCID_API_BASE_URL" not in os.environ:
        print(f"LUCID_API_BASE_URL is not set; using {API_BASE_URL}.")

    try:
        BotClient(API_KEY, API_BASE_URL).connect_and_run_bot(calculate_actions)
    except KeyboardInterrupt:
        pass
    except Exception as error:
        print(error)
        raise SystemExit(1)
View bot_client.py

bot_client.py

import json
import sys
import http.client
import time
import ssl
from uuid import uuid4
import urllib.error
import urllib.request
from urllib.parse import quote, urlsplit, unquote
from base64 import b64encode
from collections.abc import Callable, Generator, Iterator, Sequence
from typing import Literal, Optional, TypedDict, Union, cast


class Coordinate(TypedDict):
    x: int
    y: int


Resource = Literal["gold", "iron"]
Direction = Literal["north", "east", "south", "west", "north-east", "south-east", "south-west", "north-west"]
PlanetName = Literal["Mercury", "Venus", "Earth", "Mars", "Jupiter", "Saturn", "Uranus", "Neptune", "Pluto"]


class Inventory(TypedDict):
    gold: int
    iron: int


class TerrainCell(TypedDict):
    type: Literal["empty", "mountain", "hidden", "void"]


class CraterFormation(TypedDict):
    x: int
    y: int
    size: int


class _CraterOptions(TypedDict, total=False):
    formation: CraterFormation


class CraterCell(_CraterOptions):
    type: Literal["crater"]


class OreCell(TypedDict):
    type: Resource
    amount: int


class UnitCell(TypedDict):
    type: Literal["unit"]
    unitId: str
    teamId: str


MapCell = Union[TerrainCell, CraterCell, OreCell, UnitCell]


class _GridCellOptions(TypedDict, total=False):
    amount: int


class GridCell(_GridCellOptions):
    type: str


class CoordinateGrid(TypedDict):
    width: int
    height: int
    cells: Sequence[GridCell]


class GameMap(TypedDict):
    width: int
    height: int
    cells: Sequence[MapCell]


class BaseLocation(TypedDict):
    teamId: str
    position: Coordinate


class Base(BaseLocation):
    baseId: str


class TerritoryState(TypedDict):
    bases: Sequence[BaseLocation]


class OreState(TypedDict):
    map: Union[CoordinateGrid, GameMap]


class MovementState(TerritoryState, OreState):
    pass


class _UnitOptions(TypedDict, total=False):
    inventory: Inventory


class Unit(_UnitOptions):
    unitId: str
    teamId: str
    position: Coordinate
    inventoryCapacity: int
    readyOnTurn: int


class Notice(TypedDict):
    entityId: str
    error: Literal["movement_interrupted"]
    note: str


class BonkEffect(TypedDict):
    type: Literal["bonk"]
    coordinate: Coordinate
    entityIds: list[str]
    note: str


class DepositEffect(TypedDict):
    type: Literal["resource_deposit"]
    coordinate: Coordinate
    baseId: str
    resource: Resource
    amount: int
    total: int


class HighGoldSettings(TypedDict):
    width: int
    height: int
    min: int
    max: int
    spawnPercent: int


class _MarsViewOptions(TypedDict, total=False):
    baseResourceDeposits: dict[str, Inventory]


class MarsView(_MarsViewOptions):
    turn: int
    map: GameMap
    units: list[Unit]
    bases: list[Base]
    scores: dict[str, int]
    teamResources: dict[str, Inventory]
    highGoldArea: HighGoldSettings
    warnings: list[Notice]
    info: list[Notice]
    effects: list[Union[BonkEffect, DepositEffect]]


class Position(TypedDict):
    x: float
    y: float


class SolarPlanet(TypedDict):
    name: PlanetName
    position: Position
    orbitRadius: float


class Orbit(TypedDict):
    planet: PlanetName
    angle: float


class SolarRocket(TypedDict):
    teamId: str
    position: Position
    previousPosition: Position
    heading: float
    target: Optional[PlanetName]
    targetPosition: Optional[Position]
    orbit: Optional[Orbit]


class _SolarViewOptions(TypedDict, total=False):
    entityId: str


class SolarView(_SolarViewOptions):
    turn: int
    planets: list[SolarPlanet]
    rockets: list[SolarRocket]


class MoveAction(TypedDict):
    type: Literal["move"]
    direction: Direction


class SimpleAction(TypedDict):
    type: Literal["mine", "pass", "deposit", "spawn", "build-base"]


# The wire field is named "from", a Python keyword.
TradeAction = TypedDict("TradeAction", {"type": Literal["trade"], "from": Resource})


class FollowPlanetAction(TypedDict):
    type: PlanetName


class FlyAction(TypedDict):
    type: Literal["move"]
    x: float
    y: float


Action = Union[MoveAction, SimpleAction, TradeAction, FollowPlanetAction, FlyAction]


class _MatchOptions(TypedDict, total=False):
    cancellationReason: str
    turnAdvanceMode: str
    rulesVersion: str


class TeamDescriptor(TypedDict):
    id: str
    displayName: str
    botType: Union[Literal[False], str]


class MatchSummary(_MatchOptions):
    teams: list[TeamDescriptor]
    matchId: str
    status: str
    turn: int
    maxTurns: int


class _StateOptions(TypedDict, total=False):
    actionRevisions: dict[str, int]
    view: Union[MarsView, SolarView]
    turnEndsAtMs: float


class BotState(_StateOptions):
    match: MatchSummary
    serverTimeMs: float


class _TurnOptions(TypedDict, total=False):
    turnEndsAtMs: float


class TurnEvent(_TurnOptions):
    turn: int
    view: Union[MarsView, SolarView]
    serverTimeMs: float


class MatchStartingEvent(TypedDict):
    message: str
    match: MatchSummary
    serverTimeMs: float
    startsAtMs: float


class MatchEndedEvent(TypedDict):
    match: MatchSummary
    serverTimeMs: float


class AcceptedAction(TypedDict):
    acceptedForTurn: int


class ActionResponse(TypedDict, total=False):
    accepted: list[AcceptedAction]
    submissionId: str
    state: Optional[BotState]


class Identity(TypedDict):
    teamId: str
    displayName: str


ActionsByEntity = dict[str, Action]
CalculateActions = Callable[[BotState, Identity], ActionsByEntity]
ServerEvent = tuple[str, object]


def mars_view(state: BotState) -> Optional[MarsView]:
    view = state.get("view")
    return view if view is not None and "units" in view else None


def solar_view(state: BotState) -> Optional[SolarView]:
    view = state.get("view")
    return view if view is not None and "rockets" in view else None


REQUEST_TIMEOUT_SECONDS = 5
STREAM_IDLE_TIMEOUT_SECONDS = 10
MAX_ATTEMPTS = 3
RETRY_DELAY_SECONDS = 0.25


def is_transient(error: BaseException) -> bool:
    if isinstance(error, ApiError):
        return error.status in (408, 429, 500, 502, 503, 504)
    if isinstance(error, ssl.SSLCertVerificationError):
        return False
    if isinstance(error, urllib.error.URLError) and isinstance(
        error.reason, ssl.SSLCertVerificationError
    ):
        return False
    return isinstance(error, (OSError, http.client.HTTPException))


class BotClient:
    def __init__(self, api_key: str, api_base_url: str) -> None:
        if not api_key.strip() or api_key == "replace-with-api-key":
            raise ValueError("Set API_KEY to your team API key before starting the bot.")
        self.api_key = api_key
        self.api_base_url = api_base_url.rstrip("/")
        endpoint = urlsplit(self.api_base_url)
        if endpoint.scheme not in ("http", "https") or endpoint.hostname is None:
            raise ValueError("The competition URL must use HTTP or HTTPS and include a host.")
        self.host = endpoint.hostname
        self.port = endpoint.port
        self.secure = endpoint.scheme == "https"
        self.http: Optional[http.client.HTTPConnection] = None
        self.stream_http: Optional[http.client.HTTPConnection] = None
        self.http_absolute_url = False
        self.http_proxy_headers: dict[str, str] = {}
        self.revisions: dict[str, int] = {}
        self.bot_session_token: Optional[str] = None
        self.bot_session_revision: Optional[str] = None
        self.bot_match_id: Optional[str] = None
        self.events: Optional[Generator[ServerEvent, None, None]] = None
        self.events_match_id: Optional[str] = None

    def _connection(self, timeout: float) -> tuple[http.client.HTTPConnection, bool, dict[str, str]]:
        proxy_url = urllib.request.getproxies().get("https" if self.secure else "http")
        if proxy_url and not urllib.request.proxy_bypass(f"{self.host}:{self.port}" if self.port else self.host):
            proxy = urlsplit(proxy_url if "://" in proxy_url else "http://" + proxy_url)
            if proxy.scheme != "http" or proxy.hostname is None:
                raise ValueError("Use an HTTP proxy URL in HTTP_PROXY or HTTPS_PROXY.")
            headers: dict[str, str] = {}
            if proxy.username is not None:
                credentials = unquote(proxy.username) + ":" + unquote(proxy.password or "")
                headers["Proxy-Authorization"] = "Basic " + b64encode(credentials.encode()).decode()
            if self.secure:
                connection = http.client.HTTPSConnection(proxy.hostname, proxy.port or 80, timeout=timeout)
                connection.set_tunnel(self.host, self.port or 443, headers=headers)
                return connection, False, {}
            return http.client.HTTPConnection(proxy.hostname, proxy.port or 80, timeout=timeout), True, headers
        factory = http.client.HTTPSConnection if self.secure else http.client.HTTPConnection
        return factory(self.host, self.port, timeout=timeout), False, {}

    def _open(self, request: urllib.request.Request, connection: http.client.HTTPConnection,
              absolute_url: bool, proxy_headers: dict[str, str]) -> http.client.HTTPResponse:
        headers = {**dict(request.header_items()), **proxy_headers}
        connection.request(request.get_method(), request.full_url if absolute_url else request.selector,
                           request.data, headers)
        response = connection.getresponse()
        if response.status >= 400:
            try:
                body = response.read().decode("utf-8")
            finally:
                response.close()
            raise ApiError(response.status, response.reason, body)
        return response

    def _open_json(self, request: urllib.request.Request) -> http.client.HTTPResponse:
        if self.http is None:
            self.http, self.http_absolute_url, self.http_proxy_headers = self._connection(REQUEST_TIMEOUT_SECONDS)
        return self._open(request, self.http, self.http_absolute_url, self.http_proxy_headers)

    def _open_stream(self, request: urllib.request.Request) -> http.client.HTTPResponse:
        self.stream_http, absolute_url, proxy_headers = self._connection(STREAM_IDLE_TIMEOUT_SECONDS)
        return self._open(request, self.stream_http, absolute_url, proxy_headers)

    def close_http(self) -> None:
        connection, self.http = self.http, None
        if connection is not None:
            connection.close()

    def close(self) -> None:
        try:
            self.close_events()
        finally:
            self.close_http()

    def request(self, path: str, method: str = "GET", body: object = None,
                headers: Optional[dict[str, str]] = None, recovering: bool = False) -> object:
        data = None if body is None else json.dumps(body).encode("utf-8")
        req = urllib.request.Request(
            f"{self.api_base_url}{path}",
            data=data,
            method=method,
            headers={
                "x-api-key": self.api_key,
                **({"x-bot-session": self.bot_session_token} if self.bot_session_token else {}),
                "content-type": "application/json",
                **(headers or {}),
            },
        )

        # Retry writes only when a match ID and idempotency token pin the request.
        keyed_write = (
            method == "PUT" and "/current/" not in path
            and isinstance(body, dict)
            and bool(cast(dict[str, object], body).get("submissionId"))
        )
        bound_registration = (
            path == "/api/team/matches/current/register" and headers is not None
            and "x-bot-match-id" in headers and self.bot_session_token is not None
        )
        attempts = MAX_ATTEMPTS if method == "GET" or path == "/api/auth/api-key" or keyed_write or bound_registration else 1
        for attempt in range(attempts):
            try:
                with self._open_json(req) as response:
                    if path == "/api/team/matches/current/register":
                        self.bot_session_token = response.headers.get("x-bot-session") or self.bot_session_token
                    self.bot_session_revision = response.headers.get("x-bot-session-revision") or self.bot_session_revision
                    return json.loads(response.read().decode("utf-8"))
            except (ApiError, OSError, http.client.HTTPException) as error:
                if (isinstance(error, ApiError) and error.status == 409
                    and 'bot_session_missing' in error.body and not recovering
                    and self.bot_match_id and self.bot_session_token
                    and path != "/api/team/matches/current/register"):
                    self.request("/api/team/matches/current/register", method="POST",
                                 headers={"x-bot-match-id": self.bot_match_id, "x-bot-session-recover": "1"}, recovering=True)
                    return self.request(path, method, cast(object, body), headers, recovering=True)
                if not is_transient(error):
                    if not isinstance(error, ApiError):
                        self.close_http()
                    if isinstance(error, ApiError) and error.status == 404 and "/matches/" not in path:
                        raise RuntimeError(f"Cannot reach the competition API at {self.api_base_url}; check LUCID_API_BASE_URL.") from error
                    raise
                self.close_http()
                if attempt + 1 == attempts:
                    raise RuntimeError(
                        f"Cannot reach {self.api_base_url}; check the URL and network connection ({attempts} attempt(s))."
                    ) from error
            time.sleep(RETRY_DELAY_SECONDS * 2 ** attempt)
        raise RuntimeError("Request attempts exhausted.")

    def validate_api_key(self) -> Identity:
        return cast(Identity, self.request("/api/auth/api-key", method="POST"))

    def current_match_id(self) -> str:
        return self.get_state()["match"]["matchId"]

    def register_team_to_match(self) -> BotState:
        try:
            match_id = self.current_match_id()
        except ApiError as error:
            if not error.is_match_unavailable():
                raise
            announced = ""
            while True:
                response = cast(dict[str, object], self.request("/api/team/matches/current/register", method="POST"))
                if "match" in response:
                    match_id = self.current_match_id()
                    break
                if "queue" not in response:
                    raise RuntimeError("Invalid registration response.")
                queue = cast(dict[str, object], response["queue"])
                waiting = ("Waiting for a private practice match. Create one on the website; keep this bot running."
                           if queue.get("autoQueue") is False else "Waiting in PvP queue; keep this bot running.")
                if announced != waiting:
                    print(waiting)
                    announced = waiting
                time.sleep(1)
        self.bot_match_id = match_id
        self.bot_session_token = self.bot_session_token or str(uuid4())
        state = cast(
            BotState,
            self.request("/api/team/matches/current/register", method="POST",
                         headers={"x-bot-match-id": match_id, **({"x-bot-session-revision": self.bot_session_revision} if self.bot_session_revision else {})}),
        )
        print(f"registered for match {state['match']['matchId']}")

        if state["match"]["status"] == "pending":
            print("waiting for all teams to join")
        else:
            print("All players joined, starting match")

        return state

    def wait_for_match_start(
        self, events: Iterator[ServerEvent], state: BotState
    ) -> BotState:
        return self.wait_for_progress(events, state, starting=True)

    def wait_for_progress(
        self, events: Iterator[ServerEvent], state: BotState, starting: bool
    ) -> BotState:
        attempt = 0
        while attempt < MAX_ATTEMPTS:
            idle = False
            try:
                for event_name, event_data in events:
                    if event_name == "match_ended":
                        self.close_events()
                        ended = cast(MatchEndedEvent, event_data)
                        return {**state, "match": ended["match"], "serverTimeMs": ended["serverTimeMs"]}
                    if event_name == "match_starting":
                        start = cast(MatchStartingEvent, event_data)
                        if start["match"]["turn"] < state["match"]["turn"]:
                            continue
                        state = self.apply_match_starting_event(state, start)
                        if starting:
                            return state
                    if event_name == "turn":
                        turn = cast(TurnEvent, event_data)
                        if turn["turn"] > state["match"]["turn"]:
                            next_state = self.state_from_turn_event(state, turn)
                            if is_terminal(next_state):
                                self.close_events()
                            return next_state
            except (ApiError, OSError, http.client.HTTPException) as error:
                if not is_transient(error):
                    self.close_events()
                    raise
                idle = isinstance(error, TimeoutError)
            except Exception:
                self.close_events()
                raise
            self.close_events()
            latest = self.get_state(state["match"]["matchId"])
            if (is_terminal(latest) or latest["match"]["turn"] > state["match"]["turn"]
                    or (starting and latest["match"]["status"] != "pending")):
                return latest
            state = latest
            # A quiet pending lobby is healthy when state polling still succeeds.
            attempt = (
                0 if idle and starting and state["match"]["status"] == "pending"
                else attempt + 1
            )
            if attempt < MAX_ATTEMPTS:
                time.sleep(RETRY_DELAY_SECONDS * 2 ** max(attempt - 1, 0))
                events = self.match_events(state)
        raise RuntimeError(
            f"Match {state['match']['matchId']} event stream made no progress after {MAX_ATTEMPTS} attempts."
        )

    def match_events(self, state: BotState) -> Generator[ServerEvent, None, None]:
        match_id = state["match"]["matchId"]
        if self.events is None or self.events_match_id != match_id:
            self.close_events()
            target = quote(match_id, safe="")
            self.events = self.stream_events(f"/api/events/matches/{target}?afterTurn={state['match']['turn']}")
            self.events_match_id = match_id
        return self.events

    def close_events(self) -> None:
        events, self.events = self.events, None
        self.events_match_id = None
        if events is not None:
            events.close()

    def stream_events(self, path: str) -> Generator[ServerEvent, None, None]:
        req = urllib.request.Request(
            f"{self.api_base_url}{path}",
            method="GET",
            headers={
                "x-api-key": self.api_key,
                **({"x-bot-session": self.bot_session_token} if self.bot_session_token else {}),
                "accept": "text/event-stream",
            },
        )

        try:
            with self._open_stream(req) as response:
                event_name = "message"
                data_lines: list[str] = []

                for raw_line in response:
                    line = raw_line.decode("utf-8").strip()

                    if line == "":
                        if data_lines:
                            yield event_name, json.loads("\n".join(data_lines))
                            event_name = "message"
                            data_lines = []
                        continue

                    if line.startswith(":"):
                        continue

                    if line.startswith("event:"):
                        event_name = line.removeprefix("event:").strip()
                    elif line.startswith("data:"):
                        data_lines.append(line.removeprefix("data:").strip())
        finally:
            if self.stream_http is not None:
                self.stream_http.close()
                self.stream_http = None

    def replace_actions(
        self, actions: ActionsByEntity, match_id: Optional[str] = None
    ) -> ActionResponse:
        submissions: list[dict[str, object]] = []

        for entity_id, action in actions.items():
            revision = self.revisions.get(entity_id, 0) + 1
            self.revisions[entity_id] = revision
            submissions.append(
                {
                    "entityId": entity_id,
                    "revision": revision,
                    "action": action,
                }
            )

        return cast(
            ActionResponse,
            self.request(
                (
                    f"/api/team/matches/{quote(match_id, safe='')}/action"
                    if match_id else "/api/team/matches/current/actions"
                ),
                method="PUT",
                body={"submissionId": str(uuid4()), "submissions": submissions},
            ),
        )

    def advance_entity_actions(self, match_id: Optional[str] = None) -> ActionResponse:
        return cast(
            ActionResponse,
            self.request(
                (
                    f"/api/team/matches/{quote(match_id, safe='')}/action"
                    if match_id else "/api/team/matches/current/actions"
                ),
                method="PUT",
                body={"submissionId": str(uuid4()), "advanceOnly": True},
            ),
        )

    def get_state(self, match_id: Optional[str] = None) -> BotState:
        target = quote(match_id, safe="") if match_id else "current"
        return cast(BotState, self.request(f"/api/team/matches/{target}/state"))

    def wait_for_next_turn(
        self, events: Iterator[ServerEvent], state: BotState
    ) -> BotState:
        return self.wait_for_progress(events, state, starting=False)

    def apply_match_starting_event(
        self, state: BotState, event_data: MatchStartingEvent
    ) -> BotState:
        next_state = cast(BotState, dict(state))
        next_state["match"] = event_data["match"]
        next_state["serverTimeMs"] = event_data["serverTimeMs"]
        next_state["turnEndsAtMs"] = event_data["startsAtMs"]
        return next_state

    def state_from_turn_event(self, state: BotState, event_data: TurnEvent) -> BotState:
        match = cast(MatchSummary, dict(state["match"]))
        match["turn"] = event_data["turn"]
        match["status"] = "completed" if event_data["turn"] >= match["maxTurns"] else "active"

        next_state: BotState = {
            "match": match,
            "view": event_data["view"],
            "serverTimeMs": event_data["serverTimeMs"],
        }

        if "turnEndsAtMs" in event_data:
            next_state["turnEndsAtMs"] = event_data["turnEndsAtMs"]

        return next_state

    def recover_state(self, match_id: str) -> BotState:
        while True:
            try:
                return self.get_state(match_id)
            except Exception as error:
                if isinstance(error, ApiError) and error.is_auth_error():
                    raise
                print(f"Cannot refresh match state: {error}; retrying.")
                time.sleep(RETRY_DELAY_SECONDS)

    def wait_for_live_progress(self, state: BotState, starting: bool = False) -> BotState:
        while True:
            try:
                return self.wait_for_progress(self.match_events(state), state, starting)
            except Exception as error:
                if isinstance(error, ApiError) and error.is_auth_error():
                    raise
                print(f"Cannot read turn updates: {error}; reconnecting.")
                latest = self.recover_state(state["match"]["matchId"])
                if is_terminal(latest) or latest["match"]["turn"] > state["match"]["turn"] or (
                    starting and latest["match"]["status"] != "pending"
                ):
                    return latest
                state = latest
                time.sleep(RETRY_DELAY_SECONDS)

    def connect_and_run_bot(self, calculate_actions: CalculateActions) -> None:
        try:
            identity = self.validate_api_key()
            try:
                gameState = self.register_team_to_match()
            except ApiError as error:
                if not error.is_pvp_disabled():
                    raise
                print("No match assigned. PvP practice is currently disabled.\nCreate a practice match on the website or ask an organizer to assign one,\nthen run your bot again.", file=sys.stderr)
                raise SystemExit(1) from None
            self.revisions = dict(gameState.get("actionRevisions", {}))
            # One run belongs to this match, even if another becomes current.
            match_id = gameState["match"]["matchId"]
            isClientActionMatch = gameState["match"].get("turnAdvanceMode") == "client-action"

            if gameState["match"]["status"] == "pending":
                gameState = self.wait_for_live_progress(gameState, starting=True)

            refresh_after_late_submission = False
            while not is_terminal(gameState):
                if not isClientActionMatch:
                    latest = self.recover_state(match_id) if refresh_after_late_submission else gameState
                    refresh_after_late_submission = False
                    if is_terminal(latest) or latest["match"]["turn"] > gameState["match"]["turn"]:
                        gameState = latest
                    else:
                        gameState = self.wait_for_live_progress(gameState)

                matchStatus = gameState["match"]["status"]
                if matchStatus != "active":
                    print(gameState["match"].get("cancellationReason") or f"match {gameState['match']['matchId']} is {matchStatus}; stopping bot")
                    return

                view_received_at = time.monotonic()
                deadline = gameState.get("turnEndsAtMs")
                if deadline is not None and gameState["serverTimeMs"] >= deadline:
                    latest = self.recover_state(match_id)
                    if is_terminal(latest):
                        gameState = latest
                        break
                    if latest["match"]["turn"] > gameState["match"]["turn"]:
                        print(f"Skipping stale turn {gameState['match']['turn']}; using turn {latest['match']['turn']}.")
                        gameState = latest
                        view_received_at = time.monotonic()
                turn = gameState["match"]["turn"]
                try:
                    actions = calculate_actions(gameState, identity)
                except Exception as error:
                    print(f"Strategy failed on turn {turn}: {error}; submitting no actions.")
                    actions = {}

                response: ActionResponse
                try:
                    response = self.replace_actions(actions, match_id)
                except Exception as error:
                    if isinstance(error, ApiError) and error.is_auth_error():
                        raise
                    latest = self.recover_state(match_id)
                    if is_terminal(latest):
                        gameState = latest
                        break
                    print(f"Action submission failed on turn {turn}: {error}; submitting no actions.")
                    try:
                        response = self.replace_actions({}, match_id)
                    except Exception as fallback_error:
                        if isinstance(fallback_error, ApiError) and fallback_error.is_auth_error():
                            raise
                        print(f"No-action submission failed: {fallback_error}; continuing.")
                        response = {}
                accepted = response.get("accepted", [])
                late = any(action["acceptedForTurn"] > turn + 1 for action in accepted)
                deadline = gameState.get("turnEndsAtMs")
                server_now_ms = gameState["serverTimeMs"] + (time.monotonic() - view_received_at) * 1000
                refresh_after_late_submission = late or (deadline is not None and server_now_ms >= deadline)
                if refresh_after_late_submission:
                    print(f"Actions for turn {turn} arrived late; shorten your strategy calculation.")
                else:
                    print(f"submitted turn {turn}, actions: {actions}")

                if isClientActionMatch:
                    gameState = response.get("state") or self.recover_state(match_id)
            print(gameState["match"].get("cancellationReason") or f"match {match_id} is {gameState['match']['status']}; stopping bot")
        finally:
            self.close()


def is_terminal(state: BotState) -> bool:
    return state["match"]["status"] in ("completed", "cancelled")


class ApiError(RuntimeError):
    def __init__(self, status: int, reason: str, body: str) -> None:
        self.status = status
        self.reason = reason
        self.body = body
        if self.is_match_unavailable():
            message = "No current match is available; join a match on the competition page."
        elif status == 409 and self.is_auth_error():
            message = "Another bot session is using this API key; stop that bot before starting this one."
        elif self.is_auth_error():
            message = "The API key was rejected; check API_KEY in your environment."
        elif status == 404:
            message = "No current match is available; join a match on the competition page."
        else:
            try:
                message = str(json.loads(body)["error"]["message"])
            except (ValueError, KeyError, TypeError):
                message = f"The server rejected the request ({status} {reason})."
        super().__init__(message)

    def is_auth_error(self) -> bool:
        if self.status in (401, 403):
            return not self.is_match_unavailable()
        try:
            return self.status == 409 and json.loads(self.body)["error"]["code"] == "bot_session_conflict"
        except (ValueError, KeyError, TypeError):
            return False

    def is_pvp_disabled(self) -> bool:
        if self.status != 409:
            return False
        try:
            return json.loads(self.body)["error"]["code"] == "pvp_disabled"
        except (ValueError, KeyError, TypeError):
            return False

    def is_match_unavailable(self) -> bool:
        if self.status != 403:
            return False
        try:
            detail = json.loads(self.body)["error"]
            return (
                detail.get("code") == "forbidden"
                and detail.get("message") == "Team is not in an active match."
            )
        except (ValueError, KeyError, TypeError, AttributeError):
            return False

Java

Install JDK 21 or newer and Maven 3.9 or newer. Run java -version and mvn -version. Both must show Java 21 or newer. Maven downloads Gson on the first run.

  1. Save all three files in one folder.
  2. Write your strategy in calculateActions in Main.java. Use the team ID in identity to find your workers.
  3. BotClient parses state into records. Use state.view().units() to read workers. Game helpers are below the helper banner; give grid helpers state.view().map() and territory helpers state.view().bases(). You can write your own CoordinateGrid.
Command shell

Run command

API_KEY='your-team-key' LUCID_API_BASE_URL='https://lpc.sh' mvn -q compile exec:java
View Main.java

Main.java

import java.util.*;

public class Main {
    public static Map<String, MarsAction> calculateActions(
            GameState<MarsView> state, Identity identity) {
        var actions = new LinkedHashMap<String, MarsAction>();

        for (var worker : state.view().units()) {
            if (!worker.teamId().equals(identity.teamId())) continue;

            // Strategy goes here.

            actions.put(worker.unitId(), MarsAction.pass());
        }

        return actions;
    }

    // Game helpers. Feel free to use, discard, or modify.
    // Fill in the strategy section above.
    private static boolean inBounds(CoordinateGrid grid, Coordinate p) {
        return p.x() >= 0 && p.y() >= 0 && p.x() < grid.width() && p.y() < grid.height();
    }

    public static Set<String> isClaimed(List<Base> bases, Coordinate p) {
        var claims = new LinkedHashSet<String>();
        for (var base : bases) {
            int distance = Math.abs(base.position().x() - p.x()) + Math.abs(base.position().y() - p.y());
            if (distance <= 2) claims.add(base.teamId());
        }

        return claims;
    }

    public static String isBase(List<Base> bases, Coordinate p) {
        return bases.stream()
            .filter(base -> base.position().equals(p))
            .map(Base::teamId)
            .findFirst()
            .orElse(null);
    }

    public static String isOre(CoordinateGrid grid, Coordinate p) {
        if (!inBounds(grid, p)) return null;

        var type = grid.terrain(p);
        return (type.equals("gold") || type.equals("iron")) && grid.amount(p) > 0 ? type : null;
    }

    public static boolean canMoveOnto(CoordinateGrid grid, List<Base> bases, Coordinate p, String teamId) {
        if (!inBounds(grid, p)) return false;

        var type = grid.terrain(p);
        var owner = isBase(bases, p);
        return !type.equals("hidden") && !type.equals("void") && (owner == null || owner.equals(teamId));
    }

    /** Cost until ready at destination; start must be ready. Null means no known route. */
    public static List<Coordinate> pathTo(
            CoordinateGrid grid, Coordinate start, Coordinate end,
            List<Base> bases, String teamId, int obstacleCost) {
        if (obstacleCost < 1
                || !canMoveOnto(grid, bases, start, teamId)
                || !canMoveOnto(grid, bases, end, teamId)) {
            return null;
        }

        var distance = new HashMap<Coordinate, Long>();
        var previous = new HashMap<Coordinate, Coordinate>();
        record Step(Coordinate position, long cost) {}
        var queue = new PriorityQueue<Step>(Comparator.comparingLong(Step::cost));
        distance.put(start, 0L);
        queue.add(new Step(start, 0));

        while (!queue.isEmpty()) {
            var step = queue.remove();
            var p = step.position();
            if (step.cost() != distance.get(p)) continue;
            if (p.equals(end)) break;

            for (int dy = -1; dy <= 1; dy++) {
                for (int dx = -1; dx <= 1; dx++) {
                    if (dx == 0 && dy == 0) continue;
                    var next = new Coordinate(p.x() + dx, p.y() + dy);
                    if (!canMoveOnto(grid, bases, next, teamId)) continue;

                    var type = grid.terrain(next);
                    long cost = distance.get(p) + (type.equals("mountain") || type.equals("crater") ? obstacleCost : 1);
                    if (cost >= distance.getOrDefault(next, Long.MAX_VALUE)) continue;

                    distance.put(next, cost);
                    previous.put(next, p);
                    queue.add(new Step(next, cost));
                }
            }
        }

        if (!distance.containsKey(end)) return null;

        var path = new ArrayList<Coordinate>();
        for (var p = end; !p.equals(start); p = previous.get(p)) path.add(p);

        Collections.reverse(path);
        return path;
    }

    public static List<Coordinate> pathTo(CoordinateGrid grid, Coordinate start, Coordinate end) {
        return pathTo(grid, start, end, List.of(), null, 3);
    }

    public static void main(String[] args) throws Exception {
        try {
            new BotClient().runMars(Main::calculateActions);
        } catch (Exception error) {
            System.err.println(error.getMessage());
            System.exit(1);
        }
    }
}
View BotClient.java

BotClient.java

import com.google.gson.*;
import com.google.gson.annotations.SerializedName;
import com.google.gson.reflect.TypeToken;
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.*;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.*;
import java.util.function.BiFunction;

record Identity(String teamId, String displayName) {}
record Match(String matchId, String mode, String status, String turnAdvanceMode,
                    String rulesVersion, int turn, int maxTurns, Long startedAtMs, Long completedAtMs,
                    List<String> expectedTeamIds, List<String> teamIds, List<String> activeTeamIds,
                    List<Team> teams) {}
record Team(String id, String displayName) {}
record GameState<V>(Match match, V view, long serverTimeMs, Long turnEndsAtMs,
                           Map<String, Long> actionRevisions) {}
record Coordinate(int x, int y) {}
record Base(String baseId, String teamId, Coordinate position) {}
record Inventory(long gold, long iron) {}
record Worker(String unitId, String teamId, Coordinate position, int readyOnTurn,
                     Inventory inventory, long inventoryCapacity) {}
record Formation(int x, int y, int size) {}
record Cell(String type, Long amount, Formation formation, String unitId, String teamId) {}
interface CoordinateGrid {
    int width();
    int height();
    String terrain(Coordinate point);
    default long amount(Coordinate point) { return 0; }
}
record Grid(int width, int height, List<Cell> cells) implements CoordinateGrid {
    public Cell cell(Coordinate p) { return cells.get(p.y() * width + p.x()); }
    public String terrain(Coordinate p) { return cell(p).type(); }
    public long amount(Coordinate p) { return cell(p).amount() == null ? 0 : cell(p).amount(); }
}
record HighGoldArea(int width, int height, long min, long max, int spawnPercent) {}
record Notice(String entityId, String error, String note) {}
record Effect(String type, Coordinate coordinate, List<String> entityIds, String note,
                     String baseId, String resource, Long amount, Long total) {}
record MarsView(int turn, Grid map, List<Worker> units, List<Base> bases,
                       Map<String, Long> scores, Map<String, Inventory> teamResources,
                       Map<String, Inventory> baseResourceDeposits, HighGoldArea highGoldArea,
                       List<Notice> warnings, List<Notice> info, List<Effect> effects)
        implements CoordinateGrid {
    public int width() { return map.width(); }
    public int height() { return map.height(); }
    public String terrain(Coordinate p) { return map.terrain(p); }
    public long amount(Coordinate p) { return map.amount(p); }
}
record Position(double x, double y) {}
enum Planet { Mercury, Venus, Earth, Mars, Jupiter, Saturn, Uranus, Neptune, Pluto }
record SolarPlanet(Planet name, Position position, double orbitRadius) {}
record Orbit(Planet planet, double angle) {}
record Rocket(String teamId, Position position, Position previousPosition, double heading,
                     Planet target, Position targetPosition, Orbit orbit) {}
record SolarView(int turn, List<SolarPlanet> planets, List<Rocket> rockets, String entityId) {}
enum Resource { @SerializedName("gold") GOLD, @SerializedName("iron") IRON }
enum Direction {
    @SerializedName("north") NORTH, @SerializedName("east") EAST,
    @SerializedName("south") SOUTH, @SerializedName("west") WEST,
    @SerializedName("north-east") NORTH_EAST, @SerializedName("south-east") SOUTH_EAST,
    @SerializedName("south-west") SOUTH_WEST, @SerializedName("north-west") NORTH_WEST
}
sealed interface Action permits MarsAction, SolarAction {}
record MarsAction(String type, Direction direction, Resource from) implements Action {
    public static MarsAction move(Direction direction) { return new MarsAction("move", Objects.requireNonNull(direction), null); }
    public static MarsAction mine() { return new MarsAction("mine", null, null); }
    public static MarsAction deposit() { return new MarsAction("deposit", null, null); }
    public static MarsAction spawn() { return new MarsAction("spawn", null, null); }
    public static MarsAction buildBase() { return new MarsAction("build-base", null, null); }
    public static MarsAction trade(Resource from) { return new MarsAction("trade", null, Objects.requireNonNull(from)); }
    public static MarsAction pass() { return new MarsAction("pass", null, null); }
}
record SolarAction(String type, Double x, Double y) implements Action {
    public static SolarAction target(Planet planet) { return new SolarAction(Objects.requireNonNull(planet).name(), null, null); }
    public static SolarAction move(double x, double y) { return new SolarAction("move", x, y); }
}

public final class BotClient {
    private static final Gson GSON = new Gson();
    private static final java.lang.reflect.Type MARS_STATE = new TypeToken<GameState<MarsView>>() {}.getType();
    private static final java.lang.reflect.Type SOLAR_STATE = new TypeToken<GameState<SolarView>>() {}.getType();
    public static GameState<MarsView> readMarsState(String json) { return GSON.fromJson(json, MARS_STATE); }
    public static GameState<SolarView> readSolarState(String json) { return GSON.fromJson(json, SOLAR_STATE); }
    private final String baseUrl;
    private final String apiKey;
    private String botSessionToken;
    private String botMatchId;
    private String botSessionRevision;
    private boolean recoveringSession;
    private final HttpClient http = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(5)).build();
    public BotClient() {
        this(configuredBaseUrl(), System.getenv().getOrDefault("API_KEY", "replace-with-api-key"));
    }
    private static String configuredBaseUrl() {
        var value = System.getenv("LUCID_API_BASE_URL");
        if (value != null && !value.isBlank()) return value;
        System.err.println("LUCID_API_BASE_URL is not set; using http://localhost:5173.");
        return "http://localhost:5173";
    }
    public BotClient(String baseUrl, String apiKey) {
        if (apiKey == null || apiKey.isBlank() || apiKey.equals("replace-with-api-key"))
            throw new IllegalArgumentException("Set API_KEY to your team's API key before starting the bot.");
        this.baseUrl = baseUrl.replaceAll("/$", "");
        this.apiKey = apiKey;
    }
    private JsonObject request(String method, String path, JsonObject body, boolean retry) throws Exception {
        int attempts = retry ? 3 : 1;
        boolean recoveredSession = false;
        for (int attempt = 0; ; attempt++) {
            var builder = HttpRequest.newBuilder(URI.create(baseUrl + path)).timeout(Duration.ofSeconds(5))
                .header("x-api-key", apiKey).header("content-type", "application/json")
                .method(method, body == null ? HttpRequest.BodyPublishers.noBody() : HttpRequest.BodyPublishers.ofString(body.toString()));
            if (botSessionToken != null) builder.header("x-bot-session", botSessionToken);
            if (path.equals("/api/team/matches/current/register") && botMatchId != null) builder.header("x-bot-match-id", botMatchId);
            if (path.equals("/api/team/matches/current/register")) {
                if (recoveringSession) builder.header("x-bot-session-recover", "1");
                else if (botSessionRevision != null) builder.header("x-bot-session-revision", botSessionRevision);
            }
            try {
                var response = http.send(builder.build(), HttpResponse.BodyHandlers.ofString());
                if (response.statusCode() >= 200 && response.statusCode() < 300) {
                    if (path.equals("/api/team/matches/current/register")) botSessionToken = response.headers().firstValue("x-bot-session").orElse(null);
                    botSessionRevision = response.headers().firstValue("x-bot-session-revision").orElse(botSessionRevision);
                    try {
                        return JsonParser.parseString(response.body()).getAsJsonObject();
                    } catch (RuntimeException error) {
                        throw new IllegalStateException("Cannot read the competition API at " + baseUrl + "; check LUCID_API_BASE_URL.", error);
                    }
                }
                if (response.statusCode() == 409 && response.body().contains("bot_session_missing") && !recoveringSession && !recoveredSession && botMatchId != null && botSessionToken != null && !path.equals("/api/team/matches/current/register")) {
                    recoveredSession = true;
                    recoveringSession = true;
                    try { request("POST", "/api/team/matches/current/register", null, true); }
                    finally { recoveringSession = false; }
                    attempt--;
                    continue;
                }
                if (!Set.of(408, 429, 500, 502, 503, 504).contains(response.statusCode()) || attempt + 1 >= attempts)
                    throw new ApiError(response.statusCode(), response.body());
            } catch (java.io.IOException error) {
                if (attempt + 1 >= attempts) throw new java.io.IOException("Cannot reach " + baseUrl + "; check the URL and your connection.", error);
            }
            Thread.sleep(250L << attempt);
        }
    }
    private static final class ApiError extends Exception {
        final int status;
        final String body;
        ApiError(int status, String body) { super(status == 409 && body.contains("pvp_disabled") ? "No match assigned. PvP practice is currently disabled. Create a practice match on the website or ask an organizer to assign one, then run your bot again." : status == 403 && body.contains("Team is not in an active match.") ? "No current match is available; join a match before starting the bot." : status == 409 && body.contains("bot_session_conflict") ? "This bot was replaced by another client; stopping." : status == 401 || status == 403 ? "API key was rejected; check API_KEY." : status == 404 ? "Cannot reach the competition API; check LUCID_API_BASE_URL." : "Server request failed (HTTP " + status + ")."); this.status = status; this.body = body; }
        boolean sessionConflict() { return status == 409 && body.contains("bot_session_conflict"); }
        boolean inactiveMatch() {
            if (status != 403) return false;
            try {
                var error = JsonParser.parseString(body).getAsJsonObject().getAsJsonObject("error");
                return error.get("code").getAsString().equals("forbidden") && error.get("message").getAsString().equals("Team is not in an active match.");
            } catch (RuntimeException ignored) { return false; }
        }
    }
    private static long checkedRevision(long revision) {
        if (revision < 0 || revision > 9_007_199_254_740_991L) throw new IllegalStateException("Action revision is outside the server's safe integer range.");
        return revision;
    }
    private static boolean terminal(JsonObject state) {
        return Set.of("completed", "cancelled").contains(state.getAsJsonObject("match").get("status").getAsString());
    }
    private boolean matchEnded(String path) throws Exception {
        try {
            return terminal(request("GET", path + "/state", null, true));
        } catch (ApiError error) {
            if (error.status == 401 || error.sessionConflict() || (error.status == 403 && !error.inactiveMatch())) throw error;
            System.err.println(error.getMessage());
        } catch (java.io.IOException error) {
            System.err.println(error.getMessage());
        }
        return false;
    }
    public void run(BiFunction<GameState<MarsView>, Identity, Map<String, MarsAction>> strategy,
                    BiFunction<GameState<SolarView>, Identity, Map<String, SolarAction>> solarStrategy) throws Exception {
        runForRules(null, strategy, solarStrategy);
    }

    public void runMars(BiFunction<GameState<MarsView>, Identity, Map<String, MarsAction>> strategy) throws Exception {
        runForRules("lpc2026", strategy, null);
    }

    public void runSolar(BiFunction<GameState<SolarView>, Identity, Map<String, SolarAction>> strategy) throws Exception {
        runForRules("SolarOrbitor-demo", null, strategy);
    }

    private void runForRules(String rulesVersion,
                    BiFunction<GameState<MarsView>, Identity, Map<String, MarsAction>> strategy,
                    BiFunction<GameState<SolarView>, Identity, Map<String, SolarAction>> solarStrategy) throws Exception {
        var identity = GSON.fromJson(request("POST", "/api/auth/api-key", null, true), Identity.class);
        JsonObject current;
        try { current = request("GET", "/api/team/matches/current/state", null, true); }
        catch (ApiError error) {
            if (!error.inactiveMatch()) throw error;
            String announced = "";
            for (;;) {
                var enrollment = request("POST", "/api/team/matches/current/register", null, false);
                if (enrollment.has("match")) { current = request("GET", "/api/team/matches/current/state", null, true); break; }
                if (!enrollment.has("queue")) throw new IllegalStateException("Unexpected registration response.");
                var queue = enrollment.getAsJsonObject("queue");
                String waiting = queue.has("autoQueue") && !queue.get("autoQueue").getAsBoolean()
                    ? "Waiting for a private practice match. Create one on the website; keep your bot running."
                    : "Waiting in the PvP practice queue; keep your bot running.";
                if (!announced.equals(waiting)) { System.out.println(waiting); announced = waiting; }
                Thread.sleep(1000);
            }
        }
        botMatchId = current.getAsJsonObject("match").get("matchId").getAsString();
        botSessionToken = UUID.randomUUID().toString();
        var state = request("POST", "/api/team/matches/current/register", null, true);
        if (rulesVersion != null && !state.getAsJsonObject("match").get("rulesVersion").getAsString().equals(rulesVersion)) {
            throw new IllegalStateException("This starter does not support the current game. Download its starter from Help.");
        }

        var matchId = state.getAsJsonObject("match").get("matchId").getAsString();
        var path = "/api/team/matches/" + URLEncoder.encode(matchId, StandardCharsets.UTF_8).replace("+", "%20");
        System.out.println("registered for match " + matchId);
        var revisions = new HashMap<String, Long>();
        if (state.has("actionRevisions")) state.getAsJsonObject("actionRevisions").entrySet().forEach(entry -> revisions.put(entry.getKey(), checkedRevision(entry.getValue().getAsLong())));
        int submittedTurn = -1;
        long stateReceivedAt = System.nanoTime();
        while (!terminal(state)) {
            var match = state.getAsJsonObject("match");
            int turn = match.get("turn").getAsInt();
            if (match.get("status").getAsString().equals("active") && state.has("view") && turn != submittedTurn) {
                Map<String, ? extends Action> actions;
                try {
                    actions = match.get("rulesVersion").getAsString().equals("SolarOrbitor-demo")
                        ? solarStrategy.apply(GSON.fromJson(state, SOLAR_STATE), identity)
                        : strategy.apply(GSON.fromJson(state, MARS_STATE), identity);
                    if (actions == null) throw new IllegalArgumentException("Strategy returned null.");
                } catch (RuntimeException error) {
                    System.err.println("Strategy failed on turn " + turn + ": " + error + "; submitting no actions.");
                    actions = Map.of();
                }
                var submissions = new JsonArray();
                for (var entry : actions.entrySet()) {
                    long revision = checkedRevision(revisions.getOrDefault(entry.getKey(), 0L) + 1);
                    revisions.put(entry.getKey(), revision);
                    var submission = new JsonObject();
                    submission.addProperty("entityId", entry.getKey()); submission.addProperty("revision", revision); submission.add("action", GSON.toJsonTree(entry.getValue()));
                    submissions.add(submission);
                }
                var body = new JsonObject();
                body.addProperty("submissionId", UUID.randomUUID().toString()); body.add("submissions", submissions);
                try {
                    var result = request("PUT", path + "/action", body, true);
                    boolean late = state.has("turnEndsAtMs") && !state.get("turnEndsAtMs").isJsonNull()
                        && state.get("serverTimeMs").getAsLong() + (System.nanoTime() - stateReceivedAt) / 1_000_000 >= state.get("turnEndsAtMs").getAsLong();
                    if (result.has("accepted")) for (var accepted : result.getAsJsonArray("accepted")) {
                        var action = accepted.getAsJsonObject();
                        if (action.has("acceptedForTurn") && action.get("acceptedForTurn").getAsInt() > turn + 1) late = true;
                    }
                    if (late) System.err.println("Turn " + turn + " actions were late; use a faster strategy. Fetching the newest state.");
                } catch (Exception error) {
                    if (error instanceof ApiError apiError && (apiError.status == 401 || apiError.status == 403 || apiError.sessionConflict())) {
                        if (!apiError.inactiveMatch()) throw error;
                        if (matchEnded(path)) break;
                    }
                    System.err.println("Could not submit turn " + turn + ": " + error.getMessage() + "; continuing.");
                    try {
                        var empty = new JsonObject();
                        empty.addProperty("submissionId", UUID.randomUUID().toString());
                        empty.add("submissions", new JsonArray());
                        request("PUT", path + "/action", empty, true);
                    } catch (ApiError retryError) {
                        if (retryError.status == 401 || retryError.status == 403 || retryError.sessionConflict()) {
                            if (!retryError.inactiveMatch()) throw retryError;
                            if (matchEnded(path)) break;
                        }
                        System.err.println("Could not submit empty actions: " + retryError.getMessage());
                    } catch (Exception retryError) {
                        System.err.println("Could not submit empty actions: " + retryError.getMessage());
                    }
                }
                submittedTurn = turn;
                try {
                    state = request("GET", path + "/state", null, true);
                    stateReceivedAt = System.nanoTime();
                    if (state.getAsJsonObject("match").get("turn").getAsInt() != turn) continue;
                } catch (Exception error) {
                    if (error instanceof ApiError api && (api.status == 401 || api.status == 403 || api.sessionConflict())) throw error;
                    System.err.println(error.getMessage());
                }
            }
            long delay = 100;
            if (match.has("turnAdvanceMode") && match.get("turnAdvanceMode").getAsString().equals("timed") && state.has("turnEndsAtMs") && !state.get("turnEndsAtMs").isJsonNull()) {
                long remaining = state.get("turnEndsAtMs").getAsLong() - state.get("serverTimeMs").getAsLong() - (System.nanoTime() - stateReceivedAt) / 1_000_000;
                delay = remaining > 0 ? Math.clamp(remaining - 25, 25, 1000) : 100;
            }
            Thread.sleep(delay);
            try {
                state = request("GET", path + "/state", null, true);
                stateReceivedAt = System.nanoTime();
            } catch (Exception error) {
                if (error instanceof ApiError api && (api.status == 401 || api.status == 403 || api.sessionConflict())) throw error;
                System.err.println(error.getMessage());
            }
        }
        System.out.println(state.getAsJsonObject("match").has("cancellationReason") ? state.getAsJsonObject("match").get("cancellationReason").getAsString() : "Match " + matchId + " ended.");
    }
}
View pom.xml

pom.xml

<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
  <modelVersion>4.0.0</modelVersion>
  <groupId>competition</groupId><artifactId>lpc-starter</artifactId><version>1.0</version>
  <properties><maven.compiler.release>21</maven.compiler.release><project.build.sourceEncoding>UTF-8</project.build.sourceEncoding></properties>
  <dependencies><dependency><groupId>com.google.code.gson</groupId><artifactId>gson</artifactId><version>2.14.0</version></dependency></dependencies>
  <build><sourceDirectory>${project.basedir}</sourceDirectory><plugins>
    <plugin><groupId>org.apache.maven.plugins</groupId><artifactId>maven-compiler-plugin</artifactId><version>3.14.1</version></plugin>
    <plugin><groupId>org.codehaus.mojo</groupId><artifactId>exec-maven-plugin</artifactId><version>3.6.3</version><configuration><mainClass>Main</mainClass></configuration></plugin>
  </plugins></build>
</project>

Rust

Use the current stable release of Rust and Cargo. Cargo downloads packages the first time you run the starter.

  1. Save Cargo.toml and Cargo.lock in your client folder. Save the two .rs files in a src subfolder.
  2. Write your strategy in calculate_actions in src/main.rs. Use the team ID in identity to find your workers.
  3. BotClient parses state into structs. Match the game variant, then read the typed view. Game helpers are below the helper banner; give them the view map and bases. Your own grid can implement CoordinateGrid.
Command shell

Run command

API_KEY='your-team-key' LUCID_API_BASE_URL='https://lpc.sh' cargo run --release
View Cargo.toml

Cargo.toml

[package]
name = "lpc-starter"
version = "0.1.0"
edition = "2024"

[dependencies]
reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] }
serde_json = "1.0"
serde = { version = "1.0", features = ["derive"] }
getrandom = "0.4"
View Cargo.lock

Cargo.lock

# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4

[[package]]
name = "atomic-waker"
version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"

[[package]]
name = "base64"
version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"

[[package]]
name = "base64"
version = "0.23.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"

[[package]]
name = "bitflags"
version = "2.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"

[[package]]
name = "bumpalo"
version = "3.20.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"

[[package]]
name = "bytes"
version = "1.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"

[[package]]
name = "cc"
version = "1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877bf67ac8e52449eb2f9e98a344ea163beb70dd1f9eebe3cc5357dcfeaee4df"
dependencies = [
 "find-msvc-tools",
 "shlex",
]

[[package]]
name = "cfg-if"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"

[[package]]
name = "cfg_aliases"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"

[[package]]
name = "chacha20"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06"
dependencies = [
 "cfg-if",
 "cpufeatures",
 "rand_core",
]

[[package]]
name = "cpufeatures"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566"
dependencies = [
 "libc",
]

[[package]]
name = "displaydoc"
version = "0.2.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8"
dependencies = [
 "proc-macro2",
 "quote",
 "syn",
]

[[package]]
name = "find-msvc-tools"
version = "0.1.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48"

[[package]]
name = "form_urlencoded"
version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
dependencies = [
 "percent-encoding",
]

[[package]]
name = "futures-channel"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4"
dependencies = [
 "futures-core",
 "futures-sink",
]

[[package]]
name = "futures-core"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"

[[package]]
name = "futures-io"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed"

[[package]]
name = "futures-sink"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d"

[[package]]
name = "futures-task"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd"

[[package]]
name = "futures-util"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
dependencies = [
 "futures-core",
 "futures-io",
 "futures-sink",
 "futures-task",
 "memchr",
 "pin-project-lite",
 "slab",
]

[[package]]
name = "getrandom"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
dependencies = [
 "cfg-if",
 "js-sys",
 "libc",
 "wasi",
 "wasm-bindgen",
]

[[package]]
name = "getrandom"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
dependencies = [
 "cfg-if",
 "js-sys",
 "libc",
 "r-efi",
 "rand_core",
 "wasm-bindgen",
]

[[package]]
name = "http"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0"
dependencies = [
 "bytes",
 "itoa",
]

[[package]]
name = "http-body"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c"
dependencies = [
 "bytes",
 "http",
]

[[package]]
name = "http-body-util"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c"
dependencies = [
 "bytes",
 "futures-core",
 "http",
 "http-body",
 "pin-project-lite",
]

[[package]]
name = "httparse"
version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"

[[package]]
name = "hyper"
version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2c3e324da4c95177d6291d4c8730197c0d1822f8a9766814a4a44fa5ab797c9c"
dependencies = [
 "atomic-waker",
 "bytes",
 "futures-core",
 "http",
 "http-body",
 "httparse",
 "itoa",
 "pin-project-lite",
 "smallvec",
 "tokio",
 "want",
]

[[package]]
name = "hyper-rustls"
version = "0.27.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53"
dependencies = [
 "http",
 "hyper",
 "hyper-util",
 "rustls",
 "tokio",
 "tokio-rustls",
 "tower-service",
 "webpki-roots",
]

[[package]]
name = "hyper-util"
version = "0.1.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff"
dependencies = [
 "base64 0.23.1",
 "bytes",
 "futures-channel",
 "futures-util",
 "http",
 "http-body",
 "httparse",
 "hyper",
 "ipnet",
 "libc",
 "percent-encoding",
 "pin-project-lite",
 "socket2",
 "tokio",
 "tower-service",
 "tracing",
]

[[package]]
name = "icu_collections"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513"
dependencies = [
 "displaydoc",
 "potential_utf",
 "utf8_iter",
 "yoke",
 "zerofrom",
 "zerovec",
]

[[package]]
name = "icu_locale_core"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb"
dependencies = [
 "displaydoc",
 "litemap",
 "tinystr",
 "writeable",
 "zerovec",
]

[[package]]
name = "icu_normalizer"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f"
dependencies = [
 "icu_collections",
 "icu_normalizer_data",
 "icu_properties",
 "icu_provider",
 "smallvec",
 "zerovec",
]

[[package]]
name = "icu_normalizer_data"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0"

[[package]]
name = "icu_properties"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148"
dependencies = [
 "displaydoc",
 "icu_collections",
 "icu_locale_core",
 "icu_properties_data",
 "icu_provider",
 "zerotrie",
 "zerovec",
]

[[package]]
name = "icu_properties_data"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa"

[[package]]
name = "icu_provider"
version = "2.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73"
dependencies = [
 "displaydoc",
 "icu_locale_core",
 "writeable",
 "yoke",
 "zerofrom",
 "zerotrie",
 "zerovec",
]

[[package]]
name = "idna"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"
dependencies = [
 "idna_adapter",
 "smallvec",
 "utf8_iter",
]

[[package]]
name = "idna_adapter"
version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714"
dependencies = [
 "icu_normalizer",
 "icu_properties",
]

[[package]]
name = "ipnet"
version = "2.12.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0"

[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"

[[package]]
name = "js-sys"
version = "0.3.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5"
dependencies = [
 "cfg-if",
 "futures-util",
 "wasm-bindgen",
]

[[package]]
name = "libc"
version = "0.2.191"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a"

[[package]]
name = "litemap"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae"

[[package]]
name = "log"
version = "0.4.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"

[[package]]
name = "lpc-starter"
version = "0.1.0"
dependencies = [
 "getrandom 0.4.3",
 "reqwest",
 "serde",
 "serde_json",
]

[[package]]
name = "lru-slab"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f"

[[package]]
name = "memchr"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"

[[package]]
name = "mio"
version = "1.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee"
dependencies = [
 "libc",
 "wasi",
 "windows-sys 0.61.2",
]

[[package]]
name = "once_cell"
version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"

[[package]]
name = "percent-encoding"
version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"

[[package]]
name = "pin-project-lite"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"

[[package]]
name = "potential_utf"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661"
dependencies = [
 "zerovec",
]

[[package]]
name = "proc-macro2"
version = "1.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
dependencies = [
 "unicode-ident",
]

[[package]]
name = "quinn"
version = "0.11.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4051e23e9185c255a7e33ef59cdbca87a22d359052eecd22fc6b901fb37d9d11"
dependencies = [
 "bytes",
 "cfg_aliases",
 "pin-project-lite",
 "quinn-proto",
 "quinn-udp",
 "rustc-hash",
 "rustls",
 "socket2",
 "thiserror",
 "tokio",
 "tracing",
 "web-time",
]

[[package]]
name = "quinn-proto"
version = "0.11.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0e750cca55fe4f0439a15d0bb529da9651e79993e8e72c61a899a36d462befbe"
dependencies = [
 "bytes",
 "getrandom 0.4.3",
 "lru-slab",
 "rand",
 "rand_pcg",
 "ring",
 "rustc-hash",
 "rustls",
 "rustls-pki-types",
 "slab",
 "thiserror",
 "tinyvec",
 "tracing",
 "web-time",
]

[[package]]
name = "quinn-udp"
version = "0.5.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af66907df18639dcf4db56ca65490cabc4b27a97dbadd96f2926cca73298f016"
dependencies = [
 "cfg_aliases",
 "libc",
 "once_cell",
 "socket2",
 "tracing",
 "windows-sys 0.52.0",
]

[[package]]
name = "quote"
version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
dependencies = [
 "proc-macro2",
]

[[package]]
name = "r-efi"
version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"

[[package]]
name = "rand"
version = "0.10.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af"
dependencies = [
 "chacha20",
 "getrandom 0.4.3",
 "rand_core",
]

[[package]]
name = "rand_core"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"

[[package]]
name = "rand_pcg"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a"
dependencies = [
 "rand_core",
]

[[package]]
name = "reqwest"
version = "0.12.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
dependencies = [
 "base64 0.22.1",
 "bytes",
 "futures-channel",
 "futures-core",
 "futures-util",
 "http",
 "http-body",
 "http-body-util",
 "hyper",
 "hyper-rustls",
 "hyper-util",
 "js-sys",
 "log",
 "percent-encoding",
 "pin-project-lite",
 "quinn",
 "rustls",
 "rustls-pki-types",
 "serde",
 "serde_json",
 "serde_urlencoded",
 "sync_wrapper",
 "tokio",
 "tokio-rustls",
 "tower",
 "tower-http",
 "tower-service",
 "url",
 "wasm-bindgen",
 "wasm-bindgen-futures",
 "web-sys",
 "webpki-roots",
]

[[package]]
name = "ring"
version = "0.17.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
dependencies = [
 "cc",
 "cfg-if",
 "getrandom 0.2.17",
 "libc",
 "untrusted",
 "windows-sys 0.52.0",
]

[[package]]
name = "rustc-hash"
version = "2.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d"

[[package]]
name = "rustls"
version = "0.23.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf"
dependencies = [
 "once_cell",
 "ring",
 "rustls-pki-types",
 "rustls-webpki",
 "subtle",
 "zeroize",
]

[[package]]
name = "rustls-pki-types"
version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
dependencies = [
 "web-time",
 "zeroize",
]

[[package]]
name = "rustls-webpki"
version = "0.103.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281"
dependencies = [
 "ring",
 "rustls-pki-types",
 "untrusted",
]

[[package]]
name = "rustversion"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"

[[package]]
name = "ryu"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"

[[package]]
name = "serde"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
dependencies = [
 "serde_core",
 "serde_derive",
]

[[package]]
name = "serde_core"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
dependencies = [
 "serde_derive",
]

[[package]]
name = "serde_derive"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
 "proc-macro2",
 "quote",
 "syn",
]

[[package]]
name = "serde_json"
version = "1.0.154"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
dependencies = [
 "itoa",
 "memchr",
 "serde",
 "serde_core",
 "zmij",
]

[[package]]
name = "serde_urlencoded"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
dependencies = [
 "form_urlencoded",
 "itoa",
 "ryu",
 "serde",
]

[[package]]
name = "shlex"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"

[[package]]
name = "slab"
version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"

[[package]]
name = "smallvec"
version = "1.16.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b3dc8af474f516a851ff4bd12db780f948b9250ad37211e4eec0bccea54e01b"

[[package]]
name = "socket2"
version = "0.6.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
dependencies = [
 "libc",
 "windows-sys 0.61.2",
]

[[package]]
name = "stable_deref_trait"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"

[[package]]
name = "subtle"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"

[[package]]
name = "syn"
version = "3.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
dependencies = [
 "proc-macro2",
 "quote",
 "unicode-ident",
]

[[package]]
name = "sync_wrapper"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
dependencies = [
 "futures-core",
]

[[package]]
name = "synstructure"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02"
dependencies = [
 "proc-macro2",
 "quote",
 "syn",
]

[[package]]
name = "thiserror"
version = "2.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e"
dependencies = [
 "thiserror-impl",
]

[[package]]
name = "thiserror-impl"
version = "2.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524"
dependencies = [
 "proc-macro2",
 "quote",
 "syn",
]

[[package]]
name = "tinystr"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643"
dependencies = [
 "displaydoc",
 "zerovec",
]

[[package]]
name = "tinyvec"
version = "1.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee"

[[package]]
name = "tokio"
version = "1.54.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a0c168a474b95bfecfe908a65d4bdffc523144b649a74c09c286afb35d53e0bc"
dependencies = [
 "bytes",
 "libc",
 "mio",
 "pin-project-lite",
 "socket2",
 "windows-sys 0.61.2",
]

[[package]]
name = "tokio-rustls"
version = "0.26.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db"
dependencies = [
 "rustls",
 "tokio",
]

[[package]]
name = "tower"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4"
dependencies = [
 "futures-core",
 "futures-util",
 "pin-project-lite",
 "sync_wrapper",
 "tokio",
 "tower-layer",
 "tower-service",
]

[[package]]
name = "tower-http"
version = "0.6.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
dependencies = [
 "bitflags",
 "bytes",
 "futures-util",
 "http",
 "http-body",
 "pin-project-lite",
 "tower",
 "tower-layer",
 "tower-service",
 "url",
]

[[package]]
name = "tower-layer"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e"

[[package]]
name = "tower-service"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"

[[package]]
name = "tracing"
version = "0.1.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
dependencies = [
 "pin-project-lite",
 "tracing-core",
]

[[package]]
name = "tracing-core"
version = "0.1.36"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
dependencies = [
 "once_cell",
]

[[package]]
name = "try-lock"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"

[[package]]
name = "unicode-ident"
version = "1.0.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"

[[package]]
name = "untrusted"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"

[[package]]
name = "url"
version = "2.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"
dependencies = [
 "form_urlencoded",
 "idna",
 "percent-encoding",
 "serde",
]

[[package]]
name = "utf8_iter"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"

[[package]]
name = "want"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec4cdd0dd910afe868b7ef477227d8d538b46b3075031afee8a9f2acb0a2ed0b"
dependencies = [
 "try-lock",
]

[[package]]
name = "wasi"
version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"

[[package]]
name = "wasm-bindgen"
version = "0.2.129"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409"
dependencies = [
 "cfg-if",
 "once_cell",
 "rustversion",
 "wasm-bindgen-macro",
 "wasm-bindgen-shared",
]

[[package]]
name = "wasm-bindgen-futures"
version = "0.4.79"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3cbab34de2d982e9b48e18d216d04c4a6f641066ff19ffb699980f591ee3610e"
dependencies = [
 "js-sys",
 "tokio",
 "wasm-bindgen",
]

[[package]]
name = "wasm-bindgen-macro"
version = "0.2.129"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535"
dependencies = [
 "quote",
 "wasm-bindgen-macro-support",
]

[[package]]
name = "wasm-bindgen-macro-support"
version = "0.2.129"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7"
dependencies = [
 "bumpalo",
 "proc-macro2",
 "quote",
 "syn",
 "wasm-bindgen-shared",
]

[[package]]
name = "wasm-bindgen-shared"
version = "0.2.129"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6"
dependencies = [
 "unicode-ident",
]

[[package]]
name = "web-sys"
version = "0.3.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4"
dependencies = [
 "js-sys",
 "wasm-bindgen",
]

[[package]]
name = "web-time"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
dependencies = [
 "js-sys",
 "wasm-bindgen",
]

[[package]]
name = "webpki-roots"
version = "1.0.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1c1c8e20c8a38fc7d65abfe74eb7280e4aa667e8eec8bc84996d2874810748d9"
dependencies = [
 "rustls-pki-types",
]

[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"

[[package]]
name = "windows-sys"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
dependencies = [
 "windows-targets",
]

[[package]]
name = "windows-sys"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [
 "windows-link",
]

[[package]]
name = "windows-targets"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
dependencies = [
 "windows_aarch64_gnullvm",
 "windows_aarch64_msvc",
 "windows_i686_gnu",
 "windows_i686_gnullvm",
 "windows_i686_msvc",
 "windows_x86_64_gnu",
 "windows_x86_64_gnullvm",
 "windows_x86_64_msvc",
]

[[package]]
name = "windows_aarch64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"

[[package]]
name = "windows_aarch64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"

[[package]]
name = "windows_i686_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"

[[package]]
name = "windows_i686_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"

[[package]]
name = "windows_i686_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"

[[package]]
name = "windows_x86_64_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"

[[package]]
name = "windows_x86_64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"

[[package]]
name = "windows_x86_64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"

[[package]]
name = "writeable"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc"

[[package]]
name = "yoke"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5"
dependencies = [
 "stable_deref_trait",
 "yoke-derive",
 "zerofrom",
]

[[package]]
name = "yoke-derive"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71"
dependencies = [
 "proc-macro2",
 "quote",
 "syn",
 "synstructure",
]

[[package]]
name = "zerofrom"
version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272"
dependencies = [
 "zerofrom-derive",
]

[[package]]
name = "zerofrom-derive"
version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a"
dependencies = [
 "proc-macro2",
 "quote",
 "syn",
 "synstructure",
]

[[package]]
name = "zeroize"
version = "1.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879"

[[package]]
name = "zerotrie"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f"
dependencies = [
 "displaydoc",
 "yoke",
 "zerofrom",
]

[[package]]
name = "zerovec"
version = "0.11.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8"
dependencies = [
 "yoke",
 "zerofrom",
 "zerovec-derive",
]

[[package]]
name = "zerovec-derive"
version = "0.11.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da"
dependencies = [
 "proc-macro2",
 "quote",
 "syn",
]

[[package]]
name = "zmij"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
View main.rs

main.rs

pub mod bot_client;

use bot_client::*;

fn calculate_actions(state: &GameState, identity: &Identity) -> Actions {
    let Some(GameView::Lpc(view)) = &state.view else {
        return Actions::new();
    };
    let mut actions = Actions::new();

    for worker in &view.units {
        if worker.team_id != identity.team_id {
            continue;
        }

        // Strategy goes here.

        actions.insert(worker.unit_id.clone(), Action::Lpc(MarsAction::Pass));
    }

    actions
}

// Game helpers. Feel free to use, discard, or modify.
// Fill in the strategy section above.
use std::cmp::Reverse;
use std::collections::{BinaryHeap, HashMap};

pub type Coordinate = (i32, i32);

pub trait CoordinateGrid {
    fn width(&self) -> i32;
    fn height(&self) -> i32;
    fn terrain(&self, p: Coordinate) -> &str;
    fn amount(&self, _p: Coordinate) -> u64 {
        0
    }
}

impl CoordinateGrid for GameMap {
    fn width(&self) -> i32 {
        self.width
    }

    fn height(&self) -> i32 {
        self.height
    }

    fn terrain(&self, (x, y): Coordinate) -> &str {
        match &self.cells[(y * self.width + x) as usize] {
            Tile::Empty => "empty",
            Tile::Mountain => "mountain",
            Tile::Crater { .. } => "crater",
            Tile::Gold { .. } => "gold",
            Tile::Iron { .. } => "iron",
            Tile::Hidden => "hidden",
            Tile::Void => "void",
            Tile::Unit { .. } => "unit",
        }
    }

    fn amount(&self, (x, y): Coordinate) -> u64 {
        match self.cells[(y * self.width + x) as usize] {
            Tile::Gold { amount } | Tile::Iron { amount } => amount,
            _ => 0,
        }
    }
}

fn in_bounds(grid: &impl CoordinateGrid, (x, y): Coordinate) -> bool {
    x >= 0 && y >= 0 && x < grid.width() && y < grid.height()
}

pub fn is_claimed(bases: &[Base], p: Coordinate) -> Vec<&str> {
    let mut owners = Vec::new();
    for base in bases {
        let owner = &base.team_id;
        let position = (base.position.x, base.position.y);
        if (position.0 - p.0).abs() + (position.1 - p.1).abs() <= 2
            && !owners.contains(&owner.as_str())
        {
            owners.push(owner.as_str());
        }
    }
    owners
}

pub fn is_base(bases: &[Base], p: Coordinate) -> Option<&str> {
    bases
        .iter()
        .find(|base| (base.position.x, base.position.y) == p)
        .map(|base| base.team_id.as_str())
}

pub fn is_ore(grid: &impl CoordinateGrid, p: Coordinate) -> Option<Resource> {
    if !in_bounds(grid, p) {
        return None;
    }
    let terrain = grid.terrain(p);
    if matches!(terrain, "gold" | "iron") && grid.amount(p) > 0 {
        Some(if terrain == "gold" {
            Resource::Gold
        } else {
            Resource::Iron
        })
    } else {
        None
    }
}

pub fn can_move_onto(
    grid: &impl CoordinateGrid,
    bases: &[Base],
    p: Coordinate,
    team_id: Option<&str>,
) -> bool {
    in_bounds(grid, p)
        && !matches!(grid.terrain(p), "hidden" | "void")
        && is_base(bases, p).is_none_or(|owner| Some(owner) == team_id)
}

/// Cost until ready at destination; start must be ready. None means no known route.
pub fn path_to(
    grid: &impl CoordinateGrid,
    start: Coordinate,
    end: Coordinate,
    bases: &[Base],
    team_id: Option<&str>,
    obstacle_cost: u32,
) -> Option<Vec<Coordinate>> {
    if obstacle_cost == 0
        || !can_move_onto(grid, bases, start, team_id)
        || !can_move_onto(grid, bases, end, team_id)
    {
        return None;
    }

    let mut distances = HashMap::from([(start, 0u32)]);
    let mut previous = HashMap::new();
    let mut queue = BinaryHeap::from([Reverse((0u32, start))]);

    while let Some(Reverse((distance, p))) = queue.pop() {
        if distance != distances[&p] {
            continue;
        }
        if p == end {
            break;
        }
        for dy in -1..=1 {
            for dx in -1..=1 {
                if dx == 0 && dy == 0 {
                    continue;
                }
                let next = (p.0 + dx, p.1 + dy);
                if !can_move_onto(grid, bases, next, team_id) {
                    continue;
                }
                let cost = distances[&p]
                    + if matches!(grid.terrain(next), "mountain" | "crater") {
                        obstacle_cost
                    } else {
                        1
                    };
                if cost >= *distances.get(&next).unwrap_or(&u32::MAX) {
                    continue;
                }
                distances.insert(next, cost);
                previous.insert(next, p);
                queue.push(Reverse((cost, next)));
            }
        }
    }
    if !distances.contains_key(&end) {
        return None;
    }
    let (mut path, mut p) = (Vec::new(), end);
    while p != start {
        path.push(p);
        p = previous[&p];
    }
    path.reverse();
    Some(path)
}

fn main() {
    if let Err(error) = bot_client::BotClient::new()
        .and_then(|mut client| client.run_for_rules("lpc2026", calculate_actions))
    {
        eprintln!("{error}");
        std::process::exit(1);
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use serde_json::json;

    fn envelope(view: serde_json::Value, rules: &str) -> serde_json::Value {
        json!({
            "match": {
                "matchId": "practice", "mode": "practice", "status": "active",
                "turnAdvanceMode": "client-action", "rulesVersion": rules,
                "turn": 4, "maxTurns": 480, "teams": [], "expectedTeamIds": ["a"],
                "teamIds": ["a"], "activeTeamIds": ["a"]
            },
            "view": view, "serverTimeMs": 1000, "actionRevisions": {"worker-a": 2}
        })
    }

    #[test]
    fn typed_state_preserves_hidden_inventory() {
        let state: GameState = serde_json::from_value(envelope(json!({
            "turn": 4,
            "map": {"width": 2, "height": 1, "cells": [{"type": "gold", "amount": 7}, {"type": "empty"}]},
            "units": [
                {"unitId":"worker-a", "teamId":"a", "position":{"x":0,"y":0}, "readyOnTurn":6, "inventoryCapacity":8, "inventory":{"gold":2,"iron":1}},
                {"unitId":"worker-b", "teamId":"b", "position":{"x":1,"y":0}, "readyOnTurn":0, "inventoryCapacity":8}
            ],
            "bases": [{"baseId":"base-a", "teamId":"a", "position":{"x":1,"y":0}}],
            "scores":{"a":3}, "teamResources":{"a":{"gold":3,"iron":0}},
            "highGoldArea":{"width":16,"height":16,"min":12,"max":18,"spawnPercent":35},
            "warnings":[], "info":[], "effects":[]
        }), "lpc2026")).unwrap();
        let identity: Identity = serde_json::from_value(json!({"teamId":"a"})).unwrap();
        let Some(GameView::Lpc(view)) = &state.view else {
            panic!("expected LPC view")
        };
        assert_eq!(
            view.units[0].inventory,
            Some(Inventory { gold: 2, iron: 1 })
        );
        assert_eq!(view.units[1].inventory, None);
        assert_eq!(view.units[0].ready_on_turn, 6);
        assert_eq!(is_ore(&view.map, (0, 0)), Some(Resource::Gold));
        assert_eq!(is_claimed(&view.bases, (0, 0)), vec!["a"]);
        assert_eq!(state.action_revisions["worker-a"], 2);
        assert_eq!(
            serde_json::to_value(calculate_actions(&state, &identity)).unwrap(),
            json!({"worker-a":{"type":"pass"}})
        );
        assert_eq!(
            serde_json::to_value(MarsAction::Move {
                direction: Direction::NorthEast
            })
            .unwrap(),
            json!({"type":"move", "direction":"north-east"})
        );
        assert_eq!(
            serde_json::to_value(MarsAction::Trade {
                from: Resource::Iron
            })
            .unwrap(),
            json!({"type":"trade", "from":"iron"})
        );

        let mut pending = envelope(serde_json::Value::Null, "lpc2026");
        pending["match"]["status"] = json!("initializing");
        let pending: GameState = serde_json::from_value(pending).unwrap();
        assert!(pending.view.is_none());
        assert!(calculate_actions(&pending, &identity).is_empty());
    }

    #[test]
    fn invalid_view_names_the_field_in_the_selected_rules() {
        let error = serde_json::from_value::<GameState>(envelope(
            json!({
                "turn": 4,
                "map": {"width":1,"height":1,"cells":[]},
                "units": "this should be a list"
            }),
            "lpc2026",
        ))
        .unwrap_err();
        assert!(error.to_string().contains("expected a sequence"), "{error}");
        assert!(!error.to_string().contains("untagged enum"), "{error}");
        let error = serde_json::from_value::<GameState>(envelope(
            json!({
                "turn": 4,
                "units": [],
                "bases": []
            }),
            "lpc2026",
        ))
        .unwrap_err();
        assert!(error.to_string().contains("missing field `map`"), "{error}");
    }

    #[test]
    fn visible_claims_and_weighted_routes() {
        let mut grid: GameMap = serde_json::from_value(
            json!({"width": 5, "height": 3, "cells": vec![json!({"type": "empty"}); 15]}),
        )
        .unwrap();
        grid.cells[6] = serde_json::from_value(json!({"type": "mountain"})).unwrap();
        grid.cells[9] = serde_json::from_value(json!({"type": "hidden"})).unwrap();
        grid.cells[14] = serde_json::from_value(json!({"type": "void"})).unwrap();
        grid.cells[2] = serde_json::from_value(json!({"type": "gold", "amount": 5})).unwrap();
        grid.cells[12] = serde_json::from_value(json!({"type": "iron", "amount": 0})).unwrap();
        let bases: Vec<Base> = serde_json::from_value(json!([
            {"baseId":"a-base","teamId":"a","position":{"x":0,"y":1}},
            {"baseId":"b-base","teamId":"b","position":{"x":4,"y":1}}
        ]))
        .unwrap();
        assert_eq!(is_claimed(&bases, (2, 1)), vec!["a", "b"]);
        assert_eq!(is_base(&bases, (0, 1)), Some("a"));
        assert_eq!(is_ore(&grid, (2, 0)), Some(Resource::Gold));
        assert_eq!(is_ore(&grid, (2, 2)), None);
        let route = path_to(&grid, (0, 1), (2, 1), &bases, Some("a"), 3).unwrap();
        assert_eq!(route.len(), 2);
        assert_ne!(route[0], (1, 1));
        assert_eq!(route.last(), Some(&(2, 1)));
        assert!(!can_move_onto(&grid, &bases, (4, 1), Some("a")));
        grid.cells[9] = serde_json::from_value(json!({"type": "empty"})).unwrap();
        assert!(!can_move_onto(&grid, &bases, (4, 1), Some("a")));
        assert!(can_move_onto(&grid, &bases, (4, 1), Some("b")));
        assert_eq!(path_to(&grid, (0, 1), (4, 1), &bases, Some("a"), 3), None);
        assert_eq!(path_to(&grid, (0, 1), (4, 2), &bases, Some("a"), 3), None);
        assert_eq!(
            path_to(&grid, (0, 1), (0, 1), &bases, Some("a"), 3),
            Some(vec![])
        );
    }
}
View bot_client.rs

bot_client.rs

use reqwest::{blocking::Client as HttpClient, Method};
use serde::{de::DeserializeOwned, Deserialize, Serialize};
use serde_json::{json, Value};
use std::{
    collections::HashMap,
    env,
    error::Error,
    thread,
    time::{Duration, Instant, SystemTime, UNIX_EPOCH},
};

#[derive(Debug)]
struct ApiError {
    status: u16,
    body: String,
}
impl std::fmt::Display for ApiError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        if self.inactive_match() {
            return write!(
                f,
                "No current match is available; join a match before starting the bot."
            );
        }
        match self.status {
            409 if self.body.contains("pvp_disabled") => write!(f, "No match assigned. PvP practice is currently disabled. Create a practice match on the website or ask an organizer to assign one, then run your bot again."),
            409 if self.body.contains("bot_session_conflict") => write!(
                f,
                "This bot was replaced by another client for this team and match."
            ),
            401 | 403 => write!(f, "API key was rejected; check API_KEY."),
            404 => write!(
                f,
                "Cannot reach the competition API; check LUCID_API_BASE_URL."
            ),
            status => write!(f, "Server request failed (HTTP {status})."),
        }
    }
}
impl Error for ApiError {}
impl ApiError {
    fn fatal(&self) -> bool {
        self.status == 401
            || self.status == 403
            || (self.status == 409 && self.body.contains("bot_session_conflict"))
    }
    fn inactive_match(&self) -> bool {
        self.status == 403
            && serde_json::from_str::<Value>(&self.body).is_ok_and(|value| {
                value["error"]["code"] == "forbidden"
                    && value["error"]["message"] == "Team is not in an active match."
            })
    }
}

pub struct BotClient {
    http: HttpClient,
    base_url: String,
    api_key: String,
    bot_session_token: Option<String>,
    bot_match_id: Option<String>,
    bot_session_revision: Option<String>,
    recovering_session: bool,
}

impl BotClient {
    pub fn new() -> Result<Self, Box<dyn Error>> {
        let api_key = env::var("API_KEY").unwrap_or_default();
        if api_key.trim().is_empty() || api_key == "replace-with-api-key" {
            return Err("Set API_KEY to your team's API key before starting the bot.".into());
        }
        let base_url = env::var("LUCID_API_BASE_URL")
            .ok()
            .filter(|url| !url.trim().is_empty())
            .unwrap_or_else(|| {
                eprintln!("LUCID_API_BASE_URL is not set; using http://localhost:5173.");
                "http://localhost:5173".into()
            });
        Ok(Self {
            http: HttpClient::builder()
                .timeout(Duration::from_secs(5))
                .build()?,
            base_url: base_url.trim_end_matches('/').into(),
            bot_session_token: None,
            bot_match_id: None,
            bot_session_revision: None,
            recovering_session: false,
            api_key,
        })
    }
    fn request<T: DeserializeOwned>(
        &mut self,
        method: Method,
        path: &str,
        body: Option<&Value>,
        retry: bool,
    ) -> Result<T, Box<dyn Error>> {
        let attempts = if retry { 3 } else { 1 };
        for attempt in 0..attempts {
            let mut request = self
                .http
                .request(method.clone(), format!("{}{}", self.base_url, path))
                .header("x-api-key", &self.api_key);
            if let Some(token) = &self.bot_session_token {
                request = request.header("x-bot-session", token);
            }
            if path == "/api/team/matches/current/register" {
                if let Some(match_id) = &self.bot_match_id {
                    request = request.header("x-bot-match-id", match_id);
                }
            }
            if let Some(body) = body {
                request = request.json(body);
            }
            if path == "/api/team/matches/current/register" {
                if self.recovering_session { request = request.header("x-bot-session-recover", "1"); }
                else if let Some(revision) = &self.bot_session_revision { request = request.header("x-bot-session-revision", revision); }
            }
            match request.send() {
                Ok(response) => {
                    let status = response.status();
                    if status.is_success() {
                        if let Some(revision) = response.headers().get("x-bot-session-revision").and_then(|value| value.to_str().ok()) {
                            self.bot_session_revision = Some(revision.to_owned());
                        }
                        if path == "/api/team/matches/current/register" {
                            self.bot_session_token = response
                                .headers()
                                .get("x-bot-session")
                                .and_then(|token| token.to_str().ok())
                                .map(String::from);
                        }
                        let value: Value = response.json().map_err(|_| {
                            format!(
                                "Cannot read the competition API at {}; check LUCID_API_BASE_URL.",
                                self.base_url
                            )
                        })?;
                        if !value.is_object() {
                            return Err(format!(
                                "Cannot read the competition API at {}; check LUCID_API_BASE_URL.",
                                self.base_url
                            )
                            .into());
                        }
                        return Ok(serde_json::from_value(value)?);
                    }
                    let text = response.text()?;
                    if status.as_u16() == 409 && text.contains("bot_session_missing") && !self.recovering_session && self.bot_match_id.is_some() && self.bot_session_token.is_some() && path != "/api/team/matches/current/register" {
                        self.recovering_session = true;
                        let result = (|| {
                            let _: Value = self.request(Method::POST, "/api/team/matches/current/register", None, true)?;
                            self.request(method.clone(), path, body, retry)
                        })();
                        self.recovering_session = false;
                        return result;
                    }
                    if ![408, 429, 500, 502, 503, 504].contains(&status.as_u16())
                        || attempt + 1 == attempts
                    {
                        return Err(ApiError {
                            status: status.as_u16(),
                            body: text,
                        }
                        .into());
                    }
                }
                Err(error) => {
                    if attempt + 1 == attempts {
                        return Err(format!(
                            "Cannot reach {}; check the URL and your connection. ({error})",
                            self.base_url
                        )
                        .into());
                    }
                }
            }
            thread::sleep(Duration::from_millis(250 << attempt));
        }
        unreachable!()
    }
    fn match_ended(&mut self, path: &str) -> Result<bool, Box<dyn Error>> {
        match self.request::<GameState>(Method::GET, &format!("{path}/state"), None, true) {
            Ok(state) => Ok(state.terminal()),
            Err(error) => {
                if error
                    .downcast_ref::<ApiError>()
                    .is_some_and(|api| api.fatal() && !api.inactive_match())
                {
                    return Err(error);
                }
                eprintln!("{error}");
                Ok(false)
            }
        }
    }
    pub fn run(
        &mut self,
        strategy: impl Fn(&GameState, &Identity) -> Actions + std::panic::RefUnwindSafe,
    ) -> Result<(), Box<dyn Error>> {
        self.run_for_rules("", strategy)
    }

    pub fn run_for_rules(
        &mut self,
        rules_version: &str,
        strategy: impl Fn(&GameState, &Identity) -> Actions + std::panic::RefUnwindSafe,
    ) -> Result<(), Box<dyn Error>> {
        let identity: Identity = self.request(Method::POST, "/api/auth/api-key", None, true)?;
        let current: GameState = match self.request(Method::GET, "/api/team/matches/current/state", None, true) {
            Ok(current) => current,
            Err(error) => {
                if !error.downcast_ref::<ApiError>().is_some_and(|api| api.inactive_match()) { return Err(error); }
                let mut announced = "";
                loop {
                    let enrollment: Value = self.request(Method::POST, "/api/team/matches/current/register", None, false)?;
                    if enrollment.get("match").is_some() { break self.request(Method::GET, "/api/team/matches/current/state", None, true)?; }
                    if enrollment.get("queue").is_none() { return Err("Unexpected registration response.".into()); }
                    let waiting = if enrollment["queue"]["autoQueue"].as_bool() == Some(false) {
                        "Waiting for a private practice match. Create one on the website; keep your bot running."
                    } else { "Waiting in the PvP practice queue; keep your bot running." };
                    if announced != waiting { println!("{waiting}"); announced = waiting; }
                    thread::sleep(Duration::from_secs(1));
                }
            }
        };
        self.bot_match_id = Some(current.match_info.match_id);
        let mut token = [0u8; 16];
        getrandom::fill(&mut token)
            .map_err(|error| format!("Could not create a bot session: {error}"))?;
        token[6] = (token[6] & 0x0f) | 0x40;
        token[8] = (token[8] & 0x3f) | 0x80;
        let hex: String = token.iter().map(|byte| format!("{byte:02x}")).collect();
        self.bot_session_token = Some(format!(
            "{}-{}-{}-{}-{}",
            &hex[..8],
            &hex[8..12],
            &hex[12..16],
            &hex[16..20],
            &hex[20..]
        ));
        let mut state: GameState = self.request(
            Method::POST,
            "/api/team/matches/current/register",
            None,
            true,
        )?;
        if !rules_version.is_empty() && state.match_info.rules_version != rules_version {
            return Err(
                "This starter does not support the current game. Download its starter from Help."
                    .into(),
            );
        }

        let match_id = state.match_info.match_id.clone();
        let mut url = reqwest::Url::parse(&format!("{}/api/team/matches/", self.base_url))?;
        url.path_segments_mut()
            .map_err(|_| "Invalid base URL")?
            .pop_if_empty()
            .push(&match_id);
        let path = url.path().to_owned();
        println!("registered for match {match_id}");
        let mut revisions = HashMap::<String, u64>::new();
        for (entity, revision) in &state.action_revisions {
            if *revision > 9_007_199_254_740_991 {
                return Err("Invalid revision".into());
            }
            revisions.insert(entity.clone(), *revision);
        }
        let mut submitted_turn = None;
        let mut state_received_at = Instant::now();
        let run_id = SystemTime::now().duration_since(UNIX_EPOCH)?.as_nanos();
        while !state.terminal() {
            let turn = state.match_info.turn;
            if state.match_info.status == MatchStatus::Active
                && state.view.is_some()
                && submitted_turn != Some(turn)
            {
                let actions = match std::panic::catch_unwind(|| strategy(&state, &identity)) {
                    Ok(actions) => actions,
                    Err(_) => {
                        eprintln!("Strategy failed on turn {turn}; submitting no actions.");
                        Actions::new()
                    }
                };
                let mut submissions = Vec::new();
                for (entity, action) in &actions {
                    let revision = revisions.entry(entity.clone()).or_default();
                    if *revision >= 9_007_199_254_740_991 {
                        return Err(
                            "Action revision exceeds the server's safe integer range".into()
                        );
                    }
                    *revision += 1;
                    submissions
                        .push(json!({"entityId": entity, "revision": revision, "action": action}));
                }
                let body = json!({"submissionId": format!("{run_id}-{}-{turn}", std::process::id()), "submissions": submissions});
                match self.request::<Value>(
                    Method::PUT,
                    &format!("{path}/action"),
                    Some(&body),
                    true,
                ) {
                    Ok(result) => {
                        let missed_deadline = state.turn_ends_at_ms.is_some_and(|end| {
                            state
                                .server_time_ms
                                .saturating_add(state_received_at.elapsed().as_millis() as u64)
                                >= end
                        });
                        let accepted_late = result["accepted"].as_array().is_some_and(|accepted| {
                            accepted.iter().any(|action| {
                                action["acceptedForTurn"]
                                    .as_u64()
                                    .is_some_and(|accepted_turn| accepted_turn > turn + 1)
                            })
                        });
                        if missed_deadline || accepted_late {
                            eprintln!(
                                "Turn {turn} actions were late; use a faster strategy. Fetching the newest state."
                            );
                        }
                    }
                    Err(error) => {
                        if let Some(api) = error.downcast_ref::<ApiError>() {
                            if api.fatal() {
                                if !api.inactive_match() {
                                    return Err(error);
                                }
                                if self.match_ended(&path)? {
                                    break;
                                }
                            }
                        }
                        eprintln!("Could not submit turn {turn}: {error}; continuing.");
                        let empty = json!({"submissionId": format!("{run_id}-{}-{turn}-empty", std::process::id()), "submissions": []});
                        if let Err(error) = self.request::<Value>(
                            Method::PUT,
                            &format!("{path}/action"),
                            Some(&empty),
                            true,
                        ) {
                            if let Some(api) = error.downcast_ref::<ApiError>() {
                                if api.fatal() {
                                    if !api.inactive_match() {
                                        return Err(error);
                                    }
                                    if self.match_ended(&path)? {
                                        break;
                                    }
                                }
                            }
                            eprintln!("Could not submit empty actions: {error}");
                        }
                    }
                }
                submitted_turn = Some(turn);
                match self.request::<GameState>(Method::GET, &format!("{path}/state"), None, true) {
                    Ok(latest) => {
                        state = latest;
                        state_received_at = Instant::now();
                        if state.match_info.turn != turn {
                            continue;
                        }
                    }
                    Err(error) => {
                        if error
                            .downcast_ref::<ApiError>()
                            .is_some_and(|api| api.fatal())
                        {
                            return Err(error);
                        }
                        eprintln!("{error}");
                    }
                }
            }
            let delay = if state.match_info.turn_advance_mode == TurnAdvanceMode::Timed {
                match (state.turn_ends_at_ms, Some(state.server_time_ms)) {
                    (Some(end), Some(now)) => {
                        let remaining = end
                            .saturating_sub(now)
                            .saturating_sub(state_received_at.elapsed().as_millis() as u64);
                        if remaining > 0 {
                            remaining.saturating_sub(25).clamp(25, 1000)
                        } else {
                            100
                        }
                    }
                    _ => 100,
                }
            } else {
                100
            };
            thread::sleep(Duration::from_millis(delay));
            match self.request(Method::GET, &format!("{path}/state"), None, true) {
                Ok(latest) => {
                    state = latest;
                    state_received_at = Instant::now();
                }
                Err(error) => {
                    if error
                        .downcast_ref::<ApiError>()
                        .is_some_and(|api| api.fatal())
                    {
                        return Err(error);
                    }
                    eprintln!("{error}");
                }
            }
        }
        println!("{}", state.match_info.cancellation_reason.unwrap_or_else(|| format!("Match {match_id} ended.")));
        Ok(())
    }
}

pub type Actions = HashMap<String, Action>;

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Identity {
    pub team_id: String,
    pub display_name: Option<String>,
}

#[derive(Debug)]
pub struct GameState {
    pub match_info: MatchSummary,
    pub view: Option<GameView>,
    pub action_revisions: HashMap<String, u64>,
    pub server_time_ms: u64,
    pub turn_ends_at_ms: Option<u64>,
}

impl<'de> Deserialize<'de> for GameState {
    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
        #[derive(Deserialize)]
        #[serde(rename_all = "camelCase")]
        struct Envelope {
            #[serde(rename = "match")]
            match_info: MatchSummary,
            view: Option<Value>,
            #[serde(default)]
            action_revisions: HashMap<String, u64>,
            server_time_ms: u64,
            turn_ends_at_ms: Option<u64>,
        }
        let envelope = Envelope::deserialize(deserializer)?;
        let view = envelope
            .view
            .map(|view| match envelope.match_info.rules_version.as_str() {
                "lpc2026" => serde_json::from_value(view).map(|view| GameView::Lpc(Box::new(view))),
                "SolarOrbitor-demo" => serde_json::from_value(view).map(GameView::Solar),
                version => {
                    return Err(serde::de::Error::custom(format!(
                        "Unsupported rules version: {version}"
                    )));
                }
            })
            .transpose()
            .map_err(serde::de::Error::custom)?;
        Ok(Self {
            match_info: envelope.match_info,
            view,
            action_revisions: envelope.action_revisions,
            server_time_ms: envelope.server_time_ms,
            turn_ends_at_ms: envelope.turn_ends_at_ms,
        })
    }
}

impl GameState {
    fn terminal(&self) -> bool {
        matches!(
            self.match_info.status,
            MatchStatus::Completed | MatchStatus::Cancelled
        )
    }
}

#[derive(Debug)]
pub enum GameView {
    Lpc(Box<MarsView>),
    Solar(SolarView),
}

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MatchSummary {
    pub match_id: String,
    pub mode: MatchMode,
    pub status: MatchStatus,
    pub rules_version: String,
    pub turn: u64,
    pub max_turns: u64,
    pub turn_advance_mode: TurnAdvanceMode,
    pub teams: Vec<Team>,
    pub expected_team_ids: Vec<String>,
    pub team_ids: Vec<String>,
    pub active_team_ids: Vec<String>,
    pub started_at_ms: Option<u64>,
    pub completed_at_ms: Option<u64>,
    pub cancellation_reason: Option<String>,
}

#[derive(Debug, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum MatchMode {
    Ranked,
    Practice,
}
#[derive(Debug, Deserialize, PartialEq)]
#[serde(rename_all = "lowercase")]
pub enum MatchStatus {
    Pending,
    Initializing,
    Active,
    Completed,
    Cancelled,
}
#[derive(Debug, Deserialize, PartialEq)]
#[serde(rename_all = "kebab-case")]
pub enum TurnAdvanceMode {
    Timed,
    ClientAction,
    MultiClientAction,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Team {
    pub id: String,
    pub display_name: String,
    pub bot_type: BotType,
}
#[derive(Debug, Deserialize)]
#[serde(untagged)]
pub enum BotType {
    Human(bool),
    Bot(String),
}

#[derive(Debug, Deserialize, Clone, Copy, PartialEq, Eq)]
pub struct Position {
    pub x: i32,
    pub y: i32,
}
#[derive(Debug, Deserialize, Clone, Copy, PartialEq)]
pub struct SolarPosition {
    pub x: f64,
    pub y: f64,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MarsView {
    pub turn: u64,
    pub map: GameMap,
    pub units: Vec<Worker>,
    pub bases: Vec<Base>,
    pub scores: HashMap<String, f64>,
    pub team_resources: HashMap<String, Inventory>,
    pub base_resource_deposits: Option<HashMap<String, Inventory>>,
    pub high_gold_area: HighGoldArea,
    pub warnings: Vec<CollisionNotice>,
    pub info: Vec<CollisionNotice>,
    pub effects: Vec<Effect>,
    pub placements: Option<Vec<Placement>>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Worker {
    pub unit_id: String,
    pub team_id: String,
    pub position: Position,
    pub ready_on_turn: u64,
    pub inventory_capacity: u64,
    pub inventory: Option<Inventory>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Base {
    pub base_id: String,
    pub team_id: String,
    pub position: Position,
}
#[derive(Debug, Deserialize, Clone, Copy, PartialEq, Eq)]
pub struct Inventory {
    pub gold: u64,
    pub iron: u64,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct HighGoldArea {
    pub width: u32,
    pub height: u32,
    pub min: u64,
    pub max: u64,
    pub spawn_percent: u32,
}
#[derive(Debug, Deserialize)]
pub struct GameMap {
    pub width: i32,
    pub height: i32,
    pub cells: Vec<Tile>,
}
#[derive(Debug, Deserialize, Clone)]
#[serde(tag = "type", rename_all = "lowercase")]
pub enum Tile {
    Empty,
    Mountain,
    Crater {
        formation: Option<CraterFormation>,
    },
    Gold {
        amount: u64,
    },
    Iron {
        amount: u64,
    },
    Hidden,
    Void,
    Unit {
        #[serde(rename = "unitId")]
        unit_id: String,
        #[serde(rename = "teamId")]
        team_id: String,
    },
}
#[derive(Debug, Deserialize, Clone)]
pub struct CraterFormation {
    pub x: i32,
    pub y: i32,
    pub size: u32,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CollisionNotice {
    pub entity_id: String,
    pub error: String,
    pub note: String,
}
#[derive(Debug, Deserialize)]
#[serde(tag = "type")]
pub enum Effect {
    #[serde(rename = "bonk")]
    Bonk {
        coordinate: Position,
        #[serde(rename = "entityIds")]
        entity_ids: Vec<String>,
        note: String,
    },
    #[serde(rename = "resource_deposit")]
    ResourceDeposit {
        coordinate: Position,
        #[serde(rename = "baseId")]
        base_id: String,
        resource: Resource,
        amount: u64,
        total: u64,
    },
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Placement {
    pub team_id: String,
    pub placement: u32,
    pub score: Option<f64>,
}

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SolarView {
    pub turn: u64,
    pub planets: Vec<Planet>,
    pub rockets: Vec<Rocket>,
    pub entity_id: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Planet {
    pub name: PlanetName,
    pub position: SolarPosition,
    pub orbit_radius: f64,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Rocket {
    pub team_id: String,
    pub position: SolarPosition,
    pub previous_position: SolarPosition,
    pub heading: f64,
    pub target: Option<PlanetName>,
    pub target_position: Option<SolarPosition>,
    pub orbit: Option<Orbit>,
}
#[derive(Debug, Deserialize)]
pub struct Orbit {
    pub planet: PlanetName,
    pub angle: f64,
}
#[derive(Debug, Deserialize, Serialize, Clone, Copy, PartialEq, Eq)]
pub enum PlanetName {
    Mercury,
    Venus,
    Earth,
    Mars,
    Jupiter,
    Saturn,
    Uranus,
    Neptune,
    Pluto,
}
#[derive(Debug, Deserialize, Serialize, Clone, Copy, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum Resource {
    Gold,
    Iron,
}
#[derive(Debug, Serialize, Clone, Copy)]
#[serde(rename_all = "kebab-case")]
pub enum Direction {
    North,
    East,
    South,
    West,
    NorthEast,
    SouthEast,
    SouthWest,
    NorthWest,
}
#[derive(Debug, Serialize)]
#[serde(tag = "type", rename_all = "kebab-case")]
pub enum MarsAction {
    Move { direction: Direction },
    Mine,
    Deposit,
    Spawn,
    BuildBase,
    Trade { from: Resource },
    Pass,
}
#[derive(Debug, Serialize)]
#[serde(tag = "type")]
pub enum SolarAction {
    Mercury,
    Venus,
    Earth,
    Mars,
    Jupiter,
    Saturn,
    Uranus,
    Neptune,
    Pluto,
    #[serde(rename = "move")]
    Move {
        x: f64,
        y: f64,
    },
}
#[derive(Debug, Serialize)]
#[serde(untagged)]
pub enum Action {
    Lpc(MarsAction),
    Solar(SolarAction),
}

C++

Install a C++20 compiler, CMake 3.20+, and libcurl development files with HTTPS support. On Ubuntu: sudo apt install g++ cmake libcurl4-openssl-dev. On macOS: install Xcode command-line tools and brew install cmake curl. On Windows: use Visual Studio C++ tools and vcpkg install curl, then pass its CMake toolchain file when configuring. CMake downloads nlohmann/json if it is not installed.

  1. Save all four files in one folder. Configure with cmake -S . -B build -DCMAKE_BUILD_TYPE=Release, then build with cmake --build build --config Release. Rebuild after editing your strategy.
  2. Write calculate_actions in bot_template.cpp. State and actions use nlohmann::json and the field names in the rules. Use identity.at("teamId") to identify your workers. Optional helpers in game_helpers.hpp accept the view map and bases.
Command shell

Run command

API_KEY='your-team-key' LUCID_API_BASE_URL='https://lpc.sh' ./build/bin/lpc-bot
View bot_template.cpp

bot_template.cpp

#include "bot_client.hpp"
#include "game_helpers.hpp"

using namespace lpc;

Actions calculate_actions(const Json &state, const Json &identity) {
  Actions actions;
  for (const auto &worker : state.at("view").at("units")) {
    if (worker.at("teamId") != identity.at("teamId"))
      continue;
    // Write your strategy here. Example: {{"type", "move"}, {"direction",
    // "north"}}.
    actions[worker.at("unitId").get<std::string>()] = {{"type", "pass"}};
  }
  return actions;
}

int main() {
  try {
    BotClient().run("lpc2026", calculate_actions);
  } catch (const std::exception &error) {
    std::cerr << error.what() << '\n';
    return 1;
  }
  return 0;
}
View bot_client.hpp

bot_client.hpp

#pragma once

#include <algorithm>
#include <cctype>
#include <chrono>
#include <cstdint>
#include <cstdlib>
#include <curl/curl.h>
#include <functional>
#include <iomanip>
#include <iostream>
#include <map>
#include <memory>
#include <nlohmann/json.hpp>
#include <random>
#include <sstream>
#include <stdexcept>
#include <string>
#include <thread>

namespace lpc {
using Json = nlohmann::json;
using Actions = std::map<std::string, Json>;
// Game state uses the JSON field names documented in /help/rules. Use at() for
// required fields; contains()/value() for optional ones (e.g. enemy inventory).
using Strategy = std::function<Actions(const Json &, const Json &)>;
using Clock = std::chrono::steady_clock;
constexpr std::uint64_t max_revision = 9007199254740991ULL;

class ApiError : public std::runtime_error {
public:
  long status;
  std::string code, detail;
  ApiError(long status, const Json &body)
      : std::runtime_error(message(status, body)), status(status),
        code(body.value("error", Json::object()).value("code", "")),
        detail(body.value("error", Json::object()).value("message", "")) {}
  bool inactive() const {
    return status == 403 && code == "forbidden" &&
           detail == "Team is not in an active match.";
  }
  bool fatal() const {
    return status == 401 || status == 403 || code == "bot_session_conflict";
  }

private:
  static std::string message(long status, const Json &body) {
    const auto error = body.value("error", Json::object());
    const auto code = error.value("code", "");
    if (status == 403 &&
        error.value("message", "") == "Team is not in an active match.")
      return "No current match is available; join a match before starting the "
             "bot.";
    if (code == "pvp_disabled")
      return "No match assigned. PvP practice is currently disabled. Create a "
             "practice match on the website or ask an organizer to assign one, "
             "then run your bot again.";
    if (code == "bot_session_conflict")
      return "This bot was replaced by another client for this team and match.";
    if (status == 401 || status == 403)
      return "API key was rejected; check API_KEY.";
    if (status == 404)
      return "Cannot reach the competition API; check LUCID_API_BASE_URL.";
    return "Server request failed (HTTP " + std::to_string(status) + ").";
  }
};

class BotClient {
  struct CurlRuntime {
    CurlRuntime() {
      if (curl_global_init(CURL_GLOBAL_DEFAULT) != CURLE_OK)
        throw std::runtime_error("Could not initialize HTTPS.");
    }
    ~CurlRuntime() { curl_global_cleanup(); }
  } runtime_;
  std::unique_ptr<CURL, decltype(&curl_easy_cleanup)> http_{curl_easy_init(),
                                                            curl_easy_cleanup};
  std::string base_, key_, session_, match_, revision_;
  bool recovering_ = false;
  static constexpr const char *current = "/api/team/matches/current";

  static std::size_t write(char *data, std::size_t size, std::size_t count,
                           void *output) noexcept {
    try {
      static_cast<std::string *>(output)->append(data, size * count);
      return size * count;
    } catch (...) {
      return 0;
    }
  }
  static std::size_t header(char *data, std::size_t size, std::size_t count,
                            void *output) noexcept {
    try {
      std::string line(data, size * count);
      auto colon = line.find(':');
      if (colon != std::string::npos) {
        auto name = line.substr(0, colon);
        std::transform(name.begin(), name.end(), name.begin(),
                       [](unsigned char c) { return std::tolower(c); });
        auto value = line.substr(colon + 1);
        auto first = value.find_first_not_of(" \t\r\n");
        value = first == std::string::npos
                    ? ""
                    : value.substr(first, value.find_last_not_of(" \t\r\n") -
                                              first + 1);
        (*static_cast<std::map<std::string, std::string> *>(output))[name] =
            value;
      }
      return size * count;
    } catch (...) {
      return 0;
    }
  }
  static std::string env(const char *key) {
    const auto *value = std::getenv(key);
    return value ? value : "";
  }
  static std::string token() {
    std::random_device random;
    std::ostringstream out;
    for (int i = 0; i < 16; i++) {
      unsigned byte = random() & 255;
      if (i == 6)
        byte = (byte & 15) | 64;
      if (i == 8)
        byte = (byte & 63) | 128;
      if (i == 4 || i == 6 || i == 8 || i == 10)
        out << '-';
      out << std::hex << std::setw(2) << std::setfill('0') << byte;
    }
    return out.str();
  }
  static bool terminal(const Json &state) {
    const auto status = state.at("match").at("status");
    return status == "completed" || status == "cancelled";
  }
  Json request(const std::string &method, const std::string &path,
               const Json *body = nullptr, bool retry = true) {
    const int attempts = retry ? 3 : 1;
    const auto payload = body ? body->dump() : "";
    for (int attempt = 0; attempt < attempts; ++attempt) {
      // Reset options, retaining this handle's connection pool.
      curl_easy_reset(http_.get());
      std::string text;
      std::map<std::string, std::string> response_headers;
      curl_slist *raw_headers = nullptr;
      auto add = [&](const std::string &value) {
        auto *updated = curl_slist_append(raw_headers, value.c_str());
        if (!updated) {
          curl_slist_free_all(raw_headers);
          throw std::bad_alloc();
        }
        raw_headers = updated;
      };
      add("x-api-key: " + key_);
      if (!session_.empty())
        add("x-bot-session: " + session_);
      if (body)
        add("Content-Type: application/json");
      if (path == std::string(current) + "/register" && !match_.empty()) {
        add("x-bot-match-id: " + match_);
        add(recovering_ ? "x-bot-session-recover: 1"
                        : "x-bot-session-revision: " + revision_);
      }
      std::unique_ptr<curl_slist, decltype(&curl_slist_free_all)> headers(
          raw_headers, curl_slist_free_all);
      const std::string url = base_ + path;
      curl_easy_setopt(http_.get(), CURLOPT_URL, url.c_str());
      curl_easy_setopt(http_.get(), CURLOPT_CUSTOMREQUEST, method.c_str());
      curl_easy_setopt(http_.get(), CURLOPT_HTTPHEADER, headers.get());
      curl_easy_setopt(http_.get(), CURLOPT_TIMEOUT_MS, 5000L);
      curl_easy_setopt(http_.get(), CURLOPT_NOSIGNAL, 1L);
      // Never follow redirects with team/session headers. TLS verification
      // stays on.
      curl_easy_setopt(http_.get(), CURLOPT_FOLLOWLOCATION, 0L);
      curl_easy_setopt(http_.get(), CURLOPT_WRITEFUNCTION, &write);
      curl_easy_setopt(http_.get(), CURLOPT_WRITEDATA, &text);
      curl_easy_setopt(http_.get(), CURLOPT_HEADERFUNCTION, &header);
      curl_easy_setopt(http_.get(), CURLOPT_HEADERDATA, &response_headers);
      if (body) {
        curl_easy_setopt(http_.get(), CURLOPT_POSTFIELDS, payload.c_str());
        curl_easy_setopt(http_.get(), CURLOPT_POSTFIELDSIZE_LARGE,
                         static_cast<curl_off_t>(payload.size()));
      }
      const auto result = curl_easy_perform(http_.get());
      if (result != CURLE_OK) {
        if (attempt + 1 == attempts)
          throw std::runtime_error("Cannot reach " + base_ +
                                   "; check the URL and your connection.");
      } else {
        long status = 0;
        curl_easy_getinfo(http_.get(), CURLINFO_RESPONSE_CODE, &status);
        auto json = Json::parse(text, nullptr, false);
        if (status >= 200 && status < 300) {
          if (response_headers.count("x-bot-session-revision"))
            revision_ = response_headers.at("x-bot-session-revision");
          if (path == std::string(current) + "/register" &&
              response_headers.count("x-bot-session"))
            session_ = response_headers.at("x-bot-session");
          if (!json.is_object())
            throw std::runtime_error("Cannot read the competition API at " +
                                     base_ + "; check LUCID_API_BASE_URL.");
          return json;
        }
        if (!json.is_object() || !json.contains("error") ||
            !json["error"].is_object())
          json = Json::object();
        ApiError error(status, json);
        if (error.code == "bot_session_missing" && !recovering_ &&
            !match_.empty() && !session_.empty() &&
            path != std::string(current) + "/register") {
          recovering_ = true;
          try {
            request("POST", std::string(current) + "/register");
            auto recovered = request(method, path, body, retry);
            recovering_ = false;
            return recovered;
          } catch (...) {
            recovering_ = false;
            throw;
          }
        }
        const bool transient = status == 408 || status == 429 ||
                               status == 500 || status == 502 ||
                               status == 503 || status == 504;
        if (!transient || attempt + 1 == attempts)
          throw error;
      }
      std::this_thread::sleep_for(std::chrono::milliseconds(250 << attempt));
    }
    throw std::runtime_error("Request failed.");
  }
  bool submission_failure(const ApiError &error, const std::string &path) {
    if (!error.fatal())
      return false;
    if (!error.inactive())
      throw error;
    try {
      return terminal(request("GET", path + "/state"));
    } catch (const ApiError &latest) {
      if (latest.fatal() && !latest.inactive())
        throw;
    } catch (const std::exception &latest) {
      std::cerr << latest.what() << '\n';
    }
    return false;
  }

public:
  BotClient() : base_(env("LUCID_API_BASE_URL")), key_(env("API_KEY")) {
    if (!http_)
      throw std::runtime_error("Could not initialize HTTPS.");
    if (key_.find_first_not_of(" \t\r\n") == std::string::npos ||
        key_ == "replace-with-api-key")
      throw std::runtime_error(
          "Set API_KEY to your team's API key before starting the bot.");
    if (key_.find_first_of("\r\n") != std::string::npos)
      throw std::runtime_error("Invalid API_KEY.");
    if (base_.empty()) {
      base_ = "http://localhost:5173";
      std::cerr << "LUCID_API_BASE_URL is not set; using " << base_ << '\n';
    }
    while (!base_.empty() && base_.back() == '/')
      base_.pop_back();
    if (!(base_.starts_with("https://") || base_.starts_with("http://")) ||
        base_.find_first_of("\r\n?#@") != std::string::npos)
      throw std::runtime_error(
          "Invalid LUCID_API_BASE_URL; use the server's HTTP or HTTPS URL.");
  }
  void run(const std::string &rules, const Strategy &strategy) {
    const auto identity = request("POST", "/api/auth/api-key");
    Json state;
    try {
      state = request("GET", std::string(current) + "/state");
    } catch (const ApiError &error) {
      if (!error.inactive())
        throw;
      std::string announced;
      while (true) {
        const auto enrollment =
            request("POST", std::string(current) + "/register", nullptr, false);
        if (enrollment.contains("match")) {
          state = request("GET", std::string(current) + "/state");
          break;
        }
        if (!enrollment.contains("queue"))
          throw std::runtime_error("Unexpected registration response.");
        const std::string waiting = enrollment.at("queue").value("autoQueue", true)
            ? "Waiting in the PvP practice queue; keep your bot running."
            : "Waiting for a private practice match. Create one on the website; keep your bot running.";
        if (announced != waiting) {
          std::cout << waiting << std::endl;
          announced = waiting;
        }
        std::this_thread::sleep_for(std::chrono::seconds(1));
      }
    }
    match_ = state.at("match").at("matchId").get<std::string>();
    session_ = token();
    state = request("POST", std::string(current) + "/register");
    if (state.at("match").at("rulesVersion") != rules)
      throw std::runtime_error("This starter does not support the current "
                               "game. Download its starter from Help.");
    std::cout << "registered for match " << match_ << std::endl;
    std::unique_ptr<char, decltype(&curl_free)> escaped(
        curl_easy_escape(http_.get(), match_.c_str(),
                         static_cast<int>(match_.size())),
        curl_free);
    if (!escaped)
      throw std::bad_alloc();
    const std::string path = std::string("/api/team/matches/") + escaped.get();
    auto revisions =
        state.value("actionRevisions", std::map<std::string, std::uint64_t>{});
    for (const auto &[entity, revision] : revisions)
      if (revision > max_revision)
        throw std::runtime_error("Invalid action revision.");
    const auto run_id = session_;
    int submitted_turn = -1;
    auto received_at = Clock::now();
    while (!terminal(state)) {
      const int turn = state.at("match").at("turn");
      if (state.at("match").at("status") == "active" &&
          state.contains("view") && !state["view"].is_null() &&
          submitted_turn != turn) {
        Actions actions;
        try {
          actions = strategy(state, identity);
        } catch (...) {
          std::cerr << "Strategy failed on turn " << turn
                    << "; submitting no actions.\n";
        }
        Json submissions = Json::array();
        for (const auto &[entity, action] : actions) {
          if (revisions[entity] >= max_revision)
            throw std::runtime_error(
                "Action revision exceeds the server's safe integer range.");
          submissions.push_back({{"entityId", entity},
                                 {"revision", ++revisions[entity]},
                                 {"action", action}});
        }
        Json body = {{"submissionId", run_id + "-" + std::to_string(turn)},
                     {"submissions", submissions}};
        try {
          auto receipt = request("PUT", path + "/action", &body);
          const auto elapsed =
              std::chrono::duration_cast<std::chrono::milliseconds>(
                  Clock::now() - received_at)
                  .count();
          bool late = state.contains("turnEndsAtMs") &&
                      !state["turnEndsAtMs"].is_null() &&
                      state.value("serverTimeMs", 0LL) + elapsed >=
                          state["turnEndsAtMs"].get<long long>();
          for (const auto &accepted : receipt.value("accepted", Json::array()))
            late = late || accepted.value("acceptedForTurn", 0) > turn + 1;
          if (late)
            std::cerr << "Turn " << turn
                      << " actions were late; use a faster strategy. Fetching "
                         "the newest state.\n";
        } catch (const std::exception &error) {
          const auto *api = dynamic_cast<const ApiError *>(&error);
          if (api && submission_failure(*api, path))
            break;
          std::cerr << "Could not submit turn " << turn << ": " << error.what()
                    << "; continuing.\n";
          Json empty = {
              {"submissionId", run_id + "-" + std::to_string(turn) + "-empty"},
              {"submissions", Json::array()}};
          try {
            request("PUT", path + "/action", &empty);
          } catch (const std::exception &fallback) {
            const auto *failed = dynamic_cast<const ApiError *>(&fallback);
            if (failed && submission_failure(*failed, path))
              break;
            std::cerr << "Could not submit empty actions: " << fallback.what()
                      << '\n';
          }
        }
        submitted_turn = turn;
        try {
          state = request("GET", path + "/state");
          received_at = Clock::now();
          if (state.at("match").at("turn") != turn)
            continue;
        } catch (const ApiError &error) {
          if (error.fatal())
            throw;
          std::cerr << error.what() << '\n';
        } catch (const std::exception &error) {
          std::cerr << error.what() << '\n';
        }
      }
      long long delay = 100;
      if (state.at("match").value("turnAdvanceMode", "") == "timed" &&
          state.contains("turnEndsAtMs") && !state["turnEndsAtMs"].is_null()) {
        const auto elapsed =
            std::chrono::duration_cast<std::chrono::milliseconds>(Clock::now() -
                                                                  received_at)
                .count();
        const auto remaining = state["turnEndsAtMs"].get<long long>() -
                               state.value("serverTimeMs", 0LL) - elapsed;
        if (remaining > 0)
          delay = std::clamp(remaining - 25, 25LL, 1000LL);
      }
      std::this_thread::sleep_for(std::chrono::milliseconds(delay));
      try {
        state = request("GET", path + "/state");
        received_at = Clock::now();
      } catch (const ApiError &error) {
        if (error.fatal())
          throw;
        std::cerr << error.what() << '\n';
      } catch (const std::exception &error) {
        std::cerr << error.what() << '\n';
      }
    }
    const auto reason = state.at("match").value("cancellationReason", Json());
    std::cout << (reason.is_string() ? reason.get<std::string>()
                                     : "Match " + match_ + " ended.")
              << std::endl;
  }
};
} // namespace lpc
View game_helpers.hpp

game_helpers.hpp

#pragma once
#include "bot_client.hpp"
#include <optional>
#include <queue>
#include <set>
#include <vector>

namespace lpc {
using Position = std::pair<int, int>;
inline Position position(const Json &p) {
  return {p.at("x").get<int>(), p.at("y").get<int>()};
}
inline std::vector<std::string> is_claimed(const Json &bases, Position p) {
  std::set<std::string> owners;
  for (const auto &base : bases) {
    auto [x, y] = position(base.at("position"));
    if (std::abs(x - p.first) + std::abs(y - p.second) <= 2)
      owners.insert(base.at("teamId"));
  }
  return {owners.begin(), owners.end()};
}
inline std::optional<std::string> is_base(const Json &bases, Position p) {
  for (const auto &base : bases)
    if (position(base.at("position")) == p)
      return base.at("teamId").get<std::string>();
  return std::nullopt;
}
inline const Json *tile_at(const Json &grid, Position p) {
  auto [x, y] = p;
  const int width = grid.at("width"), height = grid.at("height");
  if (x < 0 || y < 0 || x >= width || y >= height)
    return nullptr;
  const auto index = static_cast<std::size_t>(y) * width + x;
  const auto &cells = grid.at("cells");
  return index < cells.size() ? &cells.at(index) : nullptr;
}
inline std::optional<std::string> is_ore(const Json &grid, Position p) {
  auto *tile = tile_at(grid, p);
  if (tile && (tile->at("type") == "gold" || tile->at("type") == "iron") &&
      tile->value("amount", 0) > 0)
    return tile->at("type").get<std::string>();
  return std::nullopt;
}
inline bool can_move_onto(const Json &grid, const Json &bases, Position p,
                          const std::string &team) {
  auto *tile = tile_at(grid, p);
  auto owner = is_base(bases, p);
  return tile && tile->at("type") != "hidden" && tile->at("type") != "void" &&
         (!owner || *owner == team);
}
// Cost until ready at destination. Start must be ready. nullopt means no known
// route; an empty vector means start == end. Returned steps exclude start.
inline std::optional<std::vector<Position>>
path_to(const Json &grid, Position start, Position end, const Json &bases,
        const std::string &team, int obstacle_cost = 3) {
  if (obstacle_cost < 1 || !can_move_onto(grid, bases, start, team) ||
      !can_move_onto(grid, bases, end, team))
    return std::nullopt;
  using Entry = std::pair<std::int64_t, Position>;
  std::priority_queue<Entry, std::vector<Entry>, std::greater<Entry>> queue;
  std::map<Position, std::int64_t> distance{{start, 0}};
  std::map<Position, Position> previous;
  queue.push({0, start});
  while (!queue.empty()) {
    auto [cost, p] = queue.top();
    queue.pop();
    if (cost != distance.at(p))
      continue;
    if (p == end) {
      std::vector<Position> path;
      for (auto at = end; at != start; at = previous.at(at))
        path.push_back(at);
      std::reverse(path.begin(), path.end());
      return path;
    }
    for (int dy = -1; dy <= 1; ++dy)
      for (int dx = -1; dx <= 1; ++dx) {
        if (dx == 0 && dy == 0)
          continue;
        Position next{p.first + dx, p.second + dy};
        if (!can_move_onto(grid, bases, next, team))
          continue;
        const auto terrain = tile_at(grid, next)->at("type");
        const auto next_cost =
            cost + ((terrain == "mountain" || terrain == "crater")
                        ? obstacle_cost
                        : 1);
        if (distance.count(next) && distance.at(next) <= next_cost)
          continue;
        distance[next] = next_cost;
        previous[next] = p;
        queue.push({next_cost, next});
      }
  }
  return std::nullopt;
}
} // namespace lpc
View CMakeLists.txt

CMakeLists.txt

cmake_minimum_required(VERSION 3.20)
project(lpc_starter LANGUAGES CXX)
if(POLICY CMP0135)
  cmake_policy(SET CMP0135 NEW)
endif()
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(LPC_STRATEGY "bot_template.cpp" CACHE STRING "Strategy source file")
find_package(CURL REQUIRED)
find_package(nlohmann_json 3.11 QUIET)
if(NOT nlohmann_json_FOUND)
  include(FetchContent)
  FetchContent_Declare(json
    URL https://github.com/nlohmann/json/releases/download/v3.12.0/json.tar.xz
    URL_HASH SHA256=42f6e95cad6ec532fd372391373363b62a14af6d771056dbfc86160e6dfff7aa)
  FetchContent_MakeAvailable(json)
endif()
add_executable(lpc-bot ${LPC_STRATEGY})
target_link_libraries(lpc-bot PRIVATE CURL::libcurl nlohmann_json::nlohmann_json)
# Same executable path with single- and multi-configuration generators.
set_target_properties(lpc-bot PROPERTIES RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin/$<0:>")

Go

Install Go 1.22 or newer. No external packages are needed.

  1. Save all four files in one folder.
  2. Write calculateActions in bot_template.go. Use identity.TeamID to identify your workers. State has typed fields; nil worker Inventory means it is hidden. Optional helpers in game_helpers.go accept the view map and bases.
Command shell

Run command

API_KEY='your-team-key' LUCID_API_BASE_URL='https://lpc.sh' go run bot_template.go bot_client.go game_helpers.go
View bot_template.go

bot_template.go

package main

import (
	"fmt"
	"os"
)

func calculateActions(state *GameState, identity *Identity) Actions {
	actions := Actions{}
	if state.View == nil {
		return actions
	}
	for _, worker := range state.View.Units {
		if worker.TeamID != identity.TeamID {
			continue
		}
		// Write your strategy here.
		actions[worker.UnitID] = Action{Type: "pass"}
	}
	return actions
}

func main() {
	client, err := NewBotClient()
	if err == nil {
		err = client.Run("lpc2026", calculateActions)
	}
	if err != nil {
		fmt.Fprintln(os.Stderr, err)
		os.Exit(1)
	}
}
View bot_client.go

bot_client.go

package main

import (
	"bytes"
	"crypto/rand"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"net/http"
	"net/url"
	"os"
	"strings"
	"time"
)

// Match and game fields use the same names as the wire protocol. A nil View
// means the match has not started; a nil Inventory means it is not visible.
type Identity struct {
	TeamID      string `json:"teamId"`
	DisplayName string `json:"displayName"`
}
type Position struct {
	X int `json:"x"`
	Y int `json:"y"`
}
type SolarPosition struct {
	X float64 `json:"x"`
	Y float64 `json:"y"`
}
type Inventory struct {
	Gold int `json:"gold"`
	Iron int `json:"iron"`
}
type Worker struct {
	UnitID            string     `json:"unitId"`
	TeamID            string     `json:"teamId"`
	Position          Position   `json:"position"`
	ReadyOnTurn       int        `json:"readyOnTurn"`
	InventoryCapacity int        `json:"inventoryCapacity"`
	Inventory         *Inventory `json:"inventory"`
}
type Base struct {
	BaseID   string   `json:"baseId"`
	TeamID   string   `json:"teamId"`
	Position Position `json:"position"`
}
type Tile struct {
	Type      string                    `json:"type"`
	Amount    int                       `json:"amount"`
	Formation *struct{ X, Y, Size int } `json:"formation"`
	UnitID    string                    `json:"unitId"`
	TeamID    string                    `json:"teamId"`
}
type GameMap struct {
	Width  int    `json:"width"`
	Height int    `json:"height"`
	Cells  []Tile `json:"cells"`
}
type Notice struct {
	EntityID string `json:"entityId"`
	Error    string `json:"error"`
	Note     string `json:"note"`
}
type Effect struct {
	Type       string   `json:"type"`
	Coordinate Position `json:"coordinate"`
	EntityIDs  []string `json:"entityIds"`
	Note       string   `json:"note"`
	BaseID     string   `json:"baseId"`
	Resource   string   `json:"resource"`
	Amount     int      `json:"amount"`
	Total      int      `json:"total"`
}
type Placement struct {
	TeamID    string  `json:"teamId"`
	Placement int     `json:"placement"`
	Score     float64 `json:"score"`
}
type Planet struct {
	Name        string        `json:"name"`
	Position    SolarPosition `json:"position"`
	OrbitRadius float64       `json:"orbitRadius"`
}
type Rocket struct {
	TeamID           string         `json:"teamId"`
	Position         SolarPosition  `json:"position"`
	PreviousPosition SolarPosition  `json:"previousPosition"`
	Heading          float64        `json:"heading"`
	Target           *string        `json:"target"`
	TargetPosition   *SolarPosition `json:"targetPosition"`
	Orbit            *struct {
		Planet string  `json:"planet"`
		Angle  float64 `json:"angle"`
	} `json:"orbit"`
}
type GameView struct {
	Turn int `json:"turn"`
	// LPC fields.
	Map                  GameMap              `json:"map"`
	Units                []Worker             `json:"units"`
	Bases                []Base               `json:"bases"`
	Scores               map[string]float64   `json:"scores"`
	TeamResources        map[string]Inventory `json:"teamResources"`
	BaseResourceDeposits map[string]Inventory `json:"baseResourceDeposits"`
	HighGoldArea         struct {
		Width, Height, Min, Max int
		SpawnPercent            int `json:"spawnPercent"`
	} `json:"highGoldArea"`
	Warnings   []Notice    `json:"warnings"`
	Info       []Notice    `json:"info"`
	Effects    []Effect    `json:"effects"`
	Placements []Placement `json:"placements"`
	// Solar Orbitor fields.
	EntityID string   `json:"entityId"`
	Planets  []Planet `json:"planets"`
	Rockets  []Rocket `json:"rockets"`
}
type Team struct {
	ID          string          `json:"id"`
	DisplayName string          `json:"displayName"`
	BotType     json.RawMessage `json:"botType"`
}
type MatchSummary struct {
	MatchID            string   `json:"matchId"`
	Mode               string   `json:"mode"`
	Status             string   `json:"status"`
	RulesVersion       string   `json:"rulesVersion"`
	Turn               int      `json:"turn"`
	MaxTurns           int      `json:"maxTurns"`
	TurnAdvanceMode    string   `json:"turnAdvanceMode"`
	Teams              []Team   `json:"teams"`
	ExpectedTeamIDs    []string `json:"expectedTeamIds"`
	TeamIDs            []string `json:"teamIds"`
	ActiveTeamIDs      []string `json:"activeTeamIds"`
	StartedAtMs        *int64   `json:"startedAtMs"`
	CompletedAtMs      *int64   `json:"completedAtMs"`
	CancellationReason string   `json:"cancellationReason"`
}
type GameState struct {
	Match           MatchSummary      `json:"match"`
	View            *GameView         `json:"view"`
	ActionRevisions map[string]uint64 `json:"actionRevisions"`
	ServerTimeMs    int64             `json:"serverTimeMs"`
	TurnEndsAtMs    *int64            `json:"turnEndsAtMs"`
}

// LPC: type = move/mine/deposit/spawn/build-base/trade/pass. Move needs
// Direction (e.g. north-east); trade needs From (iron/gold).
// Solar: Type = planet name (e.g. Earth), or move with X and Y.
type Action struct {
	Type      string   `json:"type"`
	Direction string   `json:"direction,omitempty"`
	From      string   `json:"from,omitempty"`
	X         *float64 `json:"x,omitempty"`
	Y         *float64 `json:"y,omitempty"`
}
type Actions map[string]Action
type Strategy func(*GameState, *Identity) Actions

const currentPath = "/api/team/matches/current"
const maxRevision uint64 = 9007199254740991

type apiError struct {
	status        int
	code, message string
}

func (e *apiError) inactive() bool {
	return e.status == 403 && e.code == "forbidden" && e.message == "Team is not in an active match."
}
func (e *apiError) fatal() bool {
	return e.status == 401 || e.status == 403 || e.code == "bot_session_conflict"
}
func (e *apiError) Error() string {
	switch {
	case e.inactive():
		return "No current match is available; join a match before starting the bot."
	case e.code == "pvp_disabled":
		return "No match assigned. PvP practice is currently disabled. Create a practice match on the website or ask an organizer to assign one, then run your bot again."
	case e.code == "bot_session_conflict":
		return "This bot was replaced by another client for this team and match."
	case e.status == 401 || e.status == 403:
		return "API key was rejected; check API_KEY."
	case e.status == 404:
		return "Cannot reach the competition API; check LUCID_API_BASE_URL."
	default:
		return fmt.Sprintf("Server request failed (HTTP %d).", e.status)
	}
}

type BotClient struct {
	http                                     *http.Client
	baseURL, key, session, matchID, revision string
	recovering                               bool
}

func NewBotClient() (*BotClient, error) {
	key := strings.TrimSpace(os.Getenv("API_KEY"))
	if key == "" || key == "replace-with-api-key" {
		return nil, errors.New("Set API_KEY to your team's API key before starting the bot.")
	}
	base := strings.TrimRight(strings.TrimSpace(os.Getenv("LUCID_API_BASE_URL")), "/")
	if base == "" {
		base = "http://localhost:5173"
		fmt.Fprintln(os.Stderr, "LUCID_API_BASE_URL is not set; using", base)
	}
	parsed, err := url.Parse(base)
	if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" {
		return nil, errors.New("Invalid LUCID_API_BASE_URL; use the server's HTTP or HTTPS URL.")
	}
	return &BotClient{baseURL: base, key: key, http: &http.Client{
		Timeout: 5 * time.Second,
		// Do not forward a team key to a redirect target. Use the final HTTPS URL.
		CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse },
	}}, nil
}
func token() (string, error) {
	b := make([]byte, 16)
	if _, err := rand.Read(b); err != nil {
		return "", err
	}
	b[6] = (b[6] & 15) | 64
	b[8] = (b[8] & 63) | 128
	return fmt.Sprintf("%x-%x-%x-%x-%x", b[:4], b[4:6], b[6:8], b[8:10], b[10:]), nil
}
func (c *BotClient) request(method, path string, body []byte, retry bool, out any) error {
	attempts := 1
	if retry {
		attempts = 3
	}
	for attempt := 0; attempt < attempts; attempt++ {
		req, err := http.NewRequest(method, c.baseURL+path, bytes.NewReader(body))
		if err != nil {
			return err
		}
		req.Header.Set("x-api-key", c.key)
		if c.session != "" {
			req.Header.Set("x-bot-session", c.session)
		}
		if body != nil {
			req.Header.Set("Content-Type", "application/json")
		}
		if path == currentPath+"/register" && c.matchID != "" {
			req.Header.Set("x-bot-match-id", c.matchID)
			if c.recovering {
				req.Header.Set("x-bot-session-recover", "1")
			} else {
				req.Header.Set("x-bot-session-revision", c.revision)
			}
		}
		response, err := c.http.Do(req)
		if err != nil {
			if attempt+1 == attempts {
				return errors.New("Cannot reach " + c.baseURL + "; check the URL and your connection.")
			}
		} else {
			data, readErr := io.ReadAll(response.Body)
			response.Body.Close()
			if readErr != nil {
				if attempt+1 == attempts {
					return readErr
				}
			} else if response.StatusCode >= 200 && response.StatusCode < 300 {
				if revision := response.Header.Get("x-bot-session-revision"); revision != "" {
					c.revision = revision
				}
				if path == currentPath+"/register" {
					if session := response.Header.Get("x-bot-session"); session != "" {
						c.session = session
					}
				}
				if len(bytes.TrimSpace(data)) == 0 || bytes.TrimSpace(data)[0] != '{' || json.Unmarshal(data, out) != nil {
					return errors.New("Cannot read the competition API at " + c.baseURL + "; check LUCID_API_BASE_URL.")
				}
				return nil
			} else {
				var envelope struct {
					Error struct{ Code, Message string }
				}
				_ = json.Unmarshal(data, &envelope)
				failure := &apiError{response.StatusCode, envelope.Error.Code, envelope.Error.Message}
				if failure.code == "bot_session_missing" && !c.recovering && c.matchID != "" && c.session != "" && path != currentPath+"/register" {
					c.recovering = true
					defer func() { c.recovering = false }()
					var registration GameState
					if err := c.request("POST", currentPath+"/register", nil, true, &registration); err != nil {
						return err
					}
					return c.request(method, path, body, retry, out)
				}
				transient := response.StatusCode == 408 || response.StatusCode == 429 || response.StatusCode == 500 || response.StatusCode == 502 || response.StatusCode == 503 || response.StatusCode == 504
				if !transient || attempt+1 == attempts {
					return failure
				}
			}
		}
		time.Sleep(time.Duration(250<<attempt) * time.Millisecond)
	}
	return errors.New("Request failed")
}
func terminal(s *GameState) bool {
	return s.Match.Status == "completed" || s.Match.Status == "cancelled"
}
func safelyCalculate(strategy Strategy, state *GameState, identity *Identity) (actions Actions) {
	defer func() {
		if recover() != nil {
			fmt.Fprintf(os.Stderr, "Strategy failed on turn %d; submitting no actions.\n", state.Match.Turn)
			actions = Actions{}
		}
	}()
	return strategy(state, identity)
}
func (c *BotClient) Run(rulesVersion string, strategy Strategy) error {
	defer c.http.CloseIdleConnections()
	var identity Identity
	if err := c.request("POST", "/api/auth/api-key", nil, true, &identity); err != nil {
		return err
	}
	var state GameState
	if err := c.request("GET", currentPath+"/state", nil, true, &state); err != nil {
		var api *apiError
		if !errors.As(err, &api) || !api.inactive() {
			return err
		}
		announced := ""
		for {
			var enrollment map[string]json.RawMessage
			if err := c.request("POST", currentPath+"/register", nil, false, &enrollment); err != nil {
				return err
			}
			if _, ok := enrollment["match"]; ok {
				if err := c.request("GET", currentPath+"/state", nil, true, &state); err != nil {
					return err
				}
				break
			}
			if _, ok := enrollment["queue"]; !ok {
				return errors.New("Unexpected registration response.")
			}
			var queue struct {
				AutoQueue *bool `json:"autoQueue"`
			}
			if err := json.Unmarshal(enrollment["queue"], &queue); err != nil {
				return err
			}
			waiting := "Waiting in the PvP practice queue; keep your bot running."
			if queue.AutoQueue != nil && !*queue.AutoQueue {
				waiting = "Waiting for a private practice match. Create one on the website; keep your bot running."
			}
			if announced != waiting {
				fmt.Println(waiting)
				announced = waiting
			}
			time.Sleep(time.Second)
		}
	}
	c.matchID = state.Match.MatchID
	if c.matchID == "" {
		return errors.New("Missing match ID.")
	}
	var err error
	c.session, err = token()
	if err != nil {
		return err
	}
	if err = c.request("POST", currentPath+"/register", nil, true, &state); err != nil {
		return err
	}
	if state.Match.RulesVersion != rulesVersion {
		return errors.New("This starter does not support the current game. Download its starter from Help.")
	}
	fmt.Println("registered for match", c.matchID)
	path := "/api/team/matches/" + url.PathEscape(c.matchID)
	revisions := state.ActionRevisions
	if revisions == nil {
		revisions = map[string]uint64{}
	}
	for _, revision := range revisions {
		if revision > maxRevision {
			return errors.New("Invalid action revision")
		}
	}
	runID := c.session
	submittedTurn := -1
	receivedAt := time.Now()
	for !terminal(&state) {
		turn := state.Match.Turn
		if state.Match.Status == "active" && state.View != nil && submittedTurn != turn {
			actions := safelyCalculate(strategy, &state, &identity)
			submissions := make([]map[string]any, 0, len(actions))
			for entity, action := range actions {
				if revisions[entity] >= maxRevision {
					return errors.New("Action revision exceeds the server's safe integer range")
				}
				revisions[entity]++
				submissions = append(submissions, map[string]any{"entityId": entity, "revision": revisions[entity], "action": action})
			}
			body, err := json.Marshal(map[string]any{"submissionId": fmt.Sprintf("%s-%d", runID, turn), "submissions": submissions})
			if err != nil {
				return err
			}
			var receipt struct {
				Accepted []struct {
					AcceptedForTurn int `json:"acceptedForTurn"`
				} `json:"accepted"`
			}
			err = c.request("PUT", path+"/action", body, true, &receipt)
			if err != nil {
				ended, fatal := c.submissionFailure(err, path)
				if fatal != nil {
					return fatal
				}
				if ended {
					break
				}
				fmt.Fprintf(os.Stderr, "Could not submit turn %d: %v; continuing.\n", turn, err)
				empty, _ := json.Marshal(map[string]any{"submissionId": fmt.Sprintf("%s-%d-empty", runID, turn), "submissions": []any{}})
				if err = c.request("PUT", path+"/action", empty, true, &receipt); err != nil {
					ended, fatal = c.submissionFailure(err, path)
					if fatal != nil {
						return fatal
					}
					if ended {
						break
					}
					fmt.Fprintln(os.Stderr, "Could not submit empty actions:", err)
				}
			} else {
				late := state.TurnEndsAtMs != nil && state.ServerTimeMs+time.Since(receivedAt).Milliseconds() >= *state.TurnEndsAtMs
				for _, action := range receipt.Accepted {
					late = late || action.AcceptedForTurn > turn+1
				}
				if late {
					fmt.Fprintf(os.Stderr, "Turn %d actions were late; use a faster strategy. Fetching the newest state.\n", turn)
				}
			}
			submittedTurn = turn
			var latest GameState
			if err := c.request("GET", path+"/state", nil, true, &latest); err == nil {
				state = latest
				receivedAt = time.Now()
				if state.Match.Turn != turn {
					continue
				}
			} else if fatalError(err) {
				return err
			} else {
				fmt.Fprintln(os.Stderr, err)
			}
		}
		delay := 100 * time.Millisecond
		if state.Match.TurnAdvanceMode == "timed" && state.TurnEndsAtMs != nil {
			remaining := time.Duration(*state.TurnEndsAtMs-state.ServerTimeMs)*time.Millisecond - time.Since(receivedAt)
			if remaining > 0 {
				delay = remaining - 25*time.Millisecond
				if delay < 25*time.Millisecond {
					delay = 25 * time.Millisecond
				}
				if delay > time.Second {
					delay = time.Second
				}
			}
		}
		time.Sleep(delay)
		var latest GameState
		if err := c.request("GET", path+"/state", nil, true, &latest); err == nil {
			state = latest
			receivedAt = time.Now()
		} else if fatalError(err) {
			return err
		} else {
			fmt.Fprintln(os.Stderr, err)
		}
	}
	if state.Match.CancellationReason != "" {
		fmt.Println(state.Match.CancellationReason)
	} else {
		fmt.Printf("Match %s ended.\n", c.matchID)
	}
	return nil
}
func fatalError(err error) bool { var api *apiError; return errors.As(err, &api) && api.fatal() }
func (c *BotClient) submissionFailure(err error, path string) (bool, error) {
	var api *apiError
	if !errors.As(err, &api) || !api.fatal() {
		return false, nil
	}
	if !api.inactive() {
		return false, err
	}
	var latest GameState
	if err := c.request("GET", path+"/state", nil, true, &latest); err != nil {
		if fatalError(err) {
			return false, err
		}
		return false, nil
	}
	return terminal(&latest), nil
}
View game_helpers.go

game_helpers.go

package main

import "container/heap"

// Optional game helpers. PathTo returns steps after start, including end.
// nil means no known route; an empty non-nil slice means start == end.
func IsClaimed(bases []Base, p Position) []string {
	owners := []string{}
	seen := map[string]bool{}
	for _, base := range bases {
		if abs(base.Position.X-p.X)+abs(base.Position.Y-p.Y) <= 2 && !seen[base.TeamID] {
			owners = append(owners, base.TeamID)
			seen[base.TeamID] = true
		}
	}
	return owners
}
func IsBase(bases []Base, p Position) (string, bool) {
	for _, base := range bases {
		if base.Position == p {
			return base.TeamID, true
		}
	}
	return "", false
}
func tileAt(grid GameMap, p Position) *Tile {
	if p.X < 0 || p.Y < 0 || p.X >= grid.Width || p.Y >= grid.Height {
		return nil
	}
	index := p.Y*grid.Width + p.X
	if index >= len(grid.Cells) {
		return nil
	}
	return &grid.Cells[index]
}
func IsOre(grid GameMap, p Position) string {
	tile := tileAt(grid, p)
	if tile != nil && (tile.Type == "gold" || tile.Type == "iron") && tile.Amount > 0 {
		return tile.Type
	}
	return ""
}
func CanMoveOnto(grid GameMap, bases []Base, p Position, teamID string) bool {
	tile := tileAt(grid, p)
	if tile == nil || tile.Type == "hidden" || tile.Type == "void" {
		return false
	}
	owner, base := IsBase(bases, p)
	return !base || owner == teamID
}
func PathTo(grid GameMap, start, end Position, bases []Base, teamID string, obstacleCost int) []Position {
	if obstacleCost < 1 || !CanMoveOnto(grid, bases, start, teamID) || !CanMoveOnto(grid, bases, end, teamID) {
		return nil
	}
	distances := map[Position]int{start: 0}
	previous := map[Position]Position{}
	queue := &pathHeap{{start, 0}}
	heap.Init(queue)
	for queue.Len() > 0 {
		item := heap.Pop(queue).(pathItem)
		if item.cost != distances[item.p] {
			continue
		}
		if item.p == end {
			route := []Position{}
			for p := end; p != start; p = previous[p] {
				route = append(route, p)
			}
			for i, j := 0, len(route)-1; i < j; i, j = i+1, j-1 {
				route[i], route[j] = route[j], route[i]
			}
			return route
		}
		for dy := -1; dy <= 1; dy++ {
			for dx := -1; dx <= 1; dx++ {
				if dx == 0 && dy == 0 {
					continue
				}
				next := Position{item.p.X + dx, item.p.Y + dy}
				if !CanMoveOnto(grid, bases, next, teamID) {
					continue
				}
				step := 1
				tile := tileAt(grid, next)
				if tile.Type == "mountain" || tile.Type == "crater" {
					step = obstacleCost
				}
				cost := item.cost + step
				if old, exists := distances[next]; exists && old <= cost {
					continue
				}
				distances[next] = cost
				previous[next] = item.p
				heap.Push(queue, pathItem{next, cost})
			}
		}
	}
	return nil
}
func abs(n int) int {
	if n < 0 {
		return -n
	}
	return n
}

type pathItem struct {
	p    Position
	cost int
}
type pathHeap []pathItem

func (h pathHeap) Len() int           { return len(h) }
func (h pathHeap) Less(i, j int) bool { return h[i].cost < h[j].cost }
func (h pathHeap) Swap(i, j int)      { h[i], h[j] = h[j], h[i] }
func (h *pathHeap) Push(item any)     { *h = append(*h, item.(pathItem)) }
func (h *pathHeap) Pop() any          { old := *h; item := old[len(old)-1]; *h = old[:len(old)-1]; return item }
View go.mod

go.mod

module lpc-starter

go 1.22
API and reference

Practice opponents

Practice supports zero to nineteen opponents. Novice collects nearby resources and returns full loads. Beginner remembers explored terrain and searches for gold. Intermediate remembers terrain, compares travel and return costs, builds up to three workers, and can build a second base. It stops investing halfway through the match and returns to bank before time runs out.

A seed repeats the initial map only with the same settings and roster. Actions and generated bot IDs can affect the result.

Inspect and follow workers

A count badge marks workers that share a visible tile. Select the tile to inspect its workers. Select Follow unit to follow a worker with the camera. The inspector keeps the selected tile.

Game-state helpers

The downloaded clients include helpers for finding a path, checking territory, and identifying bases and ore. They use the player view and its map, not the enclosing response. Use gameState.view for state helpers and gameState.view.map for pathTo.

isClaimed returns all visible claiming teams because territories can overlap. A hidden base can still claim a tile. isBase returns a visible base owner or null. isOre returns gold or iron for a visible deposit with stock, otherwise null. canMoveOnto rejects unknown terrain and enemy bases; pass your team ID to allow friendly bases. Workers do not block movement.

pathTo finds a route across eight directions using visible movement costs. It excludes the start and includes the destination. An empty path means the start is the destination. Null means no known route. Supply bases and your team ID to account for enemy bases. The default obstacle cost is three. Use the match setting if it differs.

A route includes the time until a worker can act again after entering an obstacle. Wait until view.turn reaches worker.readyOnTurn, then check the route against the new view. Actions sent during cooldown do not queue.

Each starter provides state and action types. Java and Rust parse state into records or structs; JavaScript provides editor types, and Python uses typed dictionaries. TypeScript accepts objects with only the fields each helper needs. Java and Rust accept your own CoordinateGrid implementation and take known bases separately for territory checks. Helpers stay below the strategy function in your bot file. Python and Rust use snake_case names and None.

Submit actions

Use your authenticated team ID to select your workers. Do not rely on roster order. Return worker IDs mapped to individual action objects. Submit fresh actions each turn. Use pass to tell a worker to do nothing.

LPC practice advances when you submit. Timed ranked matches normally advance every second. The full rules explain action payloads, accepted turns, and submission receipts.

Connection and recovery

Keep your team key private. The client stays with the match it registers for and exits when that match finishes or is cancelled.

Run one bot session per team key and match. Browser viewing stays available. Registration returns an x-bot-session header; custom clients must keep it and send it with later requests. Another session gets HTTP 409. After a disconnect, the session has 60 seconds to reconnect before another bot can take over.

The helper retries eligible reads and match-bound submissions up to three attempts. A retry preserves the body, submission ID, and revisions. The starter selects a match before registering, then retries registration with the same match ID and random session token. Requests time out after five seconds. JavaScript, TypeScript, and Python use turn streams that reconnect after ten seconds without data. Java and Rust poll at turn deadlines in timed matches and every 100 milliseconds otherwise.

After a reconnect, the client fetches current state and skips missed strategy calls. Strategy errors and rejected actions are logged, then the bot submits no actions for that turn and continues. Late actions trigger a warning and a refresh to the newest state. Authentication and session ownership errors stop the bot.

If there is no current match, create practice or ask the organizers for a ranked lobby. Fix your payload for invalid_action. Check your revision counters for stale_action_revision. Do not reuse a submission ID with different content for submission_conflict.

Each batch accepts at most 100 worker actions and a JSON body up to 1 MiB. Larger bodies return HTTP 413. Invalid batch sizes return HTTP 400. The full rules contain the custom transport contract.

Full LPC 2026 rules
# Lucid Programming Competition - Rules 2026

Teams collect resources, build bases, and recruit workers. Your program submits one action per worker for each turn. After each turn, you receive an updated view of the world visible to your team.

Resource quantities are measured in kilograms (kg). Inventory, bank, cost, and deposit amounts in the API use the same numeric values: an amount of 5 means 5 kg.

## Objective and starting state

Collect gold and deposit it at your bases. Final score is the gold remaining in your team's shared bank. Carried gold does not contribute to that score; spending banked gold reduces the amount left to score. When banked gold is equal, compare these values in order, with higher values winning:

1. Banked iron.
2. Total gold carried by all of the team's workers.
3. Total iron carried by all of the team's workers.
4. Number of bases.
5. Number of workers.

Only move to the next criterion when the previous values are equal. If every value matches, teams share placement, skipping the following placements (1, 1, 3). There is no lottery or team-ID tie-breaker. The reported score remains banked gold in kg; the other values determine placement only. These rules rank teams within a match, not across the competition's overall leaderboard.

The default match has 16 teams on a 50 by 50 map and ends after 480 turns. Each team starts with one base, one worker nearby, and a shared bank with unlimited storage containing 0 kg of gold and 15 kg of iron. All friendly bases use this bank. Workers carry up to 8 kg total. Gold deposits start with 6–9 kg outside the rich center and 12–18 kg inside it; iron deposits hold 2–4 kg; the initial layout allocates three gold and two iron deposits per team.

Coordinates use `x` increasing east and `y` increasing south. The `cells` array stores the map one row at a time, from left to right and then top to bottom. Coordinates and array indices start at zero. To find cell `(x, y)`, use `cells[y * width + x]`. For example, on a 50-wide map, `(2, 1)` is `cells[52]`: skip the first 50 cells, then move two cells into the second row. Workers and bases are separate lists; multiple workers can occupy one cell.

## Worker actions

Send actions under the worker's `unitId`, not a base ID or team ID. Each submitted action is consumed for its accepted turn, including actions whose requirements are not met. Submit fresh actions each turn.

- **Move:** `{"type":"move","direction":"north"}`. Directions are `north`, `north-east`, `east`, `south-east`, `south`, `south-west`, `west`, and `north-west`.
  - Ordinary movement takes one turn, including diagonals.
  - Entering a mountain or crater puts the worker on cooldown for 2 additional turns. Actions will be accepted but not executed during this travel time. The worker enters the destination immediately. Ignored actions are consumed, not queued. After those two turns, submit a new action. The configurable `obstacleTraversalCost` includes the entry turn; a cost of 1 adds no cooldown. Each visible worker has `readyOnTurn`: it can act when the current view's `turn` is at least this value.
  - Workers cannot leave the map or enter an enemy base.
  - Friendly and opposing workers may overlap. Worker occupancy does not block movement, and simultaneous movement to the same tile is allowed.
- **Mine:** `{"type":"mine"}` takes 1 kg from the gold or iron deposit at the worker's position, if stock and inventory space remain. Deposits within two Manhattan steps of a base are claimed: only claiming teams may mine them. If friendly and enemy claims overlap, either claiming team may mine. Unclaimed deposits are available to everyone.
  - Multiple workers can mine the same deposit in a turn. If stock is insufficient for all eligible miners, workers with the longest current streak of eligible mine actions on that tile mine first. An eligible attempt counts even if another worker takes the remaining ore. Waiting, taking another action, or an ineligible mine attempt ends the streak. For equal mining streaks, workers that have stayed on the tile longest mine first. Leaving and returning resets both mining and occupancy priority. Workers tied on both use a seeded random tie-breaker. Submission time does not determine mining priority.
- **Deposit:** `{"type":"deposit"}` transfers all carried gold and iron to the shared bank when standing on a friendly base.
- **Trade:** `{"type":"trade","from":"iron"}` exchanges 3 kg of carried iron for 1 kg of gold. `from: "gold"` exchanges 3 kg of carried gold for 1 kg of iron. Trading uses the worker's inventory, not the bank, and does not require a base.
- **Spawn:** `{"type":"spawn"}` creates a worker on the acting worker's tile, spending from the bank. It does not require standing on a base. The first two recruited workers cost 5 kg and 6 kg of iron; each subsequent price is the sum of the previous two (5, 6, 11, 17, … kg). Starting workers do not count.
- **Build base:** `{"type":"build-base"}` costs 6 kg of banked iron. The builder must stand on empty terrain with no base and no other worker, including friendly workers. Mountains, craters, and resource tiles are not valid sites. The site must be more than two Manhattan steps from every enemy base; friendly bases do not restrict construction. New bases affect mining eligibility in the same turn.
- **Pass:** `{"type":"pass"}` does no work for the accepted turn. A worker with no accepted action also idles. Request-level passing is described below.

## Turn resolution

Each turn resolves in this order:

1. Due depleted deposits respawn.
2. Workers move.
3. Workers spawn new workers or build bases.
4. Workers mine.
5. Workers trade.
6. Workers deposit.
7. Built-in bot memory updates, and the turn counter advances.

Construction happens before deposits: iron deposited this turn cannot pay for construction until a later turn. The match seed determines an initial team order for construction. After each turn, the first team moves to the back of that order. All teams keep their place in the rotation, even when they do not submit a construction action. If new opposing bases would claim each other's sites, the team earlier in that turn's order builds first.

Within a team, construction consumes its shared bank in action acceptance-time order, with equal times resolved by worker ID. This applies to both building workers and building bases. An unsuccessful construction action spends no resources and does not stop other workers from building. Mining scarcity uses the seeded selection described above. A worker performs only its submitted action.

## Resource respawning

When a deposit is depleted, a replacement of the same resource is scheduled for the start of the following turn. It receives a newly seeded amount (6–9 kg of outside gold, 12–18 kg of center gold, or 2–4 kg of iron). Its location is selected reproducibly from empty terrain without a worker or base. Each gold replacement independently has a 35% chance (configurable) of spawning in the center and otherwise spawns outside it, regardless of where it was mined. If the chosen zone has no eligible cell, it uses the other zone. The emptied original position can be reused if no other eligible cell exists in the chosen zone.

Placement prefers sites at least two movement steps from the old site and other resource sites (diagonal distance counts as one step). If none meet both spacing preferences, it relaxes spacing from other resources, then spacing from the old site. If no valid site exists, respawning waits until a later turn.

## Vision and private information

Friendly workers and bases reveal tiles within Manhattan distance 3 by default. Mountains and craters do not block vision. A worker or base on either obstacle also reveals all eight neighboring cells.

A live player view contains:

- `map`: visible terrain and resource amounts, with unseen playable cells marked `hidden`.
- `units`: workers on visible cells. Your workers include their inventory; opponents' inventories are omitted.
- `bases`: bases on visible cells.
- `scores`: banked gold totals for all teams, including teams outside your vision.
- `teamResources`: banked gold and iron for your team only.
- `baseResourceDeposits`: deposits at your bases only.
- `turn`, `warnings`, `info`, and `effects`.

The enclosing state response includes `match` and `serverTimeMs`, and may include `turnEndsAtMs`. Pending matches may omit `view`. Do not infer hidden terrain or inventories from missing data. Completed replays available to participants contain the full map and resources, plus `placements` calculated using the tie-break criteria for the displayed turn; admin live views are also authoritative/full-state. Live player access is different from both.

## Actions and timing

The downloaded helpers take worker IDs mapped to individual action objects:

```json
{
  "your-worker-id": { "type": "mine" }
}
```

This only mines successfully when the worker stands on an eligible deposit and has inventory space. Submit a new action each turn; a sequence such as moving, mining, returning, and depositing belongs in your strategy logic.

Helpers submit to the match selected at registration. Custom clients can use `PUT /api/team/matches/current/actions` or `PUT /api/team/matches/:matchId/action` to stay bound to a specific match:

```json
{
  "submissionId": "a-new-unique-id-for-this-request",
  "submissions": [
    {
      "entityId": "your-worker-id",
      "revision": 1,
      "action": { "type": "mine" }
    }
  ]
}
```

- A submission replaces the worker's pending action for its accepted turn. It does not append future work.
- Omitting a worker leaves any action already accepted for that turn intact. Returning `{}` does not cancel all pending actions. An empty array is not a valid worker action; submit `{"type":"pass"}` to replace a pending action with an idle action.
- At the raw batch endpoint, `{"advanceOnly":true}` passes without replacing actions. It advances client-action practice, not arbitrary ranked/timed turns.
- Malformed LPC actions reject the batch with HTTP 400 and code `invalid_action`. Correct the payload before resubmitting. No actions are replaced and no client-action turn advances. This does not predict whether a valid action will succeed on the map.
- Revisions are non-negative safe integers (at most 9,007,199,254,740,991), scoped to one match, team, and entity. Every replacement, including a pass action, must use a revision strictly greater than that entity's highest accepted revision. Consuming actions or advancing turns does not reset the counter; a new match does.
- For a new submission, if any action has a stale or equal revision, the entire batch is rejected with HTTP 409 and code `stale_action_revision`. No actions change and no client-action turn advances. Successful acknowledgments report the accepted revision and `acceptedForTurn` for each entity.
- Registration and team-state responses include `actionRevisions`, a map of entity IDs to their highest accepted revisions. Helpers initialize their counters from registration, so restarting in the same match continues counting. Custom clients must do the same; do not reset to 1 mid-match.
- Use a unique `submissionId` for every new request, including empty batches and `{"advanceOnly":true}`. It must contain 1–128 letters, digits, hyphens, or underscores; UUIDs work. IDs are scoped to your authenticated team across matches. The starter clients generate them.
- To recover a lost acknowledgment, resend the same ID and exact revisions/actions to the same match-specific endpoint. The server returns the original acknowledgment (including `submissionId`) without replacing actions or triggering another turn, even after completion or a server restart. Object key order does not matter; array order does. Reusing an accepted ID for a different payload or explicit match returns HTTP 409, `submission_conflict`.
- Receipts are retained until the associated match is deleted. The acknowledgment confirms acceptance, not completion of a turn. If the server fails after accepting a request but before advancing a client-action match, the actions remain pending and a retry only returns the receipt. Check state; use a new intentional submission/pass to advance. Timed matches continue on their server schedule.
- To retry registration safely, first read the current match, choose a random UUID for `x-bot-session`, and send the selected match ID in `x-bot-match-id` on registration. Reuse both headers for every retry. Registration stays bound to that match even if a new match becomes current.
- Register one bot session per match and API key. Keep the `x-bot-session` response header and send it with state, action, and event-stream requests. All action routes require it, including requests authenticated by a browser cookie. A competing session receives HTTP 409 with code `bot_session_conflict`. Browser viewing and the Join button remain available. An open bot stream holds the session; after a disconnect, the same token has 60 seconds to reconnect before another bot can claim it. Completion releases the session. Sessions reset when the server restarts.
- Keep one helper process per team. Calling a helper again generates a new submission ID; it is not a retry. Helpers retry eligible transport failures at most three times with the original serialized body, ID, and revisions; they do not recalculate actions for a retry. Registration retries use the same random session token and selected match ID. Other writes through a mutable current-match endpoint are not automatically retried. See `/help` for timeout and reconnect limits. Requests without an ID (including raw `pass`) have no deduplication guarantee. Repeating a revision under a new ID is still a stale-revision error; do not automatically resubmit rejected work with a higher revision.
- In scheduled modes, acceptance before the current deadline targets `match.turn + 1`; acceptance at or after that deadline targets `match.turn + 2`. Inspect `acceptedForTurn` in the acknowledgement. The server decides acceptance under its match lock; sending before a local-clock deadline does not guarantee acceptance before the server deadline.

Turn modes:

- `timed`: the server advances on its timer, normally every 1000ms, even if a client is idle.
- `client-action`: contestant LPC practice advances when the human client submits actions or a pass request. Without submissions, the turn does not advance.
- `multi-client-action`: the server can advance once every controllable entity has an accepted action, subject to a minimum turn duration (200ms); a 1000ms fallback timer prevents waiting indefinitely. Readiness is per entity, not one request per team. This mode is available through the ranked-lobby API; ordinary admin round creation uses timed matches.

The organizers' default template determines contestant practice mechanics. LPC practice uses client-action turns; Solar Orbitor practice uses timed turns. The starter clients register for the team's existing current match; they do not create it. See `/help` for setup and connection behavior.

The default mountain terrain weight is 20%; crater terrain weight is 10%.

## High gold area

A centered 16 by 16 rectangle contains richer gold deposits, each holding 12–18 kg, twice the normal amount range. Outside this rectangle, gold deposits hold 6–9 kg. When exact centering falls between cells, the rectangle is shifted toward the top/left.

Initially, 35% of generated gold deposits are assigned to the center, rounded to the nearest whole deposit. Each depleted gold deposit independently chooses its respawn zone using the same configured center percentage and receives an amount from its new zone's range. If one zone has too few eligible initial cells, placement uses remaining cells in the other zone.

The 35% center share concentrates gold in the rich rectangle, which covers 10.24% of the default map area. Visible deposits expose their remaining quantity in kg in `amount`, which decreases by 1 with each successful mining action.