Getting xAPI out of a Cocos Creator game and into SLS
How I instrumented a Chinese Language game for the Student Learning Space — the prompts I used, the steps in order, and the two bugs that nearly sank it.
What I started with
Three things sat in one folder on my Mac:
DLXLB_GAME_3B14/— a Chinese Language game built in Cocos Creator 3.8.7. A Ferris wheel with five cabins; the student describes a rabbit by picking words. 291 files, 59 MB. https://vle.learning.moe.edu.sg/resource/manage?resource=MEDIA&location=MOE&keyword=DLXLB_GAME_3B14.zip&ownerGroups=1061,1174,1163&status=ALL&resourceType=ALL https://vle.learning.moe.edu.sg/moe-library/module/view/approved/e0f4dd4c-4e3e-4ebf-815e-e63b0f37298b/section/108198981/activity/108202454?version=e0f4dd4c-4e3e-4ebf-815e-e63b0f37298b&pageNo=1- https://iwant2study.org/lookangejss/appXapiIntegratorAgent/api/samples/timeline/scorable_newTab_timeline_countable-nouns-are-nouns-that-can-be-counted-with-pictures-replacements-by-acp.zip— a working xAPI package produced earlier by my xAPI Integrator tool, on ordinary HTML5 content. My known-good reference.
- Nothing else.
What I wanted was two layers of data in the Learning Record Store: every answer the student submitted — the question, the options, what they clicked — and then a mark.
The game had no xAPI in it at all.
First: why the obvious route doesn't work
My xAPI Integrator injects tracking by reading the page's HTML. On the reference package it found five questions, drag-and-drop interactions and a scoring block, all by inspecting DOM elements.
A Cocos Creator build has no DOM. The entire game is one <canvas>. Run that zip through the integrator and it happily adds the libraries, then tracks nothing but mouse coordinates on a rectangle.
So the work was never "run the tool". It was "write the piece the tool cannot write" — a bridge that sits inside the game's own JavaScript and hands the xAPI library a correct payload.
That reframing came out of the first prompt, and it saved me from a day of wondering why the tool produced an empty package.
The prompts, in order
Prompt 1 — study before you build
file:///Users/…/DLXLB_GAME_3B14/index.htmlhttps://iwant2study.org/lookangejss/appXapiIntegratorAgent/public/please study the game that is in the folder.
I would like to know if it is possible to inject xAPI code in/Users/…/scorable_newTab_timeline_…-by-acp.zipinside the game. xAPI statement need to have two layers. The first layer is what the student clicks in the game, the question, what the student's response was, and then eventually the score given to the student — one mark for each correct, zero for each wrong answer.
That prompt was dictated and messy. It did not matter. What mattered is that it carried four things:
- The target — the actual folder path, connected to the session so the files could be read.
- A working reference — the ACP zip. Without it, the assistant would have invented an xAPI format instead of matching the one SLS already accepts.
- The tool — so it could judge whether the tool applied here. It decided it didn't, and said why.
- The requirement in my own words — two layers, one mark per correct answer.
Notice what I did not ask for: code. I asked whether it was possible. The study came back with the five question files, the exact class and method where answers are checked, and the news that the integrator wouldn't help. That's a better starting point than a file full of guesses.
Handover cue for anyone replicating this: ask for the study first, always. The build is cheap once the study is right.
Prompt 2 — answer the design question honestly
I was asked something I hadn't thought about: the game never lets a wrong answer through. Answer wrongly and it shows 「答案错误,请重试!」, clears your selection, and asks again until you get it right.
Score at the end and every child gets 5 out of 5.
I chose attempt-decayed marking: 1 mark if the first submit is correct, 0.5 on the second, 0 from the third onward. The multi-select question stays all-or-nothing at 1 mark, because that is what the game tells the child.
This is the step most people skip. The scoring rule is a pedagogical decision, not a technical one, and no assistant can make it for you.
Prompt 3 — state the constraint
Do not change the code in the xAPI. I only need you to rewrite the payload. SLS only accepts xAPI in a certain format.
Short, and it shaped everything that followed. lib/xAPI.js comes from the ACP tool; if a future regeneration overwrites it, any edit I made there is lost. So the rule was: the vendor file is read-only, my code lives in a separate file.
Prompt 4 — check before you ship
is the contents of this folder ready to be zipped to be uploaded to SLS to test?
It wasn't — the folder was still the raw build, with zero xAPI references in index.html. Asking saved me an upload of a 44 MB package that would have reported nothing.
Prompt 5 — ask for the artefact, named
Please create the necessary files in the folders and zip it up for me, giving it a different name, so that I know this is the file to upload.
The "different name" instruction is small and worth copying. When you're cycling through test uploads, UPLOAD-TO-SLS_DLXLB_3B14_xAPI_v1.zip next to DLXLB_GAME_3B14.zip removes a whole class of mistake.
Prompt 6 — the one that actually mattered
The first version went into SLS and came back wrong. This is what I sent:
无参考答案
The xAPI statement only has these five words, it is completely wrong. I went through the entire quiz and clicked through all the questions — xAPI captures it as zero marks for 5 questions. There should also be marks or scores sent but it shows 0.
index.js:23 Cannot adjust train position: target position node is not set xAPI.js:101 [xAPI] Missing agent. Using anonymous fallback.
Three things in that message did the work: what I saw (the literal string), what I did (played the whole quiz), and the raw console output, pasted verbatim. No summarising, no "it seems like". That message produced two precise diagnoses within minutes.
The steps, in order
- Put the game folder and the reference xAPI package in one folder, and connect that folder to the session.
- Ask for a study. Get back: where the questions live, where answers are checked, and whether your existing tooling applies.
- Decide the mark scheme yourself.
- State your constraints — which files are off-limits, and why.
- Get the bridge file, the vendor libraries and the patched
index.html. - Zip from the terminal, not Finder:
zip -r -Xavoids__MACOSXfolders and.DS_Store. - Upload, play the whole activity, and read the console.
- Paste what you see straight back, including errors that look unrelated.
- Re-zip, re-upload, verify.
What the bridge actually does
The game registers its classes with Cocos, so they can be reached by name and their methods wrapped:
var F = cc.js.getClassByName('GameLevelFriend');
var origCheck = F.prototype.checkAnswers;
F.prototype.checkAnswers = function () {
var snap = evaluate(this); // read the verdict BEFORE the game clears it
var out = origCheck.apply(this, arguments);
recordSubmit(this, snap);
return out;
};
The evaluate(this) call has to run before origCheck, because on a wrong answer the game immediately empties selectedAnswers. Read it afterwards and you get an empty response every time.
At startup the bridge walks Game1.gameLevels, reads each level's question JSON and builds a registry — so the maximum mark is known before the first submit rather than guessed at the end.
Three traps inside the vendor library
These are behaviours in lib/xAPI.js that you cannot fix from outside — only avoid.
A score of 0 is silently replaced. When score <= 0, the library assumes the content failed to report a score and substitutes its own: the count of questions whose last history event was correct. Because this game forces every question right eventually, a genuine zero would have been sent to SLS as full marks.
The fix lives in the data, not the code. After each question is cleared, the bridge appends a closing event whose correct flag reports whether a mark was earned, not whether the final click was right. Now the substituted value is also 0, and 0 is written over 0.
A shorter history is discarded wholesale. Before sending, the library compares your payload against a cached copy in localStorage. If the cached history is longer, it sends the cached object instead — old score and all. So the history array must only ever grow, across reloads and restarts.
Two words poison the payload. A reason containing pause or hidden makes the library return the cached state unconditionally. Use plain reasons like answer-q3-attempt2 and final.
The part I got wrong
My first design assumed the payload alone could carry everything. It could not, and 无参考答案 was SLS telling me so.
That string appears nowhere in the 294 files I uploaded — it is SLS's own placeholder for a missing model answer. The reason it was missing is structural. lib/xAPI.js contains zero occurrences of definition, interactionType or correctResponsesPattern, and both statements it builds use a bare object:
object: { id: params.activityId }
No interaction definition means no question, no options, no reference answer — no payload can add a field to a statement that has no place to put it.
The fix respects the original constraint. lib/xAPI.js is still untouched; the bridge builds its own statements and posts them through the ADL wrapper the vendor file already configured:
object.definition
interactionType "choice"
correctResponsesPattern ["0[,]1[,]2[,]4"]
choices 0 毛灰灰的 · 1 耳朵长长的 · 2 尾巴短短的
3 鼻子长长的 · 4 嘴边有长长的胡子
result
response "0[,]1[,]2[,]4" success true score.raw 1 / 1
And a bug that was entirely mine. The zero marks had a separate cause. My code restored every question found in the cached state as closed, including questions that had been saved while still wrong. Once locked that way, answering correctly could never earn anything — every later submit was treated as a replay. SLS reloads the page when content opens in a new tab, which is precisely the path that poisons that cache, so it failed on the first real test.
Both bugs shared a shape: they only appear on the second run, in the real environment. Neither would have shown up in a single clean pass on my laptop.
The result
Q2: 它长什么样子?
Student answer: 毛灰灰的、尾巴短短的、耳朵长长的、嘴边有长长的胡子
✅ Correct (Attempt 1)
Score: 5 / 5
Interactions: 13
t=38s 选择「毛灰灰的」 ✓ · 它长什么样子?
t=41s 选择「尾巴短短的」 ✓ · 它长什么样子?
t=44s 选择「耳朵长长的」 ✓ · 它长什么样子?
t=49s 选择「嘴边有长长的胡子」 ✓ · 它长什么样子?
t=51s 第 1 次确认 · 它长什么样子? → … ✓
Every click timestamped, every submit scored, the reference answer attached. A teacher can now see not just that a child got question 2 right, but that they built the answer over thirteen seconds, one attribute at a time, and never selected 鼻子长长的.
Still open
The console also reported [xAPI] Missing agent. Using anonymous fallback. SLS did not pass an agent parameter, so statements are attributed to a placeholder rather than the student.
That one is not a code problem. SLS only supplies the full parameter set under certain question-type and upload configurations, and the agent cannot be reconstructed from the JWT. Worth resolving before reading anything into the data — a perfectly formed record attached to nobody is still not evidence of learning.
For anyone replicating this
- Bring a working reference. The single highest-leverage thing in the whole exercise was having a package that already worked in SLS. It turned an open-ended design problem into "match this".
- Ask "is it possible" before "write it". The study is where you find out your existing tool doesn't apply.
- Make the pedagogical decisions yourself. Retry-until-correct versus first-attempt marking is a teaching question wearing a technical costume.
- Paste errors verbatim. Including the ones that look irrelevant.
Cannot adjust train positionturned out to be harmless game noise — but the line underneath it was the agent problem, and I'd have summarised it away. - Test in the real environment, twice. Both of the bugs that reached production only appear on a second run inside SLS. A clean first pass proves very little.
- Keep vendor files read-only. Put your code in its own file. When the generator regenerates, you lose nothing.
- CG module:https://vle.learning.moe.edu.sg/community-gallery/module/view/c5398418-313f-4a86-b9e1-b039a6755db0/section/110141603/activity/110141612?pageNo=1
The whole thing — study, build, two rounds of debugging, working package — took an afternoon. Most of that was the two bugs, and both of them were found by pasting a console log into a chat window.