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.
What this is, conceptually
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_jsonand 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
- Open Step 5 from the wizard nav, scoped to one game.
- See the list of installed templates — for
now just
standard_unit_v1, but architecturally a catalog scanned fromassets/data/templates/*.json. - Pick a template per card type (Unit, Event, Item, etc.) plus a default fallback. Save.
- Click Preview on a sample card from the game to see the result before bulk-rendering.
- 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.phpon 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
Where each thing lives
| File | Role | Touches |
|---|---|---|
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. Supportsfit: 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
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:
The model becomes:
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:
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:
- Create an Imagick canvas of the template's canvas size.
- Draw the canvas border (procedural, optional).
- Collect every layer + slot into one list, sort by z-index.
- 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.
- Write the PNG. Auto-create
cards/rendered/if missing. - 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'squeryFontMetrics()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.
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
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:
unit→portrait_unit_v1(vertical 750×1050)event→landscape_event_v1(horizontal 1050×750)resource_card→mini_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.jsontemplate (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_overridesby 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_mainpath. - 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_renderstable 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 5Today
game_meta.card_layout.slot_overridesis 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 templateA second template proves the multi-template story (sci-fi, fantasy, minimalist — pick one). Mostly JSON, no engine work.
-
Ship a global icons setResources (
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
buildRenderModelsums across resources. Once icons exist, emit"2{icon:energy}+1{icon:gold}"instead of"3". -
Card-back template + storageFirst "real" multi-variant render use case. Forces the storage decision (extra column vs
game_card_renderstable). -
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>.jsonAdd 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 editorLong-tail. A drag-and-drop slot editor that writes back to template JSON. Useful once more than two or three templates exist.