Concise engineering reference for the completed First Playable (the original single-level greybox milestone). Updated when the actual repository state changes.
Stale relative to the current v0.8.0 build. The project has since grown to a 25-level, 5-act campaign with a full moveset (dash, wall-jump, spin), new hazard/platform types, a level select, and a bundled launcher. This document was not rewritten for that growth — see
README.md's Architecture and Features sections for the current script list and mechanics, andSESSION_HANDOFF.mdfor the most recent dated snapshot of repository state. Everything below this point describes the original First Playable only.
scenes/main_scene.tscn— the game entry scene (run/main_scene). RootMain(Node2D,scripts/main_scene.gd) owns, in draw order: aBackdropinstance, aTerraingroup of greybox platforms, onePlayer, theGoalArea2D, aVignetteCanvasLayer, and theHud.scenes/player.tscn—CharacterBody2Dplayer (scripts/player.gd) with a stylized standing-figurePolygon2Dbody and aVisoraccent child, aCollisionShape2D, aVisualsfeedback node (scripts/player_visuals.gd), and a smoothedCamera2D. Its root is pinned toz_index = 2; see Presentation for why that number is load-bearing.scenes/platform.tscn— reusable greybox solid (scripts/platform.gd,GreyboxPlatform).@tool; setsize/color/edge_thickness/edge_colorper instance and the collider, body and lit top strip resize together. Each instance builds its ownRectangleShape2Din_ready()so instances never share/clobber a collider.scenes/backdrop.tscn— the parallax sky (see Presentation).scenes/hud.tscn— controls panel, run clock, attempt counter, completion banner (see Presentation).
The optional addons/godot_mcp_toolkit/ is not enabled in the public project
configuration. It remains available as local development tooling, but a fresh
clone does not start a localhost MCP runtime or require the per-user bridge
configuration.
Everything on screen is procedural — polygons, gradients and one shader, no image assets. That is a deliberate staging decision for this demo: the visual direction is coherent without introducing an art pipeline that would expand scope. A production-art pass is intentionally outside the current milestone.
Palette rule. The world is cool (dark indigo → slate blue); the only warm element in the game is the goal and its ledge highlight. A player who has never seen the level can find the win condition by looking for the one amber thing.
scenes/backdrop.tscn. Four depth layers behind the play space:
Sky— aCanvasLayeratlayer = -100holding a full-rectTextureRectwith aGradientTexture2D. A CanvasLayer, not a world node, so it can never scroll off the edge of the world no matter how far the camera travels.Stars— aParallax2D(scroll_scale0.06/0.03) holding aStarField(scripts/star_field.gd): aMultiMeshInstance2DwhoseMultiMesh(TRANSFORM_2D,use_colors = true, over a hand-built unit-quadArrayMesh) places 430 stars in one draw call. Not child nodes — 430Polygon2Ds would dominate the scene's node count for something that never moves within its layer. And not a_draw()loop either: that was the first implementation, andtools/probe_perf.gdmeasured it issuing ~430 of the frame's ~472 draw calls, becausedraw_circle()is one draw command each no matter how cheap the circle is. 90% of the frame's draw calls for static background dressing is a real cost on the WebGL2 target, where per-call overhead dominates. The multimesh instance buffer is built once on rebuild, never per frame. Density is squared-distributed toward the top and alpha fades toward the horizon, so the field dissolves into the ridges instead of ending on a line.FarRidge/MidRidge/NearRidge—Parallax2Dlayers (scroll_scale0.10/0.26/0.48) each holding aParallaxRidge(scripts/parallax_ridge.gd): an@toolPolygon2Dthat generates a seeded, box-smoothed skyline and skirts it down todepthso it fills the frame below. One wide non-tiling polygon per layer rather than a repeating one, which sidestepsrepeat_sizeseams.
Ridge colours are darker than the platform fill on purpose. Playable geometry
must never be ambiguous with scenery, and the lit top strip on every platform
(edge_thickness, default 5 px) is the strongest readability cue in the game: it
says "you can land here" at a glance and separates a slab from the ridge behind
it. Walls that cannot be landed on set edge_thickness = 0.0.
shaders/vignette.gdshader. A canvas_item shader on a full-rect
ColorRect in the Vignette CanvasLayer (layer = 50, above the world, below
the HUD). Darkens the frame corners so the eye settles on the player. mouse_filter = 2
on the rect so it never eats input.
scripts/player_visuals.gd (PlayerVisuals) — non-authoritative feedback,
deliberately a separate node from the controller. It reads the player through its
public is_dashing() / facing() / landed API and only ever scales and
recolours the visual Body polygon, never the CollisionShape2D. A bug in
here can make the game look wrong; it cannot make the game play wrong.
- Squash on landing, scaled by impact speed, decaying over
recover_time. - Stretch in flight, scaled by vertical speed.
- Dash signature: the body flashes to near-white (
dash_tint) and goes wide and flat, trailing a pool of afterimages. - Presentation pose (added later, no physics change): the body flips to face
movement direction via
scale.xfromfacing(), and a few lightweight states give it a little life — an idle breathing bob (Time-based), a run step-bob plus a small forward lean, a fall lean, and a wall-slide flatten. AVisorchild polygon (cool ice-white,0.92,0.97,1) stays on the face as the player accent; it is never amber, keeping the amber goal the only warm element. All of this readsfacing()/velocity/is_on_floor()/is_on_wall_only()and mutates only theBody/Visortransforms — the collision shape and the controller are untouched.reset_state()clears body/visor transform and the walk phase so a new life never resumes a stale pose.
The afterimage pool is pre-allocated in _ready() and reused round-robin, so
node count stays flat during play (the Performance section depends on that). Two
non-obvious properties, both regression-tested in tests/test_presentation.gd
because both fail invisibly:
- The ghosts are
z_as_relative = false,z_index = 1. A relative-1(the first implementation) put them behind the backdrop's ridge polygons, which fill the lower frame at z 0 — the trail worked perfectly and was never once visible. The player scene root isz_index = 2so it still draws over its own trail. Terrain is z 0. Those three numbers are one contract. - A dash covers only ~90 px, so eight un-stretched 28 px-wide images sit almost
on top of each other and read as nothing.
ghost_stretch(1.6) widens them into a single streak.
scenes/hud.tscn + scripts/hud.gd (Hud) — a CanvasLayer at
layer = 100, added as a child of Main (it reads get_parent() for run_time,
attempts, last_run_time and the level_completed signal, so it must stay a
direct child of the level root).
The controls panel is generated from the live InputMap, not from a
hand-written list of key names. This is the whole reason it is built this way: a
printed control list is the first thing to rot when a binding changes, and a
platformer that lies about its own controls is worse than one that shows none.
Each row renders the primary key, the alternate key, and the gamepad button,
resolved via InputMap.action_get_events() and
OS.get_keycode_string(physical_keycode) — physical, because the bindings are
physical and keycode is 0 on those events. tests/test_presentation.gd asserts
every row resolves to a real, bound action that renders a non-empty label, and
that every gameplay action appears somewhere in the panel.
The panel shows on start, auto-hides after auto_hide_delay (8 s), and toggles
on toggle_help. Once the player has toggled it by hand the countdown is
cancelled and their choice sticks. A small "TAB — controls" hint cross-fades in
whenever the panel is hidden, so the panel is always rediscoverable.
Hud is the one script in the project that implements _process (it polls the
level's clock and attempt count, and reads toggle_help).
The clock holds the finishing time while the banner is up. Touching the goal
respawns the player in the same frame, which zeroes run_time — so a clock read
straight from run_time blanked to 0:00.00 at the exact moment the player had
earned a time, directly above a banner announcing that time. Both numbers were
individually correct and the pair read as a bug. _refresh_stats() therefore
reads last_run_time whenever Banner.visible, and tests/test_presentation.gd
pins it. This is also why the level keeps last_run_time at all.
The proving ground is a left-to-right ascent across thirteen platforms:
Ground → S1_1 → S1_2 → S2_1 → S2_2 → S2_3 → S3_1 → S4_A → S4_B → S5_1 → S6_1 → S6_2 → TopLedge, with the goal sitting on TopLedge and ShaftWall
closing the right edge. The route progresses through several sections — S1
(single-step platforms), S2 (three progressively spaced platforms), S3 (a
transition), S4 (two wide platforms with a dash-required gap), S5 (a narrow
bridge), and S6 (the final climb to TopLedge). Wider gaps between later
platforms require dashes as skill gates; the earlier gaps are clearable with
plain jumps. Structural walls (PitA_Left/Right, PitB_Left/Right,
PitC_Left/Right) frame the pits between sections. tests/test_level.gd
guards the opening gap between Ground and S1_1 so the first jump is part of
the route rather than a flat-ground bypass, then enforces the geometry
contracts that are easy to miss in a screenshot:
- Standing headroom. Any slab overhanging a landable surface must leave at least the body height (52 px) of clearance. Less than that and the collider intersects the ceiling: the player is squeezed into the floor and the jump is eaten on its first frame, so the button silently does nothing. The geometry has been verified so no platform overlap violates this contract.
- The goal rests on a platform.
Goalis a sibling ofTerrain, so moving the final ledge without moving the goal leaves the win condition floating in mid-air. Both moves are now checked together.
The world must not show its own edges. Ground, LeftWall and ShaftWall
are sized far past the frame (Ground is 620 px deep, the two walls run
y −400 → 1360) rather than being trimmed to the geometry that matters. Sized to
fit, all three ended their fill mid-screen with backdrop visible beyond: the
floor read as a slab hovering over the mountains, and each wall as a grey column
stopping in the sky. Nothing about that is a gameplay bug and no assertion could
see it — it just made a finished level look unfinished in every screenshot. Two
constraints bound how far they can grow: the kill plane must stay below every
platform's bottom (test_level checks this, so Ground stops at 1360 under a
kill_depth of 1400), and the walls must clear the camera's highest reach at
TopLedge.
These three also carry a darker fill than the platforms
(0.145, 0.161, 0.216 vs 0.212, 0.231, 0.302). They are structural mass rather
than things you aim at, and at full-frame size the platform tone made the floor
compete with the small slabs the player is actually reading. The value still sits
well clear of the near ridge (0.043, 0.051, 0.090), so mass never reads as
scenery, and the walls keep edge_thickness = 0.0 because they are not landable.
Precision-platformer feel via tunable @export values (pixels/seconds):
- Run:
max_speedwith separate ground/air accel + decel times (converted to per-frame rates), giving snappy grounded control and lighter air steering. - Jump: height + rise/fall times drive derived jump velocity and asymmetric
gravity (
h = ½gt²), so tuning is done in intuitive units. - Feel affordances:
coyote_time,jump_buffer_time, a shortdash_buffer_timeat the landing refresh edge, and variable jump height (jump_release_dampingtrims upward velocity on early release). - Wall movement: sliding down a wall caps fall speed at
wall_slide_speedwhile pressing into it; a wall jump launches up and away (wall_jump_push,wall_jump_up_scale) with a briefwall_jump_lock_timeso input can't cancel the push. Ground/coyote jumps take priority over wall jumps. - Dash: one fixed-speed horizontal dash (
dash_speedfordash_time), refreshed on landing. A press held in the shortdash_buffer_timewindow before that refresh is consumed on the next grounded frame; expired inputs do not create a later dash. It ends early on wall contact and bleeds excess speed back tomax_speedso it grants no permanent momentum. - Emits
landed(fall_speed)for future feedback (dust/squash/sfx).
The per-frame order is: timers → dash (owns velocity while active) → gravity →
wall slide → jump → horizontal → move_and_slide → landing detection.
Remembers the player spawn, respawns on falls below kill_depth, and offers an
instant restart action. Fall-death and manual restart share one code path, and
that path also clears the player's visual state — so a new life never resumes a
dash, a squash, or a trail from the old one. A Goal Area2D emits
level_completed when touched.
Level Completion Flow: When the goal is reached:
_level_completeflag is set, blocking duplicate triggers and all gameplay input.last_run_timeis frozen,run_timestops.- Boss/chase deactivated if active.
level_completedsignal emitted.- Player respawned to spawn (safe state while banner shows).
game_scene.gdsaves checkpoint to next level, pauses tree, waits 2.4s for the completion banner, then fades and loads the next level.
It also owns the run state the HUD displays: run_time (starts on the player's
first input, not on scene load), last_run_time (frozen at completion), and
attempts.
level_completed is deliberately zero-argument. Consumers connect zero-arg
lambdas to it; the finishing time is published via last_run_time.
Boss Chase (Level 5): When boss_config.enabled, spawning boss and minions
at level build. Chase triggers when player passes trigger_x. Boss/minions
pursue the player; catch distance 48px (boss) / 36px (minion) = death.
Boss speeds up from 170→320 px/s during chase.
Top-level scene managing level loading, transitions, pause overlay, and overall
game flow. Loads the current level as a child inside LevelContainer and
overlays the pause menu on a separate CanvasLayer.
Level transition flow: GameManager.level_changed → fade overlay + level
card (name + number) → _do_level_swap() → _load_current_level() → fade in.
Completion flow: level_completed → save checkpoint (immediate) → pause
tree → wait 2.4s for banner → fade out → load next level. On Level 5 completion
shows "ALL LEVELS COMPLETE" victory card.
Pause menu (scripts/pause_menu.gd, scenes/pause_menu.tscn): CanvasLayer
with process_mode = ALWAYS (freezes gameplay). Sub-panels: Settings (volume
sliders), Progress (current level, highest unlocked, checkpoint, completions),
Reset Progress (with confirmation). ESC toggles pause.
File-based progression persistence (user://save_data.json). Checkpoint is set
to level_num + 1 after each level completion. Handles: corrupted saves (fallback
to Level 1), missing saves (default to Level 1), invalid level numbers.
Checkpoint semantics: Completing Level N saves checkpoint = N+1. Death on Level N+1 restarts Level N+1 (not Level 1). Reset returns to Level 1.
Actions defined in project.godot (keyboard + controller):
| Action | Keyboard | Controller |
|---|---|---|
| move_left | A / Left | Left stick X (−) |
| move_right | D / Right | Left stick X (+) |
| jump | Space / W | A (south button) |
| dash | Shift / J | X (west button) |
| restart | R | Back/Select |
| toggle_help | Tab / F1 | Start |
Keys bind by physical keycode (layout-independent). Re-generate with
tools/setup_input.gd (see Validation) if actions need to change — hand-editing
the serialized InputEvent objects is error-prone. Close any running instance of
the game first; it holds project.godot open and the write will be lost.
Adding an action here is only half the job: add it to Hud.ROWS too, or
tests/test_presentation.gd will fail on "panel documents every gameplay
action". That coupling is intentional — an undiscoverable control is a bug.
No editor required; run with Godot 4.7.2 on PATH, or pass the installed
executable path to the commands below. The project does not depend on a
machine-specific Godot location.
- Parse/import + boot check:
Godot --headless --path <proj> --quit-after 120 - Movement/respawn regression test (exit 0 = pass):
Godot --headless --path <proj> --script res://tests/test_movement.gd - Game-feel regression test (exit 0 = pass):
Godot --headless --path <proj> --script res://tests/test_feel.gd - Gameplay-loop regression test (exit 0 = pass):
Godot --headless --path <proj> --script res://tests/test_loop.gd - Level-integrity + completability test (exit 0 = pass):
Godot --headless --path <proj> --script res://tests/test_level.gd - Presentation/HUD-truthfulness test (exit 0 = pass):
Godot --headless --path <proj> --script res://tests/test_presentation.gd - Full regression wrapper (exit 0 only when all five suites pass):
pwsh -File tools/run_all_tests.ps1 -GodotPath <godot-executable>(-GodotPathcan be omitted whengodotis onPATH.) - Rewrite input actions:
godot --headless --path <proj> --script res://tools/setup_input.gd - Movement-envelope measurement (tuning aid, prints numbers, always exit 0):
Godot --headless --path <proj> --script res://tools/probe_envelope.gd - Level solvability probe (per-gap reachability on the intended route):
Godot --headless --path <proj> --script res://tools/probe_reach.gd - Visual playthrough capture (needs a real window — omit
--headless):Godot --path <proj> --script res://tools/capture_run.gd - Frame-cost + node-count probe (needs a real window — the headless server does
not render, so every render monitor would read zero):
Godot --path <proj> --script res://tools/probe_perf.gd
tests/test_movement.gd drives the real physics engine and asserts on run
acceleration, jump arc, floor detection, key-binding matching, respawn, dash
(triggering / speed / momentum bleed), and wall slide + wall jump.
tests/test_loop.gd additionally checks that repeated goal entries emit the
completion signal, advance the attempt counter, and reset the current timer;
this protects the short demo loop from stale Area2D or state bugs.
tests/test_feel.gd covers the feel affordances that fail silently: fast speed
pickup, coyote time (jump fires just after leaving a real ledge, and does not
after the window expires), jump buffering (a press just before touchdown
auto-fires on landing), dash buffering (a near-landing press fires after the
refresh and an expired airborne press does not fire later), and variable jump
height (a full hold climbs meaningfully higher than a tap). Session 3's
rendered audit found the existing movement tuning already responsive, so no
controller values were changed; the 90%-of-cap speed guard protects the
intentionally quick pickup profile. Synthetic key presses can register a frame
late under the headless input pump, so timing-sensitive checks scan a few frames
rather than asserting on a single one.
tests/test_presentation.gd guards the two presentation properties a screenshot
flatters. A controls panel built from a stale list still looks like a controls
panel, and a dash trail hidden behind the backdrop looks exactly like a trail
that was never written — so this suite asserts on node state instead of pixels:
every panel row resolves to a real bound action and renders a non-empty key
label, every gameplay action is documented somewhere in the panel, the clock
holds at zero until first input and then runs, and the dash afterimages actually
become visible, at a legible alpha, on the right draw layer relative to both the
terrain and the player, and clear themselves on both dash-end and respawn. It
also checks that the documented Tab/F1 toggle hides and restores the controls
panel, drives the player onto the goal, and asserts the completion banner appears
with the HUD clock still showing the finishing time.
The two probe_*.gd tools are tuning aids (not pass/fail tests): they drive the
real physics to answer "how far can the player actually go" and "is each gap on
the route clearable". Measured envelope on flat ground: running jump ≈ 181 px
horizontal / ≈ 92 px peak rise; dash-jump ≈ 309 px horizontal. probe_reach.gd
walks the intended route platform-by-platform and classifies each transition as
trivial / DASH-required / unreachable (overlapping "hop up" pairs are handled
separately, since a right-run model doesn't fit them). This is how the greybox's
solvability is checked against the real controller instead of guessed. Touching
the goal counts as arrival for the last transition — the goal sits on the final
platform, so a clean final hop trips it in mid-air and the level respawns the
player, which would otherwise read as a miss.
tests/test_loop.gd covers the session-spanning systems that fail quietly:
goal completion (level_completed fires on entry and the player loops back to
spawn), the manual restart action, repeated fall-respawn staying anchored to
spawn with clean state, and the "one dash per grounding" refresh rule (an
airborne dash consumes availability, a second mid-air dash is refused, landing
refreshes it).
tests/test_level.gd guards the level itself: the geometry contract above
(headroom, goal resting on a platform, spawn clear of terrain with ground under
it, kill plane below everything) plus the load-bearing question — an autopilot
drives the real controller from spawn to the goal in one continuous run, holding
"run right", jumping near each lip, and spending the dash on the one gap too wide
to clear flat. It finishes in a deterministic 633 frames (10.5 s). Per-gap
probing cannot replace this: probe_reach.gd teleports the player to a clean
takeoff spot for every jump, so it is blind to composition failures such as
landing too close to the next edge to get a run-up, or arriving with the dash
already spent. Verified to fail (3 checks) when TopLedge is put back where it
was, so it is not a test that can only pass.
All five suites use real synthetic key events where bindings matter; the
autopilot and the probes drive Input.action_press instead, because a synthetic
InputEventKey can register a frame late or be dropped under the headless input
pump. That was not a theoretical concern: it made probe_reach.gd
non-deterministic, silently turning "ran off the edge without jumping" into a
false UNREACHABLE and nearly prompting a redesign of a level that was fine.
tools/capture_run.gd closes the gap the headless suites structurally cannot:
they prove the course is completable but render nothing, so a slab could be
mispositioned, mis-sized, or invisible and every assertion would still pass. It
extends tests/test_level.gd and reuses the same autopilot rather than copying
it — the trick is starting _autopilot() without awaiting it, so it suspends on
its own physics_frame awaits while a second coroutine grabs
root.get_texture().get_image() every 12th RenderingServer.frame_post_draw.
Frames land in build/shots/ (git-ignored, and .gdignored so they are never
re-imported), one per ~0.2 s of play, and each is logged with the player's world
position so a frame can be tied to a spot on the route. It must run without
--headless: the headless display server draws nothing, so the tool refuses
rather than writing 32 blank PNGs.
tools/probe_perf.gd closes the other gap a screenshot cannot: cost. It reuses
the same un-awaited-autopilot trick, and per rendered frame samples
TIME_PROCESS, TIME_PHYSICS_PROCESS, a wall-clock delta, and
RENDER_TOTAL_DRAW_CALLS_IN_FRAME, plus a full node count for the peak. It also
refuses to run headless, for the same reason capture_run does — the render
monitors would all read zero and the output would look like a clean bill of
health. Two of its checks are pass/fail rather than informational: node count
must not grow and must not spike mid-run. That is what turns "no per-frame
spawning" from a claim in this document into something that breaks the build.
The game targets the browser (gl_compatibility renderer, single-threaded web
build), and the exported build is how the rendered frame is actually inspected.
- Export preset:
export_presets.cfgdefines oneWebpreset — single-threaded (variant/thread_support=false, so no SharedArrayBuffer / cross-origin-isolation requirement) and no GDExtension. Output goes tobuild/web/(git-ignored).exclude_filterkeeps dev-only content out of the player payload (addons/godot_mcp_toolkit/*,tests/*,tools/*,docs/*): with binary-token script export the built-in GDScript exporter compiles addon scripts to.gdcbefore the toolkit's own strip plugin runs, so without the filter the whole addon shipped as inert dead weight. Trimming it took the.pckfrom 737 KB to 23 KB. The toolkit is disabled in the public project configuration, so excluding it is safe (verified: the trimmed build still boots and takes input). build/.gdignore: the export writes inside the project, so without this the engine's filesystem scanner re-imports the exported PNGs (.importfiles appear inbuild/web/) and those imported resources get packed into the next export — a loop that grows the.pckon every build..gitignoretracks this one file (build/*+!build/.gdignore) so the guard survives a fresh clone.- Export templates: the matching version's web templates must live in
%APPDATA%/Godot/export_templates/4.7.2.stable/(web_nothreads_*.zipetc.). They are not bundled with the engine binary; install once from the officialGodot_v<ver>-stable_export_templates.tpzrelease asset (extract thetemplates/web*files +version.txtinto that folder). - Export:
Godot --headless --path <proj> --export-debug "Web" build/web/index.htmlA textless "completed with warnings" notice is emitted by the headless editor filesystem scan and is benign; a complete build isindex.{html,js,wasm,pck}plus audio worklets. - Serve + preview:
tools/serve_web.py [port](default 8060) servesbuild/web/with the correctapplication/wasmMIME type and COOP/COEP headers..claude/launch.jsonwires this to the preview tooling under the nameweb.
Verified in-browser on the freshly exported build: the engine boots on WebGL2, the greybox and HUD render, and the browser console is clean. The in-app browser automation's synthetic keyboard injection did not move the canvas player reliably, so this check does not claim a full browser completion run; the full route was completed through the real controller in the native suite/capture.
Two different measurements, because they answer two different questions.
Web frame time. A historical pre-presentation measurement exists below, but it is not a current-build claim. The current browser check verified load, rendering, input, and a clean console; browser frame timing was not sampled in this finishing pass because the in-app preview does not provide a reliable continuous rAF measurement surface.
| historical build | avg | median | p95 | worst | fps |
|---|---|---|---|---|---|
| greybox (pre-presentation) | 16.67 ms | 16.64 ms | 17.26 ms | 18.01 ms | 60.0 |
A historical locked-60-fps measurement with no stutter, the worst single frame overrunning the 16.7 ms budget by 1.3 ms. This row has not been re-measured since the presentation layer landed, and the number above is not a claim about the current build. It remains here as historical context only; current-build browser timing is intentionally left unmeasured rather than guessed at.
Native probe. tools/probe_perf.gd plays the full autopilot route in a real
window and reports percentiles plus node counts. Final v0.1.0 release run: 384
sampled frames, warm-up discarded. The thirteen-platform route (633 physics
frames / ~10.5 s) sampled 622:
| metric | avg | median | p95 | worst |
|---|---|---|---|---|
| engine process frame | 25.264 ms | 16.913 ms | 71.478 ms | 71.478 ms |
| engine physics frame | 3.502 ms | 0.512 ms | 16.199 ms | 16.199 ms |
| wall frame time (vsync-locked) | 16.667 ms | 16.666 ms | 17.003 ms | 17.180 ms |
| draw calls | 42.443 | 42 | 46 | 52 |
(the avg/worst columns above are the v0.1.0 release run; the current
numbers are wall frame 16.666 ms avg / 16.666 ms median / 16.765 ms
p95 / 16.921 ms worst and draw calls 42.6 avg / 47 median / 51 p95 / 60 worst.)
TIME_PROCESS includes managed-environment scheduling outliers, while the wall
delta remains on the 16.67 ms vsync interval. The metrics that reflect the
presentation layer's cost remain healthy: draw calls stay low and node count is
flat at 163 across the route.
- The presentation layer is one draw call per layer, not per element. The
star field's first implementation issued a
draw_circle()per star, and the probe caught it at ~472 draw calls per frame — ~430 of them stars. RewritingStarFieldas aMultiMeshInstance2Dtook the frame to 43 draw calls, a 91% reduction, with no visual change (verified against freshcapture_runframes). That is the whole reason the probe exists: the backdrop looks expensive, and the claim that it is not has to be measured, not asserted. - Per-frame work is scalar.
Player._physics_processis float math plus onemove_and_slide(). Noget_node()lookups, string building, or container allocation on the hot path (Vector2is a value type).Main._physics_processis one float compare and one input poll. - One render-rate script.
Hud._processis the only_processin the project: twoObject.get()calls, twoStringformats and one input poll per drawn frame. Everything else per-draw is the engine'sCamera2Dsmoothing. The ridges are three static polygons andParallax2Dscrolling is engine-side. - Node count is flat. Measured across the full 633-frame route:
start=163 peak=163 end=163. Twenty-two greybox platforms (thirteen route platforms, six pit walls, LeftWall, ShaftWall, Ground), the backdrop's four layers, the player (one collider, a body polygon, a visor polygon, one camera, eight pooled afterimages), oneArea2Dgoal, and the HUD. The dash trail reuses its pool round-robin, so the tree never grows during play. This is asserted, not just described — twoprobe_perfchecks fail if anything spawns per frame, which is a one-line regression the moment someone writesadd_child()in a feedback path. - Restarts are O(1).
_respawn()assigns a position and clears scalars — no scene reload, instancing, orqueue_free. That is what makes the "instant retry" goal actually instant, and it is why respawn cost cannot drift with level size. GreyboxPlatformrebuilds nothing at runtime._apply()runs on property set and in_ready()only (it is an@toolconvenience), never per frame. The same is true ofParallaxRidge._rebuild()andStarField._rebuild().
An optional Python-based launcher with auto-update capability exists in launcher/. The game remains independently launchable via ProjectAscent.exe.
- launcher/version.py: Semantic version parsing (MAJOR.MINOR.PATCH)
- launcher/updater.py: GitHub Release API integration, download, SHA-256 verification, backup/rollback, and safe installation
- launcher/config.py: User preferences (ask/auto/never-check)
- launcher/launcher.py: Tkinter GUI
- version.txt: Current game version
- Check GitHub Releases API (/releases/latest)
- Compare semantic versions (not lexicographic)
- Download release ZIP to temporary directory
- Verify SHA-256 checksum (if .sha256 asset available)
- Extract to staging directory (with path traversal protection)
- Backup current installation
- Replace files from staging
- Verify new version
- Launch game
Any failure at any step restores the previous version from backup.
- ZIP extraction validates member paths to prevent path traversal
- Only GitHub Releases are used (not branches or arbitrary commits)
- SHA-256 verification rejects checksum mismatches
- Staging directory is isolated from live installation
- Backup is created before any modification
44 launcher tests covering: version parsing, checksum verification, backup/restore, extraction (including path traversal), installation, network failure, and offline behavior.
The launcher can be compiled to a standalone Windows executable using PyInstaller:
pip install pyinstaller
python tools/build_launcher.py
This creates dist/ProjectAscentLauncher.exe (approximately 11 MB) that runs without Python or Tkinter installed. The EXE is git-ignored and must be rebuilt if launcher source changes.
- Human-in-the-loop feel judgement (does it play well, not just render) still benefits from a person at the keyboard. The owner has already reported that the first real browser playtest felt good; the final browser check reconfirmed load, rendering, HUD presence, and a clean console.
- Level solvability is no longer a judgement call:
test_level.gdruns the course end-to-end every time the suite runs, andprobe_reach.gdreports each gap against the measured envelope (that is how oversized gaps were caught during the level design pass and pulled in, and how the dead landing strip underTopLedgewas found). What neither can judge is whether the route is fun or well-paced; that still needs a human playtest. - The wall-jump mechanic (built + regression-tested) is not yet exercised on the
critical path —
ShaftWallis currently a right-side boundary. Turning the finish into a wall-jump climb is a deliberate, feel-sensitive level-design pass (a forced wall-jump requires the player to gain height on the wall, which is a skill gate worth tuning with a person in the loop), tracked as a next step rather than rushed here.