How does an AI game director choose the next encounter?

The interactive below is a small arena survival game with its director exposed. Press Start run and play for a minute: move with the arrow keys or WASD (on a phone, drag on the left side of the arena), and your character fires on its own. While you play, keep an eye on the director strip under the arena and the Jev inspector beside it. Then switch to the fixed wave script, play again, and compare the two tension lines.

Figure 1Jev game director: telemetry in, the next beat out

Play an arena survival run while a simulated System One model reads your telemetry twice a second, scores tension, competence and staleness, and a director samples the next of 14 designer-written beats. Toggle a fixed wave script to compare.

Built with vanilla JavaScript, Canvas 2D, and CSS. Answers are simulated by a local heuristic mock; nothing calls Jev. Request and answer shapes follow the TypeSafe API docs. Render and interaction by Pro Trailblazer.

New tab

What you're seeing

You are the red circle. Walkers (filled circles) and runners (triangles) chase you, tanks (heavy squares) are slow and take fourteen hits, and spitters (hollow diamonds) keep their distance and shoot. Red crosses are medkits and striped boxes are ammo. Every shot costs one of your 60 rounds, so ammo boxes matter more than they look. Health, ammo, enemy count, and run time sit above the arena.

The director strip puts two charts on one 60 second clock. The top lanes show each beat the director ran, labeled with the probability it was sampled at. Small hatched boxes are holds, and red labels are code overrides. Underneath, the dashed line is the target tension curve and the red line is the tension score Jev returned on each tick. Once you have played both modes, a gray line shows the other mode's last run. The log below spells out the last six decisions in words.

The inspector shows one request going out every 500 ms. The in-flight bar spans a single tick, and the red edge marks the moment the next request is due. The three score questions (tension, competence, stagnation) are drawn as dials. Tension has a dashed mark for the current target, and the other two have a red tick at their override thresholds. The choice question, next_beat, shows all 14 beats as bars: the top choice in bold and the beat that was actually sampled in red. Below that, the cost meter turns the measured call rate and token count into dollars.

A few things to try:

  • Watch the `next_beat` confidence pill during a calm stretch. When it turns red (below 0.35), the log reads "flat distribution, holding" and no beat starts. Half a second later the director asks again.
  • Compare the sampled beat with the top choice. The log says when they differ. A swarm at 0.44 runs a bit under half the time, and an elite at 0.26 about one time in four.
  • Let your health drop below 20 in a busy fight. The next answer that scores tension above 3.2 triggers a red override: a medkit or a rescue, whatever the distribution said.
  • Press Pause and open Last response. The JSON is the full answer in the documented shape, with probabilities, a legend for every score level, and usage.
  • Play one run in each mode. The run summary puts them side by side: time survived, kills, beats run, holds and overrides, director calls, cost, and the average gap between the target curve and measured tension.

One call, four questions, every 500 ms

Jev is a : it reads state and answers typed questions instead of writing text. The request has the same shape as the one in the spellcrafting demo, but the state is different. Each tick the game packs its own numbers into a JSON state and sends it with all four questions in a single request. Nothing in the state is prose. It is the telemetry a game already tracks, plus the pacing phase, because the model can only weigh what it is sent. This is a real state captured from a run, 25 seconds in, at the start of a peak:

{
  "hp": 73,
  "hp_delta_10s": 0,
  "damage_taken_10s": 0,
  "kills_10s": 10,
  "enemies_alive": 10,
  "enemy_mix": {
    "walker": 10,
    "runner": 0,
    "tank": 0,
    "spitter": 0
  },
  "avg_enemy_distance": 253,
  "time_since_last_damage_s": 10.5,
  "ammo": 14,
  "pickups_on_floor": 0,
  "pickups_ignored_30s": 0,
  "movement_variety_10s": 1,
  "deaths": 0,
  "run_time_s": 25.5,
  "last_6_beats": [
    "trickle",
    "swarm",
    "mixed_wave",
    "flank_pair",
    "swarm",
    "swarm"
  ],
  "pacing": {
    "phase": "peak",
    "target_tension": 3.2
  }
}

The questions travel with every request. Two of the four, trimmed, look like this. The model never sees the ids (tension, next_beat); those keys exist only for the code that reads the answers.

{
  "tension": {
    "type": "score",
    "instructions": "How much pressure is the player under right now?",
    "criteria": [
      "cruising, nothing threatens them",
      "light pressure, handling it easily",
      "a real fight, taking some damage",
      "overwhelmed, losing ground",
      "about to die"
    ]
  },
  "next_beat": {
    "type": "choice",
    "instructions": "Given the state, which beat should the director run next?",
    "criteria": {
      "breather": "Nothing spawns for a few seconds. Right after a peak, or when the player is overwhelmed and needs to recover.",
      "swarm": "Eight walkers from the edges. A peak beat for a player who is handling things easily.",
      "rescue": "Clear the enemies closest to the player. Only when the player is about to die.",
      "...": "eleven more beats"
    }
  }
}

Each beat's description says when it is appropriate, not just what it spawns. That is where the designer's pacing knowledge goes. The answer comes back with a score and a distribution for each dial and a probability for every beat, wrapped as { model, answers, usage }.

How the director decides

The answer lands somewhere between 70 and 500 ms after the request, and ordinary code takes it from there. This is the same logic the demo runs:

every 500 ms:                                         // never blocks the frame loop
    send({ state: telemetry(), model: "jev-latest", questions })

on answer(a):                                          // lands 70 to 500 ms later
    error = target_tension(run_time) - a.tension.score
    speed = a.competence.score > 2.5 ? 1.15 : 1.0

    if hp < 20 and a.tension.score > 3.2 and not rescue_running:
        run(sample({ medkit_drop, rescue }))           // code override, ignores the slot
    else if beat_running:
        return                                         // dials still update the chart
    else if a.next_beat.confidence < 0.35:
        log("flat distribution, holding")
    else:
        p = a.next_beat.probabilities
        if a.stagnation.score > 1.5:
            p = without(p, last_2_beats)               // renormalized
        run(sample(p))                                 // weighted random, not argmax

The target curve is hardcoded: a valley at 0.8 for 8 seconds, a climb to 3.0, a peak at 3.2, and a relief back down, repeating every 40 seconds. error is how far the run is from that curve. The beat question already sees the phase and the target in its state, so its probabilities lean toward pressure when the run is behind and toward relief when it is ahead.

A beat holds a slot for 1.5 to 4 seconds. The director asks every tick, but it only starts a new beat once the slot is free. The answers in between still move the dials and the chart, which is why the strip shows a measured point every half second and a beat label only every few seconds.

Why the director asks for scores instead of yes or no

"Is the player struggling?" is a yes or no question. Its answer flips back and forth as a fight swings, and code that branches on it lurches between easy and hard. A score question returns a position on a ladder the designer wrote, and the score is the probability-weighted mean of the levels. A 2.6 means a real fight leaning toward overwhelmed. You can subtract that from a target, plot it, and set a threshold on it, which is what a pacing model needs.

The distribution behind the score is information too. On one tick from a test run, tension came back at 1.54 with 0.42 on level 1 and 0.49 on level 2: the model is split between light pressure and a real fight, and its confidence (0.38) says so. A director can treat a confident 1.5 differently from a split one.

Why sampling beats argmax

If the director always ran the single most likely beat, the same state would always produce the same encounter, and players learn that pattern within a couple of runs. Sampling from the probabilities keeps the designer's preferences while still letting the second and third choices happen. It is the same reason a language model's next token is sampled at a above zero rather than picked greedily. The temperature post and the top-k and top-p sampling post cover that side.

The confidence floor guards the other end. A flat distribution is the model saying several beats fit about equally well. Rolling dice on that is noise, not pacing, so the director holds for a tick and asks again with fresher telemetry. In the demo, confidence is computed as 1 minus normalized entropy over the 14 beats, so 0.35 is a real bar: a distribution with one clear favorite clears it, and three or four near-equal options do not. How much a real threshold can be trusted depends on the model's , so a production director would check it against logged runs.

Why code keeps the override rules

Three rules sit between the answer and the spawn. A player under 20 HP who is close to panic always gets a medkit or a rescue. A stale run cannot repeat either of its last two beats. A dominant player gets enemies 15 percent faster. These are guarantees a designer has to be able to promise and test, so they live in code where a unit test can pin them, not in instructions a model weighs.

The split is the point of the pattern. The model supplies judgment that is hard to write as rules (how tense does this mix of numbers feel), and the code supplies rules that must never bend. When the two disagree, the code wins, and the timeline marks it in red so you can see how often that happens.

What a 500 ms worst case means at 2 Hz

The director asks twice a second, so one tick is 500 ms. TypeSafe reports 70 to 500 ms end to end, so even the slowest answer lands before the next request goes out, and at most one request is ever in flight. The game never waits on it. The frame loop keeps drawing and the answer is applied on whatever frame it lands. At 60 frames per second, a 300 ms answer arrives about 18 frames after it was sent, and the inspector counts those frames for every request.

A beat that starts 300 ms late is invisible to a player. A frame that stalls for 300 ms is not. That is why the call has to be asynchronous, and why the questions describe the next few seconds rather than the next frame.

What it costs to ask twice a second

Each request in the demo is about 645 input tokens, and most of that is the 14 beat descriptions. Output is free. Two calls a second for an hour is 7,200 calls, about 4.6 million input tokens, which comes to about $0.20 per hour of play at $0.042 per million. A thousand players for an hour would cost about $195. The meter in the inspector computes this live from the measured call rate.

That is cheap for one player and real money at scale, and the levers are plain. Shorter beat descriptions cut every call. Asking only when a beat slot frees up would cut the rate to roughly one call every few seconds, at the cost of the live dials. Once the cost per call is known, the call rate becomes a design decision rather than a mystery.

Frequently asked questions

How does dynamic difficulty adjustment work?

The game measures how the player is doing (damage taken, kills, health, how long since the last hit) and changes what it throws at them in response. Classic systems use hand-tuned formulas on those numbers. The pattern in this demo keeps the measuring in the game but hands the judgment to a model: the telemetry goes out as state, typed questions come back as scores and probabilities, and code applies them with fixed rules on top.

What is an AI director in games?

It is a system that watches the run and decides what happens next: when to send enemies, when to give the player a break, when to drop supplies. Its job is pacing rather than difficulty alone, usually following a target rhythm of build-up, peak, and relief. The director in this demo picks from 14 beats a designer wrote, so it can only ever run encounters that already exist.

Can AI adjust game difficulty in real time?

Yes, if the model is fast and the call is asynchronous. TypeSafe reports 70 to 500 ms per request for Jev, which fits inside a 2 Hz loop: every answer lands before the next tick, and the game keeps running at full frame rate while it waits. The adjustments then happen at the pace of encounters (every few seconds), not frames.

Why not let a chat model write the encounters?

A generated encounter can name enemies or effects the game has no code for, and someone has to check every one before it ships. A typed choice is by construction: it can only return a beat from the designer's list. It is also far faster and cheaper, since only input tokens are billed and nothing has to be parsed.

What happens when the model is unsure?

The confidence value says so. Here a next_beat confidence under 0.35 makes the director hold and ask again half a second later. A different game might fall back to a default beat or keep the current one running. Either way the decision about what uncertainty means lives in code.

Key takeaways

  • A game director doesn't need to generate anything. It needs typed judgments about the game's own telemetry, and one call can carry several of them.
  • Ask for scores on ladders the designer writes. A fractional tension of 2.6 can be compared with a target curve, and a yes or no cannot.
  • Sample the next beat from the probabilities so the same state doesn't always produce the same encounter, and use confidence as a floor so a flat distribution means wait, not roll.
  • Keep the guarantees in code. Emergency rescues, no repeats when the run goes stale, and speed scaling are rules a designer can test, and the model never gets a vote on them.
  • At 2 Hz, a 500 ms worst case still lands before the next tick, so the loop can be fully asynchronous. The bill is driven by input tokens, mostly the question descriptions sent on every call.

Written by Ryan Murray. Published , updated .