Active Engagement Time
A tiny, zero-dependency tracker that measures how long a player is actively driving a playable — and reports it to the DMP as one comparable metric across every control scheme.
Overview
AET turns engagement into a single number you can compare across any playable — tap, swipe, drag, gyro, hold. It counts active seconds (time the player is genuinely driving the game) and fires one event per second.
AET (report) = count(AET) ÷ impressions = average active seconds per user.
One file (aet.js, ~150 lines, no deps). You write one line of game-specific code — telling
AET when the player is engaged. Everything else (per-second clock, AFK + tab-hidden gating, first-input signal,
cumulative across PLAY AGAIN, live event firing, debug HUD) is built in.
▶ Try it live — the interaction playground runs a mock playable for every control (drag, gyro, hold, swipe, tap) with the live AET HUD. Great for onboarding.
Install
1. Get the file. Download it directly (no GitHub access needed):
curl -O https://aet-sdk.vercel.app/aet.js
…or open aet-sdk.vercel.app/aet.js and Save As aet.js,
or clone creativehubiion/aet-sdk and copy aet.js in.
2. Drop it into your playable and import it — your bundler inlines it into the creative. It is
not loaded from a URL at runtime (creatives must be self-contained). ES module; for non-bundled
creatives load via <script type="module">.
import { AET } from "./aet.js";
Quick start
const aet = new AET({
send: (name) => fetch(window.trackingType + name), // your DMP call
});
aet.start(); // run starts (PLAY tapped) → fires "StartPlayable" once
// tell AET when the player is engaged — pick ONE per control:
onTap(() => aet.markInput()); // discrete (tap / swipe)
gameLoop((dt) => aet.setActive(isSteering)); // continuous (drag / gyro / hold)
aet.pause(); // run ends → AET kept; PLAY AGAIN → start() resumes adding
That's it. AET fires once per active second; idle / AFK / backgrounded time never
counts; replays accumulate within the impression.
How it works
AET measures active seconds — not a count of inputs (a held drag is "1 input", a gyro stream is hundreds; those aren't comparable). Every frame it asks one question — "is the player engaged right now?" — and sums the time the answer is yes.
- 1
AETevent = 1 active second. - The clock starts at the first real interaction (which also fires
control_active) — not on load. - Active time is gated on three things, all required: run live · ad foregrounded (Page Visibility) · player engaged.
- It accumulates across PLAY AGAIN within one impression (resets on a fresh page load).
- Events fire live — you never lose data if the user closes the ad.
Models & idleMs
Every control reduces to one of two models — that's the whole game-specific decision:
continuous setActive(bool)
Each frame, say whether the player is steering. Best for drag, gyro, hold. No idleMs — when
you stop, it's idle that same frame.
discrete markInput()
Call on each input. Best for tap, swipe. The player stays active for idleMs after each
input, so a tapping burst reads as continuous and pauses read as idle.
What idleMs is
One knob: how long one input keeps you "active" before AET assumes you stopped. If another input lands
within idleMs, active runs unbroken; if idleMs passes with no input, it flips to idle.
(Only used by discrete controls; continuous ignores it.)
Choosing the cap (idleMs)
Set it to roughly the time between inputs when the player is actively playing, plus a little headroom — so in-rhythm play reads as continuous but real pauses read as idle. Three ways, best to worst:
- From data. Log gaps between inputs in a playtest; the histogram splits into in-rhythm gaps and pauses — set the cap in the valley (~85th percentile of in-play gaps).
- From the game's cadence (heuristic below).
- Fallback default:
2000ms — a sane middle for casual discrete games.
| Game type | Gap between inputs | Suggested idleMs |
|---|---|---|
| Rapid-tap / masher | ~0.1–0.3 s | ~700 |
| Reaction / whack-a-target / swipe-dodge | ~0.5–1.5 s | ~2000 |
| Match / merge / casual puzzle | ~1.5–3 s | ~3000 |
| Deliberate / turn-based | 3 s+ | use dwell + interaction count instead |
idleMs in both versions so it doesn't
skew the comparison. AET compares most fairly between similar controls — or two continuous controls, where the
cap doesn't apply at all.API
| Member | Description |
|---|---|
new AET(opts) | create a tracker |
.start() | begin/resume the active clock; fires the start event once per impression |
.markInput() | discrete call on each input |
.setActive(bool) | continuous set whether the player is steering this frame |
.update(dtMs) | advance the clock manually (only if autoLoop:false) |
.pause() / .end() | stop accruing (run over); cumulative AET retained |
.destroy() | tear down loop + HUD + listeners |
.reset() | clear totals for a new impression |
.seconds / .ticks | current cumulative AET |
Options
| Option | Default | Notes |
|---|---|---|
send | required | (eventName) => void — your DMP call |
tickMs | 1000 | ms per AET event (1 = 1 active second) |
idleMs | 700 | activity window for markInput() — see “choosing the cap” |
autoLoop | true | self-runs rAF; set false to drive via update(dt) |
autoVisibility | true | auto-pause counting when the tab/ad is hidden |
fireStart | true | fire the start event on first start() |
debug | false | force the HUD (also auto-on with ?debug=1) |
events | see below | override { start, controlActive, tick } names |
Control recipes
// TAP (discrete)
input.on("pointerdown", () => aet.markInput());
// SWIPE / flick (discrete)
onSwipeDetected(() => aet.markInput());
// DRAG — track the finger (continuous), each frame:
const dx = pointer.isDown ? pointer.x - player.x : 0;
aet.setActive(pointer.isDown && Math.abs(dx) > DEADZONE_PX);
// GYRO / tilt (continuous), each frame:
aet.setActive(gyroActive && Math.abs(gamma) > GYRO_DEADZONE);
// HOLD (continuous):
zone.on("pointerdown", () => held = true);
zone.on("pointerup", () => held = false);
aet.setActive(held); // each frame
Rule of thumb: “active” should mean the player is causing something to happen (character moving, charging, aiming) — not merely touching the screen.
Engine adapters
Phaser
create() {
this.aet = new AET({ send: (n) => fetch(window.trackingType + n), autoLoop: false });
}
startRun() { this.aet.start(); }
update() {
this.aet.setActive(this._isSteering()); // continuous
this.aet.update(this.game.loop.delta);
}
onGameOver() { this.aet.pause(); } // PLAY AGAIN → startRun() again
Plain canvas / requestAnimationFrame
const aet = new AET({ send: (n) => fetch(TRACKER + n) }); // autoLoop on
playBtn.onclick = () => aet.start();
canvas.addEventListener("pointerdown", () => aet.markInput());
function onGameOver() { aet.pause(); }
PixiJS / Three.js: same as Phaser — autoLoop:false, then in your ticker call
setActive(...) (continuous) and update(deltaMs).
Events & DMP
Fire to the same tracker via window.trackingType + eventName (PLL/GAM macros fill
campaign_id at serve time).
| Event | When | Report metric |
|---|---|---|
StartPlayable | first start() / impression | start rate |
control_active | first active frame | engagement rate |
AET | every 1 s of active engagement | AET = count ÷ impressions |
Fire your own CTAClick / milestones / PlayAgain with the same send call.
AET. The first A/B-test creatives shipped it
under the older name active_tick — same mechanism, just a different events.tick value.
New playables use AET; override events.tick only to match a campaign that already
expects another name.QA & debug HUD
Open any playable with ?debug=1 — a HUD shows AET 0.0s · ticks 0 · ○ idle. Verify in 30s:
- Do your control → HUD flips to ● ACTIVE and ticks climb.
- Stop (lift finger / level the phone / pause tapping) → returns to ○ idle and freezes.
- Background the tab → counting stops; foreground → resumes.
If it ticks while you're not engaged, your setActive/markInput predicate is too
loose. If it never ticks while engaged, you're not calling it (or start() wasn't called). Try the
interaction playground to see every model side by side.
Pitfalls
- Don't count raw inputs as the metric — gyro and tap aren't comparable. Time is.
- Don't fire only at game-over — users close ads mid-run. AET fires live per second.
- Don't reset per replay — let it accumulate across PLAY AGAIN (the SDK does this).
- Don't make “active” = “finger on screen” — make it “player is causing motion”.
- When A/B testing two versions, match the control difficulty so AET reflects real engagement — not one control simply being easier.