Seedance 2.5 is live — 30-second cinematic video with native audio & real-person references
AI Image Generation for Games: Cut Cost With Caching
2026/09/03

AI Image Generation for Games: Cut Cost With Caching

Build a cache-first game image pipeline with semantic keys, single-flight requests, async polling, budget caps, durable storage, and acceptance-cost math.

Caching can bring the average AI image API cost per player action down to $0.002, but it cannot make a genuinely new image cheaper. At Z-Image's current reAPI rate of $0.005 per completed image, the controlling rule is paid miss rate × completed attempts per accepted asset ≤ 0.4. A 40% miss rate reaches the ceiling only when every new asset passes on its first completed attempt; staying below $0.002 requires a result below that boundary.[3]

Cache the meaning of an item, not just its prompt. Merge concurrent misses, pre-generate only likely items, and measure cost per accepted game asset rather than per API response.

TL;DR

  • At $0.005 per completed Z-Image generation, a game targeting an effective cost at or below $0.002 per craft needs paid miss rate × completed attempts per accepted asset ≤ 0.4. With exactly one attempt, 40% meets the ceiling; strictly below it means less than 40%.[3]
  • Keep a recipe key for what the player combined and a semantic asset key for the resulting object and art version.
  • Enforce one active asset job with a shared database uniqueness constraint. An in-memory Promise map can merge same-process callers, but it is not the lock that protects multiple workers.
  • Image generation is asynchronous. Submit once, retain the task ID, poll the task endpoint, and copy completed output to storage you control. [4][5]
  • A three-task smoke test completed three original candidates on the first submission at 15 credits or $0.015 total. They were not visually reviewed, so they are candidates, not accepted assets.[6]
  • If 3,000 daily images are already genuinely new semantic assets after caching, a conventional cache will not rescue the economics. The game must reuse more art, narrow the generative surface, or support a higher cost.

The real problem is cost per craft, not cost per image

An infinite-crafting mobile game makes the problem concrete. Players combined items such as iron and fire, and the game generated both a new result and its picture. The developer reported paying about $0.002 per image, more than the advertising revenue attached to one craft. They had already stored previous combinations, yet still needed roughly 3,000 new images a day. [1]

A later beginner asked a similar question while building a free game whose core mechanic depended on generated images.[2] Neither thread establishes a market-wide best provider or production benchmark. They do expose one constraint: a game may create novel requests faster than its revenue can pay for them.

Start with this equation:

daily generation cost =
  craft events
  x paid miss rate
  x completed attempts per accepted asset
  x price per completed generation

At $0.005 per completed Z-Image task, the ceiling is:

$0.005 x paid miss rate x completed attempts per accepted asset <= $0.002
paid miss rate x completed attempts per accepted asset <= 0.4

For 3,000 craft events a day over 30 days, the one-attempt scenarios look like this:

Paid miss rateNew generations/day30-day API cost
100%3,000$450
40%1,200$180
20%600$90
10%300$45

These are scenarios, not predicted hit rates, and every row assumes one completed attempt per accepted asset. At 1.5 attempts, for example, the miss rate must be no more than 26.7% to keep the same $0.002 ceiling. If all 3,000 events remain unique after semantic deduplication, the first row applies.

Recipe keys and semantic asset keys solve different problems

A recipe key represents the player's input. If order does not matter in your rules, sort the exact canonical item IDs before hashing them. The unhashed identity is easy to inspect:

recipe identity: v3 | fire | iron

That makes iron + fire and fire + iron resolve through the same server-owned recipe row. Include a rules version because a later balance patch may change the result. Do not slugify IDs for the key: lossy normalization can merge two different items.

A semantic asset key represents what the game ultimately displays. Its unhashed identity can remain equally clear:

asset identity: ember-shield | inventory-icon-v1 | z-image | 1:1 | filtered

Several recipes may resolve to ember-shield; translations may also name it differently. Those paths can still reuse one approved visual.

Raw prompts make poor keys: wording changes fragment the cache, and identical prompts go stale after an art-direction change. Map each recipe to a stable result ID, then key the art by result ID, model, aspect ratio, safety mode, and style version. Bump the version when you want new art.

A cache-first request flow

Treat generation as background asset production, even when the player starts it. A practical flow is:

  1. Resolve the incoming recipe to a semantic result ID.
  2. Look up the semantic asset key in durable storage.
  3. On a hit, return the stored URL immediately.
  4. On a miss, atomically insert one shared job row keyed by the semantic asset.
  5. In the same database transaction, reserve one slot in a shared daily submission budget. Other workers return the existing job.
  6. Commit the row as submitting before making one POST, then persist the task ID immediately after the response.
  7. If submission is ambiguous, mark submit_uncertain and stop. Poll a known task ID with GET until it becomes completed or failed.
  8. Record terminal status and settled credits. Copy a completed image to your own storage and move it to pending_review.
  9. Only an acceptance check may move pending_review to ready.

After step six, return a placeholder and pending state. Let a worker poll while the client checks your game endpoint. Never expose the reAPI key to the client.

Polling is free, and the in-flight task endpoint is cached for five seconds, so poll about every five seconds.[5] Respect Retry-After on a 429. Do not blindly retry the POST: reAPI does not deduplicate generation requests by Idempotency-Key, so a lost response may hide an accepted task and a second charge.[7]

A runnable Node 20 module with a shared lock

The smallest example that protects multiple workers needs shared state. The module below is plain .mjs, uses PostgreSQL through the postgres package, and returns after submission instead of holding the player's request open.

npm install postgres

Create the tables once. Populate game_recipes from the game's own rule data; the browser sends two item IDs, never a result ID or display name.

CREATE TABLE game_recipes (
  recipe_key text PRIMARY KEY,
  result_item_id text NOT NULL,
  result_display_name text NOT NULL
);

CREATE TABLE game_asset_jobs (
  asset_key text PRIMARY KEY,
  origin_recipe_key text NOT NULL REFERENCES game_recipes(recipe_key),
  result_item_id text NOT NULL,
  state text NOT NULL CHECK (state IN (
    'submitting', 'submit_uncertain', 'processing', 'completed',
    'pending_review', 'ready', 'failed', 'rejected'
  )),
  submit_owner uuid NOT NULL,
  prompt text NOT NULL,
  task_id text UNIQUE,
  source_url text,
  durable_url text,
  usage_credits integer,
  error_message text,
  created_at timestamptz NOT NULL DEFAULT now(),
  updated_at timestamptz NOT NULL DEFAULT now()
);

CREATE TABLE game_generation_budgets (
  budget_day date PRIMARY KEY,
  submissions integer NOT NULL CHECK (submissions >= 0),
  submission_limit integer NOT NULL CHECK (submission_limit > 0)
);

Save this as cache-first.mjs. The full SHA-256 keys hash exact canonical IDs; there is no slugification step that can collapse two different IDs.

import { createHash, randomUUID } from 'node:crypto';
import postgres from 'postgres';

const API_BASE = 'https://reapi.ai/api/v1';
const apiKey = process.env.REAPI_API_KEY;
const databaseUrl = process.env.DATABASE_URL;
const dailyLimit = Number(process.env.DAILY_NEW_ASSET_LIMIT ?? '100');

if (!apiKey) throw new Error('Set REAPI_API_KEY on the server');
if (!databaseUrl) throw new Error('Set DATABASE_URL on the server');
if (!Number.isInteger(dailyLimit) || dailyLimit < 1) {
  throw new Error('DAILY_NEW_ASSET_LIMIT must be a positive integer');
}

const sql = postgres(databaseUrl);
const localFlights = new Map(); // L1 only; PostgreSQL owns correctness.

function hashIdentity(value) {
  return createHash('sha256')
    .update(JSON.stringify(value), 'utf8')
    .digest('hex');
}

function canonicalId(value) {
  if (typeof value !== 'string' || value.length === 0 || value.length > 200) {
    throw new Error('Item IDs must be non-empty canonical server IDs');
  }
  return value; // Preserve the exact string; do not trim, slugify, or case-fold.
}

export function recipeKey(leftItemId, rightItemId) {
  const pair = [canonicalId(leftItemId), canonicalId(rightItemId)].sort(
    (a, b) => (a < b ? -1 : a > b ? 1 : 0),
  );
  return `recipe:v3:${hashIdentity({ rulesVersion: 'v3', itemIds: pair })}`;
}

function assetKey(resultItemId) {
  return `asset:${hashIdentity({
    resultItemId,
    styleVersion: 'inventory-icon-v1',
    model: 'z-image',
    aspectRatio: '1:1',
    contentFilter: true,
  })}`;
}

async function resolveRecipe(leftItemId, rightItemId) {
  const key = recipeKey(leftItemId, rightItemId);
  const [recipe] = await sql`
    SELECT result_item_id, result_display_name
    FROM game_recipes
    WHERE recipe_key = ${key}
  `;
  if (!recipe) throw new Error('Unknown recipe');
  if (recipe.result_display_name.length > 200) {
    throw new Error('Canonical display name is too long');
  }
  return { ...recipe, recipeKey: key };
}

function publicJob(job) {
  return {
    assetKey: job.asset_key,
    state: job.state,
    taskId: job.task_id ?? null,
    url: job.state === 'ready' ? job.durable_url : null,
    usageCredits: job.usage_credits ?? null,
  };
}

async function claimSharedJob(recipe, key, prompt) {
  const owner = randomUUID();

  return sql.begin(async (tx) => {
    const created = await tx`
      INSERT INTO game_asset_jobs (
        asset_key, origin_recipe_key, result_item_id,
        state, submit_owner, prompt
      ) VALUES (
        ${key}, ${recipe.recipeKey}, ${recipe.result_item_id},
        'submitting', ${owner}, ${prompt}
      )
      ON CONFLICT (asset_key) DO NOTHING
      RETURNING *
    `;

    if (created.length === 0) {
      const [existing] = await tx`
        SELECT * FROM game_asset_jobs WHERE asset_key = ${key}
      `;
      if (!existing) throw new Error('Concurrent job was not readable');
      return { ownsSubmit: false, job: existing };
    }

    const budget = await tx`
      INSERT INTO game_generation_budgets (
        budget_day, submissions, submission_limit
      ) VALUES (
        (now() AT TIME ZONE 'UTC')::date, 1, ${dailyLimit}
      )
      ON CONFLICT (budget_day) DO UPDATE SET
        submissions = game_generation_budgets.submissions + 1,
        submission_limit = EXCLUDED.submission_limit
      WHERE game_generation_budgets.submissions < EXCLUDED.submission_limit
      RETURNING submissions
    `;
    if (budget.length === 0) throw new Error('Shared daily budget reached');

    return { ownsSubmit: true, owner, job: created[0] };
  });
}

async function markSubmitUncertain(key, owner, message) {
  const [job] = await sql`
    UPDATE game_asset_jobs
    SET state = 'submit_uncertain', error_message = ${message},
        updated_at = now()
    WHERE asset_key = ${key} AND submit_owner = ${owner}
      AND state = 'submitting'
    RETURNING *
  `;
  return job;
}

async function submitClaimedJob(claim, key, prompt) {
  let response;
  let body = '';

  try {
    response = await fetch(`${API_BASE}/images/generations`, {
      method: 'POST',
      headers: {
        Authorization: `Bearer ${apiKey}`,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        model: 'z-image',
        prompt,
        aspect_ratio: '1:1',
        content_filter: true,
      }),
    });
    body = await response.text();
  } catch (error) {
    const job = await markSubmitUncertain(
      key,
      claim.owner,
      error instanceof Error ? error.message : String(error),
    );
    return publicJob(job ?? claim.job);
  }

  let task;
  try {
    task = JSON.parse(body);
  } catch {
    task = null;
  }

  if (!response.ok || !task?.id) {
    const message = `Unconfirmed submit response: HTTP ${response.status}`;
    const job = await markSubmitUncertain(key, claim.owner, message);
    return publicJob(job ?? claim.job);
  }

  const [saved] = await sql`
    UPDATE game_asset_jobs
    SET state = 'processing', task_id = ${task.id}, updated_at = now()
    WHERE asset_key = ${key} AND submit_owner = ${claim.owner}
      AND state = 'submitting'
    RETURNING *
  `;
  if (!saved) {
    throw new Error(`Task ${task.id} was accepted but its ID was not persisted; do not resubmit`);
  }
  return publicJob(saved);
}

async function startOrRead(recipe, key) {
  const prompt = [
    `A clean 3D inventory icon of ${recipe.result_display_name}.`,
    'One centered object, readable silhouette, plain warm background,',
    'soft studio light, no text, no logo, no existing game character.',
  ].join(' ');

  const claim = await claimSharedJob(recipe, key, prompt);
  if (!claim.ownsSubmit) return publicJob(claim.job);
  return submitClaimedJob(claim, key, prompt);
}

export async function getOrCreateCraftAsset({ leftItemId, rightItemId }) {
  const recipe = await resolveRecipe(leftItemId, rightItemId);
  const key = assetKey(recipe.result_item_id);
  const existing = localFlights.get(key);
  if (existing) return existing;

  const work = startOrRead(recipe, key).finally(() => {
    if (localFlights.get(key) === work) localFlights.delete(key);
  });
  localFlights.set(key, work);
  return work;
}

export async function pollAsset(key) {
  const [job] = await sql`
    SELECT * FROM game_asset_jobs WHERE asset_key = ${key}
  `;
  if (!job) throw new Error('Unknown asset job');
  if (job.state !== 'processing' || !job.task_id) return publicJob(job);

  const response = await fetch(`${API_BASE}/tasks/${job.task_id}`, {
    headers: { Authorization: `Bearer ${apiKey}` },
  });
  if (response.status === 429) {
    const parsed = Number.parseInt(response.headers.get('retry-after') ?? '', 10);
    return {
      ...publicJob(job),
      retryAfterSeconds: Number.isFinite(parsed) ? Math.max(parsed, 1) : 5,
    };
  }
  if (!response.ok) {
    throw new Error(`GET task failed with HTTP ${response.status}; retry this GET, not the POST`);
  }

  const task = await response.json();
  if (task.status === 'processing') return publicJob(job);
  const credits = Number.isInteger(task.usage?.credits)
    ? task.usage.credits
    : null;

  if (task.status === 'failed') {
    const [failed] = await sql`
      UPDATE game_asset_jobs
      SET state = 'failed', usage_credits = ${credits},
          error_message = ${task.error?.message ?? 'Generation failed'},
          updated_at = now()
      WHERE asset_key = ${key} AND task_id = ${job.task_id}
      RETURNING *
    `;
    return publicJob(failed);
  }

  if (task.status !== 'completed') {
    throw new Error(`Unexpected task status: ${task.status}`);
  }

  const sourceUrl = task.output?.image_urls?.[0] ?? null;
  const [completed] = await sql`
    UPDATE game_asset_jobs
    SET state = 'completed', source_url = ${sourceUrl},
        usage_credits = ${credits},
        error_message = ${sourceUrl ? null : 'Completed task returned no image URL'},
        updated_at = now()
    WHERE asset_key = ${key} AND task_id = ${job.task_id}
    RETURNING *
  `;
  return publicJob(completed);
}

export async function stageForReview(key, durableUrl) {
  const [job] = await sql`
    UPDATE game_asset_jobs
    SET state = 'pending_review', durable_url = ${durableUrl},
        updated_at = now()
    WHERE asset_key = ${key} AND state = 'completed'
      AND source_url IS NOT NULL
    RETURNING *
  `;
  if (!job) throw new Error('Only an archived completed asset can enter review');
  return publicJob(job);
}

export async function reviewAsset(key, accepted, reason = null) {
  const nextState = accepted ? 'ready' : 'rejected';
  const [job] = await sql`
    UPDATE game_asset_jobs
    SET state = ${nextState}, error_message = ${reason}, updated_at = now()
    WHERE asset_key = ${key} AND state = 'pending_review'
    RETURNING *
  `;
  if (!job) throw new Error('Asset is not pending review');
  return publicJob(job);
}

The database row, not localFlights, prevents a second worker from submitting the same asset. A process that dies during POST leaves submitting; treat a stale row as submit_uncertain and investigate it rather than recycling it. After a known task ID exists, transient polling failures retry only GET.

The shared counter is a conservative submission cap. An unconfirmed or rejected POST still consumes its slot, which is safer than silently exceeding the cap. If a terminal task fails, its status and usage.credits stay in the row. This example does not auto-retry failed or rejected assets; add a separate attempt ledger and an explicit, budgeted operator action before supporting retries.

Your storage worker should download source_url, upload it to your object store, and pass that durable URL to stageForReview. Only reviewAsset(..., true) creates a cacheable ready asset.

What the three-task smoke test proves

On September 3, 2026, we submitted three original 1:1 inventory-item prompts with content_filter: true: a moss lantern, a glass compass, and an ember shield. Each task completed on its first submission and reported 5 credits. [6]

CandidateTask IDObserved completion timeCredits
Moss lanterntask_01a065490250739d893a1efcd83f4d1d25.1 s5
Glass compasstask_01a0654995df7265b44b132dce34a91425.0 s5
Ember shieldtask_01a0654995fd73e28e7c68217066407018.2 s5
Total3 tasks15 / $0.015

The public smoke-test record contains the request settings, task IDs, elapsed times, and settled credits. It deliberately omits the temporary output URLs.[6]

This is a smoke test, not a latency benchmark; it says nothing about p95, bursts, or long-term reliability. The protocol recorded task results and settlement but did not include an art review. completed means three candidates arrived, not that an art reviewer accepted them.

That is why the useful creative metric is:

cost per accepted asset =
  total settled generation credits x $0.001 / accepted assets

If three attempts yield one usable icon, its effective cost is $0.015. Record technical status and creative acceptance separately.

Pre-generate for latency, not imaginary savings

Pre-generate only predictable requests, such as the top 20 recipes reachable from a player's inventory. Use the same semantic cache and single-flight path, with a separate daily cap.

Do not generate the theoretical combination space. Unused assets cost money, and open-ended crafting outgrows a brute-force queue. Rank by observed demand and stop at the cap.

The hybrid usually feels best:

  • approved semantic assets return immediately;
  • high-probability next items are pre-generated;
  • long-tail misses show a placeholder while one background task runs;
  • unpopular or abusive requests hit a quota instead of an unlimited retry loop.

Safety, storage, and operational boundaries

Keep content_filter: true for player-facing generation. Z-Image leaves that setting off by default, so relying on the default is not enough. [4] Moderate or constrain player text before it becomes a prompt, block attempts to imitate protected characters or real people, and rate-limit by account and device. An upstream filter is one layer, not your complete game policy.

Keep the key server-side. Log recipe and asset keys, task ID, status, credits, and acceptance without retaining unnecessary player data.

Finally, copy completed files to durable storage before publishing their URLs to game clients. Task output URLs are not a permanent asset contract. [5] Store a checksum and content type beside the asset record so a retry cannot quietly replace approved art.

Conclusion: when caching is not enough

Caching works only when the game permits reuse. If paid miss rate × completed attempts per accepted asset exceeds 0.4, the $0.002 ceiling cannot hold at a $0.005 unit price. If the 3,000 daily requests in the original example are already unique semantic assets after caching, changing cache software will not solve the problem.

Then change a product constraint: share art between similar items, use procedural recoloring or overlays, generate only popular items, cap discoveries, charge for long-tail creation, or accept a larger media budget.

Check the live Z-Image model page before setting a budget, then use the Z-Image API guide and Tasks API reference to implement submission and polling. Prices and model behavior can change; your own settled credits remain the source of truth for a production bill.

FAQ

Does caching reduce the price of a new AI image?

No. It reduces how often you buy one. The current Z-Image charge remains five credits per completed generation; a cache hit costs no new generation credits.

Should the prompt itself be the cache key?

Usually not. Resolve recipes to a stable result ID, then version its art separately.

What if item order matters?

Do not sort the recipe inputs. water + fire and fire + water should have different recipe keys whenever your rules give them different outcomes.

Should a game pre-generate assets or generate them in real time?

Use both: pre-generate a budgeted likely set, serve hits immediately, and generate unpredictable long-tail items in the background.

How often should I poll a reAPI image task?

About every five seconds is a practical starting point. Faster polling does not make generation finish sooner and can waste rate-limit budget.

Are failed generations charged?

The Z-Image documentation says failed and rejected requests are not charged. Still inspect status together with usage.credits: a completed but visually unusable result is different from a failed task and counts toward creative cost.[4][5]

Can I promise an average below $0.002 per craft?

Only after measuring your paid miss rate and completed attempts per accepted asset. At $0.005 per image, staying strictly below $0.002 requires their product to remain below 0.4. A 40% miss rate merely meets the ceiling when every asset passes on its first completed attempt.

Can the game client call the image endpoint directly?

It should not. A client-side credential can be extracted and abused. Route generation through your backend, enforce quotas there, and let clients query a game-owned pending job.

References

  1. Reddit, r/StableDiffusion. Cheapest image generation API? Posted August 27, 2024; retrieved September 3, 2026 from reddit.com/r/StableDiffusion/comments/1f2j9fh
  2. Reddit, r/vibecoding. Image generator API. Posted December 31, 2025; retrieved September 3, 2026 from reddit.com/r/vibecoding/comments/1q0f1q5
  3. reAPI. Z-Image model page and live pricing. Retrieved September 3, 2026 from reapi.ai/models/z-image
  4. reAPI. Z-Image API documentation: inputs, asynchronous response, pricing, filters, and output retention. Retrieved September 3, 2026 from reapi.ai/docs/z-image
  5. reAPI. Tasks API: status, settled usage, polling, rate-limit behavior, and output storage. Retrieved September 3, 2026 from reapi.ai/docs/api/tasks
  6. reAPI. Three-task Z-Image smoke-test record. Run September 3, 2026. Temporary output URLs are omitted; no visual acceptance review was performed.
  7. reAPI. API overview: idempotency behavior. Retrieved September 3, 2026.