I need a hex map


My party of adventurers are sailing to the Lemurian Remanant as part of a quest imposed upon some of them so they could heal from the damage of an automaton. Think the some what misinterpreted way a clay golem in 1e AD&D does damage. In this case, in Hyperborea that interpretation is clear.

In any case, in their last session they fended off many attacks. From giant octopuses, giant squids, and a bunch of great white sharks. As it so happens, a shark was able to take a damaging bite out of their ship.

They had already planned to stop at an island on the charts to prepare for the crossing of a nasty part of the open sea, the River Okeanos. With the damaged ship, the stop has become imperative.

I was mostly going to handwave the stop for the most part. Now, I kinda want to plop down something and see if it I can distract them.

First, I needed to make a map of the island and I think it needed to be hexes since it's "wilderness".

That means two things:

  1. Inkscape
  2. Pelle Nilsson's hex map extension

So, once you have Inkscape installed as well as the extension you are head to make some hexmaps to your heart's content.

First I created a page full of 2.5" (flat side to flat side) hexes on US letter sized paper. In the Style table I have Rotate option checked. This have the hexes "pointy end" towards the top of the page.

screen capture of hexmap inkscape extension and a page of hexes

These hexes represent 25 miles per hex. For a bit finer detail I then wanted to add in some 5 mile hexes. I partly chose the 2.5" hex partly because it's easily divisible by 5. I also created a new layer so that each new hex size are independent from each other and I change the properties of them.

You will need to try and undo the number of columns and rows so that the hexes cover the page just enough.

screen capture of hexmap inkscape extension and a page of with smaller hexes

I had considered a third layer of .1 in hexes but that creates a lot of svg objects and it can bog down your computer a bit when you need to change properties or even group them together.

Once I had this base map I added the rough shape of the island from the Hyperborea map along with what I am interpreting to be breaks around it. Reefs or other navigation hazards for large ships.

I wasn't not satisfied with how the map was turning it.

I finally decided what I needed for the map the players will see is one large hex with smaller hexes inside that. The big hex being 25 miles and the smaller hexes being 5 miles.

So I created a monstrous 8" hex, 1.6" hexes inside that, and .12" hexes inside those. Yeah, I did just say that those are a pain and resource drain. However, I only made enough to just fit the big hex.

I then meticulously deleted everything outside the monster hex (uploaded as slightly different version since someone noted some weirdness with it and we'll see if this helps --edit 2020-09-22).

screen capture of hexmap with 1 large hex, many smaller hexes inside that, and even smaller hexes inside those

I changed the shading/"grayness" of the lines for each hex type. You do this under the "Fill and Stroke" submenu under the "Object" menu. The big, 25 mile hex is pure black: 255. The 5 mile hexes are a dark grey: 100. Finally the 1 mile hexes are a light grey: 25.

So, being a lazy DM I started looking through my various old school things I've bought over the years.

I reached for the Echoes from Fomalhaut(EFF) published by E.M.D.T. First Hungarian D20 Society. Gabor Lux is the force of nature behind this excellent zine. You can get pdf's from Drivethrurpg. However, do yourself a favor and get the paper version mailed to you from Hungary. When maps are provided with the zine the paper is top quality. Seriously, I like the paper used for these maps so much. You should also get the pdf version with your purchase (I will update this post if that is not the case).

I had thought about The Beekeeper from EFF #01 but kept scanning through the rest.

Terror from Tridentfish Island in EFF #03 really filled my need. It even has the same vibe and even some world hooks that work great in Hyperborea. I just need to smudge it a little and munge things to work with where it needed to go.

A little work later and I have something I can use. This is only the geography of the island. I can't show off the fun bits. You gotta get Gabor's excellent work to find out what my party might find on the island if they go exploring it instead of attending to the needed repairs.

hexmap of the tridentfish island geography mangled to fix the island shape

Thanks to Inkscape to making a tool that I'm capable of making a basic tool of the DM trade.

Thanks to Pelle Nilsson for making an extension to Inkscape so I can make hexmaps to my specifications.

Thanks to Gabor Lux for continually make some of the best old school content out there. When he posts he has something new to buy I don't think twice about rushing to purchase it.

Hyperborea: Amazon Carrack


TL;DR: I need a deck plan for a ship. I didn't quite like what I found so I made my own.

So, my party decided to purchase a sailing vessel and hire a crew.

So I went searching on the web and asking on the Hyperborea Forums forums as well.

Serendipitously I have been watching a YouTube channel of a guy restoring an old, wooden sailing vessel. In a few episodes he goes about explaining how to read the prints for a ship. It did help a bit with this project. I didn't go too deep into an accurate deck plan. I wanted something good enough to work but also some semblance of "correct".

So, this is what I came up with.

scalar vector graphic of an amazon carrack deck plan

Amazon Carrack Deck Plan Key

  1. Forecastle
  2. Aftcastle or Sterncastle
  3. Galley
  4. Officers Cabin
  5. Captain's Cabin
  6. Passenger Cabin
  7. Passenger Cabin
  8. Officers Cabin
  9. Crew Quarters
  10. Crew Quarters
  11. Passenger Cabin
  12. Passenger Cabin
  13. Passenger Cabin
  14. Passenger Cabin
  15. Passenger Cabin
  16. Passenger Cabin
  17. Crew Quarters
  18. Cargo Deck
  19. Steerage Deck

So this is nice and all, but I needed something that I think is better to use in my campaign which is on Roll20.

The instructions are for roll20. I have a license for fantasy grounds but I haven't really used it. So, if you are a FG user you'll probably figure it out before I do.

Most of my maps on roll20 are 25x25 squares of 70pixels.

After uploading it I put it in the map layer.

I right click on the image, select Advanced -> Set Dimensions.

Set the dimensions given for the image (see below).

Right cick on image, Advanced -> Align to Grid

Click and drag a square, it doesn't really matter how big.

Then select the grid cell size to 35x35.

This should then align the grid of the deck to the grid of the vtt map.

Also, each grid on the deck plan is 5 feet, which is what I usually use for my games.

First, a mashup of the castles and main deck. The practical surface area for combat.

This map is 700x210 pixels.

image of deckplan to use for combat with a virtual table top campaign

This map is the main deck. It is 665x210 pixels.

image of main deckplan to use with a virtual table top campaign

This map is the lower deck. It is 665x175 pixels.

image of lower deckplan to use with a virtual table top campaign

I hope it's useful for those needing a ship in whatever system you use.

I might update this post with some screenshots of exactly what I do in Roll20 but this is what I have time for at the moment.

Happy gaming everyone and remember Rule 0: Have fun.

Automating Treasure Table Rolls


One of the first things I automated via a program was determining results on the treasure table.

There is a fair about of rolling to determine treasure. There is also several sub-tables that you could possibly have to roll on, multiple times.

Here is an excerpt of the treasure table from Hyperborea

screenshot of astonishing swordsmen and sorcerors of hyperborea treasure table

Let's use Treasure Class A as an example. This is probably the most rolling you have to do. Determining coin results are easy. Things get more tedious if there are sub-tables. Gems, Jewelry, and Magic have plenty of sub-tables to handle.

Here is the gems table.

screenshot of  astonishing swordsmen and sorcerors of hyperborea gems table

You can even do more rolling if you get the correct result.

screenshot of  astonishing swordsmen and sorcerors of hyperborea gems table

Jewellery has at least 2 sub-tables to roll on. The kind of jewellery.

screen shot of astonishing swordsmen and sorcerors of hyperborea jewellery table

And what it is made of and it's worth.

screen shot of astonishing swordsmen and sorcerors of hyperborea jewellery table 2

This is just the beginning for magic items. A veritable rabbit hole to go down.

screenshot of of  astonishing swordsmen and sorcerors of hyperborea magic items table

It can end after a couple tables.

screenshot of astonishing swordsmen and sorcerors of hyperborea wands table

Eventually you get to the far end of that tree and have to come back for more rolls as needed.

Thankfully, the DM for a game I was in, and Perl Pumpking for some time, also seemed to have a thing to automate tables.

There are lots of tables that can be referenced by a DM. So Rik Signes created Roland to help with automating the rolling on them


Go ahead, give it a read. Rik does a good job explaining Roland. This post will be here waiting for you when you are done.

screenshot of roland blog post

It's not available on CPAN just yet, but I'm working on him. He's says it's bad software but I think it works just fine.

screenshot of roland github repo

Thankfully Roland can make all that rolling and referencing automated. That is assuming you are comfortable with the level of entropy and randomness a pseudo random number generator can give you

Really, go read Rik's blog post and then come back here when you are done.

Here is an example of the output of the program I created.

$ ./roll_treasure a
3500 cp
8000 gp
50 pp
  10,000 gp piece of jewelry
  5,000 gp piece of jewelry
  100 gp piece of jewelry
  750 gp piece of jewelry
  1,000 gp piece of jewelry
  200 gp piece of jewelry
  100 gp piece of jewelry
  100 gp piece of jewelry
  200 gp piece of jewelry
  100 gp piece of jewelry
  500 gp piece of jewelry
  200 gp piece of jewelry
  200 gp piece of jewelry
  200 gp piece of jewelry
  Staff of Withering
  Potion of Delusion (cursed)
  Quarterstaff +2

The program itself, when stripped down, is really quite simple.

my @rolls = @ARGV;
for my $roll ( @rolls ) {
  $roll =~ s/,| +|[()]//g;
  $roll = lcfirst $roll;
  my $count = 1;
  if ( $roll =~ m/\wx\w/ ) {
    ($roll, $count) = split('x', $roll);
  my $path;
  my $roll_length = length($roll);
  if ( 1 == $roll_length ) {
    $path = qq(treasure/class_$roll);
  else {
    $path = qq(treasure/$roll);
  for (1 .. $count ) {
    my $result = qx(~/bin/roland $path);
    say $result;

There is however data entry that must be done.

Each Treasure Class needs a table.

# treasure/class_a
- { file: treasure/a/cp }
- { file: treasure/a/sp }
- { file: treasure/a/ep }
- { file: treasure/a/gp }
- { file: treasure/a/pp }
- { file: treasure/a/gems }
- { file: treasure/a/jewelry }
- { file: treasure/a/magic }

Then each file

# treasure/a/cp
dice: 1d100
    dice: 2d6
      2: 1000 cp
      3: 1500 cp
      4: 2000 cp
      5: 2500 cp
      6: 3000 cp
      7: 3500 cp
      8: 4000 cp
      9: 4500 cp
      10: 5000 cp
      11: 5500 cp
      12: 6000 cp
      26-100: ~

Snippet of the gems table.

93-94: "Violet garnet, [[1d20x50]]gp"
  dice: 1d100
    1-50: "Emerald, [[1d20x100]]gp"
    51-100: "Fire opal, [[1d20x100]]gp"
  dice: 1d100
    1-50: "Opal, [[1d20x100]]gp"
    51-100: "Oriental amethyst, [[1d20x100]]gp"
  dice: 1d100
    1-50: "Oriental topaz, [[1d20x100]]gp"
    51-100: "Sapphire, [[1d20x100]]gp"
  dice: 1d100
    1-50: "Star ruby, [[1d20x100]]gp"
    51-100: "Star sapphire, [[1d20x100]]gp"

Here is the jewelry table

# treasury/jewelry
- { file: treasure/jewelry_type }
- { file: treasure/jewelry_value }

Jewelry type table snippet

# treasury/jewelry_type
dice: 1d20
  1: anklet, no weight
  2: armband, 1 lb
  3: bracelet, no weight
  4: bracer, 1 lb
  5: broach, no weight
  6: chain, 1 lb
  7: circlet, no weight
  8: comb, no weight
  9: crown, 4 lb

Jewelry value table snippet

# treasury/jewelry
dice: 1d100
    dice: 1d12
        - "  made of pewter, worth 1gp"
        - "  made of bronze, worth 1gp"
        - "  made of copper, worth 1gp"
        - "  made of silver, worth 1gp"

What the application runs like on the command line.

$ ./roll_treasure gems
Carnelian, 90gp

$ ./roll_treasure jewelry
crown, 4 lb
  made of electrum, worth 1,000gp

$ ./roll_treasure jewelry
armband, 1 lb
  made of bronze, worth 3gp

$ ./roll_treasure jewelry
mask, 1 lb
    made of copper set with gems, worth 400gp
  Deep blue spinel, 700gp

And that is basically it.

Data Mining Gaming Conventions for Fun and No Profit


My Assumptions

I am going to assume fluency with Perl in this post, along with and understanding of YAML, JSON, and CSV.


I've been going to Gary Con for many years now. It has had several event submission and registration systems since I started going.

In 2017 the convention started using TableTop Events(TTE) for event management. With that came along an API. With an API came a chance for automation and curiosity.

TTE has a decent event search mechanism along with the ability to download a CSV (comma separated values) file of the events.


It has a well documented and expansive API

The API is one way to grab more data than is listed in the event search and CSV file.

There are some example clients that show how one could use the API to gather various convention information.

I'm a sysadmin steeped in Perl so that is what I wrote my example in, but there are example clients writing in Javascript.

There is as a test api for your client if you write your own.

ID please

Now we need to find the conventions ID in TTE.

At the top of the page is a link to Conventions. That page should upcoming conventions as well as a search for conventions.

At the time of this writing Con of Champions is in the event submission phase of things. This one is rather special since it's geared towards helping raise money to keep TTE going. Covid-19 is causing also sorts of financial problems for businesses. Hopefully it can raise enough money to keep JT and crew operational until the pandemic is over along with the dealing any lingering economic damage.

So do a search for Con of Champions.

Copy the link. Now, I'm using Linux so this is relatively simple. Go to the command line and run:

curl  https://tabletop.events/conventions/con-of-champions 2>1 | grep "/api/convention"

This should return something like:

fetch_api : '/api/convention/105930EE-713D-11EA-AB57-B86D1B681414',
list_api : '/api/convention/105930EE-713D-11EA-AB57-B86D1B681414/event-days',

Event Data

Now that we have a con id we can use the browser to see what the event data is like.

For Con of Champions using the following URL:


This will return JSON like the following:

   "result" : {
      "items" : [
            "is_cancelled" : 0,
            "more_info_uri" : "http://",
            "host_showed_up" : 0,
            "room_id" : "C3F7257A-735E-11EA-BA85-003D1B681414",
            "special_requests" : null,
            "event_number" : 32,
            "date_created" : "2020-03-30 20:06:58",
            "actual_price" : 0,
            "max_tickets" : 100,
            "price" : 0,
            "convention_id" : "105930EE-713D-11EA-AB57-B86D1B681414",
            "space_id" : "CC9EA888-735E-11EA-BA85-113D1B681414",
            "type_id" : "9E78A594-7147-11EA-AB57-1A891B681414",
            "view_uri" : "/conventions/con-of-champions/schedule/32",
            "trashed" : 0,
            "name" : "Just Three Hexes - Start RPG Campaigns Quickly!",
            "room_name" : "Seminars",
            "long_description_html" : null,
            "wait_count" : 0,
            "allow_schedule_conflicts" : 0,
            "start_date" : "2020-05-23 15:00:00",
            "max_hosts" : 1,
            "attendee_head_count" : 0,
            "sold_count" : 0,
            "description" : "\"It takes just three hexes to start a campaign.\" You don't have to map out an entire world to get an awesome campaign rolling! In this seminar, I will show you my published \"3 Hexes\" technique for creating a fun campaign quickly!",
            "sellable" : 1,
            "startdaypart_id" : "9E84AAEC-7147-11EA-AC21-12DFCDE7307A",
            "host_count" : 1,
            "startdaypart_name" : "Saturday at 10:00 AM",
            "alternatedaypart_id" : "9E9044C4-7147-11EA-AC21-12DFCDE7307A",
            "space_name" : "C001",
            "preferreddaypart_id" : "9E84AAEC-7147-11EA-AC21-12DFCDE7307A",
            "custom_fields" : {
               "Whatvirtualsystemwillbeusedtopresent" : "I will be using Zoom to present this seminar, as well as streaming it to my Twitch channel."
            "age_range" : "teen",
            "duration" : 60,
            "submission_id" : "C5E9E018-72B2-11EA-A2B6-C13F1B681414",
            "long_description" : null,
            "is_scheduled" : 1,
            "object_name" : "Event",
            "object_type" : "event",
            "id" : "02BEB31A-72C2-11EA-A2B6-58581B681414",
            "available_count" : 100,
            "date_updated" : "2020-03-31 14:54:57",
            "claimable" : 0

      "paging" : {
         "total_pages" : 9,
         "total_items" : 213,
         "items_per_page" : 25,
         "next_page_number" : 2,
         "page_number" : 1,
         "previous_page_number" : 1

This is everything for an event. Look to see if there is a custom_fields in there. If there is make a note of them because we will need to deal with them.

Some Organizing

So, we have enough info that we need to do a little data wrangling. This can fit nicely into a config file. My current format of choice is YAML

  url: https://tabletop.events
    2020: B8477188-3D42-11E9-998D-F64533A48A88
  username: USERNAME
  password: PASSWORD
  api_key: GET_YOUR_API_KEY
  base_dir: /path/to/dump/data/YEAR
  - Whatvirtualsystemwillbeusedtoplay

Ok, this should be enough to let us write our client to grab event data.

Heading to The Bit Mines

So, lets code up our client to do some data mining.

As a Linux sysadmin I do a lot of Perl programming so that's what I'm going to do. Other languages are left as an exercise for the reader.

First up, mostly boilerplate.

Have it use whatever perl my shell is using, make sure to check if I'm being dumb so check things, and tell me about it.

Load up the modules I need to do the things I want to do. Thanks to those authors that have generously coded them up and made available on the CPAN.

Also, turn on new Perl things, and use a really new Perl thing and don't complain about it.

#!/usr/bin/env perl

use strict;
use warnings;

use Getopt::Long;
use POSIX qw(strftime);
use YAML qw(LoadFile);
use Wing::Client;
use Text::CSV;

use v5.30;

use experimental qw(signatures);

Next chunk, is setting STDOUT to autoflush output. Then setup some variables for the options I want to use and some error checking of them.

$| = 1;

my $config_file;
my $conid_year;

GetOptions (
             "config|c=s"  => \$config_file,
             "year|y=i"  => \$conid_year,
or die ("Error in command line arguments\n");

if ( $conid_year < 2020 ) {
  say q(Invalid year, must be greater than 2019);

This is the bulk of "what to do".

Create a time stamp to give files we generate a useful name, load our config file, and create our Wing object so we can hit up the TTE API.

We move on to do some prep work to prime the pump before we dive in and page through all of the event data there might be.

Finally, dump that event data into a csv file and exit the program with an appropriate exit code.

say q(Start things up);

our $time_stamp = strftime("%Y-%m-%d_%H-%M-%S", localtime(time) );

my ($config) = startup($config_file, $time_stamp);

say q(Create Wing client object);
my ($wing, $session) = get_wings($config);

my $page_number = 1;
say q(Get Data);
print q(  fetching page : ), $page_number;
my ($data) = get_data($wing, $session, $page_number);

my $events_ref;
map { push(@$events_ref, $_) } $data->{items}->@*;

($events_ref) = page_through_events($wing, $session, $events_ref);

make_csv_file($config, $events_ref);


But wait, there's more, I need to go through all the subroutines called.

Just reading in our YAML file and then replacing the YEAR placeholder with the supplied year

sub startup ($config_file, $time_stamp) {

  say q(Read config_file);
  my ($config) = LoadFile($config_file);

  $config->{base_dir} =~ s/YEAR/$conid_year/;


Create the object to talk with the API. Using our config file to provide all the info it needs.

sub get_wings ($config) {

  my $wing = Wing::Client->new(uri => $config->{url});

  my $session = $wing->post('session', 
                       { username   => $config->{username}, 
                         password   => $config->{password}, 
                         api_key_id => $config->{api_key}
  return($wing, $session);

Here's the heart of things. Calling the API, getting the event data, and then passing that data back.

sub get_data ($wing, $session, $page_number) {

  my $conid  = $config->{conid}{$conid_year};

  my $query_string = qq(/api/convention/$conid/events);

  my $data;
  $data = $wing->get($query_string, { 
    session_id               => $session->{id},
    _page_number             => $page_number,
    _items_per_page          => 100,


Now we gotta go through however many pages of data there are.

sub page_through_events ($wing, $session, $events_ref) {
  my $next_page_number  = $data->{paging}{next_page_number};
  my $total_pages       = $data->{paging}{total_pages};

  for my $page_number ( $next_page_number ... $total_pages ) {
    print qq( $page_number);
    ($data) = get_data($wing, $session , $page_number);
    map { push(@$events_ref, $_) } $data->{items}->@*;
  say q();

Once we have all that event data, dump it into a file format that is a bit more usable.

Create a file name and file handle to write to that file. The $csv object is set to use binary so it handles anything in the parsing that might be binary, otherwise it's expecting roughly the ASCII character set. Unexpected things might happen othwerwise.

Get the headers to use in the first row of the file and print that to the csv file. Check to see if there are any errors printing to the file. I added this because I initially didn't have binary parsing turned on and I spent way too much time figuring out where 15 events disappeared from the gathering of them to the printing of them.

Then, loop through the rest of the data.

sub make_csv_file ($config, $events_ref) {
  say q(make event csv file);
  my $csv_file = $config->{base_dir} . qq(/$time_stamp-events.csv);

  open my $fh, '>', $csv_file;

  my $csv = Text::CSV->new({ binary => 1});
  $csv->eol ("\n");

  my ($header_ref) = get_headers($config, $events_ref);

  my ($status) = $csv->print($fh, $header_ref);
  check_csv_status($csv, $status, $header_ref);

  my $count = 1;
  EVENT:for my $event ( $events_ref->@* ) {
    my ($event_info_ref) = get_event_info($event, $config);
    my ($status) = $csv->print($fh, $event_info_ref);
    check_csv_status($csv, $status, $event_info_ref);
    $count += 1;

Sort the keys from the hash that event data is kept in. If there happens to be a custom_fields deal with it, otherwise add to the headers array.

sub get_headers ($config, $events_ref) {
  my @headers;
  for my $header ( sort keys $events_ref->[0]->%* ) {
    if ( $header eq q(custom_fields) ) {
      for my $custom_field ( $config->{custom_fields}->@* ) {
        push(@headers, $custom_field);
    else {
      push(@headers, $header);


Just some mundane error checking to see why something might not have printed to the csv file. If so, die so you can figure out why.

sub  check_csv_status ($csv, $status, $event_info_ref) {
  unless ( 1 == $status ) {
    my ($cde, $str, $pos, $rec, $fld) = $csv->error_diag();
    say  q( csv print error);
    say qq( cde : $cde);
    say qq( str : $str);
    say qq( pos : $pos);
    say qq( rec : $rec);
    say qq( fld : $fld);

    for my $field ( $event_info_ref->@* ) {
      say $field;

For each event go through the hash. If nothing unexpected happens stuff it into the events array ref. The custom_fields key needs to be expanded with any additional fields that it brings along. Fields matching description might have newlines that will really mess up a csv file.

sub get_event_info ( $event, $config ) {
  my $event_array_ref;
  for my $event_key ( sort keys $event->%* ) {
    if ( $event_key eq q(custom_fields) ) {
      ($event_array_ref) = handle_custom_fields($event, $config, $event_array_ref);
    elsif ( $event_key =~ m/description/ ) {
      ($event_array_ref) = escape_newlines($event_key, $event, $event_array_ref);
    else {
      my $value = $event->{$event_key};
      if ( ! defined $value ) {
        $value = q();
        push(@$event_array_ref, q());
      push(@$event_array_ref, $value);
  return ($event_array_ref);

See what the custom_fields entry in the config file needs to add to the events array.

sub handle_custom_fields ($event, $config, $event_array_ref) {
  for my $custom_field ( $config->{custom_fields}->@* ) {
    my $field = $event->{custom_fields}{$custom_field};
    push(@$event_array_ref, $field);

Get rid of newlines in a field. It messes with the formatting that was submitted by the person running the event, but that's life.

sub escape_newlines ($event_key, $event, $event_array_ref) {
  my $field;
  if ( defined $event->{$event_key} ) {
    $event->{$event_key} =~ s/\R|\n//g;
  push(@$event_array_ref, $event->{$event_key});

And that's it for now.

If you think you need more event data than TTE makes available in their csv file for a convention...well now you can go digging.

Roll20 character sheet import



A long term project of mine was to find an easy way to import character sheets into Roll20.

There are tools for D&D 5e and such but nothing for my game of choice, Astonishing Swordsmen and Sorcerers of Hyperborea. Hyperborea for short.

Recently I did find an API using tool that would work. However, that requires you to have a paid account. Which I do. I still might use it.

However, I did find something that mostly works, Roll20 Character Import/Export.

I do have a program that can take a yaml file and populate a form fillable pdf. Taking that and banging my head against the wall for a few weeks. With some help from a friend to understand some javascript I was able to get it working as much I really need it to.

The Base Character Sheet

So, I'm trying out the idea of keeping character info a yaml or markdown file.

I've settled on something like this:

ST: 17
DX: 10
CN: 14
IN: 17
WS: 12
CH: 12
class: fire lord
level: 5

Race: Kelt

Gender: female

Age: 17

HP: 45

XP: 45,000

  - <starter_pack>
  - Hunting horn
  - leather mask
  - <hm>dagger x3[w]
  - <a>small shield

  - Hand Axe +2

  - Flaming Missile
  - Flame Blade
  - Fireball

This has some syntax in it that I built up as I was making my pc generator for Hyperborea. There are many table lookups and such. I then dump the data into a Form Data Forma, (FDF) that I can use to create a Hyperborea character sheet.

I can take data from one format and munge into another.

Now to think about the next data format.

Roll20 Character Sheet

Roll20 has a lot of character sheets you can use in your game. A lot of them, you can even submit yours to be added to the repo.

To figure out what Roll20 has named each of the various attributes you will need to get a copy of your target character sheet. You can either clone their repo or you can find your character sheet in the repo and get that copy.

For HTML munging and extraction I reach for Mojo::DOM.

It doesn't take much to get what I want:

my ($html) = join('',<>);

my $dom = Mojo::DOM->new($html);

say $dom->find('[name]')->map(attr => 'name')->join("\n");

It's just using CSS selectors so it shouldn't be too hard to translate that to the language of your choice.

I then created a mapping to go from my program (centered around an FDF) to the roll20 names:

"Character Name": character_name
"Class": class
Level: level
Align: align

"ST Attack Mod": meleehitbonus
"ST Damage adj": meleedmgbonus 
"Test of ST": testst 
"Feat of ST": featst

Next is the hard part.

Import Character data into Roll20 Character Sheet

I did find something that was relatively easy...ish to import that data, ChatSetAttr.

However, most folks don't pay for roll20. I use it enough and I like to pay for useful things like it, but I still wanted a way to get character sheet data imported.

To be honest...I was looking at the problem off and on for probably a year before I found this Firefox plugin.

I have manually entered the character data in a test campaign I use for ideas I want to try out. I mention this because my initial attempts to import a character that I hadn't manually imported, nor used the ChatSetAttr API didn't work.

Eventually I hit upon exporting a character sheet that I had filled out manually. This gave me the format to import.

    "schema_version": 1,
    "name": "barb_test",
    "avatar": "",
    "bio": "",
    "attribs": [
            "name": "reactionmod",
            "current": "1",
            "max": "",
            "id": "-M4dusSQdUOIdxhCvhKD"

I was then stuck on that id.

I went to the roll20 forums and was pointed to some code.

This code creates a sort of UUID. The main thrust of it being something like:

return function() {
        var c = (new Date()).getTime() + 0, d = c === a;
        a = c;
        for (var e = new Array(8), f = 7; 0 <= f; f--) {
            e[f] = "-0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ_abcdefghijklmnopqrstuvwxyz".charAt(c % 64);
            c = Math.floor(c / 64);

A revelation

So, now I needed to translate those functions into my weapon of choice, Perl.

A friend helped me with the translation so I could generate uuid's in the format that roll20 understands.

That bit of help broke open the damn...or...was just enough help to smash my head through the wall I was hitting against.

The repeating items problem

Being able to import basic player info was great.

Now I had to tackle what are in the html as repeating items. Things like gear, abilities, weapon info, spells, etc.

This was mostly a puzzle for my code. The uuid code does have something explicit for repeating items uuids.

            "name": "repeating_melee_-M4dusTPpXLZyuX3dipz_meleeweaponname",
            "current": "dagger",
            "max": "",
            "id": "-M4dusTP3-Ipr2NBQx-P"
            "name": "repeating_melee_-M4dusTPpXLZyuX3dipz_meleeclass",
            "current": "1",
            "max": "",
            "id": "-M4dusTQdb3JG38lovQM"
            "name": "repeating_melee_-M4dusTPpXLZyuX3dipz_meleeatkrate",
            "current": "1/1",
            "max": "",
            "id": "-M4dusTQEEylJV5QjKro"
            "name": "repeating_melee_-M4dusTPpXLZyuX3dipz_meleeatkmod",
            "current": "0",
            "max": "",
            "id": "-M4dusTQ7yLYX5ddp6oD"
            "name": "repeating_melee_-M4dusTPpXLZyuX3dipz_meleedamage",
            "current": "1d4+1",
            "max": "",
            "id": "-M4dusTRgK3BjoBRFvle"

In the above snippet, this represents the info for a melee weapon. It's name, weapon close, attack rate, attack modifiers, and damage. There is a a field for notes which I didn't enter for this weapon.

Each html attribute name is of the format, "repeating_melee_ROWID_attrname". ROWID is replaced with a row id that is similar to the "normal" uuid. Each item in the same line has the same ROWID but different "id"s.

That is basically it.

I am still going through and adding things but now it's just an iterative process. I update the code, fix the syntax errors in my code because I mostly do this at the end of the day and I have very little brain for such task, and then do and import to see what it looks like.

So far the only thing that isn't really working is the equipment list. I'm ok with that. It can be added manually if needed. These programs take a lot of the pain out of this.


  • start with a beginning data format
  • munge to a target format
  • add another target format
  • extract HTML attribute names from an html file
  • use those names to map from initial format to new target format
  • create properly formatted json file
  • test import
  • check that it looks ok
  • lather
  • rinse
  • repeat as needed


And with that...I need to go to bed, maybe watch some WestWorld. Good night y'all.