Monster Mash League is live at mash.domdhi.com. Browser baseball with 98 original Halloween monsters. No account, no install, free.
First commit was October 25. It was done October 30: 121 commits in five days. I launched it on Halloween.

What you can play
Four ways in:
- Home Run Derby. Hold to swing, release to hit. Solo, or a duel against a friend online.
- Perfect Game. You pitch. Aim, speed and accuracy meters. Keep every batter off base. Solo or head-to-head.
- Play Ball. A full game against the CPU, a two-player room with a code, or Quick Match to get paired with a stranger.
- League Challenge. Take your club through a championship run and unlock The Legends.

There's a tutorial you can skip, a Practice mode, a CPU-vs-CPU Watch mode, custom clubs, badges, and career records that stay on your device. Touch, keyboard, mouse and gamepad all work. Portrait and landscape both work. Reduced motion is supported. The commentary is Howlin' Hank and Gravely Graves, and the music is generated in the browser.
Every monster has its own power and contact ratings, so picking a slugger is a real decision:

It's two players, max. I built a party mode for more and then cut it on October 29. Two people, one ball, nothing to arbitrate.
The part I care about: nobody sends the ball
Online baseball has a problem. The ball's flight is physics, and physics sent over a network is lag, rubber-banding and arguments about who was safe.
So I don't send the ball. I send decisions.
The whole game is a pure function of three things: (setup, seed, decisions[]). Pitch type and meter results. Swing, take or bunt, with timing in integer milliseconds. That's it. Each browser runs the full simulation locally, at 120 ticks per second, and gets the same game. The server (a Cloudflare Worker and a SQLite-backed Durable Object per room) doesn't simulate anything. It orders the decisions, stamps them, and relays them.
Frames are JSON, one kilobyte max. The room mints the seed, so neither player picks it.
Same game on both ends, on purpose
That only works if the simulation is deterministic down to the bit. So the headless code has rules, and the linter enforces them:
- No
Date, noperformance, nosetTimeout, noMath.random. - No
sin,cos,poworexp. Trig comes from a 0.1-degree table built from integer series, because different JS engines can disagree about the last digit of a float. - Every outcome roll draws a fixed number of random values. Draw first, then branch.
- Nine separate seeded RNG streams, keyed by
(seed, pitchNo), so a change in one system can't shift another's dice.
After every pitch, both clients send a 32-bit FNV-1a hash of the game state: count, score, bases, pitcher pitch counts, RNG positions, tick. The room compares them. Match means play continues. Mismatch means the room asks for six section hashes, writes one log line, and tells both clients to throw their state away and replay from the seed through the decision log.
If the rebuilt hashes still differ, the match ends with no result. Nothing is credited. I'd rather void a game than record a wrong score.
A desync_recovered line in the logs counts as a bug, not a feature.
Receipts
As of today:
- 75,928 lines of TypeScript, not counting tests.
- 457 test files, plus Playwright end-to-end suites that include WebKit.
- 15 architecture decision records.
- 32 epics and 138 stories planned before the build, with the plan run wave by wave.
- No runtime dependencies. TypeScript, esbuild, WebGL2 rendering, procedural Web Audio, installable as a PWA with offline support.
Any change to the simulation code or sim-relevant data changes an ENGINE_BUILD hash. The WebSocket URL carries it, and the room refuses to pair two clients on different builds. A deploy can't quietly split two players onto different rules.
Play it
Swing at mash.domdhi.com. Send a friend a room code or a challenge link and try to beat their result on the same seed.
If the hashes ever disagree, you'll find out first.