I tried to build a LitRPG sandbox with on the fly generated sprites
by
Kadhir Mani
(5.8 minutes)
<p><audio url="https://api.productnow-prod.com/storage/getStorageObject?storageObjectId=fc8b8c62-71bb-401f-a9fa-0a49d335bf5d"></audio></p><p><br></p> <p><span style="white-space: pre-wrap;">I've always loved LitRPG novels for one feeling: the world is capable of so much, and it's yours to discover — not in some guide, but through exploration and experimentation. You're limited only by your creativity and understanding.</span></p><p><br></p><p><span style="white-space: pre-wrap;">It felt like LLMs should be able to do </span><i><em style="white-space: pre-wrap;">something</em></i><span style="white-space: pre-wrap;"> here. I want to experience that! My first thought was if I could get sprite sheets generating on the fly, I bet I could slowly figure the rest out. So that's where I started.</span></p><p><br></p><p><span style="white-space: pre-wrap;">The hard part wasn't generating a plausible still — it was keeping a character coherent across animation frames and generating it on the fly based on player actions.</span></p><p><br></p><p><span style="white-space: pre-wrap;">Here's where I ended up with this experiment:</span></p><p><br></p><p><animation data-src="https://api.productnow-prod.com/storage/getStorageObject?storageObjectId=4c79b13b-a3ca-4b4d-8f2f-ffd1f369efc1" data-media-type="image"></animation></p><p><br></p><p><animation data-src="https://api.productnow-prod.com/storage/getStorageObject?storageObjectId=86e32c68-c840-4080-b7b4-83e3a394d44c" data-media-type="image"></animation></p><p><br></p> <h2 id='fb694b63-42f7-45c3-8c33-ef5d5847c183'>What I built and how it works</h2><p><span style="white-space: pre-wrap;">First problem was figuring out how to request and deliver assets on the fly. I ended up going with a pretty simple web-based setup.</span></p><p><br></p><p><span style="white-space: pre-wrap;">The server runs in </span><b><strong style="white-space: pre-wrap;">NestJS</strong></b><span style="white-space: pre-wrap;">, the front end loads </span><b><strong style="white-space: pre-wrap;">Phaser inside Next.js</strong></b><span style="white-space: pre-wrap;">.</span><b><strong style="white-space: pre-wrap;"> </strong></b><span style="white-space: pre-wrap;">The structure kept things clean: one HTTP call to request asset generation, another to load the validated result into the game. NestJS owns managing generation and validation, Phaser owns the canvas, input, animation, and game-state.</span></p><p><br></p><p><span style="white-space: pre-wrap;">Great easy enough. Next I had to figure out how to generate the assets in a reliable way. I've tried single-shotting these things before, but they don't come out neatly.</span></p><p><br></p><p><span style="white-space: pre-wrap;">So instead, I tried something different. In the same way I would iterate to clean up a single sheet, I baked that process (to some approximation) into a </span><b><strong style="white-space: pre-wrap;">Mastra</strong></b><span style="white-space: pre-wrap;"> agent loop. I added two things to the engineering harness:</span></p><ol><li value="1"><span style="white-space: pre-wrap;">Validating the correctness of the sprite sheet -&gt; which should provide specific feedback when it fails</span></li><li value="2"><span style="white-space: pre-wrap;">Cleaning up the sprite sheet -&gt; removing the background, converting formats, etc</span></li></ol><p><br></p><p><span style="white-space: pre-wrap;">If the agent produced a poor sprite sheet, it would receive feedback from the harness and try again with the same sheet, asking the model to fix it rather than create a new one.</span></p><p><br></p> <p><span style="white-space: pre-wrap;">Here's the full flow:</span></p><mermaid-diagram-slides data-speed="3000" data-height="320"><mermaid-slide data-title="Step 1: Request" data-description="The front end fires a single HTTP call to trigger generation. NestJS immediately returns 202 In Progress and kicks off the agent loop asynchronously."> flowchart LR FE["Next.js / Phaser"] --&gt;|"HTTP POST /generate-asset"| API["NestJS Server"] API --&gt;|"202 In Progress"| FE </mermaid-slide><mermaid-slide data-title="Step 2: Generate" data-description="NestJS triggers the Mastra Agent (powered by Claude Sonnet), which calls Gemini 2.0 Flash as a tool to produce a candidate sprite sheet."> flowchart LR FE["Next.js / Phaser"] --&gt;|"HTTP POST"| API["NestJS Server"] API --&gt;|"202 In Progress"| FE API --&gt;|"Trigger async"| MA["Mastra Agent\n(Claude Sonnet)"] MA --&gt;|"Prompt + ref image + pose sequence"| GEM["Gemini 2.0 Flash"] GEM --&gt;|"Candidate sprite sheet"| MA </mermaid-slide><mermaid-slide data-title="Step 3: Inspect" data-description="The candidate sheet is passed to an algorithmic inspection function — a normal function call that checks for identity drift, pose misalignment, and texture bleed."> flowchart LR FE["Next.js / Phaser"] --&gt;|"HTTP POST"| API["NestJS Server"] API --&gt;|"202 In Progress"| FE API --&gt;|"Trigger async"| MA["Mastra Agent\n(Claude Sonnet)"] MA --&gt;|"Prompt + ref image + pose sequence"| GEM["Gemini 2.0 Flash"] GEM --&gt;|"Candidate sheet"| MA MA --&gt;|"Run inspection"| INSP["Algorithmic Inspection\n(Drift, Pose, Bleed)"] INSP --&gt;|"Pass / Fail + flags"| MA </mermaid-slide><mermaid-slide data-title="Step 4: Edit Failing Frames" data-description="If inspection fails, the agent sends the existing sheet back to Gemini with targeted feedback. Gemini edits — not regenerates — preserving style consistency."> flowchart LR FE["Next.js / Phaser"] --&gt;|"HTTP POST"| API["NestJS Server"] API --&gt;|"202 In Progress"| FE API --&gt;|"Trigger async"| MA["Mastra Agent\n(Claude Sonnet)"] MA --&gt;|"Prompt + ref image + pose sequence"| GEM["Gemini 2.0 Flash"] GEM --&gt;|"Candidate sheet"| MA MA --&gt;|"Run inspection"| INSP["Algorithmic Inspection\n(Drift, Pose, Bleed)"] INSP --&gt;|"Pass / Fail + flags"| MA MA --&gt;|"Failing frames + targeted feedback"| GEM GEM --&gt;|"Edited image — style preserved"| MA </mermaid-slide><mermaid-slide data-title="Step 5: Retry Loop" data-description="Generate → Inspect → Edit repeats until all frames pass or the retry cap is hit. Best variants are composited into the final sheet."> flowchart LR FE["Next.js / Phaser"] --&gt;|"HTTP POST"| API["NestJS Server"] API --&gt;|"202 In Progress"| FE API --&gt;|"Trigger async"| MA["Mastra Agent\n(Claude Sonnet)"] MA --&gt;|"Prompt + ref image + pose sequence"| GEM["Gemini 2.0 Flash"] GEM --&gt;|"Candidate sheet"| MA MA --&gt;|"Run inspection"| INSP["Algorithmic Inspection\n(Drift, Pose, Bleed)"] INSP --&gt;|"Pass or Fail"| MA MA --&gt;|"Fail → targeted feedback"| GEM MA --&gt;|"Pass or retry cap hit"| COMP["Composite Best Variants"] </mermaid-slide><mermaid-slide data-title="Step 6: Engineering Cleanup" data-description="Sheets that pass inspection go through algorithmic cleanup — background removal and other post-processing — before being stored in NestJS's indexed asset folder."> flowchart LR COMP["Composite Best Variants"] --&gt;|"Passed sheet"| CLEAN["Engineering Cleanup\n(Remove BG, etc.)"] CLEAN --&gt;|"Store asset"| STORE["NestJS Indexed Asset Folder"] </mermaid-slide><mermaid-slide data-title="Step 7: Load into Phaser" data-description="The cleaned, stored sheet is fetched via HTTP. Phaser loads it with strict grid alignment, atlas padding, and imageSmoothingEnabled=false for crisp pixel art."> flowchart LR FE["Next.js / Phaser"] --&gt;|"HTTP POST"| API["NestJS Server"] API --&gt;|"202 In Progress"| FE API --&gt;|"Trigger async"| MA["Mastra Agent\n(Claude Sonnet)"] MA --&gt;|"Prompt + ref image + pose sequence"| GEM["Gemini 2.0 Flash"] GEM --&gt;|"Candidate sheet"| MA MA --&gt;|"Run inspection"| INSP["Algorithmic Inspection\n(Drift, Pose, Bleed)"] INSP --&gt;|"Pass or Fail"| MA MA --&gt;|"Fail → targeted feedback"| GEM MA --&gt;|"Pass or retry cap hit"| COMP["Composite Best Variants"] COMP --&gt;|"Passed sheet"| CLEAN["Engineering Cleanup\n(Remove BG, etc.)"] CLEAN --&gt;|"Store asset"| STORE["NestJS Indexed Asset Folder"] STORE --&gt;|"GET /assets/:id"| FE FE --&gt;|"Grid-aligned · atlas padding · no smoothing"| RENDER["Crisp Pixel Art in Phaser"] </mermaid-slide></mermaid-diagram-slides><p><br></p> <p><span style="white-space: pre-wrap;">Laid out in its entirety:</span></p><flow-diagram data-nodes="[{&quot;data&quot;:{&quot;label&quot;:&quot;Front End (Next.js / Phaser)&quot;},&quot;id&quot;:&quot;fe&quot;,&quot;position&quot;:{&quot;x&quot;:268.33272320000003,&quot;y&quot;:-47.050700800000016},&quot;type&quot;:&quot;input&quot;},{&quot;data&quot;:{&quot;label&quot;:&quot;NestJS Server&quot;},&quot;id&quot;:&quot;nest&quot;,&quot;position&quot;:{&quot;x&quot;:270,&quot;y&quot;:180},&quot;type&quot;:&quot;default&quot;},{&quot;data&quot;:{&quot;label&quot;:&quot;Mastra Agent (Claude Sonnet)&quot;},&quot;id&quot;:&quot;agent&quot;,&quot;position&quot;:{&quot;x&quot;:763.845504,&quot;y&quot;:-6.153369600000008},&quot;type&quot;:&quot;default&quot;},{&quot;data&quot;:{&quot;label&quot;:&quot;Gemini Image Tool&quot;},&quot;id&quot;:&quot;gemini&quot;,&quot;position&quot;:{&quot;x&quot;:761.408,&quot;y&quot;:167.328},&quot;type&quot;:&quot;default&quot;},{&quot;data&quot;:{&quot;label&quot;:&quot;Algorithmic Inspection (Drift, Pose, Bleed)&quot;},&quot;id&quot;:&quot;inspect&quot;,&quot;position&quot;:{&quot;x&quot;:760,&quot;y&quot;:340},&quot;type&quot;:&quot;default&quot;},{&quot;data&quot;:{&quot;label&quot;:&quot;Pass?&quot;},&quot;id&quot;:&quot;pass&quot;,&quot;position&quot;:{&quot;x&quot;:504.36800000000005,&quot;y&quot;:351.28202239999996},&quot;type&quot;:&quot;default&quot;},{&quot;data&quot;:{&quot;label&quot;:&quot;Engineering Cleanup (Remove BG, etc.)&quot;},&quot;id&quot;:&quot;cleanup&quot;,&quot;position&quot;:{&quot;x&quot;:270,&quot;y&quot;:340},&quot;type&quot;:&quot;default&quot;},{&quot;data&quot;:{&quot;label&quot;:&quot;NestJS Indexed Asset Folder&quot;},&quot;id&quot;:&quot;store&quot;,&quot;position&quot;:{&quot;x&quot;:268.33272320000003,&quot;y&quot;:499.3585664000001},&quot;type&quot;:&quot;default&quot;},{&quot;data&quot;:{&quot;label&quot;:&quot;Load into Phaser&quot;},&quot;id&quot;:&quot;phaser&quot;,&quot;position&quot;:{&quot;x&quot;:269.74297600000006,&quot;y&quot;:645.2560384},&quot;type&quot;:&quot;output&quot;}]" data-edges="[{&quot;id&quot;:&quot;e-fe-nest&quot;,&quot;label&quot;:&quot;POST /generate-asset&quot;,&quot;source&quot;:&quot;fe&quot;,&quot;target&quot;:&quot;nest&quot;,&quot;type&quot;:&quot;smoothstep&quot;},{&quot;animated&quot;:true,&quot;id&quot;:&quot;e-nest-fe&quot;,&quot;label&quot;:&quot;202 In Progress&quot;,&quot;source&quot;:&quot;nest&quot;,&quot;target&quot;:&quot;fe&quot;,&quot;type&quot;:&quot;smoothstep&quot;},{&quot;id&quot;:&quot;e-nest-agent&quot;,&quot;label&quot;:&quot;Trigger async&quot;,&quot;source&quot;:&quot;nest&quot;,&quot;target&quot;:&quot;agent&quot;,&quot;type&quot;:&quot;smoothstep&quot;},{&quot;id&quot;:&quot;e-agent-gemini&quot;,&quot;label&quot;:&quot;Prompt + ref image&quot;,&quot;source&quot;:&quot;agent&quot;,&quot;target&quot;:&quot;gemini&quot;,&quot;type&quot;:&quot;smoothstep&quot;},{&quot;id&quot;:&quot;e-gemini-inspect&quot;,&quot;label&quot;:&quot;Candidate sheet&quot;,&quot;source&quot;:&quot;gemini&quot;,&quot;target&quot;:&quot;inspect&quot;,&quot;type&quot;:&quot;smoothstep&quot;},{&quot;id&quot;:&quot;e-inspect-pass&quot;,&quot;source&quot;:&quot;inspect&quot;,&quot;target&quot;:&quot;pass&quot;,&quot;type&quot;:&quot;smoothstep&quot;},{&quot;id&quot;:&quot;e-pass-cleanup&quot;,&quot;label&quot;:&quot;Yes&quot;,&quot;source&quot;:&quot;pass&quot;,&quot;target&quot;:&quot;cleanup&quot;,&quot;type&quot;:&quot;smoothstep&quot;},{&quot;animated&quot;:true,&quot;id&quot;:&quot;e-pass-gemini&quot;,&quot;label&quot;:&quot;No: Edit + Feedback&quot;,&quot;source&quot;:&quot;pass&quot;,&quot;target&quot;:&quot;gemini&quot;,&quot;type&quot;:&quot;smoothstep&quot;},{&quot;id&quot;:&quot;e-cleanup-store&quot;,&quot;label&quot;:&quot;Store asset&quot;,&quot;source&quot;:&quot;cleanup&quot;,&quot;target&quot;:&quot;store&quot;,&quot;type&quot;:&quot;smoothstep&quot;},{&quot;id&quot;:&quot;e-store-phaser&quot;,&quot;label&quot;:&quot;GET /assets/:id&quot;,&quot;source&quot;:&quot;store&quot;,&quot;target&quot;:&quot;phaser&quot;,&quot;type&quot;:&quot;smoothstep&quot;}]" data-height="580"></flow-diagram><p><br></p><ul><li value="1"><b><strong style="white-space: pre-wrap;">Claude Sonnet</strong></b><span style="white-space: pre-wrap;"> runs the agent loop — reasoning, tool calls, and validation decisions</span></li><li value="2"><b><strong style="white-space: pre-wrap;">Gemini 2.0 Flash (image preview)</strong></b><span style="white-space: pre-wrap;"> generates and edits sprite frames, exposed as a tool to Sonnet</span></li><li value="3"><b><strong style="white-space: pre-wrap;">Algorithmic inspection</strong></b><span style="white-space: pre-wrap;"> checks each candidate for identity drift, pose misalignment, and texture bleed</span></li><li value="4"><b><strong style="white-space: pre-wrap;">Edit-not-regenerate</strong></b><span style="white-space: pre-wrap;"> — failing frames go back to Gemini with the existing asset + targeted feedback, preserving style consistency</span></li><li value="5"><b><strong style="white-space: pre-wrap;">Retry cap</strong></b><span style="white-space: pre-wrap;"> — loop runs until frames pass or hit the limit; best variants are composited into the final sheet</span></li><li value="6"><b><strong style="white-space: pre-wrap;">Engineering cleanup</strong></b><span style="white-space: pre-wrap;"> — sheets that pass inspection go through background removal and other algorithmic cleanup before being stored</span></li></ul><p><br></p> <h2 id='71c977d4-5953-48c2-8ba0-6017dc3ddcbe'>Some code snippets</h2><p><span style="white-space: pre-wrap;">Agent prompt</span></p><p><file-upload data-src="https://api.productnow-prod.com/storage/getStorageObject?storageObjectId=49cc1c5b-ee8b-4d0f-8caa-38aeadf03822" data-file-name="spriteSheetPromptRenderer.ts" data-description="" data-file-type="application/octet-stream"></file-upload></p><p><span style="white-space: pre-wrap;">Sprite sheet generation prompt (inside the tool call)</span></p><p><file-upload data-src="https://api.productnow-prod.com/storage/getStorageObject?storageObjectId=49be017e-4795-41d3-88f8-541a82b674c5" data-file-name="spriteGenerationPrompt.ts" data-description="" data-file-type="application/octet-stream"></file-upload></p><p><span style="white-space: pre-wrap;">Sprite sheet validator</span></p><p><file-upload data-src="https://api.productnow-prod.com/storage/getStorageObject?storageObjectId=2a6a0496-f3c7-4b5f-8eb3-b561c4effabc" data-file-name="validateSpriteSheet.ts" data-description="" data-file-type="application/octet-stream"></file-upload></p><p><span style="white-space: pre-wrap;">Sprite sheet clean up</span></p><p><file-upload data-src="https://api.productnow-prod.com/storage/getStorageObject?storageObjectId=de4e195d-8161-47ba-ae96-ba439e2e2c93" data-file-name="transparentizeSpriteSheetBackground.ts" data-description="" data-file-type="application/octet-stream"></file-upload></p><p><br></p> <h2 id='36c28b63-f66a-4187-b771-4d30ae0c4f25'>What worked, what failed, and lessons learned</h2><p><b><strong style="white-space: pre-wrap;">What worked</strong></b></p><ul><li value="1"><span style="white-space: pre-wrap;">The end-to-end pipeline actually produced assets — Mastra's iterative agent loop generated and loaded sprite sheets into the game in real time</span><ul><li value="1"><web-citation data-url="https://mastra.ai/" data-title="Mastra website"></web-citation></li></ul></li><li value="2"><span style="white-space: pre-wrap;">Iterative refinement (generate → inspect → edit) caught obvious failures before they reached the canvas, worked surprisingly well</span></li><li value="3"><span style="white-space: pre-wrap;">The Mastra/Phaser split kept responsibilities clean: agent orchestration on the server, real-time rendering in the browser</span><ul><li value="1"><web-citation data-url="https://phaser.io/" data-title="Phaser website"></web-citation></li></ul></li><li value="4"><span style="white-space: pre-wrap;">The engineering harness (retry cap, best-variant compositing) is model-agnostic — swapping in a specialized sprite model could meaningfully improve output quality without rebuilding the pipeline</span></li></ul><p><span style="white-space: pre-wrap;">&nbsp;</span></p><p><b><strong style="white-space: pre-wrap;">What failed</strong></b></p><ul><li value="1"><span style="white-space: pre-wrap;">Animation quality was poor: frames drifted in proportions, pose alignment, and fine detail across states</span></li><li value="2"><span style="white-space: pre-wrap;">Sheets at a glance looked acceptable, but broke down once animated — temporal coherence, not per-frame quality, is the real bottleneck</span></li></ul><p><br></p> <h2 id='f126ccf1-0fa9-48bf-93d1-584b3f5d4bd2'>Closing thoughts</h2><p><span style="white-space: pre-wrap;">I'm still hopeful someone will figure this out and publish a new kind of game. One where we're only limited by our collective creativity (and I guess the model capability 😅). Until then, I'll keep messing around with this, see what I can figure out next!</span></p><p><br></p><p><span style="white-space: pre-wrap;">What do you think? Sound like a potentially fun game genre?</span></p><p><br></p><p><b><strong style="white-space: pre-wrap;">Other random thoughts</strong></b></p><ul><li value="1"><span style="white-space: pre-wrap;">Sequence-aware, frame-by-frame generation seems like the safest bet when consistency matters, might try that next: </span><web-citation data-url="https://arxiv.org/html/2412.03685v2" data-title=""></web-citation><span style="white-space: pre-wrap;"> </span><web-citation data-url="https://computationalcreativity.net/iccc24/papers/ICCC24_paper_199.pdf" data-title=""></web-citation><span style="white-space: pre-wrap;"> </span><web-citation data-url="https://www.scenario.com/blog/ai-sprite-generator" data-title=""></web-citation></li><li value="2"><span style="white-space: pre-wrap;">It seems like to make this a truly player creativity bound sandbox, LLMs need to be seamlessly integrated in, aka I think chat like features aren't enough, we can do so much more than that!</span><ul><li value="1"><span style="white-space: pre-wrap;">At its core we're setting the rules of the world and letting players fill in the details based on their actions</span></li></ul></li><li value="3"><span style="white-space: pre-wrap;">To make this work at scale, caching shared discoveries (aka generated assets) could be great for making the unit economics of the game work, plus add to the shared experience of the sandbox</span></li><li value="4"><span style="white-space: pre-wrap;">Curious if you can extend this to boss battle loops, where the model can generate the boss attack patterns and stuff on the fly</span></li><li value="5"><span style="white-space: pre-wrap;">It'd be super interesting if the entire economy of such a game was player run, including the normal shops</span><ul><li value="1"><span style="white-space: pre-wrap;">I think it'd cause the whole game to feel like a truly living ecosystem where anything could happen, knowing most of the drops and things were some LLM in the background deciding what made sense</span></li></ul></li><li value="6"><span style="white-space: pre-wrap;">Making this a browser-based game (similar to Runescape) would make it wildly accessible and easy to continually deploy updates to</span><ul><li value="1"><span style="white-space: pre-wrap;">That would also force restrictions on performance, which (I think?) means you focus on game loop quality more</span></li></ul></li></ul><p><br></p>