Same brief, two builds — difference catalog

The identical metroidvania prompt, run by Fable 5 · Lumen Vale and Opus 4.8 · Sunken Atrium. Every concrete difference pulled out of the source, side by side. Raw numbers, no scoring.
Method note. This page lists facts, not verdicts — what each codebase actually does, so the differences can be read directly instead of through an Opus-shaped interpretation of "what Fable is doing well." Two would-be differences were checked and thrown out: both games use the same fixed 1/120 s timestep, and both set imageSmoothingEnabled = true. The physics constants also can't be compared raw — see §1.
0

What's identical (checked, ruled out)

Before the differences: the two builds share a large, near-parallel feature set. These were verified the same in both, so they are not where the divergence lives.
fixed timestep · both 1/120 s accumulator imageSmoothing · both ON scene machine · title→play→dead→win, both coyote time · both jump buffer · both variable jump height · both double-jump gated by pickup · both AABB axis-separated collision · both camera lerp + room clamp · both i-frame blink · both knockback on hit · both screen shake · both damage flash · both fade transitions · both squash & stretch · both grounding shadow · both vignette · both parallax background · both ambient dust motes · both HUD hearts + ability slot · both animated gate hint · both animated exit portal · both pickup halo + bob · both chroma-key + despill + auto-trim pipeline · both procedural sprite fallbacks · both e2e debug hook on window · both
A

Architecture at a glance

What came out, the module graphs, and where each model's file-edits actually landed. Counts verified from source and the build logs (the earlier version of this block had eyeballed numbers — particles are 16/9 not 22/12, and Opus has more draw methods, not fewer — corrected here).
Fable — Lumen Vale
FABLE · Lumen Vale. animated lantern-knight, 3-step platform climb, glowing pickup, layered foreground
Opus — Sunken Atrium
OPUS · Sunken Atrium. same-quality gpt-image backdrop, idle-only hero, two-tier floor + cliff, no sound

module graphs

Fable · 7 files · 1332 LOC

one fat core + separate audio
main.ts 57L
boot · canvas · fixed-step loop
game.ts 783L
Player + Enemy + Game all in one file — physics, camera, draw, gate, HUD
↓ imports ↓
particles.ts 126L
16 emit calls · circles
sfx.ts 71LAUDIO ✓
WebAudio, 7 sounds
assets.ts 210L
idle + run sprites
input.ts 44L
keys
level.ts 41L
room geometry (flat consts)

Opus · 9 files · 1525 LOC

more modules, entities split out
main.ts 36L
boot (leaner)
game.ts 648L
loop, 13 render/draw methods, gate, HUD, screens
↓ depends on ↓
entities.ts 349L
Player + Enemy pulled into their own module (idle-only sprite, no run pose)
config.ts 42L
extracted constants
util.ts 29L
aabb · clamp · approach
particles.ts 136L
9 emit calls · squares
assets.ts 142L
idle sprite only
level.ts 59L AUDIO ✗
typed Level interface · no sfx module exists anywhere

where the edits landed — most-edited files (verified counts)

▮ Fable▮ Opus
game logic
game.ts ×9
entities ×5 + game ×4
test / QA harness
~0 (ran via bash)
game.spec ×6 + qa-shot ×4
level / content
×2
×3
juice (particles)
×2
0 in top edits
config / shell
×5 (vite,style,index,input)
×6 (config,index,main)
Verified side-facts: visual QA was comparable (Fable 16 image-reads / 12 playwright runs · Opus 14 / 9). Narration volume was not — Fable 2,313 chars across 9 text blocks, Opus 9,994 across 46 (4×). Opus's single most-edited file was its test (game.spec.ts, 6 edits); Fable's was its game (game.ts, 9 edits).
1

Coordinate space & resolution

The single most important fact for reading everything below: the two games run in different-size pixel worlds. Every constant in §2 lives inside one of these spaces, so raw number-vs-number comparisons are not apples-to-apples.

Fable · Lumen Vale

  • internal canvas 960 × 540
  • room width 1440 (1.5× viewport)
  • ground line at y = 470
  • pixel area 518,400 px

Opus · Sunken Atrium

  • internal canvas 480 × 270
  • room width 720 (1.5× viewport)
  • low floor y = 236 · plateau y = 150
  • pixel area 129,600 px (¼ of Fable)
2

Physics constants

Raw values as written, then a normalized column that removes the resolution gap (Opus distances ×2 into Fable's 960-wide space, or expressed as a fraction of each game's own screen). Read the normalized column for feel.
constantFableOpusnormalized / note
GRAVITY22001000Opus ×2 ≈ 2000 in Fable-space
MOVE_SPEED340132Opus ×2 = 264 in Fable-space
ACCEL (ground)30001400Opus ×2 = 2800
ACCEL (air)2200900Opus ×2 = 1800
JUMP impulse760340Opus ×2 = 680
DOUBLE-JUMP impulse800330see note ▼
MAX_FALL950540Opus ×2 = 1080
COYOTE time0.10s0.08ssame units (seconds)
JUMP_BUFFER0.12s0.10ssame units (seconds)
var-jump clip (release)vy→−220vy→−120Opus ×2 = −240
cross-room walk time4.2s5.5sroom width ÷ move speed
jump rise (% screen H)≈24%≈21%single-jump apex ÷ view height
Source: metroidvania/src/game.ts:17–25 · opus-metroid/src/config.ts:13–21

Fable — two facts in the numbers

  • double > single: 800 vs 760 — air jump is +5% stronger than the ground jump.
  • comments cite level geometry — the jump constant is annotated against the gate's actual pixel height:
JUMP_V = 760; // max rise ≈131px → cannot clear the 210px gate DOUBLE_JUMP_V = 800; // chained near apex ≈276px → clears the gate with grace

Opus — two facts in the numbers

  • double < single: 330 vs 340 — air jump is −3% weaker than the ground jump.
  • comments are generic — constants annotated by role, not by level measurement:
JUMP_VELOCITY = -340; // first jump impulse DOUBLE_JUMP_VELOCITY = -330; // air jump impulse
3

Player animation

Both apply procedural squash/stretch, facing flip and a run-bob. The difference is whether there are drawn animation frames underneath that.

Fable · 2 sprite sheets

  • player-idle.png + player-run.png
  • frame-swaps idle⇄run while moving, every ~0.11s; uses run sheet whenever airborne
  • + procedural squash on land, vy-based air-stretch, run-bob sin(t·11)
  • contact shadow only near floor (y > GROUND−60)

Opus · 1 sprite sheet

  • player-idle.png only — no run sheet
  • animation is fully procedural: facing mirror, runPhase bob, squash/stretch
  • squash tuned per event: jump 0.8/1.28, double 0.7/1.35, land impact-scaled
  • death adds a ragdoll rotate(0.4) + sink
4

Audio

A clean binary difference.

Fable · WebAudio synth

  • dedicated sfx.ts (71 lines), 10 audio refs
  • 7 sounds: jump · doubleJump · land · hurt · die · pickup (3-note arpeggio) · win (C-E-G fanfare)
  • per-sound oscillator type, frequency ramp, gain envelope
  • unlocks on first key gesture; guarded for headless

Opus · silent

  • 0 AudioContext / oscillator references anywhere in src
  • no sfx module exists
  • the game ships with no sound
5

Enemy

Both are a single horizontal-patrol crawler with no chase AI. Sizing and detail differ.

Fable

  • hitbox 46 × 38
  • patrol speed 70
  • bob + body lean while walking
  • dust puff emitted at each turn
  • sprite enemy.png

Opus

  • hitbox 26 × 18
  • patrol speed 42
  • leg-wiggle sin animation
  • hitbox inset 3–6px for "fair" contact
  • sprite enemy-crawler.png
6

Level topology

Same beat ("can't pass the gate until you find the wings"), built with different geometry. This is a structural difference, not a count difference.

Fable · flat floor + wall

  • one flat floor spanning the whole room
  • 3 floating jump-through platforms stepping up-LEFT to a high pickup
  • enemy patrols the floor in front of a 210px solid WALL (the gate)
  • you jump OVER the wall (needs double-jump), exit portal far right at floor level
  • 5 collider records · 4 standable surfaces

Opus · two-tier floor + cliff

  • two floor heights: low-left y236 (spawn+enemy+pickup), raised plateau-right y150 (exit)
  • the gate is the 86px CLIFF step between them — you climb UP, not over
  • 1 small perch ledge on the low side
  • 2 invisible side walls to bound the room
  • 5 collider records · 3 standable surfaces
7

Terrain rendering

Both build their ground from the slab/ground art; the strategy differs.

Fable · prerendered once

  • builds a full static ROOM canvas at startup (prerenderWorld)
  • tiles ground.png chunks, mirroring every other tile
  • stretches platform.png slabs onto the jump-throughs
  • bakes a gradient grounding-shadow below the floor line
  • each frame: one drawImage of the baked canvas

Opus · procedural per frame

  • redraws terrain every frame (no prerender)
  • each platform = gradient-filled stone rect (#1a3b44→void)
  • + clipped platform-slab.png "mossy cap" tiled across the top
  • + a 1px lichen highlight line on the top edge
  • uses two separate art pieces (ground + platform) collapsed into one (slab)
8

Particle system

Same idea, different primitive and different emitter set.

Fable · particles.ts (126L)

  • draws circles (ctx.arc)
  • glow via shadowBlur
  • 5 emitters: dust · burst · sparkle · ring · generic spawn
  • dedicated double-jump ring (12-point expanding circle)
  • list uncapped

Opus · particles.ts (136L)

  • draws squares (ctx.fillRect)
  • glow via additive "lighter" composite
  • 4 emitters: dust · burst · sparkle · mote
  • ambient mote drift system (atmosphere)
  • list capped at 600 (120 for ambient)
9

Glow / lighting technique

How each makes things "shine" — a different canvas API choice that shows up everywhere (gate seam, pickup halo, exit portal, particles).

Fable

  • shadowBlur12 uses across the file
  • soft blurred bloom around sprites, text, pickup, particles
  • 0 uses of additive composite

Opus

  • additive globalCompositeOperation="lighter"5 uses
  • gate seam, pickup halo, exit-portal glow, particles all additive
  • + 4 shadowBlur uses (title/death/win text)
10

Generated assets

What art each asked gpt-image-2 to produce.

Fable · 7 sprites

  • player-idle · player-run · enemy · pickup · platform · ground · background
  • separate ground + platform art
  • platform pre-cropped to top 70% (art had a 2nd slab in-frame)
  • title screen reuses background.png

Opus · 6 sprites

  • player-idle · enemy-crawler · pickup-wings · platform-slab · background · title-art
  • no run sheet; one collapsed platform-slab for all terrain
  • dedicated title-art.png for the menu
  • both chroma pipelines: despill + auto-trim, slightly different thresholds
11

Code structure & strings

File layout and a few literal-string artifacts.

Fable · 7 files · 1332 LOC

  • monolithic game.ts (783L) holds Player + Enemy + Game + helpers
  • separate sfx.ts; flat exported consts (VIEW/ROOM/SOLIDS…)
  • debug hook window.__game {state, teleport}
  • HUD font Georgia serif; banner system ("AETHER WINGS")
  • title: "LUMEN VALE / a tiny metroidvania", 72px serif

Opus · 9 files · 1525 LOC

  • split entities.ts (349L) + config.ts + util.ts + typed level.ts
  • no audio file; Level as a typed interface
  • debug hook window.__metroid {…getters, startGame}
  • HUD font ui-monospace; room-name + controls-hint timer + ability chip
  • title: "SUNKEN ATRIUM", 40px mono; subtitle literally reads "built with Fable"
12

From the build logs — two specific "why"s

Pulled from the stream-json build logs (tool calls + the model's own narration) for each run. Facts, with the verbatim lines that source them.

Q1 · why no run sprite?

The decision was made in the asset manifest — a planning artifact written before any game code — not in the renderer and not during QA.

Fable · planned 2 poses

  • gen-sprites.ts lists player-idle and player-run
  • run prompt: "mid-stride running pose facing right, cape trailing, one leg forward one back, leaning into the run"
  • the words run-cycle / walk-cycle / sprite-sheet / animation appear 19× in its log

Opus · planned 1 pose

  • gen-sprites.mjs lists 6 sprites, player-idle only ("standing idle facing right")
  • hero framed from the start as "chroma-keyed, mirrored + squash/stretch by code"
  • those same animation words appear in its entire log — the option was never raised, not raised-and-rejected

Q2 · why the simpler level?

Both did comparable visual QA (Fable 16 image-reads / 12 playwright runs; Opus 14 / 9). The difference is what each scoped the level as and what its edits optimized toward.

Fable · built it, barely narrated it

  • total narration: 2,313 chars / 9 blocks
  • shipped a 3-step vertical climb of jump-through platforms on the first pass
  • tuned the jump arc against measured geometry (code comment: "max rise ≈131px → cannot clear the 210px gate")
  • one design line in the whole log: "got the whole vertical on the first sprite batch"

Opus · scoped it as a mechanic demo

  • total narration: 9,994 chars / 46 blocks (4× Fable)
  • scoped the room as "one screen… with a high plateau the player can only mount via double-jump" — one mechanic to show
  • its level edit optimized for test determinism, verbatim: "a cleaner design: move the enemy's patrol away from the pickup so the player can stand under the wings and jump straight up to grab them" → traversal got simpler
  • spent QA effort on its own test harness (found "keyboard.press releases instantly, so every jump was getting clipped to a 7px hop") and on confirming gate-blocks / wings-unblock / state: win
Two observations (for you to weigh, not conclusions): (1) the run-sprite gap lived in a checklist, not in skill — an asset manifest that mandated "idle + run for any walking character" would close it deterministically. (2) Opus's level bent toward whatever its success signal was — a green, deterministic e2e — so the layout simplified to make the grab repeatable. The verification target shaped the artifact.