Side projects
Hareesh Gali
Side project

Qwen plays Smash

I put a 1.7B language model inside the per-frame control loop of Super Smash Bros. Melee. That meant compressing the model's entire scoring pass into one CUDA-graph replay, building a micro-simulator that imagines 6,144 futures per decision, and training a critic on the gap between what that simulator predicts and what happens. Then I measured whether any of it beat six if-statements. This post is about the distance between an LLM saying grab and a grab happening.

Qwen doesn't touch the controller directly. It reads a one-line description of the game state, things like distance, damage, and what the opponent is doing, and scores nineteen tactics such as approach, grab, utilt, edgeguard, and down_b. A lifecycle controller decides when the model gets control and when an action already in progress should finish. Under that, a motor layer turns the chosen tactic into stick coordinates and button presses, chasing the opponent into range before committing. A jab aimed at where someone was a few frames ago is just a whiff.

Here is a full match with the agent's decisions laid out underneath. Drag anywhere on the strip to scrub. The coloured band shows who held the controller at each moment, blue for the LLM, purple for a committed option running to completion, orange for the survival layer taking over. The curves are damage percent. Both panels track whichever decision sits nearest the playhead.

A full match, uncut. Stars mark stocks taken, crosses mark stocks lost, green dots mark attacks that connected. In the right panel, the tan number is what the simulator predicted for a tactic and the green number is what the calibrator corrected it to after training on this agent's own record. For grab the pair reads 97% and 14%, against a measured rate of 13%. Nothing in this panel reads the model's internals. It compares predictions against outcomes.

The game never waits for you

Melee advances every 16.7 ms at real time, whether you have decided or not. My first Qwen controller took 260 ms to score the nineteen candidate words on a 4090, which is about sixteen frames. The opponent has hit me and recovered before the model answers.

Decision latency measured back to back in one session, p50 over 40 iterations, 19 candidate words, RTX 4090. The first bar is my naive implementation rather than a tuned HuggingFace baseline, and the next section takes it apart.

Almost none of that 260 ms is the model being big. The scoring loop was doing a lot of pointless work. To score nineteen candidate words, the stock path builds nineteen sequences, each one the 377-token state prompt with a different candidate glued to the end, and pushes all nineteen through the model. The prompt gets prefilled nineteen times. Then it casts the logits to fp32 and takes a log-softmax across the full 151,936-token vocabulary at every position. That materializes 4.39 gigabytes so I can read back about forty numbers from it.

stage of the eager scoring callp50
tokenize and apply the chat template0.5 ms
build input tensors1.5 ms
model forward, 19 rows of 380 tokens233.6 ms
logits.float() then log_softmax over the vocabulary24.7 ms
Python loop gathering per-token logprobs0.7 ms
measured end to end261.1 ms

The forward is 90% of it, and that forward is doing 7,220 tokens of prefill to answer a question about 377. Restructuring the decision fixes the bulk of it. Prefill the state once, keep the KV, and run the nineteen candidates against it, which is 377 tokens plus about forty rather than 7,220. Normalize only at the candidate-token positions and materialize only their target log-probabilities, instead of a full sequence-by-vocabulary log-softmax. That version runs in 42.3 ms without any graph capture at all.

Capture is the last step. I measured it on its own instead of folding it into the total. Running the identical body two ways, once dispatched op by op and once replayed from a captured graph, holds the kernels, their order and their shapes constant so the only variable is who issues them.

Each bar removes one thing, and only the last one is dispatch. Capture is worth 12.8 ms of the 231.

An Nsight Systems trace over twenty iterations of each mode, with NVTX ranges marking the phases and --cuda-graph-trace=node so replayed graphs report their individual kernels, says what capture does and does not change.

per decisiondispatched op by opreplayed from the graph
kernels launched3,6903,690
GPU kernel time29.8 ms29.3 ms
gap time between kernels21.0 ms0.6 ms
CUDA runtime API calls3,9152
GPU busy across the phase58.6%97.8%
One decision from each phase, 3,690 kernels per lane, sliced from the GPU timeline rather than from the CPU-side markers, since a graph replay returns before the GPU has run and its kernels land after the marker closes. At full width both look solid, because 29 ms of kernel execution dominates either way. Drag the zoom in and the eager lane breaks into kernel, gap, kernel, gap, while the captured lane stays packed. Percentages track the visible window, so they move as you zoom and differ slightly from the twenty-iteration averages in the table.

The kernel count is identical in both modes, 73,800 across twenty iterations either way, and the GPU spends the same 29 ms running kernels, which is the check that the two modes are running the same computation. What changes is that the host stops making four thousand runtime calls per decision and makes two. Four thousand, to ask a small model which of nineteen words it prefers. The 21 ms of accumulated dead time between kernels collapses to 0.6 ms. Median gap goes from 1.02 microseconds to 0.06, and the difference in wall time is 12.8 ms.

In the clean benchmark, capture reduces wall time by 12.8 ms, from 42.3 to 29.5. Under Nsight the instrumentation inflates the eager path to 50.3 ms, since tracing 3,915 API calls per iteration is not free, so the trace is used to explain the mechanism rather than to set the headline number. Kernel execution accounts for about 70% of clean eager wall time and nearly all of captured wall time.

The split of 261 ms down to 30 ms is 219 ms of redundant arithmetic and 12.8 ms of dispatch. So graph capture is the small half. The rest is not recomputing the prefix nineteen times and not building a full vocabulary distribution at every position, both of which HuggingFace will avoid for you via past_key_values and logits_to_keep if you bother to use them. Capture takes the already-restructured 42.3 ms path to 29.5 ms.

I hit the same bottleneck one layer down. The agent trains its motor skills in an approximate analytic world model, a micro-simulator of Melee's delivery game built from twelve constants I measured off the emulator, running thousands of parallel environments as torch tensors. No neural dynamics, only equations and a done mask. The first version ran 17k episodes a second and I assumed the card was busy. Scaling from 4k to 65k environments left wall time per wave unchanged, which is what launch-bound looks like.

An episode is 115 simulator steps, so the peak of 6.3M episodes/sec is 0.73 billion environment-steps/sec. Holding the environment count fixed, torch.compile is worth between 1.8x and 9.2x depending on how full the card already was. The rest of the climb is batch size.

Rollout latency

At decision time the agent forks the live game state into 2,048 futures for each of the three moves the online wave rolls out, grab, shine and jab, 6,144 in total. A fourth tactic, pressure, is a delivered jab and reads the jab block rather than getting its own. The rollouts run under the trained motor policy. The futures are not copies. Each one perturbs both players' starting x by up to 1.5 units, draws a movement-speed multiplier between 0.9 and 1.1, and jitters the move's active-frame window by one frame in either direction, sampled independently. Adding that wave to a decision takes it from 30 ms to 60 ms. Running the wave on a second CUDA stream alongside the LLM forward saves 0.4 ms of the 30, because both halves want the whole card and a second stream does not supply one.

configurationsimulatorexecutiondecision p50
HuggingFace scoringnoneeager260.5 ms
CUDA-graph replaynoneone graph launch30.2 ms
imagination wave alone6,144 futuresfused29.4 ms
graph + wave6,144 futuressequential60.2 ms
graph + wave6,144 futurestwo CUDA streams59.8 ms

Sixty milliseconds is three and a half game frames, which the lifecycle layer covers, since it owns every frame between decisions. The wave's cost comes from its 115 sequential steps rather than its width, so the number of futures riding along barely matters.

192 futures take 28.9 ms and 6,144 take 30.1 ms. Thirty-two times the imagination for four percent more clock. Width is close to free in wall-clock latency across this range, though obviously not in work done. Having an imagination at all costs 29 ms.

Simulator calibration

The simulator predicts that grabs will connect about 97% of the time. Against an opponent who moves, the measured rate is 13%. It is not a liar so much as an optimist with no exposure to consequences. Feeding that forecast into decisions sends the agent into exchanges it has already won in its head. Rather than trust the simulator directly, I trained a small calibrator on the gap between its forecasts and what happened. It has about 340 parameters, its inputs are the simulator's own predictions plus a state summary, and its targets are the realized outcome of that tactic. Every decision logs a prediction and its outcome during play. At decision time the calibrator emits a connect probability for every tactic from the state summary and the tactic identity, and for the tactics the simulator rolls out its forecast is an additional input. The same network therefore scores smashes and aerials, which are never simulated online.

tacticnsimulator predictscalibrator saysmeasured rate
grab7897%14%13%
shine3371%5%6%
pressure2487%0%0%
jab2379%1%0%
usmash25not simulated9%12%
fsmash19not simulated1%0%
dsmash12not simulated9%8%
aerial2not simulated1%0%

That is the whole dataset, 216 logged decisions, few enough to be worth testing carefully. Only 158 of those rows carry a simulator forecast, since four of the tactics are never rolled out, so every predictor below is scored on that common 158-row subset. The calibrator still trains on all 216, because the smash rows are what teach it their observed rates. I ran five-fold cross-validation and scored the out-of-fold predictions against two baselines refit on each training split. The pairs carry no run identifier and were appended in play order, so random folds can put adjacent decisions from one match on both sides of a split. Contiguous folds are a rougher but more conservative proxy, since a match can still straddle a fold boundary. Both are below.

predictorBrier, randomBrier, contiguouslog loss, randomlog loss, contiguous
the raw simulator0.7990.79910.14610.146
global base rate (one constant)0.0700.0710.2690.278
per-tactic base rate0.0700.0690.3830.248
the calibrator0.0740.0740.3010.300

Read the Brier columns first, because they are bounded and log loss is not. The simulator gets 0.799. A constant gets 0.070. That is bad enough that the rest of this section is mostly explanation. Its log loss above 10 comes from predictions above 0.999 that missed, and a prediction of exactly 1.0 on a miss has infinite log loss, so the value depends on where I clip, 1e-6 here. Treat it as a statement that the simulator asserts near-certainty and is often wrong, not as a precise quantity. A constant 97% against a 13% event would only score about 3.05.

The calibrator loses to a single constant that predicts the overall connect rate for everything, on both metrics and under both fold schemes. The best predictor in the table under contiguous folds is the per-tactic base rate at 0.248, which is a lookup of each tactic's own historical rate. No network. No gradients. A column of averages beat the thing I spent a weekend on. With 216 decisions the only thing I am confident about is that the simulator is badly miscalibrated. The calibrator might be useful. This dataset does not show it. Grab's measured rate carries a 95% interval of [6, 21], so the calibrator landing on 14% looks better than the evidence supports.

It did settle one argument. The agent had an incorrigible smash-attack habit and I kept trying to shame it out with reward shaping. The logged outcomes showed the habit was half right, since up-smash was the best-connecting attack in the vocabulary at 12%, and half an artifact of smashes never being scored against reality at all. Giving them observed-rate estimates alongside the simulated tactics dropped the smash share.

Does the LLM earn its seat?

I avoided this comparison for most of the project. A 1.7B model reads a one-line state summary and picks one of nineteen words. That is a 19-way classifier. You could fit one in a spreadsheet. I ran the comparison I had been avoiding, same doctrine, same four seeds, same 150 seconds, from random picks through hand-written rules and a small distilled selector up to the full stack.

One note on the metric first. I rank these by damage ratio rather than stocks. A 150-second sample yields single-digit stock counts, and stocks turn on blast-zone geometry and on the rushing CPU throwing itself offstage, so a passive policy can bank kills it did nothing to earn. Damage is continuous, attributable, and moves about four hundred points per run, which is enough to separate policies that stocks cannot.

policydamage ratio, one column per seedmeankillsscoring p50
random picks0.350.410.500.500.441.0no model
hand-written rules0.480.550.760.560.594.0no model
small distilled selector0.550.490.450.610.531.5no model
Qwen3-1.7B, base0.440.440.430.560.472.832 ms
Qwen3-1.7B, fine-tuned0.670.580.590.620.623.032 ms
fine-tuned + imagination + calibrator0.740.620.510.500.594.232 ms

Each policy runs four times under the same duration, doctrine and reset procedure, and the stochastic policies use the same four RNG seeds. The emulator itself is not seeded, so these are aligned replicates rather than statistically paired trials. I show the individual runs rather than intervals, because a bootstrap over four numbers looks more authoritative than four numbers deserve. Damage ratio is dealt over taken. The scoring column is the model call alone, so all three Qwen rows share a graph path and differ only in weights and in what runs after the score. End to end, the first two Qwen rows decide in about 30 ms and the bottom row carries the imagination wave, which puts it near 60 ms, as in the earlier table. I expected Qwen to do better here. It did not, at least not convincingly. The fine-tune posts the best mean at 0.62 and the hand-written rules post 0.59, on four runs each, and the rules get there with negligible decision latency. Nothing in those eight runs establishes that the language model beats the heuristic. On this task, against this opponent, at this sample size, a reasonable engineer uses the heuristic and spends the afternoon outside.

One thing in that table holds up. The cleanest positive result is fine-tuning, which is higher than base Qwen in all four aligned runs, 0.44, 0.44, 0.43, 0.56 against 0.67, 0.58, 0.59, 0.62. That is the only comparison in the table where one policy sweeps the other, and the gain in the mean is about the same size as the gain from random picking to hand-written rules. The base model runs barely ahead of picking at random.

What the LLM buys is spread. The heuristic uses 6 of 19 tactics and spends 35% of its decisions on its favourite. The fine-tuned model uses 13.8 and its top pick takes 15%, and base Qwen is much the same at 14.0 and 18%, so the variety comes from the language model rather than from anything I trained into it. It makes the demo more varied to watch, which is not the same as playing better.

Smash ablations

With the toolkit finally real, the experiments became one-flag operations. Ban the smash attacks, or gate them so they unlock once the opponent is above 70%, and watch what the agent does with what is left. Four seeds each, same doctrine, same 150 seconds, per-seed ratios printed for the reasons above.

Read this table only against itself. The control arm here is the same configuration and the same four seeds as the fine-tuned row in the previous table, and it lands at 0.57 rather than 0.62, because a seed fixes the model's sampling RNG and not the emulator. The run loop is bounded by wall-clock time, so two runs at one seed see different frame counts and a CPU opponent that reacts to them. Comparisons within a batch share a run protocol and a set of RNG seeds. Comparisons across batches add another source of run-to-run noise.

armdamage ratio, one column per seedmeanmean dealttilt sharejuggle windows / air hitskills
everything, open competition0.590.560.700.410.5744113%24 / 5.22.8
smashes banned0.250.230.240.360.2722221%20 / 8.50.8
smashes gated to 70%+0.300.360.400.330.3528220%22 / 6.81.8

The smashes are the offense. Removing them halves damage output, 441 down to 222, and every banned seed lands below every ungated seed. Gating them to high percent recovers about a quarter of the lost damage, 60 points of the 219 the ban costs, but roughly half of the lost kills, 1.0 of 2.0, which is what a move that closes stocks without building them should do. The tilts fill the vacuum, moving from 13% of picks to around 20% in both restricted arms.

The air hits in that last column do not separate the arms. Per seed they run 1, 0, 7, 13 for ungated, 0, 13, 9, 12 for banned, and 3, 7, 11, 6 for gated, so every arm covers nearly the same range. Window count holds steady between 20 and 24 and carries information. What happens inside a window is noise at four seeds. I had a lovely mechanistic story ready for it and the data declined to cooperate.

Conclusion

It is not a good Melee player. The scoreline against the rushing CPU hovers around even, down-tilt whiffs on anything that moves because it fires where the target used to be, and grabs connect rarely enough that the throw path mostly sits idle. The model picks a word in thirty milliseconds and the rest of the stack spends the next several frames failing to deliver it.

Methods, numbers, and honesty notes

Everything runs on a single RTX 4090 against headless Slippi Dolphin through libmelee, Fox versus a scripted rushing CPU doctrine on Final Destination. The controller steps once per game frame with no dropped frames, measured. The recorded match runs at real time, 59.9 fps. The gameplay evaluation runs at 3x emulation speed, 179.8 fps, which shortens the wall-clock budget per frame from 16.7 ms to 5.6 ms, so a 30 ms decision costs more game frames there than it does in the video, not fewer. The model is Qwen3-1.7B with a LoRA fine-tune on self-generated play labels, merged into the base weights before graph capture so no adapter machinery sits inside the replay path.

Latencies are p50 over 40 iterations after 8 warmups, with torch.cuda.synchronize() on both sides of each timed region, scoring all 19 candidate tactics. In-game telemetry is never used for a published latency, because it wraps the scoring call alone and undercounts the decision. The Nsight Systems 2026.4.1 traces cover twenty iterations per dispatch mode; GPU-busy time is the union of kernel intervals over the phase, so overlapping kernels are not double counted.

Simulator episodes are fixed 115-step rollouts, with environments that finish early continuing under a done mask. Eager and compiled throughput are measured at identical environment counts.

A candidate's score is the mean log-probability of its own tokens, score(c) = (1/|c|) times the sum over t of log p(c_t | prompt, c_<t), so a two-token label such as down_b is not penalized against a one-token label. Both the naive path and the graph path compute it the same way.

The hand-written rules are six conditions on distance, opponent hitstun, opponent percent and opponent attack frame, selecting among approach, up-air, up-smash and forward-smash. The distilled selector is a 33k-parameter network predicting per-skill return, contact and risk, trained on the same self-generated rollout banks. The fine-tune is LoRA rank 16 over 2,919 examples drawn from those banks. Those labels and the merged adapter predate every gameplay run reported here by three days, so no evaluation trajectory could have entered training. A seed sets the model's sampling RNG, not the environment, and the labels share this post's opponent doctrine and stage, so these results are in-distribution for the fine-tune.

The calibrator is evaluated over 216 logged prediction-and-outcome pairs with 5-fold out-of-fold evaluation against global and per-tactic baselines refit on each training split. Metrics are reported on the 158 rows that carry a simulator forecast, so all predictors are scored on one population. I report both random and contiguous folds because the pairs carry no match identifier.

Gameplay comparisons give every arm the same four seeds, duration, opponent doctrine and emulator reset procedure. A run recording zero decisions means the emulator never booted, which is a harness failure rather than policy behaviour; it is retried rather than averaged in, and every row published here uses all four requested seeds. Four seeds resolve large effects and nothing subtler, so individual runs are printed instead of inferential statistics.

This demo branch carries entertainment-oriented shaping such as variety bonuses and passivity penalties. The numbers describe this agent, not a general Melee benchmark.