Movie Mogul 1.1.0 — Bugs, Budget Overruns, and 40-Year-Old BASIC

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:

VersionCalculationPay
Wrongint((3.5 + 5) × 180) = int(1530)$1,530K
Correctint(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.

The test that locked this in:

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):

ConditionOutcomeProbability
x ≥ 70On budget31%
30 ≤ x < 70+2% overrun40%
15 ≤ x < 30+5% overrun15%
7 ≤ x < 15+10% overrun8%
3 ≤ x < 7+20% overrun4%
x < 3+30% overrun2%

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:

  1. 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.

  2. The on-budget threshold is roll < 30 instead of roll < 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])

IndexNameWhere it’s used
stats[0](unused)Never read by game logic.
stats[1]Star powerPay formula seed (÷2), Oscar award threshold, Best Picture score
stats[2]Pay additivePay formula (added to stats[1]/2 before multiplying)
stats[3]Dramatic rangeCasting fit penalty (bq) if below role requirement
stats[4]Comedic abilityCasting fit penalty
stats[5]Charm / sex appealCasting fit penalty; also Oscar movie eligibility check
stats[6]Action / physicalityCasting fit penalty

Role Requirements (requirements[0..7])

IndexNameWhere it’s used
requirements[0]Gender restriction1=male, 5=either, 9=female — enforced at casting
requirements[1](unused)Populated from seq file, never read
requirements[2]Role prestigeContributes to aq (compounding quality score); Oscar threshold
requirements[3]Role qualityAlso contributes to aq
requirements[4..7]Skill requirementsMatched 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.prg behavior 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.

Play the game