How I used Ink, reducers, and a dash of nostalgia to ship a playable Frogger clone.
One morning I caught myself alt-tabbing into a browser game between builds and realized the terminal should be my arcade. That moment kicked off tFrogger: a React + Ink take on Frogger that feels native to the CLI.
Getting the terminal to behave like a game canvas took more time than any feature. Most emulators shrug at real “frames per second,” so I experimented with different GAME_SPEED
values in src/constants.ts
until the redraw cadence felt consistent across iTerm, Windows Terminal, and the macOS default. Anything faster than ~12fps triggered flicker or outright input lag. The slower loop meant every pixel (well, emoji) movement had to stay precise—especially collision math that’s usually smoothed out at 60fps.
The lower framerate made hit boxes extra sensitive. I started rounding every obstacle position before comparisons and added a slight tolerance to collision checks in checkCollisions
so a single skipped frame wouldn’t register a miss. The same rounding shows up when I map obstacles back into the board renderer, otherwise you’d see the frog “phase” partway through a log, then snap back.
Keeping the frog on top of logs while the frame rate fluctuated was the hardest bug in the entire project. When Ink skipped a render, the frog lost track of the log’s position and fell straight into the river. I fixed it by storing the frog’s relative offset inside moveObstacles
and re-applying it no matter how the log wrapped (see the snippet below). Collision recovery also needed love: if traffic hits the frog, handleCollision
in Game.tsx
now pauses the loop, decrements lives, and snaps the frog back to the start before resuming play. Without that reset window the game would repeatedly register collisions and instantly end the run.
Ink is great at rendering React components, but real-time movement needs a predictable loop. I leaned on a reducer for player state in src/Game.tsx
so I could keep movement pure and composable:
const [frogState, dispatchFrog] = useReducer(frogReducer, {
position: {
x: levelConfigs[0]!.width / 2,
y: levelConfigs[0]!.height - 1,
},
onLogId: undefined,
})
The useReducer
setup plays nicely with Ink’s re-render cadence, while useRef
mirrors the latest state into the game loop without tripping React’s rules. A setInterval
keyed off GAME_SPEED
calls moveObstacles()
and checkCollisions()
so every frame updates the board and timer in lockstep.
My first pass let logs drift out of sync with the frog, especially when the board wrapped around. The fix was to compute relative positions. In moveObstacles
I remap each obstacle, track the frog’s onLogId
, and recalculate its position once the log shifts:
const relativePosition =
newFrogState.position.x - Math.round(currentLog.position.x)
newFrogState.position = {
x: (updatedLog.position.x + relativePosition + config.width) % config.width,
y: newFrogState.position.y,
}
That modulo trick keeps cars, logs, and alligators cycling smoothly at different speeds while the frog rides along. I also halved the speed for lilypads in higher levels so the final rows stay survivable.
I didn’t want folks guessing whether the frog landed on a lily or whiffed. checkCollisions
casts a tiny tolerance around the frog so near-misses still count, and the debugLog
helper pipes state snapshots when you launch:
# enable debug mode
npx tfrogger --debug
Menus rely on FullSizeBox
plus useStdoutDimensions
to stay centered even if you resize the terminal mid-run.
I wanted every game over to feel like a checkpoint, not a reset. highScores.ts
writes the top runs to disk and the Game Over screen lets you toggle a mini leaderboard with S
. The save helper still points at a legacy .config/potion-wars
directory—leftover from an older prototype—so renaming it to .config/tfrogger
in getSaveDirectory.ts
is on my short list.
To keep quality in check I added an AVA snapshot test in src/tests/game.test.tsx
that renders the final frame under debug mode. It catches accidental layout regressions whenever I tweak the board.
I’d love to dial in richer metrics (time-to-first-win, controller support) and patch the save directory naming. If you try a run, drop feedback or high-score screenshots—I’m especially curious how far folks push the alligator levels.
Thanks for hopping along, and if you’ve got ideas or find a bug, open an issue so we can make the terminal arcade even better.