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.
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.
- Health. Current health divided by the cap, sorted into 5 bands.
- Encirclement. Within a fixed distance around the player, how many of 8 directions have an enemy in them, sorted into 4 bands. It counts directions, not enemies.
- Kill streak. How many kills since the last time the player got hit, sorted into 5 bands.
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.
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.
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).
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.
- The demo builds and plays. 8 enemy types, 5 weapons, 5 passives, 5 weapon evolution recipes.
- The three states were never calibrated. No subjective reports from players, and no other independent measurement.
- No match data is saved, so this page has no measured curve of "how difficulty changes over time". The figures above are either computed from numbers in the model or drawn from the code.
- The design doc says the three tables should slowly update from match outcomes. The code doesn't do that yet.
- When a parameter hits its limit, the action that can no longer move it stays in the candidate list. It can still get sampled, and then nothing happens.
- There are 6 type errors and no tests.
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.