Case Study · Personal project

3D Frogger MP — a classic loop through modern design iteration.

A solo 3D multiplayer Frogger clone that I built and shipped over about five months. Rather than walk through it start to finish, I've pulled out five design decisions from across the project and explained the thinking behind each one.

Play it at froggie.eriksmedia.com

Live multiplayer. You may land in a session with other players, or alone with AI opponents. Mobile-friendly. Use arrow keys, WASD, or the on-screen D-pad.

Role
Solo project (design, code, deploy)
Built in
Three.js / WebGPU, Node + TypeScript, Docker
Timeline
Jan – Jun 2026 (~5 months)
Hosted at
froggie.eriksmedia.com
What this is

3D Frogger MP is a multiplayer Frogger clone with state, AI opponents that fill in for missing humans, a phone-friendly D-pad, and a tongue-attack mechanic that lets players bump each other off logs. It runs in a Docker container on my homelab and you can play it right now.

It started as an experiment with game development, something I'd wanted to try for a long time. I included it in the portfolio because the design decisions are easy to follow here in a way they usually aren't in production work, where you can see what was chosen and why. The five sections below each cover one of those decisions.

01A world that does wayfinding

Every object in the world is there to answer a navigation question.

The starting point was a grid, a player cube, some cars, and some logs. From there I set myself a rule: nothing goes into the world unless it answers a question the player is already asking, like where they can walk, where they'll die, or where the playable area stops. Solo 3D projects tend to drift into a generic asset-store look because decoration gets added for its own sake, and I wanted to avoid that by having each piece do some navigational work first and look nice second.

Iterating on the visual style
  1. Cubes on flat color
  2. Models and foliage
  3. Perimeter fences
  4. Waterfalls and moss
  5. Forest border and fog
Each frame is a later iteration of the world's visual style. Cubes on flat color become models, then fences, then waterfalls, then a fogged forest border, with every addition still doing navigational work. Click any frame to step through them full size.
The decorated Frogger world in isometric view: foliage, perimeter fences, waterfalls at the river edge, varied vehicles, and a cel outline pass.
The world as it stands now. Every element is doing navigational work: foliage marks ground you ignore, fences mark edges you cannot cross, waterfalls mark the river boundary.

Foliage, deep-cloned so materials don't share state

The empty grid first needed something growing on it so it would register as ground to ignore rather than something to cross. The harder part wasn't the geometry. In Three.js many meshes can share a single material, so tweaking one bush at runtime would change all of them at once, and deep-cloning each instance avoided a whole class of shared-state bugs before they came up.

A real fence model, with a procedural fallback

Fences mark the edges you can't cross. The fence is a GLB model that loads asynchronously, and if that load fails on a slow connection or a missing asset, the world falls back to generating a simple fence in code instead. That fallback matters because the edge is communicating a rule, and a missing fence would leave the player unsure where the boundary actually is.

A cel outline that warns you about traffic blocked from the camera's view

A around players, vehicles, and logs ties the cartoon look together, but the reason I kept it is more practical, and the tunnels are the clearest case. A car can be most of the way behind a tunnel wall and still be a second or two from hitting you, and the outline draws a rim over the hidden part of the car, so even when the body is occluded the player still gets a clear warning that something is coming. The same pass keeps every object readable against a busy background, but in the tunnels it's doing actual safety work rather than just decoration.

Comparison image
Comparison image

Outlines off — cars, logs, and frogs soften into the road and the tunnel shadows, and traffic sitting inside a tunnel is easy to lose.

Establishing the look of the waterfall

The river side needed its own visual treatment. The waterfalls mark where the playable area ends, and they also just make the river feel like a river. They got built up in layers over time: rock backing first, then moss accents, then an animated water surface over the lip. Later I added a shader-driven pass with a foam line, a parallax sheet, and some drifting mist, and the layer count scales down on weaker hardware so the effect simplifies instead of breaking.

Polygons
Shaders
The same waterfall built two ways. The polygon version cascades over stacked rock and moss meshes with an animated water surface; the shader version adds a foam line, a parallax sheet, and drifting mist on top.
02Squish as intent

The interesting part of the animation work was deciding what to animate.

For a long stretch of early development the players were just colored boxes that slid from one grid cell to the next. That was enough to test movement and collisions, and it stayed that way until the rest of the game was working. Once it was, most of what turned those boxes into something that reads as a frog came down to a handful of small animation changes, usually somewhere between five and fifty lines each, with an outsized effect on how the game feels to play.

The squish is the one I want to spend a little time on, because the design work and the technical work are so clearly separated in it. The technical side is essentially between two poses, which is straightforward. The actual design decision is choosing what to exaggerate and by how much, and then leaving the result to read on its own without explaining it to the player.

Early prototype
Final frog
The same movement early and late in the project. For a long time each player was a colored box that slid between grid cells. The final frog adds a weighted hop and a squish on the take-off, built from the same interpolation between poses.

The squish only fires on intentional input

One rule here mattered more than it looks. The squish fires on a movement key press, not on every server position update. A frog riding a log drifts sideways on every tick without the player pressing anything, and if the squish fired on that drift, the animation would be showing intent the player never had. The state machine separates movement the player chose from movement they were carried into, so the squish only plays when the player actually decided to jump.

Synthesized sound, built from scratch

Late in the project I built the whole sound layer from scratch with the Web Audio API instead of using a sample library: a hop blip, the tongue zip, a squash thud, a death effect, and a goal arpeggio. Synthesizing everything kept the build free of audio dependencies and let me tune each sound to the moment it goes with. One choice that ended up mattering is that only your own frog makes sound. The AI opponents are silent, so a board full of them stays quiet instead of turning into a wall of noise.

03An AI you can see thinking

I built a way to see the AI's thinking before I tried to tune it.

A multiplayer-only game isn't much fun when nobody else is online, so the AI exists mainly to keep the world populated when a human is playing alone. The interesting part wasn't the pathfinding code itself but the scoring function behind it: how much it should cost to wait on a log, how much drifting offscreen should cost, how hard the AI should push forward at the expense of its own safety. Those weights are basically all the AI has to go on, so changing them is really a design decision and not just a tuning detail.

A pathfinding visualization toggle

Before tuning anything, I built a debug overlay that draws the AI's candidate moves on top of the world along with their scores. With it turned on you can see exactly what the AI is weighing at any moment, and with it off the AI just plays like a normal opponent. That overlay is what made the later tuning possible, since it's hard to adjust behavior you can't actually observe.

The pathfinding debug overlay turned on: gold rings with directional arrows hover above each AI frog, showing the moves it is currently evaluating.
The pathfinding overlay turned on. The gold rings and arrows above each AI frog are the moves it is weighing right now. With it visible, a bad behavior becomes something concrete I can adjust instead of a mystery.

Three tuning rounds, each fixing one bad behavior

Round one slowed the AI's decision interval on logs, since the early version re-evaluated every tick and twitched around in a way that looked more like panic than skill. Round two added some safety thresholds and a backward-movement priority for drifters, because the calmer AI had started over-committing to waiting and would ride a log straight offscreen. Round three pulled the magic numbers out into named constants and settled the main rule: when it's on a log and not drifting, it should strongly prefer to wait rather than move for the sake of moving.

Dev controls so I could actually run the experiments

A whole dev menu of toggles effectively turned the game into a test harness. A range input for the AI count and a checkbox to pause log movement let me put one AI or twenty on the board, freeze the logs, and watch the effect of a single scoring change on its own. A grid overlay labels every cell so I could line behavior up against exact coordinates, and an FPS graph in the corner, with green, yellow, and red zones, kept the frame budget on screen while the experiments ran. The tuning rounds above really only came together because those controls were there to lean on.

The game in isometric view with the dev menu open in the lower-left, a grid overlay of lettered and numbered coordinates across the board, and the FPS graph holding green in the lower-right.
The dev menu with the grid overlay and FPS graph turned on. The grid labels every cell, the toggles change the AI count and freeze the logs mid-run, and the FPS graph in the lower-right keeps the frame budget in view while testing.
04A camera you can change

Three camera modes, with isometric as the default.

The camera does a lot of the work in how a 3D game feels to play, and the simplest option is to pick one framing and lock it in place. Instead I gave the player three framings to choose between, and set the default to the one that suits how the game plays.

Three camera modes on one button

The default is an isometric view that pulls back and shows the whole board, which helps when you're planning a long crossing. A follow mode sits just behind and above the frog for a closer, over-the-shoulder feel, and an angled mode sits in between the two. A single button cycles through all three, isometric to angled to follow, and its tooltip updates to name the current mode so it's clear what you're switching to.

Isometric
Angled
Follow
The same board in all three modes. Isometric pulls back for planning, follow sits behind the frog, angled splits the difference. One button cycles them. Click any frame to view it full size.

Why isometric is the default

The isometric view keeps most of the traffic in view while still keeping the player pointed toward the goal, so you can read the board and plan a crossing instead of reacting one row at a time. The angle also keeps the outer edges of the map out of frame, the parts I didn't want to spend detail on, so the view stays on where the play actually happens. And it puts a different enough spin on the original top-down Frogger to feel like its own thing rather than a straight copy.

The follow camera is the more immersive option, and some players will prefer it, but it only shows the next row or two of the board. That makes it better as something you switch to once you know the layout than as the view a new player has to plan a whole crossing from, so it sits one button-press away rather than being the default.

05The multiplayer on-ramp

How a stranger gets into a session, on whatever device they brought.

One of the trickier parts of a small multiplayer game is the gap between the moment someone clicks a link you sent them and the moment they're actually playing. Two things help close that gap here: a waiting room that makes the session feel real before it starts, and a control scheme that gives phone players the full set of actions without covering the screen in buttons.

A waiting-room lobby

Before the game starts, players land in a lobby that shows the current roster, a copy-able invite link, and inline fields to edit their own name and color. The idea is to make a cold start feel like an actual room rather than an empty board: you can see who else is there, grab a link to pull a friend in, and set up your frog while you wait, instead of sitting on an empty board wondering whether anything is happening.

The Froggie lobby in the waiting phase: a roster of frogs, an inline name field and color picker, a copy-able invite link, and a Start Game button.
The waiting-phase lobby. Roster, name and color picker, and a copy-able invite link, so the wait feels like an actual room instead of an empty board.

A mobile D-pad that also takes mouse input

On a phone there's no keyboard, so a D-pad overlay handles directional input through touch events, and it also responds to mouse events so the same control works for a desktop developer testing in emulation. It sits behind a settings toggle so desktop players can hide it completely.

A tongue button with a cooldown

The tongue attack knocks an adjacent frog back, and since it's the one player-versus-player action in the game, it needed its own button on mobile. The actual design decision was the cooldown length. If it's too short, players just spam each other off the logs constantly, and if it's too long, nobody bothers using it. The value I landed on is short enough to feel responsive but long enough that firing it is a real decision, and the button reuses the same event handling as the D-pad so the two don't interfere with each other.

The game in a landscape phone viewport: a directional D-pad and a red tongue button sit in the bottom-right corner, leaving the play area clear.
A landscape phone layout. The D-pad and tongue button tuck into the corner, so the full action set is reachable by thumb without eating the board.
06Reflections

Looking back at the project.

Frogger MP started as an experiment. I'd wanted to design a video game for years but had always treated it as out of reach, since it seemed to need more engineering than I had, and a lot of the project was seeing how far I could get by iterating my way through it. The reason it's in the portfolio is that the design decisions are unusually easy to see here. A lot of my production work ends up buried in meeting notes and design docs, where the reasoning is hard to point back to later, whereas with this project the decisions are right there in how it plays.

A lot of the habits I lean on at work show up across these five sections: building a way to see what's happening before trying to tune it, making a constraint visible so I can actually work against it, and leaving some things for the player to pick up on without spelling them out. Frogger didn't teach me any of that, but it's probably the clearest place I can point to where all of it is on display at once.

And it is genuinely fun, which is the other reason it is here. Go play it.