GET /api/work/shotcall
Shotcall
Live · · solo
Like Wordle, but with a bouncing ball. One puzzle a day: work out which pocket it ends up in. Guess wrong and it shows you one more bounce.A daily puzzle. A ball bounces three times and drops into a pocket; you guess which one. Every miss shows you one more bounce.
- TypeScript
- Astro
- SVG
- Node
- Board shapes searched
- 170,356
- Boards that survive
- 1,037
- Ball positions verified
- 1.55M

Parameters
Request
curl "https://portfolio.dylansg0318.workers.dev/api/ work/ shotcall"
Response200 OK · 3.5 KB
{ "id": "shotcall", "title": "Shotcall", "status": "live", "year": 2026, "category": "game", "role": "solo", "summary": "A daily puzzle. A ball bounces three times and drops into a pocket; you guess which one. Every miss shows you one more bounce.", "summary_plain": "Like Wordle, but with a bouncing ball. One puzzle a day: work out which pocket it ends up in. Guess wrong and it shows you one more bounce.", "stack": [ "TypeScript", "Astro", "SVG", "Node" ], "metrics": { "board_shapes_searched": "170,356", "boards_that_survive": "1,037", "ball_positions_verified": "1.55M" }, "links": { "play": "https://portfolio.dylansg0318.workers.dev/play/shotcall" }, "problem": "A daily puzzle has to be the same kind of hard every day, so the shape is fixed: three bounces, four tries, fourteen pockets. That also caps the supply. A plain rectangle only has about 91 boards once mirror images count as one, which is three months. And there's no server, so tomorrow's board has to already be in the page without the answer being in it.", "unique": "Table size doesn't matter, gcd(W, H) does. A shared factor closes the ball's cycle early, so a 12×6 is bigger than an 11×9 and worse. Every table shipped is coprime. And one block on the cloth takes 91 boards to 1,037 without giving the player anything new to learn, since bouncing off solid things is already the game.", "ai": "Built with Claude Code in one session. The idea was mine — I turned down two of its ideas as too complicated before suggesting the bouncing ball — and so were the miss-shows-a-bounce rule, the freehand chalk line and the puff of dust when you erase. I threw out its first interface; it had copied this site's design system and looked like every other generated page. It also shipped two things blind that I caught: pocket letters that weren't on the board, so the scorecard said \"Not J\" about nothing, and a chalk line you couldn't erase. The generator, grading and physics are its work under my direction, and those are the parts I wanted proved: seven checks over 1.55 million ball positions, and every shipped board replayed through the same rule the browser runs.", "learned": [ "Generating content is mostly filtering. 170,356 board shapes go in and 1,037 come out, about 0.6%. Nearly all the real code is the eight reasons a board gets thrown out, and one of them, a bounce landing dead on a corner, is 59% on its own.", "Dedupe before you count. 84% of the boards that passed every filter were mirror images of one already kept. \"Five years of puzzles\" was 2.8 once I checked.", "A failing test isn't always wrong code. The reversibility check failed on 32% of boards and the check was the bug. I only changed it because I could point at the exact line.", "Test pointer code with a real mouse and a real finger. Two bugs only existed on the input I wasn't using, and neither showed in the code." ], "cover": { "url": "https://portfolio.dylansg0318.workers.dev/_astro/cover.VyJa4GDl.png", "width": 2560, "height": 1440, "alt": "A solved Shotcall board. The ball's real path is chalked in white from the top rail, off the right rail, the bottom rail and the underside of a wooden block, into pocket L; the player's own amber line, drawn freehand, tracks it bank for bank and dies on the bottom rail beside K. Four pockets are crossed out, fourteen are lettered A to P on the green cloth." }, "page": "https://portfolio.dylansg0318.workers.dev/projects/shotcall" }
Live: this is the answer from this site’s API, not a mock. Send re-runs the request with your parameters; if the answer names a page, it opens.
The problem
A daily puzzle has to be the same kind of hard every day, so the shape is fixed: three bounces, four tries, fourteen pockets. That also caps the supply. A plain rectangle only has about 91 boards once mirror images count as one, which is three months. And there's no server, so tomorrow's board has to already be in the page without the answer being in it.
What was unique
Table size doesn't matter, gcd(W, H) does. A shared factor closes the ball's cycle early, so a 12×6 is bigger than an 11×9 and worse. Every table shipped is coprime. And one block on the cloth takes 91 boards to 1,037 without giving the player anything new to learn, since bouncing off solid things is already the game.
Where AI fit in
Built with Claude Code in one session. The idea was mine — I turned down two of its ideas as too complicated before suggesting the bouncing ball — and so were the miss-shows-a-bounce rule, the freehand chalk line and the puff of dust when you erase. I threw out its first interface; it had copied this site's design system and looked like every other generated page. It also shipped two things blind that I caught: pocket letters that weren't on the board, so the scorecard said "Not J" about nothing, and a chalk line you couldn't erase. The generator, grading and physics are its work under my direction, and those are the parts I wanted proved: seven checks over 1.55 million ball positions, and every shipped board replayed through the same rule the browser runs.
Parameters
Request
curl "https://portfolio.dylansg0318.workers.dev/api/ work/ shotcall? fields=problem%2Cunique%2Cai"
Response200 OK · 1.5 KB
{ "problem": "A daily puzzle has to be the same kind of hard every day, so the shape is fixed: three bounces, four tries, fourteen pockets. That also caps the supply. A plain rectangle only has about 91 boards once mirror images count as one, which is three months. And there's no server, so tomorrow's board has to already be in the page without the answer being in it.", "unique": "Table size doesn't matter, gcd(W, H) does. A shared factor closes the ball's cycle early, so a 12×6 is bigger than an 11×9 and worse. Every table shipped is coprime. And one block on the cloth takes 91 boards to 1,037 without giving the player anything new to learn, since bouncing off solid things is already the game.", "ai": "Built with Claude Code in one session. The idea was mine — I turned down two of its ideas as too complicated before suggesting the bouncing ball — and so were the miss-shows-a-bounce rule, the freehand chalk line and the puff of dust when you erase. I threw out its first interface; it had copied this site's design system and looked like every other generated page. It also shipped two things blind that I caught: pocket letters that weren't on the board, so the scorecard said \"Not J\" about nothing, and a chalk line you couldn't erase. The generator, grading and physics are its work under my direction, and those are the parts I wanted proved: seven checks over 1.55 million ball positions, and every shipped board replayed through the same rule the browser runs.", "page": "https://portfolio.dylansg0318.workers.dev/projects/shotcall" }
Live: this is the answer from this site’s API, not a mock. Send re-runs the request with your parameters; if the answer names a page, it opens.
A ball comes onto a pool table at 45°, bounces three times off the rails or the block in the middle, and drops into the first pocket it reaches. Fourteen pockets, four guesses. Each miss shows you one more bounce, so a wrong guess still gets you something.
The name’s the pool rule: call the pocket before you shoot.
You can chalk the route yourself too. Drag across the cloth, and when the real path shows up you get both lines and how far off you were. One board a day, or the table can rack another one on the spot.
Where the boards come from
The hard part isn’t the puzzle, it’s having a new one every morning that’s the same kind of hard as yesterday’s. Every board has exactly three bounces, and that’s what nearly killed it: a plain rectangle only has about 91 different puzzles, so three months and it’s over. One block in the middle of the table takes that to 1,037, a bit under three years, and it doesn’t ask anything new of you. Bouncing off solid things is already the game.
The puzzle-maker just tries everything. Every table size, block and starting point, 170,356 boards, gets rolled, and anything not worth playing gets thrown out: the ball hits an exact corner and you can’t tell what happened, a leg too short to notice or too long to count, the ball never touches the block. 6,392 make it through, and 84% of those turned out to be mirror images of each other, so one of each pair goes. 1,037 left, about 0.6% of what went in.
Those get sorted by difficulty into seven groups, one per weekday. Easiest Monday, hardest Saturday, the way crosswords do it.
When the day’s board is spent, a button racks another one right there. It has to pass the same tests, it just doesn’t count for the day. There’s a Today’s hard key too: same table, any angle, somewhere between two and six bounces, and it never tells you how many.
Before the site builds, a separate checker replays all 365 boards. If the ball doesn’t land where the puzzle says, the build stops.
The rule
The ball moves on the 45° lattice, one unit sideways for every unit forward, so it’s on a whole dot at every step and can’t slip through a surface. A block face is the same rule as a rail — flip the perpendicular component, leave the other alone — which is why the block adds nothing for the player to learn. The daily has no tolerance to tune; hard mode gives that up, below.
Why the tables are odd sizes
I assumed a bigger table meant a harder puzzle. It doesn’t. What matters is gcd(W, H), because it decides how much of the rim the ball can reach before its path starts repeating: gcd 1 reaches about half the rim, gcd 6 about a fifth. A 12×6 has more cloth than an 11×9 and is worth less. Every shipped table is coprime, and the generator throws if you add one that isn’t.
Where the boards come from
Brute force with hard filters. Every combination of table size, block, entry point and direction gets walked, and most of it goes:
| boards | |
|---|---|
| considered | 170,356 |
| a bounce landed exactly on a corner | −100,472 |
| a leg shorter than 2 dots or longer than 7 | −39,964 |
| the ball never touches the block | −12,092 |
| the fourth contact was the block, not a pocket | −5,352 |
| the answer sits too close to the entry | −4,140 |
| the ball crosses its own answer early | −1,488 |
| fourteen spread pockets won’t fit | −456 |
| survived | 6,392 |
| mirror images of a board already kept | −5,355 |
| shipped | 1,037 |
Corners alone are 59%: a ball landing dead on a corner reflects off both faces at once, which is right, but on screen you can’t tell what happened. The mirrors got me. 84% of what passed was a flip of a board already in the list, and that’s what turned “about five years of supply” into 2.8.
There’s a closed form on a bare rectangle, but the block breaks it and the brute-force search runs in a fifth of a second anyway, so I didn’t bother.
Grading the year
Difficulty is weighted by what actually costs you: total dots to count, the longest single leg, bounces off the block, how uneven the legs are, how crowded the pockets around the answer are. Scores split into seven equal-count tiers, so Saturday is the hardest seventh of the boards that exist, not the ones over a cutoff I guessed at.
The answer isn’t in the file; the browser traces the path anyway, so the generator asserts the pocket instead of storing it. One thing though: it’s a prerendered site, so the whole year is in the page and anyone who wants to can work out next Tuesday. It’s a puzzle for fun, so I’m fine with that.
Drawing your own line
The line is freehand, no snapping, no smoothing. Scoring it is the judgement call: average distance from each drawn point to the path looks fine and is worthless, since a one-inch scribble sitting on the path scores nearly perfect. So it runs both ways, drawing against path and path against drawing, averaged.
Drawing is a toggle because of phones: a finger can only draw where the page
gives up scrolling, and the cloth is most of a phone screen, so it’s on by default
for a mouse and off for touch. Two bugs only existed on the input I wasn’t using.
Capturing the pointer on pointerdown retargets the click that follows, and
preventDefault() there kills the synthesised click on a phone. Both moved to
the first move event.
The table that racks itself
The practice button has to deal the same game, so what makes a board fair moved out of the build script into a module the demo imports. Regenerating the year afterwards gave a byte-identical file, which is how I know the move changed nothing. In the browser it’s rejection sampling: about 3.7% of random boards survive, so it lands in roughly 27 tries at 0.039 ms each.
Hard mode, off the grid
The grid was never needed for the bounce; a constant-velocity tracer reproduces all 365 dealt boards to the bit. What it bought was a finite space to count and dedupe, which a daily with a fixed supply needs and a mode about variety doesn’t. So hard mode drops it: any angle from 12° off the rail, two to six bounces climbing through the week, and the count is never printed — on the daily, “three” was a checksum on your trace. No board file either. It’s made from the date with a seeded PRNG on your device, so it’s the same for everyone and never runs out.
Exactness becomes margins: the answer within 0.3 dots of its pocket, every earlier contact a full dot from any other mouth, nothing within 0.6 of a corner. One thing I got wrong first: drawing the bounce count per attempt let short paths crowd out long ones, and forty of sixty boards came out two-bounce. Now the count’s drawn once and the search is for a board with that count.
The gate, and the test that was wrong
The physics gate checks seven invariants over every board the generator can produce: 25,448 boards, 1.55 million ball positions, 0.19 seconds. Six are the obvious ones — stays on the table, never inside the block, never tunnels, one diagonal unit per step, flips only at a surface. The seventh runs each path backwards, and it has to retrace itself exactly.
That one failed on 32% of boards, and the test was the bug. Reversing at a contact means negating the ray the ball arrived on, not the one it left on. Changing a test until it passes is usually how a bug ships with a green check, so it mattered that I could point at the exact line.
The generator, the gate and the demo each used to have their own copy of the reflection rule, which is two extra ways for the shipped game to disagree with the board it dealt. They import one module now, and the gate replays all 365 committed boards through it, plus 2,000 practice boards and 373 hard ones from fixed seeds.
What I learned
- Generating content is mostly filtering. 170,356 board shapes go in and 1,037 come out, about 0.6%. Nearly all the real code is the eight reasons a board gets thrown out, and one of them, a bounce landing dead on a corner, is 59% on its own.
- Dedupe before you count. 84% of the boards that passed every filter were mirror images of one already kept. "Five years of puzzles" was 2.8 once I checked.
- A failing test isn't always wrong code. The reversibility check failed on 32% of boards and the check was the bug. I only changed it because I could point at the exact line.
- Test pointer code with a real mouse and a real finger. Two bugs only existed on the input I wasn't using, and neither showed in the code.
Parameters
Request
curl "https://portfolio.dylansg0318.workers.dev/api/ work/ shotcall? fields=learned"
Response200 OK · 833 B
{ "learned": [ "Generating content is mostly filtering. 170,356 board shapes go in and 1,037 come out, about 0.6%. Nearly all the real code is the eight reasons a board gets thrown out, and one of them, a bounce landing dead on a corner, is 59% on its own.", "Dedupe before you count. 84% of the boards that passed every filter were mirror images of one already kept. \"Five years of puzzles\" was 2.8 once I checked.", "A failing test isn't always wrong code. The reversibility check failed on 32% of boards and the check was the bug. I only changed it because I could point at the exact line.", "Test pointer code with a real mouse and a real finger. Two bugs only existed on the input I wasn't using, and neither showed in the code." ], "page": "https://portfolio.dylansg0318.workers.dev/projects/shotcall" }
Live: this is the answer from this site’s API, not a mock. Send re-runs the request with your parameters; if the answer names a page, it opens.