One input pipeline, three ways to hold a stone
A player can aim Pebble Merge with a mouse, a finger, or the arrow keys. All three end at the same clamped position on the same board. That was not three features. It was one small function and a bug I shipped on the phone before I fixed it.
Three ways in, one place out
The board thinks in its own units, not screen pixels, so the first job of any input is to
translate. The whole translation is this: take the event, read where it landed inside the
canvas rectangle, divide by the rectangle width, multiply by the board width. One helper does it
for every input type. One helper. Not three. The mouse, the finger, and the key all arrive at the
same aim line through the same four arithmetic steps, and the only reason I ever imagined there
would be three separate handlers was that I had not yet written the first one and the absence of
code made the differences look larger than they were. For a touch it reads
e.touches[0].clientX; for a mouse it
reads e.clientX; either way it subtracts the rectangle's left edge first. The
result is clamped between 20 and the board width minus 20, because a pebble dropped at the very
edge would hang half off the wall and look broken.
Then both the move and the press handlers do the same three things. Update the aim. If a pebble is already in the hand, move it there too. If this is a touch event that can still be cancelled, stop it from doing the default thing. The mouse and the finger are therefore the same code path with a different source of the X coordinate, and that is the entire trick. I had imagined three handlers and a translation layer between them. There was no layer. There was one function and a question about which property to read.
The scroll bug
I wired touch first without the cancel call. It felt fine on the desktop simulator, because the simulator does not scroll a page when you drag on a canvas. On a real phone it was a different game. You put your thumb down to aim, you slid it sideways, and the whole page scrolled instead of the pebble. The board moved under your hand. The drop landed wherever the page happened to be by the time you lifted. I played it once on a train and closed the tab in annoyance before I even understood what I was annoyed about. Annoyed at the game. Not at myself. The game, obviously, because the game was the thing in my hand and the tab was the thing I could punish, and I punished it without ever noticing that the annoyance was really at a line of code I had not finished.
The fix is two parts and both matter. The listener is registered with { passive: false
}, which tells the browser we intend to cancel the event, and then inside the handler we
call preventDefault on the touch case. Miss either one and the page scrolls. I missed
the passive flag first, added preventDefault, watched it do nothing, and only then read the
warning the browser had been printing the whole time, the warning that said in plain text exactly
what was wrong and that I had trained myself not to read because reading warnings felt like
admitting the code was talking back to me. The warning said exactly what was wrong. I had not been
reading warnings. That is the actual bug, the one under the scroll bug, the one that cost me a
train ride of irritation before I bothered to look.
A nice side effect: because the finger and the mouse share the handler, fixing the phone fixed the tablet and the trackpad in the same commit. I did not have to remember a second place. That is the quiet win of unifying early. Every input bug now has exactly one home.
Keyboard is the exception, and that is on purpose
The arrow keys do not go through the same helper. They cannot, because they carry no coordinate. A key press is a step, not a position, so the handler nudges the aim by a fixed amount, 18 board units left or right, and clamps it the same way. Space drops. Arrow up swaps the held pebble for the next one. It is a coarser instrument by design. Someone steering with keys is not trying to place a stone to the pixel. They are tapping their way through a round on a laptop with no mouse, and 18 units is close to one pebble width, so each press moves the aim by roughly what a finger would move in a small slide.
I argued with myself about whether to make the keyboard continuous, hold to glide. I decided against it. Glide invites mash. Mash wins. The same lesson, again, from a different file, the one about the combo counter that ran to 34 because the fastest input had been allowed to outrun the reward it was supposed to earn. A held key gliding the aim invites the same spam problem the combo window taught us about, where mashing beats aiming, and the keyboard player is exactly the one who would mash, because the keyboard is the instrument of repetition and a person with a key held down is a person already voting for speed over placement. Discrete steps keep the key player honest in the same way the drop cooldown keeps the mouse player honest. Different mechanism, same instinct: do not let the fastest input win the game for free.
What it cost me to learn
The expensive part was not the code. The code is forty lines and most of it is the clamp. The expensive part was the week I spent debating whether mobile even needed to feel good, because mobile was where the clones lived and I told myself the serious players used a mouse. Lazy. That framing was lazy, and I knew it even then, and I repeated it anyway because it let me ship the desktop build first and call the delay a priority instead of an excuse. The serious players are whoever is in front of the screen right now, and on a phone that is most of them, most of the time, and a board that scrolls away from your thumb is not a game you come back to. I almost shipped the laziness as a feature and called it desktop first.
There is a smaller lesson I keep relearning from this file. The difference between mouse and touch is one property read, not a philosophy. The difference between those two and keyboard is real but tiny, a step instead of a point, and worth keeping because it changes the feel in the right direction. Most of the input complexity I imagined did not exist. It was a story I told myself about why the phone could wait. The phone could not wait, and the fix was smaller than the excuse.
We ship one pipeline now. Mouse, finger, keys. Same aim, same clamp, same drop. The only branch in the code is the one that decides where to read the X from, and the only branch in my head is the one I had to remove, the one that said some players mattered more than others.