Drawing connected cells as one outline: contour paths, inset normals, and a bridge animation
Replacing per-cell rounded rectangles with one SVG contour path per connected component in a Skia React Native game board. A right-hand-rule walker over a vertex grid, inset padding via right-hand normals, convex-only corner rounding with a clamp, and the 300ms bridge animation that hides the fact that the contour on frame N and the contour on frame N+1 share no structure.
Jelmata scores by connected components — adjacent same-player cells form a group, and your total score is the product of your group sizes. For most of the game’s life, the board drew each occupied square as its own rounded rectangle. A group of four cells in an L-shape was, to the renderer, four independent RoundedRects that happened to sit next to each other. Adjacent cells looked connected at a glance. Zoomed in, you could see thin seams where each rectangle’s corner radius pulled inward from its neighbors.
The first fix was a patch job. An annotation layer called ExtensionRect painted extra rounded bars between every pair of adjacent same-player cells, plus a special case that filled the 2×2 corner gap with a tiny square. It worked — but “worked” meant three overlapping layers per group, plus a hand-written corner-fill branch in the annotation hook. Any change to cell radius, inset, or grid size risked reopening a seam.
The proper fix is to stop thinking about cells as the unit of rendering and start thinking about groups. One outline per connected component. No patches, no extension bars, no corner fills. If the outline is the silhouette of the whole group, there’s nothing to patch because there’s no interior edge.
One <Path> per component
The new renderer lives in src/components/Board/useContourPaths.ts. It takes the board’s occupiedCells, runs the existing getConnectedComponents utility for each player, and for every component produces one object of the form { path, color }. The path is a single SVG path string tracing the outside of the group.
Skia’s React Native renderer consumes SVG path strings through its <Path> component, so the entire cell layer in Board.tsx collapses into a single map: for every component, emit one <Path>. The old cells.map() loop with its per-cell RoundedRect and its “skip this cell if it’s part of a group” conditional is gone. So is the entire extension-rect layer inside useAnnotationData. Removing code is the best kind of refactor.
Tracing the perimeter on a vertex grid
The interesting part is building the path string. The algorithm in contourPath.ts is a right-hand-rule walker over the vertex grid, not the cell grid. A 6×6 board has a 7×7 grid of vertices at the cell corners, and the walker steps from one vertex to the next along cell edges, always keeping filled cells on its right.
At each vertex the walker looks at the four cells meeting at that point — NW, NE, SW, SE — and classifies the corner based on how many are filled:
- One cell filled (on the inside of the turn) → convex corner, outline bends outward by 90°.
- Three cells filled → concave corner, outline bends inward.
- Two cells filled on one side of the edge just walked along → straight, walker keeps going.
The whole classification fits in a four-case switch indexed by current direction. Two small tables drive the walk:
const DIR_VECTORS = [
[1, 0], // right
[0, 1], // down
[-1, 0], // left
[0, -1], // up
];
const RIGHT_NORMALS = [
[0, 1], // right → down
[-1, 0], // down → left
[0, -1], // left → up
[1, 0], // up → right
];
The loop terminates when it returns to the starting vertex moving in the starting direction, or when it hits a safety bound of blockSet.size * 8 + 4 steps. That bound is generous enough to handle any valid shape and tight enough to catch any bug that would otherwise spin forever.
Inset padding with right-hand normals
Each contour vertex sits exactly on a cell corner, but the rendered gel should float a few pixels inside the grid line — otherwise adjacent opposing groups would touch. The inset comes from a small trick: push every vertex inward along the sum of its incoming edge’s right-hand normal and its outgoing edge’s right-hand normal, scaled by the cell padding.
const px = offset + vx * cellSize + (rnIn[0] + rnOut[0]) * cellPad;
const py = offset + vy * cellSize + (rnIn[1] + rnOut[1]) * cellPad;
Three cases fall out of this one line:
- Convex corner: both normals point away from the group, so the vertex moves diagonally inward by the full padding in both axes. A proper exterior shrink.
- Concave corner: the two normals point in opposite directions along one axis, so their sum cancels that axis and only the perpendicular component remains. Which is exactly what you want when the outline has to dip into a notch.
- Straight vertex: never emitted — the walker only records turns — so this case doesn’t come up.
That’s the whole inset calculation. No special-case code per corner type. The geometry does the work.
Rounding only the convex corners (with a clamp)
The final step walks the list of vertices and builds an SVG path string. Concave vertices get a plain L line-to — you can’t round an inside corner with a simple arc without it looking wrong, and Jelmata’s gel aesthetic actually wants those inside corners sharp so groups read as “made of cells.” Convex vertices get replaced with two commands: an L to the point where the curve should start, then an SVG elliptical arc A r r 0 0 1 to the point where the curve should end.
The effective radius at each convex vertex is clamped to half the length of the shorter adjacent edge. That matters for single-cell groups and narrow necks. Without the clamp, a one-cell component would try to draw four arcs with radius larger than the cell itself and the curves would overlap into nonsense. With the clamp, single cells still render as crisp squircles — or as perfect circles when the clamp kicks in hardest. Same path builder, no special case for the degenerate shapes.
The bridge: 300ms of pretending the old path is still current
Merged outlines look great for static boards, but they introduced a new problem: dropping a cell into an existing group no longer animates. In the old renderer, a fresh cell was its own RoundedRect, and you could fade or pop it in independently. In the new renderer, that cell is part of a single path belonging to the whole group, and the path string on frame N doesn’t share any structure with the path string on frame N+1. A pure state swap looks like the group silently reshapes itself between frames — no sense of motion, no hint that anything just happened.
The fix is a bridge. During the 300ms after a new cell is placed, the renderer does not draw the final contour at all. Instead it draws the old contour (the group as it was before the placement) as a static base, and on top of that draws a set of rounded rectangles that grow outward from each adjacent old cell toward the new cell’s position. At t = 0 each overlay rect is exactly the size of its source cell; at t = 1 it has stretched by one full cell width into the new square. When the animation finishes, the overlay rects disappear and the final merged contour takes over. The visual effect is that the group pushes a pseudopod out to absorb the new cell.
This is the trade: the contour path is wrong for 300ms (it’s the old shape), but the overlay makes that wrongness look like intentional motion. The alternative — interpolating between two non-isomorphic SVG path strings, vertex by vertex — is a significantly harder problem whose reward would be a visually indistinguishable result.
useLayoutEffect because useEffect flashes a frame
The animation lives in useExpansionAnimation.ts. It’s driven by useLayoutEffect, not useEffect, because the initial progress: 0 state has to be set before the browser commits the next paint. Otherwise Skia paints a single frame of the final contour at full size before the animation loop takes over, and the whole bridge reads as a jarring snap-then-animate.
From there a standard requestAnimationFrame loop walks progress from 0 to 1 over 300ms with an ease-out-cubic curve, setting state on each frame and cancelling itself on unmount.
Not every placement animates. The hook skips entirely when:
- The board is being replayed (the scrubber sets state directly, so there’s no “new move” to bridge from).
- The move was an undo (
lastScoreDeltais null). - The newly placed cell has no adjacent same-player neighbors — an isolated placement has no existing group to grow out of, so it just appears as its own one-cell contour.
One more subtlety in the overlay rect geometry: if the new cell is completing a 2×2 block, the overlay bar on the side that closes the square needs to extend outward by the cell padding — otherwise there’s a hairline gap at the inside corner during the animation. The hook builds a small extend: [boolean, boolean] flag per overlay by checking whether the perpendicular neighbors on each side also belong to the final component, and the render code in Board.tsx nudges the rect’s position and size accordingly. It’s the same 2×2 corner-fill idea from the old annotation code — resurrected, briefly, for the duration of a 300ms transition.
Keeping annotations stable through the grow
Jelmata draws small numeric labels on each group showing the score it contributes, plus connector lines inside each component. Those annotations are computed from the same occupiedCells the contours are computed from — which means if you pass the current board state to the annotation hook during an animation, the labels jump to their final positions on frame one while the overlay rects are still mid-bridge. The numbers blink to their new spot before the cells arrive.
The fix is a one-line override in Board.tsx: while the expansion animation is active, compute annotations from the pre-expansion occupiedCells stored on the animation state, not from the current ones. The labels on the existing group stay put, the overlay rects grow into place, and only after the animation finishes does the annotation layer rebuild against the new board. It’s a small cheat — the labels are technically stale for 300ms — but it’s the difference between a transition that feels deliberate and one that feels like a UI bug.
Any animation that bridges two different derived data sets needs to pin both sets to the same moment in time, or one will race ahead of the other. The user reads the mismatch as a bug faster than they read the animation as animation.
Prototype in a browser tab, port in an afternoon
The contour algorithm didn’t start inside the app. It started as a single HTML file in the repo’s assets/ directory — a 400×400 SVG board you could click to place cells and watch the outline redraw. Working on the algorithm in plain JavaScript in a browser tab with devtools open is a lot faster than iterating inside the React Native build, and the prototype caught the corner-rounding clamp and the straight-vertex bug long before the real renderer saw the code.
The port into Jelmata was almost mechanical: translate the `${r},${c}` row-column keys to the game’s existing `${x}-${y}` column-row format, wrap the function in a memoized hook, and hand the output to Skia’s <Path> instead of an SVG <path>. Same algorithm, different host.
If you’re adding a new rendering technique to a mature app, write the first version somewhere it can’t hurt anything. A prototype you can reload with a keystroke is worth more than most of what’s in your test harness.
The player-facing take — just the visual effect, plus the bit about the pseudopod animation — lives at How Jelmata Draws Connected Cells on the game blog.