The hardest technical problem in calli wasn't the database schema or the subscription logic. It was getting the app to correctly judge whether a user's stroke matches the reference stroke for a Chinese character.
This sounds simple. It isn't.
Here's the problem: the user draws a stroke on a canvas with their finger. The reference stroke is a path in a coordinate system derived from hanzi-writer (an open-source Chinese character animation library). The canvas coordinate system and the hanzi-writer coordinate system are not the same. And the judgment has to feel good — too strict and users fail correct strokes, too lenient and you're not actually teaching anything.
I built this as a standalone package: @calli/stroke-engine. Pure TypeScript, no runtime dependencies, works in the browser and in React Native. Here's how I got there.
The coordinate problem
Hanzi-writer uses a 900×900 unit grid. The origin (0, 0) is at the bottom-left corner, and y increases upward. This is the typical math convention.
A browser <canvas> element has its origin at the top-left corner, and y increases downward. This is the typical screen convention.
These are not compatible. A stroke that looks correct on screen, if you compare its canvas coordinates directly to hanzi-writer coordinates, will appear vertically flipped.
The fix is a coordinate transform. The canvas maps to the screen via an SVG matrix (for hanzi-writer's rendering), so the same matrix can convert from canvas pixels to hanzi-writer space. Once both paths are in the same coordinate system, comparison becomes possible.
In practice:
function toHanziCoords(
canvasX: number,
canvasY: number,
canvasWidth: number,
canvasHeight: number
): [number, number] {
// Map canvas pixel to [0,1] range, then to hanzi 900-unit space
// Flip y because canvas y increases down, hanzi y increases up
const x = (canvasX / canvasWidth) * 900
const y = (1 - canvasY / canvasHeight) * 900
return [x, y]
}
Claude worked out this transform after I explained the coordinate systems. What took me the most time wasn't the math — it was figuring out which coordinate system I was actually in at each step. That required drawing it out on paper.
Normalizing to compare
Once both paths are in the same coordinate space, the comparison algorithm works on normalized coordinates: both paths scaled and translated to a [0, 1] × [0, 1] bounding box.
The reason: a user might draw the correct stroke but in a slightly different position or scale. We want to reward correct shape and direction, not require pixel-perfect placement.
function normalize(points: [number, number][]): [number, number][] {
const xs = points.map(([x]) => x)
const ys = points.map(([, y]) => y)
const minX = Math.min(...xs), maxX = Math.max(...xs)
const minY = Math.min(...ys), maxY = Math.max(...ys)
const rangeX = maxX - minX || 1
const rangeY = maxY - minY || 1
return points.map(([x, y]) => [
(x - minX) / rangeX,
(y - minY) / rangeY,
])
}
After normalization, we compare direction and shape. Direction matters for stroke order: a stroke that looks right but goes the wrong way is a failing stroke.
The matching score
The final scoring function compares two normalized paths (the user's stroke and the reference) using:
- Start point distance: how close is the user's starting point to the reference's start?
- Direction similarity: cosine similarity between the overall stroke direction vectors
- Path deviation: average point-to-nearest-point distance between the two paths after sampling to a uniform number of points
Each component gets a weight. The final score is a weighted sum in [0, 1]. Above a threshold → pass.
export function matchStroke(
userPath: [number, number][],
referencePath: [number, number][],
): number {
const u = normalize(resample(userPath, 20))
const r = normalize(resample(referencePath, 20))
const startDist = dist(u[0], r[0])
const dirSimilarity = cosineSimilarity(
direction(u),
direction(r)
)
const pathDev = averageDeviation(u, r)
return (
(1 - clamp(startDist, 0, 1)) * 0.3 +
dirSimilarity * 0.4 +
(1 - clamp(pathDev, 0, 1)) * 0.3
)
}
The weights came from tuning against real character data. I wrote the first version, tried it on a batch of test strokes, found it was failing correct strokes on characters with very short strokes (where normalization distorts the path), and adjusted.
Why zero dependencies
The @calli/stroke-engine package has no runtime dependencies on purpose.
On mobile, every dependency adds to bundle size and load time. More importantly: React Native has strict constraints on what can run in the JS thread. A library that works fine in Node.js or a browser might use APIs that don't exist in the React Native runtime.
Writing it in pure TypeScript with standard math operations means it works in any environment that runs JavaScript. Same code runs in the browser's <canvas> handler and in React Native's touch event handler. No adapter layer needed.
The tradeoff is that I couldn't pull in a matrix math library or a geometry package — things that would have made some of this easier. But the package is small and the math isn't complex enough to need them.
What I'd do differently
The thresholds are too aggressive for beginners on short strokes. A short horizontal stroke like 一 passes easily, but short diagonal strokes in complex characters fail at a higher rate than they should. I need a per-stroke difficulty adjustment that's informed by the stroke's length in the reference path, not just the normalized comparison.
Also, the resampling function (making both paths have the same number of evenly-spaced points) currently uses linear interpolation. Bezier-based resampling would be more accurate for the curved strokes that show up in characters like 口 and 国.
Both of those are on the list. The current version is good enough to teach correctly, and the engine is isolated enough that I can swap in improvements without touching the apps.
If you're building something similar and want to look at the actual implementation, the monorepo is at github.com/linnalihe/app-calli.