Wiffle Ball, Decompiled
Candystand's Wiffle Ball was a fixture of early-2000s browser games. We decompiled its code from the copy preserved in Flashpoint. Two decades later, it turns out the game had been quietly breaking its own rules the whole time.
The box score
| Finding | What it means in play | Kind |
|---|---|---|
| Amanda and Todd are swapped | Each plays with the other's ratings | Bug |
| Hit-by-pitch is just a ball | With 3 balls, getting hit does nothing | Bug |
| The silent walk-in run | No points, and it can't win the game | Bug |
| "You Tied" never appears | The art exists; the game can't reach it | Quirk |
| The bat ignores height | Shoelace-high or head-high, you can hit it | Secret |
| You get 3 frames, the CPU gets 1 | Your timing window is triple the computer's | Secret |
| Your mouse steers after the swing | Keep moving through contact to aim | Secret |
| Launch angles are random | A whole table of hit types is thrown away | Bug |
| The flutter is a zig-zag | A sine bug flattens the wiffle wobble | Bug |
| How the CPU decides to swing | Never at slow pitches; always down the middle | Secret |
| The school's missing curb | One side of the street has no collision | Bug |
| The backwards Mercedes | Its hitbox drives the other way | Bug |
| Nothing is round | Hydrant, wagon and bike collide as boxes | Bug |
| The invisible balloon | A typo picks a balloon that doesn't exist | Bug |
| The CPU fielder chases a ghost | After a grounder bounces, it runs to the old spot | Bug |
| An aim-assist that never shipped | It learns your misses, then ignores them | Leftover |
| Secret keys | N restarts; Ctrl+V shows the build; ~30 dev keys | Secret |
The big one
Amanda and Todd got each other's stats
The select screen says Amanda "may not be a pro at bat, but watch out for those killer pitches!" and that Todd is "a good all-around player." In the game itself, it's the other way around.
Each player's ratings come from one line: addBatter(hand, fielding, hitting, pitching, …). "Pitching" is the random scatter added to every pitch, so lower is better.
| Who | Fielding | Hitting | Pitch scatter | Card says |
|---|---|---|---|---|
| Kent | 80 | 8 | 4 | Strong hitter, decent glove ✓ |
| Tom | 90 | 10 | 8 | Slugger ✓ |
| Amanda (as shipped) | 90 | 5 | 6 | Weak bat, killer arm ✗ |
| Todd (as shipped) | 95 | 3 | 2 | All-rounder ✗ |
Todd, the "all-rounder," secretly had the weakest bat, the best glove and the most accurate arm in the game. The select-screen rating bars agree. They use a consistent scale (fielding 80 → 4 bars, 90 → 5, 95 → 6; hitting 1 bar per point). Amanda's card shows 6 fielding bars and 3 hitting bars, which is exactly Todd's in-game row.
The cause is in the setup code. The developer-only branch loads the players in the order Ken, Tom, Tod, Ada. The web build loads Ken, Tom, Ada, Tod. The stat rows match the first order, so they were apparently written for it and never swapped.
-- authoring mode (the developers' copy) playerfilenames = ["cnwb_Ken", "cnwb_Tom", "cnwb_Tod", "cnwb_Ada"] -- the shipped web build playerfilenames = ["cnwb_ken.cct", "cnwb_tom.cct", "cnwb_ada.cct", "cnwb_tod.cct"]
In play: if you picked Amanda for her pitching, you got a mid-pack arm. If you wanted a slugger who could also field, Todd was never it. The batting hot zones and pitch mixes are swapped along with them.
Rules the code bends
Hit-by-pitch is just a ball, and with three balls it's nothing
Getting plunked never sends you to first. It adds one ball to the count. If you already have three balls, the game skips even that, so a hit-by-pitch can never walk you.
#hitbatter:
if ballcnt < 3 then
ballcnt = ballcnt + 1
end ifThe silent walk-in run
Walk with the bases loaded and a run scores, but through a shortcut. Hits award points for runs and check for a walk-off win. A walked-in run does neither. If the home side walks in the winning run in the last inning, play continues until the second out ends it.
if bases[3] = 1 then
runs[batter] = inithackv(readhackv(runs[batter] + 1)) -- no addScore, no game-over check"You Tied" can never appear
The game ships with three end-of-game banners: You Win, You Lose and You Tied. Whenever the score is level at the end of the last bottom half, the game quietly adds an inning (inningend + 2). It does that forever, so the tie banner is unreachable art.
if (innings >= inningend) and (runs[1] = runs[2]) and ((innings mod 2) = 1) then inningend = inningend + 2 end if
Batting secrets
The bat ignores height
Contact checks three things: the ball is crossing the plate (from 2.5 ft in front of it to half a foot behind), it hasn't bounced, and it's within 2 feet left or right of your bat's target. Its height is never tested. A pitch at your shoelaces or over your head is just as hittable as one at the belt.
if (ball[3] > -0.5) and (ball[3] < 2.5) then -- depth if not bounceflg then if deltaZ < 0 then if (ball[1] > (-2 + targetx)) and (ball[1] < (2 + targetx)) then -- side to side -- ...contact. ball[2] (height) is never checked.
You get three frames to make contact. The CPU gets one.
The swing animation runs frame by frame. A human batter can connect on swing frames 7, 8 or 9; the computer only on frame 8. At 15 frames per second, that's a 200 ms window for you and 67 ms for the CPU.
if ((animbatter >= 7) and (animbatter <= 9) and human batting) or (animbatter = 8) then
Your mouse keeps steering after you let go
Swing power and direction come from how fast the mouse moved over the last eight frames. The game measures that at the moment of contact, not at the moment you released. Moving the mouse sideways through the swing pulls the hit left or right.
Every launch angle is random
The code has a table meant to make high pitches fly and low pitches skid (grv), and it applies it. Five lines later, every hit's vertical speed is overwritten with a random number from 1 to 240.
ball[10] = getAt(grv, ind) * 20.0 * speedfactor -- per-zone launch ... ball[10] = random(240) -- ...replaced for every hit
The CPU's hit power has a similar problem. Its table [14, 16, 14, 18, 22, 18, 10, 12, 10] goes through * 2 / 24, and Lingo does that in whole numbers. Every entry collapses to 1 except the two 10s, which collapse to 0. So every CPU hit leaves the bat at the same speed, and on low-inside or low-outside pitches it has no forward speed at all.
The wiffle-ball flutter is a zig-zag
After contact, the ball is supposed to wobble in a little corkscrew, the signature wiffle-ball movement. The game builds it from a 72-entry cosine table, reading sine by shifting the index. It shifts by 180 entries, which wraps around to 180 degrees, so "sine" returns minus-cosine and the corkscrew flattens into a diagonal line. The direction is meant to be random, but it's set in a local variable, so the wobble always turns the same way.
on qsin rot2
return getAt(cosTable, (((rot2 / 5) + 180) mod 72) + 1) -- a 90° shift would be 18
endHow the CPU batter decides to swing
The computer never swings at a slow pitch. Don't get excited, though: a pitch that slow hits the dirt, and a bounced pitch is always a ball. We threw 96 soft tosses aimed everywhere from the middle to the top of the zone. The CPU took all 96, and all 96 were balls.
The opposite holds down the middle. The CPU forces a swing at any pitch headed for the center square. In our test it swung at all 96 hard pitches down the middle and put 88 of them in play. Pitch to the corners.
if predictedZone = 5 then rnd = 1000 -- dead center: never take if rnd < 25 then flg = 0 if deltaZ > (-0.6 * speedfactor) then flg = 0 -- too slow: never swing
Field glitches
Morris School is missing a curb
The left curb is defined, then immediately overwritten by the right curb under the same name. Balls that should clatter off the left curb roll right through it.
obj05 = [-22, -1, 0, -17, 0.5, 45, #rectangle, 0, #concrete, #leftCurb] obj05 = [17, -1, 0, 22, 0.5, 45, #rectangle, 0, #concrete, #rightCurb]
The black Mercedes on Larkin Street drives backwards (invisibly)
Most vehicles drive left to right. The black Mercedes drives the other way, so its sprite is mirrored: it's drawn at 500 − x. Its collision box isn't mirrored, so the thing you can actually hit drives the opposite direction of the car you see.
Separately, the collision check reuses one position offset for every vehicle part: whichever vehicle was measured last. So even the delivery trucks' hitboxes sit slightly out of step with their pictures.
Nothing is round
The fire hydrant, the red wagon and the bicycle are tagged #cylinder, but the code that handles round objects is labeled #cylinderd. They never match, so all three bounce balls like boxes.
The invisible balloon
The yard and school have hot-air balloons drifting overhead, in pairs that shouldn't match colors. When they would match, the code meant to pick balloon 5 but typed 53. There is no balloon 53, so that balloon is invisible until it drifts off-screen and comes back as a new one. Elsewhere, the same fix correctly uses 5.
if bobjectn[1 + (x * 2)] = bobjectn[2 + (x * 2)] then bobjectn[2 + (x * 2)] = 53 end if
The CPU fielder chases a ghost
When the computer fields a grounder that has bounced, it feeds the ball's position into the wrong conversion: screen-to-world instead of world-to-screen. It then reads two values that conversion never fills in. So after a grounder bounces, the CPU fielder keeps running to where it first predicted the ball would go.
Leftovers and secrets
An aim-assist that never shipped
Every pitch, the game measures how far your throw missed the spot you clicked and saves a correction for that batter, moving 70% of the way toward your error. The only code that reads those corrections loads them into two variables and never uses them. It's an unfinished feature: the game studies every pitch you throw and does nothing with what it learns.
Secret keys
N jumps back to the character select at any time. That one works in the shipped game.
Hold Ctrl and tap V to show the build number. The preserved copy is V0802.2.
There's a pause key that can never work. It's written as the two-character string "pp", which a single keypress never equals. It also requires a #play state and an "ending" frame label, and neither exists.
About thirty developer keys are still in the code, switched off by debug = 0. They switch camera views, swap fields and players, and run pitch and cannon test rigs.
More from the files
Code names. The select screen's timeline is organized by labels named Stuart, Homer, Robin and Justin. Those are Kent, Tom, Amanda and Todd.
Borrowed code. The loading screen still tries to show "spacenation boy small" and two instruction images left over from another game's loader. They don't exist here, so nothing appears.
Anti-cheat. Scores sat in memory as the real value plus a random 5,001–10,000, to defeat memory editors. The select screen also refuses to run (ERROR 21 / ERROR 22) unless the web page hosting it hands over a copyright string. That was Candystand's protection against other sites hot-linking its games.
The sound board. Every sound effect lives in a small Flash movie hidden inside the Shockwave file. The game plays a sound by jumping that movie to a frame, and ten volume levels exist as separate frames. One car-alarm sound points one frame early and plays a beat late.
House rules (these are on purpose)
| Outs per half-inning | 2 (the help screen says so) |
| Fair territory | 26.6° either side of center (|x / z| > 0.5 is foul), a 53° wedge instead of baseball's 90° |
| Hit type | Decided only by distance: under 23 ft single, under 47 double, under 72 triple, 72+ home run |
| Bounced pitch | Always a ball |
| Fielders | One: the pitcher fields everything |
| Speed | 15 frames per second, with all physics per frame. On a slow 2001 PC, the game ran in slow motion. |