iion · playables

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 AET event = 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.)

idleMs = 2000 (2s) PLAY tapped → StartPlayable, AET 0, idle first tap @ 3.0s → control_active, clock STARTS, active tap @ 4.5s → 1.5s gap < 2s → stayed active (AET ~1.5s) no taps after 4.5s → active until 6.5s (2s), then idle

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:

  1. 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).
  2. From the game's cadence (heuristic below).
  3. Fallback default: 2000 ms — a sane middle for casual discrete games.
Game typeGap between inputsSuggested 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-based3 s+use dwell + interaction count instead
Guardrails. Keep the cap well under an obvious walk-away (at 2 s, a 10 s+ pause clearly reads idle). If you're running an A/B test, use the same 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

MemberDescription
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 / .tickscurrent cumulative AET

Options

OptionDefaultNotes
sendrequired(eventName) => void — your DMP call
tickMs1000ms per AET event (1 = 1 active second)
idleMs700activity window for markInput() — see “choosing the cap”
autoLooptrueself-runs rAF; set false to drive via update(dt)
autoVisibilitytrueauto-pause counting when the tab/ad is hidden
fireStarttruefire the start event on first start()
debugfalseforce the HUD (also auto-on with ?debug=1)
eventssee belowoverride { 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).

EventWhenReport metric
StartPlayablefirst start() / impressionstart rate
control_activefirst active frameengagement rate
AETevery 1 s of active engagementAET = count ÷ impressions

Fire your own CTAClick / milestones / PlayAgain with the same send call.

Naming. The SDK default tick event is 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:

  1. Do your control → HUD flips to ● ACTIVE and ticks climb.
  2. Stop (lift finger / level the phone / pause tapping) → returns to ○ idle and freezes.
  3. 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.