Pages  ›  Card Layout

index-layout.php — Card Layout (Step 5)

The final composition step. Steps 1–4 produce structured card data and a square AI-generated art image. Step 5 takes those two ingredients and composites them into a finished card — art placed inside a frame, with name, cost, rules text, and stats laid out at exact pixel coordinates. The result is a print-ready PNG per card.

index-layout.php (UI) + ajax-render-card.php (HTTP) + inc-card-renderer.php (engine) Imagick-based Shipped v1

What this is, conceptually

Content vs layout

Card content is what the card says — its name, rules, stats, art. Card layout is where each of those things lives on the printed face. They are different concerns and changing one shouldn't ripple into the other. Step 5 owns layout so Steps 1–4 can stay focused on content.

Three pieces work together to produce the rendered card:

  • A template — a JSON file describing canvas size, optional frame layers, and named slots (rectangles where data gets drawn). Pure data, no code.
  • A render model — a flat object built from the card row + game context. The translation layer between card_json and what the renderer needs.
  • A renderer — a pure function renderCard(model, template) → PNG. Walks the slots, draws each one, writes the file.

The template is the where and how. The render model is the what. The renderer marries them. Each is independently changeable: a designer can swap templates without touching cards; a developer can change the renderer engine without touching templates; new card types can ship without rewriting the template.

The user's journey on this page

  1. Open Step 5 from the wizard nav, scoped to one game.
  2. See the list of installed templates — for now just standard_unit_v1, but architecturally a catalog scanned from assets/data/templates/*.json.
  3. Pick a template per card type (Unit, Event, Item, etc.) plus a default fallback. Save.
  4. Click Preview on a sample card from the game to see the result before bulk-rendering.
  5. Click Render all cards to batch-render the whole game. Already-rendered cards are skipped unless Force re-render is ticked.

Per-card render is also reachable from Step 4 via the Render Card button next to each card — that fires the same engine, just one card at a time. The "View rendered" link on the card row appears once a render exists.

Why a separate step (and not a section in Step 4)

Three reasons this isn't tucked under Step 4 (Cards) or Step 3 (Decks):

  • Layout is game-wide; cards and decks are not. One template applies to every card of a given type. Authoring a template per-card or per-deck would be wrong both as data shape and as user mental model.
  • Step 4 already owns "style" in a different sense. save-style-settings.php on Step 4 stores art-direction style hints (mood words, palette references) sent to GPT for image generation — not visual layout. Putting two unrelated "style" concerns on the same page would collide on terminology and confuse users.
  • It's where this content eventually lives anyway. The proposed schema reserves a Layer 6 for print: card size, bleed, crop marks, copies, language. Step 5 is the natural home for layout today and the print-spec layer when that arrives.

Architecture

[ Step 4: card content + art ] (cards/, OpenAI) | v +---------------------------------+ | Step 5: Card Layout (this) | index-layout.php | - pick template per card type | | - preview on a sample card | | - batch render the whole game | +---------------------------------+ | AJAX v +---------------------------------+ | ajax-render-card.php | mode = single | deck | game | preview | - load card row + game_meta | | - pick template by type | | - call buildRenderModel() | | - call renderCard() | | - write PNG to cards/rendered/ | | - update game_cards columns | +---------------------------------+ | v +---------------------------------+ | inc-card-renderer.php | pure: no DB, no HTTP | - loadTemplate | | - buildRenderModel | | - renderCard (Imagick) | +---------------------------------+ | v cards/rendered/<card>.png game_cards.card_rendered_main / card_rendered_at

Where each thing lives

FileRoleTouches
assets/data/templates/standard_unit_v1.json The shipped template. One file per layout. None — pure data.
php-includes/inc-card-renderer.php Pure renderer engine: loadTemplate, buildRenderModel, renderCard, layout / wrap / autoFit / icon helpers. Imagick. No DB, no HTTP, no globals beyond $_game_defaults (read-only).
ajax-render-card.php Thin HTTP wrapper. Modes: single, deck, game, preview. DB (load card, update card_rendered_main), filesystem (cards/rendered/), the engine.
index-layout.php The Step 5 page. Template picker, preview, batch render. DB (read game_meta, sample card), the AJAX endpoint.
php-includes/inc-game-defaults.php Scans assets/data/templates/*.json and exposes $_game_defaults['templates']. Filesystem only.
install.php Idempotent migration: adds card_rendered_main + card_rendered_at columns to game_cards. DB.
php-includes/inc-functions-final.php Per-card "Render Card" button + "View rendered" link inside renderCardBlock. HTML output only.

The template format

A template is a JSON file with three top-level sections: canvas, layers, and slots.

canvas — the page

Width, height, background colour, and an optional procedural border (rounded rectangle). The procedural border means a template works without any frame artwork — you can render a card today and drop in a fancy PNG frame later.

layers — static images on every card

A z-ordered list of full-canvas image layers (typically just the frame artwork). Each layer can be marked optional: true so it gracefully no-ops if the PNG isn't there. The standard_unit_v1 template ships its only layer as optional — no frame is required for v1.

slots — named regions filled from card data

A map of slotId → spec. Three slot types:

  • image — an image (typically the AI art) drawn into a rectangle. Supports fit: cover | contain | stretch. Cover crops centred, contain letterboxes, stretch distorts.
  • text — a single text string (name, cost number, attack value). Supports word wrap, alignment, autoFit (shrink-to-fit until it fits maxLines × height), and an optional background shape (circle, rectangle, rounded rectangle) for stat badges.
  • richtext — rules text with inline icon tokens. Tokenises strings like "Deal 1 {icon:sword} to the opponent." and lays out text + icons together with proper line breaking.

Every slot has a source — a dotted path into the render model (card.attributes.health, card.rules_text). The renderer never reads card_json directly; it only reads the model. This decoupling is what lets the same template work for cards from radically different mechanics.

A slot in detail

"name": { "type": "text", "rect": { "x": 60, "y": 620, "w": 630, "h": 60 }, "z": 200, "source": "card.name", "font": "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", "size": 42, "color": "#1a1a1a", "align": "center", "autoFit": { "min": 22, "max": 42, "maxLines": 1 } }

A long card name will shrink down to fit one line; a short name stays at 42px. The renderer logs a warning if even the minimum size overflows, so you know which cards need attention rather than just seeing clipped text on a finished sheet.

The render model: bridging card data to slots

The renderer doesn't speak card_json. It speaks a flat, pre-computed model with predictable keys. buildRenderModel() is the translator. For a card with this shape:

// game_cards.card_json { "name": "Nimble Knitter", "type": "unit__support", "cost": { "energy": 2 }, "attributes": { "health": 3 }, "effects": [ { "effect_key": "gain_n_resource_to_self", "parameters": { "amount": 2, "resource_type": "energy" } } ] }

The model becomes:

{ "name": "Nimble Knitter", "subtype_label": "Support", // looked up in subtypes catalog "cost_display": "2", // summed across resources "attributes": { "health": 3 }, // pass-through "rules_text": "Gain 2 {icon:energy}.", // composed from effects.json render template "art_path": "cards/card_xxx_yyyy.png" }

Rules-text translation

The interesting work is rules text. Each effect on the card has an effect_key that points into assets/data/effects.json:

{ "effect_key": "gain_n_resource_to_self", "render": "Gain {amount} {resource_type}.", "params": { ... } }

composeEffectSentence() takes the render template and substitutes the card's actual parameters. Parameter names that look like icon candidates (resource_type, icon, damage_type) get wrapped in {icon:…} tokens; everything else becomes plain text. The richtext renderer then handles the rest — tokenising the result, looking up assets/icons/<name>.png, and laying out icons inline with the surrounding text.

Because translation runs at render time, edits to a render string in effects.json propagate to every card that uses that effect, the next time you re-render. Cards don't need to be regenerated.

The renderer engine

renderCard() is a single function with one job: turn (model + template) into a PNG file. The shape:

  1. Create an Imagick canvas of the template's canvas size.
  2. Draw the canvas border (procedural, optional).
  3. Collect every layer + slot into one list, sort by z-index.
  4. Walk the list:
    • For a layer, composite the image at full canvas size.
    • For a slot, dispatch on type (image / text / richtext). Optional slots silently skip when their source resolves to nothing; required slots warn.
  5. Write the PNG. Auto-create cards/rendered/ if missing.
  6. Return {success, path, warnings[]}.

Why pure

renderCard() takes arrays and a path, returns a status array. No DB, no $_GET, no session_start(). That means:

  • It's testable. Build a model + a template inline, call the function, inspect the file. No fixtures needed.
  • It's reusable. CLI batch jobs, queue workers, and command-line utilities can call it without going through HTTP.
  • The HTTP wrapper (ajax-render-card.php) stays a thin shell. All of its complexity is "loading rows and building the model" — the actual rendering is one function call.

Auto-fit and word wrap

The hard parts of any text renderer:

  • Word wrap. wrapText() uses Imagick's queryFontMetrics() to measure each candidate line as it builds it. Real font metrics, not character counts.
  • Auto-fit. Try the max font size; word-wrap; if the result fits within maxLines × height, done. Otherwise step down by 2pt and try again until you reach min. If even min overflows, render anyway and emit a warning.
  • Rich-text tokenisation. Splits on {icon:xxx} markers, then splits the surrounding text on whitespace boundaries so each "word" can break independently. The layout engine accumulates words into lines, breaking when the running width would exceed the rect.

Per-game overrides

A template is shared across games. To stylise without forking the file, each game has a game_meta['card_layout'] entry that the renderer overlays at load time.

{ "template_by_type": { "unit": "standard_unit_v1", "event": "standard_event_v1" }, "template_default": "standard_unit_v1", "slot_overrides": { "standard_unit_v1": { "name": { "color": "#ffd700" }, // gold name "rules_text": { "size": 20 } // smaller body text } } }

Templates stay immutable assets. Per-game tweaks live as overrides. Migrating to a different game's look is just changing one game_meta row.

Step 5 today only edits template_by_type and template_default. Slot-level overrides (color, size, rect) are consumed by the renderer but currently authored by editing the meta JSON directly — a proper per-slot UI is v1.1.

What this unlocks

The architecture is the value

A working renderer is satisfying, but the bigger payoff is the shape of this system — one engine, many templates, per-game overrides. Most of the things below need zero engine changes; they're all template work.

Themed templates

Drop a new file in assets/data/templates/:

  • scifi_unit_v1.json — angular frame, neon stat colours, monospaced rules text. Same slots, same engine.
  • fantasy_parchment_v1.json — aged-paper canvas background, hand-drawn frame asset, serif body font.
  • minimalist_card_v1.json — no frame at all, just centered art and a thin name strip. For users who want to print on plain card stock.

The Step 5 picker will list each of them automatically — no code change, just files.

Different card shapes per type

Today every type uses standard_unit_v1. There's no rule that says they have to. A game could ship:

  • unitportrait_unit_v1 (vertical 750×1050)
  • eventlandscape_event_v1 (horizontal 1050×750)
  • resource_cardmini_resource_v1 (square 600×600)

The applies_to_types field on each template's _meta already gates the picker so users can only pick layouts that make sense for a given type.

Localisation

Rules text is composed from effects.json render strings. To support a second language, add a parallel file (effects.es.json, effects.fr.json) with the same effect keys but localised render templates. Switch on a per-game language setting at render time. Every card translates automatically — no per-card rewriting.

Multiple variants per card

The renderer takes (model, template, outPath). Nothing stops a job from rendering a card several times with different templates and writing to different paths:

  • A front face with the standard layout.
  • A back face from a separate card_back_v1.json template (flat colour, deck logo, no slots).
  • A foil / promo variant with a different frame and colour overrides.
  • A language variant: same card, French body text.

The current game_cards schema only stores one rendered path; multi-variant rendering would need either a wider row or a normalised game_card_renders table. Both paths are short.

Print pipeline (the original goal)

Once individual card PNGs exist, getting to a print-ready PDF is mostly arrangement, not rendering:

  • Imposition — lay out N cards per sheet with bleed and crop marks.
  • Card backs — alternate page of card backs aligned to fronts.
  • Manifest — deck-by-deck count list for the printer.
  • CMYK conversion — if the printer demands it. Imagick handles this with a single setImageColorspace() call.

None of this requires changing the renderer; it builds on top of the renderer's output. This is the layer that turns DeckCraft from "card designer" into "print-ready board game in a box" — the original product goal.

Iconography library

Drop PNGs into assets/icons/. Any rules text referencing {icon:<name>} picks them up automatically. Useful icons to ship globally: resources (energy, gold, mana), actions (attack, defend, draw), keywords (strike, permanent, hidden). Once an icon exists, every card whose effects mention that resource/action gets it for free.

Live preview while editing

Today preview is on-demand (button click) because each render is ~200–500ms. With small enough rects and aggressive caching, debounced live preview becomes feasible — tweak a slot's colour in the override editor and watch the result update. The renderer engine is already fast enough; the missing piece is the per-slot UI.

What v1 deliberately doesn't do

  • No per-slot UI. Step 5 only edits which template applies to which type. Tweaking colours / fonts / rect positions requires editing game_meta.card_layout.slot_overrides by hand.
  • No template authoring UI. Templates are JSON files dropped into a folder. A visual template editor (drag slots, set fonts, save back to JSON) is a real piece of software in itself; not v1.
  • Single render per card. No card backs, no variants, no multi-language renders. The schema stores one card_rendered_main path.
  • No print-pipeline output. Renders are PNGs, not bleed-and-crop PDFs. The print pipeline is a separate layer that consumes these PNGs.
  • No GD fallback. The renderer is Imagick-only. Local Windows dev typically has GD only and would need to test against the server. (Scoped this way intentionally to keep the engine simple.)
  • Cost display sums across resources. { energy: 2, gold: 1 } renders as "3". Per-resource icon display ("2{icon:energy}+1{icon:gold}") lands when the iconography library does.
  • No custom fonts shipped. The default template uses DejaVu Sans (ubiquitous on Ubuntu). Drop TTF files into assets/fonts/ and reference them in templates to customise.

Decisions worth revisiting

Storage shape: a column or a table?

v1 stores rendered PNGs as one column on game_cards (card_rendered_main). When card backs / variants / languages arrive, this becomes either:

  • More columns: card_back_main, card_rendered_es, etc. Simple but rigid.
  • A game_card_renders table keyed by (card, variant, language). Flexible but a migration moment.

Lean: stay with the column until a second variant exists; promote to a table the same week the second variant ships.

Translation timing

Rules text is built at render time from effects.json. Pro: editing a render template propagates everywhere on next render. Con: every render does a small amount of string work. Caching the rendered string in game_cards.rules_text becomes worthwhile only if the renderer ever becomes a bottleneck.

Preview button vs live

v1 uses a button. Imagick renders are 200–500ms; debounced live preview is feasible but would thrash the server during quick tweaks. Worth revisiting once the per-slot override UI lands — that's the page where live preview pays off.

Subtype handling in type field

card_json.type currently holds either the root id (unit) or a subtype slug (unit__support). The schema splits them. The render-model builder is defensive (looks for the __ separator) but the upstream generator should normalise on save. Today's renderer is unaffected; the upstream cleanup is independent.

TODOs

  • Build a per-slot override UI on Step 5
    Today game_meta.card_layout.slot_overrides is consumed by the renderer but edited as raw JSON. A simple form per slot (colour, font size, rect nudges) covers most needs. Live preview pays off here.
  • Ship a non-default template
    A second template proves the multi-template story (sci-fi, fantasy, minimalist — pick one). Mostly JSON, no engine work.
  • Ship a global icons set
    Resources (energy, gold, mana) and a few keyword icons (strike, defend) cover most rules text. Once they exist, every card with those effects gets icons for free.
  • Multi-resource cost display
    buildRenderModel sums across resources. Once icons exist, emit "2{icon:energy}+1{icon:gold}" instead of "3".
  • Card-back template + storage
    First "real" multi-variant render use case. Forces the storage decision (extra column vs game_card_renders table).
  • Print pipeline (imposition + bleed + PDF)
    The big one. Builds on rendered PNGs; takes the project from "card designer" to "box-of-cards producer". Worth a dedicated design pass — not a quick fix.
  • Localisation: parallel effects.<lang>.json
    Add a per-game language setting and load the matching effects file at render time. Schema already accommodates this; the work is in the loader and a small UI toggle.
  • Visual template editor
    Long-tail. A drag-and-drop slot editor that writes back to template JSON. Useful once more than two or three templates exist.