BeamNG.Earth
BeamNG.drive용 1:1 비율 실시간 지구, 실제 고도·위성 이미지·부동 원점·보조 서버에서 LOD 스트리밍
아직 감지된 업데이트가 없습니다. GitHub 릴리스가 나오면 여기에 보입니다.
개인 계정의 공개 저장소 소유자는 GitHub로 로그인해 운영자를 확인하고 소개를 직접 관리할 수 있습니다. 관리자 승인은 필요하지 않습니다.
로그인한 뒤 확인 파일을 만들어 이 저장소의 기본 브랜치에 30분 안에 커밋해 주세요. 다른 브랜치나 포크의 파일은 인정하지 않습니다. 파일을 올린 뒤 아래에서 확인하면 관리자 승인 없이 관리할 수 있습니다. 자격을 유지하려면 파일을 남겨 두세요.
로그인이 필요하면 로그인 화면으로 이동합니다. 로그인 후 이곳으로 돌아와 원하는 확인 버튼을 다시 눌러 주세요.
프로젝트 폴더에서 /nomorevibe verify를 실행하면 소유권을 확인하고 소개를 직접 관리할 수 있습니다.
1분 만에 확인하기Hitman Cricket Game
저장소 README에서 가져옴
README 전체를 불러오는 중입니다. 아래는 저장된 발췌입니다.
A small, browser-based 3D cricket batting game. Five overs, 30 legal balls, three wickets. Read the delivery, pick a direction, and time your swing. Built with plain TypeScript, Three.js, and Vite; no external art, fonts, character packs, or physics engine are required.
Play it here — nothing to install, and it works on a phone.
Requires Node.js 22.12+ (or 20.19+) and npm.README 전문 보기 ↗A small, browser-based 3D cricket batting game. Five overs, 30 legal balls, three wickets. Read the delivery, pick a direction, and time your swing. Built with plain TypeScript, Three.js, and Vite; no external art, fonts, character packs, or physics engine are required.
Play it here — nothing to install, and it works on a phone.
Requires Node.js 22.12+ (or 20.19+) and npm.
npm ci
npm run dev
Open the local URL printed by Vite (normally http://127.0.0.1:5173). Use a recent browser with WebGL. The ground fills the whole window: there is no page around it, and every control — sound, help, share, pause, fullscreen — sits on the field itself. On a phone, swipe directly on the field to bat; on desktop, use the keyboard. The camera widens and drops its aim on taller screens so portrait play still sees the full pitch. The Share button opens your phone’s share sheet, or copies the game link on desktop.
npm test # Vitest gameplay tests
npm run build # TypeScript checks and production assets in dist/
npm run preview # Serve the production build locally
The production build can be served by any static web host. base: './' supports deployment in a subdirectory.
npx vite build --config vite.single.config.ts # one self-contained HTML file
That build inlines every asset as a data URI and splits nothing, so dist-single
holds a page with nothing left to fetch — which is what a branch preview needs
when the only real deployment tracks the default branch.
A phone gets the cover art. src/assets/cover.webp fills the screen, title.webp sits in the sky, and Play and How to play sit low on the pitch at the artwork's own proportions: a button is 54% of the width, and the pair end a twentieth off the foot of the screen. Nothing else is on it — the scoreboard, the confidence meter, the field labels and the button row all wait until there is an innings to describe. The trophy line above the buttons is the way onto the board, and it is always there; what it quotes is not. Until there is a personal best it quotes the board's leader, because a best of nought is a sentence about nobody. Across a landscape phone a portrait cover crops to nothing useful, so the live ground stands in for it and the lockup sits over the real thing.
Both calls to action are set in Jaro (Agyei Archer, Céline Hurka, Mirko Velimirović), bundled as a 19 kB latin subset at src/assets/jaro-latin.woff2 under the SIL Open Font License 1.1; the notice ships beside it in jaro-OFL.txt. The orange is #e9582b, taken off the cover art rather than the interface's own --orange.
A pointer that is not coarse gets the card over the live ground instead, where there is room for the keys and the pitch behind them. The choice is made once, from the same touch-device test the rest of the game uses.
The start screen offers a three-ball tutorial. Each ball is slow, dead straight, and scripted to teach one gesture: a middle-stump ball to drive straight (swipe up), one on the legs to whip away square (swipe right to left), and one wide outside off to cut square behind point (swipe left to right). An arrow on the field sweeps the way you must swipe. Nobody gets out, nothing counts towards a score, and Skip to innings leaves at any point.
The first three innings a player finishes in each mode are a little kinder, and less so each time: the OK window reaches further into what would have been a mistimed, skied stroke, and the block takes a shade more of the ball. Perfect and good timing are left exactly as they are — they are where the fours and sixes come from — so the help keeps a new player in rather than scoring for them. No yorkers are bowled to them either; the pace is the same. Nothing on screen says so, and the innings count as any other. A player who brings a career back with a key is past it already.
The off side's scoring stroke, played off the back foot and square of the wicket: swipe right, or press D. He rocks back and across, leaves the ball out from his body and leans away from it so the arms reach it straight, strikes it square with a horizontal bat and the flat face to the bowler, and the arc keeps going — the wrists roll, the body unwinds on the back foot, and the blade wraps up over the front shoulder with the curved back of it against the shoulder. It replaces the off-side punch that used to sit on this input; the cover drive on W+D is untouched.
It wants width. Compatibility runs 1 outside off, 0.85 on off, 0.4 on middle and nothing worth having down the leg side. The bat reaches from the off stump out to the boundary side of the crease, and never back inside it: swung at a ball on middle or leg it plays square of the stumps anyway and the ball passes it, which is what cutting at a straight one deserves — close enough to nick, never square enough to hit.
It is also the second answer to a bouncer, alongside the pull. CUT.minWidth gates that case alone — how far outside off a short ball has to finish before there is room to cut it, read off where the ball ends up, so one swinging away is cuttable and one rearing at the body is not. A short ball with width is judged on timing alone: middled it is six, well timed four, held back it is worked along the ground, and anything later or earlier feathers the edge.
Its way back to the guard is authored too. A follow-through that wraps the bat behind the front shoulder cannot travel home in a straight line: the guard holds the bat behind the other shoulder, and every short path between the two goes through the batter's head and then his chest. The stroke names a recover pose — blade swung forward out in front of him, where he can see it, before it drops into the pick-up — and the tail of the animation routes through that. The long-on drive names one for the same reason.
An edge is not a skier. It comes off the face at gloves height and dies behind the stumps, and there is nobody in the frame to take it — a fielder placed there stands between the camera and the batter and fills the shot. It has its own sound, bat-edge.mp3, read ahead of the general wicket sound because the thin noise off the face is the whole story of the dismissal.
Swipe left, up-left, up, up-right, or right for leg side, long on, straight, cover, or the square cut, and down to block, on a 90-degree fan so a hurried drag still finds it. With the confidence meter full, down-left is the scoop and down-right the reverse scoop — the two downward diagonals, between 110 and 160 degrees off vertical, with straight down still the block for twenty degrees either side; without the meter those gestures are what they always were. The slivers either side of that fan stay dead, so a sideways drag is still no shot. A short 24-pixel swipe commits the shot immediately when its direction becomes clear. Timing is measured at that moment, not at finger-down or release, and uses the same timing bands, compatibility, scoring, and wickets as keyboard play. One shot per ball is shared across input methods. Taps, second fingers, and cancelled gestures do not trigger a shot. A gesture cannot carry into the next ball.
While the meter is full and a ball is on its way, a swipe guide fades in over the pitch in front of the crease: eight soft streaks of light, one per direction, each a blurred glow with a faint core that fades out along its length — light leaving the thumb, not arrows. The streaks that would spend the meter on this ball — the charge's three drives, the sweep's two leg-side swipes, a scoop's diagonal — are gold, breathe with the edge of the field, and carry a small pulse of light running outward; the rest stay a dim white. Only the strokes the ball in the air is actually for light up: a ball on middle stump lights the charge and the scoop, and the reverse scoop's streak waits for one on or outside off. It is a thumb's map, not a menu, and it goes with the shot the moment one is played. Touch devices only: a keyboard has nothing to swipe.
Use the on-screen Pause button to resume, restart, or change mode. Change Mode opens the mode picker over the pause card. The picker carries a back key beside its heading, across from the sound key: it goes back to whatever opened the picker, which from here is the pause card with the innings still paused behind it, ready to resume. Picking a mode instead starts a fresh innings in it, and is counted as an innings walked out on the way a restart is. A link that names a mode (?mode=) hides the key, since there is nothing to change to. Page scrolling is suppressed on the field during play; dialogs can still scroll on small screens.
| Key | Action |
|---|---|
| A or ← | Leg-side shot (left) |
| W or ↑ | Straight drive |
| D or → | Off-side shot (right) |
| S or ↓ | Defence: front foot to a ball pitched up, back foot to a short one |
| A + W (or ← + ↑) | Long-on drive |
| W + D (or ↑ + →) | Cover / long-off drive; the back-foot punch to a short ball |
| Enter | Start innings / play again |
| Esc | Pause / resume |
| R | Restart innings |
| B | Open the board (Esc or B closes it) |
| M | Step the sound: on → music off (game sounds on) → all off |
The arrow keys are read as the same three shots before anything else looks at them, so combos, the one-shot gate and the timing bands work identically whichever pair a player reaches for — and a mixed pair such as A + ↑ is still a combo. Arrow keys are also swallowed during a delivery so the page cannot scroll out from under the innings.
Press combo keys within 100 ms. Timing uses the first press, without adding the combo recognition delay. Only one attempt is allowed per delivery; repeated keydown events are ignored. A+D is not a shot combination. During a three-key sequence the first valid pair wins.
Swing as the ball reaches your bat. Perfect timing is within 40 ms, good within 78 ms, and OK within 135 ms; quick deliveries tighten these bands by 18%. These are narrow on purpose — a rhythm that roughly works will not keep finding the boundary.
Only a middled ball reaches the rope, and timing alone decides which one:
| Timing | Middled (a shot the line suits) |
|---|---|
| Perfect | Six |
| Good | Four |
| OK | One, two or three along the ground |
| Poor | Caught |
Reaching for a shot the line does not suit skies it whatever the timing, and so does poor timing. A ball in the air is only ever six or a catch — never a nudged single — and the call is held back until it comes down, so a skied shot has to be watched all the way. A shot the line suits outright is never caught. A missed ball on the stumps can be Bowled, or LBW after a failed shot; a miss outside the stumps is a dot ball. Every ball counts, and the innings ends at 30 balls or three wickets.
Deliveries are not all the same pace, and the gap is the point. A spinner floats down in about 1.3 seconds and a seam ball in about 0.8; an express ball arrives in 0.4 and tightens the timing windows with it. A bouncer is not a slow ball with a length on it either — it can come at you at 168. You have to read the pace before you can time it.
But not off the hand. The bowler's action is identical every ball, so the ball itself is the only thing left to read a slower one off — and a ball that crawls out of the hand at two thirds the pace of the last one announces itself in the first frame, a whole second before it arrives. There is nothing to be deceived by in that. So a slower ball leaves the hand at very nearly the pace of a length ball — 84% of it rather than 63% — and dies on the way down instead, reaching the bat at under half the speed it left at. That is also what a floated ball does: the drag it never had the speed to overcome takes the pace off it late.
Only a slow ball is held back. A quick one is never asked to accelerate down the pitch, because balls slow down and do not speed up. And the curve is arranged so the ball still arrives at precisely the moment it always did: every timing window in the game is measured off the contact time, and none of them move. What changes is only where the ball is in between.
He is a right-arm quick, and the action is the five things a real one does, in the order that makes them work. He runs in — a short approach, a couple of strides, with the stride tied to the ground he covers so his feet plant rather than skate; the bound and the delivery stride are what there is to watch, and a long jog in front of them only spends the batter's waiting time. He leaps into the bound and turns side-on in the air, bowling arm swept down and back, front arm reaching up at the target: the gather, where the energy is stored. His back foot lands parallel to the crease and takes the load. The front leg reaches out and braces, the front arm is pulled down hard into the ribs, and that block is what whips the shoulders round and the arm over the top. The ball leaves just past vertical, from a hand two and a bit metres up. Then he falls away over the braced leg, the back leg swings through, he runs off down the pitch — and he stands back up. A follow-through held to its last frame leaves a man bent double over his own knee, watching a shot he cannot see, which is the tell of an animation that stopped rather than finished. It runs on its own clock from the moment of release rather than on the ball's flight, because the flight ends when the stroke is played: tied to that, a shot cut short strands him half way out of the follow-through for as long as the result takes to show.
Both ends of the action are stationary — waiting at his mark, where the batter looks straight at him for half a second before every ball, and back on his feet once the ball has gone — and both used to be frames of the run held still, with the feet staggered mid-stride and the arms at the shortened reach a runner pumps them at, which puts both elbows out.
Standing is its own pose now, and the knees are the whole of it: feet under the hips, legs all but straight, arms hanging. A leg carrying weight at 86% of its length is a crouch, and a man crouching while he waits reads as braced for something that never comes. A fielder is a different case — he is watching a batter about to hit it, so his stance widens and his knees soften ready to move — but that is the resting pose bent rather than a second one written out beside it, and the crouch is the only difference between them.
The action is the disguise, so it runs on a clock of its own and the ball is
never passed into it. A bowler runs in and bowls at the same tempo every ball,
and what changes is the ball: a slower one comes out of the same arm at the same
speed, and the batter reads the action, commits, and finds the ball is not where
the action said it would be. An action that slowed down with the ball would
announce every variation a full second before it arrived and leave nothing to be
deceived by — so animate takes a time and nothing else, and the flight time
cannot reach it even by accident. Measured across an innings from 117 to 155
kph, the run-up is 900 ms on every ball.
The arm goes over in one accelerating sweep, from the bottom of the gather to the ball leaving the hand, and it is doing about 29 rad/s as it lets go — a fast bowler's arm. It used to be two sweeps: a climb at 8 rad/s and a last flick at 18, so the arm was still gathering pace at the exact moment the ball went, and then fell off a cliff to 5 on the far side of it. An arm that stops at release is what a slow action actually looks like, because the eye follows it through the ball and what it sees is the arm stop. It carries its speed past the ball now and bleeds it off over the next fifth of a second.
He accelerates into the crease and never drops back below the speed he ran in at: 5.9 m/s in, 8.2 through the leap, 7.2 through the gather and 7.1 through the delivery stride. The shape is what matters rather than any one of those numbers. It used to fall off a cliff — 6.6 in, then 3.4 and 2.8 — because the gather and the delivery stride had a fifth of a second between them to cover a metre, and short distance over fixed time is a man slowing down. Getting the shape right meant slowing the approach as much as quickening the delivery: at 6.9 in and 6.1 through the stride the arithmetic was nearly level and it still read as momentum lost, because against a fast run-in anything short of faster reads as slower. A fast bowler's delivery stride alone is longer than the gather and the stride were put together; it is 1.4m now, longer than his leg.
He is lowest as the front foot lands and tallest as the ball goes: the hips travel up and over the braced leg. That is both what a delivery stride is for and the only way a leg that length reaches a foot planted that far in front of it — stood tall the whole way through, the leg is stretched flat before the foot ever gets down.
The bowling arm is a straight arm on a circle rather than a hand the limb solver chases, because that is what it physically is: an arm that bends at the elbow through the delivery swing is a throw, and it is the one thing the laws of the game actually measure. Driving it by angle means it cannot quietly soften into one, and a test measures it anyway.
Two things are checked rather than watched. The ball has to leave his hand at the point the delivery's own trajectory starts from, or it appears out of the air beside him. And no limb may be asked to reach further than it is long: past that the two-bone solver clamps, the shin stops short of the foot, and on screen the leg has come off at the knee. Both plants — the back foot he runs up over and the braced front foot — are measured in the world he is travelling through rather than in his own frame, because a foot that slides while it is carrying weight is the whole tell of a figure being dragged along instead of running. The front foot also lands behind the popping crease, since the game has no way to call a no-ball.
The bowler is not a random number generator — he watches the innings and answers it.
| Delivery | When it comes | What it does |
|---|---|---|
| Yorker | After he has been hit for three sixes | 152–163 kph, pitched at the toes and skidding on. It arrives at boot height in under half a second, and it is aimed at the stumps. |
| Bouncer | Occasionally, any line | Lands short and rears to chest height. It is over the stumps, so you can never be bowled or caught off it — but it can only be pulled (or hooked, outside off) for six, and only if you middle it: perfect timing on a leg-side swipe. The cover drive's swipe punches it off the back foot into the off side for a single or two, and swiping down in time blocks it off the back foot, a dot; anything else goes through to the keeper. |
| Slower ball | Once four quick balls have gone by | 78–98 kph and floated in at nearly a second and a half, straight after a burst of pace — and out of the same arm at the same speed, leaving the hand at nearly the pace of a length ball and dying late. |
The on drive has a six of its own too, from a broadcast recording: swipe to long-on and time it perfectly on a line that suits the stroke, and the arms open out towards long-on with the blade climbing, the hands keep going up and across, and the finish is both arms straight above the helmet with the bat to the sky over the front shoulder, back foot on its toe. Anything short of perfect is the on drive along the ground, unchanged — four, three, two or one. It is a regular stroke, so no slow motion.
The pull is not a separate control: swipe leg side (or press A) at a ball up around your chest and the batter plays it off the back foot with a horizontal bat, instead of the front-foot flick he uses at a normal-height ball. Rebuilt from a net session filmed from the bowler's end, square on the off side and close on the arms, it comes in two versions, drawn ball by ball and scored the same. Version two, half the time, is the hook's stroke at a ball on the body: back and across with the bat high, square to the bowler, the ball met out in front of him with both arms long and the flat face to the bowler, swung round flat out to leg, wrapped behind his head with the back of the blade on the front shoulder, and turned on round until his back is to the bowler. Version one, the other half, keeps the old body, side on and met beside him, with the same swing of the bat: up high, the face to the ball, round flat to leg with the arms long and wrapped over the front shoulder, standing where he hit it. Only the pull, the hook, the back-foot punch and the back-foot defence follow a ball up there — every other stroke swings at its own height and a bouncer passes over the bat.
Only a perfectly timed pull hits a bouncer at all, so one that does gets a flash of its own, in either mode: focus lines out from him, a swoosh behind the bat, and a red streak behind the ball, with the ground left in its colours. ?bouncers=1 is the playtest for it: every ball a bouncer, in either mode, with the timing and the scoring unchanged. The lines and the swoosh are white and blue, the swoosh running blue at the bat into white behind it; ?pullpen= tries them in another pair of colours — gold, neon, red or royal — side by side.
A four runs to the rope along the ground; only a six leaves it, and only a mishit hangs in the air.
Swipe leg side (or press A) at a ball on the pads and the batter flicks it, in two versions drawn ball by ball, three in four and one in four. Version one is the flick the game has always played: the front foot opened to leg and the bat whipped round level, scored as every other stroke. Version two is rebuilt from a coaching film in four angles: a stride straight down the line of the ball, the knee bent over the front foot and his head over it; the bat comes down straight and meets the ball beside the front pad, under his eyes, the face to the bowler. Then the wrists do it: the bottom hand rolls over the top one, closing the face, as the hands push the bat out low after the ball to midwicket. Only then does it come up, the roll carrying on the same way as it rises, and it stops there, the bat upright in front of him with the hands out ahead of his chest, below his chin, the flat face to the bowler and the curved back to his helmet; he holds it, bent over the front leg with his head down, before standing up into his guard.
A ball short of a length, rising to the thigh, is flicked off the back foot, version three, every time: back and across towards off stump with the front foot drawn back beside the back one, so the feet are side by side and his chest is open to the bowler, tall on the back leg; the bat comes down straight and meets the ball late, just in front of the front pad, under his eyes, the flat face turned to square leg. Then the hands lift the bat straight up, the face back to the bowler, the toe tips over across the front of his helmet, and it goes on up to stand diagonally in front of it, the toe over the back shoulder, held there, the flat face to the bowler. A bouncer is still pulled.
It is played delicately, with the ball's own pace doing the work, so versions two and three are never a six. Middled or a shade under it is four along the ground through midwicket; held back or mistimed it is a one, a two or a three (never three in a Test), or chipped up in the air and caught at midwicket, rarely when held back and half the time when mistimed. Across a ball it does not suit, it is as good as mistimed. The version is rolled off the innings' seed and the ball, so the same ball plays the same version for both players in a Rival Match. ?flickv=1 or 2 always plays that one, and since version two is scored its own way, it is practice. scripts/flick-check.mjs plays it in a browser in the Blast and in Survival.
The cover drive's swipe (or W and D) at a ball short of a length is the back-foot punch: anything that reaches the batter above 70 cm, which is every ball back of a length and every bouncer. Pitched up, the same swipe is still the cover drive. Rebuilt from two net sessions filmed in slow motion: the back foot takes a step straight back towards the stumps, he stands up tall with the weight on it, and the bat comes down straight, the hands close in front of the front hip, onto the ball by the front thigh, the face to cover and the handle a little ahead so it stays down. Then it is driven: the toe swings out towards cover, the hands come up close to him, by his chest and then up by his face with the top elbow high, where the stroke stops, and the front foot slides back to close the gap with the back one, so he finishes with his feet together and the bat leant out towards cover. The wrists never roll it. He holds it, then comes back into his stance.
It goes through cover along the ground and is never six. Back of a length, middled it is four, a shade under two or three (one or two in a Test), held back ones and twos, and poor it takes the edge to the keeper or goes off a thick edge for a single. A bouncer, up at his chest, can only be pushed into the off side: a single or two at best, and poor it is caught behind three times in four. Played across a ball on the legs it is the edge too. Missed, a bouncer goes on through to the keeper.
Swipe down, or press S, and the batter blocks it — and how depends on the length, not the swipe. A ball pitched up, full or a good length, gets the front-foot defence: a long stride to the pitch of it, the front knee bent with his head over it, the back leg long on its toe, the bat straight beside the front pad with the handle ahead of the blade so the face looks down, the top elbow up and the bottom hand soft. A ball back of a length, or a bouncer, gets the back-foot defence: back and across towards off stump with the front foot drawn back beside it, tall, the bat straight up and down in front of him and the top hand high, the ball played late under his eyes at whatever height it arrives. Both are held a moment, then he goes back to his guard. Both were rebuilt from net sessions filmed in slow motion, and the bouncer, which used to pass through the old block, is now blocked like any other ball. Get the bat down in time — OK timing or better — and the ball dies at his feet: a dot ball, and nothing off the middle of a dead bat carries to a fielder, so a defended ball can never be caught. Leave it later than that and the ball simply goes past the bat; if it was going on to hit the stumps, that is Bowled or LBW, and if it was missing them it is a play and miss.
Defence beats any stroke pressed with it — a player reaching for the block has decided not to play one — and the downward swipe fan is a full 90 degrees, so a hurried drag down still finds it. It scores nothing, and it costs nothing either: blocking leaves the confidence meter exactly where it stands.
Three balls in a row that go nowhere, though — blocked, left or beaten, in any combination — and the field has something to say about it. A wicket is not one of them; there is nothing to needle a batter about once he is out. The run then starts again from zero, so it takes another three before you hear it a second time.
Confidence is earned by scoring and lost by not scoring. A six adds 28, a four 22, a three 16 and a two 12; a ball that beats the bat takes 18, while a single or a block leaves it where it stands — a block is a decision, not a failure — and a wicket empties it. Roughly four scoring shots fill it from nothing. It is a run of form rather than a bank balance, so it cannot be saved across a collapse.
Full, the meter pulses. When a ball you can walk at is coming — on the stumps, on a length, at a bowler's pace — the edge of the field lights up gold and the call goes out from the bowler's run-up, a full delivery before it arrives. Drive it and time it perfectly or well, and the batter charges down the wicket and hits it out of the ground for six, and the meter is spent.
The charge is built from two recordings of the real stroke. The back foot skips up to the front one, the front foot strides out a stride and a half past the crease while the bat goes up to the sky over the back shoulder, and from there it is a lofted straight drive on the move: the ball met on the full a stride and a half short of the crease, the arms opening out straight up the ground, and the blade climbing over the front shoulder into a wrap with the hands high beside the helmet and the bat laid back across the shoulder with its flat face to the sky and its curved back resting on the shoulder. From the ball the bat is fixed in his hands but for one roll, the bottom hand over the top as it goes over him; drawn from its poses alone it turned more than a turn and a quarter in them, wrapped with the face on his shoulder, and hung down his back on its edge. He stands square to the bowler and watches it go, holding the wrap a good while, then brings the bat home in one smooth movement, back over the top on the arc it went over on and down in front of him into the pick-up, before he walks back to his crease. Because he goes down the pitch before he hits it, the charge is resolved the moment you swipe and the ball is drawn to where he meets it; the slow motion runs from the swipe, through the run and the hit, to a beat off the bat.
Swipe to cover instead, with the same full meter and the same timing, and it is the charge over cover: the same walk at the ball, then the face opened to extra cover with the body still closed, the arms extended out that way, and the hands lifting the bat level, never upright, to a finish held with the hands just above the helmet, the bat pointing up the pitch over extra cover and the head left leaning to the off side — six over extra cover, and its own call. The bat never turns in his hands after the ball. Built from a second pair of recordings, and its follow-through from a net clip filmed from the bowler's end.
Swipe to long-on and it is the mirror: the charge over long-on, from a third recording. The face is closed towards mid-on at contact, the arms extend out that way with the blade climbing, and it finishes the way the straight charge does — over the top and wrapped over the front shoulder, the left one for a right-hander, facing mid-on — six a little squarer than long-on, with its own call. It comes home the same way, back up over the top and down in front. While the charge is on, the cover and long-on swipes each reach eight degrees closer to vertical than usual, because a thumb that starts straight up and curls was committing as a straight swipe before it got there; the straight charge keeps the fourteen and a half degrees either side.
The special stroke at the spinner: a full meter, a ball pitched up, and a leg-side swipe. It is built from a broadcast recording filmed from three angles — side on from the leg side, from behind the bowler and from behind the stumps. A long lunge down the pitch, the front foot well out towards the bowler and across to the line, the back knee going down to the turf far behind him, while the bat goes up high behind the back shoulder. The hands come down by the back hip with the bat still cocked, then out in front of him with the arms long, and the bat sweeps through flat out in front of the front pad: the ball is met further down the pitch than any drive meets it (SLOG_MEETS_AT), a long way from his body. Then it keeps going round: at the bowler at chest height, up and out over the leg side with his hands out that way, upright beside his head, and only then over the top and down behind it, his hands high over his head and his chest turned square to the leg side, still on the knee. Into the ball the swing turns the face, never his wrists; after it the forearms roll it over as the bat climbs, so behind his head the flat face is up to the sky and the curved back against him. He holds it there a good while, the sole of his back shoe to the keeper, then goes home unhurried. Perfect timing is six over midwicket.
Once an over the spinner drops one short: an off break or a leg break, never the arm ball, pitched well short of where he lands the rest and sitting up at the waist. It is too short to sweep, so the leg-side swipe at it (or A) is the pull, and so is the leg-side swipe at the arm ball when it is timed good or better; timed worse, the arm ball is still flicked off the pads, and the full ball is swept or flicked as before. No meter. It is built from a net session filmed from the bowler's end: the back foot steps back and across towards off stump and the ball is met on the front foot where it stood, with the bat of the second pull, the arms long and the flat face to the bowler; then the bat goes round flat to leg and up into a wrap behind his head, the curved back of the blade on the front shoulder, and the front foot goes round with it, lifted and carried round to land behind the back foot about 45cm in front of the stumps. He stands tall throughout and turns only as far as square to the leg side, and the bat does not turn in his hands from the ball until the elbows fold into the wrap. Middled it is six over midwicket, a shade under four, held back one or two, and poor goes off the top edge, caught three times in four. In a Test it is paid off the mode's own ladder. The same short ball is cut, punched through cover or defended off the back foot from the other swipes; driven straight or to long-on it is four at the most. ?spinpull=1 bowls the spinner every over with every ball but the arm ball dropped short, and is practice.
The third pair of special strokes, and the first that go behind the wicket. With the meter full, swipe down and to the left (or press S + A) and he plays the scoop; swipe down and to the right (S + D) and he plays the reverse scoop. Each has its ball: the scoop wants one on middle or leg, so the blade can get under it from in front of the stumps, and the reverse wants width — off stump or outside. Neither is offered at a bouncer, and a yorker, a slower ball or a quick one are all scooped the same way. Playing one at the wrong line is playing at air: a play and miss if it was missing the stumps, and if it was not, Bowled or LBW. Either way the meter is spent — it is a stroke a batter commits to.
Timing names the rest. Perfect is six over the keeper, good is four that clears him and runs away to the rope, and OK is paddled away for ones, twos and threes. Poor timing is a top edge, and with the keeper standing behind, a top edge is a catch. Miss it altogether and there is nothing but the pads between the ball and the stumps. Both are regular-speed strokes: no slow motion.
The scoop is built from a net session filmed from the off side and from behind the stumps. He gets out of the ball's way first: the back foot goes across to the off side and the front foot opens, so he stands square to the bowler on a wide base, knees bent and low, with his body outside the line of a ball on the stumps, and the bat comes down early in front of him. At the ball it is laid back almost flat, pointing at the bowler with the face up — a ramp — the hands low beside the front thigh, and the ball is met at knee height and ridden up off the face over his leg-side shoulder to fine leg. Then the bat lifts toe first without turning in his hands until it stands upright in front of the front shoulder with the hands at his chin, and he holds it there and turns his head to watch the ball go behind him. The reverse is from a second net session, filmed from the bowler's end, and he plays it as a left-hander would. The bat swings out to the leg side outside him and up beside the front shoulder, the hands out in front and his chest open to the bowler, while the front foot strides a long way out to the off and the front leg stretches nearly straight. As the bat comes down past his legs the back knee goes down to the turf, and the ball is met out beside the front knee with the arms long, the blade level and pointing out to the off, the face open to the sky. The bat follows it up and back over the slips to stand upright out in front of him with the face to the keeper, and he holds it there on the knee with the leg stretched out, watching the ball go. Neither turns the bat in his hands on the way into the ball, and after it the face turns only as the forearms take it round, evenly, and rides the handle home.
The cue over the meter names whichever special stroke the ball on its way is for. A ball on the stumps at a bowler's pace is the charge's first and the scoop's second, so it is called as the charge; the scoops are called for the balls only they answer — the yorker, the slower ball, the quick one, the wide one — and both are still there to be played on a chargeable ball if you would rather.
VITE_CHARGE_ONLY=meter (or ?charge=meter) is the playtest for them: the meter full every ball and the ball left to the bowler, so every line comes round.
He walks back to his crease on his feet: each one plants and stays where it was put while the body moves over it, then swings back a stride and plants again. Translating the whole batter instead freezes his feet to him and skates him up the pitch.
VITE_CHARGE_ONLY (or ?charge=1 on a dev server) is a playtest build for it: the meter is full every ball and every ball is one he can walk at.
Any upward drive charges it: straight, long-on or cover. The gesture asked for is "swipe up", and a thumb flick that drifts twenty degrees is still a swipe up — but the swipe sectors are 45 degrees wide, so pinning the charge to the straight drive alone threw it away on a gesture the player had no way of seeing was off. Miss it anyway and the call says which half went wrong — CHARGE MISTIMED or THE CHARGE WANTED A DRIVE — with the meter still charged for the next one.
The length and pace windows exclude every special without naming one: a yorker pitches at your toes, a bouncer over your head, and neither a slower ball nor an express one leaves you time to walk at it.
The game automatically pauses when its tab is hidden or its window loses focus. Resume explicitly to continue. Your personal best is saved locally when browser storage is available. The supplied normal-hit.mp3 plays for ordinary bat contact (including a contacted dot and a block), boundary-hit.mp3 plays for both fours and sixes, bat-edge.mp3 is the thin knick off the face when a cut is edged behind, stumps-rattle.aac is the wicket breaking — played by the scene the moment the ball reaches the stumps, bowled or stumped, in place of the synthesized wicket tone — and sledge.mp3 comes back from the field after three balls the batter has not scored off. The synthesized fallback is an impact rather than a voice, so a sledge without its clip simply stays silent. These clips are bundled locally and decoded after the first Start tap for mobile audio unlocking. Bounce/wicket effects remain synthesized. Mute and pause stop any playing hit clip.
The sound key has three settings, stepped through in turn and remembered between visits: everything on, the game's music off with the shot sounds still on, and everything off. The middle one is for playing over your own music. All of the game's sound, music included, plays through a Web Audio context rather than an audio element: on Android an element that plays is a player the phone pauses every other app for, even muted, and a context is not, so the game plays over a player's own music rather than stopping it. On an iPhone a page with only a context playing mixes with other audio too, and in the middle setting the game also asks Safari to (navigator.audioSession); the price there is that the ring switch silences the game. A browser that cannot decode the AAC tracks falls back to an element for the music. The key shows on the phone cover too, so the music can be turned off before it has started. A small synthesized fallback keeps play functional if audio loading is unavailable.
Survive replaces the confidence meter with an injury meter, and it is its opposite in the way that matters: confidence is earned and spent and earned again, this only ever goes down. Nothing heals it. A blow costs DAMAGE[where] × (kph/140)² out of a hundred — helmet 46, ribs 28, gloves 17, pad 12 — so an express bouncer on the helmet is most of an innings and the same blow off a slower ball is a nuisance. At nought he is carried off, which ends the innings without a wicket falling: the scorecard reads 9 down, not 10, and that difference is the whole of what the meter is for.
Sixty-two live innings said it was not working. One of the sixty-two retired hurt, and the average innings finished having used nineteen of the hundred. The cause was not the damage numbers — it was that nobody met the mechanic. An attacking innings lasted thirteen balls and took a blow once every thirty-five, and thirty-eight per cent of innings faced no bouncer at all, because the short ball was rolled for off the weight table and a roll can simply not come up.
Three things changed, and only after each was measured against the simulator:
The bouncer is placed, not rolled for. One in every over of pace, at a position drawn fresh each over — the idiom the spinner's arm ball already used: never the same ball twice, never absent from the over. The plan is asked before the two owed deliveries, because asked after, a yorker earned by four sixes displaced the bouncer and the over finished without one, which is the exact failure placing it was meant to end. Neither debt is cleared by standing aside, so the yorker simply arrives next ball.
The last two overs belong to the quick bowlers, and get two apiece — the most the laws allow. The spinner is kept out of them. A side nine wickets down with two overs left has stopped trying to bowl him out and started trying to get him off. This shape is worth more than a flat rate because it taxes surviving rather than taxing every ball: a batter chasing the hundred is barely troubled, and a batter blocking for the draw walks into a barrage. Measured, it holds a good player's win rate within two points of where it was while roughly quadrupling what the death overs cost him. A flat rate at the same retirement figure cost fifteen points of win rate.
The damage table went up by about half, ratios untouched. Raised there rather than by lowering HEALTH.full, which is the same arithmetic and would have broken the board: rows carry the meter as a number out of a hundred and the Test ladder ranks on it, so a smaller ceiling would have left every innings played before the change permanently above every innings played after it.
Measured across 20,000 innings a profile, against the live distribution's closest match:
| retired before | retired after | win rate before → after | |
|---|---|---|---|
| competent, chasing | 2.4% | 8.7% | 2.0% → 0.9% |
| expert, chasing | 1.4% | 6.4% | 34.2% → 32.5% |
| expert, blocking | 28.2% | 64.4% | draws 57.9% → 25.0% |
Two things were tried and rejected on the numbers. Injury degrading the timing window — shrinking the 86ms clean band as the meter fills — moved retirement by nothing and cost an eighth of a good player's wins, because worse timing produces more wickets as fast as it produces more blows; it is also invisible, and invisible difficulty reads as bad luck rather than as injury. And raising damage alone bought retirement only by shortening innings, converting bowled-outs into retirements one for one without making the meter any more present.
The meter says its own name and a percentage, and nothing else. It used to relabel itself ONE MORE AND HE IS OFF on turning critical — twenty-two characters where six had been — and visibly grew to hold them, shoving the scoreboard beside it about mid-innings. A gauge that changes size when the news gets bad draws the eye to the movement rather than to the reading. Critical is told in colour instead: the meter's own pulse, and the red edge on the field.
The mode explains it once per device (HUD.hurtNote, remembered in localStorage by private-mode.ts), the first ball a batter is one blow from being carried off, and never again — a lesson rather than a warning. What it says is a trade, not advice, and deliberately so: the obvious counsel is to get behind it and defend, and that is the one thing the numbers say not to do. Blocking is what lets the ball through to the body, and a batter who defends his way out of a critical meter retires hurt about six times more often than one who keeps playing.
A third mode, still being built: three wickets, as many balls as they last — to a hard stop at five hundred — and as many runs as they make. docs/MARATHON.md is the spec and the build order; this is what is in the code so far. On production it is reached by ?mode=marathon and by nothing else until it ships. Everywhere else — a branch's preview, the dev server — it is the hero card at the top of Select Mode, so it is played the way it will be found. The screen is laid out from the design in Figma (node 1-13): the Marathon across the top, the Blast and Test Survival under it, Rivals and Rival Matches as two tiles, and the Leaderboards card across the foot under them. With the Marathon hidden, on production before launch, the screen starts at the two modes (SHOW_MARATHON in src/Game.ts, which reads the Vercel environment the bundle was built for; VITE_SHOW_MARATHON puts the card on production at launch). Nothing is counted toward a career — the career endpoints still turn a Marathon away (src/server/mode.ts). It has boards of its own, though (The Marathon's two ladders, below), and a finished innings that would make one is offered a place on the end card, sent only when the player claims it.
The ball is Survival's. The same ladder resolves it (resolveSurvive), the same meter takes the blows, the same table prices them. What is new is in src/config/marathon.ts and src/game/Marathon.ts:
Three batters, getting worse. The opener's windows are near the Blast's, the No. 3's between, the tailender's Survival's own; their block narrows with them, by less. Each walks out with a full meter and is named on the screen as he takes guard. A batter carried off is not out (143*) and the next comes in; the innings ends with the third, out or carried off.
The bowling climbs in blocks of ten overs, by the innings' over rather than the batter's. Overs 1–5 are Survival's, less its last-two-overs barrage. From 6 the pace bowler is a swing bowler (from 11 at first, until the playtest found the start too gentle — SWING_FROM): inswingers (three in ten), pitched on or outside off and coming back into the batter; outswingers (three in ten), pitched on middle or leg and going away; reverse swing, placed like the bouncer — one in every over he swings it, a second in about half, never more — dead straight to the pitch and then darting two or three stumps' width off it, at 142–156 (141–147 on the gun; REVERSE) — in from on or outside off, or away from middle or off, finishing no wider than outside off (the away one started on middle or leg at first, and a playtest saw only the ones coming in: from behind the batter, the leg-stump line is the one his body covers, so it did its moving out of sight); and the rest straight, on any line. The swing is big and late — the ball holds its line for the first two fifths of the way (Delivery.late) — and the express bowler opens Level 2 with the eleventh over, always — drawn anywhere in the ten at first, a playtester batted thirteen overs and never saw him — and has two more of the thirteenth to the twentieth, never two running. From 21 every ten is four of his, three of spin and three of swing: 172–186 kph, a bouncer every over and a second one over in three, a yorker every over, and a slower ball (112–126) in about one over in two. Where each bowler's other overs fall in a block is drawn off the seed, so a seed replays the innings. DeliveryGenerator reads it all from MARATHON_PLAN.blocks; a plan without blocks — every other mode's — draws exactly the balls it drew before.
The express bowler is a slinger, after a slow-motion reference of the most famous one: he runs in with the ball held at his chest in both hands; at the leap the hands part, the front arm straight up in front of his face and the ball arm dropping away behind, the knee driven high; he gathers side-on with the ball arm hanging low and wide; then slings it — the arm coming through wide and round rather than over the top, the front arm chopped down in front of him to the chest, the body tilted hard away with the bowling shoulder high and the head going down — and in the follow-through his chest spins on round while his hips and feet carry on down the pitch (a twist the figure now has, so a turning trunk no longer crosses the legs), the bowling arm carried across his body and the front arm flung straight out behind him, fingers up, as he runs off across the pitch. It is in , the same parameters as the fast bowler's with different values, on the same run-up and the same clock. The ball leaves at the same moment from within a few centimetres of the same point: he runs in from a wider lane, so an arm swinging in from out there arrives where an arm coming over the top does, and a batter who has learned one release has learned the other. What the sling cannot have is a release at shoulder height — the ball starts from a fixed point over his head — so the arm leans out as far as still reaches it, about thirty-five degrees. Every test of the action runs against both bowlers; the first cut of this nudged the fast bowler's numbers and a playtest could not tell the two apart. The scene puts the action on for his overs only ().
scripts/marathon-sim.ts plays it: a good player makes about 350 to 375 and reaches the twenty-first over three innings in four, a competent one about 120, and each wicket costs about half the runs the last batter made. One express over to a fresh opener carries him off two to fourteen times in a hundred, by player — inside the spec's one in six. scripts/marathon-check.mjs walks it in a browser.
A top fifty, one row per player, best innings only. The ranking is built and tested; the two endpoints behind it are not, so the screen runs against fifty invented innings until they are.
Innings are ordered by runs, then sixes, then fours, then fewest wickets, then fewest dots, then earliest submission. A dot is a ball that scored nothing and was not a wicket, which keeps the last two keys independent of each other.
Strike rate is deliberately not on the ladder. The innings ends at thirty balls or three wickets, so fewer balls faced means the player got out — ranking on strike rate would put whoever threw it away above whoever saw it through. Wickets lost already carries that, the right way round.
All six keys pack into one number, so a store can sort the board natively with no comparator in the query path and the browser computes the identical number to answer "do I qualify" with no network call:
rank = runs<<17 | sixes<<12 | fours<<7 | (3-wickets)<<5 | (30-dots)
score = rank * 2**28 + (MAX_T - secondsSinceLaunch)
Twenty-five bits of rank over twenty-eight of clock is exactly 53, a double's mantissa, with nothing spare. Two tests guard it: one pins the budget at 53, the other proves a real innings packs past 2^32 without wrapping — because JavaScript's shift operators are 32-bit and a << on that second line would hand back a small scrambled number with no error anywhere. LAUNCH_MS is 1 Jan 2026, and changing it reshuffles existing ties; the submission stamp has to come from the store, or a wrong clock decides a tiebreak.
A row reads left to right as one sentence: where they came, who they are, what the innings came to, and how it was made. Where two rows are level on runs, the lower one says what split them — level · fewer 6s, level · lost more, level · later — and lights the figure it turned on where that figure has a column. Colour is not carrying that alone; the note says the same thing in words. Under the fiftieth row is the cut, the only rule on the sheet with a colour and words on it, and below the cut sits the innings just played when it did not make it.
The fifty invented rows are the best fifty of a field of a hundred and ten, because that is what a board is: sample fifty at random and the worst of them is whoever was bowled in the first over, and the cut-off line reads four runs. Every one of them is played out ball by ball rather than written down as figures, so the runs add up out of the balls that produced them and plausible() passes on all fifty. Two ties are planted — one split below runs, one the clock alone split — because both are rare enough in fifty innings to go unseen until they turn up on the real board.
A player is a long random id the browser holds on to, written to localStorage, a long-lived cookie and IndexedDB at once and restored from whichever survived. They fail in different ways and at different times, which is the point: a browser that forgets the id has not lost a row, it has quietly minted a second player who plays under the same name. Each id carries the moment it was minted, so when the copies disagree the older one wins and is written back over the younger. All three are written by the page's script, and Safari — every browser on an iPhone — deletes script-written storage after a week away and caps a script's cookie at seven days. So there is a fourth copy, the return ticket: the game posts its id, name and kit to /api/me, the server hands them straight back as a cookie it sets itself, which Safari keeps for the year it asks for, and when the other three come back empty the ticket puts the player back before the game starts. The endpoint reads and writes nothing in the database. None of this is a security measure — an id in a browser identifies a browser, not a person — and the answer to someone wanting two rows is a delete path for the owner rather than a cleverer cookie.
Survive has a board of its own, at ?mode=survive on both endpoints, and it is a second ladder rather than a second copy of this one. The five-over ladder ranks on runs and reads everything else as a tiebreak, because in that innings runs are the only thing anybody is trying to get. A Test match is not one contest, it is three — and a hundred is not a high score in it, a hundred is a win.
So the ladder is tiered, and each tier is ranked on the figure that tier was actually about:
| Tier | Ranked on | Why |
|---|---|---|
| Won | fewest balls used | A chase is a race. The same hundred off thirty-eight beats it off fifty-two. |
| Drawn | most runs made | He had nothing left to chase, so what he made while surviving is the measure. |
| Lost | most balls faced | How long the last man kept them out is the only thing left to be proud of. |
Runs sit under all three as a tiebreak, so two identical chases are split by what was scored on the way, and under runs sits the meter: two innings level on everything a scorecard holds are split by how much of a battering they took to get there. One ladder over one number would put a man who made eighty and lost above a man who blocked sixty balls and saved the match, which is exactly backwards.
rank = tier<<23 | primary<<14 | runs<<5 | healthLeft/4
score = rank * 2**28 + (MAX_T - secondsSinceLaunch)
(primary = balls saved, runs, or balls faced)
The meter earns that rung in the drawn tier, where the two keys above it are the same figure: a draw is ranked on runs and then tied on runs, so before this every pair who batted out the overs for the same score fell straight through to the clock — and a draw for a modest score is the common innings here, not a rare one. It is also the one tier where damage costs nothing by itself. He survived, so the meter never ended anything, and a man worked over for sixty balls sat level with one who was barely touched. Under a loss it says less, because there the meter has usually already had its say by ending the innings early.
What is ranked is the bar, not the blow count beside it: one on the helmet costs thirty and two on the pad sixteen, so a ladder reading the tally would have said the opposite of what the player watched. It is stored in steps of four points out of a hundred, because twenty-five steps need five bits and a hundred would need seven. Nothing is lost by the rounding — the cheapest blow in the mode still costs several points, so any two innings that took a different battering are at least one step apart.
Twenty-five bits of rank over twenty-eight of clock, which is a double's fifty-three exactly. The meter's five bits took the last of the room this ladder had spare, so it now sits where the other one does: at the line, with nothing left. A test holds it there, because a field added after this cannot be paid for out of the rank — it would have to come out of the clock, and shortening the clock reshuffles ties that are already settled. The clock stays at the bottom regardless: it is the only key the store stamps itself, so it is the one figure a submitted row cannot lie about. The tier is read off the figures rather than carried beside them, because the store has to decide it from a row somebody posted and cannot trust an ending sent along with it. The order matches the game's own endingOf: the hundred is checked first, so a chase finished on the last ball is a win; the wicket next, because out is out whenever it happened — a man bowled on the sixtieth ball is all out and has lost, and he tops the losing tier rather than joining the draws, since no loser kept them out longer. Only the meter is read after the overs: a blow that empties it as the tenth over finishes cut nothing short, so it is still a draw.
A row is a table row and nothing more: place · letter · kit · name · runs · balls · blows, with the three figures at one size, one weight and in the same place every time. It went through a louder draft first — the ranked figure blown up on each row — and that draft was wrong in a way worth recording: there is no one hero number on this board, so blowing up the ranked figure blew up a different figure from tier to tier, and the moving emphasis read as the ranking itself. Three quiet columns say it in one place, and the units are drawn once at the head of the list rather than fifty times down it.
The letter is the whole of what a row says about how the innings ended — W, D, L, R — in the sheet's tier colours, where a league table puts it. Four rather than three, because a loss comes two ways: bowled out, or carried off. That difference is the whole of what the injury bar is for and it used to cost a line of prose under every name; a fourth letter says it in a column that was already there. The colour never carries it alone: the letter says it, and the word behind the letter (Bowled out, Retired hurt) is what a screen reader hears, which is the only voice a row has now that the prose is gone.
What orders a block is said once, above it: Wins · fewest balls used, Draws · most runs, Losses · longest innings. The rule belongs to a whole block rather than to a row — fifty copies of it was the clutter — and it cannot live only in the footer, which is below the scroll and answers a question the reader is asking here. The tiers are contiguous, so three bands cover fifty rows.
That leaves one rung with nowhere to write itself. The meter has no column, so where the battering is what put a row under the one above it, the blows are lit instead of explained — the same thing the five-over sheet does with the figure that settled a tie. Blows are not the meter, since a helmet costs more than a pad, but they are its shadow, and a reader who looks at the lit figure is looking at the right thing. A pair level even on the meter was split by the clock, which no figure on the row can show; the footer says it.
The Test card's claim strip is the five-over card's strip, moved. One element with one form, one picker and one set of listeners, relocated into whichever card is on screen — two copies under two sets of ids would have been two of every bug as well.
The meter also tightened what the store will take. Damage comes from blows and from nothing else, so a bar that moved with no blow behind it is refused; and a man carried off — balls left to bowl, no wicket against him — with health still on the bar is a shape the mode cannot produce. Neither is an anti-cheat measure, any more than the rest of the floor is: the game is a static page and a determined person can post anything that passes. They reject what could not exist.
The sheet carries both ladders as tabs — The Blast and Test Survival, in the dark above it, which was the one piece of that screen carrying nothing. Before them the board you could reach was the board for the innings you were in, and the other one was only reachable by playing the other mode. The sheet always opens on the mode the player came from: the tab is a way out of the board they asked for, not a question about which one they meant. Tabs are drawn only where both ladders exist — a build that plays one mode has one board, and a row of tabs over it would be two names for one thing.
The tabs do not scroll away. The fifty rows scroll inside the sheet rather than moving it, so the tabs and the sheet's own head stay where they are; and on a screen too short to hold the sheet's fixed parts, where the overlay has to scroll for the foot of it to be reachable at all, the tabs are sticky. Making that true meant containing the list's paint: without contain:paint on the scroller its rows still counted towards the overlay's scrollable height, so fifty of them made the backdrop scrollable by fourteen hundred pixels, and a flick anywhere off the list dragged the whole sheet off the top of the screen and left the ground behind it.
Three things follow from the sheet being shared. The innings just played peeks on its own board and on no other, so switching from a Test card shows the Blast's fifty and nothing of yours. A fetch that lands after the player has tabbed away is dropped rather than drawn, or it would put the ladder they just left back under the tab they just chose. And the card's keys at the foot of the sheet — the ones a claim leaves behind — are held across a tab, because a player who has taken a place, looked at the other ladder and come back has not given up their way to play again.
The two boards have separate ranking and row keys and one shared name registry. Separate keys because a chase and a five-over slog are not comparable and one sorted set holding both would rank them against each other. Shared names because a name is a person, not an innings: a player carries theirs from one board to the other, and nobody else can bat under it on the board they have not played yet.
The Test Marathon has a tab of its own on the sheet, drawn only where the mode can be reached (?mode=marathon, or a build with VITE_SHOW_MARATHON), and two ladders behind a toggle, because a Marathon is a team's innings and three men's at once:
| Ladder | Ranked on | Keys |
|---|---|---|
| Team | total runs, then strike rate (fewer balls), then fours and sixes, then the clock | marathon:board, marathon:players |
| Individual | the best of the three batters: runs, then not out above out, then fewer balls, then the clock | marathonone:board, marathonone:players |
team = runs<<18 | (511-balls)<<9 | boundaries · 2**22 + minutes clock (51 bits)
solo = runs<<10 | notOut<<9 | (511-balls) · 2**28 + seconds clock (49 bits)
The team ladder's clock is in minutes, because twenty-nine bits of rank leave it twenty-two; a minute is plenty to split two 300s. A row is one big figure, the runs, on the right with the strike rate quiet under it, and everything else in words under the name: how the innings ended (All out, Retired hurt, Batted out, Declared), its balls and its boundaries on the team ladder; which of the three he was, left-handed where he was, and his balls on the individual one, with a star on the runs for not out. The first cut had Test Survival's four quiet columns and a letter for the ending, and a playtest found it a wall of numbers that only the footnote explained.
One claim writes both. The card offers the team ladder's place where the innings takes one there and the individual's where only that one does; POST /api/score?mode=marathon takes the whole innings — the total and each batter's figures — and records each ladder's row from it, so a player never claims twice for one innings. The store checks the shape it can check: balls and runs that add up batter by batter, a meter that only moved with a blow, one left-hander at most, nobody after a man still batting, and an ending the figures bear out (marathonPlausible in src/game/marathon-board.ts). It shares the name registry and the rate limit with the other two boards, through the same admit().
?demo=1 fills both ladders with fifty invented Test innings alongside the other boards', saving nothing. And on every preview and the dev server, without asking, both ladders and the Rivals ranking carry made-up rows among the real ones — every other demo row, so the filler leaves room under it — because nobody has played the new boards on a fresh preview; real rows take their true places and are never pushed off, and production never sees it (PREVIEW_FILL).
There are no invented rows for the Test board. The fifty fixtures stand in for the other one while there is no database; a ladder of people who never batted is worse here than an empty screen.
An innings that earns a place puts a strip on the card between the figures and the keys: a green banner saying where it landed, the two rows it landed between, and one key to do something about it. The rows either side are the point — fifth has 102 is what makes 101 mean something, and a place on its own does not. Each peek row carries the same three figures the board does (runs, sixes, fours), so it is a true preview of the screen the key opens rather than a different thing that resembles it. Other players are bars rather than names: the peek is about where the player sits, and the full board one tap away has every name on it.
Tapping the key morphs the strip rather than growing the card — the peek and the key step aside, the picker and the name field take exactly their place, and nothing above moves. Submitting opens the full board, with Play again, Share and Insta Story pinned to the foot of it: fifty rows is a long scroll, and a player who has just been put on the board should not have to reach the bottom of it to leave.
The register key is filled and green, continuing its banner, rather than a second white-on-orange key competing with Play again. Play again is the card's primary and stays the card's primary.
Every state is in /tools/card-lab.html, which is also where the peek's own bug showed up: a hardcoded place put the player between two rows that did not bracket them, because 101 runs is not sixth on a board topped at 107. The lab asks the board where the innings goes now.
Whether an innings qualifies is answered from the board already on screen, so nothing waits on the network at the one moment a wait would be felt. A board that has not loaded is not a reason to say no — the store ranks it properly either way. The place shown on the key is the browser's guess; the line after claiming is the store's answer.
A returning player is not asked twice: the name and kit are kept in localStorage (src/game/player.ts, deliberately not in the three-store identity, because losing a preference costs one tap and losing an id costs a row), and the key submits straight away.
Game shortcuts are suppressed while a text field has focus. Without that, typing "Rohit" restarts the innings on the R — the shortcut listener is on window.
A private window is handed a player id it will forget the moment it closes, so an innings claimed from one takes a row on the board that nobody can ever come back to and improve. The game therefore opens such a window on the poster at public/younaughtyyou.webp rather than on the cover.
No browser will say whether it is private. It is deliberately undetectable: a page that could tell would be a page that could treat those players differently, which is the thing private browsing exists to stop. So src/game/private-mode.ts guesses, from the one side effect left to read. A private window is given a small temporary quota where an ordinary one is given a share of the disk, and navigator.storage.estimate() reports the gap: tens of gigabytes against a fraction of one. Firefox adds a second tell — no service workers in a private window — and that is the lot. Nothing asks whether storage works, because in a private window it works fine; it is only forgotten afterwards.
The quota tell has one predictable way of being wrong, and it was wrong the first time it was run: a machine with very little disk left reports a private window's figure while being nothing of the kind. So the line is drawn low — twice the JS heap ceiling, held between one and two gigabytes — and a browser that has been here on an earlier day is cleared whatever its quota says. That mark (hitman-seen, a date in localStorage) is the one thing a private window can never produce, because it does not last the night.
The poster is the screen. It already says the thing in its own type, so the page adds no words of its own. It is fitted rather than filled, and held to the top of the window: the line runs along the top of the poster and the two emoji sit right out at its edges, so a phone — a narrower shape than the poster — would cut the joke off at both ends if the picture were cropped to fit. The room left over gathers underneath in the poster's own blue, deepened, which is where the keys go: the cover's own pair, the white key on its orange base and the navy plate under it, because the cover already stands that pair over artwork and this is the same game. On a screen with no room to spare they sit over the foot of the poster instead. The picture's last tenth is faded out into that blue: a poster that simply stopped ruled a line across the screen, and where the line fell depended on the phone.
What the keys do is not a lock, because the check is a guess:
?private=1 forces it on and ?private=0 forces it off, which is how the screen is worked on without two browsers open.public/ rather than bundled, so a missing file costs a headline set in the game's own type rather than a build. The words the poster carries are in the page too, hidden, and take its place if it never loads. The file name is the whole contract — src/ui/PrivateNotice.ts is the only place it is asked for, and any format a browser reads will do.The poster is portrait, near enough to nine by sixteen; this one is 941 × 1672, which arrived as a 1.8 MB PNG and was written to WebP at quality 82 — the setting the kit pictures use — for 122 KB with nothing visible lost. A replacement wants the same shape, its own copy of the line along the top, and a quiet lower fifth, which is where the keys land on a screen with no room to spare.
Two events are counted: private-window when the notice goes up, and private-play-anyway when somebody bats on regardless. The second is the one worth watching — it is also how a check that has started guessing wrong would show up, as a ratio that climbs after a browser release.
Five pictures, at public/avatars/1.webp … 5.webp, 96 px square. They are served as files rather than bundled, so the game builds and runs whether or not they are there. Until they arrive — and if one ever fails to load — each kit is the coloured disc with the player's initial that the board has always drawn, and the picture simply takes itself off the page. The board never shows a broken image, only an earlier version of itself. Kit colours are in src/config/board.ts, taken off the kits in the pictures and lightened until navy ink on them clears 6:1.
npm run dev serves the endpoints itself, over an in-memory board. No Vercel CLI, no credentials, no database — the whole feature works on localhost: play an innings, claim a place, watch a name be refused because somebody already has it. The rules are the real ones (vite.config.ts mounts the same readBoard and submitScore that api/ does); only where the rows are kept stands in. The board starts empty and is forgotten when the server stops.
To run against the real database instead, use Vercel's own CLI, which pulls the credentials down rather than having you copy them:
npm i -g vercel && vercel link
vercel env pull .env.development.local
vercel dev
npm run build runs tsc --noEmit, then node scripts/function-check.mjs, then vite build — because the first two ask questions the third cannot.
The middle one exists for a bug that shipped: package.json declares "type": "module", so Vercel loads each function as ESM, and ESM requires an explicit extension on every relative import. Extensionless specifiers resolve perfectly in Vite, in Vitest and under moduleResolution: "Bundler", and crash in Node. The deployment went green, the build was clean, and every request died with FUNCTION_INVOCATION_FAILED before a line of the handler ran — so there was nothing in the logs the handler wrote, because the handler never existed.
Hence the .js on relative imports in api/ and in everything those files reach. It is not a mistake and must not be tidied away. TypeScript maps .js back to the .ts beside it, so it costs the rest of the project nothing.
function-check.mjs transpiles api/ to real ESM with no bundler in the way and imports each handler in Node, asserting a function comes back. Nothing is called, so it needs no credentials and makes no requests — it asks only whether the module graph resolves. Reintroduce a missing extension and it fails; that was verified rather than assumed.
api/board.ts and api/score.ts import no Vercel package and have no tsconfig of their own, deliberately. Both were tried and both made things worse:
@vercel/node is the builder as well as the types. A copy pinned here is one more thing that can disagree with the platform's own, so the two interfaces the endpoints actually touch are declared in src/server/http.ts instead. They are structural, so anything that passes a request and response of that shape works.api/tsconfig.json forcing CommonJS is wrong in a package declaring "type": "module" — it emits require into a tree Node will read as ESM.What is real is that a discriminated union keyed on ok: true | false only narrows under strictNullChecks, and the compiler that builds the functions is not the one configured here. api/score.ts reading outcome.status type-checked locally and failed on deploy three times running. Hence refused() in board-store.ts is a real type predicate rather than an if (!outcome.ok) that leans on inference — the code compiles whatever settings it meets. Compiling the functions with --strict off is how that was reproduced, and is worth doing after any change to them.
npm run check:board # the dev server
node scripts/board-check.mjs https://…vercel.app # a real deployment
Unit tests run the rules against an in-memory store, which catches logic and cannot catch a missing credential, a function in the wrong region, or an api/ directory Vercel never turned into functions. This is the check that does, and it is the first thing to run against any new deployment. It reads the board, submits a real innings, proves the row survives a fresh read, proves a worse innings does not displace it, and proves each refusal — a taken name, an impossible innings, something that is not a player, a kit that does not exist — then checks the preflight and the edge-cache header. It then does the same for the Test ladder and proves the two are really separated: an innings on one is nowhere near the other, a slower chase does not improve on a faster one, and a name held on either board is refused on the other. Last it posts a Marathon innings, finds it on both of that mode's ladders and on neither of the others, has an impossible one refused, and makes sure the career endpoint still turns the mode away. One wrong key prefix is invisible to the unit tests, which run two stores because the test made two.
It writes. Every run leaves a row under a throwaway id and a name nobody would want, and a name it claims is never released. Point it at a preview rather than at the board people are playing for.
The integration injects one set of credentials into Production, Preview and Development alike, so without care a branch under test writes to the board people are playing for. Every environment but production therefore prefixes its keys (preview:board, development:board), driven by VERCEL_ENV, which Vercel sets on its own. Anywhere it is unset is treated as development rather than as production, so the accident is a wasted key rather than a polluted board.
GET /api/board answers with the fifty and the packed score the fiftieth is holding — the same answer for everybody, deliberately, so it can sit in Vercel's edge cache for ten seconds. Where a player stands is worked out in their own browser from the packed score, which is the same number computed by the same function, so the response carries nothing personal. A hundred people opening the board in the same ten seconds cost one pair of Redis commands rather than a hundred, which is what keeps a half-million-command month out of reach. It reads with the read-only token: an endpoint that cannot write is one fewer thing to get wrong.
The mode is read in one place, src/server/mode.ts, for these endpoints, the career endpoints and the dev server alike. ?mode=marathon picks the Marathon's two ladders on these two endpoints and is still turned away with a 400 by the career endpoints, until the Marathon has a career (docs/MARATHON.md). Before that reader, anything that was not survive was taken for the Blast, and a Marathon innings would have gone onto the Blast's board.
POST /api/score checks the rate limit first (so a script pays nothing to be turned away), then the shape, then plausible(), then the name — and stamps the submission itself. The clock is read on the server and nowhere else, or a laptop running fast would win every tiebreak it entered.
The storage shape is chosen to spend as few commands as possible rather than for how it would look in a relational schema:
| Key | Type | Holds |
|---|---|---|
board | sorted set | player id → packed score. ZADD GT CH, so a worse innings cannot displace a better one and the reply says whether anything moved. |
players | hash | player id → the row as JSON. One HMGET returns all fifty. |
names | hash | folded name → player id. HSETNX, so two people claiming one name in the same second cannot both be told it is free. |
survive:board, survive:players | as above | The Test ladder, on its own keys. ?mode=survive on either endpoint picks them. |
marathon:…, marathonone:… | as above | The Marathon's team and individual ladders. ?mode=marathon reads both and one submission writes both. |
Two things are deliberately not scoped by mode. names is one registry across the whole game, for the reason given above. The rate limit is the other: it counts submissions from an address, and an address that has posted sixty innings has posted sixty whichever mode they were played in.
Reading the whole board is two commands, not fifty. The row is written only when the score actually improved — the ranking and the figures beside it have to describe the same innings, or the board shows a player's best score next to their latest innings' boundaries.
A name is compared in a folded form — case, spacing, punctuation and accents thrown away — so "Big Show", "bigshow" and "Bíg Shów" are one name and the second person to want it is told so. A name once held is never released, including when the holder has a bad day: letting one go free would let the next person pick up somebody else's reputation.
Rate limiting is by address and the address is never used as identity, because a school, an office and everyone behind CGNAT all arrive as one. The limit is deliberately generous for the same reason.
plausible() is not an anti-cheat measure and should not be mistaken for one. The game is a static page, so a determined person can post any innings that passes it. It turns down the ones that could not have happened, which is the floor. Replay verification is the ceiling, and nothing built here is thrown away by it.
Every named row on a board is a player you can open and chase. The design is
Figma section 14, "Player profiles" (node 333:11508): four lanes, A to D.
A named row, a podium step or a folded chip, on any board, opens that
player's profile over the board (src/ui/PlayerSheet.ts, opened from
openPlayer in the HUD). Stats only, and the face is the hero: no card round
it, the top half of the sheet in the metal of their rung — matte navy, brushed
bronze, graphite with the Star card's star, black and gold with the Hitman
rings, the Dukes ball's red for a Test Marathon EMERGING — under a soft
spotlight in the rung's own colour. Under the face: the rung and what is left
to the next, their bio, three figures at a glance (the career's measure, their
place on the mode's board, their best rank), a switch between the modes, the
mode's six with the figure a chase aims at lit, and CHASE. A Test Marathon
offers a second target under its six: their best batter. A mode they have
never batted says so and offers nothing to chase.
What the row already knew — the name, the kit, the place, the best — is drawn at once, so the sheet is never empty while the rest comes, and CHASE works off the row even when the store cannot be reached. The face flies up from its row into the hero and back down on the way out, the tiles arrive thirty milliseconds apart, a mode slides in from the side it lies on, and the ground crossfades; under reduced motion it all fades in 150ms. Your own row opens My profile. A guest's innings has no profile and opens nothing.
CHASE starts that mode with their best as the target, a normal innings in
every way. Before the first ball a card says what is to beat, counting up to
it, with both faces and what passing them is worth; nothing is bowled until
LET'S BAT, and BACK lets the chase go. In play the scoreboard is left alone: a
chip under it says what is wanted, off how many in the Blast. Going past the
target is a moment between balls, held on the celebration clock as a
milestone is — PASSED and their name, the two places swapping, a knock in the
hand. The end card says how it went where the place card stood: beaten by how
many and where it took you, with who is next; or how far short on a bar, with
CHASE 187 AGAIN in place of PLAY AGAIN. A chase won is over once its card has
said so. What it says is src/game/chase.ts; the screens are
src/ui/ChaseUi.ts.
The chase rides the ordinary score post (chase, and chaseLadder: 'solo'
for the Marathon's best batter). The store judges it again, on runs — beat
Kabir's 187 with 188 — against the best they hold now, and puts it on their
profile either way.
My Stats is My profile: the same free face on the ground of the rung in front, then what the profile has collected, the career cards as the mode switch (each the rung's badge and the career figure), Road to Hitman, the six, and the key. What it collected leads as one card: the newest thing taken from you and not won back ("Kabir beat your 187 · You're #5 now"), or a quiet line of who looked and chased when nothing was taken. Tapped, the Your profile sheet lists the last ten from the last thirty days, by day, with WIN IT BACK on anything taken — a chase of the runs that took it — and "You won it back" once you have, worked out from where you both stand now rather than stored. Who looked is only ever a count, a viewer once a day, never named.
The end card says who an innings went past and who stands next, from the
store's answer to the post (around), and the cover leads with a Blast best
taken in the last day.
The edit from My profile has a bio under the name, never on the gate: eighty
characters as they look, the count turning to what is left from seventy, cut
past eighty, three lines to start from while it is empty. Many players are
young, so a bio must never be a way to reach somebody: no links, emails,
@handles or phone numbers, and the names' word filter a word at a time and
over spelled-out letters (src/server/bio-rules.ts, run in the browser half a
second after the typing stops and again by the store). It is saved only where
it changed.
One endpoint, /api/profile (src/server/profile-endpoint.ts, shared by
api/profile.ts and the dev server): GET ?handle=&viewer= is somebody's
profile, GET ?player= your own activity, POST { playerId, name, bio } your
bio, held to the name the player holds. A score post's answer gains around
(who it went past, two named and a count, and who is next) and chase.
Profiles are opened by handle, never id: the store keeps handle → id, written
on every post, counted innings and name claim, and filled for players named
before the profiles by scripts/handle-backfill.ts (opt-in, --write; see
docs/LAUNCH.md).
| Key | Type | Holds |
|---|---|---|
handles | hash | handle → player id. |
bios | hash | player id → the bio and when. |
seen:<id>:<day> | set | the handles that looked on one of India's days; expires after eight. A count, never read back as names. |
act:<id> | list | the newest ten events (beat, short, passed), trimmed and expired at thirty days as they are pushed. |
prate: | counter | profile reads and bio saves from one address, apart from the boards'. |
The board above answers one question — what is the best innings anybody has played — and answers it by keeping exactly one row per player and only writing when it improves. Seven more ladders answer a different one: what has a player done over every innings. A maximum and a sum are not the same arithmetic, so none of that shape could be reused, and this is a second record rather than a second column.
| The Blast | ranked on | tie split on |
|---|---|---|
| Runs | career runs | fewer innings to get there |
| Boundaries | sixes | fours |
| Highest | biggest single innings | best unbeaten innings |
| Test Survival | ranked on | tie split on |
|---|---|---|
| Balls | balls faced, all innings | fewer innings |
| Blows | blows taken | balls faced |
| Runs | career runs | balls faced |
| Boundaries | sixes | fours |
Highest is the innings total, wickets and all. 132 without losing one and 140 for one are 132 and 140; the bigger score is the higher score. Best unbeaten is the separate figure — the biggest total made without a wicket falling at all — which is why 140/1 wins the first and 132* wins the second.
Every career board packs the same way, and deliberately more plainly than the two innings ladders do:
score = (primary * 2**14 + secondary) * 2**14 + (MAX_DAYS - daysSinceLaunch)
Twenty bits of total over fourteen of tiebreak over fourteen of clock is 48, comfortably inside a double's 53, and the room left over is the point: a career board added later is packed with the same function rather than by finding two spare bits. The clock is in days rather than seconds because a career total that is level today was level yesterday too — and dropping the stamp from twenty-eight bits to fourteen is exactly what pays for a twenty-bit total. LAUNCH_MS is shared with the innings boards so the two stamps mean one thing.
POST /api/innings goes out after every innings anybody finishes, with nothing asked of the player and nothing waiting on the answer. That is not a convenience: "most runs, all time" assembled only out of the innings somebody chose to register would be a total of their good days. POST /api/score stays what it always was — the claim, which wants a name and refuses anything that would not improve the player's row.
A player who has never registered is counted from their first innings and kept off the boards until they claim a name, because the name registry is what makes a name one person's. The moment they claim one, their whole career appears on the ladder rather than only what came after it.
An innings board is self-limiting: the worst a forged submission can do is claim one innings of 180, and the mode's own ceiling caps it. A career adds up every submission, so the same request sent a thousand times is a thousand times the damage. Three rules answer that, and all three are free because the record is being read and written anyway:
None of them is an anti-cheat measure and none should be mistaken for one — the game is a static page. They bound what a script can accumulate to roughly what a person could play, which is the floor.
MY CAREER CARD, under the sheet rather than among the pills above it. Every pill re-sorts what is already on screen; this one is a destination, and it leaves — so it is shaped like a key, not like a seventh ladder.
A row of numbers is inert on its own: nine figures and no answer to the only question anybody actually asks about them, which is whether they are any good. The badge answers it in one word, and that word is what makes the card worth sending — 900 runs means nothing to a friend who has never played this, and STAR means something immediately.
| Tier | Blast (runs) | Test (balls) | Made of | |
|---|---|---|---|---|
| DEBUTANT | 0 | 0 | Navy | First time out there. |
| EMERGING PLAYER | 350 | 100 | Bronze | Making a name out there. |
| STAR | 3,600 | 1,000 | Black and silver | People turn up to watch. |
| HITMAN | 15,000 | 4,250 | Black and gold | The one the game is named for. |
The thresholds are measured, not guessed. Five days of GoatCounter puts a Blast innings at about 54 runs and a Test innings at about 17 balls, and a player at about 2.5 innings on a day they play at all — so roughly 140 runs a day in the Blast against 39 balls a day in the Test match. On those rates, EMERGING PLAYER is six or seven innings (the second or third sitting), STAR is a few committed weeks, and HITMAN is upwards of a month of heavy play and out of reach of everybody else. The top rung is the one figure that is chosen rather than measured: the rates put it anywhere across a wide band, so where in that band it lands says how far away the last rung should feel rather than what the data found.
One caveat worth recording, because it will matter the next time these are revisited: GoatCounter's export counts unique visitor-days, not raw events. That is provable from the export itself — mutually exclusive families disagree with their parent in a way only deduplication explains (the Blast's six score bands sum to 304 against 136 innings-end, and the five duration bands independently sum to 293). So innings-per-day is modelled from how many distinct bands a player hits, not counted. The band shares are reliable; the volumes are an estimate.
The two modes carry different numbers, on purpose. They were shared to begin with, on the assumption that sixty balls and sixty-odd runs an innings put the two measures in the same range. The data says otherwise: Test innings end early where Blast innings mostly run their course, and the gap is about three and a half to one. Shared thresholds would have had Test players climbing three and a half times slower for identical time at the crease, so the two ladders are set to cost the same effort instead — about six or seven innings to the second rung in either mode.
Four rungs, and deliberately not more. A longer ladder flatters the arithmetic and nobody else: each step means less, and a player two thirds of the way up still cannot say in one syllable what they are. Adding a rung later is one entry in TIERS — everything the card paints reads whatever is in that list, and nothing else needs touching.
It is read off the figure the mode already leads on — career runs in the Blast, balls faced in the Test match — rather than off a score of its own. That keeps one number in charge of the headline tile, the badge under it and the progress bar under that, so a card cannot say a player is climbing on one line and stalled on the next. It also means the Test ladder rewards the thing that mode is about: a blocker with two thousand balls and forty runs is a STAR, where a tier read off runs would have called him a debutant.
The board existed before careers did, so everybody already on it would otherwise open their first card and find themselves a DEBUTANT beside somebody who arrived that morning. Being early should count. So the seed hands the top five STAR and the next eleven EMERGING PLAYER — the shape of a side, and meant to be.
What it grants is the tier, not the runs. Their figures stay the one real innings the seed filed: a player who scored 148 has a card that says 148, in silver, with the STAR badge on it. This card is built to be sent to other people, and one claiming three thousand runs for somebody who scored a hundred and forty-eight is a claim about them that is not true — which is a different thing from a number being approximate.
Three rules fall out of that and are held by tests:
Founding place · 3rd on the board — in place of the usual line. "People turn up to watch" under a handed badge would be the one sentence on the card making something up.It is written once, by the seed, and the counting path only ever carries it forward — so an innings can raise a player above it and nothing can take it away.
Each rung carries a Theme: the ground it is painted on, the shadow it stands on, the mat behind it, its ink, its quiet, its accent, its sheen, its tiles, and how strongly it blooms. Nothing else on that card decides a colour, so a new tier is a palette rather than an edit to the painter.
On the two black tiers, the black is the card and the metal is the accent. The ground is a near-black — neutral for silver, the faintest warmth for gold, because gold on gold has nowhere to shine from — and the metal is spent on the badge, the ring round the kit, the bar and the two hairlines, and nowhere else. bloom is the figure that decides whether that holds: at the strength the navy card wants, the same accent wash lifts the middle of a near-black ground into grey or brown haze, and the tier stops being black and silver and becomes silver-grey with silver on it. So it is per theme — 0.15 on navy, 0.05 to 0.08 on the metals, enough that the ground is not flat and not a pixel more. Bronze sits darker than it first did for the same reason in reverse: on a milky brown ground, cream figures stop being cream and start being beige.
That is the difference between a screen somebody reads and a thing somebody collects. A card used to be one navy object with a coloured word on it, which made every player's card the same card; a bronze one and a black-and-gold one are recognisable across a room at thumbnail size, where a badge is not. The first rung is deliberately the plainest and the only one that is not a metal — a first-innings card that arrived in gold would leave the top of the ladder nothing to be.
The three metal tiers get two things the navy one does not: a brushed gradient on the badge, the bar and the footer's call — light at the top edge, the colour through the middle, dark underneath, which is what separates gold-coloured type from type that looks like gold — and a second hairline inset inside the first, which is the oldest trick there is for making a printed thing look like it was worth printing.
Under the hero row is a bar showing how far into the rung a player is, measured across the gap between two rungs rather than from nought — the difference between a bar that creeps for a week and one that visibly moves every time somebody plays. The rung above is named at the end of it, in its own colour, which is the only place that colour appears on the card: a preview of what the thing is about to be made of.
It shows innings, runs, highest, best unbeaten, sixes, fours and balls in the Blast; balls faced, innings survived, runs, blows, boundaries and the three results in the Test match, where won, drawn and lost carry the Test board's own tier colours. Survived is wins and draws added together — the innings he came through — and is derived rather than counted, because a third counter beside the two is a third counter to keep in step.
The card on screen is a painted picture, not a DOM copy of one. game/StatsCard.ts draws it to a canvas the way ShareCard.ts draws the innings card, and the overlay shows that very image. That is the one arrangement which guarantees the card somebody shares is the card they were looking at when they decided to — a second rendering would drift from the first the day one of them gained a figure. The cost is that a canvas is a rectangle to a screen reader, which is what the alt text and the text fallback are for.
The Test Marathon's card is the second on the rail wherever the mode can be played, between the Blast's and Test Survival's, and the one card not on its tier's ground: a dark British Racing Green with crimson rising from the foot and in the type — the stamp and the tile labels, in a cast light enough to read — green edges, and the tier's metal left to the badge, the ring and the bar, so a STAR reads green, red and silver and a HITMAN green, red and gold (cardTheme in src/game/tier.ts). On it are the highest total and the best individual score as its two big figures, then career runs, runs per innings — not an average, which would divide by dismissals a declared or retired batter never had — fifties, hundreds and doubles by any of the three batters, and the longest innings in balls. Its tier is in career runs at twice the Blast's rungs — EMERGING PLAYER at 750, STAR at 7,500, HITMAN at 30,000 — since a run costs the Blast's time at the crease but a Marathon innings is twenty minutes, so a day of it banks several times the runs. A finished Marathon is counted from the very figures its boards are sent (MARATHON_CAREER), and it is ranked on one career ladder of its own, Runs: every run the three batters have made, added up, with the innings it took and its fours and sixes on the row, under a third tab beside Team and Individual. Level totals go to whoever took fewer innings, and only a named player is ranked — a career counted before the name was claimed joins the ladder with the claim or the next innings.
There are two ways in, because there are two moments somebody wants it. My Stats is the third tab on the board, beside the two games — it drops the ladder pills while it is lit, since a career is one thing and there is nothing under it to re-sort, and it shows the career for whichever game the player was last looking at rather than whichever is first in the row. And the Career Stats widget under the innings card opens it as a page of its own, with a way back to the card rather than a modal over it, because at that moment the player has travelled somewhere rather than switched tabs. The widget's NEW flag comes off the first time it is opened, once and for good: a flag that still says new on the fortieth innings is one nobody reads, and it teaches the player that the flags on that screen mean nothing.
The innings card's own share is now one key, not two. It was a WhatsApp anchor beside an Instagram button, which is two keys asking the same question and getting the same answer — on a phone both ended in the system share sheet, and choosing between them before seeing it is a decision nobody has the information to make. One key opens the sheet with the card in it and lets the phone offer everywhere it can go. Where a browser will not hand a file to another app at all, WhatsApp's own link still opens with the caption, so the playable link travels even when the picture cannot.
The career card keeps both of its keys, because there the two really are different pictures: a card sized for a chat thread, and a 9:16 frame with the address painted on.
Two keys sit under it, stacked and equal, each wearing the colour of where it is going:
wa.me link still opens WhatsApp with that text, so the link survives even when the picture cannot.The figures are mirrored into localStorage, so the sheet opens with the player's own numbers rather than a spinner and the card is painted from those; the store's answer replaces them a moment later, and only repaints where it actually disagreed. The foot of the card says out loud what the whole thing is for: these figures belong to this browser. Clear its storage or play somewhere else and a second career starts from nought. That is the honest version of the nudge — the card does not merge devices, it makes keeping to one worth something.
GET /api/career answers with every ladder of a mode at once, because the sheet draws them as tabs over one screen and a round trip per tab would make switching feel broken. One ZRANGE per board and a single HMGET for the union of everybody who appears on any of them — four boards cost five commands, not eight, and a player standing on three of them is fetched once. It carries nothing personal, so it sits in the edge cache for five minutes; an all-time total five minutes old is not wrong.
GET /api/career?player=… is the same endpoint answering with one player's own figures, never cached by anyone.
| Key | Type | Holds |
|---|---|---|
blast:careers | hash | player id → the whole career as JSON, with the name, the last innings' id and the day's count. Two commands to count an innings. |
blast:career:runs, …:boundaries, …:highest | sorted sets | player id → packed score, one per ladder. ZADD GT, because a career total only ever rises and a lower score arriving is a request that overtook a newer one. |
survivecareer:careers, survivecareer:career:* | as above | The Test career, on its own keys. |
names | hash | shared with the innings boards, and only ever read here. Claiming is a write with a rule attached, and that rule lives in one place. |
The careers start empty, and seven empty boards on the morning a feature ships is the worst possible time for them to be empty. scripts/career-seed.ts walks the existing innings boards and files everybody already on one as a career of exactly one innings — the one that put them there, which is a real innings, stamped with the day it was actually played rather than the day of the migration.
npx vite-node scripts/career-seed.ts # say what it would do
VERCEL_ENV=production npx vite-node scripts/career-seed.ts --write
What it seeds is true and incomplete, which is the trade and worth saying out loud: a player with four hundred innings behind them starts on the runs of their best. Every innings after it is counted in full, so the boards converge on the truth rather than away from it, and nothing is ever invented — a Test row's sixes and fours start at nought, because the Test board never carried them and there is no honest way to guess. It is a one-shot: a record that already exists is left exactly as it is, so running it twice cannot count anybody's best innings twice.
A questionnaire, in the game and on a link of its own, answered entirely by tapping. hitman-cricket.vercel.app/feedback is the link to hand round; inside the game it is a quiet line on the cover, the pause card and the innings-end card.
Every question is a tap and there is exactly one box anybody types in, at the end, optional. That rule decides most of the design: a form that asks somebody to type is a form they fill in on a laptop later, which is to say never, and the people worth hearing from are on a phone with one thumb free and an innings they have just lost. Ten taps, forty seconds.
Nothing is asked that the page already knows. The mode played, the runs, the balls faced, the personal best, how many separate days this browser has come back and whether it is a thumb or a keyboard all ride along as context rather than costing a screen — a question whose answer is already on the machine only buys a worse answer. What goes with the answers is said on the form itself, above the send key, rather than in a policy nobody opens: sent with your device type and this innings' figures. No name, no email.
| Asked | Answers |
|---|---|
| Have you played it yet? | Played a full innings · Opened it, didn't finish · Not yet. Link only — in the game the answer is that they are mid-innings. |
| Where did you come from? | A friend sent it · Social post · Search · I built it · Somewhere else. Link only. |
| Honestly, how much fun was that? | Not really · It's okay · Good fun · Loved it · Couldn't stop |
| How hard did it feel? | Too easy · About right · Tough but fair · Too hard · Couldn't middle a thing |
| Did the keys play the shot you meant? | Every time · Mostly · About half · Rarely. Asked about swipes on a phone: asking a phone player about the keys is asking about a game they did not play. |
| What let you down most? | Timing the swing · Reading the line · Seeing the pitch · Loading or stutter · Sound · Nothing really |
| Which shot felt best to play? | Straight drive · Cover drive · Leg side · Square cut · The block · None of them |
| Would you come back tomorrow? | Definitely · Maybe · Probably not |
| Would you send it to a friend? | Already did · Yes · Maybe · No |
| What would you want next? | More overs · Bowl as well as bat · Head-to-head with a friend · More shots · A season or career · Better looks · Keep it simple. Two picks, and the only question that takes more than one. |
| What would get you to have a go? | Asked instead of all of the above where the gate says they have never played. |
| Anything you'd change? | The one box. 280 characters, optional. |
The one multi-answer question is multi-answer on purpose: what somebody wants next is a shortlist rather than a winner, and forcing it to one turns every answer into whatever was nearest the thumb. At the limit, a third tap pushes the oldest pick out rather than doing nothing — a key that refuses to go down reads as broken.
The branch is the whole reason the link has a gate on it. Somebody who followed it from a friend and has never faced a ball cannot say which shot felt best, and asking them anyway fills the answers with noise; they get one question about what would get them to play, the box, and a key into the game.
One question at a time, filling the card, with the answers as keys at a thumb's size. A single-answer question records the tap and moves on by itself, so ten questions are ten taps and there is no Next key to find between them. The alternative — every question on one scrolling sheet — is a wall of them, a thumb on the scrollbar and a decision about whether to start at all. The pips along the top are what promise it will be over shortly; they are marks rather than "4 of 11", which is a quantity to dread.
It is built out of the innings-end card's own parts: the same navy slab, the same lit top rule and cut ledge, the same two radii. A form in a different material would read as a survey bolted onto a game.
Nothing is sent until the last screen, and a form left half-done is left rather than sent: an opinion somebody stopped giving halfway through is not an opinion they gave. What was tapped on the way is held in sessionStorage, so a mis-hit on the back gesture costs nobody their answers, and a send that fails says so and keeps them.
Once it has been answered, every link into it goes for good. A link that keeps asking after it has been answered is not an invitation.
The questionnaire waits to be opened. The stars go looking: a panel of five in the game's Test-match voice — the deep cricket green the Marathon's stories stand on, mown in stripes, QUICK ONE after a red ball, the thing asked about on the stories' cream bar with its red rule, gold stars like the logo's, and the PLAY key's white and orange for the way on — rising under the innings-end card 1.6 seconds after it goes up. No blue: the card behind it is navy, and a second navy panel read as a form stacked on a form. It goes up asking about one thing by name — How was Test Marathon? — with one tap. The tap is not final: the stars stay live while the sticker is up, and a second thought is a second tap, met as the new mark with its own fuss and its own words. What is sent is the mark the player settles on — 1.5 seconds after the last change, or at once when they move on (Done, the cross, saying more, the next innings, or leaving the page) — so three-then-five is one rating of five. The rules are in src/game/rating.ts; the slip is src/ui/Rating.ts.
When it asks is most of the design, and every rule is a rule about not asking:
| Moment | Asked about |
|---|---|
| The first finished innings of a mode not yet rated — Test Survival, Test Marathon, a Rival Match | That mode, by name. Freshest the moment it ends, and the only time the opinion is of the mode rather than of a memory of it. |
| The third finished innings | The game. Enough to have an opinion, early enough that the people who are going to leave have not left. |
And the brakes. Never on the first innings anybody plays, of any mode — that is an opinion of controls they have not learned yet. Never on a practice innings, never on an innings walked out on (the count is taken in end, where the career's is). One ask a visit. Waved away, it keeps quiet for seven days; waved away three times, it stops for good. Each thing is rated once per browser, and a new mode is a new thing — which is what the questionnaire could never do: its one hitman-feedback flag meant anybody who answered before the Marathon existed could never be asked about it. A browser that has sent the questionnaire is not asked about the game again (its fun question was a rating in all but stars), but is asked about each mode. The Blast is not asked about by name; asking about the game already asks about it.
How it is met depends on the stars, deliberately unevenly. A thumb can tap a star or slide along the row — the stars fill as it goes, each growing out of its middle, and the rating is where it lifts, with the questionnaire's own five words under them. A tap pops each star in turn, left to right, the last one biggest. Then: one or two, and the stars cool and settle — nothing is thrown in the air for an opinion that the game fell short. Three glows. Four bursts sparks. Five is hit for six: the fifth star goes up as a red ball and off the top of the screen, SIX! is stamped where it left, and the panel takes the thud. A phone buzzes, a longer buzz for four and five. A player who has asked for less motion gets the stars filling and nothing else.
Then it offers more, in words that answer the stars — sorry it fell short for a low mark, what would make it a five for a three, glad you're enjoying it for a four — as two keys side by side at half the width each, DONE and the way to say more (TELL US WHY, TELL US MORE, WHAT'S NEXT?). Never a key with a link under it. That opens the questionnaire's own screen with a handful of follow-up questions (FOLLOW_UPS in src/game/feedback.ts): one about the stars, and up to two about the thing rated — a Marathon's length and what made it, how Survival's blows felt, whether a Rival Match was fair and how easy it was to get a friend in. Never more than three and the box. Sending them does not count as answering the questionnaire; its quiet lines stay.
One rating can be several rows carrying one ref: the stars, the stars again if they were changed after being sent, and the follow-up answers, which carry the final stars too. Take the latest row of each ref as its rating, and average over refs, not over rows, or everybody who changed their mind or said more is counted twice. The columns are stars, thing (game, survive, marathon, rivals), moment (mode, innings) and ref, ahead of the questions.
?rate=1 previews it. A row of keys along the foot of the picture — GAME, BLAST, MARATHON, SURVIVAL, RIVALS — each puts the sticker up for that thing at once, over whatever is on screen. Tap, slide, change your mind, say more: it is the real sticker and the real follow-up, and nothing is sent or remembered, so it can be played with as often as you like. It works on any preview deployment and on production.
scripts/rating-check.mjs reaches it the way a player does — finishing innings — because its rule is about when: the first innings asks nothing, the second asks about its mode, a third that visit asks nothing. It then puts the slip up through the debug hook (__cricket.rating('marathon')) to look at the low mark, the cross and the keyboard, judging the celebrations by the pieces they leave and the requests they send rather than by frames a software renderer may never draw. SHOTS=dir keeps pictures of each. QUICK=1 leaves the innings out and checks only the slip, for a machine whose software renderer cannot bat three innings in reasonable time; it holds nothing about when, so it does not stand in for the full run.
The button row is deliberately not a fourth. It is six keys already, it sits over a live ball, and a form is not something to reach for mid-over.
/feedback is the link to send people, and it works because vercel.json rewrites it to the same page. Everywhere without that rewrite — the dev server, a Pages build, the one-file preview — the query does the same job: ?feedback=1. feedbackRoute() reads both, before anything else on the page runs.
That route is settled in main.ts before the game is imported, which is the reason Game sits behind a dynamic import. A friend who tapped a link to answer ten questions about a cricket game should not have to download three.js, the ground, the crowd and the cover art to do it: the form is about 19 kB of the bundle, the game is 665 kB, and only one of them is fetched. The private-window check is skipped on that page too — it exists to protect a place on the board, and there is no board on it.
POST /api/feedback on the same Upstash database the board uses, as one list: LPUSH then LTRIM is two commands a form and keeps it to the most recent FEEDBACK_KEPT without anything having to tidy up later. The rate counter is deliberately not the board's — somebody who has posted forty innings has not filled in forty questionnaires, and one shared key would let an evening of play use up the right to say what they thought of it. Ten forms an hour from an address, against the board's hundred and twenty innings.
The rules live in src/server/feedback-store.ts and the questions in src/game/feedback.ts, which is what the form is drawn from and what the endpoint validates against. One list, so an answer that arrived by any route other than tapping a chip is dropped rather than stored, and a question id can be a column heading without anybody wondering what is underneath it. A form is never turned down for being incomplete: somebody who tapped once and left has told you something.
Reading them back is GET /api/feedback?key=…, gated on FEEDBACK_KEY in the environment and answering anything without it the way it answers a request for a page that is not there. A 401 would confirm there is something here worth a key. It returns CSV — one row per form, one column per question, the shortlist joined by a space — because what actually happens to it is a paste into a spreadsheet and a sort. ?format=json is there for anything reading it by machine.
The suggestion is the only field anybody typed, and it is handled as such. Control characters and the invisible formatting marks go on the way in, the same set a board name is stripped of and for the same reason: a directional override turns the rest of a spreadsheet row around. A cell opening with = is a formula to Excel and Sheets, so it is prefixed with a quote on the way out. And it is never rendered into a page by this game — write-only, read as a spreadsheet — which is what removes the question of escaping it rather than answering it.
npm run dev serves all of this from memory, the way it serves the board: the form can be opened, filled in, sent, and read back at http://127.0.0.1:5173/api/feedback with no credentials and no database. The dev read has no key on it — it is answering whoever is running the dev server, and they wrote the answers.
On a Pages build there is no endpoint behind the form. That deployment is survive-only and deliberately has no functions; a form opened there will say it could not be sent. Set VITE_BOARD_API to the Vercel origin on that workflow if playtest feedback should reach you — the allowlist in src/server/http.ts already names the Pages origin.
Every mode is played in the stadium (src/scene/stadium.ts): two tiers of crowd right round the ground with a band of glass boxes between them, under one white cantilever roof with lamps along its edge; over the middle of the far end, the commentary box; behind it, two floodlight towers; and in front of it all the sightscreen, with the seats behind it covered over in white. Every other section has a tunnel into its lower tier and every section an aisle down its middle, so the ring has the rhythm of a real stand rather than one unbroken band of people.
It is built for the phone first. A phone held upright sees about seventeen degrees either side of straight down the pitch, so the far end is all of the ground it ever shows. Everything that makes this a stadium rather than a ring of seats is in that stretch: the commentary box sits over the sightscreen and under the score bar, and the floodlight towers stand in behind the end stand rather than at the corners, where a real ground puts them, so that a phone catches the edge of each head at the sides of its frame. A desktop sees the towers whole and the rest of the bowl.
How it got here. Two grounds came before it on this branch, both with a Victorian pavilion behind the bowler: first in red brick, then calmed down to cream walls and green roofs. On a phone either one read as a banquet hall stood in the middle of a stadium. The research that followed was plain about why: a real ground seen from behind the bowler — the broadcast's camera, and this game's — is a sightscreen with a stand full of people behind it, and the grounds people remember (Adelaide's roof shells and scoreboard, the SCG's green-roofed members' stands, the gasholders behind the Oval) are remembered by their skyline, not by a building's windows. Two blockouts were drawn from that, a members' stand under curved roof shells and a modern bowl; the bowl won, lost the big screen it was first drawn with, and gained the floodlights and the commentary box.
The Blast is played under the floodlights. A night version of the same stadium (src/scene/night.ts), for the Blast after dark: a clear deep blue sky, a sparse scatter of soft round stars, and a small full moon set high, in the gap a phone held upright shows between the score bar and the buttons. A first draft put a big moon low beside the commentary box and kept the clouds; the moon read as a lamp on the roof. The sun becomes the floodlights behind the batter's end, so he is lit from the side the camera sees, with a cooler light from the far towers catching his edges; the lamps burn, each with a glow round it — a bank on every other section of the roof, since three a section all the way round read as fairy lights; the environment map takes in the floodlights' heads, the ring of roof lamps and the lit stands, so a helmet, the glass and the ball catch them rather than reflecting a dark dome; and every figure's kit glows a little in its own colour, because lit only by the floodlights a navy shirt went to black and an orange one to brown; the boards along the rope light up the way LED boards do at a night game. Nothing is rebuilt to switch: the sky is repainted, its environment map taken again, the lights moved and recoloured, and the moon and the glows shown. The fill light is in the scene by day too, at nothing, so switching compiles no shader.
The Blast keeps the player's own hours. From six in the evening to seven in the morning, by the device's local clock, it is played under the floodlights; the rest of the day it is a day game. The hour is read off Date in whatever time zone the device keeps: nothing is asked for and nothing is sent anywhere (src/game/lights.ts).
The pause card carries the switch, in a Blast innings only: a two-way toggle, Day with a sun and Night with a moon, the one in play lit, changed between balls. A choice made there lasts until the clock next turns — switch to day at nine at night and it is day all evening, and the clock has it back at seven the next morning — and is kept in this browser (hitman-lights, which ?fresh=1 clears with the rest) with the moment it runs out. A choice that lasted forever would switch the clock off for anybody who had ever touched the toggle; one that lasted only the visit would have to be made every time. A Test is always played by day, and its pause card has no switch. ?lights=day or ?lights=night overrides all of it, for looking at one on purpose and for the checks; the covers are always photographed by day, so their pair still lines up.
The ground before it is kept. ?ground=bowl builds the bowl — one tier of stands in a full ring, flat roofs, the low clubhouse with three flags, and the four floodlight poles — from the same code it always was, in src/scene/grounds.ts. It is there to be looked at beside the new one. The turf and the boards along the rope belong to both and stay in GameScene.
It is cheaper than the ground it replaced. The bowl is built a box at a time, a draw call each and two in the sun's shadow; the stadium lays its pieces into a Batch (src/scene/build.ts) that merges them into one mesh a colour, and its crowd is one instanced mesh. Twice the stand, a commentary box and four towers, and 647 draw calls a frame against 800 on a desktop — 678 at night, when the floodlights cast the fielders' shadows. The stands themselves stand beyond the shadow map's edge, so they are left out of the shadow pass. scripts/scene-check.mjs walks both grounds and holds the stadium to the lower budget.
The roof's underside is painted lilac. From the crease it is lit by nothing but the grass — the hemisphere light's ground colour and the lower half of the sky's environment map are both green — and in the roof's own white it drew an olive band round the whole ground. Lilac is what that green light turns into a neutral grey.
The covers are gone. The first innings in a browser used to put the old ground up first, to be swiped off the new one (src/ui/Unveil.ts, remembered as hitman-unveiled). The stadium is nobody's news any more, so a player now walks straight out to bat, and the swipe, its pictures, its check and the seed every other check set to get past it went with it.
src/config/gameplay.ts: timing bands, line positions, delivery weights/speeds/lengths, the compatibility matrix and the threshold that separates a middled shot from a skied one, the triggers for each special delivery, wicket probabilities, and innings pacing.
src/game/: seeded delivery generation that tracks what the innings has done to the bowler, continuous bounce/swing/spin trajectories with per-delivery length, shot resolution, scorekeeping, keyboard input, and small Web Audio effects.
src/game/Tutorial.ts: the three scripted coaching balls, their deliveries, and an outcome that never dismisses the player.
src/Game.ts: explicit innings state machine and game clock. Pausing freezes gameplay time.
src/entities/Cricketer.ts: the body the bowler and every fielder are built from, and src/entities/rig.ts: the two-bone solver and segment placement they share with the batter.
src/entities/Bowler.ts: the bowling action, from the top of the mark to the end of the follow-through, on one clock of its own that the delivery cannot reach.
src/scene/GameScene.ts: reusable procedural characters, bat, wickets, ball, field, instanced crowd, and an aspect-aware camera. The popping crease is 1.2m in front of each wicket and the batter stands inside it rather than over the stumps, so the ball is met about a metre in front of them; GAME.travelScale carries the shorter flight that leaves, and every delivery keeps the duration it was tuned to. Bowler and fielders are articulated rather than stacked. Trunk, pelvis and head are each one lathed skin — shoulders, ribs, waist and neck in a single unbroken surface — and the trunk tucks inside the wider pelvis at the waist so the join is buried rather than shown. Every limb is solved between two points and drawn as a segment tapering towards the joint it points at, with a joint sphere sized to the radius the two limbs meeting inside it actually arrive with: a joint wider than its limbs is a bead on a string, one narrower is a gap, and a limb tapering the wrong way — thickest at the wrist — is most of why a body reads as a pile of parts rather than a body. The distant stadium keeps its cheaper faceted shading. The resolver determines outcomes; rendering visualizes them.
No stroke passes the bat through the batter. The pick-up holds the blade up behind one shoulder and the contact holds it down at the ball, half a turn away, so the short path between them can run round the back of his hip; a wrapped follow-through ends behind the other shoulder, so the short path home can run through his head. Both are steered by the poses either side of them — the leg-side flick lays its handle back so the blade comes down in front of him, the straight drive finishes a little wider of the grille, and the cut and the long-on drive each name a pose. A test measures the bat against the ellipsoids the figure is actually built from — trunk, hips and helmet — every 8ms of every stroke, at both lengths, across the full width of the crease, and fails if it reaches inside any of them.
Lines are shuffled in bags of five, giving six of each base line over a 30-ball innings. Pace/style is sampled independently, and the innings is medium-fast by default: about six in ten come out between 118 and 142 kph, roughly one in five is quick, one in seventeen is genuinely express, and one in six is slow. Everything else is a change from that — the quick ones and the slow ones are what happens to a batter who has settled into a rhythm, and a mix with a quarter of each is not a surprise, it is a lottery. Swing develops before the bounce; spin turns after it and finishes before the final third of the pitch. Compatibility uses the nearest line to the final ball position. Travel duration is scaled for arcade readability. The stage mirrors X so negative world X appears on the left from the batting camera, matching the A key.
The score is recorded when a ball is resolved and displayed at visual contact. The result animation completes before the end screen; no additional ball is generated after the innings ends. Fielders are scenery except for the scripted catcher. There are no extras, running controls, teams, or full fielding AI.
During local development, open /tools/card-lab.html to review the innings-end card in every state it can reach, including each step of claiming a place — driving those off a real innings needs a board behind it as well as thirty balls. Open /tools/pose-lab.html to review the guard, backlift, contact, follow-through, and recovery for every stroke side by side, and orbit the whole grid — a stroke played square cannot be judged from the one angle the game happens to use. /tools/bowler-lab.html does the same for the bowling action, one panel per moment, and carries the fielders' own figure below it; a side-on gather cannot be judged from behind the bowler's arm either. /tools/rig-viewer.html plays the action instead — the fast bowler's or the express bowler's, or both on a split screen through one camera — at down to a twenty-fifth of its speed, with a scrubber marked at every phase and the arm's angle and speed read off the figure as it moves; the whole run-up is 840 ms, which is why it needs one. node scripts/rig-viewer.mjs out.html bundles it into one page with nothing to fetch, for sending to someone. This review page is excluded from the production build. The animation tests verify connected grips, reachable arms, planted back toes, distinct footwork, and blade-to-ball alignment across the delivery range.
Use /?debug=1&seed=222 for a repeatable innings. The debug panel shows the phase, seed, delivery line/style/speed, base and final X, ideal contact time, shot, timing delta/grade, compatibility, quality, and outcome. Exact line information is hidden in normal play. The debug-only window.__cricket.snapshot() bridge is read-only and supports automated keyboard testing. A seed reproduces the same delivery/outcome sequence when shot inputs are the same.
With the development server running on port 5173:
npm run test:browser
The supplied scripts use headless Microsoft Edge through Playwright (channel: 'msedge'), which must be installed. They take desktop/narrow screenshots, check runtime errors and page overflow, and play a full innings using keyboard events and Playwright's virtual clock. They also verify three-wicket termination, pause, restart, combo input, one-shot gating, and the absence of debugging data in normal play. Screenshots are written to the ignored test-results/ directory.
Verified in Edge: a 30-ball seeded innings finished at 138/0 in 5.0 overs, all seven delivery styles appeared, and a no-shot innings ended at three wickets. Unit tests cover all supported scores, timing boundaries, compatibility entries, wicket rules, trajectory continuity, line balance, and deterministic outcomes. Chrome and Safari are target browsers but have not been manually certified here.
The game reports to GoatCounter, which is a hit counter with events: a page view, and named events that are paths with a flag on them. There are no custom properties, so everything worth knowing is in the event's name — runs go out as a band, score-50-74, never as a figure, because fifty thousand distinct paths is not a dashboard.
Nothing is reported ball by ball. A thirty-ball innings that sent a hit per delivery would be thirty hits for two minutes of one player's time, which buys nothing except a noisier dashboard. The moments below are the ones that answer a question:
| Event | What it answers |
|---|---|
| page view | How many arrive, from where, on what. GoatCounter collects this on its own. |
first-shot | Whether they worked out how to swing. Once per session, tutorial or innings. |
tutorial-start, tutorial-complete | Whether the three balls are taken up, and whether they are seen through. |
innings-start, innings-replay | Every innings begun, and the ones that were not the first of the session. |
innings-restart | An innings walked out on mid-over. Frustration, or a bad first ball. |
innings-end | An innings played out. Against innings-start, the completion rate. |
innings-all-out, innings-overs-up | Which way it ended: three wickets, or thirty balls. The difficulty dial. |
score-0-9 … score-100-plus | Where the scores actually fall, claimed or not. |
board-open | Whether the fifty is looked at. |
board-tab-classic, board-tab-survive | Whether the other mode's ladder is reached from the tab over the sheet. |
board-tab-marathon, board-tab-mine | Whether the Test Marathon's ladders and the player's own card are opened from the tabs. |
board-ladder-runs, …-boundaries, …-highest, …-balls, …-blows | Which career ladder is opened. The one measure of whether the career boards are worth the tabs they cost. |
board-ladder-marathon-team, …-solo, …-runs | Which of the Test Marathon's three ladders is opened from its strip: the team innings, the individual innings, or career runs. |
stats-open | Whether the career card is looked at at all. |
stats-share-whatsapp, stats-share-story | Whether it is then sent anywhere, which is the whole point of drawing it. The tap, not the delivery — whether the sheet was sent or dismissed is between the player and their phone. |
The password and restore events are also counted by the store itself, because an ad blocker takes a player off GoatCounter and its export is visitor-days rather than events: /api/restore raises a total and a day's figure for each password created or changed, security question set, Forgot password? asked, and player brought back by password, question or key (src/server/tally.ts, one hash, tally). scripts/password-report.ts reads those beside the exact number of players holding a password right now, read-only:
VERCEL_ENV=production npx vite-node scripts/password-report.ts
The profiles are counted the same way: /api/profile raises profile-view for a viewer new on a profile that day (the same count the profile shows), and every post raises chase-beat, chase-short and passed for the events it leaves on other players' profiles. scripts/profile-report.ts reads those beside what the store holds now — players with a profile and a bio, the last eight days' looks, the events of the last thirty — read-only, and prints figures only, never an id or a name:
VERCEL_ENV=production npx vite-node scripts/profile-report.ts
The taps — Played before?, the cards, the sheets opened — never reach the store, so they are GoatCounter's alone.
counting() in src/game/analytics.ts decides who is counted: not localhost, not a page opened with ?seed= or ?debug=1, which is how the browser checks and a tuning session play their innings, and not a Vercel preview, which is where a change is tried by the people making it. A scripted thirty balls is not a player. A build off Vercel carries no environment and is counted as before. The counter script is loaded async, so events raised before it lands queue for up to fifteen seconds and go out when it does; if it never lands — a blocked script, an ad blocker — the queue is dropped and the innings is untouched. Nothing here can throw into the game loop.
The clock only runs while the game is actually being played: a ball live, the bowler walking back, the call on screen. It stops for a pause, a hidden tab, the cover and the end card, so a tab left open all afternoon is not an afternoon of play. It is wall-clock time rather than the game's own, which the charge stretches into slow motion — a second of slow motion is still a second of somebody's life.
A session's length is reported as marks passed rather than measured at the end, because there is no reliable end to measure at: a browser closing a tab will not be waited on to send anything, and a beacon fired on the way out is the least trustworthy hit there is. A mark is sent while the page is alive and cannot be lost. The counts falling away across the marks are the distribution — a hundred sessions past one minute and thirty past ten is a curve, and the median is wherever it crosses half. An average would have been one number hiding that shape, and would have needed the unreliable beacon to compute.
An innings is different: it has a real end, and its length is known at that moment, so it goes out as a band. Seven of them, narrow where the innings actually fall — thirty balls is around two minutes — and open at both ends for the three-wicket collapse and the player who wandered off mid-over.
GoatCounter counts a visitor once a day and, deliberately, cannot recognise them tomorrow: the hash it identifies a visit by is salted, and the salt rotates. That is most of why the game needs no cookie banner, and it has a consequence worth being plain about — the dashboard gives a daily active count and can never give a monthly one. Thirty days of uniques added up counts somebody who played every day as thirty people, and no amount of squinting at that figure turns it into a monthly one.
So the recognising happens in the player's own browser, which remembers what GoatCounter has forgotten, and what leaves it is a band: new or returning, how long since last time, how many separate days in total. No date and no identifier goes out, so the counter stays as anonymous as it was. That gives:
visitor-new fires once per browser, ever. Summed over a month, that is new players that month, with no double counting.back-* bands are a return curve, and days-played-* is how sticky the game is. This is what a monthly active count is usually a proxy for, measured directly instead.A browser with storage switched off reports none of these rather than reporting itself new every session, which would have made the one exact figure here the least trustworthy thing on the dashboard.
Two things GoatCounter cannot do, and where to go instead. It cannot cross-tabulate, so did the players who took the tutorial score better is not a question it will answer; the board store holds the exact figures for every claimed innings and can. And it counts a visitor per path per day, so innings-start gives both totals (hits) and players who started at least one (visits) — the replay rate falls out of the two without a second event.
The game is published at https://hitman-cricket.vercel.app/, from codex/cricket-batting-game, and Vercel is the only host of the three this repository has used that can serve the board: GET /api/board and POST /api/score are functions, and GitHub Pages and Sites both publish files and nothing else. That is why it is the address.
vercel.json carries one rewrite, /feedback to the page, which is what makes the questionnaire a link worth sending rather than a query string. Everywhere without it, ?feedback=1 opens the same form.
Two consequences worth knowing:
hitman-cricket.vercel.app follows the latest production deployment. The long per-deployment URLs — hitman-cricket-1qc721gwe-… and friends — are nailed to one commit each, deliberately, so they never update. A link shared from the deployments list will never show anything you ship afterwards.FEEDBACK_KEY is what makes the answers readable. Set it in the Vercel environment and GET /api/feedback?key=… hands back every questionnaire as CSV. Until it is set there is nothing to read and the endpoint says so as a 404.ap-south-1 / bom1). Functions default to Washington DC, which makes every Redis command cross the planet twice on a screen that should open instantly..github/workflows/deploy.yml is gone: it published to GitHub Pages on every push, and a second copy of the game with no board behind it is worse than no second copy. Whatever Pages last built stays up until Pages is switched off in the repository's settings. .openai/hosting.json still describes a Sites deployment and has been left alone.
Source remains in the Hitman-Cricket GitHub repository. No account, download, or local server is required to play the published link. A personal best stays on each player's device; a place on the board is kept by the store. Both provided sound clips are included in the public game.
One link is one match room. Whoever opens it joins; whoever taps Play bats;
every ball is written to the room as it happens, so two friends can bat at the
same time and see each other's balls land between their own, or bat days apart
and get the same thing replayed. Nobody sees a score until their own last ball.
Ties break on runs, sixes, fours; level on all three is a draw. A room lives a
week; an innings left for a day is a forfeit; a challenge declined is a defeat.
Every finished match goes on a record — won, lost, drawn — kept on the server
for good and shown at the top of Rival Matches and under the cards on My Stats.
The Rivals ranking, behind the trophy key at the top of Rival Matches, ranks
registered names on wins, then fewest losses, then runs, over every finished
match — all fifty in a sheet over the list, scrolled to the player's own row. It was a tab on the leaderboard until the Test Marathon
needed the room; a ranking of matches belongs with the matches. A stricter rule — a match counts
only against a registered name — is written and switched off for now; see
src/config/rivals.ts.
The host bats in the home kit; the friends who follow bat in green, purple and
blue, in the order they came in.
docs/CHALLENGE.md has every state, where each piece lives, and how to test it
— including ?room=<state>, which draws any face of the room on one phone,
and scripts/challenge-check.mjs, which plays a whole match between two
headless browsers.
EXPRESS_ACTIONsrc/entities/Bowler.tsPACE_ACTIONGameScene.expressThe levels are told. The first over the pace bowler swings it, a banner goes up across the field — OVER 6 · CLOUD COVER, the ball has started to swing, and the rule he bowls by — and the sky clouds over a little in the three seconds after: the blue greys, wisps too thin to see before come through, and the sun dims (OVERCAST in src/scene/sky.ts; at nought the shader is exactly the Blast's). It is told once an innings, at the top of its over, and the bowler waits at his mark while it is up (LEVEL_BANNER_MS). The express bowler had a banner of his own at first, express pace; a playtest took it off, since a man running in at 180 announces himself.
Every fifty is a moment, each batter's own. A Test innings goes on past the hundred, so the marks go on too: 150, 250 and 350 get the fifty's raised bat with the number written beside him, and the double, the triple and four hundred are three days of their own — a strongman's double biceps with the bat stood up in his fist, arms flung wide with the bat out along the arm, and the champion: feet wide, the bat straight up at full stretch and the left fist pumped down by his hip — each with a doodle of its own rather than the hundred's fire made bigger. 150, 250 and 350 get a sticker of the number slapped on beside him with a googly-eyed ball peeking over it; the double a neon starburst going off behind him; the triple wings opening behind his outstretched arms with a halo over him; and four hundred a poster that covers the whole picture — a navy starfield, the giant 400, spotlights, and a podium under his feet with LARA'S CLUB round it. For the big three the layer goes up behind him: the scene draws him (and his stumps) again on his own and lays that over it, with a marker outline stamped round his silhouette. The nearing card waits ten short of every one of them: the nervous 190s. Past four hundred every hundred is four hundred's and every other fifty the raised bat's. The rules are in src/game/milestone.ts (kindAt), the celebrations in src/entities/Batter.ts (ROUTINES), the doodles in src/ui/Milestone.ts, the cut-out in GameScene's cutout, and the Blast's fifty and hundred are exactly what they were.
?moments=1 puts a row of keys along the foot of the picture, one a milestone (50, 100, six sixes, 150 to 400), to see any of them without batting to it. A tap between balls plays it at once and holds the bowler at his mark; with a ball on its way it goes up when that ball is dead. It adds no runs and counts and sends nothing.
Declaring is on the pause card once twenty overs are done, and not a ball before.
Every batter has to settle. He walks out with no meter to spend: a second meter beside the injury, Focus, its icon an eye, fills a ball at a time — FOCUS 12/30 (it said SETTLING until a playtest renamed it: focus is what fills, settled is what he is when it has) — and a blow knocks it back a ball for every four points it costs him. Thirty balls and the call says he is settled, and the meter is his confidence, a quarter full. It fills a little more slowly than the Blast's — two points less a stroke: six +26, four +20, three +14, two +10, and a single or a block +2, where the Blast gives nothing (half the Blast's at first, which a playtest found filled far too late), drains when he is beaten (−10) or hit (−1 per four points), and full it buys one special stroke on the Blast's own rules — the charge at the arm ball or the express bowler's slower ball, the slog sweep at the spinner, the scoops at the rest. A special swipe mistimed is just a Test stroke. He never goes back to unsettled; the next man in starts again. ?settled=1 walks every batter out settled and full, for trying the strokes. The numbers are in SETTLE and CONFIDENCE in src/config/marathon.ts.
One of the three bats left-handed, any of them, drawn off the seed apart from the bowling — so adding him changed no ball any seed bowls. While he is in, GameScene.leftHanded takes off the mirror the stage always carries, so he, his field and the ball's flight all come out the other way round; the bowler is mirrored back on his own and stays right-arm, now bowling across him. Not a rule of the game knows he is left-handed: the line, the stance and the leg side stay in the one frame. The swipes and the side keys are read the other way round to match (InputManager's mirrored), so his pull is a swipe to the right or D, and the labels along the foot of the field swap. Nothing else is said: a batter on the other side of the stumps says it himself. ?lefty=1, 2 or 3 puts him at that place in the order; ?lefty=0 bats all three right-handed.
Two switches for trying a bowler without batting ten or twenty overs to reach him: ?mode=marathon&swing=1 gives every over to the Level 2 swing bowler; ?mode=marathon&express=1 gives every over to the express bowler; ?mode=marathon&reverse=1 gives every over to the swing bowler and has him bowl nothing but reverse swing. Each bowls as he would in the real innings. Both together share the overs, never two of his running. Like ?spin=1 and ?bouncers=1, they change the bowling and nothing else. And ?mode=marathon&wear=fast keeps the whole innings but wears the pitch five times as fast: blocks of two overs instead of ten, so the swing comes on in the second over instead of the sixth, the express bowler takes the third instead of the eleventh, and Level 3 — four overs in ten his — starts at the fifth instead of the twenty-first (marathonFastWear). Only when each step comes is changed; the declaration still waits for twenty overs. ?mode=marathon&round=1 bowls every over round the wicket, from the first (roundEvery). An innings played with any of these switches — or ?spin=1, ?charge, ?slowmo or ?bouncers=1 — is practice (src/game/practice.ts): its card says Practice innings — not for the leaderboard where a place would be offered, the career does not count it and the browser's own best stays put. In a Rival Match the switches do nothing. And ?mode=marathon&nets=1 is the nets: round the wicket from the first ball, with a row of keys along the foot of the picture — PACE, SWING, SPIN and SLING, the seamer of the first overs, the swing bowler with his reverse swing, the spinner and the express bowler, each as he bowls in the innings — and a fifth that shows the side he is bowling from, ROUND or OVER, and swaps it. A tap changes the bowler from the next ball, which starts an over of his own (DeliveryGenerator.nets); the innings is otherwise an innings.
The Test look, in both Test modes: a darker ball, a Dukes's deep cherry rather than the Blast's bright red, and a strip with grass left on it — a little greener, not a green top, with fewer cracks and less dust between the wickets and the bowlers' footmarks worn just the same. pitchTexture takes how much grass to leave (BALL.testGrass in GameScene.ts, a half); the green strip is painted the first time a Test is played, so a player who only bats in the Blast never pays for it, and at nought the Blast's strip is painted exactly as it always was. __cricket.greenTop() says which is down.
The speed gun reads every ball at the foot of the field as it leaves the hand — 142 KM/H, dark on a light pill — and is gone before the next, in the Marathon first. The game's internal speeds are tuned for play (the express ball is 172–186), so src/game/speed-gun.ts shows them the way a real gun would: up to 140 as they are, the quicks folded into 140–160, never under 70 or over 160, the order always kept. Flight time and injury still use the internal speed.
Round the wicket, now and then. From the sixth over about three overs in ten are bowled from the other side of the stumps (ROUND), drawn per over and kept for all six balls: the bowler runs in on his right of the stumps instead of his left — from the left of the screen, behind a right-hander — and the ball leaves his hand out wide, more than half a metre outside them for the fast bowler and the spinner and nearly a metre for the slinger, whose arm comes over a long way from his hips. It angles in on a straight line, pitching nearer the side it came from and going on across the batter, and arrives exactly where and when it would have from over the wicket (drawnAt): the rules meet the ball at the bat, so the line, the length and the timing are the over's as ever, and what is new is the angle it is read from. The side is forked off the seed rather than drawn from it, so every seed bowls the balls it bowled before; the other modes stay over the wicket.
The pitch wears, in the steps the bowling climbs: fresh, then a stage as the ball starts to swing, another as the express bowler comes on, and the last at Level 3. Each stage repaints the strip: the grass going off it and the surface browning, the footmarks scuffed darker and spreading, the rough a bowler's follow-through digs outside off stump, a dusty patch on a length, and cracks between the wickets, more, longer and wider each time (wear in src/scene/turf.ts, WEAR_STAGES; GameScene.wear). The first cut had no wear to see at all, and the playtest asked where it was. ?wear=fast brings the stages at overs 2, 3 and 5.
The rules, the first time. The first two times a Marathon starts, a coachmark goes up over the ground before the first ball, one rule at a time, and the bowler waits at his mark until the last is put away: Three batters, Focus to settle (the Focus meter popping out of the dark as a white pill, a dotted green arrow from the words up to it), The pitch wears, and Every innings counts — all out or declared, the score goes on the board, and declaring from pause after over 20 is a choice that keeps it rather than a step every innings owes (the pause key popping out as a white disc). White words and a line drawing on the dimmed screen, Next and Skip at the foot; Skip ends them for good. The first cut was a green and gold card at the foot of the screen, and a playtest asked for a coachmark (src/ui/MarathonIntro.ts, remembered as hitman-marathon-intro). The five-hundred-ball stop is deliberately not among them — told first, it reads as a target.
The end card is the Blast's, under a Test's numbers. Under the total, the worm — the runs climbing ball by ball, a red ball on the line where each batter was out and an amber one where he was carried off — and the batting card: a row a batter, runs, balls, fours, sixes and strike rate, out, retired hurt or not out under his name, the total under them and whoever never got in named below (src/ui/MarathonCard.ts). Then the boards' strip, a full-width PLAY AGAIN, and CHANGE MODE and SHARE side by side under it; the share is the scorecard as a line of text, since the Blast's picture is of a five-over card. It replaced a strip of ball marks and a line of text with the three batters run together in it.
recoversrc/entities/Batter.ts: articulated right-handed batter with eight strokes — the five shot directions, a back-foot pull the leg-side input selects at bouncer height, a square cut that stands tall for a ball at the chest, and a charge down the pitch the confidence meter unlocks — built from smooth primitives, with the bat blade extruded from a real cricket-bat outline, with a side-on guard, flexed knees, the back bent forward over the ball, the head carried on the spine, the bat cocked back over the shoulder so the toe points at first slip with the face opened up, a shared two-hand bat grip, and two-bone arm/leg posing. Both fists ride the bat's own rotation — the right hand below the left on the handle, knuckles lined up along it — and only the gauntlets turn, back up the forearm; a hand free to face its own arm ends up gripping the handle a quarter-turn away from the other one. Elbow hints are scored for room and turned around the arm when one would bury the elbow in the chest or lay the forearm along the handle. The bat carries a full orientation — a handle axis plus a blade face — and poses slerp that rotation, so the blade travels a clean arc instead of rolling at random when a stroke reverses it. Each shot has its own footwork, contact pose, and follow-through; drives step forward, the leg-side stroke rolls into a flick, and the square cut rocks back onto the back foot, strikes square with a horizontal bat, and wraps the blade up over the front shoulder. A stroke may also name a recover pose, the shape the bat comes down through on its way back to the guard, for a follow-through the bat cannot travel home from in a straight line without passing through the batter. Both gloves stay attached to the bat handle throughout the stroke. The blade, outgoing ball, impact sound, and result feedback are synchronized at visual contact without altering input timing scores.
src/game/Confidence.ts: the confidence meter. Boundaries, twos and threes fill it, a ball that beats the bat drains it, a single or a block leaves it alone, and a wicket or spending it empties it — a run of form rather than a bank balance, so it cannot be saved across a collapse. Full, it buys one charge down the pitch.
src/game/Sledge.ts: counts the run of balls the batter has not scored off, and says when the fielders have heard enough.
src/game/Share.ts: the innings link, the WhatsApp message, and the file name and type each shared picture travels under.
src/game/ShareCard.ts: the innings-end card painted onto a canvas so it can leave the page as a picture. Both share buttons draw the same card, minus its buttons, with the Hitman Cricket lockup in the corner; the story button stands that card on the cover art in a 1080x1920 frame with the address painted on. Two platform limits shape it. A wa.me link carries text and nothing else, so where the browser can hand a file to another app the button goes through the share sheet instead and the link stays as the fallback; and a picture in a story is a picture, so the address is readable type rather than a tappable sticker, since link stickers are added inside Instagram or WhatsApp and not by whoever sent the image. The card is a PNG for its flat colour and sharp type, the story a JPEG for its photograph.
src/game/leaderboard.ts: the ladder, written once and imported by both sides — the browser runs it to decide whether an innings is worth asking a name for, and the store will run it to decide what the board actually is. Carries the packed score, the plausibility floor, and decidedBy, which names the figure that separated two innings so a row can point at the reason it sits where it does.
src/server/board-store.ts: what the board is on the store's side — reading it, and everything that decides whether a submitted innings is taken. Every command it needs is named on a BoardStore interface rather than reached for through a Redis client, so the whole submit path is tested with no network and no database, and the day this moves off Redis one adapter changes and none of the rules do. A Ladder says which figures a row carries, how they pack, what could not have happened, and which keys to write; the rules above it are written once and run for both boards.
src/server/memory-store.ts: the board in memory, behind the same interface. It backs the dev server and the tests — one implementation rather than two, because a second copy drifts from the one the endpoints are developed against. It keeps the semantics the Redis adapter leans on rather than the convenient ones.
src/server/upstash.ts: that interface over Upstash. Note it does not use the client's own Redis.fromEnv() — Vercel's integration injects KV_-prefixed names, fromEnv looks for UPSTASH_-prefixed ones, and it would find nothing at runtime on a page nobody is watching.
src/server/http.ts: CORS, the address the edge reports, and how a failure is phrased. The allowlist is an allowlist rather than a * because one of the two endpoints writes.
scripts/function-check.mjs: whether each function loads at all in Node. Part of npm run build.
api/board.ts and api/score.ts: thin handlers. They turn a request into a call and the answer into a response, and hold no rules of their own.
src/game/board-api.ts: the browser's side. Every call is on a four-second leash and a failure is an answer rather than an exception, because the board must never hold up an innings. The invented fifty answer only where there is no API to ask — a live board that quietly fell back to made-up names would be lying about who is on it.
src/config/board.ts: the five kits and where their pictures live. One place, because three screens draw them.
src/game/player.ts: the name and kit this browser bats under, kept apart from the identity in identity.ts — that one must never be lost, this one costs a tap.
src/game/board-fixture.ts: fifty innings nobody played, so the board could be designed before there is a database behind it. Deterministic from one seed.
src/server/career-key.ts and src/server/key-admin.ts: the career key — minted from the wordlist, kept as a slow hash that a restore is checked against, and sealed with KEY_SEAL_SECRET where it is set so the owner can read a lost one back. scripts/key-show.ts reads one; scripts/key-reset.ts gives one player a new one and touches nothing else.
src/server/password-rules.ts, src/server/career-password.ts, the password half of src/server/recovery-store.ts, src/game/password-nudge.ts and src/ui/Password.ts: the career password, the way back players keep. Never asked before the first ball; offered on My Stats from day one, on the end card from 50 career runs, and by a sheet after three end cards walked past. Kept per player id as a slow hash with an optional security question, never sealed or read back. A wiped phone types a few letters of the name, picks itself from the players with a password, and enters it; Forgot password? puts the question, and with none the career key is the way back.
src/game/identity.ts: the player id, written to and restored from three stores at once. A store that throws is treated as empty and a store that hangs is left behind after a second, so a wedged IndexedDB costs the player a second rather than the game.
src/server/return-ticket.ts, src/game/return-ticket.ts and api/me.ts: the return ticket, the server-set cookie that brings a player back when Safari has deleted the other three copies. The server half checks the shape and returns it as a cookie; the page's half asks for it when it needs writing and reads it at startup. The page never writes it, because a script-written cookie is capped at seven days.
src/ui/Leaderboard.ts: the board as a string of HTML built from figures and nothing else, the way the rest of this interface is written, which is what lets fifty rows be checked with no browser in the room. Names come off the board, which is to say off other players, so they are written into the page as text and never as markup.
src/game/survive-board.ts and src/ui/SurviveBoard.ts: the Test ladder and the sheet that draws it. Same shapes, same escaping, same peek — a different ladder underneath and a row that leads with what happened rather than with the number.
src/ui/DotMatrix.ts: the scoreboard's lamps. Faces are 7 rows of dots, drawn as SVG with the dark lamps as well as the lit ones — the unlit grid is what makes a panel read as a board rather than as text in a box. Punctuation is narrow, so an over count reads 5.0 rather than 5 . 0.
src/ui/HUD.ts and src/styles.css: a full-window stage holding the start card, scoreboard, in-field controls, shot feedback, help, pause, and innings-end screens. Ball feedback is a call that rises off the field and fades on its own — no panel interrupts play, and delivery speed and style are not reported.
tests/share.test.ts: what the shared card says for every innings ending, that it copies the innings rather than holding a reference to it, the file name and type each picture travels under, and that the address is in the caption because the picture cannot be tapped.
tests/game.test.ts: deterministic game-rule and progression tests, confidence-meter arithmetic, which deliveries can be charged, the flight curve that hides a slower ball off the hand without moving the moment it arrives, and the WhatsApp share message.
tests/batter.test.ts: grip attachment, hand order and fist alignment on the handle, guard geometry, per-stroke footwork, blade placement at contact, elbow clearance from trunk and handle, hands kept in front of the shoulders through every stroke, and continuous blade travel with a squared face.
tests/bowler.test.ts: an action that is the same pose at the same moment whatever the ball does and cannot be told how fast it is, an arm accelerating into the ball and still going after it, momentum carried into the crease rather than lost on the way, no jump where the run-up hands over to the follow-through, the ball leaving the hand where the delivery starts, a straight bowling arm, an arm that climbs over the top once, planted feet that do not slide, a legal front foot, and no limb reaching past its own length anywhere in the action.
tests/leaderboard.test.ts: every rung of the ladder in turn, the packed score's 53-bit budget and that a real innings packs past 2^32 without wrapping, and the innings the plausibility floor turns down.
tests/player.test.ts: the stored name and kit, including a kit that no longer exists, junk under the key, and storage switched off.
tests/board-store.test.ts: the submit path end to end against an in-memory Redis that keeps the semantics the adapter leans on — ZADD GT only moves a score upwards, HSETNX only claims a free name. A fake that took every write would pass tests the real store would fail. Covers one row per player however many innings they play, a better innings surviving a worse one that follows it, name folding, and a rate limit that charges a rejected submission nothing.
tests/board.test.ts: that all fifty invented innings could have been dealt, that the last row is a real innings rather than a duck, both planted ties, what a row says about why it sits where it does, and that a name is written into the page as text.
tests/identity.test.ts: restoring a player from whichever store survived, folding a browser that minted a second id back onto the first, and staying playable when every store is broken.
tests/scoreboard.test.ts: a lamp face for every character the board can show, the dark grid behind the lit one, and panel sizing.
tests/mobile.test.ts: swipe directions including the defensive fan and its dead slivers, actual pointer event listeners, recognition timing, cancellation, one-shot gating, and normal/boundary sound selection.
claim-open, claim-done, claim-failed | The registration funnel: offered, taken, and refused by the store. |
survive-… | The same events again, for a Test innings. GoatCounter has no custom properties, so the mode is in the name or it is nowhere — everything about an innings is prefixed, and everything about a session (the first shot, the help screen, the minutes played) is not, because those are the same fact whichever innings they happened in. |
survive-result-won … survive-result-lost | Which of the five result cards the Test innings earned. |
survive-critical-reached | Whether the batter ever got one blow from being carried off. The direct measure of how many players meet the injury meter at all — the result events cannot answer it, because an innings that goes critical and is then bowled out reports only the bowling. |
survive-injury-0-24 … survive-injury-retired | Where the meter finished, in the bands the simulator reports, so the live spread sets against the modelled one without arithmetic. |
survive-blows-0 … survive-blows-5-plus | How many blows he took. Read beside the meter rather than instead of it: one on the helmet costs more than two on the pad. |
survive-balls-under-1-over … survive-balls-8-10-overs | How long the last man lasted, in overs rather than balls, because that is how a Test innings is read. |
marathon-… | The same again for a Test Marathon: marathon-innings-start, -innings-end, -claim-done and the rest, plus the Marathon's own — -intro, -intro-finished, -intro-skipped, -batter-settled, -batter-out, -batter-retired, -declared. |
marathon-ended-all-out, …-retired, …-declared, …-balls | How a Marathon ended: three men gone, the last carried off, declared, or the five hundredth ball. |
marathon-total-0-49 … marathon-total-500-plus | What the side made, on the Marathon's own scale: the Blast's bands stop at a hundred, where a Marathon has barely started. |
marathon-best-0-24 … marathon-best-300-plus | The most any one of the three made, cut at the marks the game celebrates. |
marathon-overs-0-4 … marathon-overs-70-plus | How long it went, in completed overs, cut where the bowling changes: the swing at six, the express bowler at eleven, the declaration at twenty. |
spinshort-faced, spinshort-other | The pull off the spinner's ball: an innings that faced the spinner's ball dropped short, and one that met it with any stroke but the pull. Once an innings, whatever the count (src/game/stroke-tally.ts), under the mode's prefix. |
spinpull-played, -arm, -six, -four, -out, -missed | The pull off the spinner played at all that innings, played at the arm ball, and what it came to: hit for six, for four, out, beaten. Read against spinshort-faced, whether players find the leg-side swipe at the short ball and whether it pays. |
hookball-faced, hookball-other | The same for the hook: an innings that faced a bouncer on off stump or wider, and one that met it with any stroke but the hook. |
hook-played, -six, -four, -out, -missed | The hook played at all that innings, and what it came to. |
fifty, century, six-sixes, and survive-…, marathon-… of each | A milestone reached in an innings, under its mode's prefix like everything else about an innings. The Marathon's own: marathon-raise-150, -raise-250, -raise-350, -double, -triple, -four. Not sent for a moment asked for with ?moments=1. |
practice-… | Any of the above from a practice link — a test switch on, ?nets=1 and the like — so the nets and the tuning switches are counted apart from the innings they would swell: practice-marathon-innings-end, practice-score-25-49. |
share-whatsapp, share-story, share-link | The taps. Whether the sheet was then sent, no browser will say. |
challenge-set, challenge-rematch | A Rivals room made, and one made off the back of a result. Against challenge-joined, how many invitations find a taker. |
challenge-shared, challenge-copied | The link sent through the phone's sheet, or copied to paste somewhere. The taps, as above. |
challenge-opened, challenge-joined, challenge-declined | A link landing on the challenge-received sheet, and the two ways off it. Opened against joined is the funnel; declined is the one that goes down as a defeat. |
challenge-accepted, challenge-resumed, challenge-answered | A match innings begun, one picked back up, and one that landed. Accepted against answered is the completion rate for a match, which innings-end cannot give because it does not know which innings were matches. |
challenge-live, challenge-apart | Whether the friend was batting at the same time, or this innings answered one played earlier. The pair says which of the two ways the mode is actually used. |
challenge-won, challenge-lost, challenge-drew | A result seen, once per person per match, from whichever side. |
challenge-by-forfeit | A result that was a walkout or a decline rather than two innings. How many matches never become matches. |
challenge-players-2, …-3, …-4-plus | How many were in the room when the result was read. Whether the forwarded link ever becomes a group. |
rivals-list, rivals-head-to-head | Rival Matches opened, and a friend's row tapped for the tally. Whether the record is looked at, and whether rivalries form. |
modes-board | The boards opened from the Leaderboards card on the mode screen, rather than from the cover's trophy. |
super10-play-card | Super10 played from its PLAY card on Choose a mode. |
super10-play-poster | Super10 played from its poster on Choose a mode's rail. |
super10-field-look, super10-field-look-innings | The field map enlarged in a Super10 innings: every time, and once an innings (so the share of innings that ever enlarged it can be read). |
super10-quit-over-1 … -10 | A Super10 innings walked out on, by the over it was in: restarted, left for Choose a mode from the pause sheet, or the page closed on it. Once an innings. |
super10-open-card, super10-got-it, super10-excited | Before release: Super10's coming-soon card opened, its page put away, and I'M EXCITED pressed (the store's count of it is still read by scripts/super10-report.ts). |
rivals-ranking | The Rivals ranking opened from the trophy key on Rival Matches. Whether anybody looks for it now it is behind an icon. |
restore-open-profile | Played before? tapped on the name screen. Its siblings say where else the restore sheet was opened from: -stats, -board, -card, and restore-open-taken, the link under a name the gate was told is already held. |
restore-find-listed, restore-find-none, restore-picked | The restore sheet's search: a few letters that turned somebody up, ones that found nobody, and a player tapping themselves on the list. |
restore-switch-key, restore-switch-password | Turned from the password face to the career key, and back. |
restore-sent, restore-sent-password, restore-failed-… | A key or a password offered to the store, and which way it was turned down. |
restore-done-password, restore-done-question, restore-done | Brought back, by password, by the security question after Forgot password?, and by a career key. |
password-card-stats, password-card-end, password-nudge-shown | The asks, seen: the card on My Stats (once an opening), the card on the end of an innings, and the sheet after three end cards walked past. |
password-open-<from> | The sheet opened, and from where: stats, end, nudge, details, welcome. |
password-created, password-set-<from> | A first password saved — one figure whichever way in, and again by the way in. Against password-open-…, the completion rate. |
password-changed, password-question-set | A password changed from Your details, and a security question picked. |
password-closed-<from>, password-nudge-later, password-save-failed | The sheet left unsaved, the nudge put off, and a save the store refused. |
password-welcome-offered | Welcome back, after a career key, offering a password. |
password-forgot, password-forgot-no-question, password-answer-wrong | Forgot password? tapped, with no question to answer, and a wrong answer. |
profile-open-row, -podium, -chip | Another player's profile opened off a board, and by which of the three. Against chase-start, how often a look becomes a chase. Not under a mode's prefix: it is the board's, not the last innings'. |
profile-failed | A profile the store did not answer for, so the sheet stood on what the row knew. |
stats-open, activity-open | My profile opened, and the Your profile sheet opened from its headline. |
activity-headline | My profile led with Rank down, once an opening. Against activity-open and activity-win-back, how often being passed brings a player back. |
chase-start, chase-start-batter, chase-bat, chase-back | CHASE on a profile (the Marathon's best batter apart), and the intro's two keys. Each mode under its own prefix. |
chase-passed, chase-beat, chase-short | The target passed in play, and how chases end. Against chase-start, how often a chase is won. |
activity-win-back | WIN IT BACK on the Your profile sheet: what was taken, chased. |
winback-beat, winback-short | How a WIN IT BACK innings ended, under its mode's prefix. chase-beat and chase-short count these among every other chase. |
bio-saved, bio-cleared | A bio written or changed from Your details, and one taken away. |
help-open | The controls did not explain themselves. |
feedback-open, feedback-open-link | The questionnaire opened, and whether from inside the game or from the shared link. |
feedback-cover, feedback-card, feedback-pause | Which of the three lines into it is the one people actually use. |
feedback-sent, feedback-failed | Forms finished, and forms the endpoint could not take. |
feedback-left-0 … | Which screen a form was abandoned on. A questionnaire everybody leaves on the same screen has a bad question on it. |
rating-ask-game, rating-ask-survive, rating-ask-marathon, rating-ask-rivals | The stars put up, and about what. |
rating-game-1 … rating-rivals-5 | The stars given. The ask against these is the response rate. |
rating-dismissed | The slip waved away without a star. |
rating-changed | A mark changed after it had been sent. Counted apart, because the counts of each mark cannot take one back. |
rating-more-open, rating-more-sent, rating-more-left-0 … | The follow-up after a rating: opened, sent, and where it was left. |
webgl-fail | The ground could not load, and nothing that follows was ever possible. |
visitor-new, visitor-returning | Whether this browser has played here before. |
back-same-day … back-over-30-days | How long a returning player was away. |
days-played-1 … days-played-11-plus | How many separate days this browser has played on. |
innings-under-30s … innings-over-5m | How long an innings took, in each mode under its own prefix. |
played-1m, played-3m, played-5m, played-10m, played-20m, played-30m | Marks a session got past. |
day-batted-1m … day-batted-120m | Marks a device's batting in one day got past (1, 3, 5, 10, 20, 30, 45, 60, 90, 120 minutes), added up across every session that day and sent once each, the moment it is passed (src/game/playtime.ts). Against the daily actives, the minutes a player bats a day. |
week-batted-1m … week-batted-300m | The same for a week, Monday to Sunday (1, 5, 10, 20, 30, 60, 90, 120, 180, 300 minutes). |
week-active | A device's first load in a week, batting or not: the exact weekly actives, and what week-batted-… is read against. |
month-active | A device's first load in a calendar month, in the player's own timezone, batting or not: the exact monthly actives, which GoatCounter's own figures cannot give. |