Type a Prompt, Run a 3D Dragon: Generating Real Game Models from Text


The feature request was deceptively small: “let me play as a flying car.”

In our endless-runner game, you customize the character you run with. We already had a handful of built-in shapes and a way to recolor them. But the thing kids actually wanted was to invent their own character — type whatever’s in their head and run with it. Our first attempt generated a 2D image and stood it up like a paper cutout. It worked, but it always read as flat — a sticker sliding through a 3D world.

The real ask was a real 3D model. From a sentence. In a kids’ game, on a phone, in seconds. Here’s how we built that, and the two traps that cost us the most time on the way.

The pipeline: text → image → mesh

There is no single model that turns “golden dragon” into a game-ready textured mesh in one shot — not at a price or latency that works for a tap-and-wait interaction. So we chained two stages that each do one thing well:

  1. Text → image. The prompt goes to our standard image generation, producing a single clean, centered picture of the subject on a plain background. This stage is cheap, fast, and we already had it.
  2. Image → 3D. That image feeds an image-to-3D model (we use TRELLIS, an open, commercially-licensable model, hosted on a GPU inference platform). It returns a GLB — a binary glTF file with geometry and baked textures, the format game engines and the web load natively.

The client takes the GLB, parses it, normalizes its scale and orientation so it always sits on the ground facing the right way, and drops it in as your runner. End to end it’s around 20–40 seconds and roughly two cents of compute per model.

The two-stage split turned out to be the unlock. Image generation is a solved, well-understood step we could tune independently (prompt phrasing, plain background, single subject). The 3D stage then gets a clean input instead of trying to infer geometry from an ambiguous description. Each stage is replaceable on its own — if a better image-to-3D model ships next month, we swap one provider and the rest of the pipeline doesn’t move.

Every generation call goes through one door

We made a hard rule a while back, after a billing spike taught us the hard way: every LLM and image call goes through our internal AI service, never directly to a provider. That gateway is the single place where tracing, cost attribution, a commercial-use model allowlist, and rate limits actually exist.

The 3D feature didn’t get to be an exception. So adding it meant adding a new capability to the gateway — a model/generate endpoint — rather than letting the game call the inference platform directly. The gateway orchestrates both stages internally: it calls its own image provider, hands the result to the 3D model, and returns a finished asset.

The payoff was immediate and concrete. Every single generation shows up in our tracing dashboard with the exact cost — the inference platform bills by GPU-seconds, so we read the real prediction time off each job and record the precise dollar figure, not an estimate. The day we shipped, we could already see per-model cost, who generated what, and the running total. That’s only possible because there was one door to instrument.

Don’t put 2 MB blobs in your database

Each GLB is one to two megabytes. The first version, built fast to prove the flow, wrote the binary straight into a MongoDB document. It worked in the demo. It was also a latent mistake: databases are for structured data you query, not multi-megabyte binaries you stream. Document bloat, slow queries, backups ballooning — all the classic symptoms were waiting.

So before locking it in, we moved storage to where binaries belong:

  • The gateway uploads each finished GLB to a dedicated object storage bucket, behind a CDN on its own subdomain.
  • The database stores only metadata — the prompt, the CDN URL, the cost, who made it. Small, queryable, cheap.
  • The client fetches the model straight from the CDN, cached at the edge forever (the files are immutable — each has a unique key).

The bucket is private; only the CDN can read it. The gateway writes to it using a scoped cloud IAM role, so no long-lived keys live anywhere. This is the same shape we use for every other asset class in the platform — the lesson of “binaries go to object storage, the database holds the pointer” is one you only have to learn once.

The trap: a CDN that silently drops CORS headers

Here’s the one that ate an afternoon. The game fetches the GLB from the asset CDN cross-origin, so the response needs an Access-Control-Allow-Origin header. Simple — until it wasn’t.

The CDN’s managed CORS policy only adds that header when the incoming request carries an Origin header. That sounds reasonable and is quietly lethal, because CDNs cache by URL. The sequence that breaks you:

  1. Something fetches the file without an Origin header (a warmup, a preload, a direct hit). The CDN caches a response that has no CORS header.
  2. The browser later requests the same URL with an Origin, expecting CORS.
  3. The CDN serves the cached, header-less copy from the edge. The browser’s CORS check fails. The model won’t load — and there’s no error on the server side, because the server returned a perfectly valid 200.

We’d actually been bitten by this exact cache-poisoning shape once before on another service, so we recognized the symptom. The fix has two parts. First, you can’t set this header through the CDN’s “custom headers” config — it explicitly rejects CORS headers there. You have to attach a small edge function on the response that sets Access-Control-Allow-Origin: * unconditionally, on every response regardless of the request. The assets are public and immutable, so a blanket allow is correct. Second, you invalidate the cache once to flush the poisoned entries that were already sitting at the edge.

The meta-lesson: a header that’s set conditionally, combined with a cache that’s keyed unconditionally, is a bug waiting for the right request order. If a response is cacheable and ever needs CORS, give it CORS every time.

The part kids actually feel: a library that remembers

A generated character that vanishes on refresh isn’t a feature, it’s a tease. So every model a player makes is saved to their own library — an anonymous per-device identity scopes it, no login required. Open the character picker and your past makes are right there, tap to run with any of them again. Because the heavy bytes live on the CDN and only metadata lives in the database, listing your library is a tiny, instant query, and loading a model is a cached edge fetch.

The interaction details matter more than they look. The moment you hit “make,” a placeholder card with a spinner appears in the character grid itself — not a blocking modal — so the wait happens in context, right where the result will land. When it finishes, it auto-selects, and you’re already looking at your new character. The 20–40 second generation is the one place this feature asks for patience, so we spend real effort making the wait feel like progress instead of a freeze.

What I’d tell past me

Three things, in order of how much time they’d have saved:

  1. Chain narrow models instead of hunting for one magic one. Text→image→3D, each stage doing one job, beats waiting for a single text-to-game-asset model. It’s cheaper to tune, easier to debug, and each link is independently swappable.
  2. The gateway is the reason you can see anything. Routing the new capability through the one AI door — instead of calling the inference platform directly because it was faster to wire up — is the only reason we had exact per-model cost on day one.
  3. Binaries to storage, CORS every time. Don’t put megabytes in your database, and never set a CORS header conditionally on a cacheable response. Both are cheap to get right up front and expensive to discover in production.

The request was “let me play as a flying car.” The answer turned out to be a small pipeline, one well-guarded door, and a couple of old lessons re-learned at the edge. Now a kid types a sentence and runs with a dragon. Worth it.