Timeback
Track learning activities and award XP with educational integration
Overview
Integrate your project with Timeback to track learning activities and award XP based on student performance.
Timeback is 1EdTech-compliant, enabling deep interoperability between learning applications and educational systems.
Getting Started
$ playcademy init# ...Integrations:? Would you like to set up Timeback integration? Yes? Select subjects: Math? Select grade levels: 3, 4$ playcademy timeback initConfiguration
After enabling Timeback, your playcademy.config.js file will include Timeback configuration:
export default {
name: 'My Math Project',
integrations: {
// ↓ Added
timeback: {
courses: [
{
subject: 'Math',
grade: 3,
totalXp: null, // TODO: Set before setup
masterableUnits: null, // TODO: Set before setup
},
{
subject: 'Math',
grade: 4,
totalXp: null, // TODO: Set before setup
masterableUnits: null, // TODO: Set before setup
},
],
},
},
}Required Configuration Before Setup
You must configure totalXp and masterableUnits for each course before running playcademy timeback setup.
Both are critical for accurate progress tracking and completion calculation.
Configure these values based on your educational content:
timeback: {
courses: [
{
subject: 'Math',
grade: 3,
totalXp: 1000, // Total XP available in grade 3
masterableUnits: 10, // Total levels/ranks/skills in grade 3
},
// ... other courses
],
}Run Setup
Once configured, create the OneRoster resources:
$ playcademy timeback setup$ playcademy timeback verify✔ Created 3 course(s)✔ Timeback integration set up successfully! Grade 3 (Math): course-id-1 Grade 4 (Math): course-id-2 Grade 5 (Math): course-id-3✔ Verification completeRun Before Deploying
Run playcademy timeback setup before deploying your
project.
Custom Configuration
If you need to customize the Timeback configuration, you can do so by modifying your playcademy.config.js file.
(Note that customizing the configuration is not required, and the default configuration will work for most use cases.)
/**
* Shared configuration applies to all courses
*/
export default {
name: 'Math Adventure',
integrations: {
timeback: {
base: {
organization: {
name: 'My School District',
type: 'district',
},
course: {
title: '{appName} - Grade {grade}', // Template variables
totalXp: 100, // Default XP for all courses
masterableUnits: 10, // Default units for all courses
},
},
courses: [
{ subject: 'Math', grade: 3 },
{ subject: 'Math', grade: 4 },
],
},
},
}/**
* Customize individual courses
*/
export default {
name: 'Math Adventure',
integrations: {
timeback: {
courses: [
{
subject: 'Math',
grade: 3,
totalXp: 100,
masterableUnits: 10,
},
{
subject: 'Math',
grade: 4,
title: 'Advanced 4th Grade Math',
courseCode: 'ADV-MATH-4',
totalXp: 150,
masterableUnits: 15,
},
],
},
},
}/**
* Base config + per-course overrides
*/
export default {
name: 'Math Adventure',
integrations: {
timeback: {
base: {
organization: {
name: 'My School',
},
course: {
title: '{appName} - Grade {grade}',
totalXp: 100, // Default
masterableUnits: 10, // Default
},
},
courses: [
{ subject: 'Math', grade: 3 },
{
subject: 'Math',
grade: 4,
title: 'Custom Title', // Overrides base
totalXp: 150, // Overrides base
masterableUnits: 15, // Overrides base
},
],
},
},
}Template Variables
Use {grade}, {subject}, {appSlug}, {appName} in base config strings.
The CLI will expand these variables for you when creating OneRoster resources.
Management Commands
| Command | Description | When to Use |
|---|---|---|
timeback setup | Create OneRoster courses | After enabling Timeback |
timeback verify | Check course status | After running timeback setup |
timeback update | Sync config changes | After modifying courses in config |
timeback cleanup | Remove Timeback integration | Remove Timeback but keep your project |
View Full Command Documentation
See Timeback CLI Reference for all options.
Common Workflows
Run this after configuring your courses in playcademy.config.js to initialize all OneRoster resources.
This creates courses, components, and the interactive resource for your app.
$ playcademy timeback setupAfter modifying your course configuration (e.g., changing masterableUnits or adding grades), sync those changes to OneRoster.
Use this whenever you update your Timeback config to keep your OneRoster resources in sync.
$ playcademy timeback updateCheck that all Timeback resources were created successfully and are properly configured in OneRoster.
Run this after setup to confirm everything is ready.
$ playcademy timeback verifyClean up Timeback integration from OneRoster if you need to remove it.
This deletes the resources but keeps your project intact.
$ playcademy timeback cleanupLearning Loops
Every app has two fundamental time scales. Understanding them helps you think about what to track and when.
| Concept | Scope | Duration | You Track |
|---|---|---|---|
| Session | Chain of activities in one sitting | Minutes | XP, accuracy, time |
| Unit | Mastery-based goal tied to a standard (may require multiple sessions) | Hours/Days/Weeks | masteredUnits |
Sessions
A session is a single, focused learning activity that students complete in one sitting.
Call startActivity() when it begins and endActivity() when it ends.
Every session awards 0 or more XP based on accuracy and active time.
Units
A unit is a discrete mastery-based milestone that students work toward across multiple sessions.
When a student completes a unit (beats a level, earns a rank, etc.), report masteredUnits: 1 in endActivity().
The platform accumulates these to calculate pctCompleteApp: (masteredUnits / masterableUnits) × 100.
When to Report Mastery
Mastery is typically achieved when a session meets minimum accuracy and maximum time thresholds.
For example: completing a quiz with ≥90% accuracy in under 5 minutes might demonstrate mastery.
Your app defines the thresholds that determine when a unit is mastered.
Progress
Set masterableUnits in playcademy.config.js to define the total number of units in your course.
As students complete units, report masteredUnits: 1 to track their progress toward course completion.
What counts as a "unit" depends on your app's structure:
| App Structure | Example Units |
|---|---|
| Level-based | Levels, stages, worlds |
| Rank-based | Ranks, tiers, badges |
| Skills-based | Skills, competencies, standards |
| Module-based | Modules, quizzes, chapters |
| Custom structures | Any discrete learning milestone |
Configure masterableUnits per course:
export default {
name: 'Astro Math',
integrations: {
timeback: {
courses: [
{
subject: 'FastMath',
grade: 3,
totalXp: 300,
masterableUnits: 3, // Grade 3: 3 units
},
{
subject: 'FastMath',
grade: 4,
totalXp: 450,
masterableUnits: 5, // Grade 4: 5 units
},
],
},
},
}Activity Tracking vs Mastery Tracking?
When students complete a discrete learning unit, report masteredUnits: 1 in endActivity().
The platform tracks cumulative progress and calculates pctCompleteApp as (masteredUnits / masterableUnits) × 100
Using Timeback in Your App
The @playcademy/sdk provides everything you need to access student context and track learning activities.
Learn more about the client.timeback namespace.
User Context
Access the user's Timeback context via client.timeback.user.
id
The user's unique Timeback identifier:
const id = client.timeback.user.id
// 'abc123-def456-...'role
The user's primary Timeback role:
const role = client.timeback.user.role
// 'student' | 'parent' | 'teacher' | 'administrator' | 'guardian'enrollments
Array of courses the user is enrolled in, scoped to your project:
const enrollments = client.timeback.user.enrollments
// [{ id: 'enrollment-abc', subject: 'FastMath', grade: 3, courseId: '...' }, ...]
const freshUser = await client.timeback.user.refresh({ only: ['enrollments'] })App-Scoped
Enrollments are filtered to courses defined in your playcademy.config.js.
Enrollment refresh uses the same 5-minute cache as user.fetch(): pass { force: true } to bypass it.
organizations
Array of all organizations (schools/districts) the user is affiliated with:
const orgs = client.timeback.user.organizations
// [{ id: '...', name: 'Playcademy Studios', type: 'school', isPrimary: true }, ...]App-Scoped
Like enrollments, organizations are app-scoped.
Only organizations associated with the user's enrollments for your project are included.
fetch()
Fetch fresh user data from the server. Results are cached for 5 minutes by default.
// Fetch fresh data (cached for 5 min)
const fresh = await client.timeback.user.fetch()
// Force refresh bypassing cache
const forced = await client.timeback.user.fetch({ force: true })When to Fetch
The user context is initialized when the app loads.
Use fetch() if you need to ensure you have the latest data, such as after a user might have been enrolled in a new course mid-session.
xp
Access the current student's XP data via client.timeback.user.xp.fetch().
// Get XP for all courses
const xp = await client.timeback.user.xp.fetch()
// → { totalXp: 1500 }
// Get XP for a specific grade/subject (must match a course in your config)
const xp = await client.timeback.user.xp.fetch({
grade: 3,
subject: 'Math',
})
// → { totalXp: 800 }
// Get XP with per-course breakdown + today's XP
const xp = await client.timeback.user.xp.fetch({
include: ['perCourse', 'today'],
})
// → {
// totalXp: 1500,
// todayXp: 50,
// courses: [
// { grade: 3, subject: 'Math', title: 'Math Grade 3', totalXp: 800, todayXp: 30 },
// { grade: 4, subject: 'Math', title: 'Math Grade 4', totalXp: 700, todayXp: 20 },
// ]
// }
// Force fresh data (bypass 5s cache)
const fresh = await client.timeback.user.xp.fetch({ force: true })App-Scoped
XP is scoped to courses defined in your playcademy.config.{js,json} file.
This method only returns the student's XP for your app, not their total XP across all of Timeback.
Options
| Field | Type | Description | Default |
|---|---|---|---|
grade | number | Grade level to filter (must be used with subject) | All grades |
subject | string | Subject to filter (must be used with grade) | All subjects |
include | string[] | Additional data: 'perCourse', 'today' | [] |
force | boolean | Bypass cache and fetch fresh data | false |
Response
| Field | Type | Description |
|---|---|---|
totalXp | number | Total XP across queried courses |
todayXp | number | Today's XP (if 'today' included) |
courses | array | Per-course breakdown (if 'perCourse' included) |
Caching
XP data is cached for 5 seconds to avoid redundant network requests. Use force: true to bypass the cache.
mastery
Access the current student's mastery progress via client.timeback.user.mastery.fetch().
// Get mastery for all courses
const mastery = await client.timeback.user.mastery.fetch()
// → { totalMasteredUnits: 7, totalMasterableUnits: 10 }
// Get mastery for a specific grade/subject (must match a course in your config)
const mastery = await client.timeback.user.mastery.fetch({
grade: 3,
subject: 'Math',
})
// → { totalMasteredUnits: 3, totalMasterableUnits: 3 }
// Get mastery with per-course breakdown
const mastery = await client.timeback.user.mastery.fetch({
include: ['perCourse'],
})
// → {
// totalMasteredUnits: 7,
// totalMasterableUnits: 10,
// courses: [
// { grade: 3, subject: 'Math', title: 'Math Grade 3',
// masteredUnits: 3, masterableUnits: 3, pctComplete: 100, isComplete: true },
// { grade: 4, subject: 'Math', title: 'Math Grade 4',
// masteredUnits: 4, masterableUnits: 7, pctComplete: 57.14, isComplete: false },
// ]
// }
// Force fresh data (bypass 5s cache)
const fresh = await client.timeback.user.mastery.fetch({ force: true })App-Scoped
Mastery is scoped to courses defined in your playcademy.config.{js,json} file.
This method only returns the student's mastery for your app, not their total mastery across all of Timeback.
Options
| Field | Type | Description | Default |
|---|---|---|---|
grade | number | Grade level to filter (must be used with subject) | All grades |
subject | string | Subject to filter (must be used with grade) | All subjects |
include | string[] | Additional data: 'perCourse' | [] |
force | boolean | Bypass cache and fetch fresh data | false |
Response
| Field | Type | Description |
|---|---|---|
totalMasteredUnits | number | Total mastered units across queried courses |
totalMasterableUnits | number | Total masterable units across queried courses |
courses | array | Per-course breakdown (if 'perCourse' included) |
Caching
Mastery data is cached for 5 seconds to avoid redundant network requests. Use force: true to bypass the cache.
highestGradeMastered
Access the current student's highest grade mastered for a subject via client.timeback.user.highestGradeMastered.fetch().
const hgm = await client.timeback.user.highestGradeMastered.fetch({
subject: 'Math',
})
// → { subject: 'Math', highestGradeMastered: 4 }
if (hgm.highestGradeMastered === null) {
// No mastered grade has been established for this subject yet.
// Fall back to your normal starting point, such as enrollment grade.
}
// Force fresh data (bypass 5s cache)
const fresh = await client.timeback.user.highestGradeMastered.fetch({
subject: 'Math',
force: true,
})Subject-Scoped
Highest grade mastered is queried by subject and uses Timeback EduBridge's highestGradeOverall value.
The requested subject must match at least one course in your playcademy.config.{js,json} file.
Options
| Field | Type | Description | Default |
|---|---|---|---|
subject | string | Subject to query | Required |
force | boolean | Bypass cache and fetch fresh data | false |
Response
| Field | Type | Description |
|---|---|---|
subject | string | Subject used for the lookup |
highestGradeMastered | number | null | Highest grade mastered, or null when no data exists |
{
"subject": "Math",
"highestGradeMastered": null
}Null Means Not Established
highestGradeMastered: null is a valid response, not a failed lookup. It means Timeback does not have a mastered-grade signal for that subject yet. Games should not coerce it to 0 or grade 1, and should not block the player. Fall back to your existing placement behavior, such as the student's enrolled grade, the lowest configured grade, or your normal onboarding flow.
Caching
Highest-grade-mastered data is cached for 5 seconds to avoid redundant network requests. Use force: true to bypass the cache.
Activity Tracking
Automatic Tracking Behavior
Once you call startActivity(), the SDK handles the rest without any game-side wiring:
- Periodic heartbeats: Active/paused time is flushed every 15 seconds
- Visibility-aware pause: Timer auto-pauses when the tab is hidden; the window resets if hidden past 10 minutes
- Unload flush: A final heartbeat is sent on
pagehide, so closing the tab still reports the last window
Each of these behaviors can be tuned via the options parameter to startActivity().
startActivity
Begin tracking a learning activity. Starts an internal timer and prepares data for OneRoster submission.
// Minimal (most common)
const { runId } = client.timeback.startActivity({
activityId: 'math-quiz-1',
grade: 3,
subject: 'Math',
})
// Activity name auto-derived: "Math Quiz 1"
// With custom name override
client.timeback.startActivity({
activityId: 'multiplication-drill',
activityName: 'Advanced Multiplication Drill',
grade: 4,
subject: 'Math',
})Required Fields
| Field | Type | Description | Example |
|---|---|---|---|
activityId | string | Identifier for this activity | "math-quiz-1" |
grade | number | Grade level | 3 |
subject | string | Subject area | "Math" |
Optional Fields
| Field | Type | Description | Default |
|---|---|---|---|
activityName | string | Display name for the activity | Prettified activityId |
appName | string | Application name | From playcademy.config.{js,json} |
sensorUrl | string | URL where activity is hosted | Deployed project URL |
Course Routing
The grade and subject fields determine which OneRoster course receives the activity data.
Ensure these match a course in your playcademy.config.{js,json} Timeback configuration.
Options
startActivity() accepts an optional second argument to tune tracking behavior:
// Stop paused heartbeats after 5 minutes of hidden/idle auto-pause
client.timeback.startActivity(
{ activityId: 'math-quiz-1', grade: 3, subject: 'Math' },
{ pausedHeartbeatTimeoutMs: 5 * 60 * 1000 },
)
// Mark the activity inactive after 2 minutes without visible keyboard/mouse input
client.timeback.startActivity(
{ activityId: 'math-quiz-1', grade: 3, subject: 'Math' },
{ inactivityTimeoutMs: 2 * 60 * 1000 },
)
// Disable the periodic heartbeat interval (final unload/endActivity
// flushes still run)
client.timeback.startActivity(
{ activityId: 'math-quiz-1', grade: 3, subject: 'Math' },
{ heartbeatIntervalMs: Infinity },
)
// Resumable activity - reuse a stable runId across sessions so the
// dashboard can correlate related sessions into a single run
const activityId = 'math-quiz-1'
const runId = localStorage.getItem(`run:${activityId}`) ?? crypto.randomUUID()
localStorage.setItem(`run:${activityId}`, runId)
client.timeback.startActivity({ activityId, grade: 3, subject: 'Math' }, { runId })startActivity() returns { runId }, using your supplied UUID when provided or a new SDK-generated UUID otherwise.
During an active activity, the same value is available as client.timeback.currentRunId; it is undefined when no activity is active.
| Field | Type | Description | Default |
|---|---|---|---|
pausedHeartbeatTimeoutMs | number | Stop periodic heartbeats after a long automatic pause (Infinity disables) | 600000 (10 m) |
heartbeatIntervalMs | number | Heartbeat flush interval (Infinity disables) | 15000 (15 s) |
inactivityTimeoutMs | number | Visible keyboard/mouse idle timeout before pausing | 600000 (10 m) |
runId | string | Stable UUID to correlate resumable activities | Random UUID |
Resumable Activities
If a single logical activity spans multiple sessions (student closes the app and comes back later), persist a runId in a storage medium of your choice and pass the same value on every startActivity() call.
Downstream systems use runId to collate heartbeats and endActivity events into a single run, so the dashboard shows the correct total time-on-task instead of just the final session.
When omitted, the SDK generates a fresh UUID on every call and each session is treated as its own run.
endActivity
End the current activity and submit results to OneRoster. Calculates XP based on accuracy and active time.
// Auto-calculate XP based on score
await client.timeback.endActivity({
correctQuestions: 8,
totalQuestions: 10,
})
// Override XP calculation
await client.timeback.endActivity({
correctQuestions: 8,
totalQuestions: 10,
xpAwarded: 15, // award exactly 15 XP
})
// Report mastery (e.g., unit completed)
await client.timeback.endActivity({
correctQuestions: 8,
totalQuestions: 10,
masteredUnits: 1, // student mastered 1 unit
})
// Report absolute mastery (platform computes the delta)
await client.timeback.endActivity({
correctQuestions: 8,
totalQuestions: 10,
masteredUnitsAbsolute: 7, // student has mastered 7 units total
})
// Attach custom Caliper extensions
await client.timeback.endActivity({
correctQuestions: 3,
totalQuestions: 10,
extensions: {
reason: 'below-threshold',
threshold: 8,
scored: 3,
},
})When to Report Mastery
Send masteredUnits: 1 when the student completes a discrete learning unit in your app:
- Level-based: Student completes a level, stage, or world
- Rank-based: Student earns a rank, tier, or badge
- Skills-based: Student masters a skill, competency, or standard
- Module-based: Student completes a module, quiz, or chapter
Pass a negative value (e.g. masteredUnits: -1) to subtract mastery after a failed reassessment.
The platform tracks mastery and calculates completion automatically based on your mastery configuration.
Alternatively: use masteredUnitsAbsolute to report the student's total mastered units. The platform computes the delta from the current recorded value. This is useful when your app tracks cumulative progress and doesn't want to calculate increments itself. You cannot provide both fields in the same call.
Required Fields
| Field | Type | Description | Example |
|---|---|---|---|
correctQuestions | number | Number of correct answers | 8 |
totalQuestions | number | Total number of questions | 10 |
Optional Fields
| Field | Type | Description | Default |
|---|---|---|---|
xpAwarded | number | Override automatic XP calculation | Based on time and accuracy |
masteredUnits | number | Units mastered (negative to subtract) | 0 |
masteredUnitsAbsolute | number | Total mastered units (platform computes delta) | Not sent |
extensions | object | Custom Caliper metadata | Not sent |
Custom Extensions
Use extensions as an escape hatch for custom Caliper event metadata that should be recorded with the completed activity.
These values are attached to the progress event only. They do not affect course routing, timing, or XP calculation unless your downstream Timeback processing explicitly reads them.
XP Calculation
By default, XP is calculated as:
Base XP = Active time in minutes × Accuracy multiplier
| Accuracy | Multiplier | Example (10 min) |
|---|---|---|
| 100% | 1.25× | 10 min × 1.25 = 12.5 XP |
| 80-99% | 1.0× | 10 min × 1.0 = 10 XP |
| < 80% | 0× | 0 XP (mastery not demonstrated) |
Base rate: 1 minute of active learning = 1 XP
Re-attempts earn diminishing XP: 50% on 1st re-attempt, 25% on 2nd, 0% on 3rd+.
course.advance
Advance the current student to the next configured course in their Timeback ladder.
Call this after your app has reported enough mastery with endActivity(). The platform resolves the student's current course from their active enrollments, verifies the course is complete, then enrolls them in the next grade before unenrolling them from the current grade.
// Single-ladder game: no arguments needed
const result = await client.timeback.course.advance()
if (result.promotion.status === 'promoted') {
// Student moved from result.promotion.currentCourseId
// to result.promotion.nextCourseId
}
if (result.promotion.status === 'no-next-course') {
// Student completed the final configured course for this ladder
}
if (result.promotion.status === 'not-mastered') {
// Student has more units to master before moving on
}
// Multi-ladder game: disambiguate by subject when needed
await client.timeback.course.advance({ subject: 'Math' })Promotion Results
| Status | Meaning |
|---|---|
promoted | The student was enrolled in the next course and removed from this one |
already-promoted | The student is already enrolled in the next course |
no-next-course | The current course is the last configured course in this ladder |
not-mastered | The student has not mastered all units in the current course yet |
Errors vs Results
Promotion outcomes are returned as values so your app can react in normal control flow. For example, no-next-course means the student reached the end of the configured ladder, and not-mastered means the student needs to complete more units before advancing.
Configuration and validation problems still reject as SDK ApiErrors. This includes missing Timeback setup, ambiguous multi-subject enrollment without { subject }, no active enrollment in this game's ladder, and unavailable mastery analytics.
course.unenroll
Unenroll the current student from their active course in the Timeback ladder.
By default, the platform verifies that the current course is mastered before removing the student. Pass force: true only when your game intentionally allows a student to leave the course early.
// Single-ladder game: no arguments needed
const result = await client.timeback.course.unenroll()
if (result.unenrollment.status === 'unenrolled') {
// Student was removed from result.unenrollment.currentCourseId
}
if (result.unenrollment.status === 'not-mastered') {
// Student has more units to master before unenrolling
}
// Multi-ladder early exit: disambiguate and explicitly bypass mastery
await client.timeback.course.unenroll({ subject: 'Math', force: true })Unenrollment Results
| Status | Meaning |
|---|---|
unenrolled | The student was removed from the current course |
not-mastered | The student has not mastered all units in the current course yet |
Forced early exits are written to service logs with the student, game, course, subject, grade, mastery counts when available, and requesting developer.
pauseActivity
Pause the activity timer. Use this during non-instructional moments like showing feedback or explanations.
client.timeback.startActivity({
activityId: 'speed-math-1',
grade: 4,
subject: 'Math',
})
// Student attempts a problem...
if (studentAnswerWrong) {
// Pause timer during feedback
client.timeback.pauseActivity()
// Show correct answer or explanation
await showCorrectAnswer()
// Resume timer when they continue
client.timeback.resumeActivity()
}
// End activity (only active time counted)
await client.timeback.endActivity({
correctQuestions: 40,
totalQuestions: 50,
})When to Pause
Pause when:
- Tutorial/instruction screens
- Showing hints or explanations
- Waiting for external resources to load
- Any non-active learning time
This ensures XP reflects actual learning time.
resumeActivity
Resume the activity timer after a pause.
// After pausing
client.timeback.pauseActivity()
// ... show feedback ...
// Resume when ready
client.timeback.resumeActivity()Must Call startActivity First
You must call startActivity() before using pauseActivity() or resumeActivity().
Calling these methods without an active activity will log a warning.
Game Metrics Reporting
If your game stores its own progress data, you can expose it to the platform so game-side totals and per-run activity metrics can be compared against Timeback analytics in the dashboard.
Add server/lib/metrics.ts and export a getMetrics function that returns your game's totals. When the dashboard is comparing visible activity runs, ctx.runIds contains the Timeback run IDs being requested so your resolver can filter its response.
import { eq } from 'drizzle-orm'
import { getDb, schema } from '../db'
import type { GameCourseMetrics, GameMetricsResponse, GameRunMetrics } from '@playcademy/sdk/types'
interface CourseAccumulator {
grade: GameCourseMetrics['grade']
subject: GameCourseMetrics['subject']
totalXp: number
masteredUnits: number
activeTimeSeconds: number
activities: GameRunMetrics[]
}
export async function getMetrics(ctx: MetricsResolverContext): Promise<GameMetricsResponse> {
const db = getDb(ctx.env.DB)
const rows = await db
.select()
.from(schema.progress)
.where(eq(schema.progress.playcademyUserId, ctx.studentId))
const requestedRunIds = new Set(ctx.runIds)
const courses = new Map<string, CourseAccumulator>()
for (const row of rows) {
const courseKey = `${row.grade}:${row.subject}`
let course = courses.get(courseKey)
if (!course) {
course = {
grade: row.grade as GameCourseMetrics['grade'],
subject: row.subject as GameCourseMetrics['subject'],
totalXp: 0,
masteredUnits: 0,
activeTimeSeconds: 0,
activities: [],
}
courses.set(courseKey, course)
}
course.totalXp += row.totalXp
course.masteredUnits += row.masteredUnits
course.activeTimeSeconds += row.activeTimeSeconds
if (requestedRunIds.size > 0 && !requestedRunIds.has(row.runId)) {
continue
}
course.activities.push({
runId: row.runId,
activityId: row.activityId,
totalXp: row.totalXp,
masteredUnits: row.masteredUnits,
activeTimeSeconds: row.activeTimeSeconds,
...(row.score == null ? {} : { score: row.score }),
})
}
return {
studentId: ctx.studentId,
courses: [...courses.values()],
}
}import type { GameMetricsResponse } from '@playcademy/sdk/types'
export async function getMetrics(ctx: MetricsResolverContext): Promise<GameMetricsResponse> {
const data = await ctx.env.KV.get(`${ctx.studentId}:data`, 'json')
return {
studentId: ctx.studentId,
courses: data?.courses ?? [],
}
}The studentId on the context object is the Playcademy user ID for the student being queried. Match grade and subject to the courses in your playcademy.config.js so the dashboard can pair each entry with the corresponding Timeback analytics.
When ctx.runIds is present, keep course totals course-wide and filter only the returned activities list to the requested runs.
Metric fields are optional. If your game only tracks XP and mastered units, omit activeTimeSeconds; the dashboard will show that time was not reported by the game instead of treating it as zero.
Optional
If you don't add a metrics resolver, the dashboard simply skips the comparison.
Local Development
The Vite Plugin automatically enrolls mock users in all courses defined in your playcademy.config.js.
export default defineConfig({
plugins: [
playcademy(), // All courses enrolled automatically with mock data
],
})Customization
Override defaults for specific testing scenarios:
playcademy({
timeback: {
id: '...', // real student sourcedId for live testing
role: 'teacher', // test different user roles
organization: { id: '...', name: '...', type: 'school' }, // custom organization
courses: {
// FastMath:3 is enrolled by default if it is defined in your playcademy.config.js
'FastMath:4': false, // not enrolled
'FastMath:5': '00000033-0003-0003-0003-000000000003', // real course ID for integration testing
},
},
})Use null or false to exclude a course from enrollment.
This can be useful for testing how your app behaves when a student is enrolled in specific grades only.
Full Configuration Options
Learn more about Timeback configuration.
Live Timeback Integration
To test against live Timeback services, you must also your .env file with credentials:
# Required: Timeback API credentials
TIMEBACK_API_CLIENT_ID=your-client-id
TIMEBACK_API_CLIENT_SECRET=your-client-secret
TIMEBACK_API_AUTH_URL=https://auth.example.com
# Required: OneRoster and Caliper endpoints
TIMEBACK_ONEROSTER_API_URL=https://oneroster.example.com
TIMEBACK_CALIPER_API_URL=https://caliper.example.comSee Timeback Authentication and Endpoints for details.
Hotkeys
Press these keys in the terminal during development:
| Key | Action |
|---|---|
t | Cycle Timeback role (student → parent → teacher → ...) |
See Development Mode for full configuration options.
What's Next?
Custom Routes
Add your own backend logic alongside Timeback.
Deployment Guide
Deploy your project with Timeback integration.
Browser SDK
Explore the complete Timeback API in the SDK.
CLI Commands
Complete reference for all Timeback CLI commands.
