Movie Mogul 1.1.0 — Bugs, Budget Overruns, and 40-Year-Old BASIC
Version 1.1.0 of the Movie Mogul web port is out. It adds a few missing features, but the more interesting story is what I found while systematically cross-referencing the TypeScript implementation against the original Commodore 64 BASIC source. Two bugs had been quietly wrong since the initial port — one in the pay formula, one in the budget overrun probabilities. Both are subtle enough that the game felt correct, but the math was off. It’s possible these were errors in the port to C=64 or in the original code.
The C64 Source as Ground Truth
The original game lives in [c64/movie mogul.prg](https://github.com/mcornell/movie-mogul/blob/main /c64/movie%20mogul.prg) — a petcat-decoded BASIC
listing extracted from the D64 disk image. This is the authoritative source
for every formula. When something in the TypeScript looked suspicious, the
process was: find the corresponding BASIC lines, decode the logic exactly,
then write a failing unit test before touching any code.
One of the things I used Claude Code for, was to reimplement my original psuedocode as an annotated version of the original source. We’ll use that in this disucssion because it will be much easier to follow.
Bug 1: Pay Formula Operator Precedence
The BASIC
The salary calculation is at line 3800:
3790 x = int(rnd(1) * 300) + 31
3800 py(i) = int((an%(s(i),3) / 2) + an%(s(i),4)) * x
3802 if py(i) < 100 then py(i) = py(i) + 100
an%(actor, 3) maps to what I think is star power (stats[1]).
an%(actor, 4) is the pay additive (stats[2]). x is a random
multiplier from 31 to 330.
The critical detail is where INT() closes. It wraps only the base
expression — (stats[1] / 2) + stats[2] — and the multiplication by x
happens outside that truncation. In BASIC, INT() always truncates toward
negative infinity (equivalent to Math.floor for positive numbers).
What the Port Had
The original src/game/gameEngine.ts had:
const x = int(rng() * 300) + 31;
let py = int((actor.stats[1] / 2 + actor.stats[2]) * x); // ← wrong
This truncates the entire product. For actors with even stats[1], the
difference is zero — stats[1] / 2 is already an integer, so truncating
before or after multiplying gives the same result. But for any actor with
an odd stats[1], the base has a fractional .5 that should be
truncated away before scaling.
The Fix
The corrected line:
const x = int(rng() * 300) + 31;
let py = int(actor.stats[1] / 2 + actor.stats[2]) * x; // ← correct
What It Changes
Take Marlon Brando: stats[1] = 7, stats[2] = 5. At a mid-range
multiplier of x = 180:
| Version | Calculation | Pay |
|---|---|---|
| Wrong | int((3.5 + 5) × 180) = int(1530) | $1,530K |
| Correct | int(3.5 + 5) × 180 = 8 × 180 | $1,440K |
That’s a $90,000 difference on one salary. Across a full casting pool, the
wrong formula inflates pay for roughly half of all male actors — everyone
with an odd stats[1]. The affected actors include Brando, Tom Hanks,
Harrison Ford, Sean Connery, and Arnold Schwarzenegger, among others.
it('truncates the base before multiplying — critical with odd stats[1]', () => {
// stats[1]=5 (odd): int(5/2 + 6) = int(8.5) = 8
// x=31 → py = 8 * 31 = 248 (not int(8.5 * 31) = int(263.5) = 263)
const oddActor: Actor = { ...actor, stats: [1, 5, 6, 3, 5, 7, 5] };
const pay = calculatePay(oddActor, seqRng(0.0));
expect(pay).toBe(248);
});
Bug 2: Budget Overrun — Missing Tier and Wrong Threshold
The BASIC
After production events resolve, lines 1580–1650 oll a random number to determine cost overruns:
1580 x = int(rnd(1) * 100) + 1
1590 if x >= 70 then print "The movie comes in on budget." : goto 1650
1600 if x >= 30 then print "The production went 2% over budget." : mm = mm + int(mm * .02)
1602 if x >= 30 then 1650
1610 if x >= 15 then print "The production went 5% over budget." : mm = mm + int(mm * .05)
1612 if x >= 15 then 1650 :rem dead code — already handled by fall-through above
1620 if x >= 7 then print "The production went 10% over budget." : mm = mm + int(mm * .1)
1622 if x >= 15 then 1650 :rem dead code
1630 if x >= 3 then print "The production went 20% over budget." : mm = mm + int(mm * .2)
1632 if x >= 15 then 1650 :rem dead code
1640 print "The production went 30% over budget." : mm = mm + int(mm * .3)
1650 ct = ct + mm
Lines 1612, 1622, and 1632 are dead code — their conditions can never be true at those points in execution. But the live logic defines six distinct tiers with these probabilities (x is 1–100):
| Condition | Outcome | Probability |
|---|---|---|
| x ≥ 70 | On budget | 31% |
| 30 ≤ x < 70 | +2% overrun | 40% |
| 15 ≤ x < 30 | +5% overrun | 15% |
| 7 ≤ x < 15 | +10% overrun | 8% |
| 3 ≤ x < 7 | +20% overrun | 4% |
| x < 3 | +30% overrun | 2% |
What the Port Had
The original src/game/phaseHelpers.ts had:
// roll is Math.trunc(Math.random() * 100) → range 0–99
if (roll < 3) return { text: '...30% over budget.', overrun: trunc(budget * 0.30) };
if (roll < 7) return { text: '...20% over budget.', overrun: trunc(budget * 0.20) };
if (roll < 15) return { text: '...10% over budget.', overrun: trunc(budget * 0.10) };
if (roll < 30) return { text: '...2% over budget.', overrun: trunc(budget * 0.02) };
return { text: '...on budget.', overrun: 0 };
Two problems:
-
The 5% tier (BASIC line 1610) is completely absent. Rolls that should land in the 5% bucket (x = 15–29) were hitting the 2% branch instead.
-
The on-budget threshold is
roll < 30instead ofroll < 70. This made the film come in on budget 70% of the time rather than the correct 31%, making overruns far too rare.
The Fix
The corrected budgetOverrun function:
if (roll < 3) return { text: '...30% over budget.', overrun: trunc(budget * 0.30) };
if (roll < 7) return { text: '...20% over budget.', overrun: trunc(budget * 0.20) };
if (roll < 15) return { text: '...10% over budget.', overrun: trunc(budget * 0.10) };
if (roll < 30) return { text: '...5% over budget.', overrun: trunc(budget * 0.05) };
if (roll < 70) return { text: '...2% over budget.', overrun: trunc(budget * 0.02) };
return { text: '...on budget.', overrun: 0 };
The combined effect of both bugs meant budget overruns were significantly underrepresented and salaries for about half the cast were slightly overstated. Neither bug was dramatic enough to make the game feel broken, which is probably why they survived the initial port.
Understanding the Data
One byproduct of this was finally decoding what all the numeric fields
in the actor and role data actually represent. The original code never
names them — they’re just array indices in BASIC — but tracing each index
through the pay formula, the box office quality score (mq), and the Oscar
eligibility helps to clarify things. The full reference is in
docs/data-reference.md.
To be clear, this is just a guess on what the actor stats and role requirements are.
This is something else I asked Claude Code to do…look at actors and roles and infer what the stats
might be. Given this is based on the world in 1985, you’re going to see some things that, don’t make
as much sense 40 years later.
Actor Stats (stats[0..6])
| Index | Name | Where it’s used |
|---|---|---|
stats[0] | (unused) | Never read by game logic. |
stats[1] | Star power | Pay formula seed (÷2), Oscar award threshold, Best Picture score |
stats[2] | Pay additive | Pay formula (added to stats[1]/2 before multiplying) |
stats[3] | Dramatic range | Casting fit penalty (bq) if below role requirement |
stats[4] | Comedic ability | Casting fit penalty |
stats[5] | Charm / sex appeal | Casting fit penalty; also Oscar movie eligibility check |
stats[6] | Action / physicality | Casting fit penalty |
Role Requirements (requirements[0..7])
| Index | Name | Where it’s used |
|---|---|---|
requirements[0] | Gender restriction | 1=male, 5=either, 9=female — enforced at casting |
requirements[1] | (unused) | Populated from seq file, never read |
requirements[2] | Role prestige | Contributes to aq (compounding quality score); Oscar threshold |
requirements[3] | Role quality | Also contributes to aq |
requirements[4..7] | Skill requirements | Matched 1:1 against stats[3..6] in the bq penalty loop |
The Box Office Quality Formula
The master quality score that drives weekly box office revenue:
aq = 0
for each role:
aq = int((aq + role.prestige + role.quality) * 1.10) // compounds at 10% per role
bq = 0
for si in [2..7]: // requirement indices
for each cast member:
if actor.stats[si-1] < role.requirements[si]:
bq += actor.stats[si-1] - role.requirements[si] // negative — penalty
cq = max(reviewScore, -1) * 90 + 50 // critic score contribution
dq = int(budget / 100) // $1 per $100K of budget
mq = 38 * (aq + bq) + cq + dq
bq is zero or negative. Every point of mismatch between an actor’s skills
and the role’s demands directly reduces mq — and mq is multiplied by 38,
so miscasting compounds hard.
Arnold Schwarzenegger’s numbers ([4, 3, 7, 3, 5, 1, 9]) are a great example
of this system working as designed: low star power (cheap-ish) and essentially
no charm, but maxed-out action. Casting him in an action role with high
requirements[7] results in bq = 0 for that stat. Casting him in a
romantic drama would crater your box office.
What Else is in 1.1.0
- Help screen — press H at the title to page through the original
LoadStar manual text, verbatim from
t.movie mogul.prg - High score reset — press R on the leaderboard; Y/N confirmation, then restores the blank “No Movie-A / boA / $0” placeholder rows
- Duplicate cast error — “That actor is already cast in another role.” instead of a generic “Invalid selection.”
- Default leaderboard entries — blank placeholder rows on first load,
matching
reset mm.scores.prgbehavior from the C64 - Arnold Schwarzenegger’s name — stored in full in the data file; the runtime name-concatenation special-case is gone
Full details are in the CHANGELOG and the new data reference doc.