Home

Flow and dynamic difficulty

The game estimates whether the player is anxious, in flow, or bored, and adjusts the difficulty to keep them in flow longer. There is a demo you can play in the browser.

TYPE
Adaptive difficulty in a game, a side project
STATUS
The demo is playable; no playtesting with players yet
DEMO
vampire-survivors-ai-dda.vercel.app (hosted on Vercel; the interface is in Chinese)
WHEN
2025 to now. This page describes the January 2026 version; the online demo is a later build, from May 2026
BUILT WITH
TypeScript, React, Canvas. The controller is hand-written, with no library behind it
WHO
Built together with one collaborator

What it is

A small game like Vampire Survivors that runs in the browser. The player only moves; weapons fire on their own. Kills drop experience, coins, and healing. A run lasts 15 minutes.

The unusual part is in the background. The common approach is to raise difficulty on a schedule, say a tenth more enemies every minute. This version doesn't do that. A controller looks at the situation about once a second, estimates whether the player is anxious, in flow, or bored, and then decides whether to spawn more enemies or drop more healing.

The control loopTHE CONTROLLERPlayermoves, picks upgradesThe fighthealth, enemy positions, killsThree signals, binnedhealth 5 · encirclement 4 · kill streak 5Beliefanxiety, flow, boredom: three probabilitiesScore 7 actions, sample onehow far the expected fight is from the wanted oneThree knobsspawn gap · heal drops · pickup lifetimeSpawns and dropsone lap every 60 updates, about a second

FIG. 1 · The control loop

The controller is inside the dashed box. It only reads the situation and only moves three parameters.

What it looks at

The controller looks at three things, all numbers the game already has.

No camera, no heart rate, and it doesn't ask the player anything. So "anxious, flow, bored" are just the names of three states in the model. Whether they line up with what a player actually feels has not been verified.

How it reads state from the signals

The controller holds three tables (fig. 2). They say: if the player is in a given state, how likely is each band of each signal. For example, when the player is anxious, the chance of critical health is 0.6; in flow it is only 0.05. These numbers are assumptions filled in by hand. They were never fit to player data.

How likely each reading is under each stateHEALTHcurrent / maxANXIETYFLOWBOREDOMcritical<20%.60.05low20–40%.30.15mid40–60%.10.60.10upper60–80%.20.30high≥80%.60ENCIRCLEMENTof 8 directions, how many hold an enemyANXIETYFLOWBOREDOMtight7–8.70.10gaps5–6.30.70.10loose3–4.20.40open0–2.50KILL STREAKkills since last being hitANXIETYFLOWBOREDOM0–2.50.403–5.30.10.106–10.10.30.1011–15.10.40.10≥16.20.30the reading the controller most wants to see≤.10≤.20≤.35≤.55>.55

FIG. 2 · The three tables

Each cell is the probability of "seeing the row's signal while in the column's state". Each column sums to 1, and darker means higher probability. The numbers are copied from the code.

Each round, the controller pushes the previous estimate one step forward, multiplies by the matching entries in the three tables, and normalizes, which gives three probabilities that sum to 1. The starting estimate is anxiety 0.1, flow 0.8, boredom 0.1.

Fig. 3 is six examples computed from these tables, starting from the initial estimate and looking at a single observation. They are computed; no recorded matches went into them.

What six situations are read asANXIETYFLOWBOREDOMhealth critical, tight ring, streak 0–21.00 / 0.00 / 0.00health low, tight ring, streak 6–100.37 / 0.63 / 0.00health low, gaps, streak 3–50.24 / 0.76 / 0.00health mid, gaps, streak 11–150.00 / 1.00 / 0.00health upper, loose, streak 16+0.00 / 0.64 / 0.36health high, open field, streak 0–20.00 / 0.00 / 1.00

FIG. 3 · What six situations get read as

The three numbers on the right are the probabilities of anxious, flow, and bored, in that order.

How it adjusts

The controller has seven actions: move each of the three parameters one step up or down, plus "do nothing" (fig. 4). It never touches enemy health, speed, or damage directly.

To pick an action it uses expected free energy from active inference. The idea, roughly: for each action, first predict what the situation will look like after taking it, then compute two things. One, how far the predicted situation is from "the situation it wants to see". Two, how vague the prediction itself is. The smaller the two add up to, the more likely the action gets picked. In the end it samples an action by probability instead of taking the best one.

"The situation it wants to see" is also hard-coded: health in the middle band, surrounded but with a gap, kill streak at 11 or above (the rows marked with a triangle in fig. 2).

The three parameters the controller can moveSpawn gapupdates between enemy spawns5100starts at 60← action 0: minus 5, more enemiesaction 1: plus 5 →Heal dropschance that a kill drops healing0.010.80starts at 0.05← action 3: minus 0.05action 2: plus 0.05 →Pickup lifetimehow long a drop stays on the ground10 s60 sstarts at 30 s← action 5: minus 5 saction 4: plus 5 s →Action 6: change nothing this lap.

FIG. 4 · Three parameters, seven actions

The line is the range a parameter can take, the black block is its starting value, and each small step is how much one action moves it. Time is converted from updates running 60 times a second.

Where it stands

What follows is about the January 2026 version. The online demo is a later build, from May; the controller has changed since, and this page has not caught up.

Another version

There is also a text-based interactive narrative version. The mechanics are different: it runs in rounds, and each round decides whether to add challenge and where to stop the text to hand the choice back to the player. I haven't sorted out the material for that one yet, so I'll leave it at that.