Making tFrogger Feel Like an Arcade Classic in My Terminal featured image

Making tFrogger Feel Like an Arcade Classic in My Terminal

January 19, 2025

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.

Taming Terminal Settings

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.

Finding the Right Loop for Ink

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.

Phase 2: Moving Obstacles Without Glitches

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.

Phase 3: Feedback Loops for Players

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:

Bash
# enable debug mode
npx tfrogger --debug

Menus rely on FullSizeBox plus useStdoutDimensions to stay centered even if you resize the terminal mid-run.

Phase 4: Persistence and Endgame Polish

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.

What’s Next

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.