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:
- AngelScript projects the world position and submits a native placement frame.
- 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:
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:
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:
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.
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:
| Array | Contents |
|---|---|
AnchorKeys | One stable key per visible glance |
FrameNumbers | Viewport width and height, then screenX, screenY, scale, opacity, zOrder for each key |
EntryPayloads | One 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.
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.
| Value | Meaning |
|---|---|
"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:
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.
.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:
cd Mods/MyMod/WebUI
npm run build
Open the in-game CEF inspector with:
foae.GameUI.OpenDevTools
The native debug layer draws a box at every submitted position:
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.
AnchorKeysandEntryPayloadshave the same length.FrameNumberscontains2 + AnchorKeys.Num() * 5values.- 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
- Add the input bridge actions used by
onInputandonHover- Hooking Into Game Systems - Build and package the mod - Testing & Publishing