export { Components } from './Components'; export { Themes } from './Themes';
--
<Stack center stretch style={{ imageRendering: 'pixelated', inset: 0, overflow: 'hidden', position: 'absolute', zIndex: 0, zoom: 0.63, }}
<Video src="/AC.mp4" style={{ transform: 'translateY(-7%)', }} /> <Stack center selfCenter stretch style={{ width: '63%', zIndex: 1, }} vertical
<TitleBox children={ <h1 style={{ marginBottom: 0 }}>Building the AI for Athena Crisis } /> <TitleBox children={ <> by Christoph Nakazawa{' '} <a href="https://cpojer.net" style={{ textDecoration: 'none' }}> @cpojer </> } style={{ alignSelf: 'end', color: '#000', marginTop: 20, padding: '12px 20px', }} /> <TitleBox children={ <a href="https://turn-based-ai.nakazawa.dev/" style={{ textDecoration: 'none' }} > turn-based-ai.nakazawa.dev } style={{ alignSelf: 'end', marginTop: 20, padding: '12px 20px', }} />
image: Cover.jpg
--
image: CoverEmpty.jpg transition: none
--
image: CoverEmpty.jpg transition: none
--
image: CoverEmpty.jpg transition: none
--
image: CoverEmpty.jpg theme: dark transition: leaveOnly
--
- [nakazawa.dev](https://nakazawa.dev)
- [[email protected]](mailto:[email protected])
theme: dark
--
transition: none
--
transition: leaveOnly
--
<Stack alignCenter nowrap style={{flex: 1}}>
- Immutable persistent data structures
- Declarative game state
- Server ⇔ Client
- Optimistic Updates
- Actions → Transformers → Action Responses
transition: none
--
transition: none
--
type Vector = { x: number; y: number };
type AttackUnitAction = {
from: Vector;
to: Vector;
type: 'AttackUnit';
};
/// ----- transforms -----
type AttackUnitActionResponse = {
from: Vector;
to: Vector;
type: 'AttackUnit';
unitA?: Unit;
unitB?: Unit;
};
/// ----- via codegen -----
type EncodedAttackUnitActionResponse = [
type: 1,
fromX: number,
fromY: number,
toX: number,
toY: number,
unitA?: PlainUnit | null,
unitB?: PlainUnit | null,
];
function attackUnit(map: MapData, { from, to }: AttackUnitAction) {
const unitA = map.units.get(from);
const unitB = map.units.get(to);
if (
map.isOpponent(unitA, unitB) &&
!unitA.isCompleted() &&
unitA.canAttack(from.distance(to))
) {
// damage calculation…
return {
from,
to,
type: 'AttackUnit',
unitA: a.isDead() ? undefined : a,
unitB: b.isDead() ? undefined : b,
};
}
}
transition: none
--
const VisibleAttackUnit = {
Both: true,
Source: ({ from, to, unitA }: AttackUnitActionResponse, map: MapData) => ({
direction: getAttackDirection(from, to),
from,
type: 'HiddenTargetAttackUnit',
unitA,
weapon: getAttackWeapon(map, from, map.units.get(to)),
}),
Target: ({ from, to, unitB }: AttackUnitActionResponse, map: MapData) => ({
direction: getAttackDirection(to, from),
to,
type: 'HiddenSourceAttackUnit',
unitB,
weapon: hasCounterAttack
? getAttackWeapon(map, to, map.units.get(from))
: undefined,
}),
};
transition: none
--
function applyActionResponse(
map: MapData,
actionResponse: ActionResponse,
): MapData {
switch (actionResponse.type) {
case 'AttackUnit': {
const { from, to, unitA, unitB } = actionResponse;
const originalUnitA = map.units.get(from);
const originalUnitB = map.units.get(to);
let { units } = map;
units =
unitA && originalUnitA
? units.set(from, originalUnitA.copy(unitA).complete())
: units.delete(from);
units =
unitB && originalUnitB
? units.set(to, originalUnitB.copy(unitB))
: units.delete(to);
return map.copy({ units });
}
}
}
transition: leaveOnly
--
map = applyActionResponse(map, {
from: { x: 1, y: 1 },
to: { x: 2, y: 3 },
type: 'Move',
});
map = applyActionResponse(map, {
from: { x: 2, y: 3 },
to: { x: 3, y: 3 },
type: 'AttackUnit',
});
theme: dark
--
class AthenaCrisisAI {
public readonly responses: ReadonlyArray<ActionRespones> = [];
private act(map: MapData, action: Action): MapData | null {
const response = executeAction(map, action);
this.responses.push(response);
return response;
}
private endTurn(map: MapData): MapData | null {
const currentMap = this.act(map, EndTurnAction());
if (!currentMap) {
throw new Error('Error executing end turn action.');
}
// Return `null` to indicate that the turn ended.
return null;
}
public action(map: MapData): MapData | null {
return this.endTurn(map);
}
}
transition: leaveOnly
--
let map = …;
const ai = new AthenaCrisisAI();
while (map?.getCurrentPlayer().isBot()) {
map = ai.action(map);
}
const responses = ai.responses; // [{type: 'EndTurn'}]
class AthenaCrisisAI {
public action(map: MapData): MapData | null {
return this.move(map) || this.endTurn(map);
}
private move(map: MapData): MapData | null {
const [from, unit] = map.units.findFirst(
filterAvailableUnits(map.getCurrentPlayer()),
);
const positions = getInterestingPositions(map, from, unit);
const clusters = calculateClusters(map, positions);
const to = findPathToTargets(map, unit, clusters, from);
return to
? this.act(currentMap, MoveAction(from, to))
: this.act(currentMap, CompleteUnitAction(from));
}
}
transition: leaveOnly
--
function getInterestingPositions(
map: MapData,
from: Vector,
unit: Unit,
): ReadonlyArray<Vector> {
const isInDanger = !unit.info.hasAttack() || unit.isOutOfAmmo();
if (isInDanger) {
return map.buildings.filter((building) =>
map.matchesPlayer(unit, building),
);
}
if (unit.info.hasAbility(Ability.Capture)) {
return map.buildings.filter((building) => map.isOpponent(unit, building));
}
return map.units.filter((unitB) => map.isOpponent(unit, unitB));
}
transition: leaveOnly
--
import skmeans from 'skmeans';
function calculateClusters(
map: MapData,
positions: ReadonlyArray<Vector>,
): ReadonlyArray<Vector> {
return skmeans(positions, 3, 'kmpp', 10).centroids.map((centroid) =>
position(Math.round(centroid[0]), Math.round(centroid[1])),
);
}
function findPathToTargets(
map: MapData,
unit: Unit,
targets: ReadonlyArray<Vector>,
from: Vector,
): Vector | null {
let [target, radius] = findClosestTarget(map, unit, targets, from);
const moveableRadius = calculateRadius(map, unit, from);
while (target) {
if (moveableRadius.has(target.vector)) {
return target.vector;
}
target = (target.parent && radius.get(target.parent)) || null;
}
return null;
}
class AthenaCrisisAI {
public action(map: MapData): MapData | null {
return this.move(map) || this.endTurn(map);
}
private move(map: MapData): MapData | null {
const [from, unit] = map.units.findFirst(
filterAvailableUnits(map.getCurrentPlayer()),
);
const positions = getInterestingPositions(map, from, unit);
const clusters = calculateClusters(map, positions);
const to = findPathToTargets(map, unit, clusters, from);
return to
? this.act(currentMap, MoveAction(from, to))
: this.act(currentMap, CompleteUnitAction(from));
}
}
class AthenaCrisisAI {
public action(map: MapData): MapData | null {
return this.attack(map) || this.move(map) || this.endTurn(map);
}
private attack(map: MapData): MapData | null {
let {from, to} = getBestAttack(
map,
map.units.filter(filterAvailableUnits(map.getCurrentPlayer()))),
);
if (from && to) {
let currentMap = map;
if (from.distance(to.vector) > 1 && to.parent) {
currentMap = this.act(currentMap, MoveAction(from, to.parent));
from = to.parent;
}
return this.act(currentMap, AttackAction(from, to.vector));
}
return null;
}
}
function getBestAttack(
map: MapData,
units: ReadonlyArray<[Vector, Unit]>,
): Array<PossibleAttack> {
const possibleAttacks: Array<PossibleAttack> = [];
units.forEach(([position, unitA]) => {
attackable(map, unitA, position).forEach((item) => {
const { unit: unitB, vector } = item;
const damage = calculateLikelyDamage(unitA, unitB, map);
// Weight = Damage!?
let weight = damage;
possibleAttacks.push({
unitB,
from: position,
to: item,
weight,
});
});
});
return maxBy(possibleAttacks, ({ weight }) => weight);
}
let weight = damage; // Initialize `weight` with `damage`.
const isKill = damage >= unitB.health;
if (isKill) {
// Boost the weight if the unit will be killed.
weight = (1 / Math.max(damage, 5)) * Math.pow(MaxHealth, 2);
// Skip low damage actions.
} else if (
damage < 10 &&
unitB.info.hasAttack() &&
!unitA.info.isLongRange() &&
position.distance(vector) === 1
) {
const counter = calculateLikelyDamage(
unitB.modifyHealth(-damage),
unitA,
map,
);
// Skip if the counter attack is worse.
// No suicide.
if (counter > damage || counter > unitA.health) {
return;
}
}
const building = map.buildings.get(vector);
if (unitB.canCapture() && building) {
weight *= unitB.isCapturing() ? 10 : 1;
// Prioritize specific unit states.
} else if (
unitB.isTransportingUnits() ||
unitB.info.isLongRange() ||
building?.info.isPlayerHQ()
) {
weight *= 6;
// Prioritize if the unit has an attack.
} else if (unitB.info.hasAttack()) {
weight *= 5;
} else if (…) {
…
}
class AthenaCrisisAI {
public action(map: MapData): MapData | null {
return this.attack(map) || this.move(map) || this.endTurn(map);
}
private attack(map: MapData): MapData | null {
let {from, to} = getBestAttack(
map,
map.units.filter(filterAvailableUnits(map.getCurrentPlayer()))),
);
if (from && to) {
let currentMap = map;
if (from.distance(to.vector) > 1 && to.parent) {
currentMap = this.act(currentMap, MoveAction(from, to.parent));
from = to.parent;
}
return this.act(currentMap, AttackAction(from, to.vector));
}
return null;
}
}
- Add support for all unit and building capabilities
- Support "fog-of-war" by limiting the AIs vision
- Add customizable AI Behaviors: aggressive, defensive, passive etc.
- Predictive Optimization & Planning
- This talk was designed with React and MDX!
- Beautiful React & MDX Presentations: github.com/cpojer/remdx
- Source: github.com/nkzw-tech/turn-based-ai-talk
Work with us!
- [nakazawa.dev](https://nakazawa.dev)
- [[email protected]](mailto:[email protected])