Retro Game Generator!
I wrote this back in January 2025, but I didn’t get around to cleaning it up and publishing it for another eight months. Of course, the landscape has changed a lot in those eight months! We’re still happily using our original code — some of the planned implementation details of my forward-looking thoughts have shifted a bit (Claude Code instead of Aider for robust edits, tool use and possibly MCP resources for the asset management, potentially switching from Vercel to Netlify for its deploy→claim flow) but I’ve left what I originally wrote in place.
Like everybody else, my daughters & I have been having a lot of fun over the past few months asking Claude to make simple games for us. It's great for prototyping retro-style games1 in HTML and JS -- some favorites so far are Bear Quest Adventure, Penguin Maze Escape, Bear Soccer Mayhem, Pickle Pong Madness, Jumping Horse Adventure, and Unicorn Candy Collector.
Initially, we just built them in the Claude.ai web interface and played them in Claude’s generated artifacts. But we eventually got frustrated by some of the limitations:
Since HTML5 games can be pretty verbose, we tend to hit Claude usage limits pretty quickly, even on a paid plan.
Sometimes Claude declines to make an artifact, or makes one that we aren't allowed to preview for some reason.
Debugging — or adding external assets & libraries — is a bit too unpredictable.
Iterating on a game often means a lot of copy+pasting that is understandably not exciting for a five-year-old and a two-year-old.
They also want to be able to prompt Claude without me sometimes, for both new games and revisions — but all the meta-instructions that we need to make it work2 are a little unreasonable for the children to keep repeating when I'm not at the keyboard3.
So I started making an app that would streamline and structure the process for us.
Generating & deploying
The initial implementation was pretty straightforward. We use FastAPI to create a simple web service that:
Takes a natural language prompt
Uses the Claude API to generate a game
Deploys the resulting code to Vercel automatically
Gives you the link
The key bit was prompting Claude to output code in a specific XML format with CDATA sections so that we can easily extract a full set of files to deploy:
You develop HTML5 vanilla JS games with humans, inspired by console games from the Atari, NES, and SNES eras as well as early graphical computer games from the 1970s through the late 1990s. Your response should be structured like this:
<root>
<reply>Anything you want to say to the user you are talking to</reply>
<name>silly-very-random-phrase</name>
<file>
<name>myname.ext</name>
<body>
<![CDATA[
contents of file 1
]]>
</body>
</file>
<file>
<name>path/to/another.ext</name>
<body>
<![CDATA[
contents of file 2
]]>
</body>
</file>
</root>
The primary entry point should always be a minimal file named index.html in the root of the file tree.
When Claude responds, we then parse out the XML to get:
Anything Claude wants to say to us (in the
<reply>
tag)The Vercel project name to assign to our new game (in the
<name>
tag)The filenames and contents of all the front end code to deploy (in the
<file>
tags)
And then we deploy it to Vercel with a few API calls:
async def create_project(env, name):
async with httpx.AsyncClient(timeout=None) as client:
resp = await client.post(
f"https://api.vercel.com/v10/projects?teamId={env['VERCEL_TEAM_ID']}",
json={"name": name},
headers={
"Authorization": f"Bearer {env['VERCEL_TOKEN']}",
"Content-Type": "application/json",
},
)
return resp.json().get("id")
async def deploy_project(env, name, files):
formatted_files = [
{"data": file["body"], "encoding": "utf-8", "file": file["name"]}
for file in files
]
async with httpx.AsyncClient(timeout=None) as client:
resp = await client.post(
f"https://api.vercel.com/v13/deployments?teamId={env['VERCEL_TEAM_ID']}",
json={
"target": "production",
"name": name,
"projectSettings": {"framework": None, "buildCommand": None},
"files": formatted_files + formatted_assets
},
headers={
"Authorization": f"Bearer {env['VERCEL_TOKEN']}",
"Content-Type": "application/json",
},
)
return resp.json()
async def claude_request(prompt, env):
system_prompt = """
You develop HTML5 Vanilla JS games with humans[...]
"""
async with httpx.AsyncClient(timeout=None) as client:
response = await client.post(
"https://api.anthropic.com/v1/messages",
json={
"model": "claude-3-5-sonnet-latest",
"max_tokens": 8000,
"temperature": 1,
"system": system_prompt,
"messages": [{"role": "user", "content": [{"type": "text", "text": prompt}]}],
},
headers={
"anthropic-version": "2023-06-01",
"x-api-key": env["CLAUDE_API_KEY"],
"Content-Type": "application/json",
},
)
# Extract out our XML format in case Claude decided
# to put some other stuff before or after it
pattern = r'<root>(.*?)</root>'
match = re.search(pattern, response.json()["content"][0]["text"], re.DOTALL)
return xmltodict.parse(match.group(0)) if match else None
@app.post("/generate")
async def generate_game(prompt: str = Form(...)):
env = get_env()
ai_response = await claude_request(prompt, env)
name = f"{ai_response['root']['name']}-{int(time.time())}"
project_id = await create_project(env, name)
files = ai_response['root'].get('file', [])
# Make sure we have an array of files, even if Claude just
# gave us a single <file> tag (e.g. a standalone index.html)
if isinstance(files, dict):
files = [files]
# Save game state to database
db.save_game(name, int(time.time()), files, assets)
deployment = await deploy_project(env, name, files)
return RedirectResponse(url=deployment['url'], status_code=303)
This works really well. We've got a straightforward form where the girls can type out4 what kind of game they want, hit a button, and a minute later they're playing it at its own unique URL. Plus, we can easily bookmark and share their creations with family.
Fixing the graphics
In our first few games, Claude would render everything with very lazy abstract shapes — calling a green square a player or a red triangle a unicorn. With a lot of persistence we could often get Claude to improve the visuals, but that wasn’t very fun.
Obviously for our next step we needed some real game assets. There’s a lot of good stuff on OpenGameArt.org; I downloaded a bunch of sprites and tiles, and adapted our system to make Claude aware of them.
For a good-enough first pass, this was just a matter of extending the prompt to literally list out all the available files:
You develop HTML5 vanilla JS games with humans[...]
You have access to the following image assets, each representing a sprite or tile. For each image that you choose to use, reference the chosen image in an <asset> tag so that we know to include them in the packaged game:
[
{"path": "asset-pack-1/bear/walk_up/000.png"},
{"path": "asset-pack-1/bear/walk_up/001.png"},
{"path": "asset-pack-1/bear/walk_up/002.png"},
{"path": "asset-pack-1/bear/walk_up/003.png"},
{"path": "asset-pack-1/characters/monkey.png"},
{"path": "asset-pack-1/characters/fox.png"},
{"path": "asset-pack-1/characters/penguin.png"},
{"path": "asset-pack-1/tiles/grass1.png"},
{"path": "asset-pack-1/tiles/grass2.png"},
{"path": "asset-pack-1/tiles/dirt.png"},
]
In general, do not assume the existence of any binary files apart from the available sprites that were just listed.
And then adjusting our XML format so that Claude can signal specific asset dependencies:
<root>
<reply>Anything you want to say to the user you are talking to</reply>
<name>silly-very-random-phrase</name>
<file>
<name>myname.ext</name>
<body>
<![CDATA[
contents of file 1
]]>
</body>
</file>
<file>
<name>path/to/another.ext</name>
<body>
<![CDATA[
contents of file 2
]]>
</body>
</file>
<asset>
asset-pack-1/tiles/dirt.png
</asset>
<asset>
asset-pack-1/characters/monkey.png
</asset>
<asset>
asset-pack-1/characters/fox.png
</asset>
</root>
Then I modified the deployment process to pick up those signals & include the relevant assets in the deployed bundle:
async def deploy_project(env, name, files, assets):
formatted_files = [
{"data": file["body"], "encoding": "utf-8", "file": file["name"]}
for file in files
]
formatted_assets = []
for asset in assets:
try:
formatted_assets.append({
"data": base64.b64encode((Path('./assets/') / asset).read_bytes()).decode(),
"encoding": "base64",
"file": asset,
})
except FileNotFoundError:
# Just in case Claude hallucinates a file
print(f"Not found: {asset}")
async with httpx.AsyncClient(timeout=None) as client:
resp = await client.post(
f"https://api.vercel.com/v13/deployments?teamId={env['VERCEL_TEAM_ID']}",
json={
"target": "production",
"name": name,
"projectSettings": {"framework": None, "buildCommand": None},
"files": formatted_files + formatted_assets
},
headers={
"Authorization": f"Bearer {env['VERCEL_TOKEN']}",
"Content-Type": "application/json",
},
)
return resp.json()
This was kind of transformative. Now our games had actual characters, environments, and objects. Claude even did a good job of understanding that files like bear/walk_up/000.png
through bear/walk_up/004.png
should be treated as animation frames, and so on.
Plus, Claude seemed to get more creative with its game designs when it had proper visual elements (or rather, a set of exciting-sounding filenames with no particular insight into their contents) to work with. It feels like another of these fascinating examples where subtle differences in the prompted context lead to divergent results and emergent behaviors.
Developing a vocabulary
I was expecting that I’d need to spend a lot of time upfront on the assets — just collecting and curating a big enough library to make the games feel infinitely variable.
I’ll probably still need to do that!
But … the fairly limited collection has been unexpectedly cool — because it means that we have a more-or-less consistent visual language for most of our games. That in turn feels like it’s giving us a useful framework for thinking about the games as manipulable software, and the game-making as its own meta-game with its own rules.
So we’ve created a kind of shared universe across dozens of different games, recognizing that these elements can be recombined and repurposed in endless configurations. (Including some pretty weird ones, like this fairly terrifying “don’t let the bear catch you” first-person game loosely inspired by Wolfenstein 3D and the Windows 95 maze screensaver.)
Watching them interact with this whole process reminds me of copying over BASIC games from magazines as a kid — after meticulously entering code and playing the game as designed, you’d start making small modifications just to see what would happen, and occasionally banging your head against a wall when you got stuck on a typo or a bug. That hands-on experience taught me that software was something I could work to understand and change.
My daughters are getting a different but parallel software-literacy experience — requesting games, seeing them built, asking for changes, watching as those changes come into existence, and scolding Claude when they don't behave as intended. It feels like they're building an intuition about how software works without actually needing to write the code yet.
Speaking of changes…
Once we had something we liked, we inevitably wanted to tweak it — “The bear should move faster.” “Collecting the silver coins gives you fifty points, but collecting the gold coins gives you ten thousand points.” “This game should let two people play at the same time.”
So I added an edit endpoint to our app that would take the existing game code and a revision request, and ask Claude to send back XML for any and all changes.5 Unsurprisingly, this is where things started getting thorny! At the moment we’re sticking with what we have, but when I next dedicate some time to this I’m hoping to come up with a more robust system here, perhaps by delegating to Aider — which has already done the heavy lifting on /* lazycoding */
issues, continuations, file tree browsing and grepping, fuzzy diff formats, etc.
Also on my longer-term to-do list:
A more fully-featured asset manager, allowing both the AI and human authors to browse and select graphics to include, with proper rights tracking & attribution
A tile editor — games need maps!
More revision management, game management, and remix/share options
For now though we’ve reached a plateau where the overall experience works well enough, and the games are fun enough, that we haven’t felt any pressing need to invest more time in the tools yet.
At least Atari-era through early-SNES era
Use vanilla JS and HTML only, please avoid React, and don't rely on any server-side endpoints! Don't hallucinate external assets! Don’t /* lazycode */
— provide full file contents when making changes! Give me a complete runnable artifact every time you respond! The game should be fullscreen! Assume that the players don’t know this game, so provide a screen with instructions and keyboard controls!
Though I guess I could have set up a Claude.ai Project for this.
(or say, with voice transcription)
We’re doing this with a fresh “conversation” each time — so Claude isn’t actually seeing the full history of the prompts and prior revisions that got it to this point, just the current state of the code and the latest revision request. This is probably suboptimal — I suspect we’d often get better results if we kept a running conversation — but it’s worked well enough for a first pass.