World Glances

World glances are UI attached to a point on the campaign map. Settlement badges, army markers and battle cards are all glances. Mods can add their own for things such as landmarks, objectives, special buildings or scenario systems.

A glance has two parts:

  1. AngelScript projects the world position and submits a native placement frame.
  2. WebUI registers the React component that the game paints into the glance atlas.

The engine composites the atlas cell at the current frame's projected position. The content can be a browser frame behind without dragging behind the map when the camera moves.

Read Mod Screens (WebUI) and Hooking Into Game Systems first. A glance uses the same mod WebUI package and bridge SDK.

Files

This example adds ruin markers:

Files
Mods/
└── MyMod/
├── mod.json
├── Script/
│ └── UI/
│ └── RuinGlancesAction.as
└── WebUI/
├── src/
│ ├── index.tsx
│ └── style.css
└── dist/

There is no separate glance entry in mod.json. The AngelScript action is auto-discovered from the script root. The WebUI registration runs when the mod's normal WebUI entry loads.

Choose the IDs

Use one provider ID for the whole glance type:

Text
com.example.my-mod.ruins

The same provider ID must be passed to SendNativeModWorldGlancesFrameToUI and registerWorldGlance.

Every visible instance also needs a stable anchor key:

Text
mod:com.example.my-mod:ruin:<ruin id>

The provider ID selects the renderer. The anchor key identifies one placed atlas cell. Keep an entity's anchor key unchanged for its lifetime.

Send the native frame from AngelScript

Set bTickOnWebUIFrame on a bridge action and submit the provider's complete visible set from OnWebUIFrame. This callback runs on the camera-synchronous UI frame path.

The example assumes your mod already has a UMyRuinSystem which owns its ruin actors. Use that registry directly. Do not search the whole world for actors every frame.

AngelScript
namespace MyMod
{

USTRUCT()
struct FRuinGlanceContent
{
    UPROPERTY()
    FString Id;

    UPROPERTY()
    FString Label;

    UPROPERTY()
    bool Explored;
}

UCLASS()
class URuinGlancesAction : UBridgeAction
{
    default ActionName = "my_mod.ruin_glances";
    default bTickOnWebUIFrame = true;

    private const FString ProviderId = "com.example.my-mod.ruins";

    UFUNCTION(BlueprintOverride)
    FBridgeActionResult Execute(const FString& PayloadJson)
    {
        PushFrame();
        return UBridgeAction::Success("{}");
    }

    UFUNCTION(BlueprintOverride)
    void OnWebUIFrame(float32 DeltaSeconds)
    {
        PushFrame();
    }

    private void PushFrame() const
    {
        TArray<FString> AnchorKeys;
        TArray<float32> FrameNumbers;
        TArray<FString> EntryPayloads;

        UMyRuinSystem RuinSystem = Cast<UMyRuinSystem>(
            GetContentPackSystem(n"my_mod.ruins"));
        APlayerController PlayerController = Gameplay::GetPlayerController(0);
        if (RuinSystem == nullptr || PlayerController == nullptr)
        {
            UBridgeAction::SendNativeModWorldGlancesFrameToUI(
                ProviderId, AnchorKeys, FrameNumbers, EntryPayloads);
            return;
        }

        int32 ViewportWidth = 0;
        int32 ViewportHeight = 0;
        PlayerController.GetViewportSize(ViewportWidth, ViewportHeight);
        FrameNumbers.Add(float32(ViewportWidth));
        FrameNumbers.Add(float32(ViewportHeight));

        for (AMyRuinActor Ruin : RuinSystem.GetVisibleRuins())
        {
            FVector2D ScreenPosition;
            if (!Gameplay::ProjectWorldToScreen(
                PlayerController,
                Ruin.GetActorLocation(),
                ScreenPosition,
                true))
            {
                continue;
            }

            if (ScreenPosition.X < -80.0f
                || ScreenPosition.Y < -80.0f
                || ScreenPosition.X > ViewportWidth + 80.0f
                || ScreenPosition.Y > ViewportHeight + 80.0f)
            {
                continue;
            }

            FString RuinId = Ruin.RuinId.ToString();
            AnchorKeys.Add("mod:com.example.my-mod:ruin:" + RuinId);

            // Five placement values for this anchor, in this order.
            FrameNumbers.Add(float32(ScreenPosition.X));
            FrameNumbers.Add(float32(ScreenPosition.Y));
            FrameNumbers.Add(1.0f); // scale
            FrameNumbers.Add(1.0f); // opacity
            FrameNumbers.Add(float32(16000 + Math::RoundToInt(ScreenPosition.Y)));

            FRuinGlanceContent Content;
            Content.Id = RuinId;
            Content.Label = Ruin.GetDisplayName();
            Content.Explored = Ruin.IsExplored();

            FString ContentJson;
            FJsonObjectConverter::UStructToJsonObjectString(Content, ContentJson);
            EntryPayloads.Add(ContentJson);
        }

        UBridgeAction::SendNativeModWorldGlancesFrameToUI(
            ProviderId,
            AnchorKeys,
            FrameNumbers,
            EntryPayloads);
    }
}

}

The three arrays have a fixed layout:

ArrayContents
AnchorKeysOne stable key per visible glance
FrameNumbersViewport width and height, then screenX, screenY, scale, opacity, zOrder for each key
EntryPayloadsOne JSON string per key, in the same order

The call replaces the provider's previous frame. Send empty AnchorKeys and EntryPayloads arrays when none of its glances should remain.

Placement belongs in FrameNumbers. Keep screenX, screenY, scale, opacity and zOrder out of the JSON content struct. Camera movement then updates placement without making React repaint the atlas plate.

Register the renderer

Register at module scope in your WebUI entry. The game mounts this renderer in both the input overlay and the offscreen atlas.

TSX
import type {
  FoaeModSDK,
  ModWorldGlanceEntry,
} from "@foae/sdk";

const FOAE = globalThis.FOAE as FoaeModSDK;

interface RuinGlanceContent {
  id: string;
  label: string;
  explored: boolean;
}

function isRuinGlanceContent(value: unknown): value is RuinGlanceContent {
  if (!value || typeof value !== "object") return false;
  const ruin = value as Partial<RuinGlanceContent>;
  return typeof ruin.id === "string" && typeof ruin.label === "string";
}

function RuinGlance({ entry }: { entry: RuinGlanceContent }) {
  return (
    <div className={`ruin-glance${entry.explored ? " ruin-glance--explored" : ""}`}>
      <span className="ruin-glance__marker" />
      <span className="ruin-glance__label">{entry.label}</span>
    </div>
  );
}

FOAE.registry.registerWorldGlance({
  id: "com.example.my-mod.ruins",
  render: (frameEntry: ModWorldGlanceEntry) => {
    if (!isRuinGlanceContent(frameEntry.payload)) return null;
    return <RuinGlance entry={frameEntry.payload} />;
  },
  anchorPoint: "50% 100%",
  onInput: ({ payload, mouseButton, shiftKey }) => {
    if (!isRuinGlanceContent(payload)) return;
    void FOAE.bridge.call("my_mod.select_ruin", {
      id: payload.id,
      mouseButton,
      shiftKey,
    });
  },
  onHover: ({ payload, hovered }) => {
    if (!isRuinGlanceContent(payload)) return;
    void FOAE.bridge.call("my_mod.hover_ruin", {
      id: payload.id,
      hovered,
    });
  },
});

Do not create a second React root or append your own fixed-position overlay. registerWorldGlance owns the two render locations and the native-composite handoff.

render receives the JSON payload plus the placement values. Use the payload for presentation. The component does not need to apply the screen position, scale, opacity or z-order itself.

Anchor point and raster scale

anchorPoint says which point inside the rendered component sits on the projected world position.

ValueMeaning
"center"Centre of the component
"50% 100%"Bottom centre
"0 0"Top-left corner
"50% 14.5"Horizontal centre, 14.5 layout pixels from the top

Use a pixel value when the visual marker is somewhere inside a larger panel. The Lords of Sicily beacon glance uses the flame inside its card as the anchor rather than the card's centre.

Small text or fine line art can be rasterised at a higher resolution:

TSX
FOAE.registry.registerWorldGlance({
  id: "com.example.my-mod.ruins",
  render: renderRuinGlance,
  anchorPoint: "50% 100%",
  rasterScale: 1.5,
});

Higher raster scales consume more atlas space. Start at 1 and raise it only when the normal result is visibly soft.

CSS

Write the component in normal document flow. The atlas host positions the outer wrapper.

css
.ruin-glance {
  position: relative;
  display: flex;
  flex-direction: column;
  align-items: center;
  width: 9rem;
  pointer-events: none;
}

.ruin-glance__marker {
  width: 1.1rem;
  height: 1.1rem;
  border: 0.1rem solid #c9a84c;
  background-color: #171717;
}

.ruin-glance__label {
  padding: 0.25rem 0.45rem;
  border: 0.08rem solid rgba(201, 168, 76, 0.55);
  background-color: rgba(12, 14, 17, 0.92);
  color: #e6dec7;
}

Do not put position: fixed, a camera transform, screen coordinates or a full-screen wrapper on the component. Those belong to the host and native frame.

Frame cost

The placement method runs while the camera is moving. Keep it short.

  • Keep the glance entities in your mod system instead of scanning the world.
  • Project only candidates which can currently be shown.
  • Send presentation data in EntryPayloads; do not rebuild large unrelated responses.
  • Keep anchor keys stable.
  • Always submit the complete current set. The host removes keys missing from the new frame.

Do not update placement on a timer. Native world positions are intended to follow every camera frame.

Debugging

Build the mod WebUI after changing its renderer or CSS:

PowerShell
cd Mods/MyMod/WebUI
npm run build

Open the in-game CEF inspector with:

Text
foae.GameUI.OpenDevTools

The native debug layer draws a box at every submitted position:

Text
foae.WebUI.GlanceCompositeDebug 1

If the box appears and the component does not, check the provider IDs, the result of render, and the built WebUI files. If neither appears, check the AngelScript provider and its projected visible set.

Checklist

  • The provider ID matches on both sides.
  • Every visible entity has one stable anchor key.
  • AnchorKeys and EntryPayloads have the same length.
  • FrameNumbers contains 2 + AnchorKeys.Num() * 5 values.
  • The AngelScript action has bTickOnWebUIFrame = true.
  • The renderer is registered at module scope.
  • The rendered root has a measurable width and height.
  • The mod WebUI has been rebuilt.

Next