Proposed core schema draft for discussion
The deck-builder v1 type catalog is live:
assets/data/types/deckbuild.json (6 root types) and
assets/data/subtypes/deckbuild.json (18 subtypes).
inc-game-defaults.php loads them and populates two new
globals — $_game_defaults['types'] and
['subtypes'] — plus a back-compat flat
['card-types'] so every existing reader keeps working.
loadCardTypeSchema() now sources from the globals
instead of re-reading files, and a latent bug where behaviour
metadata silently failed to load has been fixed.
Flow is deferred. The “Rules” panel on Step 2 today is the project's half-built attempt at Flow — it generates setup JSON, which is one slice of what Flow needs to cover. For now we're leaving the full Flow design for a later pass and focusing on getting Types and the rest of Layer 2 right. Flow stays in the schema as a sketch so the shape is visible, but no work against it happens until Types are sorted.
A single layered data model that describes a DeckCraft game end-to-end,
replacing today's mix of cardtypes-default.json,
card-behaviours.json, mechanics-effect.json, and
a handful of ad-hoc game_meta keys. Built around two insights
from the Step 2 analysis: (1) today's model is fantasy-deck-builder
specific, and (2) nothing captures how a game is actually played.
The full schema lives in
proposed-schema.json — open
it side-by-side with this page.
Why rewrite the schema
Three things broke at once when we dug into Step 2:
- The data model is a fantasy combat deck-builder. Types like Unit / Structure / Event, behaviours like Permanent / Strike / Targetable, zones like Battlefield. A user who picked Trick-Taking at Step 1 lands on a page asking them to tick Units and Structures. Wrong vocabulary.
- Nothing describes how the game is played. The schema covers pieces (what's on a card, where cards live, what they cost) but not flow (turns, phases, win conditions as structured data). The engine couldn't simulate a game even if it wanted to.
- Terminology is overloaded. “Mechanic” means two unrelated things. “Archetype” means three. The code calls the same concept card-type, subtype, and archetype in different places.
All three are schema problems. Patching Step 2's UI without fixing the underlying model just pushes the mess around.
The six layers
A game is modelled as six nested layers, each answering a different question:
| Layer | Question | Fields |
|---|---|---|
| 1. Briefing | What kind of game is this? | Name, pitch, mechanic, player count, audience — free-text + enums. Already captured. |
| 2. Schema | What kinds of cards exist? | types_enabled, behaviours_enabled, effects_enabled, subtypes_used. |
| 3a. Economy | What numbers are tracked per player? | trackers — unified resources + life + score. |
| 3b. Topology | Where do cards live? | zones. |
| 4. Flow | How is a game played? new layer | turn_order, phases, win_conditions, loss_conditions. |
| 5. Content | What's the actual stuff? | families, decks, cards. Filled in Steps 3 & 4. |
| 6. Print | How will it be produced? deferred | Card size, run, language, box. Captured at export time. |
Layers 1, 2, 3a, 3b, and 5 map to things the wizard already asks about. Layer 4 is new. Layer 6 is deferred.
Every field a user answers writes into exactly one place in the
schema. No duplication, no silent drops. Today's system violates this
(game_genre_subgenre is a ghost key, elevator_pitch
was silently dropped until we fixed it) — the schema makes the
contract explicit.
Types vs subtypes vs behaviours vs effects
The four concepts that are tangled in today's code, split into their proper roles:
| Concept | Role | Who cares |
|---|---|---|
| Type | Determines the card's data shape (fields). A Unit has attack/health, an Event doesn't. | Engine (hard requirement). User (must be picked). |
| Behaviour | Engine semantics. Permanent, Strike, Hidden — what the rules actually do when a card is played. | Engine (hard requirement). User (picks from each type's allowed list). |
| Effect | The vocabulary of verbs individual cards can use: deal damage, draw, counter, gain. | Engine (executes them). AI (samples from them when writing card text). |
| Subtype | AI-facing flavour. Guardian, Striker, Generator — named presets of (type + forced behaviours + description). | AI (inspiration). UI (preset shortcuts). Engine ignores them. |
The mental model
(type) + (some subset of type's allowed behaviours)
is the engine-level identity of a card. Two different subtypes with the
same type and behaviours are indistinguishable to the engine.
That means subtypes are a soft layer. They exist to help the AI generate thematically consistent cards ("generate a Guardian Unit: a Permanent+Outpost Unit that forces opponents to target it first") and to give the UI preset shortcuts. Nothing breaks if you never use a subtype.
This is why the current "subtype table" on Step 2 is wrong as the primary selection surface: it makes users commit to a soft label as if it were a hard decision.
The missing layer: Flow
Today the only part of flow that's captured is the Rules panel on Step 2, which generates free-text setup actions. Everything else about playing the game lives in the user's head.
The proposed Flow layer covers:
- Turn order — clockwise / counter-clockwise / simultaneous.
- Phases — named stages of a turn. Each phase declares allowed_actions (player-chosen) and/or mandatory_actions (automatic). Phases can repeat ("until_ended_by_player", "once_per_player", "until_hands_empty") or be one-shot ("one_time_per_round": true).
- Win conditions — structured triggers. Metric (tracker:authority, tracker:score), comparator, threshold, target (self / opponent / any_team). Multiple conditions can coexist.
- Loss conditions — same shape as win, reversed target.
- Interrupt rules — whether non-active players can act during someone else's turn.
- Game length cap — optional turn / round limit to prevent stalemates.
With Flow captured, the assistant can do things it literally cannot do today: print a complete rulebook from the schema; simulate a game to sanity-check the design; generate cards whose rules-text references the actual phase names and trackers instead of inventing them. Every one of these is blocked on Flow.
This is the most ambitious part of the proposal. The answer to "is Flow too big?" is: start with a skeleton — phases + win_conditions mandatory, everything else optional. Even the skeleton is dramatically more than zero.
Zones (topology) — schema review deferred
Deferred, grouped with game mechanics. The zone data model has real schema-shape issues and missing concepts, but they're tangled up with Flow, economy links, and mechanic-specific topologies. Addressing zones properly means picking it up as part of the broader game-mechanics workstream. This section captures the analysis so it isn't lost.
What's in the data today
assets/data/zones-default.json ships 7 zones:
deck, hand, in_play,
discard, trash, market,
market_deck. Each is a JSON object with some subset of:
| Field | What it answers | Used by |
|---|---|---|
id | Machine name (referenced by move actions, rules, etc.) | All |
name | Human label | All |
type | Rendering hint: deck / hand / area | All |
owner | player / shared | All |
visibility | Array containing owner / all / none | All |
access | Array: who can interact (can be empty for trash) | All |
ordering | stack / none | All |
initial | Starting contents (empty array) | 3 of 7 zones |
accepts | Card types allowed in this zone | Only in_play |
rules | Automatic behaviour (refill, draw-from) | Only market |
What works, conceptually
- Zones correctly live at the game level, not on cards. Same shape we committed to for Flow.
- Data is id-referenceable — flow actions like
{ "do": "move", "from": "deck", "to": "hand" }compose naturally. - Shared vs per-player ownership is already modelled (
marketuses"owner": "shared"). acceptsonin_playis a smart constraint — it encodes which types can persist there. Under-used but the idea is right.rules.refillonmarketproves the model can capture automatic zone behaviour.
Immediate issue (side-effect of the types migration)
in_play.accepts is
["unit", "location", "trap"] — but we just dropped
location and trap as root types. Until fixed,
any engine code that enforces accepts will reject
unit-alike cards that happen to be built under the old type names.
Trivial fix: change to ["unit", "structure"] or remove
the constraint. Flagged here so it doesn't get lost.
Schema-shape issues
-
Inconsistent field presence. Some zones have
initial, onlymarkethasrules, onlyin_playhasaccepts, onlymarket_deckusesvisibility: ["none"]. Readers have to handle "key may or may not exist" for almost every field. -
initial: []is runtime state, not config. Same smell asobjective.progress.current. The zone definition says what the zone IS; starting contents belong to setup / Flow. -
visibility/accessas arrays of one-element enums. Every zone uses one of["owner"],["all"],["none"], or[]. They look like multi-value arrays but are effectively single enums. Either formalise them as enums, or document which combinations mean something. -
typeis an undocumented enum. Values actually used:deck,hand,area. The (now-dead) zone-suggestion AI prompt mentionsdeck | pile | battlefield | market | discard | custom— a different set. No single source of truth. Semantics overlap with other fields (ordering+owneralready communicate most of whattypedoes). -
orderinghas undocumented values.stackandnoneare used.queue(FIFO),sorted,randommight be needed for other games. No doc lists the valid set. -
No link to the economy layer. If a zone holds
resource_cards, nothing in the zone def tells the engine "when a card lands here, increment tracker X". Will matter once Flow is real. -
No lifecycle scope. Some zones exist all game
(hand, deck). Some only during certain phases
(
current_trickin a trick-taker). Nophase_scopeoralwaysflag. Flow will need this. -
No
max_cardsfor player hand. Present onmarket-like rules, absent where it's most commonly needed. Hand-size limits are a first-class design knob. -
Zones are not mechanic-scoped. Parallel problem to
what we just fixed for types. A trick-taking game doesn't have
in_playormarket— it hascurrent_trickandwon_tricks. Today's single-file default is deck-builder-centric. -
No way to customise a zone per game. User can
tick/untick defaults. No rename, no
max_cardsoverride, no "add a custom zone". Every game gets the same topology.
Missing zone concepts (surfaced by other genres)
- Revealed / preview zone — face-up peek at the next card (Dominion-like).
- Score pile — won tricks, completed sets, victory cards. Owned per-player or per-team.
- Staging zone — drafting, trade offer, pending-bid.
- Persistent setup zone — role cards, quest tokens, identity cards that aren't in the deck cycle.
- Supply pile (per-stack) — Dominion's supply is several decks keyed by card, not a single pool. The
marketconcept is close but not quite it.
Proposed improvements (priority order)
Fix now, mechanical:
-
Fix stale
in_play.acceptsreferenceChange["unit", "location", "trap"]to["unit", "structure"]or drop the constraint. -
Decide
visibility/accessshapeEnum vs array. Normalise existing entries either way. -
Remove
initialfrom zone definitionsSetup state, not config. Will live in Flow's setup phase.
Fix soon, schema shape:
-
Document the enums (
type,owner,visibility,access,ordering)Add a Zone Schema section to this doc or JSON Schema alongside the data file. -
Add
max_cardsas first-class optional fieldHand limits, deck limits, zone caps. -
Promote
rules.refillto a named fieldauto_refill_from+auto_refill_to. Common pattern; shouldn't hide in a genericrulesbag.
Fix when convenient (parallel to mechanic-aware Step 2):
-
Split zones into
assets/data/zones/{mechanic}.jsonParallel to the types split. Keep a small universal set only if the zones really are universal. -
Add
auto_tracker_effecthookLink zones to the economy: "when a card lands here, +N to tracker X". -
Introduce
phase_scopeon zonesRequires Flow to exist. Distinguishes persistent zones from per-phase zones.
Fix when rewriting Step 2 UI:
-
Allow per-game zone customisationRename, edit
max_cards, add/remove custom zones. Not just tick/untick. -
Replace the raw JSON
<pre>displayAlready captured in the Step 2 analysis as "visual zone layout instead of JSON". Linked here for completeness.
Example: deck-builder
A Star-Realms-inspired 2-player game fits naturally into the schema.
Excerpt (full version in
proposed-schema.json,
key EXAMPLE_1_DECK_BUILDER):
{
"briefing": {
"game_name": "StarForge",
"mechanic": "deckbuild",
"subgenre": "combat",
"player_mode": "competitive",
"player_count_min": 2, "player_count_max": 2,
"age_rating": "12+", "play_time": "30_60", "complexity": 3,
"game_goal": "Reduce opponent's Authority to zero."
},
"schema": {
"types_enabled": [{ "id": "unit" }, { "id": "structure" }, { "id": "event" }],
"behaviours_enabled": ["Permanent", "Strike", "Instant", "Outpost"],
"effects_enabled": ["deal_damage", "heal", "draw", "gain_resource", "destroy"]
},
"economy": {
"trackers": [
{ "id": "authority", "role": "life", "starts_at": 50, "per_player": true },
{ "id": "trade", "role": "resource", "starts_at": 0, "regenerates": "per_turn_reset_to_zero" },
{ "id": "combat", "role": "resource", "starts_at": 0, "regenerates": "per_turn_reset_to_zero" }
]
},
"flow": {
"phases": [
{ "id": "main", "repeat": "until_ended_by_player",
"allowed_actions": [
{ "id": "play_card", "from": "hand", "to": "in_play", "any_number": true },
{ "id": "buy_from_row", "from": "trade_row", "to": "discard", "cost_in": "trade" },
{ "id": "attack_opponent", "cost_in": "combat", "effect": "reduce_tracker", "tracker": "authority" }
]},
{ "id": "cleanup", "automatic": true,
"mandatory_actions": [
{ "id": "move_all", "from": "in_play", "to": "discard" },
{ "id": "draw", "from": "draw_pile", "to": "hand", "count": 5 }
],
"tracker_resets": ["trade", "combat"] }
],
"win_conditions": [
{ "id": "authority_zero", "target": "opponent", "metric": "tracker:authority", "comparator": "<=", "threshold": 0 }
]
}
}
Example: trick-taking
A 4-player partnership trick-taking game fits the same schema with
completely different content — no Units, no attack/defense
behaviours, no Battlefield. Instead: one new type (ranked_card)
with rank + suit fields, no behaviours, team-scoped score trackers.
This is the acid test for whether the schema is really mechanic-agnostic. It is:
{
"briefing": {
"game_name": "SkyBid",
"mechanic": "tricktake",
"player_mode": "team", "is_team_game": true,
"player_count_min": 4, "player_count_max": 4,
"game_goal": "First team to reach 500 points wins."
},
"schema": {
"types_enabled": [{ "id": "ranked_card" }],
"behaviours_enabled": [],
"effects_enabled": []
},
"economy": {
"trackers": [
{ "id": "team_score", "role": "score", "per_team": true, "starts_at": 0 },
{ "id": "bid", "role": "commitment", "per_team": true, "starts_at": 0 }
]
},
"topology": {
"zones": [
{ "id": "deck", "owner": "shared" },
{ "id": "hand", "owner": "per_player", "visibility": "owner" },
{ "id": "current_trick", "owner": "shared", "visibility": "public" },
{ "id": "won_tricks", "owner": "per_team" }
]
},
"flow": {
"phases": [
{ "id": "deal", "one_time_per_round": true, "automatic": true,
"mandatory_actions": [
{ "id": "shuffle", "target": "deck" },
{ "id": "deal", "from": "deck", "to": "hand", "count": 13, "to_each_player": true }
]},
{ "id": "bid", "repeat": "once_per_player",
"allowed_actions": [{ "id": "place_bid", "min": 0, "max": 500 }, { "id": "pass" }] },
{ "id": "play", "repeat": "until_hands_empty",
"allowed_actions": [{ "id": "play_card", "from": "hand", "to": "current_trick", "follow_suit_if_possible": true }] },
{ "id": "resolve_trick", "trigger": "current_trick_has_N_cards", "N": 4,
"mandatory_actions": [
{ "id": "determine_winner", "by": "highest_rank_in_lead_suit_or_trump" },
{ "id": "move_all", "from": "current_trick", "to": "won_tricks.of_winner" },
{ "id": "increment_tracker", "who": "winner_team", "tracker": "team_score", "by": "sum_of_point_values_in_trick" }
]}
],
"win_conditions": [
{ "id": "reach_target", "target": "any_team", "metric": "tracker:team_score", "comparator": ">=", "threshold": 500 }
]
}
}
The new type ranked_card with rank / suit /
point_value fields would be added to the default type
catalog to make trick-taking / rummy / bridge-like games first-class.
What changes vs today
| Today | Proposed | Why |
|---|---|---|
cardtypes-default.json (roots + subtypes in one flat file) | Split into types-default.json + subtypes-default.json | Makes the root-vs-subtype distinction structural, not accidental via a parent field. |
card-behaviours.json | behaviours-default.json (rename only) | Terminology consistency. |
mechanics-effect.json | effects-default.json | Kills the “mechanic” collision between Step 1 (genre) and Step 2 (card verb). |
Resources and life-points modelled separately (game_parts with part_type) | Unified as trackers with a role field (resource / life / score / commitment) | One concept, one codepath. UI can still separate them visually. |
Presets hardcoded in index-step2.php as a PHP array — only covers 3 of 10 root types and doesn't match the subtypes in the JSON | Presets are just subtypes (no separate concept) | The out-of-sync duplication gets deleted. |
“Enabled mechanics” as a flat checkbox list in game_meta | schema.effects_enabled (same data, better name) | Rename; no behavioural change. |
| No structured game flow | flow layer (phases, win/loss conditions) | New. Unlocks engine simulation, rule-book generation, meaningful card-text generation. |
| Rules panel generates free-text setup instructions | Rules generator produces structured mandatory_actions inside flow phases | AI output becomes machine-readable, not just prose. |
| Family cap of 2 enforced in PHP | Family cap is a product decision — schema imposes no limit | Remove beta-grade placeholders from the data model. |
Migration path
Big-bang rewrites on a WIP project are risky. A realistic migration in three tracks:
-
Track A — rename & split (low risk).
cardtypes-default.jsonsplits into types + subtypes.mechanics-effect.jsonrenames toeffects-default.json. No schema changes yet — just file housekeeping +inc-game-defaults.phpupdated to load the new locations. -
Track B — terminology alignment (medium risk).
Rename "enabled_mechanics" meta key → "effects_enabled". Rename
archetypeusages in UI and code. Unify resources and life-points as trackers. Add back-compat readers on the meta layer so existing games keep working. -
Track C — Flow layer (bigger work). Introduce
the
flowstructure as a new meta key. Start with a minimum viable spec:phases+win_conditions. First AI integration: haveprovideContextToIAgenerate a proposed Flow for the briefing and persist it. Iterate from there.
Tracks A and B are maybe a day each. Track C is a week+ of design and AI-prompt iteration.
Migrations log (shipped)
Concrete before/after of structural changes already in the code. Each entry follows the same template so future migrations can be appended without rethinking the format.
Migration #1 — Types catalog split shipped 2026-04-23
What changed
Replaced the monolithic cardtypes-default.json with
mechanic-scoped types/ and subtypes/ catalogs,
dropped three root types that didn't belong (location,
role, rulemod), promoted trap to
an Event subtype, renamed resource to
resource_card, and widened two allowed_behaviours
lists so subtypes no longer leak behaviours outside the parent's allow-list.
File layout
Before
assets/data/
cardtypes-default.json
# single flat array, 27 entries,
# roots and subtypes mixed,
# distinguished only by a `parent` field
After
assets/data/
types/
deckbuild.json 6 roots
subtypes/
deckbuild.json 18 subtypes
# per-mechanic catalogs;
# when tcg/tricktake/etc. arrive
# they get their own files here
Catalog contents
| Entry | Before | After | Why |
|---|---|---|---|
unit | root type | root type kept | Core. |
structure | root type | root type kept | Core, users think in "unit vs base". |
event | root type | root type kept | Core. |
item | root type | root type kept | Core. |
objective | root type | root type kept | Unique fields (progress.target). |
location | root type | dropped | Overlapped with Structure. Reintroduce if a game really needs a "global effect" type. |
role | root type | dropped | Rare in pure deck-builders. Reintroduce when a genre (social deduction, adventure) actually uses it. |
trap | root type | moved → event__trap | Trap is a Hidden + SingleUse Event. Didn't need its own type. |
rulemod | root type | dropped | Only meaningful once Flow exists (Flow is deferred). |
resource | root type | renamed → resource_card | Name collision with "resource" trackers in the economy layer. |
| Subtypes | |||
| 11 Unit subtypes | subtypes | subtypes kept | Frontliner, Striker, Summoner, Support, Disruptor, Charger, Guardian, Linger, Decoy, Stealth, Generator. |
| 6 Event subtypes | subtypes | subtypes kept | Damage, Control, Support, Resource Boost, Card Advantage, Counter/Redirect. |
event__resource | subtype | renamed → event__resource_boost | Disambiguates from the resource_card type. |
event__trap | — | new | Promoted from the old root-type trap. Forces Hidden + SingleUse. |
Field names & terminology
| Concept | Before | After | Where |
|---|---|---|---|
| Subtype locks a behaviour on | default_behaviours: [...] | forces_behaviours: [...] | subtypes/*.json |
| AI steering keywords | notes: "glass cannon, burst" (string) | ai_hints: ["glass cannon","burst"] (array) | subtypes/*.json |
| Root type preselected behaviours | default_behaviours: [...] | default_behaviours: [...] unchanged | types/*.json |
| resource_card link to tracker | — | resource_id: "wood" | types/deckbuild.json, resource_card.fields |
allowed_behaviours widening
| Type | Before | After |
|---|---|---|
unit | [Permanent, Strike] | [Permanent, Strike, Targetable, Hidden, Outpost, Lingering] |
event | [Instant, Lingering] | [Instant, Lingering, Hidden, SingleUse] (Trap needs Hidden + SingleUse) |
structure, item, objective, resource_card | unchanged | |
PHP access patterns
Before
// inc-game-defaults.php
$_game_defaults['card-types'];
// flat array, roots + subtypes,
// roots identified by parent===null
// loadCardTypeSchema() read the file
// directly on every call
After
// inc-game-defaults.php
$_game_defaults['types']; // roots only
$_game_defaults['subtypes']; // subtypes only
$_game_defaults['card-types']; // legacy flat
// (auto-rebuilt for
// back-compat)
// loadCardTypeSchema() uses the global
// (no file reads)
Side-effect: behaviour-driven inheritance now works
loadCardTypeSchema() was previously reading a non-existent
behaviours.json (typo; real file is
card-behaviours.json), so the behaviour metadata came back
empty and the subtype-inheritance pass silently did nothing. Fixed in
the same migration. Observable effect:
- Strike subtypes (e.g.
unit__striker) now haveattributes.healthstripped from their rendered schema (Strike declaresremoves_field: ["attributes.health"]). - Permanent subtypes (e.g.
unit__frontliner) now haveattributes.healthinherited from the parent (Permanent declaresrequires_field: ["attributes.health"]).
This is the intended behaviour per the original schema design
docstrings — it just wasn't firing before. Downstream AI prompts
that go through loadCardTypeSchema() will see slightly
different (more accurate) card shapes. Smoke-test before assuming
parity.
Follow-ups
- Delete
assets/data/cardtypes-default.json— no live reader references it; kept temporarily for safety. - Migrate
index-step2.php+partial-form-cardtypes.phpto use$_game_defaults['types']/['subtypes']directly instead of the back-compat flat array. - Add
types/{mechanic}.jsonfor additional mechanics when those genres enter scope.
Open questions (decisions needed before we write code)
-
Unify trackers? Is it right to fold resources and
life under one
trackersconcept with arolefield? Simpler schema, but the user thinks about Mana differently from HP. -
Subtypes: global or per-game? Today, defaults are
global and custom subtypes live in
game_meta. Proposal keeps that split. OK? -
Flow scope for v1? Minimum would be
phases+win_conditions, everything else optional. Even that is non-trivial. - Effect params — how structured? "deal_damage amount: 3, target: opponent_authority" is clean. But composite effects ("draw a card, then if it's a Unit gain 1 Combat") get messy. Recommend: one effect entry per action; composites are arrays of effects. No DSL.
- Composite win conditions? Score >= 500 AND hand empty. Model as AND/OR tree, or as flat list of independent conditions? Recommend flat list (any one fires) — simpler.
-
Localised card text?
content.cards[].rules_text_localised[lang]. Small tax now, huge cost to retrofit. Recommend designing in now, even if onlyenships. -
Where does game-length cap live? Currently
proposed as
flow.game_length_cap, separate from loss conditions. It's game-ending but not player-attributable. Keep separate? -
Does the schema version itself? Add a top-level
schema_versionso we can migrate persisted games forward when the schema changes. Recommend yes, string like"0.1".
Next step
Read the schema file, push back on anything that feels wrong or over-engineered, then we can scope Track A / B / C concretely and pick a starting point. Nothing in the wizard or the engine needs to change until we agree on the shape.