Skip to content

jengine-cards

Deckbuilding roguelike: data-driven cards, enemy intents, statuses, a run map with rewards, rests and shops.

KindProject package: linked into the builds of the projects that list it
Sourcepackages/jengine-cards
Version0.1.0
Entry pointjengine_cards::CardsPackage
Script namespaceCards
Listed byprojects/arcade-classics, projects/engine-test-lab

List it in the top-level "packages" array of project.jengine:

{
"packages": [
{ "name": "jengine-cards", "source": { "type": "path", "path": "../../packages/jengine-cards" } }
]
}

Then rebuild the editor viewport for the project (jengine viewport-build projects/<name>, or the editor’s Rebuild viewport banner). Native and web builds link it on their own.

A scene that uses its components must be a v2 scene ("version": 2): a v1 scene has no field for a package component and keeps it as inert custom data.

A script can check that the package is linked with if Cards == nil then return end.

Scene keyRust type
card_gameCardGamepackage only: needs a v2 scene

Commands run in every runtime and return nothing; a package publishes what scripts read onto components (see the notes below). The flat name also works.

CallFlat name
Cards.new_run(e, seed)cards_new_run
Cards.choose(e, node)cards_choose
Cards.play(e, uid, target)cards_play
Cards.end_turn(e)cards_end_turn
Cards.pick(e, index)cards_pick
Cards.rest(e, choice, uid)cards_rest
Cards.buy(e, kind, index)cards_buy
Cards.leave(e)cards_leave
Cards.auto(e, on, delay)cards_auto
  • projects/arcade-classics/assets/scenes/deckbuilder.scene
  • projects/engine-test-lab/assets/scenes/79-cards.scene

From packages/jengine-cards/README.md.

A deckbuilding roguelike in the style of Slay the Spire. You climb a map of rooms, fight with a deck of cards, and make the deck stronger (and thinner) as you go.

Used by projects/arcade-classics (Ashen Deck) and Engine Test Lab scene 79.

ModuleWhat it is
contentCards, enemies, relics and encounters, read from the project’s data tables. A new card is a new row, not new code.
combatA fight: the piles, energy, effects, statuses and enemy intents.
runThe map, rewards, rest sites, shops and treasure.
botA player that plays itself, for attract mode and the balance test.

The package reads four .data tables through the engine’s published data registry, jengine_core::data::global(). These are the same tables scripts read with Data.get, and you edit them in the editor’s table editor. The native runner publishes them when it loads them, and the editor viewport when the editor seeds them. Until they’re published, the game waits (CardsState.phase is loading).

The tables to read are named on the component. The defaults are data/cards, data/enemies, data/relics and data/encounters:

"card_game": { "seed": 0, "auto_delay": 0.45,
"tables": { "cards": "data/cards", "enemies": "data/enemies",
"relics": "data/relics", "encounters": "data/encounters" } }

Loading checks every reference: an encounter naming a missing enemy, or an effect adding a missing card, is an error at load (CardsState.load_error), not a gap in the middle of a run.

Cards, enemy moves and relics share one effect shape:

{ "op": "damage", "amount": 6, "times": 1, "status": "", "target": "", "card": "", "pile": "" }
opDoes
damageamount damage, times times, after strength, weak and vulnerable. Block absorbs it first.
blockBlock for the doer, after dexterity and frail.
applyAdds amount of status (see the statuses below).
draw, energyFor the player.
heal, lose_hpHeal, or lose HP. Losing HP ignores block.
add_cardPuts card into a pile (discard by default, or draw or hand). Enemies use it to slime your deck.
goldGold found in a fight.

Targets. An effect with no target goes where it naturally would:

  • a player’s damage goes to the enemy the card was aimed at (or every enemy for an all card)
  • a player’s debuff goes to that enemy, and a buff to the player
  • an enemy’s damage and debuffs go to the player, and its block and buffs to itself

Set a target to override this: self, all, random, or opponent, which aims any status at the other side (an enemy taking your strength).

Statuses.

StatusDoes
strengthAdds to every hit.
dexterityAdds to every block.
vulnerableTakes 50% more attack damage. Runs down at the end of its owner’s turn.
weakDeals 25% less attack damage. Runs down at the end of its owner’s turn.
frailGains 25% less block. Runs down at the end of its owner’s turn.
poisonLoses that much HP at the start of its turn, then 1 less.
thornsAttackers take that much damage back.
ritualGains that much strength at the end of each turn.
metallicizeGains that much block at the end of each turn.

Card rows have these fields:

  • name, type (attack, skill, power or status), rarity, cost, target (enemy, self, all or none) and text.
  • text has {d} (damage), {b} (block), {m} (the other number) and {x} (hits) filled in, as the numbers would land now.
  • effects, and the upgrade’s plus_effects, plus_cost (-1 keeps the cost) and plus_text.
  • exhaust, unplayable and art.

Enemy rows have these fields:

  • hp_min and hp_max, and moves. Each move has an intent shown to the player, a weight and its effects.
  • pattern: random by weight, never the same move three times running; or cycle, in order.
  • first_move, and starts_with for the statuses it starts with.

Relic rows have a trigger (combat_start, first_turn, turn_start, combat_end, attack_played or pickup) and effects, or extra energy each turn.

Encounter rows name a pool (easy, normal, elite or boss) and their enemies.

  • The map has twelve floors in seven lanes, built from four paths that merge. Row 0 is all fights, row 6 is treasure, row 10 is rest and row 11 is the boss. There are no elites or rest sites before row 4.
  • Fights start with 3 energy and a 5-card draw.
  • After a fight: gold and a choice of three cards. An elite also gives a relic.
  • Rest sites heal 30% of max HP, or upgrade a card.
  • Shops sell five cards and a relic, and remove a card for 75 gold.
  • The start: 70 HP, 99 gold, five Strikes, four Defends, a Bash and the Ember Heart, which heals 6 after each fight.
CallDoes
Cards.new_run(e, seed)a fresh run (seed 0 picks one)
Cards.choose(e, node)go to a room on the map
Cards.play(e, uid, target)play a card from the hand, at an enemy (its index; -1 for none)
Cards.end_turn(e)end the turn: the enemies act, and the next turn begins
Cards.pick(e, index)take reward card index, or -1 to skip it
Cards.rest(e, choice, uid)heal, or upgrade card uid
Cards.buy(e, kind, index)card index, relic, or remove card uid
Cards.leave(e)leave a shop or treasure room
Cards.auto(e, on, delay)the bot plays, one action every delay seconds; it starts a new run when one ends

Requests are carried out in the next package step, in order. A refused one (a card not in hand, not enough energy) sets CardsState.error and changes nothing.

CardsState is rewritten when anything changes, and its version goes up. It has these fields:

  • phase: map, combat, reward, rest, shop, treasure, victory or defeat.
  • floor, hp, max_hp, gold, relics, deck, seed, error and load_error.
  • map: the rooms, each with id, row, col, kind, next, reachable and visited.
  • combat:
    • turn, energy and max_energy
    • player (hp, block, statuses)
    • hand, and the pile sizes draw, discard and exhausted
    • enemies, each with hp, block, statuses and an intent (kind, name, amount, times)
    • incoming
  • reward, shop, rest and treasure, in their phases.

A card as shown has these fields:

  • uid, id, name, cost, type, rarity, target and art
  • text, with its numbers as they would land now
  • playable, and upgrade (its smithed name, cost and text, for a preview)

CardsEvents.events lists what happened this frame, in order:

  • in a fight: play, draw, damage (from, to, amount, blocked), block, status, heal, hp_loss, death, turn, enemy_move, shuffle, exhaust, add_card, energy, victory and defeat
  • in a run: enter, relic, relic_gained, card_gained, upgraded, bought, removed, rested and run_over

An enemy turn resolves all at once, so a screen that wants it to read one step at a time spaces the events out itself. Ashen Deck does this.

  • src/combat.rs:
    • damage and block modifiers
    • a turn: energy, a play, the enemy’s hit through block, the next intent and a fresh hand
    • poison through block, and thorns
  • tests/balance.rs: the arcade’s real tables.
    • Every reference resolves, and the text placeholders are known.
    • The bot plays 40 runs and must win between a quarter and four fifths of them. Today it wins about half.
    • The same seed plays the same run.
  • tests/deck_scene.rs: the arcade’s scene.
    • A run driven by commands visits every kind of room and reports every kind of fight event.
    • A refused play sets error and spends nothing; a real one hits.
  • tests/test_lab_scene.rs: scene 79, on the lab’s own tables.