301 Days

A year of gamedev experiments.

Day 81 - GGJ2017 Postmortem

| Comments

In which we spend a weekend making something.

Solo and Scoped Down

As is my tradition, traveled to Cleveland Ohio to take part in their game jam gathering. It’s a great group; a little larger each year, which is good, but still very friendly and helpful.

Since I was acting as an organizer this time, I was unsure if I’d actually be able to contribute at all, so I turned down any invitations to join a team. Knowing that I’d be working by myself, I also kept the scope incredibly small.

One way to keep it simple was to eject graphics and sound entirely. One way to keep it interesting was to make it a multiplayer game. One of the GGJ 2017 Diversifiers, “Crowd Control”, called for an eight-player game; this piqued my interest, so I committed to that.

SMS?

In the interest of being able to play around with some relatively unfamiliar tech, I decided to do a play-by-SMS game. Players would send text messages with their commands to a common phone number, and get responses the same way to let them know their current state.

The day job had led me to be familiar with a service called Twilio, which allows you to set up a phone number and have the messages sent to an HTTP endpoint, which can then be responded to via their API. They have many other services as well, but simple SMS response stuff is all I needed for this game.

A free account at Twilio would do most of what I needed, but would take up some of the text space with “Sent from a Twilio trial account”; so I made a paid account and threw $50 into it, hoping that would last long enough for the Jam.

Fight The Tide

After the theme (“Waves”) was announced, I pretty quickly came up with the premise: an eight-player brawl on the beach, with players chasing and kicking each other. As the waves recede, the players' feet are freed up to do more damage, but when the waves come crashing in they’ll knock over players who aren’t prepared for it.

Why are they just kicking? Maybe they’re in handcuffs; maybe it’s a prison break and there’s only room on the boat for one. Without needing art assets, the premise can be very flexible.

Running in the Cloud

I definitely didn’t want the game server running on my laptop; I couldn’t guarantee that it would be online all the time, let alone accessible from Twilio’s servers. So I spun up a free t2.micro instance on AWS to host the server. It had a clone of the GitLab repo, so I could do development either directly on the instance or on my laptop.

Interesting trade-offs always arise from cloud security. In the case of this game server, I opted for:

  1. SSH access from the IPs at the local GGJ site (and therefore me).
  2. Webserver running on a nonstandard port open to the world (and therefore Twilio and me).

Following some of Twilio’s examples, I set up Sinatra to handle the web requests, and pointed Twilio at the server.

In order to send messages, I would need to utilize my Twilio API keys, which needed to be kept secret (because they would give someone the ability to cost me money). In the code (which is in a public repository) I just call out to OS environment variables, which I set with a separate script that wasn’t in source control.

The Code

Beware. Ugly Ruby code ahead.

Phase One: One-player RPS

As a first step to prove out my integration with Twilio, I implemented a quick-and-dirty rock, paper, scissors game:

rps0.rblink
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
get '/sms-rps' do
  session["intro_played"] ||= false
  body = params['Body'].downcase
  puts "Body was \"" + body + "\""
  if session["intro_played"] && ['rock','paper','scissors'].include?(body)
    message = "You chose #{body}!"
    my_choice = ['rock', 'paper', 'scissors'].sample
    message += " I chose #{my_choice}! "
    message += rps_resolve(my_choice, body)
  else
    message = "Let's play! Respond with ROCK, PAPER, or SCISSORS."
    session["intro_played"] = true
  end
  twiml = Twilio::TwiML::Response.new do |r|
    r.Message message
  end
  twiml.text
end

def rps_resolve(mine, yours)
  return "It's a tie!" if mine == yours
  return "I win!" if WHAT_BEATS[mine] == yours
  "I lose!"
end

Phase Two: Two-player RPS

Now handling two players, differentiated by their phone numbers:

rps1.rblink
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
get '/sms-rps' do
  body = params['Body'].downcase
  puts "Body was \"" + body + "\""
  phone_number = params['From']
  player_number = player_phnum.has_value?(phone_number) ? player_phnum.invert[phone_number] : 0
  if player_number == 0
    player_number = player_phnum.keys.max + 1
    message = "We now welcome player #{player_number} from #{params['FromCity']} #{params['FromState']}!"
    player_phnum[player_number] = params['From']
    opponent = player_number % 2 == 0 ? player_number-1 : player_number+1
    message += "\nYour opponent is player #{opponent}!"
    message += "\nLet's play! Respond with ROCK, PAPER, or SCISSORS."
  else
    opponent = player_number % 2 == 0 ? player_number-1 : player_number+1
    message = "I know you, you're player #{player_number}!"
    if ['rock','paper','scissors'].include?(body)
      message += "\nYou chose #{body}!"
      player_moves[player_number] = body
      if player_moves.has_key?(opponent)
        opponent_move = player_moves[opponent]
        player_move = player_moves[player_number]
        message += "\nThey chose #{opponent_move}!"
        message += "\n#{rps_resolve(opponent_move, body)}"
        inform_player(player_phnum[opponent], "You chose #{opponent_move}!\nThey chose #{body}!\n#{rps_resolve(body, opponent_move)}")
        player_moves.delete(player_number)
        player_moves.delete(opponent)
      end
    end
  end
  puts player_moves
  puts player_phnum
  twiml = Twilio::TwiML::Response.new do |r|
    r.Message message
  end
  twiml.text
end

Phase Three: Fight on the Beach

Between my organizer duties and getting a little bit of sleep, I established the actual game.

Once the server is launched, it waits until it’s been contacted by at least eight players, then broadcasts the available commands to everyone.

beach2.rblink
134
135
136
137
138
  elsif $players.size > PLAYERS_NEEDED && game_start == false
    puts "Time to start the game!"
    game_start = true
    tell_everyone('Time to start entering moves! Available moves are: FORWARD (F), LEFT (L), RIGHT (R), SLIGHT LEFT (SL), SLIGHT RIGHT (SR), KICK (K), BRACE (B), and WAIT (W).')
    describe_all

The players are each placed into a blank space in the 2d grid (only one player may be in any square at a time).

Each turn, each player receives a description from their current point of view, e.g.

1
2
In front of you is player 8.To your front left is player 6.To your left is player 3.To your right is player 2.
You have 4 hit points remaining.

Each player enters their command; when all commands are received, the results are sent out to the affected players.

beach2.rblink
120
121
122
123
124
125
126
127
128
129
130
131
132
133
  if game_start && $players.index{ |h| h && h[:hp] > 0 && h[:move].nil? && !h[:phone_number].nil? }.nil?
    dummies_move
    execute_moves
    if $players.select{ |h| h && h[:hp] > 0 }.size == 1
      winner = $players.select{ |h| h && h[:hp] > 0 }.first
      tell_everyone("*** Player #{winner[:player_number]} wins! ***")
    end
    describe_all
    describe_hp
    update_map
    $players.select{ |h| h }.each do |player|
      player[:move] = nil
    end
    $wave_countdown -= 1

What’s dummies_move, you ask? Since I didn’t have eight phones or enough teammates to help constantly test the thing, I needed bots. No fancy AI, they just issue a move completely at random each turn.

beach2.rblink
237
238
239
240
241
242
243
def dummies_move
  $players.select{ |h| h && h[:phone_number].nil? }.each do |player|
    player[:move] = ['left', 'right', 'slight left', 'slight right', 'forward', 'kick', 'brace', 'wait'].sample
    player_number = player[:player_number]
    File.open("public/player#{player_number}.log", 'a'){ |file| file.write("\nPlayer entered: <b>" + player[:move] + "</b>\n\n")}
  end
end

Phase Four: Map and Status Screens

I was playing with two phones most of the time to check my work, but it became clear that I needed a better way to see what was going on with all of the players. So I implemented a quick map using code I’m definitely not proud of:

beach2.rblink
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
def update_map
  # find the extent of the txtmap
  min_x = $players.select{|h| h}.collect{|h| h[:x]}.min
  max_x = $players.select{|h| h}.collect{|h| h[:x]}.max
  min_y = $players.select{|h| h}.collect{|h| h[:y]}.min
  max_y = $players.select{|h| h}.collect{|h| h[:y]}.max
  txtmap = ''
  max_y.downto(min_y) do |y|
    puts y
    (min_x..max_x).each do |x|
      puts x
      player_number = get_player_in_cell(x, y)
      if player_number
        player_facing = [$players[player_number][:face_x], $players[player_number][:face_y]]
        txtmap += case player_facing
                  when [-1,1]
                    '\\.. '
                  when [0,1]
                    '.|. '
                  when [1,1]
                    '../ '
                  else
                    '... '
                  end
      else
        txtmap += '... '
      end
    end
    txtmap += "\n"

etc etc etc

…to give me something like this:

1
2
3
4
5
6
7
8
9
10
11
12
MAP
... ... ... ... 
... .7. .4- ... 
... /.. ... ... 

... ../ ... ... 
-3. .5. ... ... 
... ... ... ... 

... ... ... \.. 
.2. ... .1. .8. 
.|. ... ..\ ... 

To get a better way to demonstrate the game, I made a “status” page to display the last few lines of each player’s log as well as the map:

beach2.rblink
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
get '/status' do
  page = '<html><head>Fight the Tide!</head><body style = \"\n/* font-family: monospace; */\n\">' +
         '<table style="table-layout: fixed;" border="1" cellpadding="5" cellspacing="5" height="100%" width="100%"><tr>'
  (1..4).each do |player|
    page += '<td style="width: 20%; word-wrap: break-word;">' + read_end("public/player#{player}.log", 15).gsub(/\n/, "<br>") + '</td>'
  end
  page += '<td rowspan="2" style="width: 20%;"><pre>' + File.open("public/map.txt", 'r').read + '</pre></td>'
  page += '</tr><tr>'
  (5..8).each do |player|
    page += '<td style="width: 20%; word-wrap: break-word;">' + read_end("public/player#{player}.log", 15).gsub(/\n/, "<br>") + '</td>'
  end
  page += '</tr>'
  page += '</body></html>'
  page
end

Demo

I was able to get at least that much done, so I had something to show off at the end of the Jam. I convinced eight people to text in and play for a few minutes, so I’ll call that success!

Remaining Problems

Code is unmaintainable.

One player can hold up the game indefinitely.

Once a game is over, it doesn’t restart.

Occasional delivery failures are not handled.

I’ll have to decide if it’s worth revisiting this game and addressing those issues. Tomorrow.


Day 81 code

Comments