DEV Community

Behram
Behram

Posted on

How I built a 'Blockchain of State' for my Animation Engine

In Part 1, I explained how I built the Rendering Engine—the logic that interpolates pixels between two frames to create that "Magic Move" effect.

But if Part 1 was about Playing the movie, Part 2 is about Making it.

Building a player is hard. Building an Editor is 10x harder.

You have to manage state, history, undo stacks, and "time travel". If the user changes a box in Slide 1, what happens to its clone in Slide 2? If they group items in Slide 2, does it break the group in Slide 1?

Here is the engineering deep dive into the "Blockchain of State" that powers the editor.

1. The "Blockchain" (Source ID)

Excalidraw elements are stateless. They don't know they are part of an animation. They just exist.

To create continuity, I needed a Lineage Chain.

When you duplicate a slide (Frame 1 -> Frame 2), I don't just copy the elements. I stamp them with a sourceId.

// src/services/editor/element/ElementCloner.ts

const newId = generateId();
const sourceId = originalEl.id;

return {
    ...originalEl,
    id: newId,
    customData: {
        ...originalEl.customData,
        sourceId: sourceId, // <--- The Link
    }
};
Enter fullscreen mode Exit fullscreen mode

This creates a linked list:
Element A (Frame 1)Element A' (Frame 2)Element A'' (Frame 3)

This is my "Blockchain". It allows the renderer to look backwards and say "Oh, object xyz in Frame 2 is actually the future version of object abc in Frame 1. Let's morph them."

2. The "Forking" Problem (Isolation)

Here is a bug that kept me up at night:

  1. User creates Frame 1.
  2. Clones it to Frame 2.
  3. In Frame 2, they select 3 items and hit "Group".
  4. 😱 Suddenly, those items are also grouped in Frame 1!

Why? Because even though I changed the element IDs, I copied the groupIds array verbatim. Both frames shared the same "Group Identity".

I had to implement Isolation Mapping. When cloning a frame, I must regenerate the Group IDs so that Frame 2 is a completely independent "Fork" of reality.

Groupe Element Isolation between frames

// src/services/editor/element/ElementCloner.ts

private static createGroupIdMap(elements: Frame[]): Map<string, string> {
    const groupIdMap = new Map<string, string>();
    // Map old Group ID -> New Unique Group ID
    elements.forEach(el => {
        el.groupIds.forEach(gid => {
             groupIdMap.set(gid, generateUniqueId());
        });
    });
    return groupIdMap;
}
Enter fullscreen mode Exit fullscreen mode

Now, Step 2 is mathematically isolated from Step 1. You can group, ungroup, and mess around without destroying the past.

3. "Smart Sync" (The Merge Conflict)

The biggest friction point in animation tools is Drift.
You finish designing Slide 5, and then you realize you spelled "Architecture" wrong in Slide 1.

In a normal tool, you're doomed. You have to fix it in Slide 1, then Slide 2, then Slide 3...

I wanted a "Smart Sync" button.
When you are on Slide 1 and hit "Sync to Next", the engine runs a diff:

  1. Identify: Find the clones in Slide 2 (using sourceId).
  2. Diff: "The text changed, the color changed, but the position is different."
  3. Patch: Update the text, color, AND position to ensure the next slide is perfectly aligned with the source.

It's effectively a Git Merge Strategy for visual elements.

Smart Sync

// src/services/editor/step/StepSyncService.ts

if (nextFrameElement) {
    // 1. Update visual properties (color, text)
    updatedElement.text = sourceElement.text;
    updatedElement.backgroundColor = sourceElement.backgroundColor;

    // 2. Update position to match source (+ offset)
    // This ensures perfect alignment and fixes "Drift"
    updatedElement.x = sourceElement.x + offset.x; 
    updatedElement.y = sourceElement.y + offset.y;
}
Enter fullscreen mode Exit fullscreen mode

This feature alone turned the tool from a "toy" into a "production" editor.

4. Sub-steps (The Build Order)

Finally, I had the "Click to Reveal" problem.
You don't want to create 5 separate frames just to reveal 5 bullet points. You want one frame with 5 "Sub-steps".

But I couldn't just pick a number.

  • If you reveal a Text element, you MUST reveal its Container Box first.
  • If you reveal an Arrow, you MUST reveal both the Start and End nodes first.

If you don't enforce this, you get "Floating Text" or "Arrows pointing to nothing".

I implemented a Dependency Graph in build-order.ts:

Sub Step

// src/utils/editor/build-order.ts

export const getEffectiveBuildOrder = (element) => {
    let order = element.customData.buildOrder;

    // Physics Rule 1: Text cannot appear before its container
    if (element.containerId) {
        const containerOrder = getOrder(element.containerId);
        order = Math.max(order, containerOrder);
    }

    // Physics Rule 2: Arrows wait for targets
    if (element.startBinding) {
        order = Math.max(order, getOrder(element.startBinding.elementId));
    }

    return order;
};
Enter fullscreen mode Exit fullscreen mode

This guarantees valid physics. The user can be sloppy, but the engine corrects them.

5. UI Shortcuts (The Speed Layer)

I wanted users to "animate at the speed of thought", so I built a global shortcut system (useEditorShortcuts.ts).

Action Shortcut Logic
Nav Frames [ / ] Jumps focus to frame coordinates.
Smart Sync Shift + [ Pushes changes to the next frame.
Duplicate Shift + ] Clones frame + isolation mapping.
Step Builder Alt + ↑/↓ Changes the "Build Order" of selected elements.

The "Safe Guardian"

Shortcuts are dangerous. You don't want to switch slides while typing a text label.
I explicitly check document.activeElement to prevent this:

// src/hooks/editor/useEditorShortcuts.ts
const isTyping = ['INPUT', 'TEXTAREA'].includes(activeElement?.tagName);
if (!isTyping) { /* Enable shortcuts */ }
Enter fullscreen mode Exit fullscreen mode

6. What's Next? (The "Black Box" Problem)

I still have a lot to solve.

1. Velocity-based Timing:
Right now, my animation duration is fixed (e.g., 500ms).
This looks great for short moves, but for long-distance travel, elements "teleport" too quickly. I plan to implement dynamic duration based on pixel distance to keep the velocity consistent.

2. The User Feedback Loop:
This is where I need your help.
I have 10+ new users signing up daily, but without a direct feedback channel, I feel like I'm coding in a black box. I want to avoid the "Engineer's Overthinking Cycle."

If you love Excalidraw or animation tools, I'd love your critique.
I created a promo code postara for free access.

Please break my app and tell me what sucks.

Try it out: https://postara.io/

Top comments (0)