The finished mod renders a compact battle panel containing the current arena phase, a server-time countdown, and the arena identifier. Python owns battle truth and window lifetime; Gameface owns presentation and reports its surface-sizing attempt.

Follow current event producers, callers and cleanup paths before adapting this example.

What we’re building

A fixed, passive 300 × 78 battle overlay. It appears only after the shared battle page has populated, updates from arena-period events and a four-Hz countdown, remains unfocused, and is destroyed before the page is disposed.

Final result

  • One Python mod file.
  • One native resource JSON file plus HTML, CSS, and JavaScript.
  • One panel-sized Wulf window on the passive window layer.
  • One JSON payload property and one frontend-ready command.
  • One cancellable countdown callback and one cancellable parent-readiness callback.

What you’ll learn

  • Why avatar entry, page readiness, Wulf readiness, engine readiness, DOM readiness, and surface sizing are different facts.
  • How a handwritten Wulf ViewModel crosses into Gameface.
  • How to make a native layout check and parent retry stale-safe.
  • Why resizeViewPx and show(False) are part of correctness.
  • How to destroy Python, Wulf, model, callback, and frontend resources symmetrically.

Difficulty and prerequisites

Level 4 — Gameface. Complete the Level 3 Battle Session Counter and read Standalone Gameface overlays. This standalone tutorial uses native resource registration; no injection helper is required.

Requirements and constraints

Functional requirements

  1. Show only while a PlayerAvatar and populated shared battle page own the context.
  2. Display phase, remaining whole seconds, and arena ID.
  3. Update immediately on the four-argument arena-period event.
  4. Refresh the countdown without rebuilding unchanged payloads.
  5. Remain passive and never steal battle input focus.
  6. Destroy all owned resources on page disposal, avatar exit, disconnect, or mod finalization.

Non-functional requirements

  • Python 2.7 compatibility.
  • No fullscreen transparent input surface.
  • No arbitrary creation sleep.
  • Bounded, token-guarded readiness retries.
  • Traceback-bearing diagnostics at unsafe hook and callback boundaries.
  • Explicit resource names and matching cross-runtime contracts.

Acceptance criteria

  • Repeated init() installs one listener/hook set.
  • Avatar entry alone creates no window.
  • One page population produces at most one window.
  • CSS and JavaScript baselines and Python's declared size constants agree at 300 × 78; verify the accepted native size separately.
  • The first model value renders before any data-change event.
  • Unchanged tick seconds skip publication; a period event can publish a changed phase with the same second.
  • A stale parent callback cannot recreate a destroyed window.
  • Page disposal cancels both callbacks and destroys the window before the native page.
  • Repeated fini() is safe.

Architecture

PlayerAvatar + arena period (authoritative battle context)
             ↓ copied scalars
BattlePhaseMonitor (battle-page owner)
             ↓ JSON string in one Wulf property
ViewModel → ViewImpl → panel-sized WindowImpl
             ↓ window.model.payload
Gameface JS → owned DOM

SharedPage._dispose
             ↓
callbacks cancelled → window destroyed → model released

The UI never reads BigWorld.player(). The frontend receives only presentation-ready scalars. Python never queries the DOM. The ready command is the sole reverse bridge and reports a Gameface surface-sizing attempt rather than battle-data authority or native size acceptance.

Researching the implementation

Investigation chain

Requirement: visible passive battle panel
  ↓ Owner? shared battle page + current PlayerAvatar
  ↓ View boundary? SharedPage._populate after original returns
  ↓ Window parent? current loaded Wulf main window
  ↓ Data signal? g_playerEvents.onArenaPeriodChange(period, end, length, info)
  ↓ Clock? BigWorld.serverTime(), sampled by an owned callback
  ↓ Frontend readiness? engine + DOM built + two layout frames
  ↓ Native sizing? viewEnv.resizeViewPx(exact scaled dimensions)
  ↓ Cleanup? SharedPage._dispose before original + avatar/disconnect/fini
  ↓ Update audit? recheck hooks, native declaration, bridge events, arena fields

What the source proves

  • PlayerEvents exposes a SafeEvent for arena-period changes.
  • Current consumers use the signature (period, periodEndTime, periodLength, periodAdditionalInfo).
  • Native timers derive remaining time from endTime - BigWorld.serverTime().
  • The verified battle-overlay pattern creates after SharedPage._populate and destroys before SharedPage._dispose.
  • A usable parent has a proxy and WindowStatus.LOADED.
  • Raw Gameface HTML must explicitly resize its native surface after engine and DOM readiness.

Source lookup

The monitor crosses arena, Wulf, Gameface, and browser lifecycles. Read the matching owner for each boundary:

Useful current-source searches include onArenaPeriodChange, BigWorld.serverTime, SharedPage._populate, SharedPage._dispose, WindowStatus.LOADED, and resizeViewPx. Treat them as investigation entry points and verify current registrations, callers, payloads, and teardown; aliases and resource APIs are build-sensitive.

Lifecycle and ownership

  1. Import: define inert classes, one owner, and composable wrapper functions.
  2. Initialization: subscribe global lifecycle events and install page wrappers once.
  3. Avatar entry: select a new battle generation, copy initial arena values, but create no window.
  4. Page population: mark the page ready and begin bounded parent-readiness checks.
  5. Frontend ready: center and show the already-owned window without focus once page, window and content gates all pass.
  6. Period/tick: update copied scalar state and transact only when the payload changes.
  7. Page disposal: invalidate the generation, then attempt cancellation and destruction before the previous page disposal.
  8. Avatar exit/disconnect/fini: avatar exit and disconnect deactivate the owner; finalization also attempts owned unsubscription and direct hook restoration.

State and data-authority model

StateScopeAuthorityInvalidation
Period/end timeBattleArena-period event / current arenaNew period or battle exit
Arena IDBattlePlayerAvatar.arenaUniqueIDBattle exit
Displayed secondsPresentation cacheDerived from server timeEnd-time change or tick
Window/modelBattle pageMod-owned Wulf objectsPage disposal
DOM/listenersGameface viewFrontend scriptWindow unload
Generation tokenOwnerMonotonic invalidationEvery deactivate

Implementation walkthrough

Model contract

The handwritten model registers one string property and must provide its own indexed setter. The initial JSON enters through the constructor; later updates run inside transaction(). The authored attach_model() also calls publish() during view construction, so this path does not wait for frontend readiness. publish() records _last_payload before the transaction; a failure can leave an equal later payload suppressed. A transaction does not roll back that Python cache or acknowledge display.

Readiness chain

The page chooses when creation may begin. The native resource tree supplies the layout ID before mod startup. The retry chooses when a current Wulf parent is loaded. JavaScript chooses when DOM layout is stable enough to size. No one signal substitutes for the others. The frontend calls its ready command after attempting a resize, including on later scale updates; it does not read back an accepted native size. Its unload cleanup removes registered engine/model callbacks but does not cancel queued animation frames or guard readiness continuations with a separate disposed flag. Preserve an owned disposal guard when adapting this illustrative code; these are source-visible limits, not observed client failures.

Hook contract

The wrappers call the previously installed implementation exactly once. Populate notifies after the previous method. Dispose cleans before the previous method. Finalization restores only when the class still contains this mod’s wrapper; if another mod wraps it later, safe removal cannot be guaranteed and that limitation must remain documented.

Python owner and failure boundaries

The arena producer updates its received period tuple, fires the local event and then forwards the unpacked values globally. The shared page supplies separate populate/dispose boundaries. Avatar entry copies context; it does not establish page or parent readiness.

  • The parent check runs immediately and permits at most 80 delayed retries at 0.1 seconds. This bounds attempts, not elapsed wall time. Repeated calls while waiting can supersede a generation without cancelling its earlier callback; the shared callback slot is cleared before the token check. Stale creation is rejected, but this is not a complete overlapping-callback cancellation guarantee.
  • The base window ready hook normally calls show(). This example overrides it and waits for page, window and content flags before show(False). Placement uses the parent size copied once before load; later ready commands do not refresh that snapshot.
  • The tick keeps one callback on its successful normal path. An exception before its final scheduling call can stop refresh. Cleanup is also sequential: cancellation or destruction failure can skip later releases, although the dispose wrapper logs the failure and still calls the previous page method.
  • The module and owner have separate installed flags. A layout lookup failure can leave page hooks installed with no global listeners; partial subscription failure has no rollback ledger. Conditional restoration removes only a directly installed wrapper, not one retained by a later wrapper.

These are inspected limits of this illustrative implementation, not reproduced client failures. The acceptance checklist describes behavior to verify; it does not certify these failure paths or every event/timer boundary.

Complete source code

# -*- coding: utf-8 -*-
"""Show arena phase and remaining time in a standalone Gameface panel."""
from __future__ import absolute_import, unicode_literals

# Reader map:
#   1. Wulf model/view/window classes own the Python presentation surface.
#   2. _BattlePhaseMonitor owns battle facts, callbacks, and readiness state.
#   3. Page wrappers bind that owner to the shared battle-page lifetime.

import json
import logging

import BigWorld
import constants
from Avatar import PlayerAvatar
from PlayerEvents import g_playerEvents
from frameworks.wulf import (ViewFlags, ViewModel, ViewSettings, WindowFlags,
                             WindowLayer, WindowStatus)
from gui.Scaleform.daapi.view.battle.shared.page import SharedPage
from gui.impl.pub import ViewImpl, WindowImpl
from helpers import dependency
from skeletons.gui.impl import IGuiLoader

from gui.impl.gen import R


__version__ = '1.0.1'

_LOGGER = logging.getLogger(__name__)
_MOD_NAME = 'battle_phase_monitor'
_LAYOUT = R.mods.tutorial.BattlePhaseMonitor.views.BattlePhaseMonitor
_WIDTH, _HEIGHT = 300, 78
_TICK_INTERVAL = 0.25
_MAX_PARENT_RETRIES = 80

_PERIOD_NAMES = {
    constants.ARENA_PERIOD.IDLE: 'Idle',
    constants.ARENA_PERIOD.WAITING: 'Waiting',
    constants.ARENA_PERIOD.PREBATTLE: 'Prebattle',
    constants.ARENA_PERIOD.BATTLE: 'Battle',
    constants.ARENA_PERIOD.AFTERBATTLE: 'After battle',
}


class _PhaseModel(ViewModel):
    """Expose one JSON string plus a frontend-ready command to Gameface."""

    def __init__(self, payload):
        self._initial_payload = payload
        super(_PhaseModel, self).__init__(properties=1, commands=1)

    def _initialize(self):
        super(_PhaseModel, self)._initialize()
        self._addStringProperty('payload', self._initial_payload)
        self.onReady = self._addCommand('onReady')

    def setPayload(self, value):
        self._setString(0, value)


class _PhaseView(ViewImpl):
    """Bind the model command to the battle-scoped owner."""

    def __init__(self, owner):
        self._owner = owner
        model = _PhaseModel(owner.payload)
        owner.attach_model(model)
        settings = ViewSettings(layoutID=_LAYOUT(), flags=ViewFlags.VIEW,
                                model=model)
        super(_PhaseView, self).__init__(settings)

    def _getEvents(self):
        return ((self.getViewModel().onReady,
                 self._owner.on_content_ready),)

    def _finalize(self):
        self._owner.attach_model(None)
        super(_PhaseView, self)._finalize()


class _PhaseWindow(WindowImpl):
    """Notify the owner when Wulf has loaded the native window surface."""

    def __init__(self, content, parent, owner):
        self._owner = owner
        super(_PhaseWindow, self).__init__(
            WindowFlags.WINDOW, content=content, layer=WindowLayer.WINDOW,
            name='BattlePhaseMonitor', parent=parent)

    def _onReady(self):
        self._owner.on_window_ready()


class _BattlePhaseMonitor(object):
    """Coordinate arena data, recurring ticks, and all UI readiness gates."""

    def __init__(self):
        self._installed = False
        self._page_ready = False
        self._generation = 0
        self._window = None
        self._model = None
        self._window_ready = False
        self._content_ready = False
        self._parent_size = None
        self._parent_callback_id = None
        self._tick_callback_id = None
        self._period = constants.ARENA_PERIOD.IDLE
        self._period_end_time = 0.0
        self._arena_id = 0
        self._last_seconds = None
        self._last_payload = None

        self._avatar_enter_handler = self._on_avatar_enter
        self._avatar_exit_handler = self._on_avatar_exit
        self._period_handler = self._on_period_change
        self._disconnect_handler = self._on_disconnect

    @property
    def payload(self):
        seconds = self._remaining_seconds()
        return json.dumps({
            'phase': _PERIOD_NAMES.get(self._period,
                                       'Unknown (%s)' % self._period),
            'seconds': seconds,
            'arenaId': unicode(self._arena_id) if self._arena_id else '-',
        }, separators=(',', ':'))

    def start(self):
        if self._installed:
            return
        try:
            layout_id = _LAYOUT()
        except Exception:
            _LOGGER.exception('%s: native layout lookup failed', _MOD_NAME)
            return
        if layout_id < 0:
            _LOGGER.error('%s: native layout declaration is missing', _MOD_NAME)
            return
        g_playerEvents.onAvatarBecomePlayer += self._avatar_enter_handler
        g_playerEvents.onAvatarBecomeNonPlayer += self._avatar_exit_handler
        g_playerEvents.onArenaPeriodChange += self._period_handler
        g_playerEvents.onDisconnected += self._disconnect_handler
        self._installed = True
        _LOGGER.info('%s %s: initialized', _MOD_NAME, __version__)

    def stop(self):
        if not self._installed:
            self.deactivate()
            return
        g_playerEvents.onAvatarBecomePlayer -= self._avatar_enter_handler
        g_playerEvents.onAvatarBecomeNonPlayer -= self._avatar_exit_handler
        g_playerEvents.onArenaPeriodChange -= self._period_handler
        g_playerEvents.onDisconnected -= self._disconnect_handler
        self._installed = False
        self.deactivate()
        _LOGGER.info('%s %s: finalized', _MOD_NAME, __version__)

    def _on_avatar_enter(self):
        self.deactivate()
        player = BigWorld.player()
        if not isinstance(player, PlayerAvatar):
            return
        self._arena_id = getattr(player, 'arenaUniqueID', 0)
        arena = getattr(player, 'arena', None)
        if arena is not None:
            self._period = getattr(arena, 'period',
                                   constants.ARENA_PERIOD.IDLE)
            self._period_end_time = getattr(arena, 'periodEndTime', 0.0)

    def _on_avatar_exit(self):
        self.deactivate()

    def _on_disconnect(self):
        self.deactivate()

    def _on_period_change(self, period, end_time, _length, _additional):
        self._period = period
        self._period_end_time = end_time
        self._last_seconds = None
        self.publish()

    def on_page_ready(self):
        """Begin UI creation only after the shared battle page is populated."""
        self._page_ready = True
        if isinstance(BigWorld.player(), PlayerAvatar):
            self.ensure_window()
            self._schedule_tick()

    def on_page_disposed(self):
        self.deactivate()

    def ensure_window(self):
        if not self._page_ready or self._window is not None:
            return
        self._generation += 1
        generation = self._generation
        self._load_when_parent_ready(generation, 0)

    def _load_when_parent_ready(self, generation, retry):
        """Use bounded retries and reject work from an obsolete battle."""
        self._parent_callback_id = None
        if (generation != self._generation or not self._page_ready or
                self._window is not None or
                not isinstance(BigWorld.player(), PlayerAvatar)):
            return
        parent = dependency.instance(
            IGuiLoader).windowsManager.getMainWindow()
        if (parent is None or parent.proxy is None or
                parent.windowStatus != WindowStatus.LOADED):
            if retry < _MAX_PARENT_RETRIES:
                self._parent_callback_id = BigWorld.callback(
                    0.1, lambda: self._load_when_parent_ready(
                        generation, retry + 1))
            else:
                _LOGGER.error('%s: main-window readiness timed out',
                              _MOD_NAME)
            return
        self._window = _PhaseWindow(_PhaseView(self), parent, self)
        self._parent_size = tuple(parent.size)
        self._window.load()

    def attach_model(self, model):
        self._model = model
        if model is not None:
            self.publish()

    def on_window_ready(self):
        self._window_ready = True
        self._show_if_ready()

    def on_content_ready(self):
        self._content_ready = True
        self._show_if_ready()

    def _show_if_ready(self):
        if (self._window is None or not self._window_ready or
                not self._content_ready or not self._page_ready):
            return
        width, _height = self._window.size
        parent_width, _parent_height = self._parent_size
        self._window.move(max(0, (parent_width - width) // 2), 72)
        if self._window.isHidden():
            self._window.show(False)

    def _remaining_seconds(self):
        if self._period_end_time <= 0:
            return None
        return max(0, int(self._period_end_time - BigWorld.serverTime()))

    def _schedule_tick(self):
        if self._tick_callback_id is None and self._page_ready:
            self._tick_callback_id = BigWorld.callback(
                _TICK_INTERVAL, self._tick)

    def _tick(self):
        """Refresh derived countdown state and retain one owned callback."""
        self._tick_callback_id = None
        if not self._page_ready:
            return
        seconds = self._remaining_seconds()
        if seconds != self._last_seconds:
            self._last_seconds = seconds
            self.publish()
        self._schedule_tick()

    def publish(self):
        """Cross the Wulf boundary only when serialized display state changes."""
        payload = self.payload
        if payload == self._last_payload:
            return
        self._last_payload = payload
        if self._model is not None:
            with self._model.transaction() as model:
                model.setPayload(payload)

    def deactivate(self):
        """Invalidate delayed work before cancelling and destroying resources."""
        self._page_ready = False
        self._generation += 1
        if self._parent_callback_id is not None:
            BigWorld.cancelCallback(self._parent_callback_id)
            self._parent_callback_id = None
        if self._tick_callback_id is not None:
            BigWorld.cancelCallback(self._tick_callback_id)
            self._tick_callback_id = None
        if self._window is not None:
            self._window.destroy()
            self._window = None
        self._model = None
        self._window_ready = False
        self._content_ready = False
        self._parent_size = None
        self._period = constants.ARENA_PERIOD.IDLE
        self._period_end_time = 0.0
        self._arena_id = 0
        self._last_seconds = None
        self._last_payload = None


_MONITOR = _BattlePhaseMonitor()
_INSTALLED = False
_ORIGINAL_POPULATE = None
_ORIGINAL_DISPOSE = None


def _page_populate(self, *args, **kwargs):
    """Call the previous populate first, then expose page readiness."""
    result = _ORIGINAL_POPULATE(self, *args, **kwargs)
    try:
        _MONITOR.on_page_ready()
    except Exception:
        _LOGGER.exception('%s: page-populate handling failed', _MOD_NAME)
    return result


def _page_dispose(self, *args, **kwargs):
    """Release mod state before the native page tears its owners down."""
    try:
        _MONITOR.on_page_disposed()
    except Exception:
        _LOGGER.exception('%s: page-dispose handling failed', _MOD_NAME)
    return _ORIGINAL_DISPOSE(self, *args, **kwargs)


def init():
    global _INSTALLED, _ORIGINAL_POPULATE, _ORIGINAL_DISPOSE
    if _INSTALLED:
        return
    try:
        _ORIGINAL_POPULATE = SharedPage._populate
        _ORIGINAL_DISPOSE = SharedPage._dispose
        SharedPage._populate = _page_populate
        SharedPage._dispose = _page_dispose
        _MONITOR.start()
        _INSTALLED = True
    except Exception:
        _LOGGER.exception('%s: initialization failed', _MOD_NAME)
        fini()


def fini():
    global _INSTALLED
    try:
        _MONITOR.stop()
    except Exception:
        _LOGGER.exception('%s: finalization failed', _MOD_NAME)
    if SharedPage._populate is _page_populate:
        SharedPage._populate = _ORIGINAL_POPULATE
    if SharedPage._dispose is _page_dispose:
        SharedPage._dispose = _ORIGINAL_DISPOSE
    _INSTALLED = False
[
  {
    "id": "R.mods.tutorial.BattlePhaseMonitor.views.BattlePhaseMonitor",
    "type": "Layout",
    "path": "coui://gui/gameface/mods/tutorial/BattlePhaseMonitor/BattlePhaseMonitor.html",
    "parameters": {"entrance": "BattlePhaseMonitor", "impl": "gameface"}
  }
]

Native declaration field guide: id must exactly match R.mods.tutorial.BattlePhaseMonitor.views.BattlePhaseMonitor in Python. The path resolves the packaged HTML, and parameters selects its entrance and implementation. Mount this strict JSON list at res/gui/gen_resources/tutorial_battle_phase_monitor.json; do not add legacy itemID or extension fields.

Registration and package boundaries

The client re-exports the resource root, obtains it from the GUI application, and delegates root lookup to a proxy. These Python wrappers do not expose the native JSON loader. The OpenWG 1.2.2 WG contract documents mounted registration and unique declaration filenames/IDs; this standalone example does not import that helper.

Lookup stages: the module evaluates its dotted _LAYOUT attribute during import, before init(). A missing attribute can fail there before the owner's logging handlers run. If the accessor exists, start() catches exceptions from calling it, while a negative ID takes its missing-declaration branch. Later view creation calls it again. Check import, startup and creation diagnostics separately; matching JSON and Python names does not prove mounted availability.

<!DOCTYPE html>
<html><head><meta charset="utf-8">
<link rel="stylesheet" href="BattlePhaseMonitor.css"></head>
<body><section id="panel">
  <!-- Static fallbacks keep the panel understandable before model readiness. -->
  <div class="title">BATTLE PHASE</div>
  <div class="row"><strong id="phase">Waiting</strong><span id="seconds">—</span></div>
  <div class="arena">Arena <span id="arena">—</span></div>
<!-- Load behavior after the DOM nodes it owns have been declared. -->
</section><script src="BattlePhaseMonitor.js"></script></body></html>
/* These dimensions must match Python and the JavaScript resize contract. */
html, body { width: 300px; height: 78px; margin: 0; overflow: hidden; background: transparent; }
* { box-sizing: border-box; }
/* The panel paints its own card; the native Gameface surface stays transparent. */
#panel { width: 300px; height: 78px; padding: 8px 12px; color: #f4f6f8; background: rgba(10,14,19,.78); border: 1px solid rgba(173,202,225,.55); border-radius: 4px; font: 14px/18px Arial, sans-serif; text-shadow: 0 1px 2px #000; }
.title { color: #94c9ef; font-size: 11px; letter-spacing: 1.5px; }
.row { display: flex; justify-content: space-between; align-items: baseline; white-space: nowrap; }
#phase { font-size: 21px; line-height: 24px; }
#seconds { color: #ffd77a; font-size: 19px; }
.arena { overflow: hidden; color: #aeb8c2; font-size: 11px; white-space: nowrap; text-overflow: ellipsis; }

Gameface exposes Wulf scalar properties through wrapper objects in this client generation. The frontend unwraps value before parsing JSON and explicitly registers a root-model data-change callback; otherwise the panel can remain on its HTML fallback while Python publishes fresh payloads.

(function () {
    'use strict';
    var BASE_WIDTH = 300;
    var BASE_HEIGHT = 78;
    var phaseNode = document.getElementById('phase');
    var secondsNode = document.getElementById('seconds');
    var arenaNode = document.getElementById('arena');
    var panel = document.getElementById('panel');
    var domBuiltHandler = null;
    var dataChangedCallbackId = null;
    var initialized = false;

    /* Wulf scalar properties can arrive as { value: ... } wrappers. */
    function unwrap(value) {
        if (value && typeof value === 'object' && value.value !== undefined) {
            return value.value;
        }
        return value;
    }

    /* Render only copied JSON primitives; never retain a Python-side object. */
    function render() {
        var payload = {};
        try { payload = JSON.parse(String(unwrap(window.model.payload) || '{}')); }
        catch (error) { payload = {}; }
        phaseNode.textContent = payload.phase || 'Unknown';
        secondsNode.textContent = payload.seconds === null || payload.seconds === undefined
            ? '—' : String(payload.seconds) + ' s';
        arenaNode.textContent = payload.arenaId || '—';
    }

    /* Resize the native surface as well as scaling the DOM content. */
    function applyScale() {
        var scale = Number(viewEnv.getScale());
        if (!isFinite(scale) || scale <= 0) scale = 1;
        var width = Math.max(1, Math.round(BASE_WIDTH * scale));
        var height = Math.max(1, Math.round(BASE_HEIGHT * scale));
        panel.style.transform = 'scale(' + scale + ')';
        panel.style.transformOrigin = '0 0';
        document.documentElement.style.width = width + 'px';
        document.documentElement.style.height = height + 'px';
        document.body.style.width = width + 'px';
        document.body.style.height = height + 'px';
        viewEnv.resizeViewPx(width, height);
        if (window.model && typeof window.model.onReady === 'function') {
            window.model.onReady();
        }
    }

    /* Idempotence prevents duplicate listeners if readiness converges twice. */
    function initialize() {
        if (initialized) return;
        initialized = true;
        render();
        engine.on('viewEnv.onDataChanged', render);
        dataChangedCallbackId = viewEnv.addDataChangedCallback('model', 0, true);
        engine.on('self.onScaleUpdated', applyScale);
        applyScale();
    }

    /* Listener identity and callback IDs must match their registrations. */
    function cleanup() {
        if (initialized) {
            engine.off('viewEnv.onDataChanged', render);
            engine.off('self.onScaleUpdated', applyScale);
            if (dataChangedCallbackId !== null) {
                viewEnv.removeDataChangedCallback(dataChangedCallbackId, 0);
                dataChangedCallbackId = null;
            }
            initialized = false;
        }
        if (domBuiltHandler) {
            engine.off('self.onDomBuilt', domBuiltHandler);
            domBuiltHandler = null;
        }
    }

    /* Two frames let DOM layout settle before the native surface is measured. */
    function afterLayout() {
        requestAnimationFrame(function () { requestAnimationFrame(initialize); });
    }

    /* Engine readiness and DOM readiness are independent gates. */
    var domBuilt = window.isDomBuilt ? Promise.resolve() :
        new Promise(function (resolve) {
            domBuiltHandler = function () {
                engine.off('self.onDomBuilt', domBuiltHandler);
                domBuiltHandler = null;
                resolve();
            };
            engine.on('self.onDomBuilt', domBuiltHandler);
        });
    window.addEventListener('unload', cleanup);
    Promise.all([engine.whenReady, domBuilt]).then(afterLayout);
}());
<root>
    <id>tutorial.battle_phase_monitor</id>
    <version>1.0.1</version>
    <name>Battle Phase Monitor</name>
    <description>Displays the current arena phase and countdown in Gameface.</description>
</root>

Minimal project structure

battle-phase-monitor/
├── mod_battle_phase_monitor.py
└── stage/
    ├── meta.xml
    └── res/
        ├── scripts/client/gui/mods/mod_battle_phase_monitor.pyc
        ├── gui/gen_resources/tutorial_battle_phase_monitor.json
        └── gui/gameface/mods/tutorial/BattlePhaseMonitor/
            ├── BattlePhaseMonitor.html
            ├── BattlePhaseMonitor.css
            └── BattlePhaseMonitor.js

These standalone layouts use the client-owned resource tree directly. OpenWG injection is needed only when augmenting an existing native document through an owned child model.

Packaging and installation

  1. Target WG 2.4.1.0 and validate the native declaration before packaging.
  2. Compile the Python file with the target-compatible Python 2.7 compiler.
  3. Stage bytecode, the JSON declaration and the three frontend files at the exact paths shown; archive stage/ contents without a stage/ prefix.
  4. Place aligned meta.xml at archive root.
  5. Use 7-Zip ZIP store mode (7z a -tzip -mx=0 ...) to create battle_phase_monitor_1.0.1.wotmod; verify every member is stored.
  6. Inspect and extract-test the archive, then install under mods/x.x.x.x/, replacing x.x.x.x with the actual target-client version.
  7. Restart after replacing mounted resources; native registration does not merge a helper map.

Window-layer scope: WindowLayer.WINDOW keeps native menus and queued popups above these passive panels. The minimal example does not add TAB/full-statistics hiding; that surface belongs to the battle page below the panel. Verify this limitation in the target client and follow window-layer and keyboard-routing guidance when adding visibility controls.

Testing and debugging

Static/source validation

  • Parse Python with Python 2.7 grammar.
  • Parse JSON and confirm the dotted id matches _LAYOUT.
  • Confirm HTML IDs used by JavaScript exist once.
  • Confirm 300 × 78 in Python, CSS, and JavaScript.
  • Confirm every subscription, wrapper, callback, window, model, and listener has a cleanup owner.

Runtime test matrix

These are client-test targets, not results established by the static checklist. Test the documented failure boundaries as well as successful startup and teardown.

ScenarioTarget check and source limit
First battle after client restartOne centered panel; normal movement and keys work immediately
Waiting → prebattle → battleCheck received phase updates and sampled whole seconds; the callback interval does not guarantee an exact once-per-second schedule
Resolution/UI-scale changeCheck accepted surface size, clipping and placement; this owner reuses the parent-size snapshot taken before load
Battle exitVerify successful cancellation/destruction before page disposal and no late recreation; a cleanup exception can leave partial releases
Second battleFresh arena ID and one fresh window
Missing native declaration/resourceInspect import, startup and later creation separately; a missing dotted attribute can abort import, while a startup lookup failure can leave hooks installed without owner subscriptions
CoexistenceVerify previous-method call counts, unrelated overlays and teardown with both direct and later nested wrappers

Failure modes and edge cases

  • Invalid layout ID: the native declaration or mounted path is missing or invalid; do not construct a view with it.
  • Loaded page but loading parent: the bounded callback re-resolves the main window; successful teardown cancels its stored handle, and obsolete generations reject creation.
  • Unsized Gameface surface: Wulf may fall back to a version-sensitive square size; verify readiness and resizeViewPx.
  • Window steals focus: ensure show(False) occurs from native/window readiness after content readiness.
  • Stale callback after battle: the parent retry checks generation and page readiness; the tick checks page readiness, while ready commands use separate flags. These are different guards.
  • Another mod wraps after this one: finalization cannot safely splice this wrapper out of an unknown chain; restart is the reliable cleanup boundary.
  • Mounted resources changed: restart the client and verify the native declaration and packaged path before diagnosing HTML.

Performance considerations

Each scheduled tick computes remaining seconds and compares the display cache. A changed second also builds JSON and may transact; period events and model attachment are additional publication paths. Successful ticks request the next callback at 0.25 seconds, which does not establish exact scheduling or measured cost. Gameface reacts to model data events rather than a repeating data poll. The panel has three text values and no continuous animation, entity scan, network request, or fullscreen surface. No performance benchmark was performed.

Compatibility and version sensitivity

DependencyClassificationRecheck
gui.impl.gen.RNative WG 2.4.1.0 resource treeStrict JSON, unique declaration filename, dotted accessor parity
Wulf view/window classesPrivate client UIImports, flags, layer, readiness, parent contract
SharedPagePrivate Scaleform pageImport and populate/dispose order
Arena-period eventInternal SafeEventSignature and firing sites
Gameface bridgeEmbedded runtimeEngine events, model exposure, sizing calls
Package pathsClient VFS contractNative declaration and coui:// resolution

Why this architecture?

  • Why not create on avatar entry? The loaded lobby parent may still be current; the battle page owns the correct boundary.
  • Why not fullscreen with pointer-events disabled? Wulf input ownership exists outside DOM CSS.
  • Why not let JavaScript read game state? It has no authoritative battle-object contract and would become a second state owner.
  • Why JSON? A tiny versionable payload keeps the bridge explicit without generated model files.
  • Why a callback for countdown? No event fires every displayed second, and the callback has explicit battle-page ownership.
  • Why use the native tree? The client owns registration before mod startup; this standalone layout needs no merge manager or injected child carrier.

Possible extensions

  • Add validated position persistence and Python-owned dragging.
  • Add localized phase labels while keeping stable payload keys.
  • Expose a frontend-to-Python toggle command with bounded input semantics.
  • Carry the Gameface shell into Level 5 and replace phase data with entity-presence state.

Maintenance checklist

  • Recheck Wulf/GUI module paths, SharedPage hooks, arena-event signature, and service registration.
  • Verify Wulf parent status, layer, flags, and show(False).
  • Verify view alias, native declaration path, bridge event names, and client restart behavior.
  • Match Python/model/frontend payload properties and dimensions.
  • Exercise stale callbacks and repeated battles.
  • Compile with target Python 2.7 and align manifest version.
  • Inspect archive paths and runtime VFS logs.

What you learned

A Gameface overlay is not “an HTML file on top of the game.” It is one coordinated lifecycle spanning battle context, a private native page, a Wulf parent and window, a Python model, an embedded browser, a DOM, and a sized native surface. Production safety comes from assigning each boundary one owner and invalidating every delayed crossing.