Using Contextual SMS Short Code Messages With TextMarks And ColdFusion
Yesterday, I explored the free SMS short code and web integration functionality provided by TextMarks. As I demonstrated, TextMarks acts as a proxy between cellular users and your web-based application. Because TextMarks uses a single, shared SMS short code, routing to your particular web-based application has to be done through a keyword which must be the leading piece of data in each TextMarks SMS request. The use of this routing keyword can be quite cumbersome; so, to alleviate this requirement, TextMarks provides "contextual responses".
When a user makes an SMS request using your keyword, that keyword takes over that user's context. Once the context is set, the user can then make subsequent requests without your routing keyword and TextMarks will assume your keyword based on the existing context. The huge caveat here, however, is that the subsequent requests must contain a minimal amount of data like a single digit or a character or a zip code. While this might seem silly, it's the only way to make sure that a subsequent request and a new routing directive are not confused.
NOTE: The most critical shortcoming of TextMarks' contextual response limitations is that the response, "Y" is not valid. If you want to use a single character response, you can only use characters "A" through "T". This removes the ability to do the most basic, most intuitive (Y)Yes / (N)No responses. As such, I had to fall back on the numerical digits 1/0 rather that Y/N to represent yes and no responses.
To explore this functionality, I came up with a very basic proof of concept: Blackjack. In the card game, Blackjack, the user must Hit or Stand in an attempt to get as close to 21 as possible without going bust. Once the cards are dealt, the user must make subsequent SMS requests back to TextMarks to submit his or her choice. Rather than sending the keyword "blackjack" with each subsequent request, TextMarks allows the user to use simple, one-digit responses to leverage the current context.
Before we get into the code, I just want to make it clear that this is a proof of concept and is not meant to be the most well designed web application.
Because a cell phone does not pass any cookies across with its SMS messages, session management takes on a different paradigm. Rather than letting ColdFusion manage the sessions implicitly, we are going to be creating and caching sessions explicitly in the Application scope. The unique ID used for the session caching and retrieval is passed into our web-application by TextMarks as part of our URL configuration.
Application.cfc
<cfcomponent
output="false"
hint="I define the application settings and event handlers.">
<!--- Define the application. --->
<cfset this.name = hash( getCurrentTemplatePath() ) />
<cfset this.applicationTimeout = createTimeSpan( 0, 1, 0, 0 ) />
<!--- Define page request settings. --->
<cfsetting showdebugoutput="false" />
<cffunction
name="onApplicationStart"
access="public"
returntype="boolean"
output="false"
hint="I initialize the application.">
<!---
Create a struct to hold the active sessions. We will
need this since there is no way for the cell phone to
maintain session accross the API requests.
--->
<cfset application.phoneSessions = {} />
<!---
Create a constant array of the cards in our deck
so that we can always duplicate it when we need
a new deck.
--->
<cfset application.newCardDeck = listToArray(
repeatString( "A,K,Q,J,10,9,8,7,6,5,4,3,2,", 4 )
) />
<!--- Return true so the request is processed. --->
<cfreturn true />
</cffunction>
<cffunction
name="onRequestStart"
access="public"
returntype="boolean"
output="false"
hint="I initialize the request.">
<!---
Check to see if we are manually resetting the
application (for debugging purposes).
--->
<cfif structKeyExists( url, "reset" )>
<!--- Manually initialize the application. --->
<cfset this.onApplicationStart() />
</cfif>
<!--- Return true so the request is processed. --->
<cfreturn true />
</cffunction>
<cffunction
name="onRequest"
access="public"
returntype="void"
output="true"
hint="I execute a rendering template.">
<!--- Include the UDF library. --->
<cfinclude template="./udf.cfm" />
<!---
No matter what page was requested, just include our
front controller.
--->
<cfinclude template="./index.cfm" />
<!--- Return out. --->
<cfreturn />
</cffunction>
</cfcomponent>
As you can see, the Application scope has a cache for our phone sessions (our explicit session management) and a fresh deck of cards. The deck of cards is represented by an array of 52 items - 4 sets of suitless cards. I'm keeping a deck of cards in the application scope so that I can easily duplicate it later when I need a fresh deck.
The bulk of the processing happens in the front-controller, index.cfm. When a user interacts with the Blackjack application, there are essentially two phases that any game can be in: New, in which the cards are dealt or Active, in which the user is making Hit / Stand decisions. Once a game is over (a winner is selected), any subsequent request to the Blackjack application deals a new hand in the user's session.
Index.cfm
<!---
Param the request data. In TextMarks, we are going to
set up "smsdata" to contain the "\0" data, which is the
entire request made by the user.
--->
<cfparam name="url.smsdata" type="string" default="" />
<!---
This is the unique ID of the cell phone making the request.
This ID is supplied by TextMarks and is garanteed to be a
unique numeric value.
--->
<cfparam name="url.uid" type="numeric" default="0" />
<!---
This is the phone number of the user placeing the request.
We don't really need it for this, but I just wanted to see
how the number came across in the demo.
--->
<cfparam name="url.number" type="string" default="" />
<!---
Check to see if this SMS user already has an active session
in the application cache. If so, we need to grab it. If not,
then we will need to create a new one.
--->
<cfif structKeyExists( application.phoneSessions, url.uid )>
<!--- Grab the cached user session. --->
<cfset phoneSession = application.phoneSessions[ url.uid ] />
<cfelse>
<!---
Create a new user session for this cell user. This will
contain information about the current game.
--->
<cfset phoneSession = {
cardDeck = [],
userHand = [],
dealerHand = [],
gameStatus = "",
previousResponse = ""
} />
<!--- Cache the user session. --->
<cfset application.phoneSessions[ url.uid ] = phoneSession />
</cfif>
<!---
ASSERT: At this point, no matter whether a cell user made
their first SMS request or a subsequent request, we now have
a valid phone session variable.
--->
<!---
Check to see if we need to take action on the current game
based on it's status.
NOTE: We are doing this if the game is fresh or if it is
over. Therefore, we will never truly have a valid "over"
state in the proceeding logic (seeing as the status will
be switched over to "new" right now).
--->
<cfif (
(phoneSession.gameStatus eq "") ||
(phoneSession.gameStatus eq "over")
)>
<!--- Reset the game status. --->
<cfset phoneSession.gameStatus = "new" />
<!--- Reset the player hands. --->
<cfset phoneSession.userHand = [] />
<cfset phoneSession.dealerHand = [] />
<!--- Create a new deck of cards. --->
<cfset phoneSession.cardDeck = duplicate(
application.newCardDeck
) />
<!--- Shuffle the cards. --->
<cfset createObject( "java", "java.util.Collections" ).shuffle(
phoneSession.cardDeck
) />
<!--- Clear the previous response. --->
<cfset phoneSession.previousResponse = "" />
</cfif>
<!---
Now that we have the SMS data, let's break it up into
an array so that we can start to treat it like actions.
--->
<cfset tokens = listToArray( trim( url.smsdata ), " ,*-" ) />
<!---
Wrap the processing in a try/catch so if something does
go wrong, we can catch it and return a response.
--->
<cftry>
<!---
Only certain tokens will be available in certain phases
of the game. Therefore, we will break this down by game
status before we check token.
NOTE: We never check for the status of "OVER" as this is
not really a valid status. Any game with status "over"
will be updated above and converted to "NEW".
--->
<cfswitch expression="#phoneSession.gameStatus#">
<cfcase value="new">
<!--- Deal two cards to the user. --->
<cfset phoneSession.userHand[ 1 ] = phoneSession.cardDeck[ 1 ] />
<cfset phoneSession.userHand[ 2 ] = phoneSession.cardDeck[ 2 ] />
<!--- Deal two cards to the dealer. --->
<cfset phoneSession.dealerHand[ 1 ] = phoneSession.cardDeck[ 3 ] />
<cfset phoneSession.dealerHand[ 2 ] = phoneSession.cardDeck[ 4 ] />
<!--- Remove top four cards. --->
<cfset arrayDeleteAt( phoneSession.cardDeck, 1 ) />
<cfset arrayDeleteAt( phoneSession.cardDeck, 1 ) />
<cfset arrayDeleteAt( phoneSession.cardDeck, 1 ) />
<cfset arrayDeleteAt( phoneSession.cardDeck, 1 ) />
<!--- Update the status of the game. --->
<cfset phoneSession.gameStatus = "active" />
<!--- Build the response. --->
<cfsavecontent variable="responseData">
<cfoutput>
Dealer: ---<br />
#phoneSession.dealerHand[ 1 ]# *<br />
.<br />
You: ---<br />
<cfloop
index="card"
array="#phoneSession.userHand#"
>#card# </cfloop
><br />
.<br />
Hit? (1=Y/0=N)
</cfoutput>
</cfsavecontent>
</cfcase>
<!--- ---------------------------------------------- --->
<!--- ---------------------------------------------- --->
<cfcase value="active">
<!---
Check to see how the user responded to the
request to HIT. They could only respond with "1"
(yes) or "0" (no).
Param the value such that if it is not met, it
will throw an exception - we'll let the exception
handling just repopulate the current screen.
--->
<cfparam
name="tokens[ 1 ]"
type="regex"
pattern="(1|0)"
/>
<!--- Check to see if the user wants to hit. --->
<cfif (tokens[ 1 ] eq 1)>
<!--- User wants to hit. --->
<!--- Grab the next card off the deck. --->
<cfset arrayAppend(
phoneSession.userHand,
phoneSession.cardDeck[ 1 ]
) />
<!---
Delete the card from the deck so that it
cannot be used on subsequent hits.
--->
<cfset arrayDeleteAt( phoneSession.cardDeck, 1 ) />
<!---
Now that the user has taken another card,
let's check to see if the user is bust (hand
is over 21). If so, then the game is over.
--->
<cfif !isHandBust( phoneSession.userHand )>
<!---
The user hand is valid. They can still
go again. Present the current cards and
the choice to go again.
--->
<cfsavecontent variable="responseData">
<cfoutput>
Dealer: ---<br />
#phoneSession.dealerHand[ 1 ]# *<br />
.<br />
You: ---<br />
<cfloop
index="card"
array="#phoneSession.userHand#"
>#card# </cfloop
><br />
.<br />
Hit? (1=Y/0=N)
</cfoutput>
</cfsavecontent>
<cfelse>
<!---
The user busted; update the status of the
game to be over (this will trigger a new
game on the next request).
--->
<cfset phoneSession.gameStatus = "over" />
<!---
The user hand is bust. Display the game
over screen and allow user to start a
new game.
--->
<cfsavecontent variable="responseData">
<cfoutput>
Dealer: ---<br />
#phoneSession.dealerHand[ 1 ]# *<br />
.<br />
You: ---<br />
<cfloop
index="card"
array="#phoneSession.userHand#"
>#card# </cfloop
><br />
BUSTED!! (#getHandTotal( phoneSession.userHand )#)<br />
.<br />
Play Again? (1=Y/0=N)
</cfoutput>
</cfsavecontent>
</cfif>
<cfelse>
<!---
The user wants to "Stand". Now, no matter what
the outcome, the game is over - update the
status of the game to "over". This will trigger
a new game on the next request.
--->
<cfset phoneSession.gameStatus = "over" />
<!---
User wants to stand. Now, it's the dealer's
turn to go. The dealer has to keep taking
cards until they are over a sum of 17.
--->
<cfloop
condition="dealerMustHit( phoneSession.dealerHand )">
<!--- Grab the next card off the deck. --->
<cfset arrayAppend(
phoneSession.dealerHand,
phoneSession.cardDeck[ 1 ]
) />
<!---
Delete the card from the deck so that it
cannot be used on subsequent hits.
--->
<cfset arrayDeleteAt(
phoneSession.cardDeck,
1
) />
</cfloop>
<!---
Now that we have populated the dealer's hand,
let's check to see if it is bust.
--->
<cfif isHandBust( phoneSession.dealerHand )>
<!---
The dealer hand is bust. Display the game
over screen and allow user to start a
new game.
--->
<cfsavecontent variable="responseData">
<cfoutput>
Dealer: ---<br />
<cfloop
index="card"
array="#phoneSession.dealerHand#"
>#card# </cfloop
><br />
BUSTED!! (#getHandTotal( phoneSession.dealerHand )#)<br />
.<br />
You: ---<br />
<cfloop
index="card"
array="#phoneSession.userHand#"
>#card# </cfloop
><br />
.<br />
Play Again? (1=Y/0=N)
</cfoutput>
</cfsavecontent>
<!---
If the dealer is not bust, check to see if
their hand is greater than the user's hand.
--->
<cfelseif (getHandTotal( phoneSession.dealerHand ) gte getHandTotal( phoneSession.userHand ))>
<!--- Dealer wins! --->
<cfsavecontent variable="responseData">
<cfoutput>
Dealer: ---<br />
<cfloop
index="card"
array="#phoneSession.dealerHand#"
>#card# </cfloop
><br />
WINNER!!<br />
.<br />
You: ---<br />
<cfloop
index="card"
array="#phoneSession.userHand#"
>#card# </cfloop
><br />
.<br />
Play Again? (1=Y/0=N)
</cfoutput>
</cfsavecontent>
<!---
If the dealer is not bust and the dealer's
hand is not as good as the user's hand then
the user has won.
--->
<cfelse>
<!--- User wins! --->
<cfsavecontent variable="responseData">
<cfoutput>
Dealer: ---<br />
<cfloop
index="card"
array="#phoneSession.dealerHand#"
>#card# </cfloop
><br />
.<br />
You: ---<br />
<cfloop
index="card"
array="#phoneSession.userHand#"
>#card# </cfloop
><br />
WINNER!!<br />
.<br />
Play Again? (1=Y/0=N)
</cfoutput>
</cfsavecontent>
</cfif>
</cfif>
</cfcase>
</cfswitch>
<!--- Catch any exception. --->
<cfcatch>
<!---
Check to see if we have a previous output we
could try resending.
--->
<cfif len( phoneSession.previousResponse )>
<!---
Use the previous response as the current
response. This way, they may have a chance
to recheck their inputs.
--->
<cfset responseData = phoneSession.previousResponse />
<cfelse>
<!--- An error occurred. Alert the user and. --->
<cfsavecontent variable="responseData">
Ooops<br />
An error occurred.<br />
Check your inputs.<br />
</cfsavecontent>
</cfif>
</cfcatch>
</cftry>
<!---
Store the current response into the previous output
variable. This way, if something goes wrong, we can
return the previous screen.
--->
<cfset phoneSession.previousResponse = responseData />
<!---
At this point, we should have a valid response in our
respondData variable. Now, we need to clean it up for
SMS response. Let strip out the extra white space,
including the leading / trailing line spaces.
NOTE: TextMarks will automatically take care of stripping
out our <BR /> tags and replacing them with text-valid
line breaks.
--->
<cfset responseData = reReplace(
trim( responseData ),
"(?m)(^[ \t]+|[ \t]+$)",
"",
"all"
) />
<!---
Convert the respond data into a binary variable so that we
can easily stream it back using CFContent without having to
be fanatical about clearing the content buffer.
--->
<cfset binaryResponse = toBinary( toBase64( responseData ) ) />
<!--- Set headers. --->
<cfheader
name="content-length"
value="#arrayLen( binaryResponse )#"
/>
<!---
Stream content back as HTML. By using the Variable
attribute, we are ensuring that no extra white space is
being passed back.
--->
<cfcontent
type="text/html"
variable="#binaryResponse#"
/>
Because cell phone SMS requests do not pass cookies, I am manually managing the sessions based on the unique phone ID passed in from TextMarks. The first thing I do in the request is check to see if the current user already has an active session. If they do, I get a reference to it; if they do not, I create one and then cache if for subsequent requests. Once this is done, the rest of the page logic can refer to the current user's "phoneSession" which will be persisted from request to request.
The rest of the code is fairly top-down self-explanatory. Each game gets a new deck of cards, which is duplicated from the application-cached deck and then shuffled. As each user hits, a card is popped off of the deck and added to the given hand such that it cannot be used in subsequent hits.
The code above makes use of a user-defined function library which is included right before the front-controller. This library has some utility methods used to help count the sum of each hand and determine if the hand is bust.
UDF.cfm
<cffunction
name="dealerMustHit"
access="public"
returntype="boolean"
output="false"
hint="I determine if the given dealer hand must hit (less than 17).">
<!--- Define arguments. --->
<cfargument
name="hand"
type="array"
required="true"
hint="I am the dealer hand being checked for hit requirement."
/>
<!--- Return true if the hand sum is less than 17. --->
<cfreturn (getHandTotal( arguments.hand ) lt 17) />
</cffunction>
<cffunction
name="getHandTotal"
access="public"
returntype="numeric"
output="false"
hint="I get the total sum of the cards in the given blackjack hand.">
<!--- Define arguments. --->
<cfargument
name="hand"
type="array"
required="true"
hint="I am the blackjack hand being counted (summed)."
/>
<!--- Define the local scope. --->
<cfset var local = {} />
<!--- Create a running total for the cards. --->
<cfset local.sum = 0 />
<!---
Loop over cards to get the initial count. On the first
pass, the Aces (A) will be counted as 11. If the hand
is bust, they will be re-tallied with Ace being 2.
--->
<cfloop
index="local.card"
array="#arguments.hand#">
<!--- Check to see what kind of card we have. --->
<cfif isNumeric( local.card )>
<!--- Simply add given value. --->
<cfset local.sum += local.card />
<cfelseif (toString( local.card ) eq "A")>
<!--- On the first pass, add the Ace as an 11. --->
<cfset local.sum += 11 />
<cfelse>
<!---
All other cards will be face cards at this time,
which will 10.
--->
<cfset local.sum += 10 />
</cfif>
</cfloop>
<!---
Now that we have summed the cards for the first time,
check to see if the hand is bust.
--->
<cfif (local.sum lte 21)>
<!--- The hand is not bust, so return sum. --->
<cfreturn local.sum />
</cfif>
<!---
The first pass resulted in a busted hand. This may or may
not have been due to the way we counted Aces. As such,
let's reset the sum and run through summing process again,
this time with the Ace counting for 2.
--->
<cfset local.sum = 0 />
<!--- Loop over cards a second time. --->
<cfloop
index="local.card"
array="#arguments.hand#">
<!--- Check to see what kind of card we have. --->
<cfif isNumeric( local.card )>
<!--- Simply add given value. --->
<cfset local.sum += local.card />
<cfelseif (local.card eq "A")>
<!--- On the second pass, add the Ace as an 2. --->
<cfset local.sum += 2 />
<cfelse>
<!---
All other cards will be face cards at this time,
which will 10.
--->
<cfset local.sum += 10 />
</cfif>
</cfloop>
<!--- Now, no matter what sum we have, just return it. --->
<cfreturn local.sum />
</cffunction>
<cffunction
name="isHandBust"
access="public"
returntype="boolean"
output="false"
hint="I determine if the given hand is bust (over 21).">
<!--- Define arguments. --->
<cfargument
name="hand"
type="array"
required="true"
hint="I am the blackjack hand being checked for bust."
/>
<!--- Return true if the hand sum is over 21. --->
<cfreturn (getHandTotal( arguments.hand ) gt 21) />
</cffunction>
I know that I'm not giving a lot of explanation as to how the code works, but hopefully you can look at the code and the comments and figure out what's going on. This post was more to explore the contextual response functionality that TextMarks provides rather than to explore Blackjack game architecture. One of the nice things about "contextual responses" is that your application doesn't have to know about it - the "context" is used in the TextMarks proxy and a normal request is made to your web-based application. The only thing that your web application needs to do is maintain some sort of session information across requests such that the contextual responses do have a valid meaning within your application.
Want to use code from this post? Check out the license.
Reader Comments
@Ben Great post, interesting idea on how to keep the phone in a session. I found the post was very easy to follow and very interesting.
I am interested on how the number did actually show up and where that interface from your video is available if it is for testing like that.
@Daniel,
The testing interface is actually provided by TextMarks itself on the TextMarks website. It's really easy to use - it acts just like a real phone. You might have to be logged-in, however because it send across your phone number.
The number shows up as something like:
+9175551234
Ben, nice experiment!
This won't work if more than one people are playing this game, right? How to make it work for multiple independent gamers?
@john,
From they way he has it set up it should be able to work with multiple users at the same time. Since phones can't keep track of their sessions he uses the application.phoneSessions[] variable to keep track of them for the phones. As he stated the unique id is what is used to tell the phones apart.
@John,
@Daniel is correct - each phone gets its own session so they will get their own version of the game.
This was a good example of short code. I looked at it as a tutorial of sorts.
Just found this. Great job Ben. I'm new to SMS stuff in general. I'm really interested in how the phone number is matched up to the unique id. Is that a CF thing? Where is that being handled? Maybe I missed something in the code. Apologies in advance.
@Alex,
The Phone number to unique ID pairing is handled by the TextMarks people. I am not sure how they are doing it; but apparently, each phone is guaranteed to be unique. Maybe it's something that the phone sends with SMS meta-data or something behind the scenes?
Hello, I can't get mine to work. How did you get yours to work? You can send GameGoal to the 41411. Please tell me what I am doing wrong so I can correct it.
Thanks
@Matthew,
There are many things that could be going wrong; there's no way I would be able to tell the one thing that is going wrong.
It seems to be a good idea but i am, seeking to know about Textmarks and Short codes.