How to Send xAPI Statements from Unity
One of the most frequent questions I get about is xAPI is “How can I send statements from Unity?” So I thought I should try it. First, a couple of disclaimers:
- This is a technical article about programming in Unity and C#.
- I knew nothing about Unity when I started this project. I am sure expert Unity programmers will see lots of flaws in my Unity code.
OK, you’ve been warned. Here we go.
Step 1 – Create a Unity Game
This was actually pretty easy. I just followed the steps for the Unity Roll-A-Ball tutorial. I did add a few things, like a timer, some spiffy music and two levels. But basically it is a knock-off of the Unity tutorial.
Step 2 – Add the xAPI
First, I had a decision to make. What xAPI library am I going to use to send statements? I started this project assuming I would use the GBLxAPI library. After all, it was designed to work from Unity. As I started exploring this library I found it to be a bit cumbersome. Now, if I were new to sending xAPI from C# I would probably have used the GBLxAPI library. But I am not. I’ve been sending statements using the TinCan library for .Net for several years. Could I make this work with Unity?
Step 3 – Add the Tincan Library to your Unity Project
My first challenge was to get the Tincan.dll file into my Unity project. I’ve been a C# programmer for a long time, so I thought this would be easy. Just right-click on the References item in the .Net project created by Unity and choose “add reference”. Nope. The option to add a reference is not there. So I searched the internet and found that in order to reference an external library, I would need to create folder called “Plugins” and drop my library in there. Did that, the library showed up in Unity and in my .Net project references.
In addition, I knew that the Tincan library uses some .Net libraries that were not included in the References of the Unity project. So back to the Interwebs to find out I need to create a file named csc.rsp in the Assets folder of the Unity project. This has two lines; each line tells Unity to include a particular .Net library.
-r:System.Web.dll -r:System.Net.Http.dll
Step 4 – Create functions to send statements
I wanted to send 3 xAPI statements from my Unity game. One for each level and a final statement that showed the total time required for the player to complete both levels. Let’s look at some code. This first function sets common properties for all three statements. The actor and authority are going to be the same for all statements, and I pass in verb properties. In the following code you’ll see some references to a “NameTransfer” object. This object holds static properties that store things like the Player’s name and other things that I don’t want disappearing between levels.
protected Statement GetStatementTemplate(string verb, string verbDisplay) { var s = new Statement { actor = new Agent { name = NameTransfer.playerLastName + ", " + NameTransfer.playerFirstName.Trim(), mbox = "mailto:" + NameTransfer.playerEmail }, authority = new Agent { account = new AgentAccount { name = xAPIContstants.LRSUserId, homePage = new Uri(xAPIContstants.agentHomePage) } }, verb = new Verb { id = new Uri(xAPIContstants.verbCompleted), display = new LanguageMap() }, timestamp = DateTime.UtcNow }; s.verb.display.Add("en-US", verbDisplay); return s; }
The next function will send a “Completed” statement when a level is completed.
protected void SendxAPILevelComplete(string sceneName, TimeSpan duration_) { // Cannot send statement if we have no actor. if (string.IsNullOrWhiteSpace(NameTransfer.playerEmail)) return; var s = GetStatementTemplate(xAPIContstants.verbCompleted, "completed"); // Level was completed s.result = new Result { completion = true, duration = duration_ }; // Set the game as the parent activity s.context = new Context { contextActivities = new ContextActivities { parent = new List<Activity> { new Activity { id = xAPIContstants.gameIRI, definition = new ActivityDefinition { name = new LanguageMap() } } } } }; s.context.contextActivities.parent[0].definition.name.Add("en-US", "RISC RollABall Game" ); var target_ = new Activity { id = xAPIContstants.levelIRI + "/" + NameTransfer.currentLevel, definition = new ActivityDefinition { type = new Uri(xAPIContstants.levelActivityType), name = new LanguageMap() } }; target_.definition.name.Add("en-US", sceneName); s.target = target_; SendxAPIStatement(s); if (!lrsResponse.success || !lrsSuccess) { Debug.Log(lrsMessage); playerText.text = lrsMessage; return; } }
Here is the function that sends the final “Satisfied” statement when all levels are complete.
protected void SendxAPISatisfied(TimeSpan duration_) { // Cannot send statement if we have no actor. if (string.IsNullOrWhiteSpace(NameTransfer.playerEmail)) return; var s = GetStatementTemplate(xAPIContstants.verbSatisfied, "satisfied"); var target_ = new Activity { id = xAPIContstants.gameIRI, definition = new ActivityDefinition { type = new Uri(xAPIContstants.levelActivityType), name = new LanguageMap() } }; target_.definition.name.Add("en-US", "RISC RollABall Game"); s.target = target_; s.result = new Result { completion = true, duration = duration_ // This is the time for all levels }; SendxAPIStatement(s); if (!lrsResponse.success || !lrsSuccess) { Debug.Log(lrsMessage); } }
And finally, this is the “helper” function that calls the TinCan library to send a statement.
protected void SendxAPIStatement(Statement s) { var lrs = new RemoteLRS { endpoint = new Uri(xAPIContstants.LRSEndPoint), version = TCAPIVersion.V101 }; lrs.SetAuth(xAPIContstants.LRSUserId, xAPIContstants.LRSPassword); lrsResponse = new StatementLRSResponse(); try { for (var try_ = 1; try_ < 3; try_++) { lrsMessage = ""; Debug.Log("Calling SaveStatement."); lrsResponse = lrs.SaveStatement(s); if (lrsResponse.success) { StatementsSent++; Debug.Log("Statement sent."); countText.text = "Statements Sent: " + StatementsSent; lrsSuccess = true; break; } if (lrsResponse.content?.id != null) { // Prevent possible duplicates s.id = lrsResponse.content.id; } lrsMessage = lrsResponse.errMsg; Debug.Log("Statement failed: " + lrsResponse.errMsg); Debug.Log(s.ToJSON()); System.Threading.Thread.Sleep(500); } } catch (Exception err) { lrsMessage = "Error sending statement (catch): " + err.Message; Debug.Log(err.Message); Debug.Log(s.ToJSON()); } return; }
You might be wondering why I try the SaveStatement() call in a loop. Well, it’s the internet. Sometimes things go wrong.
Step 5 – Call the code to Send Statements
The next thing to figure out is where to call the functions that send xAPI statements. First, I created a function that will call SendxAPILevelComplete() when a level is completed. It will also call SendxAPISatisfied() when both levels have been completed. The variable timeElapsed stores the amount of time a player spent on a level, while totalTime is the total time to complete both levels. These values are placed in the result.duration property of our xAPI statements.
private void SendxAPI() { var sceneName = SceneManager.GetActiveScene().name; var minutes = Mathf.FloorToInt(timeElapsed / 60); var seconds = Mathf.FloorToInt(timeElapsed % 60); var milliseconds = (timeElapsed % 1) * 1000; SendxAPILevelComplete(sceneName, new TimeSpan(0, 0, minutes, seconds, (int)milliseconds)); // Are we at the end? if (sceneName != "Level 2") return; minutes = Mathf.FloorToInt(totalTime / 60); seconds = Mathf.FloorToInt(totalTime % 60); milliseconds = (totalTime % 1) * 1000; SendxAPISatisfied(new TimeSpan(0, 0, minutes, seconds,(int)milliseconds)); }
Now the question is “when should SendxAPI() be called?” Initially, I tried putting this in the OnTriggerEnter() event of the game. This caused quite a delay in the level actually ending. For example, I use OnTriggerEnter() to stop the background music and instead play victory music when you complete a level. The music kept playing until the OnTriggerEnter event completed, so this was not the place to send the xAPI statements.
I fixed this by creating a boolean private property called “sendXAPI”. Then, in OnTriggerEnter() I set this property to true when I know a level has ended. Next, in the Update() method, which is called by Unity before a frame is updated, I placed the code below. This greatly smoothed out the game ending.
if (sendXAPI) { SendxAPI(); sendXAPI = false; }
Step 6 – The Gotchas
I ran the game in the Unity Editor, and it worked perfectly. Three statements were sent just as planned. Then I tried “Build and Run”…no statements were sent. What?
The issue is that Unity builds your code using Ahead-of-Time (AOT) compilation. The Newtonsoft package required for the TinCan library will not work with AOT. You can learn a lot more about it here. The solution is to use this library instead of Newtonsoft. It does not require any changes to the TinCan library.
So I build the game and try again. No statements sent. After much research on the WWW I find out that Unity uses something called “managed code stripping“. Basically, Unity analyzes your code and removes parts of the .Net libraries if it thinks you will not need them. However, in my case it was removing stuff I needed to send xAPI statements. In order to prevent the code stripping for a particular library, you create a link.xml file in the Assets folder. My example below says “don’t strip the System.Web library”.
<linker> <assembly fullname="System.Web" preserve="all"/> </linker>
Build again…it works! My statements were sent. Whew!
Lessons Learned
First, I have not tested all the “features” of xAPI using this method. For example, I have not tried sending a document to the State API; it wasn’t important to my game.
Second, sending xAPI statements this way due to the Gotchas took a lot of research on the web. Would it be easier using the GBLxAPI library? Maybe. I’ll try that next. But first, I need to rest for a while…