STARFALL: LAST STAND — Claude Code Kickoff Prompt (Phase 1)
> **How to use**: Open terminal, `cd` into an empty folder, run `claude`, then paste everything below the `---` line.
---
Mission
You are building **Phase 1** of a mobile-first web game called **Starfall: Last Stand** — an original sci-fi asymmetric card-and-dice strategy game for 2 players. The full spec is in `STARFALL_SPEC.md` (please read it first). This session focuses on **the pure TypeScript game engine + heuristic AI + passing unit tests**. UI comes next session.
Non-negotiables
Done criteria for this session
---
STEP 1 — Scaffold
1. Initialize Next.js 15 with TypeScript, App Router, Tailwind, pnpm.
2. Install: `zustand`, `vitest`, `@vitest/ui`, `seedrandom`, `@types/seedrandom`.
3. Create folder structure:
```
/src/engine
/src/ai
/src/ui
/src/lib
/src/store
```
4. Add `vitest.config.ts` pointing at `src/engine/**/*.test.ts` and `src/ai/**/*.test.ts`.
5. Add script: `"test": "vitest run"`, `"test:watch": "vitest"`.
6. Initialize git. Commit: `chore: scaffold starfall project`.
**STOP HERE. Report what you built. Wait for my "go" before Step 2.**
---
STEP 2 — Core Types (`src/engine/types.ts`)
Define exactly these types (refine as needed). Use discriminated unions for events.
```typescript
export type Faction = 'dominion' | 'coalition';
export type Phase = 'plan' | 'orders' | 'draw' | 'resolve';
export type ShipType =
| 'voidhawk' | 'leviathan'
| 'arrowhead' | 'hornet' | 'banshee' | 'wayfinder';
export type OrderType =
| 'voidhawks' | 'leviathan' | 'citadelFire' | 'ironguardAmbush'
| 'veyron' | 'archonKaas'
| 'arrowheads' | 'hornets' | 'banshees' | 'wayfinder'
| 'strikeTeam' | 'kaelRen' | 'redemption';
export type Card = {
id: string;
faction: Faction;
orders: OrderType[]; // 2-3 choices
};
export type Sector = {
id: number;
adjacent: number[];
ships: Partial<Record<ShipType, number>>;
fleetMarker?: { revealed: boolean; destroyed: boolean; escortStrength: number };
};
export type SpaceState = {
sectors: Sector[];
citadelShielded: boolean;
citadelDestroyed: boolean;
leviathanHP: number; // 0 = destroyed; starts at 3
leviathanSector: number | null;
voidhawksInLeviathan: number; // reserve pool
wayfinderHP: number; // 0 = destroyed; starts at 3
wayfinderSector: number | null;
};
export type GroundState = {
trackCells: Array<{ difficulty: 3 | 4 | 5; ironguard: boolean }>;
strikeTeamCell: number; // 0-indexed
ironguardsAvailable: number; // starts at 9
};
export type DuelState = {
kaelHP: number; // starts at 4
veyronHP: number; // starts at 4
kaelAlive: boolean;
veyronAlive: boolean;
kaasAlive: boolean;
};
export type GameEvent =
| { type: 'cardPlayed'; faction: Faction; cardId: string; order: OrderType }
| { type: 'attack'; attacker: Faction; from: number; to: number; dice: number[]; hits: number }
| { type: 'shipDestroyed'; faction: Faction; ship: ShipType; sector: number }
| { type: 'fleetMarkerRevealed'; sector: number; wasReal: boolean }
| { type: 'shieldDown' }
| { type: 'citadelDestroyed' }
| { type: 'characterKilled'; character: 'kael' | 'veyron' | 'kaas' }
| { type: 'bonusCardsDrawn'; faction: Faction; count: number; reason: string }
| { type: 'strikeTeamAdvanced'; from: number; to: number };
export type GameState = {
round: number;
phase: Phase;
activePlayer: Faction;
space: SpaceState;
ground: GroundState;
duel: DuelState;
hands: Record<Faction, Card[]>;
stacks: Record<Faction, Card[]>;
revealIndex: Record<Faction, number>;
decks: Record<Faction, Card[]>;
discards: Record<Faction, Card[]>;
setAside: Record<Faction, Card[]>;
bonusCards: Record<Faction, Card[]>;
winner: Faction | null;
log: GameEvent[];
seed: string;
rngCounter: number; // deterministic RNG advance
};
```
Commit: `feat(engine): core types`.
---
STEP 3 — Cards (`src/engine/cards.ts`)
Implement `createDominionDeck(): Card[]` and `createCoalitionDeck(): Card[]` per the deck distribution in spec section 5.3. Every card gets a unique `id` (e.g. `dom-voidhawk-leviathan-01`).
Write a test `src/engine/cards.test.ts` asserting each deck has 30 cards, all cards have 2–3 orders, and there are exactly 2 Redemption cards in Coalition deck.
Commit: `feat(engine): order cards`.
---
STEP 4 — Initial State (`src/engine/initialState.ts`)
Export `createInitialState(seed: string): GameState` that:
- Creates 7 sectors: 6 outer in a ring (adjacency 0↔1↔2↔3↔4↔5↔0), 1 Citadel-adjacent approach (sector 6, connected to all).
- Places 6 Fleet Markers (one per outer sector), each face-down, with escortStrength 2–4 (use seeded RNG to vary).
- Distributes initial Coalition fighters across markers (20 Arrowheads, 16 Hornets, 15 Banshees — allocation TBD, aim for balanced).
- Places Wayfinder in one random sector (seeded).
- Leviathan starts at sector 6 with 56 Voidhawks in reserve.
- Ground track: 10 cells with difficulty ramp (3, 3, 4, 4, 4, 5, 4, 5, 5, 5) — tune later.
- Duel: both characters at full HP.
- Shuffles both decks (seeded).
- Deals 6 cards to each hand.
- Returns state with `phase: 'plan'`, `activePlayer: 'coalition'`, round 1.
Write test `initialState.test.ts` asserting structural invariants.
Commit: `feat(engine): initial state`.
---
STEP 5 — Combat (`src/engine/combat.ts`)
Pure functions:
- `rollDice(count: number, seed: string, counter: number): { dice: number[]; nextCounter: number }` — uses seedrandom.
- `resolveAttack(state: GameState, params: AttackParams): GameState` — allocates dice to targets by hit threshold, applies damage to HP-based units and removes destroyed fighters/markers, emits events.
- Handle all hit thresholds from spec section 5.
- Handle Leviathan destruction = all internal Voidhawks destroyed.
- Handle Fleet Marker destruction = reveal + destroy + destroy all ships on marker.
Test `combat.test.ts`: deterministic attack scenarios.
Commit: `feat(engine): combat resolution`.
---
STEP 6 — Rules (`src/engine/rules.ts`)
Pure functions:
- `planOrders(state, faction, cardIds: [string, string, string]): GameState` — moves 3 cards from hand to stack, rest to setAside.
- `revealNextOrder(state, faction): { state, revealedCard: Card }` — flips next card in stack.
- `executeOrder(state, faction, card, orderType, params): GameState` — dispatches to order handlers; enforces legality (e.g. Redemption only if Veyron in Redemption Zone).
- Order handlers (private functions): `executeVoidhawks`, `executeLeviathan`, `executeCitadelFire`, `executeIronguardAmbush`, `executeVeyron`, `executeArchonKaas`, `executeArrowheads`, `executeHornets`, `executeBanshees`, `executeWayfinder`, `executeStrikeTeam`, `executeKaelRen`, `executeRedemption`.
- `checkWinCondition(state): Faction | null`.
- `endRound(state): GameState` — draw 3 cards, reset stacks, clear setAside to discard, increment round.
- `drawBonusCards(state, faction, count, reason): GameState`.
Test `rules.test.ts`: scripted scenarios (Strike Team reaches end → shieldDown fires; Redemption invalid when Veyron full HP; etc.).
Commit: `feat(engine): round and order rules`.
---
STEP 7 — Heuristic AI Easy (`src/ai/heuristic.ts`)
Export:
- `aiPlan(state: GameState, faction: Faction, difficulty: 'easy'): [string, string, string]` — returns 3 card IDs from hand.
- `aiChooseOrder(state, faction, revealedCard, difficulty: 'easy'): { orderType: OrderType; params: OrderParams }`.
- `aiChooseParams` for each order type (which sector to attack, which target, etc.)
Easy: pick first legal order, random card stacking, attack first adjacent enemy.
Commit: `feat(ai): easy heuristic`.
---
STEP 8 — Full Match Simulation Test (`src/engine/simulation.test.ts`)
```typescript
import { createInitialState } from './initialState';
import { planOrders, revealNextOrder, executeOrder, endRound, checkWinCondition } from './rules';
import { aiPlan, aiChooseOrder } from '../ai/heuristic';
test('AI vs AI solo match terminates with a winner in under 100 rounds', () => {
let state = createInitialState('test-seed-42');
let safety = 0;
while (!state.winner && safety++ < 100) {
// Plan phase
const cPlan = aiPlan(state, 'coalition', 'easy');
const dPlan = aiPlan(state, 'dominion', 'easy');
state = planOrders(state, 'coalition', cPlan);
state = planOrders(state, 'dominion', dPlan);
// Order phase — alternating reveals until both stacks empty
while (state.stacks.coalition.length > 0 || state.stacks.dominion.length > 0) {
for (const faction of ['coalition', 'dominion'] as const) {
if (state.stacks[faction].length === 0) continue;
const { state: s1, revealedCard } = revealNextOrder(state, faction);
const choice = aiChooseOrder(s1, faction, revealedCard, 'easy');
state = executeOrder(s1, faction, revealedCard, choice.orderType, choice.params);
state.winner = checkWinCondition(state);
if (state.winner) break;
}
if (state.winner) break;
}
if (!state.winner) state = endRound(state);
}
expect(state.winner).not.toBeNull();
expect(safety).toBeLessThan(100);
});
```
Commit: `test: full AI vs AI match simulation passes`.
---
STEP 9 — Wrap Up
1. Add README.md with:
- One-paragraph pitch
- How to run (`pnpm install && pnpm test`)
- Phase 1 status
2. Final commit: `docs: readme for phase 1`.
3. Report back with:
- Folder tree
- Number of tests passing
- Any assumptions you made or open questions
- Recommended next session agenda
---
Style Rules (apply throughout)
- **No `any`** anywhere
- **Pure functions** in engine — input state → output state
- **One concept per file** — don't stuff unrelated logic together
- **Descriptive commits** — `feat(engine):`, `test:`, `fix:`, `refactor:`
- **Comments only** where logic is non-obvious (why, not what)
- **Emit events** for everything interesting — the UI and replay system will depend on `state.log`
- **Test the invariants** — ship counts never go negative, HP never exceeds max, Redemption can't fire when illegal
Anti-patterns to avoid
- Don't import React, Next, or Supabase in `/src/engine/**`
- Don't use `Math.random()` — always use seeded RNG
- Don't mutate input state
- Don't mix UI concerns (colors, animations) into engine logic
- Don't build a class hierarchy — functional + data structures only
---
**GO**. Start with Step 1. Report back before Step 2.