Skip to content

[WIP] Add experimental gameplay tests - #8926

Draft
4ian wants to merge 31 commits into
masterfrom
claude/gdevelop-ai-game-tests-yb6sqs
Draft

[WIP] Add experimental gameplay tests#8926
4ian wants to merge 31 commits into
masterfrom
claude/gdevelop-ai-game-tests-yb6sqs

Conversation

@4ian

@4ian 4ian commented Aug 4, 2026

Copy link
Copy Markdown
Owner

No description provided.

4ian and others added 14 commits August 4, 2026 15:49
Complete v1 plan for gameplay tests: Core model (gd::GameplayTest),
GDJS harness driven over the debugger protocol, editor UI (test editor
tab, project manager section, overlay preview frame, CLI runner), the
run_game_tests orchestrator tool with a tester sub-agent on the
backend, prompt/versioning strategy (toolsVersion v13), starter test
suite and rollout phases. Folds in all lessons from the prior
new-gameplay-tester experiment.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AaQQdPZ68X8zkybtsxWE1b
- Generic Core model: gd::Test / gd::TestsContainer with a serialized
  type attribute ("gameplay" in v1) and JSON key "tests", leaving room
  for future test types; UI label stays "Gameplay tests".
- Orchestrator tool renamed to run_tests, priced 0.5 credits.
- Last-run summary persisted in the project JSON (decided).
- Approval semantics: no approval to run an existing test; persisting a
  new/changed test triggers the edit-approval row when auto-edit is off
  (via a dynamic modifiesProject check).
- Test editor falls back to EditorNavigator (bottom tabs) on small
  screens, like the scene/extension editors.
- Extension-scoped tests confirmed: synthetic empty scene + spawn().

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AaQQdPZ68X8zkybtsxWE1b
…sts P1)

Tests (JS source + metadata + last-run summary) are stored on gd::Project
and gd::EventsFunctionsExtension, serialized under a 'tests' element (only
when non-empty, so existing projects are unchanged). The 'type' attribute
defaults to 'gameplay' and leaves room for future test kinds. Tests are
stripped from exports/previews by ProjectStripper: the test runner sends
the source over the debugger channel at run time instead.

Includes GDevelop.js bindings (Test, TestsContainer, GetTests on Project
and EventsFunctionsExtension), regenerated types.d.ts and Flow types
(generators run against the updated Bindings.idl; the wasm glue itself
must be rebuilt by CI as emscripten is not available here), Core tests
(GDCore_tests pass: 73 cases / 8500 assertions) and GDevelop.js tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AaQQdPZ68X8zkybtsxWE1b
A gameplay test is a JS script (async (harness) => {...}) run against a
real game preview. The harness pauses the game main loop and steps frames
deterministically (fixed dt, speedFactor frames per animation frame - so
tests run faster than real time), simulates keyboard/mouse/touch through
the InputManager (accepting GDevelop and Web API key names, with
location-aware key codes), inspects objects/variables/behaviors via
network-sync-data snapshots, supports scenario setup (spawn, external
layout injection, skipCreatingInstances), navigation hints, FPS aiming
with an adaptive mouse-delta controller and a pointer-lock shim,
fail-fast assertions, downscaled JPEG screenshots, per-section profiling,
and an auto-recorded event log (spawn/remove/stuck/scene changes).

The editor talks to it over the debugger connection: 'gameplayTest.run'
runs a test (progress streamed as 'gameplayTest.progress', result always
sent as 'gameplayTest.result'), 'gameplayTest.stop' interrupts it. The
runner file is only included in previews with a debugger client - test
sources never ship in exported games.

The paused main loop still calls InputManager.onFrameEnded every
animation frame, which would clear simulated inputs between manually
stepped frames: it is neutralized during a run and called by the harness
after each stepped frame instead.

Covered by GDJS Karma tests (gameplaytestharness.js, passing in headless
Chrome; the only failing suites are the pre-existing Firebase e2e tests
which require network access).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AaQQdPZ68X8zkybtsxWE1b
… CLI, AI tool (P2)

Gameplay tests (stored on the project via gd::Test) are now visible and
runnable from the editor:

- A 'Gameplay tests' section in the Project Manager (add/rename/delete/
  copy/paste/duplicate/reorder, run from the context menu or the row
  button), opening a new 'gameplay-test' editor tab: a Monaco code editor
  + a properties panel (description, last run summary, run/stop,
  'Edit with AI' which copies a pre-filled prompt and opens the Ask AI).
- A GameplayTestRunner that exports a fresh preview of the in-memory
  project into a dedicated small overlay iframe (bottom-left, with
  Hide/Stop controls - kept 1x1 while hidden so the game loop keeps
  running), waits for the game to boot, then drives tests over the
  debugger protocol (gameplayTest.run/progress/result) with an
  editor-side watchdog. Runs are globally serialized. Last-run summaries
  are persisted on the tests (marks the project as having unsaved
  changes). Dependencies (preview launcher) are registered by MainFrame
  so the project manager, command palette, CLI and AI function calls can
  all run tests.
- A 'gameplay-test-frame' slot in both debugger servers (browser
  postMessage and Electron), next to the in-game-edition embedded frame.
- Preview launchers accept isForGameplayTest (separate export directory,
  window-message debugger client, no preview window). The legacy S3
  launcher rejects it explicitly.
- Command palette: OPEN_GAMEPLAY_TEST, RUN_GAMEPLAY_TEST,
  RUN_ALL_GAMEPLAY_TESTS.
- CLI: RUN_ALL_TESTS awaitable runner (optionally filtered by test names
  via --cmd-args) with a meaningful exit code for CI.
- AI integration: run_gameplay_test client-side editor function (used by
  the backend 'tester' sub-agent; persisting a new/changed test counts as
  a project modification for edit approval - via the new per-call
  getModifiesProject hook - while just running does not), a run_tests
  chat rendering stub for the server-side orchestrator tool, tests listed
  in the SimplifiedProject sent to the AI, and
  AI_ORCHESTRATOR_TOOLS_VERSION bumped to v13 (requires the matching
  generation-api and prompts deployment).

Not in this commit (follow-ups): a 'Tests' section in the extension
editor UI (extension-scoped tests are fully supported by the model, the
runner and the AI tool), harness autocompletions in the test code editor,
and pre-filling the Ask AI chat input directly.

Flow passes with 0 errors. Jest suites requiring libGD.js could not be
run in this environment (libGD.js download unavailable); they run in CI
against the rebuilt bindings.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AaQQdPZ68X8zkybtsxWE1b
Extension-scoped gameplay tests are now visible and editable in the
extension editor's list, next to Objects, Behaviors and Functions:
add/rename/delete/copy/paste/duplicate/reorder, run from the row button
or the context menu, and open in the same gameplay test editor tab as
project tests (reusing the editor, toolbar and runner as-is). The
clipboard kind is shared with the Project Manager so tests can be
copied between the project and extensions.

Open/rename/delete/run are routed to the MainFrame through a single
gameplayTestsCallbacks prop (threaded via EditorTabsPane and the
popped-out editor windows), so renames and deletions update/close the
matching editor tabs, like project tests. The existing project-scoped
handlers were generalized to take a scope.

Flow passes with 0 errors and the full newIDE test suite is green
(126 suites, 1142 tests) - now run against a locally built libGD.js
(emsdk 3.1.21, dev variant), which also validates the new Test/
TestsContainer bindings end-to-end (GDevelop.js Core.js suite: 203
tests pass).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AaQQdPZ68X8zkybtsxWE1b
- Hide and tear down the gameplay test frame when a test run ends or is
  stopped, and exclude the gameplay test frame from the running previews
  count so the "update" preview button stays disabled during test runs.
- Block all mouse/keyboard/touch input on the gameplay test iframe: the
  game is only driven by the harness.
- Make the test code a central editor with no title bar, and add a
  toolbar toggle button to show/hide the test properties panel, like in
  the scene editor.
- Hide the preview and share buttons in the toolbar when a gameplay test
  tab is active (and in popped-out editor windows).
- Make "Edit with AI" pre-fill the Ask AI chat input with a prompt about
  the current test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AaQQdPZ68X8zkybtsxWE1b
- Make the stop buttons actually stop a test: the stop request now
  interrupts the test script even when it awaits something else than the
  harness (a timer, a fetch...), by racing the script against a promise
  rejected on stop. Previously the stop flag was only checked when the
  script stepped frames.
- Make stopping work at any point of a run: a batch-level stop controller
  aborts a run still exporting/booting the game and skips the remaining
  tests of a batch (reported as "stopped").
- Report progress at frame 0 as soon as the script starts, so the frame
  and properties panel show the test as running instead of staying on
  "Starting the game..." (and treat frame 0 as a running state).
- Keep the gameplay test frame open at the end of a run, showing the
  frozen (paused and muted) game and the outcome; its button stops the
  test while running and closes the frame afterwards. The frame is
  always closed and reopened fresh when a new run starts.
- Give newly created gameplay tests a default commented source, as a
  starting point for humans writing a test manually.
- Focus the Ask AI input after "Edit with AI" pre-fills it.
- Provide autocompletions for the `harness` API in the desktop app (the
  gameplay-tests runtime folder was not read for completions).
- Hide the "'await' expression is only allowed within an async function"
  diagnostic in the test editor (the test source is actually run inside
  an async function), via a per-model diagnostics message filter.
- Add tests for stopping a test awaiting a raw promise and for
  freezeWhenFinished.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AaQQdPZ68X8zkybtsxWE1b
v13 was taken on master by the hidden-instances / set-instance-variables
tooling, so gameplay tests now ship as tools version v14 (the editor
constant was already updated when rebasing).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AaQQdPZ68X8zkybtsxWE1b
The harness reference is prompt content and now lives with the AI prompts;
Monaco autocompletions are fed directly by gameplay-test-runner.ts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AaQQdPZ68X8zkybtsxWE1b
In development, the AI uses tools v14 and the gameplay tests sections are
shown in the project manager and extension editor (plus the command palette
commands). Otherwise the editor stays on tools v13 and none of the gameplay
tests UI is visible, so this can be deployed without the feature appearing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AaQQdPZ68X8zkybtsxWE1b
The runner dependencies were registered by a MainFrame effect with no
dependency list: on every commit, React's passive-effects flush ran its
cleanup (unregistering) before child effects — and the AI function calls
processor is a child effect that synchronously starts the test run, so it
always saw an empty registry. Manual runs, triggered from event handlers
between commits, were unaffected. Register once on mount instead, reading
the latest values through refs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AaQQdPZ68X8zkybtsxWE1b
The test source must be the body of `async (harness) => {...}`, but AI
models and users sometimes pass the whole function: evaluated as-is it was
a no-op expression reported as a false 'passed' with 0 frames. The runner
now auto-unwraps a wrapper-shaped source and calls it, and any run that
completes without stepping a frame nor asserting anything is reported as
an error instead of a pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AaQQdPZ68X8zkybtsxWE1b
Comment thread newIDE/app/src/MainFrame/index.js Outdated
// run BEFORE this component's) — and the AI function calls processor is a
// child effect that synchronously starts a test run, so it would always see
// "no editor registered".
const unsavedChangesRef = React.useRef(unsavedChanges);

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Todo: do we have an helper for this?

claude and others added 15 commits August 6, 2026 08:20
Lets AI edit scripts delete gameplay tests or change their name,
description and position (changed_properties approach — never their
source, which must go through the gameplay test runner so it is executed
and verified). Returns the ordered tests of the scope (capped at 50) so
changes are self-verifying. Renames and deletions keep open editor tabs
consistent via onProjectItemRenamedOutsideEditor ('gameplay-test' kind)
and the new onWillDeleteGameplayTest callback.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AaQQdPZ68X8zkybtsxWE1b
- New EditorBottomTabsSwitcher (UI): editors switched with bottom tabs for
  small screens. The gameplay test editor uses it on mobile: properties by
  default, a second tab for the code (instead of the side-by-side mosaic).
- GameplayTestScope is now explicit ({type: 'project'} or
  {type: 'extension', extensionName}) instead of 'project' | extensionName,
  removing the ambiguity with an extension literally named 'project'. Tool
  arguments take {type, extension_name} accordingly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AaQQdPZ68X8zkybtsxWE1b
…erflow

EditorBottomTabsSwitcher now renders icon + label tabs with the same design
and metrics as the home page mobile menu (active top border, secondary color
for inactive tabs): Properties (edit icon) and Code (console icon). The
editors container and the code editor column clamp their width
(min-width: 0, overflow hidden) so the code editor can never leak past the
screen width on small screens.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AaQQdPZ68X8zkybtsxWE1b
Snapshots now expose the conditions and expressions of each object and
behavior, evaluated at snapshot time under the exact names used in the
game's events: behaviors.PlatformerObject.state.IsOnFloor,
.state.CurrentSpeed, configuration values (.state.MaxSpeed), and the
properties of events-based behaviors/objects (.state.PropertyHealth). The
inspectors are derived by the editor from the extensions' own declarations
(single source of truth — built-in, TS-based and events-based extensions
alike) and sent with the run payload; the harness evaluates the same public
getters the game's events call. Replaces the unreadable minified network
sync data, still reachable via the getRawBehaviorData last-resort escape
hatch. Reading an unknown state name throws with the list of available
names, so mistakes self-correct in one run.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AaQQdPZ68X8zkybtsxWE1b
…t load

- The gameplay test event log now tracks the scene INSTANCE, not only its
  name: a replacement by a new instance of the same scene (a restart, or
  external interference) is recorded as a 'sceneReset' event instead of
  going unnoticed.
- Asset loading (RuntimeGame._loadAssetsWithLoadingScreen) now restores
  the paused state it found instead of unconditionally unpausing: a game
  paused by a gameplay test (or the debugger) stays paused when a scene
  change loads assets mid-test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AaQQdPZ68X8zkybtsxWE1b
…ontrol probing, progress tracking

The should* booleans of getNavigationHint encoded a straight-line pursuit
policy that silently deadlocks (e.g. a target directly below the platform
the object stands on). The harness now provides facts and measurements,
and the test script owns the navigation strategy:

- getRelativePosition (replaces getNavigationHint): pure geometry - relative
  vector, distance, horizontalDistance, yawDiff, pitchDiff, reached. No
  navigation advice.
- probeControls: measure what each key actually does (net + extreme
  displacements vs a no-key baseline, yaw change), restarting the scene
  between probes. Works for any control scheme, 2D or 3D.
- makeProgressTracker: distance-to-target progress with stall detection
  (records a 'stuck' event when a stall starts), usable in manual loops.
- Time-budget yielding (12ms) replaces the per-call speedFactor frame
  counter: every stepping pattern (stepUntil, stepFrames(1) loops...) now
  renders while running and stays interruptible, at near-full speed. The
  unused speedFactor payload option is removed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AaQQdPZ68X8zkybtsxWE1b
…ger commands during tests

- probeControls -> resetSceneAndProbeControls and lookToward ->
  lookTowardWithMouseDelta: the names document the scene restarts and the
  mouse-delta mechanism.
- Document 2D/3D support on every helper (hasLineOfSight is explicitly
  2D-only: 2D hitboxes in the X/Y plane, Z ignored; everything else
  handles Z when present).
- While a gameplay test runs, the debugger client ignores state-mutating
  commands (play/pause, hot reloads, instance edits, set/call...) and
  answers with a commandIgnored message: an unpause would make the main
  loop step in parallel, a hot-reload would reset instances mid-test.
  gameplayTest.stop and read-only commands stay allowed.
- The editor disables the Preview/Update toolbar button while a gameplay
  test batch runs (useIsGameplayTestRunInProgress on the runner's batch
  lifecycle).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AaQQdPZ68X8zkybtsxWE1b
The menu offers 'Run as quickly as possible' (the default, also what the
main button does), 'Run at 4x speed' and 'Run at normal speed', so a
paced run can be watched to understand what the test does.

The pacing is done by the harness (payload speedFactor: game seconds
simulated per real second, omitted = full speed): the stepping loop waits
for the wall clock to catch up with the simulated game time, re-anchoring
when a run falls behind (heavy frame, scene load) instead of rushing to
catch up. Paced runs get a 5-minute default timeout instead of 30s (they
are slow by design and can be stopped anytime).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AaQQdPZ68X8zkybtsxWE1b
…ve test mouse deltas

Mouse-look extensions (like MousePointerLock's FirstPersonPointerMapper)
do not read the input manager: they listen to the canvas 'pointermove'
DOM events and gate everything on document.pointerLockElement. The
gameplay test pointer lock shim now:
- patches canvas.requestPointerLock/document.exitPointerLock and fakes
  document.pointerLockElement (dispatching 'pointerlockchange'), so the
  pointer is reported as locked as soon as the game requests it - without
  ever locking the real mouse;
- is fully restored when the test ends.
setMouseDelta now also dispatches a real 'pointermove' event on the
canvas carrying movementX/movementY, in addition to feeding the input
manager - so both channels receive the simulated deltas and
lookTowardWithMouseDelta can rotate FPS cameras driven by such
extensions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AaQQdPZ68X8zkybtsxWE1b
…k, pointer lock hint

- Scene change events (sceneChanged/sceneReset) now carry a 'cause':
  'harness' (a goToScene), 'game' (a change requested during a stepped
  frame), 'networkSync', or 'external' (interference - with a call stack
  snippet in causeDetail). The scene stack is instrumented for the
  duration of the run and restored afterwards.
- stopProfiling now returns a flat, JSON-safe summary ({avgStepTimeMs,
  sections: [{name, avgTimeMs}]} sorted by time descending, nested
  sections flattened as 'parent > child') instead of the raw profiler
  tree (circular, map-shaped).
- lookTowardWithMouseDelta returns {aimed, yawDiff, pitchDiff,
  sawYawResponse, sawPitchResponse} (null if object/target missing), with
  a per-axis gain adaptation. When the measured pitch never responds (a
  game mapping the vertical aim to another axis), the vertical input is
  undone and the aim falls back to yaw-only instead of winding the view
  into the ground.
- setMouseDelta records a one-time hint in the test logs when the game
  never requested the pointer lock (mouse-look usually engages after a
  click).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AaQQdPZ68X8zkybtsxWE1b
…ion semantics

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AaQQdPZ68X8zkybtsxWE1b
…e API

- SceneStack now records the cause of its own changes first-class:
  runWithSceneChangeCause(cause, action) attributes changes to a label,
  the stack labels its own call sites ('game' for changes requested by
  the game logic during step, 'networkSync' for applied sync data), a
  deferred push (assets loading) keeps the cause it was requested under,
  and anything unlabeled is recorded as 'external' with its call stack.
  consumeLastSceneChangeCause() exposes it. The gameplay test harness
  now just consumes this instead of monkey-patching the scene stack
  (tracker install/uninstall and classification flags removed).
- Profiler.getFramesAverageMeasures() returns a new FrameMeasureOutput
  type: a plain tree without the parent back-references (which made the
  output circular and impossible to JSON.stringify). The in-game overlay
  percentages now thread the parent time explicitly, and the debugger
  payload no longer relies on circular-reference replacement for it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AaQQdPZ68X8zkybtsxWE1b
…text

- Profiler.getFramesMaxMeasures(): per-section maximum over the captured
  frames (the spikes averages hide) and Profiler.getFrameTimes(): the
  chronological per-frame totals from the ring buffer.
- Gameplay test stopProfiling now returns maxStepTimeMs, per-section
  maxTimeMs, the frame-by-frame timeline (to correlate spikes with
  eventLog frames), the live object counts, the 3D renderer counters
  (draw calls, triangles, geometries, textures) when the game uses 3D,
  and the JS heap in use (Chromium).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AaQQdPZ68X8zkybtsxWE1b
claude added 2 commits August 7, 2026 08:51
…meline

stopProfiling() results are now part of the test result (result.profiles,
last 5 kept) so they reach reports without going through console logs
(where they were truncated). Each profile reports its window in harness
frame numbers (startFrame/endFrame), the most expensive frames
(worstFrames, correlatable with the eventLog), and a timeline downsampled
past 120 frames by bucket-max (spikes preserved, frameTimesBucketSize).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AaQQdPZ68X8zkybtsxWE1b
…, UI cleanups

- Harness: getRuntimeGame() and getCurrentRuntimeScene() low-level escape
  hatches (documented as last resorts).
- Debugger guard during tests is now a whitelist (refresh, getStatus,
  profiler and gameplayTest commands): fail closed, a command added later
  cannot accidentally mutate the game state mid-test.
- Editor: useForceUpdate instead of a manual counter; the test editor
  toolbar is a function component; the run-speed menu is shared and the
  'Run the test' button of the properties panel is a split button too;
  getGameplayTestProjectItemName is imported from the runner everywhere
  (re-export removed); unsavedChangesRef uses useStableUpToDateRef.
- Preview/hot-reload guarded everywhere while a gameplay test runs: the
  _launchPreview funnel early-returns (covers menu bar and palette), the
  preview commands are disabled, and the split-menu entries too.
- Non-persisting gameplay test runs (persist: false, temporary probes) no
  longer count as modifying the project, so they don't require an
  approval when auto-edit is off.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AaQQdPZ68X8zkybtsxWE1b
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants