Skip to content

Add breakpoints, pause and step for local preview#8532

Open
malec-palec wants to merge 4 commits into4ian:masterfrom
PlaytikaOSS:feature/breakpoints
Open

Add breakpoints, pause and step for local preview#8532
malec-palec wants to merge 4 commits into4ian:masterfrom
PlaytikaOSS:feature/breakpoints

Conversation

@malec-palec
Copy link
Copy Markdown
Contributor

Add breakpoints, pause and step for local preview

Adds debugging support directly inside the EventsSheet: users can set breakpoints on events, pause/resume the running preview, step through events one at a time, and inspect variable values at the paused point.

Works in local Electron preview only. Web and remote preview show a "use local preview" notification when the user tries to pause/step.

Shortcuts

Key Action
F9 Toggle breakpoint on the focused event
F10 Resume (when paused) / pause on the next frame (when running)
Shift+F10 Step to the next event

All three are also available in the EventsSheet toolbar and the Command Palette.

User flows

Set a breakpoint in a scene

  1. Open a scene's events sheet.
  2. Click an event to focus it, press F9. A red dot appears next to the event.
  3. Launch a local preview. When execution reaches that event, the preview freezes and the event gets a red frame in the IDE; a "Paused in debugger" toast appears.
  4. Press F10 to resume.

Set a breakpoint in an extension function

  1. Open an events-based extension, open one of its functions (free function or custom-object method).
  2. Focus an event, press F9.
  3. Launch a preview. When the extension function runs and hits the event, the IDE auto-navigates to the correct function tab and highlights the paused event.

Breakpoints inside behavior methods are not supported - by design.

Set multiple breakpoints

Breakpoints can live in different event sheets at the same time (multiple scenes, multiple extension functions). All of them are sent to the preview as one payload every time the set changes. Session state is kept in-memory for the lifetime of the editor process - it survives tab close/reopen, but is not persisted across editor restarts or preview relaunches.

Step through events

From a paused state:

  • Shift+F10 - step to the next event (sibling, sub-event, or next event after the current function returns).
  • F10 - resume until the next breakpoint (or forever if none).

While running:

  • F10 - pause at the start of the next frame, on the first event of the running scene's events sheet. Useful when you have no breakpoints set and just want to drop into the debugger to inspect the current state.

Inspect variable values while paused

When the preview is paused:

  • Hover over a variable pill inside any event parameter - the tooltip shows its current runtime value.
  • Supported sources: scene variables, global variables, extension scoped variables, scene-level local variables, and object variables (via the objectvar parameter kind, e.g. the second parameter of Modify a variable of an object).
  • Object-variable tooltips show the first live instance of the object in the calling container - for full per-instance inspection, use the separate Debugger panel.
  • Extension function local variables and variables inside string / number expression parameters are not resolved - they fall back to the generic "variable" label.

Paused toast

The "Paused in debugger" toast can be dragged to a custom position. Its position is kept for every subsequent pause in the same preview session. It resets when the preview window is closed or the sheet is unmounted.

Implementation details

For the full architecture, runtime internals, code-generation rules, IDE wiring, and Electron-main CDP flow, see BREAKPOINTS.md in the root of the repo.

BREAKPOINTS.md is working documentation for this branch only and will be deleted before merge.

@malec-palec malec-palec requested a review from 4ian as a code owner April 22, 2026 14:13
@4ian
Copy link
Copy Markdown
Owner

4ian commented Apr 24, 2026

Thanks for opening this PR. Looks super useful. We'll take a look asap

Copy link
Copy Markdown
Owner

@4ian 4ian left a comment

Choose a reason for hiding this comment

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

Quick questions:

  • Is this working with custom objects (prefab) events? You can add breakpoints in them and see them being stopped on/stepped in?
  • For Trigger Once, I see there is some complex logic related to them, notably skipping the clearing of "_lastFrameOnceTrigger". Is this because breakpoints will stop events and don't guarantee the code generated for events of a scene/custom object won't run until the end?

@malec-palec
Copy link
Copy Markdown
Contributor Author

Is this working with custom objects (prefab) events? You can add breakpoints in them and see them being stopped on/stepped in?

Yes. Breakpoints, pause and step all work inside custom-object methods (events of an events-based object).

The matrix of what gets breakpoint instrumentation is set in EventsFunctionsExtensionsLoader/index.js via the compilationForRuntime flag passed to the code generator:

Code type compilationForRuntime Breakpoints?
Scene layouts false Yes
Free extension functions false Yes
Custom object methods false Yes
Behavior methods true No (by design)

Two extra notes for custom objects specifically:

  1. The generated __checkBreakpoint / __pushBpFunction / __popBpFunction calls are emitted as if (runtimeScene && …) because in custom-object methods runtimeScene is taken from this._instanceContainer in the function prelude, and that field can still be undefined while the object is being constructed. Without the guard the very first call would crash before the base class finishes wiring the instance container.
  2. RuntimeInstanceContainer (the base class shared by RuntimeScene and CustomRuntimeObjectInstanceContainer) was given delegating stubs - __pushBpFunction, __popBpFunction, __checkBreakpoint - that forward to this.getScene(). So when generated code in a custom-object method calls runtimeScene.__checkBreakpoint(...) and runtimeScene is in fact a CustomRuntimeObjectInstanceContainer, the call still reaches the real RuntimeScene and uses its breakpoint state and call-depth stack. A getProfiler(): null stub was added to the same base class for the same reason (generated profiler code calls runtimeScene.getProfiler()).

Behaviors were intentionally left out: their lifecycle methods (onCreated, doStepPreEvents, …) don't have a uniform runtimeScene in scope at call time, and they are commonly shared via the marketplace where preview-only instrumentation would be dead weight in shipped projects. The trade-off felt clearly worth it for custom objects but not for behaviors.

For Trigger Once, I see there is some complex logic related to them, notably skipping the clearing of "_lastFrameOnceTrigger". Is this because breakpoints will stop events and don't guarantee the code generated for events of a scene/custom object won't run until the end?

You're right that the trigger is "events don't reach their natural end every frame", but the mechanism is a bit more specific than that.

I pause via V8's native debugger; statement (driven from Electron main over CDP). When V8 freezes there, the JS stack stays intact - when the user resumes, execution continues from exactly that point and the current frame finishes normally. So a single pause + resume on its own does not, by itself, skip events.

The problem appears with stepping, which inherently crosses frame boundaries. Each Shift+F10 programs the stepping FSM (stepNextEvent, stepPassedCurrentEvent, stepCurrentEventIndex, stepCurrentFunctionId) and issues Debugger.resume. If the next FSM match is later in the same _eventsFunction(this) call, execution stays in the current frame; but very often the match is in a future frame - the events function returns, renderAndStep returns, the browser eventually ticks, the next renderAndStep runs OnceTriggers.startNewFrame(), then events execute again, and only then does the FSM trip and re-pause via debugger;.

OnceTriggers relies on rotation: each startNewFrame() clears _lastFrameOnceTrigger and moves the current frame's _onceTriggers into it. The "consumed" status of a Once id only survives as long as the holding event is reached every frame. Stepping breaks that - the outer event holding Trigger once may not be reached for several frames while the user is stepping through a deeper subtree. Once its id rotates out of _lastFrameOnceTrigger, the next reach makes the condition return true again and re-enters the sub-events the user just stepped through.

Concrete repro (any condition + Trigger once works):

Event A (parent):
  Conditions:
    - Variable Score >= 100
    - Trigger once
  Sub-events:
    Event B (child):
      Actions:
        - Do = "Congrats!" to the text of Hint     <-- user sets a breakpoint here

Flow without the fix:

  1. During normal play, Score reaches 100, A's Trigger once fires once, B runs once, "Congrats!" is shown.
  2. The user sets a breakpoint on B and pauses on it on a later frame.
  3. The user presses Shift+F10 to step.
  4. Stepping crosses frame boundaries; A is not re-reached for a few frames; A's Once id rotates out of _lastFrameOnceTrigger.
  5. On the next frame where A is reached, Trigger once returns true again, B re-runs, the breakpoint hits again - the user is dragged back into B instead of advancing past it.

Fix is a one-shot flag on OnceTriggers:

preserveLastFrameOnNextCycle(): void {
  this._preserveLastFrameOnNextCycle = true;
}
// The next startNewFrame() skips the clearing of _lastFrameOnceTrigger,
// then resets the flag.

Set in two places, both debugger-only:

  1. RuntimeScene._triggerBreakpoint - at each pause, before debugger; runs. OnceTriggers.startNewFrame() rotates buckets exactly once per frame, at the very top, before any events run; the pause happens later in the same frame, so the next opportunity to clear _lastFrameOnceTrigger is the start of the following frame (after V8 unfreezes and the current frame completes). This call ensures that first post-pause startNewFrame() keeps the bucket instead of clearing it.
  2. RuntimeScene.renderAndStep - at the top of every frame while stepNextEvent is armed. The flag from (1) is one-shot - it's consumed by the first startNewFrame() after the pause. Stepping can span many more frames after that, so this call re-arms the flag at the top of each stepping frame, keeping the pre-pause Once bucket intact across the whole stepping session.

Scope is intentionally narrow: only the scene-level OnceTriggers is touched. Extension / behavior / custom-object OnceTriggers instances each have their own startNewFrame call sites and aren't covered yet.

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