PS-Env

A headless PlayStation 1 wrapped as a reinforcement-learning environment, and a Nature-DQN agent that reached the end of the first level of Crash Bandicoot: Warped from pixels alone: 16 times in 328 tries.

Six frames from a filmed completion of Toad Village: Crash leaves the entry portal, runs the sand path past tents and crates, crosses the castle bridge, reaches the gate and enters the exit portal.
Six frames from one of the two filmed from-start completions of Toad Village in run 14: the entry portal, the sand path, the tents, the castle bridge, the gate, the exit portal.

What PS-Env Is

PS-Env is an OpenEnv-style reinforcement-learning environment for PlayStation 1 games. At the bottom sits the game itself, a Crash Bandicoot: Warped disc image and a PS1 BIOS, neither of which ever enters the repository. The SwanStation libretro core runs them headlessly; it is downloaded as a binary and loaded at runtime with dlopen. A small C++ host exposes exactly the four things we need from the core: press buttons, grab the frame, read memory, and save or restore state. pybind11 lifts that host into Python, where a FastAPI server offers reset() and step(action) over a WebSocket JSON protocol. The agent, a Nature DQN, sits at the far end of that socket and only ever sees stacked frames and rewards.

System architecture slide: an Agent (DQN policy) sends actions to the Python env (PS-Env, OpenEnv), which calls a C++ layer (pybind11) that drives the SwanStation emulator; side boxes show reward via RAM (six addresses), deterministic resets from save states, and frame processing to 84 by 84 grayscale; frames and reward flow back to the agent.
The system as drawn for the 2026-04-15 talk "RL In Games". The Python environment exposes step and reset, the C++ layer bridges Python to the emulator, rewards come from RAM, and save states make resets deterministic. The right edge (the disc and BIOS box) is cut off in the only surviving screenshot.

The Loop

reset() restores a save state, so every episode begins on the same frame of Toad Village with the same four lives. step(a) turns one of 13 discrete actions into a button bitmask, holds it for eight emulator frames (frame skip 8; the recovered design used 2, and the training story below explains the change), and returns the last of those frames as an 84 by 84 grayscale image together with a reward and a done flag. The actor keeps the last four frames, so the network's input is 4 by 84 by 84.

One agent step: the agent sends an action to the PS-Env server, which drives the C++ host and the SwanStation core; a frame, reward and done flag come back. Agent Nature DQN, 13 Q-values PS-Env server FastAPI, WebSocket JSON C++ host pybind11, four calls SwanStation libretro core, disc, BIOS action 0 to 12 button mask, repeat 8 input state, 8 frames 84 by 84 frame, reward, done last frame, 6 RAM bytes, z XRGB frame, 2 MB RAM
One agent step. The top row carries the action out to the emulator, the bottom row carries the frame and the RAM reads back. The reward is computed in Python from the RAM bytes; the network never sees them.

What the Agent Sees

A colour frame from the completion film: Crash on the sand path in Toad Village. the emulator's frame
84 by 84 grayscale frame, three steps agot − 3
84 by 84 grayscale frame, two steps agot − 2
84 by 84 grayscale frame, one step agot − 1
84 by 84 grayscale frame, the current stept
Left, a frame from the completion film. Right, four consecutive observations eight emulator frames apart, each run through the environment's own conversion (ps_env.frames.to_gray84: grayscale, then nearest-neighbour to 84 by 84). Stacked, they are the whole input to the network: 28,224 bytes per step, no counters, no positions.

What the Agent Can Do

Thirteen discrete actions, each a PS1 button bitmask whose bit index is the libretro joypad id (Cross is bit 0, Square bit 1, the d-pad bits 4 to 7). Cross jumps, Square spins, and Up is forward, because Toad Village runs away from the camera.

Rewards Live in RAM

Nothing the reward needs is on screen in a usable form. Fruit collected, boxes broken and lives left live somewhere in the console's 2 MB of RAM, at addresses nobody documented in 1998. The team found six of them with a memory scanner (search for a known value, change the game state, search again, until one address survives), and the reward function reads them as single bytes every step with no image processing at all. The 2026 reproduction added Crash's position (CRASH_Z, a 32-bit word) for dense progress shaping and for the level-exit check, since the recovered "78 = level done" value was never observed on the real disc.

SignalRewardRead from
holding Up (forward)+0.1the action mask
pressing Down (backward)−0.02the action mask
every step−0.0002always
Wumpa fruit collected+0.5 eachMAIN_WUMPA 0x000AD22D, BONUS_WUMPA 0x000AD239
box broken+1.0 eachMAIN_BOXES 0x000AD255, BONUS_BOXES 0x000AD241
life lost−25LIVES 0x000AD231 decreased
game over (episode ends)−5,000a life lost while LIVES was already 0
level complete (episode ends)+5,000WARP_STATE 0x0006F5E9 goes from 32 to 52
forward progress+0.01 per unitnew maximum of CRASH_Z travelled since reset (164.5K units for the whole level)

Two more charges exist only in the trainer, and the training story explains why they were needed: a stall cut (an episode ends after a run of steps without new progress) that costs a lost life, and a cost of 1 on every step that makes no new progress.

The Training Architecture

The talk's architecture diagram shows the distributed design: actors on an epsilon ladder from 1.0 down to 0.01, each with its own emulator, push transitions to Redis; a GPU learner samples batches of 32, trains the policy net (three convolutions, a 512-unit layer, 13 outputs), hard-copies it to the target net and publishes weights; TensorBoard reads its logs. The reproduction below ran the single-process trainer (agent.train) against one emulator server instead, because the emulator, not the GPU, was the bottleneck: about 50 steps per second at frame skip 8 on a g5.xlarge, with the A10G mostly idle.

Diagram: TensorBoard on port 6006 reads logs from the DQN learner, which holds a policy net and a target net; weights are copied from policy to target periodically.
TensorBoard and the learner: policy net, target net, periodic hard copy.
Diagram: n actors with epsilon 1.0, 0.7, 0.5, 0.3 down to 0.05 and 0.01, feeding the WebSocket protocol; the protocol block shows reset and step messages from the client and an observation message from the server.
The actors' epsilon ladder and the WebSocket protocol they speak.
Diagram: the WebSocket protocol above the SwanStation emulator block, which takes a CUE/BIN file and a PS1 BIOS and holds a registry of games, memory decoding and the action space.
The emulator server: disc and BIOS in, a game registry, memory decoding and the action space.
Diagram detail: memory decoding of PS1 system RAM with the warp state, lives, Wumpa and box counters and their addresses, beside the table of 13 actions.
The six RAM addresses and the first rows of the action table, as drawn in January 2026.

The Training Story

The hackathon team reported the first level completed after roughly 500K steps with the distributed trainer. The 2026-09 reproduction on one g5.xlarge started from that recovered design, single-process, and found that it did not finish Toad Village at all. From scratch at frame skip 2 the agent learned to run forward and spin and never cleared the first pit: 0 completions in 186 episodes and 805K steps. A reverse curriculum at frame skip 2 wedged at the same pit (about 10% pit success for 200K steps with uniform one-step replay, and about 10% for another 200K with prioritized replay and three-step returns), and frame skip 12 never left fine stage 142.

Everything that moved the needle after that is below, in the order it was found. The numbers come from the training logs and from section 15 of the design reference.

LeverWhat it fixedEvidence
1 Frame skip 8
--frame-skip 8
A Crash jump is about 24 held frames: 12 identical decisions at frame skip 2, and greedy probes changed their mind mid-air (JUMP, DOWN, JUMP, UP). At eight frames per decision a jump is three. Pit success from the pre-pit states went from 5 to 15% to 60 to 90%. The curriculum advanced from fine stage 162 to 118 in about 425K steps.
2 Reverse curriculum over 163 solver states
--curriculum 163
--curriculum-epsilon 0.02
Start next to the exit and move the start back one solver macro (about 900 units) whenever half of the last ten episodes from the current state finish, so the agent only ever has to learn the next few seconds. A beam search (tools/solve_level) produced the states; each reset begins a few random frames into that state's macro so neighbouring states overlap in phase. Walked 162 to −1 over 1,275,682 steps and 5,805 episodes (about 7.8K steps per stage) without ever regressing a stage. A wedge at stage 142 under --curriculum-epsilon 0.1 unlocked at 0.02: action noise on a ledge kills.
3 Success buffer
--success-buffer 20000
The stage-102 wedge was forgetting, not the game: the curriculum replays only its frontier, so the 100K ring buffer held nothing from solved ground, and greedy probes from starts 102 and 104 walked backwards into a frog on a section completed 50K steps earlier. A second buffer keeps the agent's own level-completing episodes and mixes them into every batch (self-imitation); each one is also written to disk as success_<step>.npz. 40K steps without an advance before; 25 completions in the next 113K steps after, and the frontier moved from 102 to 101.
4 Demonstrations with DQfD's margin loss
--demos --demo-margin 0.8
The beam search re-run in the agent's own 13 actions held for eight frames (three segments of 152, 398 and 117 macros; 1.1 h, 3.1 h and 0.9 h of search), replayed into transitions. Mixing them into batches changed nothing; the large-margin loss makes the demonstrated action the greedy one. First segment: stage 101 to 85 in 130K steps with 98 completions. All three: 85 to 80 in 75K steps, 24 completions in 199 episodes.
5 Stall cut as a terminal
--stall-steps, commit f544791
The stage-80 wedge was the hackathon's spinning exploit again: greedy probes from starts 80 and 81 walked to z of about 325.4K and spun beside a crate for 600 steps. The stall cut had been ending such episodes but bootstrapping through the cut, so the stalled state kept its value. The cut is now a terminal for the learner. Stage 80 held for 52K steps (137 episodes, 18 completions) and unlocked at step 871,573. The frontier then moved to stage 73 by step 1,011,878, where the next wedge came.
6 Curriculum goal in progress units
--curriculum-goal-units 12000, commit faac0b8
The stage-73 wedge was the success test, not the game: every failure from start 73 happened 15K to 70K units downstream, on ground already solved, so the frontier waited for a whole-level completion to happen by chance. Counting a start-state episode as a success after 12K units of progress separates "this stage is solved" from "the rest of the level went right". Before: stage 73 held for 94K steps of run 9, and the 110 episodes started from state 73 produced 4 completions. After: 19 advances in the first 59K steps of run 10 (73 to 46), then 34 more at about 3.7K steps each. The curriculum reached the level start (stage −1) at step 1,283,694.
7 Stall penalty
--stall-penalty 25, commit 69c14fb
With every episode now starting at the level start, the agent froze on open ground at 5 to 8K units in most episodes. A lost life cost 25 and a stall cost 0, so standing still beside a hazard was the greedy policy's safest choice. The stall cut now costs a lost life. Measured together with the idle cost in the next row.
8 Idle cost
--idle-cost 1, commit dbcca7c
A probe with dqn_01325000 showed the mechanism: at z = 403,573 all 13 Q-values sat within 2.3 of each other (NOOP 74.96, UP 73.39) although UP moved 1,885 units in eight steps with no hazard in sight. A no-op in an unchanging state bootstraps on that state's own value, so the idle value decays only by 1 − γ per target sync (5,000 steps), and the margin loss on the agent's own successes (7 to 19% no-ops) re-taught idling. Every step without new progress since the last respawn now costs 1 and stays out of the success buffer. Greedy probes from the level start reached 5.6K units (idling) with dqn_01325000, 24.5K (killed by the first goat) with dqn_01375000 and 49.7K (the pit past the second goat) with dqn_01450000; at the old freezing spot UP now leads the Q-values by 1.0. The frame-skip-8 chain's first completion from the level start followed at step 1,479,301 (run 12, epsilon 0.05, 926 steps).
9 Start mixture
--curriculum-from-start 0.3
--curriculum-spread 80
Runs 10 to 13 trained at pure stage −1, every episode from the level start, and mostly stalled. Run 14 fixed the mix at about 29% level start, 35% fine state 0 and 35% uniform over states 1 to 80, so the whole level stays in the batch while the start keeps being practised. Pure stage −1: 2 completions in 375 from-start episodes (0.53%), 69% stalls. Run 14: 16 in 328 (4.88%).

One earlier completion from the level start exists outside this chain: at step 820,822 of a frame-skip-2 curriculum run, a lone probe episode from the level start at stage 133 finished the level in 3,251 steps (reward 6,887). It was 1 of 39 such probes in that log and was never followed up.

Results

Run 14

Run 14 resumed the frame-skip-8 chain from step 1,525,322 at epsilon 0.05 with every lever above in place, 24,269 demonstration transitions loaded, and ran to 2,000,000 steps: 475K steps in about 2 h 25 min, at 61 falling to 54 steps per second.

Episodes1,116: 727 ended at a lost life, 225 at the stall cut, 130 in a game over, 29 with the level complete, 5 at the 1,600-step cap.
From the level start328 episodes: 16 level complete (4.88%), 177 stall, 130 game over, 5 at the cap. These are the only episodes that play the whole level with all their lives.
From curriculum states788 episodes from states 0 to 80 (394 from state 0 alone): 13 level complete, 727 cut at the first lost life, 48 stall. Cut at the first lost life, so not comparable with the row above.
The 16 completions908 to 1,552 steps, mean 1,163, median 1,158; mean episode reward about 6,700. Each has a matching success_<step>.npz on the training box.
Earlier runs at pure stage −1Runs 10 to 13, every episode from the level start: 2 completions in 375 episodes (0.53%), 69% stalls.

The Plateau

The from-start completion rate did not trend over the run. A logistic fit of completion against step has a slope of +0.05 per 100K steps with p = 0.79, and the quadratic term is negative, an inverted U. The last completion came at step 1,933,153; the final 66K steps (151 episodes) produced none, and the run ended with three consecutive from-start game overs. Stalls stayed at about 54% of from-start episodes in every bucket, while the game-over share went 47%, 32%, 41%.

StepsFrom-start episodesCompletionsRate
1.525M to 1.6M5811.7%
1.6M to 1.7M6534.6%
1.7M to 1.8M6557.7%
1.8M to 1.9M6646.1%
1.9M to 2.0M7434.1%
of which 1.90M to 1.95M3538.6%
of which 1.95M to 2.0M3900%

Evaluating the Checkpoints

Two checkpoints were evaluated afterwards from the level start with agent.evaluate: no stall cut, a 1,600-step cap, 40 seeds at epsilon 0.05.

CheckpointEpsilonResult
dqn_final.pt (2.0M)0One episode: return 527.62, 1,600 steps, hit the cap about a third of the way in, then wandered. Seeds 1 to 7 byte-identical.
dqn_final.pt0.05, 40 seeds0 completions: 33 episodes hit the cap, 7 ended in a game over; returns 685 to 1,743 on the capped episodes.
dqn_01925000.pt0One episode: return 925.26, 1,600 steps, no completion.
dqn_01925000.pt0.05, 40 seeds1 completion (seed 27: return 6,728.46, 1,181 steps), 25 game overs, 14 at the cap. That is 2.5%, with a 95% interval of roughly 0.1% to 13%, consistent with the 4.88% seen in training.

Greedy Play Is One Episode

Epsilon 0 is deterministic: select_action draws rng.random() < 0, which is never true, so with a deterministic emulator every seed replays the same episode. Filmed with tools.record_episode, the greedy dqn_final.pt reaches the crate beside the first tent, 36,594 units into a 164.5K-unit level (22%), and jumps in place for the remaining 1,300 or so steps until the cap. The recorder's return for that episode is 1,638.64 against the evaluation server's 527.62, because the recorder steps the emulator one frame at a time and the server eight, but both hit the cap without completing. Training at epsilon 0.05 was measuring a policy plus noise; the policy on its own is stuck at one crate.

The Films

Two completions from run 14 were replayed frame for frame by feeding the trainer's recorded actions back through the deterministic emulator (tools.record_episode --actions): success_01929825.npz (154.1 s, 9,245 frames, 1,156 steps) and success_01933153.npz (157.5 s, 9,452 frames, 1,182 steps). They are the first from-start completions on film. The earlier completion at step 1,479,301 was recorded before the success file kept every step, and diverged on replay.

The completion at step 1,933,153, from the level start to the exit portal, scaled to 384 by 288 for the web (6 MB, 157 s). The agent is choosing an action every eight frames; the web copy is encoded at 30 fps, every second emulator frame of the 60 fps original.
Animated frames from a scripted episode in Toad Village: Crash runs forward along the sand path and jumps now and then.
Not the agent: the scripted client loop from the README (hold Up, jump now and then), played through the WebSocket server, as the RGB frames the server returned. It is here to show what an episode looks like at the environment's end of the socket.

Charts

Five charts rendered with matplotlib from the trainer's per-episode log lines across the 27 training logs, by tools/plot_training.py (the originals live in docs/charts/). Buckets are by the step at which an episode ended.

Bar chart: run 14 completions per from-start episode in each 100K-step bucket, with confidence intervals, and a cumulative completion count on the right axis.
Run 14, completions from the level start per 100K-step bucket with Wilson 95% intervals, and the cumulative count. The buckets are 1/58, 3/65, 5/65, 4/66 and 3/74; the intervals all overlap.
Stacked bar chart: how run 14's 1,116 episodes ended, per 50K-step bucket, by termination type.
Run 14, all 1,116 episodes by termination per 50K-step bucket: life lost 727, stall 225, game over 130, level complete 29, cap 5. The life-lost band is in effect the curriculum-start share of each bucket.
Line chart: reverse-curriculum stage after each episode against training step across the 14 frame-skip-8 runs.
The reverse curriculum's stage against training step for the whole frame-skip-8 chain, one segment per run. Stage 162 is the start state nearest the exit and −1 is the level start, reached in run 10 at step 1,283,694. Resumed runs overlap because the chain restarted from older checkpoints several times.
Two line charts: trailing mean episode reward and trailing mean episode length across the frame-skip-8 chain, with each run's start marked.
Trailing 50-episode mean of episode reward (top) and length (bottom) over the 7,406 episodes of the frame-skip-8 chain. Reward is highest early, when most episodes began next to the exit; length climbs to 680, 766 and 843 steps in runs 11, 12 and 13, when every episode began at the level start, and drops to 425 in run 14's mixture of short curriculum-start episodes and from-start ones.
Line chart: the trainer's reported steps per second against training step, one line per run, grouped by frame skip.
Steps per second against training step, one line per run. Frame skip 8: 20 to 66 steps/s; frame skip 2: 110 to 196; frame skip 12: 13 to 38. The emulator was the bottleneck, so a larger frame skip means fewer agent steps per second.

Completion Leaderboard

Every completion the trainer recorded is a replayable action list, so the level's counters can be read back exactly for each one: fruit collected, boxes broken, lives lost and steps taken. The 30 completions of runs 13 and 14 (17 from the level start, 13 from curriculum start states) were replayed one emulator frame at a time with tools/replay_stats.py; every replay matched the trainer's stored observations frame for frame and ended with the level complete after exactly the number of steps the log reports. The records below cover the 17 from the level start, the only ones that played the whole level with all their lives. Steps are agent steps of eight emulator frames; seconds are frames divided by 60.

RecordCompletionValueNotes
Most Wumpa fruitstep 1,765,00415 Wumpa1,218 steps (162.4 s), 7 boxes, 1 life lost. The median completion collected 8.
Most boxes brokenstep 1,631,27311 boxesAll 11 still counted at the exit, in 956 steps with 1 life lost. Step 1,760,604 broke 12 in total, but dying restores the boxes broken since the last checkpoint, and after four lost lives it reached the exit with 9. The median is 4 at the exit.
Fewest lives loststep 1,851,811 and six others0 lives lostSeven of the 17 lost none: 1,659,684; 1,709,019; 1,810,922; 1,819,393; 1,851,811 (the shortest of them, 935 steps); 1,929,825; and 1,933,153, the filmed one. Six lost one life, one lost two, two lost three, and 1,760,604 lost four, finishing the level on its last life.
Fastest completionstep 1,665,906908 steps, 121.1 s7,264 frames; 1 life lost, 6 Wumpa, 3 boxes. The median is 1,160 steps (154.6 s); the slowest, 1,760,604, took 1,552 steps (206.9 s).

The 13 completions from curriculum start states are kept out of the records: those episodes are cut at the first lost life, and most began part-way into the level. Among them, step 1,742,347 (from fine state 0, which is at the level start) collected 31 Wumpa in 1,476 steps, step 1,777,001 (state 53) broke 9 boxes, and step 1,658,605 (state 76) reached the exit in 472 steps (62.9 s). All 30 records, each with a progress curve sampled every 10 steps, are in assets/completions.json (63 KB), a copy of docs/data/completions.json.

Honest Status and Next Steps

Run 14 ended on a plateau. The from-start completion rate showed no trend over its 475K steps, the tail degraded (no completion after step 1,933,153, and 0 of the last 39 from-start episodes), and stalls stayed at about 54% of from-start episodes in every bucket. The greedy policy by itself does not finish the level from the start; the 4.88% is a policy plus epsilon 0.05 noise. Training longer at these settings is not the next step. The stall segment is.

What the evidence weakly favours:

Cost and Provenance

Everything above ran on one on-demand g5.xlarge in us-east-1 (4 vCPU, one A10G, a 100 GB gp3 root volume) at $1.006 per hour, over four sessions. The first ran 59.17 h ($59.52), of which about 42 h were idle: run 14 had finished at 08:51 UTC on 2026-09-06 and ssh from a new network was blocked by the security group until the instance was stopped on 2026-09-08. A second session of 1.17 h ($1.17) read the results, filmed the completions and ran the evaluations; a third of 0.33 h ($0.34) came up impaired and was force stopped; a fourth of 8.75 h ($8.80) ported Bone Yard and ran its solver and its training run. The running time is measured from the 833 CloudWatch CPU datapoints the instance publishes only while it runs. Total: 69.42 h, about $69.8, plus about $8 a month of EBS while the instance sits stopped.

The original source code of PS-Env is gone. It lived in two repositories on the hackathon laptop, which is no longer accessible. The code on GitHub is a reconstruction from what survived: the team's writeup, a blog draft and its figures, the recording of the April 2026 talk, the architecture diagram shown during it, two whiteboard photos from the design session, and the pre-hackathon proposal and emulator notes. Every address, protocol field and hyperparameter in the code is traced to one of those sources, and the design reference marks what had to be reconstructed. The reconstruction was then run end to end on the real core and disc, which is where the numbers on this page come from.

The repository on GitHub, and docs/DESIGN_REFERENCE.md, the single source of truth for the recovered facts and the record of the 2026-09 training.