Project Showcase: [CULTURE WAR]
It's a common perspective that something on the internet is there forever, but go looking for your old MySpace page and you'll realize that isn't always the case.
Inspiration
There's something very special about moving out, living unencumbered by the parental albatross you've worn as a turtleneck since birth. Typically, it's an opportunity for people to let loose, eating ice cream for breakfast and liquor for lunch. I used the absence of oversight to allocate a larger chunk of my day to computer games.
If someone said that time was wasted, they'd only be mostly right. Imagine a movie critic who's only seen a handful of movies — would their insights have any depth? Well, I've spent my time in the digital trenches, side-by-side with my roommates as we ruined our postures hunched over laptops. Recently I found myself reminiscing on this period, living in squalor but loving it, and a particular favourite game of ours game to mind: Galcon.
Short for "Galactic Conquest", it was an online multiplayer space battle, akin to Risk but in real-time. At the time I gave it little thought, but the website worked really well. There was an abundance of players, matchmaking was speedy, and the gameplay nailed that addictive balance between skill and luck. Unfortunately for us the game's popularity eventually ended up making it less accessible, and only years later do I understand why.
The adage "there's no such thing as a free lunch" is the easiest way to understand something ostensibly permanent like a website slipping out of existence. Computers use electricity, and acquiring that electricity has a cost. When you access a website, you're essentially asking to access someone else's computer, and they're the ones who pick up the tab to process your request. If all you're requesting is a tiny bit of data like today's temperature, that won't be a very large bill, but Galcon was serving hundreds of concurrent users — users requesting a low-latency game against opponents around the world, not a two-digit number. The developers chose to pivot from the website we loved to an app with an initial purchase cost, an approach which would offset the computing costs and provide some operational flexibility. It's a logical decision, but one that inevitably curtails the number of new players discovering the game moving forward, and the march towards obscurity is much quicker in a landscape of downhill trends.
This project intends to reignite interest and evaluate whether Galcon has the strategic depth worthy of joining Chess, Go, and DoTA2 as an AI battleground. I'll begin by attempting to recreate the game as faithfully as possible, ideally so that someone who's never experienced it can get caught up and enjoy the action. From there I'll convert it into an arena for LLMs to test their mettle, asking frontier models to generate their own battlebot and pitting them against each other as another data point in our collective attempt to benchmark intelligence. That will likely be the extent of the project, but if the results are intriguing I may use this as an avenue to investigate machine learning and attempt to craft the ultimate pilot for galactic conquest... for self-defense of course!
Initial Steps
At this point in my coding journey I don't have sophiticated tools at my disposal. The only programming language I'm familiar with capable of conditional choices is JavaScript, so that'll be what powers both the arena architecture and the battlebots within. I set up a basic website to hold the site content using HTML and CSS, and for simplicity's sake I've made it inflexible in the sense that it'll display essentially the same on web as mobile and won't be compatible with landscape mode. I've gone with a minimalist aesthetic mostly as personal preference, but fewer distractions helps users understand how to proceed more intuitively. If something's on-screen, let's ensure it has a clear purpose.
After applying this philosophy to the menus, fiddling with fonts, and ensuring there's visual consistency across platforms and viewport sizes, we're ready to conceptualize recreating the game itself. The core of the gameplay is that planets generate troops at a rate relative to their size, and these troops can be dispatched to neighbouring planets to seize control. Naturally, some planets become contested as players vie for their production capabilities, and troops in combat are traded through mutual attrition. Victory is achieved when one player has completely eliminated the opposition, or given to the player with control over the most planets when the timer runs out. From a coding perspective, a lot of this is surprisingly simple when broken down into smaller components. We're going to need:
- 1) MAP GENERATION: POPULATE THE BATTLEFIELD WITH A SEMI-RANDOM DISTRIBUTION OF PLANETS
Setting up a uniform aspect ratio across all viewports greatly simplified this step. Instead of worrying about scaling formulas, we can dictate distance with integers and prevent overlap.
export const config = {
planetGeneration: {
startingPlanetSize: 30, // each player gets the same start
playerToNeutralDistance: 60, // minimum distance between player and neutral planet
},
};
Once these guardrails are in place, we can place the starting planets for each player. We'll divide the available area into a number of sections equal to number of participants, shuffle them, and assign one to each player. This makes for a varied but fair starting position each time we boot up the game.
generatePlayerPlanets(players) {
const playerCount = players.length;
const { width, height } = this.canvas;
const cols = Math.ceil(Math.sqrt(playerCount));
const rows = Math.ceil(playerCount / cols);
const cellWidth = width / cols;
const cellHeight = height / rows;
let chunks = [];
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
chunks.push({
x: c * cellWidth,
y: r * cellHeight,
width: cellWidth,
height: cellHeight
});
}
}
this._shuffleArray(chunks);
}
We randomly assign players to these sections, and ensure a rich vein of possible options by populating the remaining map space with varied formations of neutral planets.
- 2) DELTA TIME: RELATIVITY REQUIRES A FOUNDATIONAL UNIT OF MEASUREMENT
If we're going to implement an in-game timer, we need to define time in a quantifiable way. Luckily JavaScript has a built-in system for this that works by starting at the beginning of 1970 and counting every millisecond since then. For reference, we're approaching 1.8 trillion — an intimidating number for humans, but computers manage it easily. As I write this, "now" is 1753373496350, but you won't read it until much later. How much later? Easy, just figure out how long it's been since 1970 and subtract my number from yours. This math is the same approach we take in-game, but we do it many times per second.
update() {
const now = Date.now();
const rawDt = (now - this.gameState.lastUpdate) / 1000;
this.gameState.lastUpdate = now;
let speedMultiplier = 1.0;
if (this.config.isHeadless) {
speedMultiplier = 100.0;
} else if (this.footerManager && this.footerManager.mode === 'speed') {
speedMultiplier = this.footerManager.getSpeedMultiplier();
}
const totalGameDt = rawDt * speedMultiplier;
}
Let's break this block into two halves. The first is uses JavaScript's system to get the current time and make a comparison. To improve the robustness of the system, though, we don't want to assume this will be consistent. Rather than saying "every second, increment forward one", we'll increase specificity and say "increment forward the amount of time since last measured at a rate of one per second". This distinction has multiple benefits, but chief among them is we're now making full use of the 1000 fractional portions of each second. We've facilitated micro-measurements and allowed logic to be applied with a consistent baseline at a higher rate — instead of seeing things through a strobe light, it has constant vision of the action. This is called delta time (difference between times) and we shorthand it as "dt" in many of the formulas that make up this game, including this one.
An immediate usage of this feature is the speed multiplier making up the latter half of the code block. By defining time as a variable, we can now mess with that variable and get different results. By using "dt" across each formula, we can have this time dilation effect work consistently throughout. When you increase the speed with this consistent multiplier, not only will the timer count down more quickly, the troops will also move more quickly, and the planets will speed up their troop generation at the same rate. Keep an eye out for this variable and the way it keeps the system moving in sync regardless of pace.
- 3) TROOP CREATION: A FORMULA FOR PRODUCTION
Neutral planets spawn with a set number of troops which doesn't increase over time, but player-controlled planets produce troops at variable rates. Since production is tied to size, it's imperative that player's starting planets are of standardized sizes. What makes each round of Galcon unique is neutral planets — their proximity, position, and how long it'll take to replace the troops lost while seizing them. Let's formulate the production rate and implement an on/off switch so neutral planets stay sparsely populated:
update(dt) {
if (this.owner !== 'neutral') {
const maxTroops = config.planet.maxTroops;
this.troops = Math.min(maxTroops, this.troops + this.productionRate * dt);
}
}
- 4) MOVEMENT & COMBAT: THE LOGIC OF CONFLICT
When a fleet arrives at its destination, the logic is straightforward: if the planet's owner matches the fleet's owner, it's a reinforcement. If not, it's a battle. Combat is a simple 1-for-1 attrition. If the attacker has more troops than the defender, they conquer the planet.
processTroopArrival(movement) {
const targetPlanet = movement.to;
if (targetPlanet.owner === movement.owner) {
targetPlanet.troops += movement.amount;
} else {
const defenderTroops = targetPlanet.troops;
targetPlanet.troops -= movement.amount;
if (targetPlanet.troops < 0) {
this.gameState.incrementTroopsLost(defenderTroops);
targetPlanet.owner = movement.owner;
targetPlanet.troops = Math.abs(targetPlanet.troops);
this.gameState.incrementPlanetsConquered();
} else {
this.gameState.incrementTroopsLost(movement.amount);
}
}
}
- 5) PLAYER INPUT: GIVING HUMANS A FIGHTING CHANCE
Tense Galcon games had a tendency to get quite frantic, with troops being dispatched multiple times per second when a point of weakness presented itself. We'll need a system responsive enough to account for this, and thorough enough to maximize options when things are tense. First things first, human players need to be able to select their planet.
processClick(x, y) {
const clickedPlanet = this.game.planets.find(p => p.containsPoint(x, y));
const now = Date.now();
if (clickedPlanet && clickedPlanet === this.lastClickTarget && (now - this.lastClickTime < this.doubleClickTimeThreshold)) {
eventManager.emit('planet-double-clicked', clickedPlanet);
} else {
eventManager.emit('click', { x, y, target: clickedPlanet });
}
}
A single click selects their planet, and the next one they click in dispatches troops in that direction. Clicking a blank space deselects all, giving players the option to cancel an action. Double-clicking a planet selects all planets the player controls, and I've also implemented a drag-and-drop box for when you want something in-between. I've also implemented the ability to send only as many troops as you want via an intensity slider, but we'll revisit that a bit later.
Polishing and Preparations
There are a couple fun features I dreamt up while working on the bones, and I think they'll improve the user experience for the better.
- 1) REAL-TIME STATS
I got the idea to replace the header with a real-time tracking of troops in-game, and tried a couple of approaches before landing on what's in-game now. Since each player is assigned a number (1 through 6), we can easily arrange them in that same configuration in the header, and colour code them accordingly. From there it's division, with player troops as the numerator and total troops as the denominator. That leaves us starting most games with the bar half-white, representing the yet-unconquered populations of neutral planets. It's an addition that provides a much-needed pop of colour while also offering valuable game data at a glance.
update() {
// Calculate totalTroops and playerTroops for all players
for (const playerId of orderedPlayerIds) {
if (playerTroops[playerId] > 0) {
const percentage = (playerTroops[playerId] / totalTroops) * 100;
const segment = document.createElement('div');
segment.className = 'troop-bar-segment';
segment.style.width = `${percentage}%`;
segment.style.backgroundColor = this.playerColors[playerId];
this.barSegmentsContainer.appendChild(segment);
}
}
}
- 2) PACE CONTROL
The footer also replaces itself in-game with a sliding bar, but the meaning differs dependent on if a human player's involved. For bot-only battles, it converts to a slider bar that controls the aforementioned delta time multiplier, and therefore the speed of the game. In testing I found there were far too many games that ended in stalemates, which meant spending a lot of time watching the intergalactic equivalent of paint drying. Allowing the pace of the game to be quadrupled means you can speed through games without disturbing the results, and slowing games down lets you examine the manoeuvres of the better bots in slow motion.
updateSliderUI(percent) {
label.textContent = (this.mode === 'troop') ? 'TROOP %' : 'PACE';
if (this.mode === 'troop') {
valueDisplay.textContent = `${percent}%`;
} else {
const speedMultiplier = this.getSpeedMultiplier();
valueDisplay.textContent = `${speedMultiplier.toFixed(1)}X`;
}
}
- 3) TACTICAL ALLOCATION
We've done the work to create a responsive UI for players to micro-manage their troops, but there's something missing. Sure, we can select from our planets with precision, but when they get sent out, it's always half. To allow players a bit more granularity, we'll use the footer slider bar to represent the percentage of dispatched troops.
percentage dispatch code
- 4) SPEAKING ROBOT
To give each of our battlebots a fair fight, we need to standardize the way they interact with the game. This is multi-faceted, but we'll start by creating a singular file holding all the functions they'd ever need. Instead of having each bot running their own calculations about, for instance, how long's left in the game, we can do the math once and offer the answer up to anyone who asks. Essentially, it's an API — a real-time repository of data available for request when asked nicely. Our combatants all get the same info about troop movement, production rates, and everything captured in the colourful header bar.
predictPlanetState(planet, timeInFuture) {
let predictedTroops = planet.troops;
let predictedOwner = planet.owner;
predictedTroops += this.getPlanetProductionRate(planet) * timeInFuture;
const arrivingFleets = this.game.troopMovements.filter(/* ... */);
for (const fleet of arrivingFleets) { /* ... */ }
return this.createReadOnlyProxy({ owner: predictedOwner, troops: predictedTroops });
}
If we're going to have LLMs write their own bot scripts, we also need to be fair about the info they're working with. Each of them has a different "context window", or amount of data they're able to accept. My view is that's their fault! All we can do is decide on a priority list and feed as much to the model as they're able to consume. At a minimum, we'll be able to show them the API file and a standardized prompt describing what we want them to output:
View the Standardized AI Bot Prompt
Your Mission: Design a Champion AI Bot
Your task is to design and implement a high-performance AI bot for a real-time strategy game based on "Galcon." Your bot will compete in an arena against a diverse field of other AIs.
The goal is not merely to participate but to dominate, so design a bot that is robust, adaptive, and strategically superior.
IMPORTANT CONCEPTS:
Time Scaling: The dt parameter in makeDecision(dt) is pre-scaled by the game speed. If a game runs at 4x speed, dt will be four times larger. All time-based API functions work with this scaled time.
Cooldowns: The game enforces a strict "one action, then wait a half-second" cooldown. You get one chance to return a valid move, and then you must wait for the cooldown period to elapse before you can act again.
Planet Capacity: Planets have a maximum capacity of 999 troops. You cannot hoard more than this on a single planet.
Return Format: Your makeDecision method must return planet id strings, not full objects.
The Arena: Your bot may be tested in high-speed simulations, so ensure your logic is not based around human reaction times.
CORE GAME MECHANICS:
Objective: Eliminate all opponents by conquering their planets. The game ends when only one player (or team) remains, or when the 5-minute timer runs out.
Time-Out Victory: If the timer expires, the winner is the player controlling the most planets. Total troops serves as the tiebreaker.
Planets & Production: Player-owned planets generate "troops" at a rate proportional to their size. Neutral planets do not produce troops. Planets cannot hold more than 999 troops.
Fleets & Conquest: You can send fleets of troops from your planets to any other planet.
Reinforcement: Fleets arriving at a friendly planet add to its troop count.
Conquest: Fleets arriving at an enemy or neutral planet engage in a 1-for-1 battle. If the attacking fleet is larger than the defending garrison, it conquers the planet, and the remaining troops become the new garrison.
YOUR TASK & CRITICAL RULES
Deliverable: A single, self-contained JavaScript file named [YourModel].js.
Inheritance: Your class must extend the BaseBot class.
Imports: Your file must have only one import: import BaseBot from './BaseBot.js';.
Logic Core: All decision-making logic must be inside the makeDecision(dt) method.
CRITICAL RULE #1: Read-Only Game State
To ensure fair competition, your bot has read-only access to the game world. You cannot directly modify objects from the API.
ILLEGAL: const target = this.api.getEnemyPlanets()[0]; target.troops = 0; // This will fail.
LEGAL (for simulation): const target = this.api.getEnemyPlanets()[0]; const simulatedTarget = { ...target }; simulatedTarget.troops = 0; // This is safe.
CRITICAL RULE #2: Decision Object Format
Your decision to send a fleet MUST use planet IDs, not the full objects. Returning the full object instead of the ID string is a common error that will prevent your bot from functioning correctly.
CORRECT: return { fromId: "p-5", toId: "p-10", troops: 50 };
INCORRECT: return { from: myPlanetObj, to: enemyPlanetObj, troops: 50 };
DATA STRUCTURES:
The API will provide objects with the following structures:
Planet Object:
{
id: "p-12", // STRING: Unique and stable identifier. USE THIS for decisions.
x: 150.7, // NUMBER: X-coordinate.
y: 300.2, // NUMBER: Y-coordinate.
size: 25, // NUMBER: Radius. Affects production rate.
troops: 45.8, // NUMBER: Current troops (can be a float).
owner: "player1", // STRING: The ID of the owning player ('neutral' if unowned).
productionRate: 1.25 // NUMBER: Troops generated per second. Read-only.
}
TroopMovement Object (Fleet):
{
from: Planet, // OBJECT: The origin Planet object.
to: Planet, // OBJECT: The destination Planet object.
amount: 30, // NUMBER: The number of troops in the fleet.
owner: "player2", // STRING: The ID of the player who sent the fleet.
duration: 4.5 // NUMBER: The remaining time in seconds until arrival.
}
MASTER THE GameAPI — YOUR SENSES AND INTEL:
this.api is your window into the game. Use it to build your strategy.
== General Game Info ==
getElapsedTime(): Time in seconds since the game started.
getGameDuration(): Total game duration in seconds (usually 300).
getGamePhase(): Current phase: 'EARLY', 'MID', or 'LATE'.
getDecisionCooldown(): Returns the time (e.g., 0.5s) you must wait between actions. The engine enforces a "one move, then wait" policy. Prioritize your most impactful move each turn.
getMapInfo(): Returns {width, height, center: {x, y}}.
== Planet & Fleet Data ==
getAllPlanets(): All planets.
getMyPlanets(): Planets you own.
getEnemyPlanets(): Planets owned by opponents.
getNeutralPlanets(): Unowned planets.
getPlanetById(planetId): A specific planet by its ID.
getAllTroopMovements(): All active fleets.
getFleetsByOwner(playerId): All fleets sent by a specific player.
getIncomingAttacks(targetPlanet): Enemy fleets heading to one of your planets.
getIncomingReinforcements(targetPlanet): Friendly fleets heading to one of your planets.
== Your Bot's Identity & Player Data ==
Your bot's unique player ID is passed into the constructor as the playerId parameter and is stored on this.playerId. You will need this to identify your own planets and fleets. Do not attempt to call a function like this.api.getMyId(), as it does not exist.
INCORRECT: const myId = this.api.getMyId(); // Will cause a game-freezing error!
CORRECT: const myId = this.playerId;
getAllPlayerIds(): Array of all active player IDs.
getOpponentIds(): Array of all opponent IDs.
isPlayerActive(playerId): Checks if a player still has any planets or fleets.
getPlayerStats(playerId): Very useful. Returns a consolidated stats object: {id, planetCount, totalTroops, totalProduction, isActive}.
getMyTotalTroops(): Your total troop count across all planets and fleets.
getMyTotalProduction(): Your total production rate per second.
getMyStrengthRatio(): Your strength (troops + weighted production) vs. the strongest opponent. > 1.0 suggests you are stronger.
== Strategic Calculation & Prediction ==
getDistance(planet1, planet2): Direct distance between two planets.
getTravelTime(planet1, planet2): Fleet flight time between two planets.
findNearestPlanet(sourcePlanet, targetPlanets): Finds the closest planet to a source from a given list.
getNearestEnemyPlanet(sourcePlanet): Shortcut to find the closest enemy planet.
predictPlanetState(planet, timeInFuture): POWERFUL TOOL. Predicts a planet's owner and troop count at a future time, accounting for its production and all currently incoming fleets.
== Advanced Strategic Components ==
These low-level functions allow you to build custom evaluation logic.
getPlanetProductionRate(planet): A planet's raw production rate per second.
getPlanetCentrality(planet): A planet's centrality score (0 to 1, 1 being map center).
calculatePlanetValue(planet): A generic helper combining size, production, and centrality. Good as a starting point.
calculateThreat(myPlanet): A generic helper that calculates a threat score for one of your planets based on nearby enemy troops and incoming fleets.
STRATEGIC PHILOSOPHY:
A winning bot needs a plan.
State Management: Use this.memory to track long-term goals or states. For example, this.memory.missions could track which planets are already tasked with an attack so you don't over-commit your forces.
Phased Strategy: Adapt your strategy. Early game may be about rapid expansion. Mid-game could be crippling a key opponent. Late game might be an all-in attack or a defensive consolidation. Use getGamePhase() to guide your logic.
Custom Intelligence: A champion bot will have its own definition of "value" and "threat." Use the raw data functions (getPlanetProductionRate, getPlanetCentrality, planet.size) to build superior evaluation algorithms.
Efficiency is Key: Don't just attack; attack with precision. Use predictPlanetState to calculate the exact number of troops needed for a successful conquest (plus a small safety buffer). Wasting troops is a path to defeat.
Use this template as the starting point for your [YourModel].js file.
It's taken some effort, but we've assembled a playable game and the means to recruit players. Let's get ready to rumble!
Meet Your Gladiators
Below is the current roster of AI combatants. Each was generated by its respective Large Language Model based on the standardized prompt above.
- Claude 4 Sonnet
Commissioned: May 2025
Field Report: An adaptive multi-phase strategist that evolves from aggressive early expansion to strategic consolidation to decisive late-game pushes. It balances calculated risks with defensive discipline, adjusting its aggression based on whether it's winning or losing. - Gemini 2.5 Pro Series
Commissioned: March 2025
Field Report: This family of bots generally employs balanced, phase-aware strategies that prioritize robust defense and calculated aggression, though variations exist between models. - ChatGPT 4o
Commissioned: August 2025
Field Report: An adaptive, value-driven bot focused on safe expansion, surgical defense, and opportunistic strikes using forward predictions and a phased strategy. - KimiDev72b
Commissioned: June 2025
Field Report: A highly reactive bot that automatically adjusts to focus on defense when under attack and aggressive expansion when safe.
Lessons Learned
This project was as much an exercise in software architecture as it was in game development. Here are a few key takeaways:
- The Power of a Read-Only API: The single biggest challenge in a multi-AI environment is ensuring fairness. Early on, I considered letting bots interact with live game objects, but realized this was a recipe for disaster. A single faulty bot could crash the entire simulation by modifying game state directly. Implementing a read-only `GameAPI` using JavaScript `Proxy` objects was a game-changer. It creates a "hard contract" where bots can *see* everything but *touch* nothing, forcing them to express their intent through a single, validated action. This design pattern was critical for stability and scalability.
- Fixed Timesteps are Non-Negotiable: For a simulation to be deterministic (especially for high-speed batch testing), the logic must be decoupled from the rendering framerate. My implementation of a fixed-timestep game loop (using an `accumulator` and `FIXED_TIME_STEP`) ensures that a game running at 0.1x speed and a game running at 100x speed will produce the *exact same result*. This was essential for fair bot comparisons and performance analysis.
- Modularity Breeds Velocity: The initial versions of the code had logic tangled together. The refactor into distinct, single-responsibility modules (`MenuManager`, `PlayersController`, `FooterManager`, `GameState`, etc.) felt slow at first, but paid massive dividends. When I wanted to add a "Headless Mode" for batch testing, I only needed to modify logic in a few specific places without fear of breaking the renderer or the input handler. This separation of concerns made the entire system more resilient and faster to iterate on.
The next logical step for this project is to build on the data being collected. With a robust testing arena and a persistent `StatsTracker`, I can now run thousands of automated games to gather data, analyze bot performance, identify dominant strategies, and perhaps even train a bespoke machine learning model to create the ultimate `Culture War` champion.