I made a new game with greg technology. It’s called Talk Paper Scissors. It lets you play rock paper scissors with strangers by calling a phone number.

the Talk Paper Scissors mascot

It’s an attempt to make an audio-only stranger video. Here’s how it works:

  • you call a phone number
  • you’re asked to say rock, paper, or scissors
  • you aren’t allowed to say anything else
  • you hear a recording of what the stranger said and are told whether you won or lost

Games are 3 rounds long. Your stats are tracked between rounds.

To play call 1-(515)-762-5762 (that’s 1-515-ROCK-ROC). Note that this is a US number so fees may apply if you call internationally!

Where did this come from?

stranger video, my last project, was a lot of fun. It was also unexpectedly popular. That got me thinking about what makes a good game about interacting with strangers. I’ve got some ideas:

  • Interactions should be limited so that users feel safe
  • The limitations should drive the game instead of feeling arbitrary
  • Interactions should be short so that games end before the novelty wears off
  • Interactions should be within a single medium1.

In stranger video the focus is on a single medium - video. Games are short (they end as soon as you blink) and video is limited to only faces. And the game is all about what you do with your face.

The original idea for Talk Paper Scissors (TPS) came from brainstorming with my friend Jakob about how to apply these ideas to audio. Making it phone-based was a way to force ourselves to work with only audio. Games are short, interaction is limited to just 3 words, and you only need 3 words to play rock paper scissors.

The implementation of TPS was a collaboration with greg technology. Greg is brilliant and ridiculously prolific and his hands were on the keyboard 95% of the time. Thanks greg.

What’s making a telephone game like?

It’s wild.

a potential controller for TPS

No really! Making something for the phone was a totally new programming model. The verbs that we had access to for interacting with the user were about what you’d expect - we could play text, ask for a recording, play a sound, hang up, etc. But the verbs that we had access to for control flow were super limited. The rough idea is:

  • You give Twilio endpoints that return XML that tells Twilio what to do.
  • The endpoints are for when a call comes in and when someone hangs up.
  • An endpoint can return a redirect to a different endpoint. E.g. if you call in and there’s someone waiting to play, we can redirect you to the “play a game” endpoint.
  • You can also “interrupt” an ongoing call and re-route it to a different endpoint.
  • Since your endpoints ultimately have to return a single XML document, you handle dynamic control flow by putting users on hold, keeping state about their call, and then interupting their call later.

Conceptually this isn’t too complicated. In practice it feels weird. The big change is that the majority of what would normally be client code runs on the server in a request handler. The phone is a dumb terminal that can’t do anything but send audio, and the majority of your game logic needs to run on your server in response to an HTTP endpoint getting hit.

We dealt with all of this by writing a ton of race conditions and then slowly fixing most of them. Oops. But on top of the paradigm weirdness Twilio had some odd behavior that gave us a bit of trouble.

Twili-oh why oh why

We ran into two problems using Twilio:

  1. The built-in transcription was not very good.
  2. It made it very hard to tell when someone hung-up mid game.

1 is pretty understandable. Transcription got so good so fast! A transcription service that was totally fine 5 years ago gets absolutely crushed by the new wave of AI tools. To handle this, we “transcribe” by putting users on hold and sending the wav that Twilio gives us to OpenAI for transcription2. Whisper works great since it (kind of) listens to our corpus of “rock, paper, scissors” and makes reasonable guesses when users are hard to hear3.

2 requires some explanation. Here is some pseudo-code. Note that hang_up and redirect_to and the other verbs would instead be returned in an XML doc that Twilio would execute - so you should read this code as though it’s being run by Twilio.

def announce_results(player, round):
    say(f"Round results: {round.results}")

    if round.opponent.state == "HUNGUP":
        say("Your opponent hung up!")

    redirect_to(player, run_next_round)

def handle_hangup(player):
    player.state = "HUNGUP"

def run_next_round(player):
    if player.state == "HUNGUP": return None
    say("Starting next round")

This is how we want rounds in TPS to work. You play a round, then we announce the results of the round, and then we play the next round as long as your opponent is still there. If your opponent is gone, we just tell you that and end the call.

This doesn’t work! And not just because it’s race-prone. The if at the start of run_next_round will never fire (which in turn means that the if in announce_results will rarely fire).

This baffled us at first. We could play a game, get to hearing our results, hang up the phone, and still see our code execute the result of announce_results and run_next_round.

Eventually we realized that Twilio just doesn’t tell you that someone has hung up until their current request has been processed. Twilio knows that the user is gone so it doesn’t bother trying to play audio - but it does follow redirects. So run_next_round is called before handle_hangup.

This seemed pretty crazy! We could think of some gross workarounds4 but it really didn’t seem like we should need workarounds. Surely Twilio wouldn’t let a hung up call stay in a redirect loop indefinitely!

On a hunch we tried adding an unnecesary redirect to our code - the theory was that Twilio would have some max number of redirects that it’d follow on a hung up call.

def run_next_round_guard(player):
    redirect_to(player, run_next_round)

def announce_results(player, round):
    # same as above
    redirect_to(run_next_round_guard, player)

This worked! Twilio followed the first redirect but not the second one, ensuring that run_next_round was only hit for “live” calls.

We ended up formalizing this with a little decorator:

def twilio_redirect_hack(func, endpoint):
    def wrapped():
        if GET.PARAMS["hack"] != "redirected":
            redirect_to(endpoint, params={"hack": "redirected"})
            func(*args, **kwargs)
    return wrapped

def announce_results(player, round):
    pass # same as above

We’re both pretty proud of this hack.

Wrapping up

This is the first game I’ve made this year with a collaborator. It turns out working with people is a lot of fun! I’ve been spending a lot more time reaching out to folks recently and I’ve been happy with how that has gone. I’ve started to zero in on the type of work that I enjoy and it’s great to move from self discovery to finding collaborators and building an audience.

Speaking of which - I’m always excited to talk about weird creative coding stuff and would love to hear from you. Shoot me an email or use one of the billion platforms I’m on.

I previewed TPS at Wordhack at Wonderville. Wordhack was incredible and I got to meet some very cool people after - consider showing up if you’re in NYC (or if you’re not)!

I was also super lucky to get to work with Greg. Greg is so talented and knows so much and is so so fast. If you’re looking to hire a contractor you should contact Greg and pay him whatever he asks for.

I’m excited to continue working on games about interacting with strangers - there are so many things that I want to build in this space. And I’ll hopefully be giving a talk on stranger.video, TPS, and whatever else I cook up for the Experimental Games Workshop at the next GDC - I’ll hear back from them on Christmas Eve5.

I’ll be back soon with a new project. Thanks for reading.

  1. I’m least confident on this idea. 

  2. We briefly had a problem around getting short wavs that were likely to just contain the words rock, paper, or scissors - but it turns out you can tell Twilio “only record for 2 seconds no matter what” and that works great. 

  3. During testing Greg gave whisper the prompt “The audio is of Rock Paper Scissors. Please transcribe it well; my job depends on it” and whisper semi-frequently returned “my job depends on it.” We’ve since moved to the prompt “rock, paper, scissors.” I hope whisper’s job is ok. 

  4. The one I remember is changing announce_results to put the user on hold, returning, and scheduling a separate thread to check whether both players were still around and if so interuppting their holds and sending them to a new game. 

  5. Not sure if I love or hate this timing.