Skip to content

A flexible engine for creating card games (Pokemon, Magic, etc) where the behavior of each card can be completely arbitrary.

Notifications You must be signed in to change notification settings

ChrisVilches/Card-Game-Kernel

Repository files navigation

Card Game Kernel

Introduction

A flexible engine for creating card games (Pokemon, Magic, etc) where the behavior of each card can be completely arbitrary.

The problem

Card games like Pokemon, Magic, Mitos y Leyendas, etc. are very difficult to code because every card can have any arbitrary behavior designed to be understood verbally by a human being (and therefore difficult to understand for computers).

The solution

This little software contains a framework where it's possible to design and code nearly any card game you can imagine. Some of the features:

  • Cards have events, which are the main mechanism of communicating between cards, and achieving the desired behavior. Events can be triggered from many places, and a lot of customization is possible.
  • Cards are divided into containers, which is a generic way of dividing a deck into your hand, the opponent's hand, disposed cards, etc. Each card of the entire set of cards present in the game fall in one container.
  • Containers can be nested, and they can be found by their path, e.g. [:player1, :hand] or [:player2, :cemetery]
  • It manages data and state globally using Redux (or a similar data store).
  • Attributes can be added dynamically to cards.
  • Events can have pre and post hooks so you can control the execution with detail.
  • Generic, unopinionated solution.
  • It can also be used for games that require a similar mechanism, like Final Fantasy or Pokemon battle systems, which require to implement many techniques and movements which behavior differs significantly from each other and there's no standard reusable model.

This is a very low level software, and therefore in order to work with it, further layers of abstraction must be written by the developer. This engine doesn't even force you to implement the concept of turns or deck. You simply have a few data structures that communicate with each other, and you must write the rest of the logic yourself.

Since this software is a low level framework, one class has to be created for each different card (usually by inheriting from the base Card class). In order to make it easier to work with, a wrapper can be written in a similar way ORMs like Hibernate or ActiveRecord help developers work easily with databases. However, this project won't include a wrapper, since the goal is to keep it low level.

Proof of concept

In order to prove this software can achieve a flexible way of developing any card game, where cards can have any imaginable behavior, a few proof of concept test cases were developed. Some of these were coded as tests and can be found in the following link.

Code of some of these examples

When card A is present, prevent the opponent from using cards of type B

Each card has a transfer event that executes whenever a card is transferred from one card container to another. In this case, the first container, and the next container could for instance be the attacking line, where you place all the cards that are going to attack the opponent.

Once the card is transferred from the first container to the next, the transfer event is executed, and then optionally validate that the card is in the correct container, and then setup a pre hook for the transfer event where you only focus on the opponent's containers. In this case, this pre hook will return false in case the opponent is trying to transfer a card from one container to another (for example again, from his hand to his attacking line) and therefore achieving the objective.

Once the card that originated the pre hook is transferred away, the hook may be eliminated.

Increment a card's counter every turn

First, no card has any default counter attribute, so it has to be added dynamically. Then create a new_turn event, which when triggered, a pre hook will increment the counter inside the card.

There are many ways to trigger the event only for the card you want. One way is to setup the hook globally, and then execute it for all cards inside a container, or also it's possible to setup a hook only for one card.

Attack the opponent's card

Make a card respond to a receive_attack event. Since triggered events can also have arguments, a damage attribute can also be included.

This event can be triggered from many places, and it depends on how you want to establish your application's logic.

Bonus: If you include the attacking card reference as part of the event arguments, you'll have access to that card from the card that receives damage, so you could also create something along the lines of:

  • If A receives damage by B, it will counterattack with 10% of the total damage.
  • If A dies (HP=0) while being attacked by B, B also dies (by triggering one of B's events).
  • More.

Pushing a state that makes the application change its course (must be handled by the application logic)

Each turn has several stages, stage1, stage2, stage3, and so on. Suddenly, something happens and a card has an event triggered, and within the event handler, it pushes a new state onto the history stack. This state makes the game change its course, and one of the players is now forced to withdraw a card from his deck. The game will only continue once he's done with this.

There's a global data store (Redux is encouraged, although any can be used as long as it supplies the correct interface), and this can store any kind of data you want. In this case we need a state stack.

Since cards can communicate with the global data, a card can directly push a new state onto the stack. The next part consists of handling each state by applying user defined logic, and this can be done from outside this framework. In other words, the user must use this as a library and build on top of it by adding logic. If we change the state from stage3 to choosing_card, it's the duty of the user to implement, let's say, a GUI menu that shows every possible card to pick, and when it's done, pop the state and go back to stage3.

If the card that triggered this state change wants to limit or filter out some cards (for example, choosing cards that are stronger than 120 is prohibited), we can also use the global data store and set a predicate (lambda function) there, so it can be accessed and used from outside. Just make sure the application logic knows about that predicate, so it can find and use it.

API Example

Global data store

Let's create a global data store using the Rydux gem. You can create your own data store as long as it implements the methods defined in the DataStore class.

class PlayerReducer < Rydux::Reducer
  def self.map_state(action, state = { gold: 0, level: 1, bonus: 0 })
    case action[:type]
    when :increment_gold
      gold = state[:gold]
      state.merge(gold: gold + 1)
    when :decrement_gold
      gold = state[:gold]
      state.merge(gold: gold - 1)
    when :level_up
      level = state[:level]
      bonus = state[:bonus]
      bonus = bonus + 1 if level % 10 == 0 # Bonus +1 each time it goes 10 levels up
      state.merge(level: level + 1, bonus: bonus)
    else
      state
    end
  end
end

# And then

class MyDataStorage < DataStore

  def initialize
    @store = Rydux::Store.new(player1: PlayerReducer, player2: PlayerReducer)
  end

  def set_data(action:, arguments: {})
    @store.dispatch(type: action, payload: arguments)
    get_data_lambda = lambda { Store.state }
  end

  def get_data
    {
      player1: @store.player1,
      player2: @store.player2
    }    
  end
end

Containers

Here we'll make some card containers. All the cards in the game are separated into containers, and these can be nested.

kernel = CardKernel.new

kernel.create_container [:player1]
kernel.create_container [:player1, :hand]
kernel.create_container [:player1, :deck]

kernel.create_container [:player2]
kernel.create_container [:player2, :hand]
kernel.create_container [:player2, :deck]

kernel.create_container [:shared_cards]

Let's now add some cards to the containers.

container = kernel.create_container [:player1, :hand]

# Use the data storage class we created before

my_data = MyDataStorage.new

container.add_card(Card.new(id: 1, data_store: my_data))
container.add_card(Card.new(id: 2, data_store: my_data))
container.add_card(Card.new(id: 3, data_store: my_data))

Cards

The Card class can be overridden and you can define detailed behavior by registering events (and its handlers) to which this class of cards will react to.

Here we create a type of card that can receive damage.

class AttackerCard < Card

  def initialize(id:)
    super(id: id)

    # This card has a custom attribute, health points (HP)
    set_attributes({ hp: 100 })

    # Event handler for when it receives an attack.
    # We register the event by its name, and then define an event handler for when it's triggered.
    on(:receive_attack, lambda { |args|

      # Decrement its health points (HP)
      current_hp = self.attributes[:hp]
      set_attributes({ hp: current_hp - args[:damage] })

      # Counter attack by triggering the same event on the card that attacked first
      if args.has_key?(:can_counterattack) && args[:can_counterattack] == true
        args[:attacker_card].trigger_event(event: :receive_attack,
          arguments: {
            damage: 3,
            can_counterattack: false # If this is true, it'd become an infinite loop
          })
      end

      return {
        current_hp: self.attributes[:hp]
      }
    })

  end
end

Of course instances of this newly created class can also be added to containers.

Hooks

Hooks are functions that execute before and after the main event handler. This allows you to control more precisely what happens before and after an event. You can do things like blocking an event from happening, or doing some pre-processing logic that will change the way the main event handler behaves.

Hooks can be configured at a global scope, and a per card scope. Once an event is triggered, the order in which these execute is as follows.

global pre hook → card pre hook → main event handler → global post hook → card post hook

Creating a global hook. This hook will be executed for each card that has the transfer event triggered. Since it was registered using the pre symbol, it will execute before the main event handler for transfer. Also note that transfer is a special event that executes automatically (no need to execute card.trigger_event(...)) when a card is moved from one container to another.

global_hooks = GlobalHooks.new
card = Card.new(id: 1, global_hooks: global_hooks)

lambda_hook = lambda { |args|

  # The transfer event will contain the "prev_container" and the "next_container" attributes in its argument object.
  # "prev_container" won't be included if it's the first time the card is put into a container.
  # The "card" attribute will be the card where the hooks are executing.
  if (!args[:prev_container].nil? && args[:prev_container].id == [:b]) && args[:next_container].id == [:b, :c] && args[:card].type == :my_type
    return false
  end

  return true
}

# "card_owner_id" is optional, as some global hooks don't need to be associated with any card in particular.
global_hooks.append_hook(:pre, event_name: :transfer, fn: lambda_hook, card_owner_id: 1)

Creating a card scoped hook. Similarly, you can register a lambda function as a pre hook inside a card. It will execute only for instances of this card class.

class PreHookCard < Card
  def initialize(id:)
    super(id: id)
    @pre[:transfer] = lambda { |args_|
      # ...
    }
  end
end

Triggering events

Trigger an event for one card.

card_instance.trigger_event(event: :event_name_goes_here, arguments: { arg1: 0, arg2: "hoge", arg3: "piyo" })

Trigger an event for all cards in a container.

container_instance.trigger_event(event: :event_name_goes_here, arguments: { arg1: 0, arg2: "hoge", arg3: "piyo" }, recursive: false)

Note the recursive argument when triggering an event in a container. If it's false, it will only trigger the event for all cards inside that container, but will not trigger it for the cards inside the nested containers. If it's true, the event will be triggered for every card in the container and all cards in every container nested to it.

Install

Install gems using the following command.

bundle

Tests

Tests can be found at the spec folder, and can be run all at once by executing the following command (it needs to gem install rspec first).

rspec spec/

About

A flexible engine for creating card games (Pokemon, Magic, etc) where the behavior of each card can be completely arbitrary.

Topics

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published

Languages