Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 21 additions & 1 deletion docs/PERFORMANCE-HANDOFF.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Performance investigation — OPEN

Updated 2026-09-10. **Do not treat the Godot performance problem as resolved.**
Updated 2026-09-11. **Do not treat the Godot performance problem as resolved.**

The development laptop experienced severe system-wide slowdowns, initially
near the first fab and later near the second. The user still reported excessive
Expand Down Expand Up @@ -68,3 +68,23 @@ workflow are tracked; generated engine binaries and web exports are not.
The games have separate browser-local saves. Changing browser, machine, or
origin does not transfer progress automatically. Git transfers the project,
not a running game's browser storage.


## Cross-device report and graphics controls — 2026-09-11

The user confirms remaining slowdown as the factory scales on both the development
laptop and a phone, without a consistent two-fab threshold. The earlier smoother
hosted run did not establish a fix. Browser versions and phone model are unknown.

The lost edge smoothing corresponds to disabling MSAA during the earlier
performance investigation. New graphics controls expose off/2×/4× MSAA,
1280×800 / 1440×900 / 1920×1200 resolution caps, and shadows. Defaults retain the
previous rendering budget. Preferences are independent of simulation saves.

An opt-in, bounded 60-sample local JSON recording reports actual settings, fab
count, frame intervals, rendering counters and node count. Optional JS heap is
approximate and excludes total browser/GPU/system memory. Use identical camera,
resolution and sound when comparing fab counts; then vary AA and shadows
separately. CI software rendering can validate controls and bounded allocations,
but cannot establish performance on the affected laptop or phone. No local game,
browser or server should be launched to test these changes.
24 changes: 22 additions & 2 deletions godot/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ are retained. On other platforms, install the matching editor and set
| New Run | Confirm and reset this prototype's save |

Touch users can use the action dock and drag the scene. Each etch consumes one
wafer and pays $100 when finished. Twelve chips finance the first fab. Reserve
wafer and pays $100 when finished. New runs start with $500; seven chips finance the first fab. Existing saves retain their balance. Reserve
$600 for a shipment of 30 wafers. If you run out of both silicon and money,
procurement offers three reclaimed wafers so the run cannot become stranded.

Expand All @@ -73,7 +73,7 @@ purchasing and production reuse those objects. Machine monitors show cycle state
instead of rebuilding percentage text throughout every cycle.

Rendering is capped at 30 fps and at a 1440 × 900 internal viewport (preserving
aspect ratio on smaller or portrait screens). MSAA and the full-screen mipmap
aspect ratio on smaller or portrait screens). By default MSAA and the full-screen mipmap
bloom pass are disabled to reduce GPU load. This trades some edge sharpness and
glow for a lower rendering budget on laptops. The simulation still advances by
elapsed time; the cap does not halve production speed.
Expand Down Expand Up @@ -167,3 +167,23 @@ The performance investigation remains open.
Version 2 saves keep the original `the-seed-v1.json` location and accept version
1 factory saves. The browser regression fixture is enabled only by
`?test=1&scenario=chorus`; normal play exposes no state setter.


## Graphics and performance recording

Open **Graphics / controls** below the game to select off, 2× or 4× MSAA,
change the internal resolution cap, or toggle shadows. These preferences persist
separately from your run. Defaults retain the previous budget: AA off, 1440 × 900
maximum, shadows on, 30 fps. Try 2× MSAA for smoother geometry; higher resolution
and 4× MSAA cost more GPU work. Bloom remains disabled.

The same panel can record up to 60 one-second samples while you play, then
download a local JSON report. It includes fab count, settings, frame intervals,
draw calls, rendered primitives, node count, and approximate JavaScript heap
where the browser exposes it. Frame intervals include the frame limiter;
JavaScript heap is **not** total browser, GPU, or system memory. Recording stops
when the tab is hidden. Nothing is uploaded. The scaling problem remains open.

Remote browser checks use `?test=1&scenario=scaling` (one funded, muted fab),
then real build controls to compare two and six fabs and record AA/shadow settings.
This fixture is available only in test mode; it does not replace normal saves.
57 changes: 55 additions & 2 deletions godot/scripts/factory.gd
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,11 @@ var selected_ring: MeshInstance3D
var debug_timer: float = 0.0
var fps_samples: int = 0
var reset_pending: bool = false
var render_cap: int = 1440
var graphics_timer: float = 0.0
var sample_time: float = 0.0
var sample_intervals: Array[float] = []
var was_recording: bool = false

func _ready() -> void:
Engine.max_fps=30
Expand All @@ -60,6 +65,8 @@ func _ready() -> void:
sim.restore({"version":1, "capital":60000, "wafers":300, "chips":1200, "fabs":6, "overclock":true, "controller":true, "linked":true, "sound_enabled":false})
if test_mode and OS.has_feature("web") and str(JavaScriptBridge.get_interface("window").location.search).contains("scenario=firstlight"):
sim.restore({"version":2, "capital":30000, "wafers":300, "chips":2000, "fabs":6, "overclock":true, "controller":true, "linked":true, "sound_enabled":false, "places":[7,1,0], "signals":1000, "resonance":1500.0, "charter":0})
if test_mode and OS.has_feature("web") and str(JavaScriptBridge.get_interface("window").location.search).contains("scenario=scaling"):
sim.restore({"version":1, "capital":60000, "wafers":300, "chips":0, "fabs":1, "sound_enabled":false})
_create_environment()
room=Chamber.new()
add_child(room)
Expand All @@ -80,6 +87,7 @@ func _ready() -> void:
camera_focus=sim.fabs==0
_create_camera()
_create_audio()
_update_graphics(1.0)
ui=Interface.new()
add_child(ui)
ui.action_requested.connect(_action)
Expand Down Expand Up @@ -141,11 +149,53 @@ func _limit_render_size() -> void:
if window.size==last_window_size:return
last_window_size=window.size
var physical:=Vector2(window.size)
var factor:=minf(1.0,minf(1440.0/maxf(1,physical.x),900.0/maxf(1,physical.y)))
var factor:=minf(1.0,minf(float(render_cap)/maxf(1,physical.x),float(render_cap)*0.625/maxf(1,physical.y)))
var target:=Vector2i((physical*factor).round())
window.content_scale_mode=Window.CONTENT_SCALE_MODE_VIEWPORT
if window.content_scale_size!=target:window.content_scale_size=target

func _update_graphics(delta: float) -> void:
if not OS.has_feature("web"):return
var browser_window = JavaScriptBridge.get_interface("window")
var settings = browser_window.seedGraphics
if settings == null:return
graphics_timer += delta
if graphics_timer >= 0.5:
graphics_timer = 0.0
var aa: int = clampi(int(settings.aa), 0, 2)
if int(get_viewport().msaa_3d) != aa:get_viewport().msaa_3d = aa as Viewport.MSAA
var cap: int = int(settings.cap)
if cap in [1280, 1440, 1920] and render_cap != cap:
render_cap = cap
last_window_size = Vector2i.ZERO
if key_light.shadow_enabled != bool(settings.shadows):key_light.shadow_enabled = bool(settings.shadows)
var recording: bool = bool(settings.recording)
if recording and not was_recording:
sample_time = 0.0
sample_intervals.clear()
was_recording = recording
if not recording:return
sample_time += delta
# Bound recording storage even if the frame cap is changed later.
if sample_intervals.size() < 240:sample_intervals.append(delta * 1000.0)
if sample_time < 1.0:return
sample_intervals.sort()
var sample: Dictionary = {
"fps": Engine.get_frames_per_second(), "window_seconds": sample_time,
"frame_interval_p95_ms": sample_intervals[mini(sample_intervals.size()-1, floori(sample_intervals.size()*0.95))],
"frame_interval_max_ms": sample_intervals.back(),
"render_width": get_viewport().get_visible_rect().size.x,
"render_height": get_viewport().get_visible_rect().size.y,
"msaa": int(get_viewport().msaa_3d), "shadows": key_light.shadow_enabled,
"fabs": sim.fabs, "chips": sim.chips, "sound_enabled": sim.sound_enabled,
"atlas": atlas, "nodes": get_tree().get_node_count(),
"draw_calls": Performance.get_monitor(Performance.RENDER_TOTAL_DRAW_CALLS_IN_FRAME),
"rendered_primitives": Performance.get_monitor(Performance.RENDER_TOTAL_PRIMITIVES_IN_FRAME),
}
browser_window.seedReceivePerformance(JavaScriptBridge.get_interface("JSON").parse(JSON.stringify(sample)))
sample_time = 0.0
sample_intervals.clear()

func _create_audio() -> void:
for id in ["etch","chip","build","supply","uplink"]:
sounds[id]=load("res://assets/audio/"+id+".wav")
Expand All @@ -170,6 +220,7 @@ func _sound(id: String, volume: float=-15.0) -> void:
voice.play()

func _process(delta: float) -> void:
_update_graphics(delta)
_limit_render_size()
# A short fixed step keeps production deterministic across render frame rates.
# Browser tab suspension does not mint unobserved chips in this prototype.
Expand Down Expand Up @@ -210,6 +261,8 @@ func _process(delta: float) -> void:
snapshot["nodes"]=get_tree().get_node_count()
snapshot["render_width"]=get_viewport().get_visible_rect().size.x
snapshot["render_height"]=get_viewport().get_visible_rect().size.y
snapshot["msaa"]=get_viewport().msaa_3d
snapshot["shadows"]=key_light.shadow_enabled
snapshot["atlas"]=atlas
snapshot["cinema"]=ui.focus_mode
snapshot["camera_focus"]=camera_focus
Expand Down Expand Up @@ -292,7 +345,7 @@ func _handle_events() -> void:
_sound("chip",-23 if index>=0 else -16)
production_chime=0.18
if sim.chips==1:ui.notify("First chip shipped. $100. A very small beginning.",true)
elif sim.chips==12 and sim.fabs==0:ui.notify("Your first fab is ready to fund. Press B or choose Install a fab.",true)
elif sim.chips==7 and sim.fabs==0:ui.notify("Your first fab is ready to fund. Press B or choose Install a fab.",true)
"build":
_add_machine(int(event.machine))
camera_focus=false
Expand Down
4 changes: 2 additions & 2 deletions godot/scripts/interface.gd
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ func _ready() -> void:
chapter_box.add_child(_label("O B J E C T I V E / 0 1",10,GOLD))
chapter_title=_label("Build the machine\nthat builds the machine.",22,WHITE)
chapter_box.add_child(chapter_title)
chapter_note=_label("Etch 12 chips. Sales are automatic.\nThen install your first autonomous fab.",12,MUTED)
chapter_note=_label("Etch 7 chips. Sales are automatic.\nThen install your first autonomous fab.",12,MUTED)
chapter_note.autowrap_mode=TextServer.AUTOWRAP_WORD_SMART
chapter_box.add_child(chapter_note)
var sep:=HSeparator.new()
Expand All @@ -121,7 +121,7 @@ func _ready() -> void:
fill.content_margin_bottom=0
objective_progress.add_theme_stylebox_override("fill",fill)
chapter_box.add_child(objective_progress)
objective_numbers=_label("$0 / $1,200",11,MUTED)
objective_numbers=_label("$500 / $1,200",11,MUTED)
chapter_box.add_child(objective_numbers)
chapter_box.add_child(_label("S Y S T E M T R A N S M I S S I O N",9,MUTED))
journal=_label("The room is quiet.\nThat part is temporary.",13,WHITE)
Expand Down
2 changes: 1 addition & 1 deletion godot/scripts/simulation.gd
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ const OVERCLOCK_COST: int = 2400
const UPLINK_COST: int = 6000
const SAVE_VERSION: int = 2

var capital: int = 0
var capital: int = 500
var wafers: int = 60
var chips: int = 0
var fabs: int = 0
Expand Down
6 changes: 3 additions & 3 deletions godot/tests/test_simulation.gd
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,11 @@ func _initialize() -> void:
check(sim.wafers==59 and sim.chips==0,"Starting a cycle consumes one wafer but grants no output early")
check(not sim.etch(),"Manual cycle cannot be double queued")
advance(sim,1.0)
check(sim.chips==1 and sim.capital==100,"Completed fabrication sells one chip for $100")
for i in 11:
check(sim.chips==1 and sim.capital==600,"New runs start with $500 and a completed chip adds $100")
for i in 6:
sim.etch()
advance(sim,1)
check(sim.capital==1200,"Twelve manual chips finance the first machine")
check(sim.capital==1200,"Seven manual chips plus starting capital finance the first machine")
check(sim.build_fab() and sim.fabs==1 and sim.capital==0,"The first fab debits the displayed price")
var before: int=sim.chips
advance(sim,3.5)
Expand Down
66 changes: 65 additions & 1 deletion godot/web/boot.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,70 @@ const loading = document.getElementById('loading');
const status = document.getElementById('status');
const retry = document.getElementById('retry');
const controls = document.getElementById('controls');
// Preferences do not touch the simulation save or silently raise the GPU budget.
const graphicsKey = 'the-seed-graphics-v1';
let preferences = {};
try { preferences = JSON.parse(localStorage.getItem(graphicsKey) || '{}') || {}; } catch {}
window.seedGraphics = {
aa: [0, 1, 2].includes(preferences.aa) ? preferences.aa : 0,
cap: [1280, 1440, 1920].includes(preferences.cap) ? preferences.cap : 1440,
shadows: typeof preferences.shadows === 'boolean' ? preferences.shadows : true,
recording: false,
};
const aaControl = document.getElementById('graphics-aa');
const capControl = document.getElementById('graphics-cap');
const shadowControl = document.getElementById('graphics-shadows');
aaControl.value = String(window.seedGraphics.aa);
capControl.value = String(window.seedGraphics.cap);
shadowControl.checked = window.seedGraphics.shadows;
for (const control of [aaControl, capControl, shadowControl]) control.addEventListener('change', () => {
Object.assign(window.seedGraphics, { aa: Number(aaControl.value), cap: Number(capControl.value), shadows: shadowControl.checked });
try {
localStorage.setItem(graphicsKey, JSON.stringify({ aa: window.seedGraphics.aa, cap: window.seedGraphics.cap, shadows: window.seedGraphics.shadows }));
document.getElementById('graphics-storage').textContent = 'Settings saved.';
} catch { document.getElementById('graphics-storage').textContent = 'Storage unavailable: settings apply to this session only.'; }
});
const recordButton = document.getElementById('record-performance');
const downloadButton = document.getElementById('download-performance');
const recordingStatus = document.getElementById('performance-status');
let samples = [];
let recordingStarted = null;
function stopRecording() {
window.seedGraphics.recording = false;
recordButton.textContent = 'Record 60 seconds';
downloadButton.disabled = samples.length === 0;
recordingStatus.textContent = `${samples.length} samples recorded. Report stays on this machine.`;
}
recordButton.addEventListener('click', () => {
if (window.seedGraphics.recording) { stopRecording(); return; }
samples = [];
recordingStarted = new Date().toISOString();
window.seedGraphics.recording = true;
recordButton.textContent = 'Stop recording';
downloadButton.disabled = true;
recordingStatus.textContent = 'Recording while you play. You can close this panel.';
});
// Called once per measured second by Godot, only during an explicit recording.
window.seedReceivePerformance = sample => {
if (!window.seedGraphics.recording || samples.length >= 60) return;
const heap = performance.memory;
samples.push({ ...sample, js_heap_bytes_approx: heap ? heap.usedJSHeapSize : null });
recordingStatus.textContent = `${samples.length}/60 samples · ${sample.fps} fps · ${sample.render_width} × ${sample.render_height} · AA ${['off', '2×', '4×'][sample.msaa]}`;
if (samples.length >= 60) stopRecording();
};
document.addEventListener('visibilitychange', () => {
// Do not mix background throttling into a foreground comparison.
if (document.hidden && window.seedGraphics.recording) stopRecording();
});
downloadButton.addEventListener('click', () => {
const report = { schema: 1, started_at: recordingStarted, browser: navigator.userAgent,
device_pixel_ratio: devicePixelRatio, screen: { width: screen.width, height: screen.height },
limitations: 'Frame intervals include the 30 fps limiter. JavaScript heap is approximate and excludes total browser/GPU/system memory. No performance-fix conclusion is implied.', samples };
const url = URL.createObjectURL(new Blob([JSON.stringify(report, null, 2)], { type: 'application/json' }));
const link = document.createElement('a');
link.href = url; link.download = 'the-seed-performance.json'; link.click();
setTimeout(() => URL.revokeObjectURL(url), 1000);
});
if (location.port === '4180' && ['localhost', '127.0.0.1'].includes(location.hostname)) {
document.getElementById('versions-link').href = 'http://localhost:3000/';
}
Expand Down Expand Up @@ -32,6 +96,6 @@ if (typeof Engine === 'undefined') {
document.getElementById('bar').style.width = `${current / total * 100}%`;
status.textContent = current < total ? `DELIVERING THE MACHINERY · ${Math.round(current / total * 100)}%` : 'WARMING UP THE FABRICATION FLOOR';
}
} }).then(() => { loading.remove(); canvas.focus(); }, failure);
} }).then(() => { loading.remove(); canvas.focus(); recordButton.disabled = false; recordingStatus.textContent = 'Ready to record.'; }, failure);
}
}
Loading
Loading