AI Generated Video Game: Where the Output Holds Up and Where It Breaks
Short answer: a generated video game holds up for exactly one session. The input handling, the core loop, the collision, the art, the HUD — all of it survives contact with a player and is usually better than the person who typed the sentence expected. What does not survive is anything that has to outlive the tab: the run in progress, the unlocked level, the score, the settings. Close the tab and the game forgets you were ever there. That is not a bug in the generator's output, it is a property of how the output is shaped, and it is the single line between "a video game" and "a demo with a scoreboard."
The rest of this page is the anatomy of that line: what holds, what breaks, the six seams where it breaks, a test that proves each one in under a minute, and the save layer that closes them without regenerating the game you already have.
One session is the whole design assumption
Open a generated build and look at the state. It is almost always the same shape:
let score = 0;
let hp = 100;
let level = 1;
let unlocked = [];
Every mutation writes straight into those variables. Damage is hp -= 12. A pickup is score += 50. A level clear is level++. The file is one HTML document, the state is a handful of top-level bindings, and the whole thing is coherent — for as long as the page is alive. A reload constructs the bindings again from their literals, and the player's two hours are gone.
This is not laziness and it is not a prompt you forgot to write. Persistence is a cross-cutting feature: it has no single insertion point. To persist a run you need a snapshot boundary, a serialization rule, a load path, a version field, and a migration for every schema change — and then every durable mutation site has to be classified as "must be saved" or "recomputed on load." A generator writes the game forward, line by line, and a forward pass has nowhere natural to put a boundary that has to be respected by code written later in the same pass.
That asymmetry is why "add save game support" appended to the end of a prompt produces something worse than nothing: you get localStorage.setItem('score', score) at the game-over screen, one scalar, no run, no unlock state, no schema. It looks like persistence, so you stop looking.
The practical consequence: do not regenerate to fix this. Regenerating gets you a new game with the same structural hole and a new set of bugs. Add the boundary once, keep the gameplay untouched, and let the boundary own every read and write.
What holds up, layer by layer
Judged as an artifact rather than as a demo, a generated video game is uneven in a predictable pattern. The layers that live inside a single frame hold. The layers that span frames, sessions or devices do not.
| Layer | Verdict | Why | What to do |
|---|---|---|---|
| Input and controls | Holds | One event listener, one mapping table, testable in ten seconds | Ship as generated |
| Core loop and collision | Usually holds | AABB or circle checks are the most represented pattern in training data | Ship, but test the edges of the playfield |
| Art and HUD | Holds, with a catch | Coherent within one generation, drifts across regenerations | Freeze the asset set before you tune gameplay |
| Audio | Half holds | Plays on the first session, often muted after a background and return | Re-arm the gesture unlock on resume |
| Level content | Breaks quietly | Three to five levels of hand-written data, no generator behind them | Decide now whether volume matters |
| Difficulty curve | Breaks | Tuned against the first level, not against a player who has played nine | Add a curve parameter, not more levels |
| Progression and save | Breaks structurally | Cross-cutting, no insertion point, no schema | Build the boundary below |
| Settings | Breaks cheaply | Lives in memory like everything else | Same boundary, two more fields |
Two of those rows are the whole article. Progression and save is the one that decides whether what you made is a video game; difficulty curve is the one that decides whether anyone plays the second session you just made possible. This page only handles the first, because it is the one that fails 100% of the time and can be fixed in an afternoon.
Six seams where the output breaks
Each seam below is stated as: what you see, why the generator produced it, the test that confirms it, and the patch. The patches assume you are editing the file you already have.
Seam 1 — The state never leaves memory
You see: play for two minutes, press refresh, land back on the title screen with a fresh run.
Why: the state is top-level let bindings, and a reload re-evaluates the literals. There is no read path at boot because nothing was ever written.
Test: reach level 2, press F5. If you are back at level 1, this seam is open.
Patch: the envelope and the flush below. Nothing else in this list matters until this one is closed, because the other five are about saving correctly, and this one is about saving at all.
Seam 2 — The save is bound to an event that does not fire
You see: desktop works. On a phone, a run vanishes after the player checks a message and comes back.
Why: the generated handler is almost always window.onbeforeunload. That event is unreliable on mobile, does not fire when the OS kills a backgrounded tab, and is actively discouraged because it can block the back-forward cache. On mobile, the last moment you are guaranteed to run code is when the page becomes hidden — not when it unloads.
Test: on a phone, start a run, switch to another app for a minute, then swipe the tab away from the task switcher and reopen the URL. A save that only listens for unload fails this every time.
Patch: listen for visibilitychange with document.visibilityState === 'hidden', and for pagehide. Treat both as "flush now," and keep a low-frequency interval as a safety net for the case where neither fires.
Seam 3 — The state you are trying to save cannot be serialized
You see: JSON.stringify throws, or it returns a string that loads back into a hollow game — enemies gone, player invisible, nothing responding.
Why: the live state graph is not data. It holds a canvas context, DOM nodes, Image and Audio objects, functions, and parent references that point back up the tree. JSON.stringify throws TypeError: Converting circular structure to JSON on the first cycle, and silently drops functions and undefined values, which is worse because it does not stop you. structuredClone is not a way out either — it throws DataCloneError on functions and DOM nodes.
Test: with the game running, type JSON.stringify(state).length in the console. If it throws, or the number is in the megabytes, the graph is not serializable.
Patch: write an explicit snapshot() that names every durable field by hand and returns only primitives and plain arrays. Whitelisting is the point — a blacklist of "things not to save" will miss the next thing the generator adds.
Seam 4 — Autosave lands at an illegal instant
You see: a reload after a pickup grants the item twice, or the game restores into a state it cannot continue from — the player mid-fall through a platform, a door half-open, a wave that never spawns.
Why: localStorage writes are atomic per key, so you will not get half a save. The failure is temporal, not spatial: the snapshot is taken at a moment when the state is between two legal states. Generated code that calls a save inside a reward handler will usually do it between score += 50 and pickedUp = true.
Test: collect an item, die immediately on purpose, reload, and count the item. Then reload twice in a row and watch whether the count keeps climbing.
Patch: save only at quiescent points — level start, level clear, pause menu open, and the moment the page is hidden. Never inside a reward grant, a death animation, or a scene transition. If you need a save mid-level, first move the player to a defined checkpoint, then save.
Seam 5 — The schema moves and the save does not
You see: after a regeneration or a manual edit, the HUD reads NaN, or the run boots into a level that does not exist and soft-locks.
Why: every change to the game adds or renames fields. An old save has the old field names; new code reads the new ones and gets undefined. undefined + 1 is NaN, and NaN propagates through every derived value — position, health, timers — until the game is unplayable in a way that looks like a logic bug rather than a data bug.
Test: keep a save, add one field to the state, reload. If the new field comes back undefined instead of its default, there is no version gate.
Patch: a schema integer in the envelope, a load path that starts from the fresh-state factory and merges the saved object over it, and an explicit floor below which the save is discarded rather than guessed at.
Seam 6 — The environment takes the storage back
You see: progress rolls backwards after the player opens the game in a second tab. Or a save that worked yesterday is gone today. Or the game dies silently in a private window.
Why: three separate mechanisms, one symptom.
- Two tabs, last write wins.
localStorageis per origin, not per tab. Two tabs both hold a run, both flush, and the slower one overwrites the faster one. - Eviction. Safari deletes script-writable storage —
localStorageincluded — from sites the user has not interacted with for seven days.navigator.storage.persist()asks for an exemption; it is a request, and it can be refused. - Quota and private mode.
localStorageholds on the order of five megabytes of text per origin, andsetItemthrowsQuotaExceededErrorwhen you pass it. In some private modes the write throws outright. An unguardedsetItemin the game loop turns that into a dead game, not a lost save.
Test: open the game in two tabs and play in both, then reload both. Separately, run localStorage.setItem(SAVE_KEY, '{') and reload — a corrupt value should start a clean run, not a white screen.
Patch: a try/catch around every write that surfaces the failure once in the HUD instead of swallowing it, a storage event listener that detects another tab taking the run, and a save size check so a runaway array does not push you into quota.
The save envelope: four fields, one rule
Every save you write gets the same shape. Four fields, no exceptions, no nesting beyond the state object itself.
const SAVE_KEY = 'yourgame.save';
const SCHEMA = 3;
// Only what must survive a reload. Primitives and plain arrays.
function snapshot(s) {
return {
v: SCHEMA, // schema version, bumped on every shape change
t: Date.now(), // when, for debugging and for "is this stale"
r: s.runId, // which run, so two tabs can tell each other apart
s: {
level: s.level,
score: s.score,
hp: s.hp,
unlocked: s.unlocked.slice(),
seed: s.seed,
muted: s.muted
}
};
}
The one rule: if a value can be recomputed from other saved values, do not save it. Save the seed, not the enemy positions. Save the level and the score, not the HUD text. Every derived value you persist is a second source of truth that will disagree with the first one after your next edit, and the disagreement will look like a rendering bug.
The prompt to paste back
This is the part that saves you an evening. Paste it into the same generator that produced the game, with the file attached, and change nothing else.
Persistence patch. Keep the game exactly as it is. Do not rewrite gameplay,
do not change the loop, do not rename existing functions.
1. Add a single SAVE_KEY constant and a SCHEMA integer. All storage access
goes through them.
2. Add snapshot(state) returning {v, t, r, s} where s contains only numbers,
strings, booleans and plain arrays. No DOM nodes, no canvas context, no
Image or Audio objects, no parent references, no functions.
3. Add markDirty() and call it wherever durable state changes: score, hp,
level, unlocks, settings. Never call localStorage from inside the game loop.
4. Flush on visibilitychange when document.visibilityState is "hidden", on
pagehide, and on a 2000 ms interval. Wrap setItem in try/catch and show one
HUD line if it throws. Never let a storage error stop the game.
5. On boot, load through load(freshState): parse the stored JSON inside
try/catch, then Object.assign(freshState(), saved.s) so any field missing
from an older save keeps its default. Coerce every numeric field with
Number.isFinite and clamp it to its legal range.
6. If the stored v is below the current minimum supported SCHEMA, discard the
save and start fresh. Do not attempt to guess missing fields.
7. Save only at quiescent points: level start, level clear, pause menu open,
page hidden. Never inside a reward grant, a death animation, or a
scene transition.
Two of those lines are the ones generators most often skip, and they are the two that matter most: line 5, which is what stops NaN after a regeneration, and line 7, which is what stops double-awards.
Loading with defaults instead of hope
The load path is where old saves meet new code, so it does the repair work rather than trusting the file.
function load(freshState) {
let raw = null;
try {
raw = JSON.parse(localStorage.getItem(SAVE_KEY) || 'null');
} catch {
localStorage.removeItem(SAVE_KEY); // unparseable: clear it, do not loop on it
}
if (!raw || typeof raw.s !== 'object' || raw.s === null) return freshState();
if ((raw.v | 0) < 2) { // below the floor: not worth migrating
localStorage.removeItem(SAVE_KEY);
return freshState();
}
const s = Object.assign(freshState(), raw.s); // new fields inherit defaults
if (!Number.isFinite(s.score)) s.score = 0;
if (!Number.isFinite(s.hp) || s.hp <= 0) s.hp = 100;
if (!Array.isArray(s.unlocked)) s.unlocked = [];
if (!Number.isInteger(s.level) || s.level < 1) s.level = 1;
return s;
}
Object.assign(freshState(), raw.s) is the whole trick: the factory runs first, so every field the save is missing already has a legal value, and the saved fields overwrite only what they actually contain. A save from three versions ago loads with the new fields at their defaults instead of undefined, and the game keeps running.
The twelve-minute test matrix
Run these in order. Each one takes about a minute and each one names the seam it proves. Stop at the first failure and fix that seam before moving on — they cascade, and a test after an open seam tells you nothing.
| Check | How | Pass looks like | A fail means |
|---|---|---|---|
| Mid-run refresh | Reach level 3, press F5 | Same level, same score, same unlocks | Seam 1: nothing is written |
| App switch, 60 s | Phone: switch apps for a minute, return | Run continues where it stopped | Seam 2: wrong lifecycle event |
| Tab killed | Swipe the tab away, reopen the URL | Run continues | Seam 2 on the harder event |
| Serialize probe | JSON.stringify(state).length in console | A number, tens of kilobytes at most | Seam 3: graph is not data |
| Double-award | Take an item, die at once, reload twice | Item counted once | Seam 4: save mid-transaction |
| Old save, new build | Keep a save, change the state shape, reload | Boots with defaults, no NaN | Seam 5: no version gate |
| Corrupt save | localStorage.setItem(SAVE_KEY, '{'), reload | Clean run, no white screen | Seam 5: unguarded parse |
| Two tabs | Open the game twice, play in both | Second tab warns or takes over | Seam 6: last write wins |
| Private window | Open in a private window and play | Game runs, save warning shown once | Seam 6: setItem killed the loop |
| Return audio | Background the tab during music, return | Audio resumes, or one tap re-arms it | Gesture unlock never re-armed |
The last row is not persistence, but it is the same seam in a different costume: state that the browser owns and that your game assumes it still owns. Any resource the browser can revoke — storage, audio context, wake lock, fullscreen — needs a re-acquire path, and a generated build never has one because the generator only ever tested the first session.
What this changes about the phrase "AI generated video game"
The honest reading of the output is this: generation is very good at the part of a game that a player experiences in one sitting, and structurally blind to the part that makes the sitting mean anything. A loop with no memory is not a smaller game, it is a different category of thing. It has the shape of a game — a goal, a fail state, a score — and none of the continuity that turns a score into a stake.
That is also why the fix is cheap. You are not rebuilding the game or asking for a better generation. You are adding one boundary, one schema, four fields, and a load path that repairs instead of trusting. Roughly sixty lines, most of them the paste-back block above, and the thing that could only be played once becomes something that can be played until it is finished.
If you are still deciding what to generate in the first place, generate for the loop — the part generation is good at — and budget an afternoon for the boundary. Not the other way around. A beautiful progression system on top of a loop nobody wants to repeat is the more expensive mistake, and it is the one that looks like progress while you make it.