1. Zero-shot generalization

    RL only

    A policy trained only on Stage 1 reaching Stage 5 on the Lunatic difficulty.

  2. One-credit clear

    Behavioral cloning

    A behavioral-cloning policy’s Lunatic 1CC.

  3. Deathbomb

    Behavioral cloning

    A behavioral-cloning policy escaping death via use of a bomb.

Several years ago, I was obsessed with the idea of building an AI model to play video games automatically, inspired by projects such as the Mario genetic algorithm by SethBling and the many AI Learns to X videos by Code Bullet.

Three years ago, I started on a journey to do the same for Touhou, a bullet hell game where the goal is to survive the volleys of bullets coming at the player. My goal was to train an RL model that could clear the game on the highest difficulty, Lunatic, in one attempt. Players call this a one-credit clear, or 1CC.

One of the reasons it took me so long was how hard Touhou is to train a model on. As I mentioned in the TH10 Gym release post, where I discussed why I use Touhou as a testbed for RL, the game combines a long-horizon task with a hard credit assignment problem. For a 1CC, the agent needs to manage resources such as bombs across about 140,000 steps, the length of the agent’s own clear. A bomb clears the bullets on screen, but it costs power, which lowers the player’s firepower and leaves one less bomb to save itself with in later stages.

For context, Touhou 10: Mountain of Faith has six stages, which I’ll call Stage 1 to Stage 6. Each has waves of enemies and a boss that shoots volleys of bullets at the player. Players call a boss’s named attacks spell cards, but I’ll just call them boss attacks in this post.

Today, I’m excited to share my first major breakthrough toward that goal. This post covers, in chronological order:

  1. The initial model that couldn’t clear Stage 1, and how I diagnosed the issue
  2. A policy trained only on Stage 1, and its zero-shot generalization to Stages 2 to 5
  3. Achieving a 1CC with behavioral cloning
  4. Degraded performance from continuing to train the cloned policy with RL, and its diagnosis

Setup

Observation

One of my first inspirations for applying ML to Touhou was this video, which used captured gameplay images to choose the best action. My first attempt three years ago also used computer vision, but I quickly realized it would be infeasible to play the game in real time with the latency of running a vision model.

That’s why I built TH10 Gym, which I used to give the model the position, velocity, and size of:

  • bullets
  • lasers
  • enemies
  • items

on the current step and the three before it, about 50 milliseconds of history. I also gave the model the player’s position, lives, and power, which sets how many bombs it can use.

For encoding, I capped the bullet list at 64 and the lasers, enemies, and items at 24 each. Every bullet on screen also counts toward a coarse occupancy map of the playfield.

Figure 1The agent's observation on one step

  • Bullet position across four steps
  • Coarse occupancy map
  • Enemy
  • Player
Player
A Stage 1 step with 110 bullets on screen. The network sees the positions of 64 bullets across four steps, including 4 just off screen. The other 50 on screen only show up as counts in the 14 × 12 occupancy map.
Values in figure 1
BulletDistance from the playerSpeedHeading
130 px from the player4.5 px per stepdown-right
237 px from the player4.5 px per stepdown-right
324 px from the player4.5 px per stepdown-right
410 px from the player4.5 px per stepdown-right
555 px from the player4.5 px per stepdown-right
655 px from the player4.5 px per stepdown-right
715 px from the player4.5 px per stepdown-right
848 px from the player4.5 px per stepdown-right
944 px from the player4.5 px per stepdown-right
1064 px from the player4.5 px per stepdown-right
1162 px from the player4.5 px per stepdown-right
1266 px from the player4.5 px per stepdown-right
1325 px from the player4.5 px per stepdown-right
1464 px from the player4.5 px per stepdown-right
1516 px from the player4.5 px per stepdown-right
1674 px from the player4.5 px per stepdown-right
1739 px from the player4.5 px per stepdown-right
1818 px from the player4.5 px per stepdown-right
1981 px from the player4.5 px per stepdown-right
2073 px from the player4.5 px per stepdown-right
2179 px from the player4.5 px per stepdown-right
2263 px from the player4.5 px per stepdown-right
2382 px from the player4.5 px per stepdown-right
2436 px from the player4.5 px per stepdown-right
2574 px from the player4.5 px per stepdown-right
2632 px from the player4.5 px per stepdown-right
2796 px from the player5.7 px per stepdown-right
2897 px from the player5.7 px per stepdown
2998 px from the player5.8 px per stepdown-right
3088 px from the player4.5 px per stepdown-right
3164 px from the player4.5 px per stepdown-right
3236 px from the player4.5 px per stepdown-right
3336 px from the player4.5 px per stepdown-right
3480 px from the player4.5 px per stepdown-right
3539 px from the player4.5 px per stepdown-right
3689 px from the player4.5 px per stepdown-right
3799 px from the player4.5 px per stepdown-right
3899 px from the player4.5 px per stepdown-right
3942 px from the player4.5 px per stepdown-right
4057 px from the player4.5 px per stepdown-right
41100 px from the player4.5 px per stepdown-right
4252 px from the player4.5 px per stepdown-right
43111 px from the player5.7 px per stepdown-right
44111 px from the player5.8 px per stepdown-right
45103 px from the player4.5 px per stepdown-right
4647 px from the player4.5 px per stepdown-right
47113 px from the player5.7 px per stepdown-right
48105 px from the player4.5 px per stepdown-right
4955 px from the player4.5 px per stepdown-right
5057 px from the player4.5 px per stepdown-right
5160 px from the player4.5 px per stepdown-right
52110 px from the player4.5 px per stepdown-right
53119 px from the player4.5 px per stepdown-right
54127 px from the player5.7 px per stepdown-right
55120 px from the player4.5 px per stepdown-right
56128 px from the player5.7 px per stepdown-right
5765 px from the player4.5 px per stepdown-right
58129 px from the player5.8 px per stepdown-right
59124 px from the player4.5 px per stepdown-right
60146 px from the player5.7 px per stepdown-right

Actions

At every step, the agent chooses to stand still or move in one of eight directions, at normal speed or in the slower focus mode, which players use for precise dodging. It also chooses whether to bomb. A bomb makes the player briefly invincible and clears the bullets on screen, at the cost of power, which weakens the player’s shot. Shooting is automatic and costs nothing.

Training

I trained the agent with PPO (Schulman et al., 2017) and GAE (Schulman et al., 2016) across eight parallel game instances, with a discount factor γ = 0.999 and λ = 0.99 for GAE.

Reward

One of my first reward functions was a sparse reward for clearing a stage. It quickly taught me the problem with sparse rewards: if the agent doesn’t clear the stage by chance during training, it has no reward to learn from.

The reward function I ended up with combines a reward for making progress with soft penalties for losing a life, which a 1CC run can afford, and for using a bomb.

The first model

The first policy stopped improving after 500,000 steps of training. I evaluated nine checkpoints from across the 2M steps of training with frozen weights, 108 attempts in total, and none of them cleared Stage 1.

Figure 2Steps per life over training

Median steps per life
Improvement stopped010020030040000.5M1M1.5M2M
Training steps
Each point is one checkpoint's median over six frozen Stage 1 attempts. None of them cleared the stage.
Values in figure 2
StepsMedian steps per lifeClears
0195.90 of 6
250K267.40 of 6
500K311.90 of 6
750K289.50 of 6
1M322.60 of 6
1.25M302.30 of 6
1.5M268.30 of 6
1.75M292.80 of 6
2M262.70 of 6

Since the game runs at 60 Hz, the reward falls off quickly. GAE weights a reward’s contribution to an action’s advantage by γ and λ multiplied together for every step between them, so that weight halves about every 60 steps, or one second. For actions such as bombing, which can affect the outcome stages later, credit assignment is extremely difficult because the credit has decayed to almost nothing by then.

Figure 3Credit for a reward, by delay

Credit the action gets
Delay between the action and the reward (log scale)
The curve shows how much credit an action gets for a reward, relative to a reward on the same step. The dashed lines mark typical delays in Touhou 10.

However, a bad dodge can kill the player within a few steps, so I ruled out the decaying credit as the cause here. Another possible cause was the value function, which predicts the expected future reward. PPO uses these predictions to judge whether the outcome of an action was better or worse than expected, so if the value function wasn’t learning, the policy wouldn’t learn either. However, its predictions had a 60% explained variance, so I ruled this out too. The next place I looked was the input to the network.

Probing the encoder

As a proxy for how well the model perceives the game, I trained a classifier with the same architecture as the policy’s encoder to predict which moves would get the player hit in the next 4 and 8 steps. I labeled recorded states from all six stages by projecting every bullet forward along its current velocity.

At 4 steps, the original encoder found 99.7% of the unsafe moves on the states it trained on, but only 29% on held-out states. A modified encoder with more explicit features, such as how soon each bullet passes closest to the player and how close it gets, found 77%. I also tested an attention-based encoder, but it performed worse at 54% recall with three times more parameters. Every encoder did better at 8 steps, where the modified encoder reached 87%.

Figure 4Unsafe-move recall on training and held-out states

Forecast horizon
  • Training
  • Held-out
Policy encoderOriginal inputs, 98K parameters
100%99%
29%48%
With geometric featuresOriginal inputs plus geometry, 107K parameters
99%99%
77%87%
Attention encoderOriginal inputs, 302K parameters
100%100%
54%66%
Recall of each encoder on 2,446 training and 1,193 held-out states from all six stages.
Values in figure 4
EncoderParametersTrain, 4 stepsHeld out, 4 stepsTrain, 8 stepsHeld out, 8 steps
Policy encoder97,794100%29%99%48%
With geometric features107,20299%77%99%87%
Attention encoder301,644100%54%100%66%

A forecast for each move

For each of the 18 moves the player can make, I encoded a forecast of how close the player would come to being hit in the next 2, 4, 8, and 16 steps if it held that move, assuming every projectile keeps its current velocity. The longest horizon, 16 steps, is about 267 milliseconds.

To compare the forecast encoder with the old one, I trained both to imitate a scripted controller, which I describe in the behavioral cloning section, and tested them on held-out states. The old encoder picked a move projected to hit the player in 4% of the states where a safe move existed. The forecast encoder never did.

Figure 5Forecast and policy probability for each move

Frame

The 2 moves that run into a bullet get 0% combined.

410 more bullets on screen aren't drawn.

The 7 moves that run into a bullet get 0% combined.

  • Bullet path over 8 steps
  • Player position after 8 steps of each move, normal speed outside, focused insideClearPasses closeRuns into a bullet
  • Policy probability
Calm frame from Stage 2, dense frame from Stage 3. Probabilities include each move's bomb variant.
Values in figure 5
FrameMoveForecastProbability
CalmStay putClear for 8 steps0%
CalmUpClear for 8 steps0%
CalmDownClear for 8 steps0%
CalmLeftClear for 8 steps0%
CalmRightRuns into a bullet within 8 steps0%
CalmUp-leftClear for 8 steps0%
CalmUp-rightRuns into a bullet within 8 steps0%
CalmDown-leftClear for 8 steps0%
CalmDown-rightClear for 8 steps0%
CalmFocused stay putClear for 8 steps0%
CalmFocused upClear for 8 steps0%
CalmFocused downClear for 8 steps41%
CalmFocused leftClear for 8 steps0%
CalmFocused rightPasses within 16 px of a bullet0%
CalmFocused up-leftClear for 8 steps59%
CalmFocused up-rightPasses within 16 px of a bullet0%
CalmFocused down-leftClear for 8 steps0%
CalmFocused down-rightClear for 8 steps0%
DenseStay putClear for 8 steps97%
DenseUpRuns into a bullet within 8 steps0%
DenseDownRuns into a bullet within 8 steps0%
DenseLeftPasses within 16 px of a bullet0%
DenseRightRuns into a bullet within 8 steps0%
DenseUp-leftRuns into a bullet within 8 steps0%
DenseUp-rightRuns into a bullet within 8 steps0%
DenseDown-leftRuns into a bullet within 8 steps0%
DenseDown-rightRuns into a bullet within 8 steps0%
DenseFocused stay putClear for 8 steps3%
DenseFocused upPasses within 16 px of a bullet0%
DenseFocused downPasses within 16 px of a bullet0%
DenseFocused leftPasses within 16 px of a bullet<1%
DenseFocused rightPasses within 16 px of a bullet0%
DenseFocused up-leftPasses within 16 px of a bullet0%
DenseFocused up-rightPasses within 16 px of a bullet0%
DenseFocused down-leftPasses within 16 px of a bullet0%
DenseFocused down-rightPasses within 16 px of a bullet0%

One limitation of this encoder is the constant-velocity assumption, which doesn’t hold for curving bullets. Training the policy with RL partly makes up for this, because the policy learns from experience.

Zero-shot generalization and reaching Stage 5

With the new encoder in place, I reran the training from randomly initialized weights against Stage 1, and the model learned to consistently beat Stage 1. With its weights frozen after 2M training steps, it cleared all eight evaluation attempts.

Figure 6Stage 1 clear rate over training, from random weights

  • Clear rate during training
  • Frozen evaluation
Stage 1 clear rate
0%25%50%75%100%00.5M1M1.5M2M
Training steps
The line averages the last 100 training attempts. Each frozen evaluation is eight attempts with one checkpoint's weights.
Values in figure 6
StepsFrozen clearsRate during training
00 of 8—
2M8 of 838%

After it succeeded on Stage 1, I ran the same frozen weights on full Lunatic runs. The policy had only ever trained on Stage 1, but its play generalized zero-shot to stages it had never been trained on, and in the run below it reached Stage 5 before losing its last life.

RL only

The RL-only policy in a Stage 3 boss attack

A policy trained only on Lunatic Stage 1 getting through a Stage 3 boss attack without a hit or a bomb.
Watch the full run on YouTube

In Touhou, when a player uses a bomb to save themself from a situation where there is no escape from the incoming bullet volley, it is called a deathbomb. In 105 recorded instances where a deathbomb could have saved the agent, the bombing action only had an average probability of less than 1%.

Figure 7Bomb probability on each of the 105 deathbomb states

RL only, 2M stepsBombing is the top choice in 0 of 105
Mean 0.68%
RL only, 2.5M stepsBombing is the top choice in 25 of 105
Mean 26%
Each dot is one recorded state where a deathbomb could have saved the agent. The line marks each policy's mean.
Values in figure 7
StateStageRL only, 2M stepsRL only, 2.5M steps
110.06%4%
213%89%
310.11%0.87%
410.00%4%
517%0.04%
6122%0.05%
730.05%81%
830.12%93%
940.00%57%
1040.01%77%
1140.00%48%
1240.93%96%
1340.00%5%
1440.00%3%
1540.46%93%
1640.39%92%
1740.00%2%
1840.00%3%
1942%96%
2040.60%94%
2140.00%33%
2240.00%68%
2340.00%85%
2440.00%44%
2550.11%3%
2650.06%0.82%
2750.17%4%
2852%70%
2950.05%2%
3050.04%0.59%
3150.14%5%
3250.28%12%
3350.22%10%
3450.54%29%
3550.37%27%
3650.11%80%
3750.09%84%
3850.06%0.91%
3950.14%6%
4050.16%6%
4150.01%0.23%
4250.01%0.21%
4350.18%16%
4450.22%14%
45511%84%
4651%83%
4750.06%2%
4850.07%1%
4950.24%6%
5050.24%6%
5150.10%2%
5250.05%0.79%
5350.12%4%
5450.18%6%
5555%38%
5653%56%
5760.02%3%
5860.00%0.30%
5960.00%2%
6060.00%8%
6160.00%10%
6260.09%21%
6360.11%22%
6460.00%1%
6560.00%0.68%
6660.00%6%
6760.00%8%
6860.04%4%
6960.16%52%
7060.00%45%
7160.00%26%
7260.00%0.23%
7360.09%21%
7460.12%23%
7560.01%11%
7660.02%2%
7760.06%9%
7840.00%32%
7940.00%12%
8041%87%
8141%96%
8240.01%81%
8350.01%0.14%
8450.20%16%
8550.23%15%
8650.00%0.05%
8750.00%0.06%
8850.40%4%
8951%59%
9050.02%0.27%
9150.03%2%
9250.16%3%
9351%70%
9450.64%31%
9550.01%0.20%
9650.02%1%
9750.21%14%
9850.11%55%
9950.24%43%
10050.01%0.26%
10150.01%0.20%
10250.06%2%
10350.25%17%
10450.00%0.03%
10550.00%0.07%

My hypothesis was that further training would teach the agent to deathbomb. After another 500,000 steps, the average bomb probability on those states did rise to 26%, but the clear rate on frozen evaluations dropped from 8 of 8 to 1 of 8.

The value function seems to have learned some mechanics of the game, such as impending danger, with sharp drops where deathbombs saved the agent, but the policy’s use of the bomb is still not consistent. In the attempt below, the agent lost a life between its two deathbombs and did not use a bomb even though the value estimate predicted impending danger.

Figure 8Value estimate over one Stage 1 attempt

  • Value estimate
  • Hit
  • Bomb
Value estimate, rescaled to this attempt
DeathbombLife lostDeathbomb0501000 s10 s20 s
Time in the attempt
The RL-only policy's value estimate over one Stage 1 attempt, rescaled from 0 to 100.
Values in figure 8
TimeEventValue, 0.5 s beforeValue at the event
11.4 shit, cancelled by a deathbomb6127
13.9 sbomb5669
21.7 shit, life lost6342
24.5 sbomb4765
25.4 shit, cancelled by a deathbomb6311

Behavioral cloning

The RL-only policy rarely deathbombed and could not clear the full game, so I tried behavioral cloning (Pomerleau, 1988) instead. I wrote a scripted controller that plays from the game state alone and trained the forecast network to copy its actions.

The cloned policy cleared a normal Lunatic run as Reimu B with four spare lives and 26,332,605 points, the 1CC I set out to achieve three years ago. It cleared the game again in a later run, shown in the full video below.

Behavioral cloning

The final boss’s last attack and the ending

The cloned policy beats the final boss’s last attack and reaches the ending. The full video shows the whole 39-minute run.
Watch the full run on YouTube

Fine-tuning the cloned policy

I wanted to see if continued RL training could improve the cloned policy, and it didn’t. After 250,000 additional steps on Stage 1, the fine-tuned policy cleared 21 of 24 attempts, down from 24 of 24.

I attributed the decreased performance to worse deathbombing decisions. Out of 44 dangerous situations, the cloned policy saved 38 by deathbombing while the fine-tuned policy only saved 16.

Behavioral cloning

A deathbomb at half speed

The cloned policy before fine-tuning.

To find out why, I first looked at the gradients. The policy and value function share most of their layers, with separate output heads. Behavioral cloning doesn’t train the value head, so its predictions were far off when PPO started. In the first update, the value loss’s gradient on the shared layers was about 1,400 times the policy loss’s.

Figure 9Gradient norm on the shared layers, by loss

  • Value loss
  • Policy loss
Gradient norm (log scale)
10⁻⁷10⁻⁵0.0010.1101K4K8K12K
PPO steps after cloning
The first twelve PPO updates, one per 1,000 steps.
Values in figure 9
StepsValue lossPolicy lossRatio
1,00024.80.01801,373×
2,00017.01.86e-79.2e7×
3,0000.5720.010654×
4,0000.02690.02720.99×
5,0000.05240.00080165×
6,0000.02830.00051455×
7,0000.03790.05680.67×
8,0000.02400.01681.4×
9,0000.03670.0025314×
10,0000.07450.04751.6×
11,0000.03970.01842.2×
12,0000.03670.08850.41×

To test whether the value gradient was what removed the deathbombs, I measured the deathbomb response: how often bombing is the top action on the 105 deathbomb states from earlier. After 6,000 steps of PPO, it fell from 85% to 47%. Training on the value loss alone, with the policy loss turned off, dropped it to the same 47%, while blocking the value gradient from the shared layers kept it at 76%.

Figure 10Deathbomb response after 6,000 steps under each ablation

Cloned policyBefore fine-tuning
85%
Full PPOPolicy and value losses
47%
Value loss onlyPolicy loss turned off
47%
Value gradients blockedKept out of the shared layers
76%
Each run started from the cloned policy and trained for 6,000 steps. The line marks the cloned policy's 85%.
Values in figure 10
RunStatesShare
Cloned policy89 of 10585%
Full PPO49 of 10547%
Value loss only49 of 10547%
Value gradients blocked80 of 10576%

Blocking the value gradient only delayed the decline. In a longer run, the response almost disappeared by 16,000 steps.

Limitations

Some of the presented metrics came from small samples, as frozen evaluations are quite expensive to run. Each setup was also trained once, so I can’t separate the effect of a change from run-to-run variance.

I haven’t tried the usual fix for an untrained value head, which is training it on the cloned policy’s play before PPO updates the policy, or keeping the fine-tuned policy close to the cloned policy with a KL penalty. The RL-only policy has also only trained on Stage 1, never on full runs where a bomb spent now is gone later.

What I learned

Throughout this project, I learned that:

  • RL is very sample inefficient. This is why I focused on making TH10 Gym fast, so I could collect samples as quickly as possible.
  • Computing bullet position forecasts before passing them to the encoder drastically cut the training the network needed. The original encoder never cleared Stage 1 in 2M steps, and the forecast network first cleared it at about 0.6M.
  • Training metrics can hide problems that only show up in frozen evaluation.

References