Crystal Spire #10: Finishing the easy bit

August 16, 2025

Happy Saturday! Let's round up this game state business.

So, I'd forgotten that we already do have enemies' next move stored in state and rendered in the HTML. We just need to add their move history. Let's call it moveHistory.

{
    name: 'Jaw Worm',
    hp: 42,
    maxHp: 42,
    block: 0,
    nextMove: 'Chomp',
    moveHistory: [],
    buffs: [],
    debuffs: []
}

Then, for the "end turn" actions, add 'Chomp' to their move history:

<a href="${encodeURI(serialize({
    ...defaultGameState,
    hp: 69,
    hand: ['Defend', 'Strike', 'Strike', 'Strike', 'Strike'],
    discardPile: ['Bash', 'Defend', 'Defend', 'Defend', 'Strike'],
    enemies: [{ ...defaultGameState.enemies[0], moveHistory: ['Chomp'] }]
}))}">
    End turn 1 (60% chance)
</a>
<a href="${encodeURI(serialize({
    ...defaultGameState,
    hp: 69,
    hand: ['Defend', 'Strike', 'Strike', 'Strike', 'Strike'],
    discardPile: ['Bash', 'Defend', 'Defend', 'Defend', 'Strike'],
    enemies: [{ ...defaultGameState.enemies[0], moveHistory: ['Chomp'] }]
}))}">
    End turn 2 (40% chance)
</a>

Then finally, per enemy, display their move history:

${enemy.moveHistory.length > 0 ? `<p>Past moves: ${enemy.moveHistory.join(', ')}</p>` : ''}

Neat! Now the resulting states from the "end turn" action say "Past moves: Chomp"! Commit: "Add moveHistory to enemies".

I noticed we don't update the Jaw Worm's next move in those URLs, so we need to fix that as well:

enemies: [{ ...defaultGameState.enemies[0], moveHistory: ['Chomp'], nextMove: 'Bellow' }]
enemies: [{ ...defaultGameState.enemies[0], moveHistory: ['Chomp'], nextMove: 'Thrash' }]

And then we'll need to update moveDescriptions:

let moveDescriptions = {
    'Chomp': 'Deal 11 damage',
    'Thrash': 'Deal 7 damage, gain 5 Block',
    'Bellow': 'Gain 3 Strength and 6 Block'
};

Done! Commit: "Update enemy nextMove in action URLs".

We're now able to render any game state in the fight based on URL parameters. Nice.


The next step will be a little more complicated. Those hardcoded actions and outcomes need to go, and we need to generate real ones based on the current game state. Once we have that, we'll be truly able to navigate through the entire fight.

I've been thinking about this a lot since last time, so let's establish some key concepts:

Based on this, I think we want two functions:

There's lots of ways we could structure the return values of these. In my first go implementing this in C#, "action" objects were tied to the game state they came from, and could .Resolve() to their potential outcomes. I'm going to try restricting actions and outcomes to be pure data this time around, on the hunch that shuffling pure data around will be easier to optimize for performance later than shuffling functions or methods around.

We can start out with this simple schema for actions:

{ name: 'Play Defend' }

Every action has a name property. Some actions may have additional properties:

{ name: 'Play Strike', enemyIndex: 0 }

We can write our getOutcomes function to read the additional properties only as needed, with different blocks of logic running depending on the action's name, which leaves a lot of flexibility for implementing future actions.

The outcome schema can be similarly simple:

{ gameState: gameState, probability: 0.6 }

Given this rough plan, we can break it down into three sequential steps:

  1. Implement getActions
  2. Implement getOutcomes
  3. Update our rendering logic to use these.

Starting with step 1... "Implement getActions" seems a lot more complicated than anything we've done so far. Let's describe the requirements in plain English first, as far as they apply to the Jaw Worm fight:

Oh. When we put it like this, it looks really straightforward to implement, actually.

function getActions(gameState) {
    let actions = [];
    if (gameState.hand.includes('Strike') && gameState.energy >= 1) {
        actions.push({ name: 'Play Strike', enemyIndex: 0 });
    }
    if (gameState.hand.includes('Bash') && gameState.energy >= 2) {
        actions.push({ name: 'Play Bash', enemyIndex: 0 });
    }
    if (gameState.hand.includes('Defend') && gameState.energy >= 1) {
        actions.push({ name: 'Play Defend' });
    }
    actions.push('End Turn');
    return actions;
}

Although, now I realize there are lots of cases our requirements don't handle. What if we're dead? What if we have multiple enemies?

And for that matter, how do we check that this code even works? So far, we've been refreshing the page, clicking around and seeing if everything looks correct, but now we have a lot more cases to check, and this isn't even wired up to the page yet.

I think this is the signal to start writing tests. For now, let's leave a comment above the function:

// TODO: Test

And commit: "Add getActions function".

Next time: setting up our first tests!


View this app version | Last commit: Add getActions function


Previous post: Crystal Spire #9: Buffing the Worm's nails