QUICK ANSWER

To get sensible structured JSON output from a small LLM for game design, give each genre its own schema and join them in a discriminated union keyed on a genre field that the model writes first. Constrained decoding only guarantees valid JSON in the right shape, so with one big schema a 7B model can still put wheat crops in a platformer. Once the model commits to the genre token, a per-genre branch makes that mix impossible to express. The Spawn Point maker reports this cut the system prompt to about a third of its old size, though those figures are self-reported and haven't been replicated.

Getting structured JSON output from a small LLM for game design is mostly a schema problem, not a prompt problem. Give each genre its own schema, join them in a discriminated union, and make the model write the genre first. Constrained decoding then rules out off-genre fields entirely. That's the pattern Lượng Lê describes in a dev.to post about Spawn Point, an open-source 2D game generator built on Godot. This guide pulls the general pattern out of that report and checks the claimed gains against the regressions the maker also published.

Why doesn't valid JSON give you a sensible game design?

According to the maker, Spawn Point doesn't ask the model to write GDScript or draw sprites. A fine-tuned 7B model outputs a game design as JSON. Plain Python and pre-tested Godot components then assemble that JSON into a playable game.

Up to v4, the model filled one schema called GameBrief with around 20 fields, including player, enemies, crops, towers, hazards and jump_tiles. The maker points out that for any one genre, about half of those fields mean nothing.

The important distinction:

  • Constrained (guided) decoding guarantees shape. The output is valid JSON that matches the schema.
  • It does not guarantee sense. The maker reports the 7B model producing a hardcore platformer with crops: ["Wheat"] and a tower defense game where the player has double_jump. Both outputs were valid JSON, and both were broken designs.

Ollama's structured outputs announcement describes the feature the same way: it constrains model output to a format defined by a JSON schema. A Towards Data Science walkthrough dates the feature to Ollama 0.5. Nothing in either description checks whether the content makes sense. If the schema allows crops and double_jump in the same object, the decoder will allow both.

The old schema had a second cost. The maker says the system prompt had to explain every field for every genre and include a full few-shot example turn.

How does a discriminated union fix off-genre output?

In v5, the model writes {"genre": ...} first, and the schema branches so that only that genre's fields exist. The maker built this with Pydantic: one class per genre, each narrowing genre to a single Literal value, all joined in a union with discriminator="genre".

The pydantic-xml docs on union types describe the general mechanism: in a discriminated union, the value of one field decides which sub-model applies.

Here is a short sketch of the pattern with made-up genres and field names. It is not the maker's code:

from typing import Annotated, Literal, Union
from pydantic import BaseModel, Field, TypeAdapter

class DesignBase(BaseModel):
    genre: str            # declared first so it leads the JSON object
    title: str

class ShooterDesign(DesignBase):
    genre: Literal["shooter"]
    ship_speed: int
    enemy_waves: list[str]

class TowerDefenseDesign(DesignBase):
    genre: Literal["tower_defense"]
    tower_types: list[str]
    lanes: int

WireDesign = TypeAdapter(
    Annotated[Union[ShooterDesign, TowerDefenseDesign],
              Field(discriminator="genre")]
)

The maker explains why this works during generation. Once the model has committed to the genre value, the grammar mask only allows fields that belong to that class. A shooter design has no lanes field it could fill in. Off-genre mixes go from discouraged by prompt text to impossible in the grammar.

What implementation traps should you expect?

The maker reports three adjustments that the pattern needed before it worked with guided decoding.

1. Put the discriminator first

The model has to emit genre before any payload field. Otherwise it can start writing fields before the branch is chosen. The maker notes that Pydantic puts inherited fields first, so genre: str sits at the top of the base class and each subclass narrows it with Literal[...]. The maker's advice is to check that the generated schema actually forces the branch choice before any payload field.

2. Post-process the generated schema

According to the post, Pydantic's .model_json_schema() emits an OpenAPI-specific discriminator keyword and leaves additionalProperties open by default. The maker's fix has three parts:

  1. Strip the discriminator keyword.
  2. Add additionalProperties: false to every object.
  3. Wrap the root in {"type": "object"}, because the maker says several inference servers require an object at the root.

A minimal sketch of that step (illustrative, not taken from the repo):

def close_schema(node):
    if isinstance(node, dict):
        node.pop("discriminator", None)
        if node.get("type") == "object" or "properties" in node:
            node["additionalProperties"] = False
        for value in node.values():
            close_schema(value)
    elif isinstance(node, list):
        for item in node:
            close_schema(item)
    return node

schema = close_schema(WireDesign.json_schema())
schema.setdefault("type", "object")

Support for schema features differs between servers. The vLLM structured outputs docs cover constrained generation with JSON schema, regex and fixed choices. Ollama accepts a JSON schema through its API, according to its blog. Before you rely on a union, check which keywords your own server and version support.

3. Treat the LLM schema as a wire format

The per-genre classes are only what the model reads and writes. The maker's game assembler, session manager and test suite still use the monolithic GameBrief, and from_wire() / to_wire() convert between the two. The maker says this let them change what the model sees without refactoring the engine. In practice, the schema that suits a 7B model and the data model that suits your game code don't have to be the same thing.

What do the numbers show, and what don't they?

The maker reports these gains:

  • System prompt: "roughly a third of its old size," with no more giant few-shot turn.
  • Entity-edit prompt: 4,182 down to 2,075 characters, about 50% smaller.
  • No mixed genres: the maker says wheat platformers are gone because the grammar can no longer express them.

Three caveats belong next to those figures:

  • "3x" is a rough description, not a benchmark. The post gives no token counts for the system prompt.
  • The 50% figure doesn't come from the schema split alone. It's measured in characters, and the maker links it to giving the edit prompt "a compact view of the entities."
  • Wrong-genre picks still happen. The maker says the model can still choose the wrong genre for an ambiguous prompt. It just can't mix fields from two genres.

The maker also published a regression table. The evals run hand-written prompts through the whole pipeline, and a prompt only passes if the finished game matches everything it asked for:

Eval setv4v5v5 (4-bit AWQ)
New capabilities (16 prompts, incl. shooters)56%88%75%
Real-world prompts (30, English and Vietnamese)87%80%77%
Older sealed set (12 prompts)67%58%n/a

The maker notes that v4 never knew shooters, so the first row isn't a fair comparison. The other two rows are regressions. Run-to-run variance is reported at about 1 to 3 prompts per set at temperature 0.4.

These sets are small, so read them with care. On the 12-prompt set, 67% to 58% is roughly one prompt, which is inside the stated variance. On the 30-prompt set, 87% to 80% is about two prompts, also near the noise floor. The maker still calls both real regressions and blames a final fine-tune patch. That patch was meant to catch implied enemies, but it over-corrected, so v5 often defaults to "Hero" when a prompt only hints at the player. The maker also says some old prompts are now ambiguous because shooters and farms exist.

The maker doesn't blame the schema split for the dips. The schema change and the training patch shipped together, though, and the post includes no ablation. So the sources can't separate the schema's effect on accuracy from the patch's. The maker says v5 shipped with the weakness written up in the model card and a fix planned for v5.1.

When are per-genre schemas worth it, and when aren't they?

This section is analysis based on the sources above.

Likely worth it when:

  • You run a small local model that struggles with long schemas and long prompts.
  • Your output has several mutually exclusive modes, such as genres, entity types or level formats.
  • The set of modes is fixed and known in advance.
  • Your prompt is full of "this field only applies when..." explanations.

Less useful when:

  • You only support one genre.
  • You use a large hosted model that already follows a long schema reliably.
  • Your modes are genuinely mixed. An action-farming hybrid would need its own class, built on purpose.

Trade-offs to plan for:

  • The routing decision moves into the first token. A wrong genre now gives a coherent design for the wrong game, which can be harder to spot than an obviously broken mix.
  • Each new genre has a cost. The maker says the shooter genre needed one small Pydantic model, a few synthetic data builders and new Godot components. That's cheap for this project because the components were already modular, and it may not be cheap for yours.

Checklist

  1. Declare the discriminator first and narrow it per class.
  2. Strip non-standard keywords and close every object with additionalProperties: false.
  3. Make sure the root is an object if your server requires it.
  4. Keep a canonical internal model and convert to and from the wire schema.
  5. Re-run every eval set after each fine-tune patch, not just the one you were fixing. This is the maker's advice.
  6. Follow Ollama's tips: define the schema with Pydantic or Zod, add "return as JSON" to the prompt, and use temperature 0 for more deterministic output. The maker's evals ran at 0.4, so expect more variance at that setting.

Where does this fit in an AI game dev workflow?

Spawn Point keeps the model away from engine code. The LLM fills in a constrained design, and tested components do the building. That's the opposite of letting an agent edit scripts directly, and the two approaches can work together. For the agent side, see how to make a game legible to a coding agent and the godot-ai MCP server guide. For checking generated builds, the Open Godot MCP playtest plan covers agent-driven testing. More is collected under Godot and coding agents.

One caveat applies to everything above. Spawn Point is a single open-source project, and its evals are self-reported. Credit goes to the maker, Lượng Lê. You can check the claims yourself through the GitHub repo, the web demo, and the model cards for the AWQ build and the GGUF build.

FAQ

Does JSON schema constrained decoding stop a small LLM from producing nonsensical content?

No. Constrained decoding guarantees that the output is valid JSON matching the schema, but it doesn't check whether the content makes sense. The Spawn Point maker reports a 7B model producing a platformer with wheat crops and a tower defense game with double_jump. Both were valid against a single combined schema.

What is a discriminated union in Pydantic and how does it work with structured outputs?

A discriminated union is a set of models where the value of one field, such as genre, decides which model applies. Used as a structured-output schema, it means that once the model writes the genre value, only that genre's fields are allowed in the rest of the object.

Why should the discriminator field come first in a JSON schema for an LLM?

Models generate tokens left to right. If genre comes first, the grammar locks onto one branch before any payload fields are written. The Spawn Point maker declares genre at the top of the Pydantic base class because Pydantic puts inherited fields first.

Should I use one big schema or several smaller schemas for LLM structured output?

Split the schema when your output has several mutually exclusive modes and you use a small model. In those cases a single schema forces long field explanations and allows invalid mixes. For a single mode, or a large model that already handles long schemas well, one schema is often simpler.

Why do some inference servers require an object at the root of the JSON schema?

The Spawn Point maker reports that several inference servers need an object type at the schema root, so the maker wraps the union in a root of type object. Pydantic emits a union schema without that root type. Supported keywords differ between servers, so check your server's documentation.

SOURCES