Kablammo: animating a variable axis in a format that has none

A Wear OS watch face on Kablammo, whose single variable axis MORF (0–60) makes the glyphs writhe. It animates while you look at the watch and freezes in always-on. Watch Face Format cannot set a variable-font axis — so the axis is baked and the type is not.

Live, in your browser real variable axis

10:27

stepped — 30 baked frames, what the watch does

10:27

smooth — the continuous axis, what WFF cannot do

Both dials are the real Kablammo variable font, the same clock, running the same sweep. The left one is quantised to the 30 instances the watch face actually ships and swaps between; the right one moves MORF continuously. The browser sets font-variation-settings and is done. Everything else on this page exists because Watch Face Format has no equivalent — the gap between these two dials is the whole project.

The wall WFF has no axis

WFF's <Font> exposes only family, size, color, letterSpacing, slant, plus width and weight as fixed enumerations mapping to Typeface constants. There is no fontVariationSettings and no custom-axis hook anywhere in the schema. Nor can you drop to code: WFF requires android:hasCode="false".

The code-based AndroidX API would do real variable fonts via Typeface.Builder().setFontVariationSettings(...). But since 14 Jan 2026 Google Play no longer accepts or updates watch faces built that way. That route is sideload-only.

What it does instead vector, not bitmaps

MORF 0
MORF 0
MORF 12
MORF 12
MORF 25
MORF 25
MORF 35
MORF 35
MORF 48
MORF 48
MORF 60
MORF 60

fontTools instances Kablammo at 30 fixed MORF values and subsets each to the eleven glyphs a clock can show (0123456789:) — 30 static TTFs of ~26 KB. Each is its own res/font resource. The face stacks the frames and lets a <Condition> pick exactly one, driven by an expression over [SECOND] and [MILLISECOND]. Swapping the whole font per frame is the only lever WFF gives you, and it is enough: the text stays vector, so there are no baked glyph bitmaps and no per-digit image combinatorics. Tiles above are rendered from the shipped fonts.

phase = fract(t / 2)               // t = seconds-with-millis
tri   = 1 - abs(2 * phase - 1)       // out and back, so it does not snap home
index = floor(clamp(tri, 0, .9999) * 30)

On the wrist live emulator capture

wake
wake
mid-sweep
mid-sweep
far side
far side
always-on
always-on

Interactive mode sweeps the axis; always-on holds one fixed frame (MORF 0, the undistorted shape — also where the sweep starts and ends, so waking does not jump). The morph stack is hidden in ambient by <Variant mode="AMBIENT">, and Google's memory-footprint tool confirms only that one frame is resident: 0.77 MB active, 0.03 MB ambient.

The one thing it cannot do. The sweep free-runs on the wall clock rather than triggering on wrist-raise: WFF has no “time since the screen woke” source, so it loops continuously and you only ever see it while the screen is on. A true play-once-on-wake needs AnimationController play="ON_VISIBLE", which works only on SequenceImages — baked PNGs, a frame set per digit per position. Not worth it.

Settings colour and layout

White
White
Amber
Amber
Cyan
Cyan
Magenta
Magenta
Lime
Lime
inline
inline
stacked
stacked

Two user settings: colour and inline-vs-stacked. (12-vs-24 hour is not one — hourFormat="SYNC_TO_DEVICE" follows the watch's own setting.) Each setting is read through a [CONFIGURATION.<id>] expression, never through selector elements: those only work as direct children of <Scene> and cannot nest, so the settings and the morph condition have no valid tree. Instead they are all multiplied into one condition — 30 frames × 2 layouts × 5 colours = 300 branches, 285 KB of XML, which the runtime renders without complaint. Memory is unchanged: still just the 30 fonts.

Colour is a ListConfiguration on purpose

amber, stacked
amber, stacked
The documented way does not work. A <ColorConfiguration> read as color="[CONFIGURATION.numcolor.0]" on the <Font> is Google's own mechanism — it is what the Flavors sample does. On Wear 5 the picker lists the swatches, the selection is stored, and the face goes on rendering defaultValue forever. Numeric ColorOption ids, as in the sample, do not help. The tell: a BooleanConfiguration read the same way does apply. The difference is where the value is read. An <Expression> is re-evaluated every frame; a data source sitting in an attribute is resolved once, when the scene is built. So colour never becomes an attribute lookup: it is a ListConfiguration, matched in an expression, and each branch carries a literal hex. That is what the ×5 fan-out buys.

Traps the schema will not catch these

format is lowercase-only. A 24-hour dial is format="hh" + hourFormat="24", never format="HH" — the pattern rejects capitals. && inside an <Expression> must be written &amp;&amp;. It is element text, not an entity reference. adb install -r over a running face renders pure black. The runtime keeps the old APK's assets, the fonts fail to load, and logcat says nothing but a quiet ApkAssets: Deleting an ApkAssets object. It looks exactly like a broken <Condition>; it is not. Reboot after installing.

Licence OFL 1.1, no RFN

Kablammo is © 2023 The Kablammo Project Authors, under the SIL Open Font License 1.1. It carries no Reserved Font Name — the only hit for that phrase in OFL.txt is boilerplate — so instancing and subsetting it, which the OFL counts as modification, is permitted, and the name may be kept. The OFL requires the notice travel with the font, so the generator ships OFL.txt into the APK as res/raw/ofl_kablammo.txt.