The finished overlay traces only the local player vehicle. It normalizes X/Z against the current arena bounds, flips the vertical axis exactly once for SVG, keeps a bounded trail, and stops cleanly on page disposal, avatar loss, or disconnect.
What we’re building
A passive 260×260 Gameface map card. Python samples the current player vehicle every 0.25 seconds and publishes normalized primitives; JavaScript draws the trail in an SVG viewBox. No enemy positions, roster data, terrain height, or native minimap entries are read or altered.
Final result
- A route polyline and current-position marker.
- Normalized coordinates independent of map size.
- At most 96 points and four payloads per second.
- Respawn-safe entity re-resolution.
- Complete Python, native resource, HTML, CSS, JavaScript, and manifest examples.
What you’ll learn
- Why X/Z is the horizontal world plane and Y is elevation.
- How current minimap grid code proves that UI rows invert world Z.
- Why normalized tactical state belongs on the Python side while pixels belong to the renderer.
- How callback generation tokens close cancellation races.
- Why IDs and copied floats survive lifecycle changes better than entity references.
Difficulty and prerequisites
Level 6 — Spatial systems. Complete the Level 5 entity tutorial and understand Gameface readiness, page ownership, engine callbacks, and entity re-resolution.
Requirements and constraints
Functional
- Activate only with a populated battle page and current
PlayerAvatar. - Read current arena bounds once per battle.
- Re-resolve
playerVehicleIDfor every sample. - Append only valid in-bounds normalized samples.
- Bound memory and publish only changed trails.
Non-goals
- No enemy or ally tracking.
- No claim that the line is a replay, server path, or authoritative movement record.
- No modification of the native minimap.
Acceptance criteria
- Bottom-left maps to (0,100) and top-right to (100,0).
- Zero-width or zero-height bounds disable sampling.
- Missing, stopped, or wrong-space player entities do not append points.
- Movement below the threshold does not grow the trail.
- The 97th accepted point discards the oldest.
- One owner has at most one sample callback and one parent-readiness callback.
- Every stale callback fails its generation check.
- Battle exit retains no avatar, vehicle, window, model, or callback.
Architecture
arenaType.boundingBox ──→ validate bounds ──┐
playerVehicleID ──→ BigWorld.entity ──→ X/Z ├─→ normalize + flip Z ─→ bounded float pairs
BigWorld.callback(0.25) ────────────────────┘ ↓ JSON
ViewModel → Gameface SVG
SharedPage._dispose ─→ generation++ ─→ cancel callbacks ─→ destroy viewResearching the implementation
Current source exposes bounds through sessionProvider.arenaVisitor.type.getBoundingBox(); the visitor returns arenaType.boundingBox. Native minimap_utils.getCellIdFromPosition uses X and Z, clamps to those bounds, and calculates the row as dimensions - (... + 1). That inversion is the evidence for screenY = 1 - normalizedZ. The tutorial deliberately does not reuse minimap pixel constants because its SVG owns a different rectangle.
Source lookup
Use these pages to investigate both the spatial math and the recurring sampler:
- Coordinate spaces, arena bounds and normalization, and minimap/UI rendering.
- Entity positions and matrices — why the tutorial resolves the current entity and copies X/Z primitives.
- Single pending callback and callback teardown rules — the foundation for generation-safe sampling.
- Gameface payload design and data updates — why normalized points cross the boundary instead of engine objects.
- Producer-to-consumer tracing — how to verify the bounding-box and minimap transform in a newer client.
Useful exact searches are getBoundingBox, arenaType.boundingBox, getCellIdFromPosition, playerVehicleID, BigWorld.entity, and BigWorld.callback. Treat them as investigation entry points and verify current producers, callers, callback signatures, and lifecycle behavior.
Lifecycle and ownership
- Install global context listeners and the guarded page wrapper once.
- On page population, validate the current avatar and bounds.
- Start one self-rescheduling callback carrying the current generation.
- Resolve the current player vehicle just for that callback body.
- Copy floats, publish, clear the callback handle, then schedule the successor.
- Dispose before native page teardown; avatar exit and disconnect are idempotent fallbacks.
State and data-authority model
| State | Owner | Meaning | Invalidation |
|---|---|---|---|
| Arena bounds | Battle generation | Current arena X/Z extent | Battle exit |
| Player vehicle ID | Avatar | Current lookup key | Re-read each tick |
| Trail | Mod | Accepted local observations | Battle exit / oldest point |
| SVG pixels | Frontend | Presentation only | View teardown |
Implementation walkthrough
Normalize once
nx=(x-left)/(right-left); ny=1-(z-bottom)/(top-bottom). Reject instead of silently projecting wildly invalid values; clamp only the tiny floating-point edge around a verified in-bounds sample.
Re-resolve, never retain
Respawn and leave-world transitions invalidate entity objects. The callback reads the current ID, resolves it, checks readiness and space, copies X/Z, then releases it.
Generation guard
Cancellation can race execution. Cleanup increments the generation; every callback checks it before reading state or scheduling its successor.
Complete source code
# -*- coding: utf-8 -*-
"""Render a bounded trace of the local player vehicle."""
from __future__ import absolute_import, unicode_literals
# Reader map:
# 1. The Wulf shell transports normalized primitive points to Gameface.
# 2. _RouteTrace owns sampling, bounds, callbacks, and the rolling trail.
# 3. SharedPage wrappers define when the visualization may exist.
import json
import logging
import math
import BigWorld
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 = 'personal_route_trace'
_LAYOUT = R.mods.tutorial.PersonalRouteTrace.views.PersonalRouteTrace
_WIDTH = _HEIGHT = 260
_SAMPLE_SECONDS = 0.25
_MAX_POINTS = 96
_MIN_DISTANCE_SQ = 0.000004
_MAX_PARENT_RETRIES = 80
class _TraceModel(ViewModel):
"""Expose one serialized route payload and a frontend-ready command."""
def __init__(self, payload):
self._payload = payload
super(_TraceModel, self).__init__(properties=1, commands=1)
def _initialize(self):
super(_TraceModel, self)._initialize()
self._addStringProperty('payload', self._payload)
self.onReady = self._addCommand('onReady')
def setPayload(self, value):
self._setString(0, value)
class _TraceView(ViewImpl):
"""Connect the model command to the route owner."""
def __init__(self, owner):
self._owner = owner
model = _TraceModel(owner.payload)
owner.attach_model(model)
super(_TraceView, self).__init__(ViewSettings(
layoutID=_LAYOUT(), flags=ViewFlags.VIEW, model=model))
def _getEvents(self):
return ((self.getViewModel().onReady, self._owner.on_content_ready),)
def _finalize(self):
self._owner.attach_model(None)
super(_TraceView, self)._finalize()
class _TraceWindow(WindowImpl):
"""Report when the native Wulf window is ready to be shown."""
def __init__(self, content, parent, owner):
self._owner = owner
super(_TraceWindow, self).__init__(
WindowFlags.WINDOW, content=content, layer=WindowLayer.WINDOW,
name='PersonalRouteTrace', parent=parent)
def _onReady(self):
self._owner.on_window_ready()
class _RouteTrace(object):
"""Own one battle generation, sampler, rolling route, and UI surface."""
def __init__(self):
self._installed = False
self._active = False
self._avatar = None
self._bounds = None
self._space_id = None
self._points = []
self._generation = 0
self._sample_callback_id = None
self._parent_callback_id = None
self._window = self._model = None
self._window_ready = self._content_ready = False
self._parent_size = None
self._last_payload = None
self._enter_handler = self._on_avatar_enter
self._exit_handler = self._on_avatar_exit
self._disconnect_handler = self._on_disconnect
@property
def payload(self):
return json.dumps({'points': self._points}, 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._enter_handler
g_playerEvents.onAvatarBecomeNonPlayer += self._exit_handler
g_playerEvents.onDisconnected += self._disconnect_handler
self._installed = True
_LOGGER.info('%s %s: initialized', _MOD_NAME, __version__)
def stop(self):
if self._installed:
g_playerEvents.onAvatarBecomePlayer -= self._enter_handler
g_playerEvents.onAvatarBecomeNonPlayer -= self._exit_handler
g_playerEvents.onDisconnected -= self._disconnect_handler
self._installed = False
self.deactivate()
def _on_avatar_enter(self):
self.deactivate()
def _on_avatar_exit(self):
self.deactivate()
def _on_disconnect(self):
self.deactivate()
def on_page_ready(self):
"""Validate the current battle and start exactly one sampler."""
avatar = BigWorld.player()
if not isinstance(avatar, PlayerAvatar):
return
arena = getattr(avatar, 'arena', None)
arena_type = getattr(arena, 'arenaType', None)
bounds = getattr(arena_type, 'boundingBox', None)
if not self._valid_bounds(bounds):
_LOGGER.error('%s: invalid arena bounds %r', _MOD_NAME, bounds)
return
self.deactivate()
self._active = True
self._avatar = avatar
self._bounds = bounds
self._space_id = getattr(avatar, 'spaceID', None)
generation = self._generation
self._sample(generation)
self._ensure_window(generation)
@staticmethod
def _valid_bounds(bounds):
try:
left, bottom = float(bounds[0][0]), float(bounds[0][1])
right, top = float(bounds[1][0]), float(bounds[1][1])
if not _finite_values(left, bottom, right, top):
return False
width, height = right - left, top - bottom
return (_finite_values(width, height) and width > 0 and height > 0)
except (IndexError, TypeError, ValueError, OverflowError):
return False
def _sample(self, generation):
"""Resolve the current vehicle briefly and copy only X/Z floats."""
self._sample_callback_id = None
if generation != self._generation or not self._active:
return
try:
vehicle_id = getattr(self._avatar, 'playerVehicleID', None)
vehicle = BigWorld.entity(vehicle_id) if vehicle_id else None
if (vehicle is not None and getattr(vehicle, 'isStarted', False) and
getattr(vehicle, 'spaceID', None) == self._space_id):
position = vehicle.position
point = self._normalize(float(position.x), float(position.z))
if point is not None and self._accept(point):
self.publish()
except Exception:
_LOGGER.exception('%s: sample failed', _MOD_NAME)
if generation == self._generation and self._active:
self._sample_callback_id = BigWorld.callback(
_SAMPLE_SECONDS, lambda: self._sample(generation))
def _normalize(self, x, z):
"""Map arena X/Z into renderer-owned [0,1] coordinates."""
bottom_left, top_right = self._bounds
left, bottom = float(bottom_left[0]), float(bottom_left[1])
right, top = float(top_right[0]), float(top_right[1])
if not _finite_values(left, bottom, right, top, x, z):
return None
width, height = right - left, top - bottom
if not _finite_values(width, height) or width <= 0 or height <= 0:
return None
nx = (x - left) / width
nz = (z - bottom) / height
if not _finite_values(nx, nz):
return None
if nx < -0.001 or nx > 1.001 or nz < -0.001 or nz > 1.001:
return None
return [round(min(1.0, max(0.0, nx)), 4),
round(1.0 - min(1.0, max(0.0, nz)), 4)]
def _accept(self, point):
"""Ignore tiny movement and retain only the newest bounded trail."""
if self._points:
dx = point[0] - self._points[-1][0]
dy = point[1] - self._points[-1][1]
if dx * dx + dy * dy < _MIN_DISTANCE_SQ:
return False
self._points.append(point)
if len(self._points) > _MAX_POINTS:
# Old points fall off so payload and SVG rendering stay finite.
del self._points[0]
return True
def _ensure_window(self, generation):
self._load_when_parent_ready(generation, 0)
def _load_when_parent_ready(self, generation, retry):
"""Retry parent readiness without reviving an old battle generation."""
self._parent_callback_id = None
if generation != self._generation or not self._active or self._window:
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._parent_size = tuple(parent.size)
self._window = _TraceWindow(_TraceView(self), parent, self)
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 not (self._window and self._window_ready and self._content_ready and self._active):
return
parent_width, _ = self._parent_size
# Leave the top-right slot free for compact status/census overlays.
self._window.move(max(0, parent_width - self._window.size[0] - 24), 240)
if self._window.isHidden():
self._window.show(False)
def publish(self):
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 callbacks first, then release model, window, and state."""
self._active = False
self._generation += 1
for name in ('_sample_callback_id', '_parent_callback_id'):
callback_id = getattr(self, name)
if callback_id is not None:
BigWorld.cancelCallback(callback_id)
setattr(self, name, None)
if self._window is not None:
self._window.destroy()
self._window = self._model = None
self._window_ready = self._content_ready = False
self._avatar = self._bounds = self._space_id = self._parent_size = None
self._points = []
self._last_payload = None
_TRACE = _RouteTrace()
_INSTALLED = False
_ORIGINAL_POPULATE = _ORIGINAL_DISPOSE = None
def _finite_values(*values):
return all(not math.isnan(value) and not math.isinf(value)
for value in values)
def _page_populate(self, *args, **kwargs):
"""Activate only after native page population succeeds."""
result = _ORIGINAL_POPULATE(self, *args, **kwargs)
try:
_TRACE.on_page_ready()
except Exception:
_LOGGER.exception('%s: page-populate handling failed', _MOD_NAME)
return result
def _page_dispose(self, *args, **kwargs):
"""Destroy route ownership before native page disposal continues."""
try:
_TRACE.deactivate()
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
_TRACE.start()
_INSTALLED = True
except Exception:
_LOGGER.exception('%s: initialization failed', _MOD_NAME)
fini()
def fini():
global _INSTALLED
try:
_TRACE.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.PersonalRouteTrace.views.PersonalRouteTrace","type":"Layout","path":"coui://gui/gameface/mods/tutorial/PersonalRouteTrace/PersonalRouteTrace.html","parameters":{"entrance":"PersonalRouteTrace","impl":"gameface"}}]
Native declaration field guide: id must exactly match R.mods.tutorial.PersonalRouteTrace.views.PersonalRouteTrace 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_personal_route_trace.json; do not add legacy itemID or extension fields.
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<link rel="stylesheet" href="PersonalRouteTrace.css">
</head>
<body>
<section id="panel">
<header>PERSONAL ROUTE · LOCAL SAMPLES</header>
<!-- The SVG owns normalized presentation coordinates from 0 to 100. -->
<svg viewBox="0 0 100 100" aria-label="Observed personal route">
<rect x="1" y="1" width="98" height="98"/>
<polyline id="trail" points=""/>
<circle id="current" cx="50" cy="50" r="2"/>
</svg>
<footer id="count">0 accepted points</footer>
</section>
<!-- Behavior loads after every DOM node referenced by the script. -->
<script src="PersonalRouteTrace.js"></script>
</body>
</html>
/* Keep these dimensions aligned with Python and JavaScript. */
html, body { width: 260px; height: 260px; margin: 0; overflow: hidden; background: transparent; }
* { box-sizing: border-box; }
/* The panel paints the visible card; the native surface remains transparent. */
#panel { width: 260px; height: 260px; padding: 10px; color: #dce7ef; background: rgba(9,14,20,.84); border: 1px solid rgba(150,202,230,.55); border-radius: 4px; font: 11px/16px Arial, sans-serif; }
header { height: 20px; color: #9bc8e8; letter-spacing: 1px; }
svg { display: block; width: 238px; height: 205px; background: rgba(10,25,31,.72); }
rect { fill: none; stroke: #54707d; stroke-width: .6; }
polyline { fill: none; stroke: #77d4ef; stroke-width: 1.25; stroke-linecap: round; stroke-linejoin: round; }
circle { fill: #fff; stroke: #77d4ef; stroke-width: .8; }
footer { height: 15px; color: #8e9ba5; text-align: right; }
(function () {
'use strict';
var W = 260,
H = 260,
panel = document.getElementById('panel'),
trail = document.getElementById('trail'),
current = document.getElementById('current'),
count = document.getElementById('count'),
domHandler = null,
dataChangedCallbackId = null,
initialized = false;
/* Wulf scalar properties can arrive wrapped as { value: ... }. */
function unwrap(value) {
return value && typeof value === 'object' && value.value !== undefined
? value.value : value;
}
/* Validate every primitive before converting [0,1] into SVG [0,100]. */
function render() {
var data = {points: []};
try { data = JSON.parse(String(unwrap(window.model.payload) || '{}')); }
catch (error) {}
var points = Array.isArray(data.points) ? data.points : [], out = [];
for (var i = 0; i < points.length; i += 1) {
var p = points[i];
if (Array.isArray(p) && p.length === 2 &&
isFinite(p[0]) && isFinite(p[1]))
out.push((p[0] * 100).toFixed(2) + ',' +
(p[1] * 100).toFixed(2));
}
trail.setAttribute('points', out.join(' '));
if (out.length) {
var last = points[points.length - 1];
current.setAttribute('cx', String(last[0] * 100));
current.setAttribute('cy', String(last[1] * 100));
current.style.display = 'block';
} else current.style.display = 'none';
count.textContent = out.length + ' accepted points';
}
/* Scale the DOM and resize the native Gameface surface together. */
function scale() {
var s = Number(viewEnv.getScale());
if (!isFinite(s) || s <= 0) s = 1;
var w = Math.max(1, Math.round(W * s)),
h = Math.max(1, Math.round(H * s));
panel.style.transform = 'scale(' + s + ')';
panel.style.transformOrigin = '0 0';
document.documentElement.style.width = document.body.style.width = w + 'px';
document.documentElement.style.height = document.body.style.height = h + 'px';
viewEnv.resizeViewPx(w, h);
if (window.model && typeof window.model.onReady === 'function') window.model.onReady();
}
/* Idempotence prevents duplicate handlers after repeated readiness signals. */
function init() {
if (initialized) return;
initialized = true;
render();
engine.on('viewEnv.onDataChanged', render);
dataChangedCallbackId = viewEnv.addDataChangedCallback('model', 0, true);
engine.on('self.onScaleUpdated', scale);
scale();
}
/* Remove exact listener identities and the explicit model callback. */
function cleanup() {
if (initialized) {
engine.off('viewEnv.onDataChanged', render);
engine.off('self.onScaleUpdated', scale);
if (dataChangedCallbackId !== null) {
viewEnv.removeDataChangedCallback(dataChangedCallbackId, 0);
dataChangedCallbackId = null;
}
initialized = false;
}
if (domHandler) {
engine.off('self.onDomBuilt', domHandler);
domHandler = null;
}
}
/* Engine readiness and DOM readiness are separate gates. */
var dom = window.isDomBuilt ? Promise.resolve() :
new Promise(function (resolve) {
domHandler = function () {
engine.off('self.onDomBuilt', domHandler);
domHandler = null;
resolve();
};
engine.on('self.onDomBuilt', domHandler);
});
window.addEventListener('unload', cleanup);
Promise.all([engine.whenReady, dom]).then(function () {
/* Two frames let layout settle before native resize/readiness. */
requestAnimationFrame(function () { requestAnimationFrame(init); });
});
}());
The root Wulf string property is a Gameface wrapper in this client generation. The renderer reads its value and explicitly subscribes to root-model changes; the callback ID is removed on unload so a recreated view owns one subscription.
<root><id>tutorial.personal_route_trace</id><version>1.0.1</version><name>Personal Route Trace</name><description>Draws bounded local samples of the player vehicle route.</description></root>
Minimal project structure
personal-route-trace/
├── mod_personal_route_trace.py
└── stage/
├── meta.xml
└── res/
├── scripts/client/gui/mods/mod_personal_route_trace.pyc
├── gui/gen_resources/tutorial_personal_route_trace.json
└── gui/gameface/mods/tutorial/PersonalRouteTrace/
├── PersonalRouteTrace.html
├── PersonalRouteTrace.css
└── PersonalRouteTrace.jsPackaging and installation
- Target WG 2.4.1.0 and validate the native declaration before packaging.
- Compile with the target-compatible Python 2.7 compiler.
- Stage the exact VFS paths and aligned manifest.
- Create a stored-member
personal_route_trace_1.0.1.wotmod. - Inspect and extract-test it before user-side installation.
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.
Finite geometry is a precondition for normalization: check copied bounds and position, computed positive extents, and normalized results before applying tolerance checks or clamping. NaN comparisons alone do not reject the value, and clamping can turn invalid arithmetic into an apparent edge point. Invalid geometry stays unavailable rather than becoming a fabricated coordinate.
Selected authored-helper controls use tuple bounds and inert entity substitutes for finite points, NaN/infinity, arithmetic overflow, degenerate bounds and outside points. They do not establish that the engine supplies nonfinite geometry or validate native vectors, current maps, rendering or Python 2 execution.
Testing and debugging
Deterministic tests
- Test each bound corner, center, out-of-bounds rejection, degenerate bounds, nonfinite inputs and overflowed arithmetic.
- Drive fake callbacks through cleanup and prove stale generations cannot reschedule.
- Swap the player vehicle ID and prove the next tick resolves the replacement.
- Append 120 samples and assert only the newest 96 remain.
- Compare Python/CSS/JS dimensions, message fields, and manifest version.
Runtime matrix
| Scenario | Expected |
|---|---|
| Drive to opposite map directions | Horizontal direction follows X; vertical UI direction is inverted from Z |
| Stationary | Trail does not grow |
| Respawn | Sampling pauses and resumes from the current vehicle ID |
| Battle exit during callback | No successor callback and no lobby overlay |
| Second battle | Fresh bounds and empty trail |
Failure modes and edge cases
- Asymmetric bounds: subtract the actual lower corner; never assume a centered map.
- Wrong axis: using world Y produces elevation noise.
- Double inversion: flip Z at one named boundary only.
- Dead entity reference: resolve by ID every tick.
- Cancellation race: generation validation remains required after
cancelCallback. - Oversized payload: cap points and round floats.
Performance considerations
Four small lookups per second are intentionally slower than frame rate. Distance filtering, four-decimal rounding, a 96-point ring, and payload deduplication bound CPU, memory, bridge traffic, and SVG work.
Compatibility and version sensitivity
| Dependency | Recheck |
|---|---|
arenaType.boundingBox | Shape, units, mode availability |
playerVehicleID | Respawn/observer semantics |
Vehicle.position/isStarted/spaceID | Readiness and lifetime |
| SharedPage wrapper | Populate/dispose order |
| Gameface shell | Layout, events, scaling, teardown |
Why this architecture?
- Why only the player? It teaches coordinates without creating a disguised radar.
- Why normalized floats? Tactical state stays independent of CSS pixels and interface scale.
- Why a sampled line? Position has no ordinary change event; bounded low-rate sampling matches the requirement.
- Why not patch the minimap? A standalone view owns all of its resources and avoids native entry collisions.
Possible extensions
- Split the route into distance-based segments.
- Compute approximate distance from accepted world samples before discarding world coordinates.
- Add explicit replay support only after rewind behavior is tested.
- Advance to Level 7 by persisting a bounded, schema-versioned battle journal.
Maintenance checklist
- Recheck arena-bounds, player-vehicle, entity, and callback API names/signatures.
- Trace the current bounds producer and native row inversion.
- Retest respawn, observer, replay, and space changes.
- Verify single callback ownership and stale-generation rejection.
- Check Python 2.7 syntax and Error Reporter traceback records.
- Revalidate Gameface resource path, dimensions, payload fields, and listener cleanup.
- Runtime-test the four map directions on the target client.
What you learned
Spatial mods become manageable when coordinate domains are explicit. World X/Z, normalized tactical coordinates, and SVG pixels are three different representations with one deliberate axis inversion between them. Lifecycle-safe sampling is equally important: a correct transform attached to a stale vehicle is still wrong.