The board in Life is not a clean loop. It forks, and every player carries their own preference for which way to go at each junction. Getting a car to slide along all of that at a single steady speed, facing the right way the whole time, with a hundred-plus cars on screen at once, turned out to be a fun problem where the obvious approach quietly cheats you on the tight corners.
The board is a graph, not a track
Most board game animations treat the path as a fixed line: hop from tile to tile, maybe ease the motion a little, done. That falls apart the moment the path can branch. An early version of mine did exactly the naive thing: it flattened the whole board into one linear nav_positions array and walked board_position_index += spin. The result was absurd. Every car drove the college route first and then appeared to backtrack through career, purely because the flat indices for career tiles happen to come after college tiles in storage order. The animation was faithfully rendering a number that no longer meant anything.
So the path lives as a real graph. The data is a set of branches (the fork nodes) and segments that connect them, each segment carrying its own list of tiles. Every position is a small selection record rather than a bare integer:
{ "kind": "tile", "seg": 4, "ti": 7 } # the 8th tile of segment 4
{ "kind": "branch", "idx": 2 } # fork node 2
A topo_next step walks one tile forward inside a segment, and at its end hands off to that segment's to branch. At a branch with more than one outgoing edge it consults a per-player branch_pref dictionary mapping each branch index to which outgoing segment that player takes. After a spin resolves, walk_from_index(start, steps, branch_pref) walks the graph one step at a time and returns the concrete ordered list of flat indices the car will visit, honoring that player's fork choices and stopping at retirement so a car never drives past the end of its life.
That separation mattered. The rules already know how to navigate the board, so the animation layer never guesses a route; it is handed the route as ground truth via PlayerState.last_path. I did not want a second, subtly different copy of the graph-walking logic living in the render code.
A curve through the tiles
With the route in hand, the movement layer pre-samples a Catmull-Rom spline through every tile center on that route. Catmull-Rom is a nice fit here because the curve passes through each control point, so the car actually touches each tile center instead of cutting the corner short, and it stays smooth across the joints between segments. A car coasting through a gentle bend and a car coming around a hairpin both ride one continuous, unbroken line.
Each segment of the route gets ROUTE_SUBDIV_PER_SEG (32) sample points. The four control points come from the route itself: for the segment between path[i] and path[i+1], the tangent points are the tiles just before and just after. There is one gotcha at the forks. A Catmull-Rom tangent is built from a point's neighbors, but at a branch node the "neighbor" in the route array might be on a completely different arm of the fork, which yanks the curve sideways toward a tile the car is not driving to. The fix is to reflect a phantom control point along the real segment direction so the tangent stays straight through the junction:
if _is_branch_idx(path[i]):
p0 = 2.0 * p1 - p2 # mirror p2 about p1: tangent runs along the segment
if _is_branch_idx(path[i + 1]):
p3 = 2.0 * p2 - p1
Sampling by arc length, not by parameter
The first thing I reached for was to march the curve parameter from 0 to 1 at a steady rate. The problem is that the parameter does not map evenly to distance. On a tight corner the curve packs a lot of bending into a small slice of parameter space, so a car advancing at a constant parameter rate visibly speeds up through corners and slows down on the straights. It looked off right away, and no amount of easing hid it.
What worked was to drive the car by distance instead. As I generate the samples, I accumulate the straight-line distance between consecutive points into a running total and store it on each sample. The sample list ends up looking like {pos, frame, dist, segment_idx}, sorted by dist simply because distance only ever increases as you walk forward. That cumulative-distance column is the arc-length table.
Then _process just accumulates elapsed seconds, multiplies by a fixed CAR_SPEED_PX_PER_SEC of 220, and binary-searches the sample list for the first sample whose dist is at least the target distance:
var distance := clamp(anim_t * CAR_SPEED_PX_PER_SEC, 0.0, total_len)
var lo := 0
var hi := samples.size() - 1
while lo < hi:
var mid := (lo + hi) >> 1
if samples[mid].dist < distance:
lo = mid + 1
else:
hi = mid
# samples[lo] is the car's position this frame
Move a fixed number of pixels per second, look up the matching sample, and the car holds one steady speed no matter how sharp the geometry gets. It costs a little setup work per move and one logarithmic search per frame, and in return the math stops drawing attention to itself.
- Walk the branch graph to get the concrete route of flat tile indices.
- Pre-sample a Catmull-Rom curve through those tile centers, 32 points per segment.
- Accumulate distance into a per-sample column to build the arc-length table.
- Each frame, convert elapsed time to distance and binary-search for the position.
Facing the right way, the short way around
Here is the part that surprised me. The cars are not rotated at runtime. The original game art gives each car as a FRAME_COUNT of 38 pre-rendered directional sprites, one per heading around the circle, so "facing" is really just picking the right frame index. Each nav tile already carries the frame index that points along the path there, and the curve sampler blends between the two endpoints' frames.
The subtle bit is that those indices wrap. Blend frame 35 toward frame 2 naively and you sweep down through 34, 33, 32 and unwind almost all the way around the circle, when the car should just nudge forward five frames through the wrap point. So the frame blend takes the shorter arc across the 38-frame loop, where 19 is the halfway mark:
static func _lerp_frame(t: float, f0: int, f1: int) -> int:
var diff := f1 - f0
if diff > 19: diff -= 38 # 35 -> 2 becomes +5, not -33
elif diff < -19: diff += 38
var v := float(f0) + float(diff) * t
return ((int(round(v)) % 38) + 38) % 38
It is a tiny idea that is easy to forget and very noticeable once it is missing: it is the difference between a car steering and a car spinning out at every corner.
The pegs riding in the car, the driver and passenger, are child sprites of the car body so they inherit its position, and they carry a frame-range-aware depth swap. For frames 9 through 27 the passenger seat sits closer to the camera, so in that range the passenger sprite renders on top of the driver; otherwise the driver is on top. Without that swap the back peg's head bleeds through the front peg's transparent canvas, which reads as the kid driving from the backseat. On a broadcast view with follow cameras zooming in, that kind of edge bleed is exactly what catches the eye.
Keeping it cheap with a hundred cars
This is a streamed game with potentially 200-plus players parked on one board, so per-frame cost is real. The frame setter is a no-op when the frame index has not changed since the last call, which between rounds skips roughly a hundred redundant texture writes per frame while idle cars sit still. And the route samples are discarded the moment a car finishes its move, so the memory for the arc-length table does not linger across rounds.
The lesson I keep relearning
The thing I am most glad I got right here was not the math. It was reuse. The project already had a path editor with all the curve work I needed: the same _catmull_rom evaluation, the same arc-length sampling, the same shortest-arc _lerp_frame, even the same hand-nudged peg offsets. It was tempting to write a fresh, movement-specific version that did just what I needed.
A parallel copy of curve math is two things to keep in sync, and at some point one of them quietly drifts and you are debugging a discrepancy that should never have existed.
So the constants and the core functions in the multi-car layer are deliberately the same as the editor's, called out in the comments as such, so that when I retune the speed or the subdivision count both the authoring tool and the broadcast move together. There is no second implementation waiting to disagree.
The takeaways, distilled:
- Let the rules walk the branch graph; hand animation a concrete route, never a raw index it has to interpret.
- Sample curves by arc length for constant velocity; the raw parameter is not distance.
- When facing comes from a wrapping sprite sheet, blend the index along the shortest arc so turns read as steering.
- Reuse one curve implementation everywhere; a second copy tends to become a future bug.