TNW Part 13 - Scripted Prototype Card Generation

This is going to be a bit of a nerdier post in which I talk about scripting the generation of prototype cards for play testing. In short, the game has a lot of cards - currently 92. Writing out, or even typing out abilities and actions and values and costs for all of them individually takes a long time. Laying them all out for printing takes a long time. I decided to try and automate as much of it as I could.

My first thought was that I could just create JSON files of all of the card data and from there, I could put it wherever I wanted to with a little code - onto a card, onto a website, etc. So that's where I started.

TNW has several card types, but I'm going to demonstrate with the Planet card type, since it's the most complex.

Here's the sample JSON format I came up with:

{
  "name": "Tarokk",
  "cost": 5,
  "technology": {
    "name": "Star Drive",
    "cost": 2,
    "description": [
      "Reduce the card cost to",
      "resolve a Nebula sector by 1."
    ]
  },
  "flavor_text": false,
  "resources": 3,
  "cache": 1
}

There are a couple important things to note about this file. First u p, planets can have either flavor text or a technology (but not both). So if technology exists, I set the flavor text to false. I could have left it out entirely, but I decided to leave it as a false value in order to remind myself of the card file's syntax when editing later.

The other important thing to note is that the technology description is broken into an array of lines. This is annoyingly necessary because I don't have a good way of programmatically generating vector graphics that actually line-wraps properly.

So as you can see, there's a little work in the initial card setup, but going forward, editing the card is as simple as opening the file, updating the value, and re-running the script.

Speaking of which, let's talk about the script.

To turn this into a prototype planet card, I write a Ruby script using the Rasem gem. Rasem is a couple of years old and looks like it's probably abandoned. It's also barely documented, but it worked fine for my purposes.

Require gems and define a function:

require 'rasem'
require 'json'

def planet_cards(input_dir, output_dir)
end

I didn't want to have to pass in cards one at a time, so the function accepts an entire directory and will iterate over all cards in it, spitting them out into an output directory.

So, moving on, let's do that:

def planet_cards(input_dir, output_dir)
  c = 1
  Dir.glob("#{input_dir}/*.json") do |card|
    file = File.read card
    data = JSON.parse(file, symbolize_names: true)

    c += 1
  end
end

Here we're using Dir.glob to iterate over all JSON files in the directory. We've also set up a counter that we'll increment and use for file naming later.

Next we'll start setting up to draw the card with Rasem and output the final SVG file to our output directory:

img = Rasem::SVGImage.new(:width=>375, :height=>525) do
  # Rasem drawing code goes here.
end
File.open("#{output_dir}/planet-#{c}.svg", "w") do |f|
  img.write(f)
end

Let's draw the structure of the card:

# card outline
rectangle 0, 0, 375, 525, stroke_width: 1, fill: "white"

# top separator line for heading
line 0, 70, 375, 70

# body separator line for tect/flavor text
line 0, 300, 375, 300

# circle that will  hold the card cost
circle 55, 55, 45, stroke_width: 2, fill: "white"

# bottom icons for resource cost.
rectangle 0, 475, 50, 50, stroke_width: 2, fill: "green"

# Make a randomly sized light-grey circle somewhere near the
# center of the artwork area to represent the planet 
# (completely optional for the prototype, but fun). 
circle rand(140..240), rand(150..200), rand(20..70), stroke_width: 2, 
fill: "#dddddd"

With the basic structure of our card created, it's time to start adding text and icons. I basically just started from the top of the card and worked my way down. Note that in the code below, I had to specify the font family with the with_style block, because I couldn't put it on the text elements themselves. The SVG spec seems to indicate that I should be able to, but Rasem doesn't allow it. I'm not sure if this is because it's not yet implemented in the code, or with me not understanding the spec. Both seem equally likely.

with_style :"font-family" => "Helvetica" do
  # Card Title
  text(233, 45, :fill => "black", :"font-size" => 30, :"font-weight" => "bold", :"text-anchor" => "middle") { raw data[:name] }
end

with_style :"font-family" => "Helvetica" do
  # Resource Cost
  text(55, 75, :fill => "black", :"font-size" => 60, :"font-weight" => "bold", :"text-anchor" => "middle") { raw data[:cost] }

  # Resource Generation Amount
  text(25, 510, :fill => "white", :"font-size" => 30, :"font-weight" => "bold", :"text-anchor" => "middle") { raw data[:resources] }
end

I'm sure I could have put other styles in the with_style block, such as font-weight and text-anchor and made this a little more DRY, but specifying fonts as an after-thought so I just left the existing code alone.

Now on to resource caches (not all planet cards have them) and technology and flavor text.

This is just more of the same, really. Positioning on all of these elements was a bit of basic math and trial and error. Generating the SVG files takes no time, so it's easy to do a lot of testing and small tweaks. Don't over-complicate this.

if data[:cache]
  rectangle 325, 475, 50, 50, stroke_width: 2, fill: "#840198"
  with_style :"font-family" => "Helvetica" do
    text(335, 508, :fill => "white", :"font-size" => 24, :"font-weight" => "bold") { raw "#{data[:cache]} ■" }
  end
end

if data[:technology]
  defs do
    y_offset = 54
    group(:id => "technology") do
      with_style :"font-family" => "Times New Roman" do
        text(150, 0, :fill => "black", :"font-size" => 20, :"font-weight" => "bold", :"text-anchor" => "middle") { raw data[:technology][:name] }
      end
      with_style :"font-family" => "Helvetica" do
        text(150, 24, :fill => "black", :"font-size" => 20, :"font-style" => "italic", :"text-anchor" => "middle") { raw "Pay #{data[:technology][:cost]} ■"}
      end
      data[:technology][:description].each do |line|
        with_style :"font-family" => "Times New Roman" do
          text(150, y_offset, :fill => "black", :"font-size" => 20, :"text-anchor" => "middle") { raw line }
        end
        y_offset += 24
      end
    end
  end
  use "technology", x: 40, y: 340
else
  defs do
    y_offset = 0
    group(:id => "flavor-text") do
      data[:flavor_text].each do |line|
        with_style :"font-family" => "Times New Roman" do
          text(150, y_offset, :fill => "black", :"font-size" => 20, :"font-style" => "italic", :"text-anchor" => "middle") { raw line }
        end
        y_offset += 24
      end
    end
  end
  use "flavor-text", x: 40, y: 340
end

The main takeaway here is the use of groups, which makes the SVG files a little easier to edit later in something like Illustrator. You can also set the position of elements relative to 0,0 for the top left corner of the group. Then you can position the group anywhere you'd like it.

For some things, like text placement, this makes positioning a little easier, because you can move the group as a whole. As you'll see, when I call use "flavor-text" for instance, I set the entire group's x,y coordinates to 40,340 on the card.

Finally, I'm just going to add a card number at the bottom so I can reference it easily when I'm making notes and future edits.

with_style :"font-family" => "Helvetica" do
  text(187, 510, :fill => "black", :"font-size" => 14, :"text-anchor" => "middle") { raw "p-#{c}" }
end

That's it! Here it is all stitched together:

def planet_cards(input_dir, output_dir)
  c = 1
  Dir.glob("#{input_dir}/*.json") do |card|
    file = File.read card
    data = JSON.parse(file, symbolize_names: true)

    img = Rasem::SVGImage.new(:width=>375, :height=>525) do
      rectangle 0, 0, 375, 525, stroke_width: 1, fill: "white"
      line 0, 70, 375, 70
      line 0, 300, 375, 300
      circle 55, 55, 45, stroke_width: 2, fill: "white"
      rectangle 0, 475, 50, 50, stroke_width: 2, fill: "green"
      rectangle 0, 0, 375, 525, stroke_width: 2, fill: "none"
      circle rand(140..240), rand(150..200), rand(20..70), stroke_width: 2, fill: "#dddddd"

      with_style :"font-family" => "Helvetica" do
        text(233, 45, :fill => "black", :"font-size" => 30, :"font-weight" => "bold", :"text-anchor" => "middle") { raw data[:name] }
      end
      with_style :"font-family" => "Helvetica" do
        text(55, 75, :fill => "black", :"font-size" => 60, :"font-weight" => "bold", :"text-anchor" => "middle") { raw data[:cost] }
        text(25, 510, :fill => "white", :"font-size" => 30, :"font-weight" => "bold", :"text-anchor" => "middle") { raw data[:resources] }
      end

      if data[:cache]
        rectangle 325, 475, 50, 50, stroke_width: 2, fill: "#840198"
        with_style :"font-family" => "Helvetica" do
          text(335, 508, :fill => "white", :"font-size" => 24, :"font-weight" => "bold") { raw "#{data[:cache]} ■" }
        end
      end

      if data[:technology]
        defs do
          y_offset = 54
          group(:id => "technology") do
            with_style :"font-family" => "Times New Roman" do
              text(150, 0, :fill => "black", :"font-size" => 20, :"font-weight" => "bold", :"text-anchor" => "middle") { raw data[:technology][:name] }
            end
            with_style :"font-family" => "Helvetica" do
              text(150, 24, :fill => "black", :"font-size" => 20, :"font-style" => "italic", :"text-anchor" => "middle") { raw "Pay #{data[:technology][:cost]} ■"}
            end
            data[:technology][:description].each do |line|
              with_style :"font-family" => "Times New Roman" do
                text(150, y_offset, :fill => "black", :"font-size" => 20, :"text-anchor" => "middle") { raw line }
              end
              y_offset += 24
            end
          end
        end
        use "technology", x: 40, y: 340
      else
        defs do
          y_offset = 0
          group(:id => "flavor-text") do
            data[:flavor_text].each do |line|
              with_style :"font-family" => "Times New Roman" do
                text(150, y_offset, :fill => "black", :"font-size" => 20, :"font-style" => "italic", :"text-anchor" => "middle") { raw line }
              end
              y_offset += 24
            end
          end
        end
        use "flavor-text", x: 40, y: 340
      end
      with_style :"font-family" => "Helvetica" do
        text(187, 510, :fill => "black", :"font-size" => 14, :"text-anchor" => "middle") { raw "p-#{c}" }
      end
    end
    File.open("#{output_dir}/planet-#{c}.svg", "w") do |f|
      img.write(f)
    end
    c += 1
  end
end

I put this inside an rb file and called the function at the end, passing in the proper directories. I added in similar functions for the other card types, and now I can generate all 92 cards with one command.

But wait, there's more!

That was cool and all, but then I had all of these separate cards and I realized I needed to put them together somehow for easy printing.

Here's the thing, though: These SVG files are eventually going to become bitmap images, so converting them to a PNG or JPG format will happen at some point. Doing that from the command line with ImageMagick or inside Ruby with Rmagick was totally possible (I know, I did it), but the text rendering wasn't that great - even with the installation of fonts for ImageMagick, there was some escaping of characters happening, etc. I could have continued to debug and work though that, but I found it much easier to just use Photoshop's batch image processing to get all of my SVG images turned into high-resolution, high quality JPG files. The quality is fine for prototyping.

So I did that! After I had a directory full of JPG images, grouping them into pages, 6 per sheet, was simple.

Read the files from the JPG directory the same way we read input files above, chunk the resulting array into groups of 6, flatten those arrays into a string of file names, plop them into a command line ImageMagick command and execute it from within the Ruby shell script.

require 'rmagick'
require 'fileutils'

files = []
Dir.glob("output/JPEG/*.jpg") { |file|
  files << file
}

files.sort!

i=1
files.each_slice(6) do |fs|
  fs.sort!
  `montage -rotate 90 -density 150x150 -units PixelsPerInch -mode concatenate -tile 2x3 -resize 525x375 #{fs.join(' ')} output/sheets/sheet-#{i}.jpg`
  i += 1
end

In the ImageMagick montage we add flags to set the overall image size, its pixel density, rows/columns for tiling, and the size we want our tiles to be.

So now my workflow for generating cards is as follows:

  1. Update JSON data for cards as-needed.
  2. Run generator.rb to make the 92 card files.
  3. Drop them all into the batch image processing script in Photoshop, set the output directory, leave everything else at its default values, and click 'OK'
  4. Run pagify.rb to make 6-card sheets for printing.

Editing aside, the entire process from zero to 92 print-ready cards on 16 sheets takes about 1 minute.

I'll call that a win.

Hey! I'm designing a board game.

I'm working on a lightweight sci-fi area control game for 2-4 players called These New Worlds. Follow the link below to learn more.

I Want Updates & Stuff!