# Drive game characters

An enemy whose every move is a decision: send the game state, get a move your game code can run.

Category: More. Live demo: https://levanto.ai/examples/game-npc-combat

Most game enemies run on hand-written rules: if health is low, drink a potion. Players learn the rules within a few fights. Here the ogre reads the fight and **picks its next move with one Choice call**, in character.

The personality is a sentence in the request, so a brute, a tactician and a coward are **three strings, not three state machines**. Your game code still owns the rules: Sage2 only picks from the moves you offer.

## Setup

Base URL: `https://sage.levanto.ai`. Send `Authorization: Bearer $SAGE_API_KEY` on every call. Get a key, free, at https://platform.levanto.ai. Keep the key on your server.

A `null` answer is not an error: Sage2 isn't sure. Each step says what to do with it.

## Step by step

### 1. List the moves

Each move is an option with a `description` of what it does. Put the game rules in the descriptions: damage, what a guard blocks, when a potion works. Sage2 weighs the moves by these words. Precise rules give sensible fights.

```python
MOVES = [
    {
        "option": "strike",
        "description": "A quick hit for 8 damage. Half damage against a guard.",
    },
    {
        "option": "heavy_swing",
        "description": "A slow hit for 16 damage. Does nothing against a guard.",
    },
    {
        "option": "guard",
        "description": "Cuts the damage of the next hit.",
    },
    {
        "option": "drink_potion",
        "description": "Restores 15 health. Only works if a potion is left.",
    },
]
```

```typescript
const MOVES = [
  {
    option: "strike",
    description: "A quick hit for 8 damage. Half damage against a guard.",
  },
  {
    option: "heavy_swing",
    description: "A slow hit for 16 damage. Does nothing against a guard.",
  },
  { option: "guard", description: "Cuts the damage of the next hit." },
  {
    option: "drink_potion",
    description: "Restores 15 health. Only works if a potion is left.",
  },
];
```

### 2. Ask for the next move

Each turn, send the game state as `content`: both health bars, who is guarding, potions left and the last few turns. The personality goes in `instructions`, followed by "What do you do next?".

Offer only the moves that are legal right now. With no potion left, `drink_potion` is not in `options`, so Sage2 can't pick it.

Sage2 returns the move in `chosen`, with a `probability` for every move.

When two moves are too close to call, `chosen` is `null`. A person can't step in mid-fight. This code takes the most likely move from `probabilities`.

```python
def next_move(personality, state, potions):
    # Offer only legal moves: with no potion left, leave drink_potion out.
    options = [m for m in MOVES if potions or m["option"] != "drink_potion"]
    result = sage("/decide", {
        "content": state,
        "question": {"id": "next_move", "kind": "choice",
                     "instructions": f"{personality} What do you do next?", "options": options},
    })["result"]
    # A close call returns null: go with the most likely move.
    return result["chosen"] or max(result["probabilities"], key=lambda p: p["probability"])["option"]
```

```typescript
async function nextMove(personality: string, state: string, potions: number): Promise<string> {
  // Offer only legal moves: with no potion left, leave drink_potion out.
  const options = MOVES.filter(m => potions || m.option !== "drink_potion");
  const result = (await sage("/decide", {
    content: state,
    question: {
      id: "next_move", kind: "choice",
      instructions: `${personality} What do you do next?`, options,
    },
  })).result;
  // A close call returns null: go with the most likely move.
  return result.chosen ?? result.probabilities.reduce((a: any, b: any) => (b.probability > a.probability ? b : a)).option;
}
```

Response, abridged (a real run):

```json
{
  "id": "next_move",
  "kind": "choice",
  "result": {
    "chosen": null,
    "probability": null,
    "probabilities": [
      {
        "option": "strike",
        "probability": 0.2909
      },
      {
        "option": "heavy_swing",
        "probability": 0.1963
      },
      {
        "option": "guard",
        "probability": 0.1573
      },
      {
        "option": "drink_potion",
        "probability": 0.3554
      }
    ]
  },
  "meta": {
    "model": "levanto-sage-2",
    "latency_ms": 99.1,
    "question_count": 1,
    "usage": {
      "billed_input_tokens": 178,
      "image_count": 0,
      "image_tokens": 0,
      "input_tokens": 178,
      "output_tokens": 0
    }
  }
}
```

### 3. Play the turn

Call the function from step 2 with a personality, the current state and the potions left. Then apply the move with your own game logic. Here the patient ogre has just taken a heavy swing and is down to 22 health.

```python
print(next_move(
    "You are Grumble, a patient ogre. You wait for the knight to make a mistake, then punish it.",
    """Your health: 22/50. Potions left: 1.
Knight's health: 30/50.
Last turns:
Knight used Heavy swing. Grumble took 16 damage.""",
    1,
))
```

```typescript
console.log(await nextMove(
  "You are Grumble, a patient ogre. You wait for the knight to make a mistake, then punish it.",
  `Your health: 22/50. Potions left: 1.
Knight's health: 30/50.
Last turns:
Knight used Heavy swing. Grumble took 16 damage.`,
  1,
));
```

## Before you ship

- **Offer only legal moves.** Leave `drink_potion` out when no potion is left. Sage2 picks only from the moves you offer.
- **Say who's guarding.** Add a line such as "The knight is guarding." to the state. The ogre can then skip a heavy swing that would hit a guard.
- **Keep the state short.** The last few turns are enough.
- **Let the player talk.** Add the player's taunt to `content`, as the live demo does.
- **Fast enough for a turn.** A move comes back in about 70 to 150 ms. Ask while the player's attack plays.

## Full recipe (Python, `pip install requests`)

```python
import os
import requests

def sage(path, body):
    r = requests.post(
        "https://sage.levanto.ai" + path,
        headers={"Authorization": f"Bearer {os.environ['SAGE_API_KEY']}"},
        json=body,
        timeout=30,
    )
    r.raise_for_status()
    return r.json()

MOVES = [
    {
        "option": "strike",
        "description": "A quick hit for 8 damage. Half damage against a guard.",
    },
    {
        "option": "heavy_swing",
        "description": "A slow hit for 16 damage. Does nothing against a guard.",
    },
    {
        "option": "guard",
        "description": "Cuts the damage of the next hit.",
    },
    {
        "option": "drink_potion",
        "description": "Restores 15 health. Only works if a potion is left.",
    },
]

def next_move(personality, state, potions):
    # Offer only legal moves: with no potion left, leave drink_potion out.
    options = [m for m in MOVES if potions or m["option"] != "drink_potion"]
    result = sage("/decide", {
        "content": state,
        "question": {"id": "next_move", "kind": "choice",
                     "instructions": f"{personality} What do you do next?", "options": options},
    })["result"]
    # A close call returns null: go with the most likely move.
    return result["chosen"] or max(result["probabilities"], key=lambda p: p["probability"])["option"]

print(next_move(
    "You are Grumble, a patient ogre. You wait for the knight to make a mistake, then punish it.",
    """Your health: 22/50. Potions left: 1.
Knight's health: 30/50.
Last turns:
Knight used Heavy swing. Grumble took 16 damage.""",
    1,
))
```

## Full recipe (TypeScript, Node 22+, nothing to install: save as `recipe.mts`, run `npx tsx recipe.mts`)

```typescript
// Node 22+. Save as recipe.mts and run npx tsx recipe.mts
async function sage(path: string, body: unknown): Promise<any> {
  const r = await fetch("https://sage.levanto.ai" + path, {
    method: "POST",
    headers: { Authorization: `Bearer ${process.env.SAGE_API_KEY}`, "Content-Type": "application/json" },
    body: JSON.stringify(body),
    signal: AbortSignal.timeout(30_000),
  });
  if (!r.ok) throw new Error(`${r.status} ${await r.text()}`);
  return r.json();
}

const MOVES = [
  {
    option: "strike",
    description: "A quick hit for 8 damage. Half damage against a guard.",
  },
  {
    option: "heavy_swing",
    description: "A slow hit for 16 damage. Does nothing against a guard.",
  },
  { option: "guard", description: "Cuts the damage of the next hit." },
  {
    option: "drink_potion",
    description: "Restores 15 health. Only works if a potion is left.",
  },
];

async function nextMove(personality: string, state: string, potions: number): Promise<string> {
  // Offer only legal moves: with no potion left, leave drink_potion out.
  const options = MOVES.filter(m => potions || m.option !== "drink_potion");
  const result = (await sage("/decide", {
    content: state,
    question: {
      id: "next_move", kind: "choice",
      instructions: `${personality} What do you do next?`, options,
    },
  })).result;
  // A close call returns null: go with the most likely move.
  return result.chosen ?? result.probabilities.reduce((a: any, b: any) => (b.probability > a.probability ? b : a)).option;
}

console.log(await nextMove(
  "You are Grumble, a patient ogre. You wait for the knight to make a mistake, then punish it.",
  `Your health: 22/50. Potions left: 1.
Knight's health: 30/50.
Last turns:
Knight used Heavy swing. Grumble took 16 damage.`,
  1,
));
```

## Requests

### 1. POST /decide

```bash
curl -s https://sage.levanto.ai/decide \
  -H "Authorization: Bearer $SAGE_API_KEY" \
  -H "Content-Type: application/json" \
  -d @- <<'JSON'
{
  "content": "Your health: 22/50. Potions left: 1.\nKnight's health: 30/50.\nLast turns:\nKnight used Heavy swing. Grumble took 16 damage.",
  "question": {
    "id": "next_move",
    "kind": "choice",
    "instructions": "You are Grumble, a patient ogre. You wait for the knight to make a mistake, then punish it. What do you do next?",
    "options": [
      {
        "option": "strike",
        "description": "A quick hit for 8 damage. Half damage against a guard."
      },
      {
        "option": "heavy_swing",
        "description": "A slow hit for 16 damage. Does nothing against a guard."
      },
      {
        "option": "guard",
        "description": "Cuts the damage of the next hit."
      },
      {
        "option": "drink_potion",
        "description": "Restores 15 health. Only works if a potion is left."
      }
    ]
  }
}
JSON
```

Response, abridged (a real run):

```json
{
  "id": "next_move",
  "kind": "choice",
  "result": {
    "chosen": null,
    "probability": null,
    "probabilities": [
      {
        "option": "strike",
        "probability": 0.2909
      },
      {
        "option": "heavy_swing",
        "probability": 0.1963
      },
      {
        "option": "guard",
        "probability": 0.1573
      },
      {
        "option": "drink_potion",
        "probability": 0.3554
      }
    ]
  },
  "meta": {
    "model": "levanto-sage-2",
    "latency_ms": 99.1,
    "question_count": 1,
    "usage": {
      "billed_input_tokens": 178,
      "image_count": 0,
      "image_tokens": 0,
      "input_tokens": 178,
      "output_tokens": 0
    }
  }
}
```

## Docs

- Choice: https://docs.levanto.ai/decision-model/choice
- Limits and errors: https://docs.levanto.ai/decision-model/limits
- OpenAPI: https://sage.levanto.ai/openapi.json
