HomeGamesResourcesAssetsContact

Check us out
on social media

Utility AI In UE5 : Part 6

Posted on November 2026

In the previous parts of this Utility AI series, we built the foundations for NPC schedules and the data that drives their behaviour. In this part, we're going to make that data much easier to author.

Rather than editing schedule periods as a list of properties, we'll create a custom timeline directly inside the Unreal Editor's Details panel. The timeline will display the day from 00:00 to 24:00, with each schedule period represented by a bar on its own track.

What we're building: a Slate widget that paints the schedule, lets us move periods between tracks, resize their start and end times, and prevents periods on the same track from overlapping.

Step 1 – The Slate Timeline Widget

The main widget is SNpcScheduleTimeline. It receives the schedule Data Asset and a property handle for its periods array. Keeping the property handle is important: it allows the editor to modify the underlying asset through Unreal's property system rather than changing values without notifying the editor.

The widget is responsible for four things:

  • Painting the 24-hour timeline and schedule periods.
  • Calculating which track each period belongs to.
  • Handling mouse input for moving and resizing periods.
  • Updating the Data Asset through property handles.

Our widget derives from SCompoundWidget and overrides the standard Slate input and layout functions:

class SNpcScheduleTimeline : public SCompoundWidget
{
public:
    SLATE_BEGIN_ARGS(SNpcScheduleTimeline) {}
    SLATE_END_ARGS()

    void Construct(const FArguments& InArgs,
        UNPCScheduleDataAsset* InSchedule,
        TSharedPtr<IPropertyHandle> InPeriodsHandle);

    virtual int32 OnPaint(
        const FPaintArgs& Args,
        const FGeometry& AllottedGeometry,
        const FSlateRect& MyCullingRect,
        FSlateWindowElementList& OutDrawElements,
        int32 LayerId,
        const FWidgetStyle& InWidgetStyle,
        bool bParentEnabled) const override;

    virtual FReply OnMouseButtonDown(
        const FGeometry& MyGeometry,
        const FPointerEvent& MouseEvent) override;

    virtual FReply OnMouseMove(
        const FGeometry& MyGeometry,
        const FPointerEvent& MouseEvent) override;

    virtual FReply OnMouseButtonUp(
        const FGeometry& MyGeometry,
        const FPointerEvent& MouseEvent) override;

    virtual FCursorReply OnCursorQuery(
        const FGeometry& MyGeometry,
        const FPointerEvent& CursorEvent) const override;

    virtual FVector2D ComputeDesiredSize(float LayoutScale) const override;
};

We also keep a weak reference to the schedule asset and a property handle for the periods. The drag state records the selected period, whether we're moving or resizing it, the initial mouse position, the original times and track, and the current track.

Step 2 – Mapping Time to the Timeline

The timeline represents a full day. We convert a time in minutes into an X position using the available timeline width. The reverse conversion is used when the user drags a period.

// Conceptual helper: minutes in a day are mapped across the timeline width.
const float MinutesPerDay = 24.0f * 60.0f;

float TimeToX(float Minutes, float TimelineWidth)
{
    return (Minutes / MinutesPerDay) * TimelineWidth;
}

float XToTime(float X, float TimelineWidth)
{
    return (X / TimelineWidth) * MinutesPerDay;
}

In the widget, the timeline area is offset by its left padding and the hour labels. We use the allotted geometry to calculate the usable width, then draw vertical guides for each hour and labels from 0 through 24.

For interaction, the calculated time is snapped to a 30-minute interval. This makes schedule editing predictable and avoids awkward values such as 10:17.

constexpr float MinutesPerStep = 30.0f;

float SnapToHalfHour(float Minutes)
{
    return FMath::RoundToFloat(Minutes / MinutesPerStep)
        * MinutesPerStep;
}
The exact coordinate helpers should use the same timeline padding and width calculations as OnPaint. That way, the mouse position and the painted bars stay aligned.

Step 3 – Drawing Schedule Periods

OnPaint is where the widget draws its visual representation. We first paint the background, then the hour grid and labels, and finally the schedule bars.

Each period has a start time, end time and track index. Its bar position is calculated from the start and end times; its vertical position is calculated from its track. The period is drawn with a background rectangle, resize handles at either end, and text showing its time range and task information.

// The bar's horizontal bounds are derived from the period times.
const float StartX = TimeToX(Period.StartTime, TimelineWidth);
const float EndX   = TimeToX(Period.EndTime, TimelineWidth);
const float BarWidth = EndX - StartX;

// The row is derived from the period's track.
const float BarY = TimelineTop
    + (RowIndex * RowHeight);

const FVector2D BarPosition(StartX, BarY);
const FVector2D BarSize(BarWidth, BarHeight);

The actual painting uses Slate draw elements, including FSlateDrawElement::MakeBox for rectangles and MakeText for labels. We draw the resize handles on top of the bar so they remain visible and easy to target.

For readability, the bar label can show the period index, its start/end time, and a small number of task names. When a period contains more tasks than fit, the display should be shortened rather than allowing the text to spill outside the bar.

Step 4 – Calculating Tracks

Tracks are the horizontal rows in the timeline. Periods on different tracks may overlap in time, but periods on the same track must not overlap.

GetPeriodRows builds a mapping from each period's TrackIndex to a display row. We collect the used track indices, sort them, and map them to compact row numbers. This means the visual timeline doesn't need to contain empty rows just because a track index is large.

During a drag, the active period uses its temporary track index so the bar follows the mouse before the edit is committed.

TMap<int32, int32> TrackToRow;
TArray<int32> Tracks;

// Collect the tracks used by schedule periods.
Tracks.AddUnique(Period.TrackIndex);

// Sort and map them to compact display rows.
Tracks.Sort();

for (int32 Row = 0; Row < Tracks.Num(); ++Row)
{
    TrackToRow.Add(Tracks[Row], Row);
}

Step 5 – Moving Schedule Periods

When the user presses the mouse over a period, OnMouseButtonDown checks whether the pointer is near either edge. If it is, we begin a resize operation; otherwise, we begin a move operation.

For a move, we remember the original start and end times, original track and initial mouse position. OnMouseMove calculates the mouse delta, converts it to minutes, and snaps it to half-hour intervals.

The period's duration is preserved: moving the start time also moves the end time by the same amount. We clamp the result to the 24-hour day and use the vertical mouse delta to determine the target track.

Before committing the new values, the widget checks whether the period would overlap another period on the target track. If it would, the edit is rejected or constrained to a valid position.

Step 6 – Resizing Schedule Periods

Resizing uses the same mouse handling, but only one side of the period changes. Dragging the left handle updates the start time; dragging the right handle updates the end time.

Both values snap to 30-minute increments. We also enforce a minimum duration and ensure that the start remains before the end. The resize is checked against other periods on the same track so it cannot create an overlap.

// Keep a valid interval while resizing.
NewStart = FMath::Clamp(NewStart, 0.0f, Period.EndTime - MinDuration);
NewEnd   = FMath::Clamp(NewEnd, Period.StartTime + MinDuration,
    MinutesPerDay);

After a valid edit, the property handle is used to write the updated value. This keeps the Data Asset in sync with the editor and allows normal property change notifications and undo/redo behaviour to participate.

Step 7 – Preventing Overlaps

The IsOverlapping helper compares a candidate interval with periods on the same track. Two intervals overlap when each begins before the other ends. Touching at an endpoint is allowed, so one period can finish exactly when the next begins.

bool IsOverlapping(
    float CandidateStart,
    float CandidateEnd,
    int32 CandidateTrack,
    int32 IgnorePeriodIndex) const
{
    // For each other period on CandidateTrack:
    // overlap = CandidateStart < Other.End
    //       && CandidateEnd   > Other.Start
    // Return true when an overlap is found.
}

We ignore the period currently being edited. Otherwise, it would always overlap itself and prevent every drag operation.

Once the user releases the mouse, NormalizeTracks compacts the track indices. It finds the tracks that are actually in use, sorts them, and remaps them to consecutive values starting at zero. This keeps the data tidy after periods have been moved between tracks.

Step 8 – Adding the Timeline to the Details Panel

We don't want the timeline to be a separate editor window. Instead, we create a custom Details panel layout for the schedule Data Asset.

FNpcScheduleDataAssetDetails implements IDetailCustomization. In CustomizeDetails, we retrieve the object being edited, cast it to our schedule Data Asset, get the property handle for the periods array, and add the timeline widget to a custom category.

class FNpcScheduleDataAssetDetails
    : public IDetailCustomization
{
public:
    static TSharedRef<IDetailCustomization> MakeInstance();

    virtual void CustomizeDetails(
        IDetailLayoutBuilder& DetailBuilder) override;
};

The customisation creates a category such as TimelinePreview and adds an SBox containing our SNpcScheduleTimeline. The SBox gives the widget a predictable width and lets it determine its height from the number of tracks.

IDetailCategoryBuilder& TimelineCategory =
    DetailBuilder.EditCategory("TimelinePreview");

TimelineCategory.AddCustomRow(FText::FromString("Schedule Timeline"))
    .WholeRowContent()
    [
        SNew(SBox)
        .MinDesiredWidth(800.0f)
        [
            SNew(SNpcScheduleTimeline, ScheduleAsset, PeriodsHandle)
        ]
    ];

Because the timeline is embedded in the Data Asset's Details panel, designers can edit the schedule visually while retaining access to the normal properties.

Step 9 – Registering the Details Customisation

The final step is registering our custom Details layout with the Unreal Editor. This is done in the editor module's startup function.

void FYoutubeTutorials_V3EditorModule::StartupModule()
{
    FPropertyEditorModule& PropertyEditor =
        FModuleManager::LoadModuleChecked<FPropertyEditorModule>(
            "PropertyEditor");

    PropertyEditor.RegisterCustomClassLayout(
        "NPCScheduleDataAsset",
        FOnGetDetailCustomizationInstance::CreateStatic(
            &FNpcScheduleDataAssetDetails::MakeInstance));

    PropertyEditor.NotifyCustomizationModuleChanged();
}

When the editor module shuts down, we unregister the layout and notify the Property Editor module again. This is important for clean module reloads and editor development.

void FYoutubeTutorials_V3EditorModule::ShutdownModule()
{
    if (FModuleManager::Get().IsModuleLoaded("PropertyEditor"))
    {
        FPropertyEditorModule& PropertyEditor =
            FModuleManager::GetModuleChecked<FPropertyEditorModule>(
                "PropertyEditor");

        PropertyEditor.UnregisterCustomClassLayout(
            "NPCScheduleDataAsset");

        PropertyEditor.NotifyCustomizationModuleChanged();
    }
}

Make sure the editor module's Build.cs includes the relevant editor dependencies, including PropertyEditor, Slate and SlateCore, as well as the module containing your schedule Data Asset.

Step 10 – The Finished Editor

With everything registered, opening an NPC Schedule Data Asset now displays the custom timeline in its Details panel. Schedule periods are visible across the day, their tracks are compacted, and we can move or resize them directly with the mouse.

What's Next?

We've now made NPC schedules significantly easier to author. The next step is to connect this schedule data to the NPC runtime so that characters select and execute the appropriate tasks as the in-game time advances.