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.
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.
What the Agent Sees
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.
- 0NOOP0x0000
- 1UP0x0010
- 2DOWN0x0020
- 3LEFT0x0040
- 4RIGHT0x0080
- 5JUMP0x0001
- 6SPIN0x0002
- 7JUMP_LEFT0x0041
- 8JUMP_RIGHT0x0081
- 9JUMP_UP0x0011
- 10SPIN_LEFT0x0042
- 11SPIN_RIGHT0x0082
- 12SPIN_UP0x0012
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.
| Signal | Reward | Read from |
|---|---|---|
| holding Up (forward) | +0.1 | the action mask |
| pressing Down (backward) | −0.02 | the action mask |
| every step | −0.0002 | always |
| Wumpa fruit collected | +0.5 each | MAIN_WUMPA 0x000AD22D, BONUS_WUMPA 0x000AD239 |
| box broken | +1.0 each | MAIN_BOXES 0x000AD255, BONUS_BOXES 0x000AD241 |
| life lost | −25 | LIVES 0x000AD231 decreased |
| game over (episode ends) | −5,000 | a life lost while LIVES was already 0 |
| level complete (episode ends) | +5,000 | WARP_STATE 0x0006F5E9 goes from 32 to 52 |
| forward progress | +0.01 per unit | new 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.
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.
| Lever | What it fixed | Evidence | |
|---|---|---|---|
| 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.
| Episodes | 1,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 start | 328 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 states | 788 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 completions | 908 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 −1 | Runs 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%.
| Steps | From-start episodes | Completions | Rate |
|---|---|---|---|
| 1.525M to 1.6M | 58 | 1 | 1.7% |
| 1.6M to 1.7M | 65 | 3 | 4.6% |
| 1.7M to 1.8M | 65 | 5 | 7.7% |
| 1.8M to 1.9M | 66 | 4 | 6.1% |
| 1.9M to 2.0M | 74 | 3 | 4.1% |
| of which 1.90M to 1.95M | 35 | 3 | 8.6% |
| of which 1.95M to 2.0M | 39 | 0 | 0% |
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.
| Checkpoint | Epsilon | Result |
|---|---|---|
dqn_final.pt (2.0M) | 0 | One 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.pt | 0.05, 40 seeds | 0 completions: 33 episodes hit the cap, 7 ended in a game over; returns 685 to 1,743 on the capped episodes. |
dqn_01925000.pt | 0 | One episode: return 925.26, 1,600 steps, no completion. |
dqn_01925000.pt | 0.05, 40 seeds | 1 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.
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.
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.
| Record | Completion | Value | Notes |
|---|---|---|---|
| Most Wumpa fruit | step 1,765,004 | 15 Wumpa | 1,218 steps (162.4 s), 7 boxes, 1 life lost. The median completion collected 8. |
| Most boxes broken | step 1,631,273 | 11 boxes | All 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 lost | step 1,851,811 and six others | 0 lives lost | Seven 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 completion | step 1,665,906 | 908 steps, 121.1 s | 7,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:
- Ship a checkpoint near 1.93M steps rather than
dqn_final.pt. The last completions came at 1.92M to 1.93M,dqn_final.ptloops in place instead of dying, and over 40 seeds at epsilon 0.05 it completed 0 wheredqn_01925000.ptcompleted 1. - Attack the 54% stall share directly: find where the from-start stalls happen, and put curriculum states, demonstrations or a stronger idle charge there, before spending more steps on the whole level.
- A second level. Bone Yard is playable: its save state is scripted from the Warp Room, it runs toward the camera so progress carries the opposite sign, its in-level
WARP_STATEis 38 and its counters sit 0x27C above Toad Village's. The solver covered 40,275 units in 38 macros, and a 1,000,000-step run finished 2,138 episodes with zero completions, most likely because that segment mapped only the first 40,275 units, so the reverse curriculum never had a mapped path to an exit to walk back from. The next attempt should map the level through to its exit before training on it.
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.