[2.3] Scenario Logic

Author name: Joylynne Grace C. Esportuno
Reviewers: Uriel Tribiana
Creation Date: March 12, 2026
References: https://github.com/wyzlab/WyzQuests/issues/32
Status: Approved and Merged


INTRODUCTION & GOALS

Problem Summary

Creating branching scenarios usually means wrestling with complex logic and messy spreadsheets. The Scenario Logic changes that by allowing creators to build decision-driven paths directly on a Visual Canvas using intuitive, visual workflows.

Goals & Non-Goals

Goals

  • Allow users to create branching paths using a decision node, or in this feature we call it as a Question Node.

  • Branching paths should create distinct links when viewed on the Quest Player.

Non-Goal

  • This feature does not aim to be a spreadsheet viewer or spreadsheet-to-canvas converter. Creators should start fresh using the Visual Canvas.

Glossary

  • Scenario — It acts as a modular chapter or specific process within a Quest. In the visual canvas, the Scenario Node serves as a parent container. You can drag and drop any other type of node into it to build your flow, though a Scenario Node cannot contain itself.

  • Question Node — A Visual Canvas element that acts as a decision point, branching user journeys into different paths based on how they answer.


HIGH-LEVEL ARCHITECTURE

System Diagram

Technologies Used

  • Next.js, React Flow, and dnd-kit.


DETAILED DESIGN & IMPLEMENTATION

Data Model

"data": {
"type": "Question",
"label": "Variable Question",
"choices": [
{
"id": "b49dfd32-0901-4076-ac7d-3cc63022ab17",
"text": "It is simply a symbol (usually a letter) that represents an unknown number or value",
"status": "wrong",
"feedback": ""
},
{
"id": "517df35b-0f1e-46bc-aae1-39f8658180f7",
"text": "A named storage location in a computer's memory that holds a data value",
"status": "correct",
"feedback": ""
}
],
"quest_mode": "exploration",
"isCollapsed": false,
"questionText": "What is a variable in programming? "
},

Schema

export const choiceObjectSchema = z.object({
id: z.string(),
text: z.string(),
feedback: z.string().optional(),
status: z.enum(["correct", "partial", "wrong"]).optional(),
points: z.number().min(0).default(1),
quest_mode: z.enum(["exploration", "linear"]).optional(),
});
 
export const questionTypeSchema = z.enum([
"multiple-choice",
"true-false",
"checkbox",
]);
 
export const questionNodeDataSchema = z
.object({
label: z.string().optional(),
type: z.string(),
questionType: questionTypeSchema.optional(),
questionText: z.string(),
choices: z.array(choiceObjectSchema).optional(),
isCollapsed: z.boolean().optional(),
isStart: z.boolean().optional(),
isEnd: z.boolean().optional(),
quest_mode: z.enum(["exploration", "linear"]).optional(),
isArchived: z.boolean().optional(),
})
.catchall(z.unknown());

API Specification

GET /api/creator/update-quest-canvas?quest_id=
Load canvas state for creator.
 
GET /api/reviewer/get-canvas?quest_id={questId}
Loads canvas metadata for readonly reviewer mode.
 
PUT api/creator/update-quest-canvas
Triggered whenever a user adds, removes, or repositions elements on the canvas.
 
POST /api/ai/compute-canvas-layout
Used during exploration-mode AI imports and smart merges to compute better node positions.

LOGIC & WORKFLOW

Main Workflow

  1. The creator goes to the Visual Canvas.

  2. The creator drags the Question Node from the Node Bar and onto the Canvas.

  3. The Creator selects a Question Type: Multiple Choice, True or False and Checkbox.

  4. The creator adds choices on the Question Node.

  5. The creator creates branching paths, dragging an edge from the choice handles and connecting it to another node.

  6. The creator clicks the save button to save current changes on the Canvas.

Logic

A Question Node can exist in two modes:

  1. As a normal canvas top-level node.

  2. As a child inside a Scenario Node.

The distinction is controlled by isChild . When isChild is false, the question updates itself directly in React Flow’s global nodes array. Meanwhile, when isChild is true, the question is embedded inside a Scenario’s data.children , so it updates through the parent Scenario’s onUpdate callback.

Rendering a Question Inside a Scenario

In the ScenarioNode component, each child is rendered like this:

children.map((child) => (
<SortableChildWrapper
key={child.id}
child={child}
onUpdate={(newData) => updateChild(child.id, newData)}
/>
))

Inside SortableChildWrapper , the code picks the correct component based on child.type

const ChildComponent = childNodeComponents[child.type];

So, if the child type is ”Question” , it renders the Question Node.

Updating Question Data Inside a Scenario

In the component, QuestionNode.tsx , collpase state is updated through:

updateNodeData(id, !!isChild, onUpdate, {
isCollapsed: !isCollapsed,
});

The important part is !!isChild.

That calls updateNodeData from useNodeActions.ts

Its logic is:

if (isChild && onUpdate) {
onUpdate(newData);
} else {
setNodes(...)
}

So for a Scenario child, it does not directly search React Flow nodes. Instead, it calls the Scenario’s onUpdate.

That onUpdate points back to updateChild in useScenarioNodeFunctions.ts

const updatedChildren = node.data.children.map((child) =>
child.id === childId
? { ...child, data: { ...child.data, ...newData } }
: child
);

Step by step:

  1. User edits the child Question.

  2. QuestionNode calls updateNodeData(...).

  3. updateNodeData sees isChild === true.

  4. It calls onUpdate(newData).

  5. Scenario’s updateChild finds the matching child by ID.

  6. It merges newData into that child’s data.

  7. The parent Scenario node is updated in React Flow.

So the state path is:

QuestionNode input
→ updateNodeData
→ onUpdate
→ updateChild
→ ScenarioNode.data.children[index].data
→ React Flow setNodes

Dropping A Question Into A Scenario
A normal canvas QuestionNode can be dragged into a Scenario.

In QuestionNode.tsx

if (isChild) return;
 
e.dataTransfer.setData("canvas-node-id", id);
e.dataTransfer.setData("node-type", data.type);

So only top-level questions can be dragged into a Scenario. If the question is already a child, dragging is blocked.

Then the Scenario drop zone handles the native drop in useScenarioNodeFunctions.ts

On drop:

  1. It reads the dragged canvas node ID.

  2. It checks whether the drop happened in the center zone.

  3. It gets the original React Flow node.

  4. It converts it into a ChildNode.

  5. It removes the original node from the canvas.

  6. It appends the new child to scenario.data.children.

The conversion happens here:

const newChild: ChildNode = {
id: nodeToConvert.id,
type: nodeToConvert.type as any,
label: (nodeToConvert.data.label as string) || "",
data: nodeToConvert.data as any,
};

Then this removes the top-level node:

.filter((n) => n.id !== canvasNodeId)

And this adds it into the Scenario:

children: [...currentChildren, newChild]

So a dropped Question is no longer a standalone React Flow node. It becomes embedded data inside the Scenario.

Scenario Rules For Questions
The Scenario has a rule: only one Question can exist inside a Scenario.

That check appears in ScenarioNode.tsx

const hasQuestionChild = children.some((child) => child.type === "Question");

And in useScenarioNodeFunctions.ts

if (draggedCanvasNode.type === "Question") {
const hasQuestion = children.some((child) => child.type === "Question");
if (hasQuestion) {
e.dataTransfer.dropEffect = "none";
return;
}
}

So if the Scenario already contains a Question, another Question cannot be dropped into it.

The UI message also reflects this:

if (hasQuestion) return "Only one question per scenario";

Why Scenario Handles Change When It Has A Question
In exploration mode, if a Scenario contains a Question, the Scenario hides its own outgoing source handles.

In ScenarioNode.tsx

const hideScenarioSourceHandles =
data.quest_mode === "exploration" && hasQuestionChild;

Then handle filtering does this:

if (hideScenarioSourceHandles) {
return handle.type === "target";
}

Meaning: once the Scenario has a Question child, branching should probably happen through the Question’s choices, not through the Scenario container itself.

That is the key scenario-question logic.

Choice Handles Become Branching Points
Each choice in a Question gets its own React Flow source handle.

In QuestionNode.tsx

<Handle
type="source"
position={Position.Right}
id={choice.id}
/>

So each answer choice can connect to a different next node.

That means the branching model is:

Scenario
contains Question
Choice A handle → next node
Choice B handle → another node
Choice C handle → another node

This is why the Scenario hides its own source handles when it contains a Question. The Question’s choices take over the routing.

Adding Or Editing Choices
When the user saves a choice, QuestionNode.tsx runs handleSaveChoice.

The workflow:

  1. Validate that choice text is not empty.

  2. Determine question type.

  3. For true-false, force status to "correct".

  4. If editing, replace the matching choice.

  5. If adding, generate a new ID.

  6. Save choices through updateNodeData.

The save line is:

updateNodeData(id, !!isChild, onUpdate, { choices: updatedChoices });

Again, if this Question is inside a Scenario, that update travels upward through onUpdate and changes the embedded child data.

Deleting A Choice
In QuestionNode.tsx, deleting a choice calls:

deleteChoice(id, !!isChild, choiceList, choice.id, onUpdate)

deleteChoice removes the choice from the node data and also removes edges connected to that choice handle:

setEdges((eds) => eds.filter((e) => e.sourceHandle !== choiceId));

So if Choice A was connected to another node, deleting Choice A also removes that connection. That prevents stale edges from pointing to handles that no longer exist.

Extracting A Question Back Out Of A Scenario
Scenario children can also be dragged out.

That happens in useScenarioNodeFunctions.ts

The hook tracks pointer position. If drag ends outside the Scenario bounds:

  1. Find the child being dragged.

  2. Remove it from scenario.data.children.

  3. Convert it back into a React Flow node.

  4. Place it at the pointer position on the canvas.

The new canvas node is built here:

const newNode = {
id: childToExtract.id,
type: childToExtract.type,
position: newPosition,
data: {
...childToExtract.data,
label: childToExtract.label,
},
};

So the same Question can move between these two states:

Top-level React Flow node
Scenario child inside Scenario.data.children

INFRASTRUCTURE & OPERATIONS

Dependencies

Dependency

Notes

React Flow node/edge state

Canvas renders entirely from nodes/edges state — no separate store

dnd-kit

A modern drag and drop toolkit for reordering child nodes within a Scenario node

Monitoring & Alerting

The Question Node/Scenario Logic is mostly client-side React state inside React Flow, so failures would usually show up as UI bugs rather than backend errors.

Logs currently go to:

  • Browser console for frontend runtime issues

  • Sever/VPS logs for API routes that use console.error

For this specific workflow, the useful metrics monitor would be:

  • Drop conversion failures: Canvas Question Nodes dragged into a Scenario Node but not added to children

  • Invalid Scenario state count: Scenario has more than one Question child, which should be impossible by current rules.

  • Orphaned choice-edge count: edges whose sourceHandle points to a deleted/nonexistent choice ID.

  • Child update failures: isChild === true but onUpdate is missing, causing embedded Question edits not to persist.

  • Canvas save failure after Scenario/Question edits.

  • React Flow rendering errors after adding/removing choices, because choice handles are dynamically created.

Deployment Plan

Pre-deploy Validation

  • Run npm run build

  • Run relevant tests if available.

  • Manually test:

    • Drag Question into Scenario

    • Confirm only one Question Node can be added in a Scenario Node

    • Add/edit/delete choice inside Scenario Node

    • Connect choice handles to other nodes

    • Delete a choice and verify its edge disappears

    • Drag Question back out of Scenario Node

    • Save and reload the quest

Database migration

  • No migration needed if the data shape stays inside existing canvas JSON.

Staged rollout

  • Deploy first to staging

  • Test old quests with no Scenario children

  • Test quests with existing Scenario children

  • Test newly created Scenario + Question branching

  • Verify saved canvas JSON still reloads correctly

Production rollout

  • Deploy during low editor traffic

  • Watch server logs

  • Have rollback ready

Post-deploy checks

  • For a top-level Question Node:

    • Drag and drop a Question Node onto the Canvas

    • Add question and choices

    • Connect each choice to different nodes

  • For a Child Question Node:

    • Drag and drop a Scenario Node onto the Canvas

    • Drop in one Question Node into the Scenario Node

    • Add choices

    • Connect each choice to different nodes

  • Save, refresh, and verify edges/choices survive reload

  • Delete a choice and confirm the associated edge is gone


TESTING & QUALITY ASSURANCE

Test Strategy

  • As of now, this feature was mainly covered through manual QA rather than dedicated automated tests. Recommended coverage:

  • Unit Tests:

    • Preventing more than one Question child inside a Scenario

    • Updating a child Question Node through onUpdate

    • Adding/editing/deleting choices

    • Deleting a choice removes edges using that choice’s sourceHandle

    • Toggling isCollapsed updates child data correctly

  • Integration Tests

    • Drag a top-level Question Node into a Scenario Node

    • Confirmed it is removed from top-level nodes

    • Confirm it appears inside Scenario.data.children

    • Confirm the child renders with isChild=true

    • Confirm edits to the embedded Question persist into the parent Scenario data

    • Confirm extracting a child Question Node back to the Canvas restores it as a top-level node

  • End-to-End Tests

    • For a top-level Question Node:

      • Drag and drop a Question Node onto the Canvas

      • Add question and choices

      • Connect each choice to different nodes

    • For a Child Question Node:

      • Drag and drop a Scenario Node onto the Canvas

      • Drop in one Question Node into the Scenario Node

      • Add choices

      • Connect each choice to different nodes

    • Save, refresh, and verify edges/choices survive reload

    • Delete a choice and confirm the associated edge is gone

  • Test Negative Cases:

    • Attempt to drop a second Question Node into the same Scenario Node

    • Delete a choice with an active edge

Known Limitations

  • If malformed canvas JSON already exists, the UI may not gracefully repair it

  • There does not appear to be a feature flag for Scenario Question branching

  • Scenario child data is embedded JSON, so querying or validating it at the database level is limited


MAINTENANCE & SUPPORT

Troubleshooting

Problem: Question Node cannot be dropped into Scenario Node

Check:

  • Does the Scenario Node already contain a Question Node?

  • Is the pointer in the center drop zone?

  • Confirm dataTransfer contains canvas-node-id

Problem: Question Node appears inside Scenario Node but edits do not save

Check:

  • Is isChild={true} passed from SortableChildWrapper?

  • Is onUpdate={(newData) => updateChild(child.id, newData)} passed?

  • Inspect updateNodeData ; child updates require onUpdate

  • Confirm Scenario.data.children[index].data changes after editing

Problem: Scenario still has outgoing handles even with Question child
Check:

  • quest_mode should be "exploration".

  • Scenario must have a child with type === "Question".

  • hideScenarioSourceHandles should evaluate true.

Changelog

  • 1.0 (Feb 23, 2026) : Question Node Initial Implementation

  • 1.1 (Mar 12, 2026) : Render Scenario Logic on Quest Player

  • 1.2 (June 9, 2026) : Render Question Node on Quest Player as an End Node


Document version: 1.0, Approved, Feature deployed to staging, 02/24/2026


Was this article helpful?