Under the hood of the League Client’s Hextech UI

Hi! I’m Jules Glegg, a Software Architect working on the League client update. We’ve been fielding a lot of questions about how the updated client’s Javascript/HTML/CSS works, and there’s a huge amount to cover there. I’m here to give a very broad overview of the most interesting parts of our UI architecture - we won’t get to cover every bolt and weld in this post alone, but we should get a good feel for the general shape of things.

If you see something you want to know more about, drop a comment and let us know! For now though, let’s dive in.

Anatomy of a League Client

Let’s take a moment to catch up on the overall architecture of the League client update.

Any given build of the League client is expressed as a list of units called plugins. Each plugin could be anything from a shared utility (like audio playback), to a single screen (like the splash art you see when you first launch the client), to a complex data flow (like patching the game). Back-end plugins that deal purely with data are written as C++ REST microservices, and front-end plugins that deal with presentation are written as Javascript client applications and run inside Chromium Embedded Framework, which we bundle alongside the client.

In all cases, each plugin is self-contained, can be independently developed, built, tested, and deployed, and uses Semver to communicate changes in its API.

{
	"id": "league_client_next_release",
	"plugins": [
		{"name": "rcp-be-login",          "version": "1.1.0"},
		{"name": "rcp-be-lol-patcher",    "version": "0.2.3"},
		{"name": "rcp-be-lol-gameflow",   "version": "1.4.4"},
		...
		{"name": "rcp-fe-audio",          "version": "1.0.0"},
		{"name": "rcp-fe-lol-uikit",      "version": "1.2.7"},
		{"name": "rcp-fe-lol-navigation", "version": "2.5.1"},
		{"name": "rcp-fe-lol-home",       "version": "1.2.11"},
		{"name": "rcp-fe-lol-gameflow",   "version": "1.1.3"},
	]
}

A JSON file describing how to construct a League Client

The rough idea is that when we feed the build system a list of plugins, we get a League client build - as long as the list of plugins satisfies each plugin’s dependencies and doesn’t contradict itself. This is the engine that allows engineers to rapidly create new, experimental iterations of the client without disrupting others, and for us to scale to many dozens of teams all working on the same client at once.

The world I’m describing might be very familiar to any of you who have worked with microservice architectures - and that’s no coincidence! The League client update really is a desktop deployment of an entire constellation of microservices.

Anatomy of a Feature

Deploying a bunch of self-contained codebases is one thing, but how do we get from there to a single cohesive, polished experience? We really want to have our cake and eat it too here - the seams between features need to be rock-solid in code, but invisible when it comes to the actual experience of playing League.

The secret, as it turns out, isn’t in the plugins - it’s between them, in the APIs they provide to one another. If the APIs are thoughtfully designed, any arbitrary combination of features can run cooperatively to create an amazing client experience.

In a traditional, monolithic web application the classic pattern is for dependencies to flow downwards. If your application has some kind of top-level navigation, for example, it probably contains a hardcoded list of navigation items and knows how to switch between screens.

In the League client, the common pattern is for dependencies to flow upwards. In this way, plugins register themselves with various systems (like Navigation) at runtime.

With the architecture laid out like so, we can completely isolate features from one another. If the Store Plugin is not present in a build, it won’t ever register a Navigation item or attempt to take control of the screen - it’s just gone. If the Chat feature is not present, it won’t ever register its controls in the Settings menu. And crucially, if ${SUPER_SECRET_FEATURE} isn’t in the build, it can’t be data mined and revealed early. Sorry, Moobeat. We love you but some secrets are too juicy to be Surrendered at 20.

Anatomy of a Front-end Plugin

We require each front-end plugin to provide a very small bootstrapper, which allows us to initialize each plugin with the right dependencies during startup. It looks something like this:

// Listen for the initialization event
pluginContext.on(EVT_PLUGIN_INIT, function(evt) {
    let register = evt.registerPublicApi;
    // Call back with the public API for this Plugin
    // This example just plays a sound
    register(function(dependencies) {
        // Get the audio engine from dependencies
        const audio = dependencies.get(‘rcp-fe-audio’);
        // Return an API which plays sound on the Voice channel
        return {
            ‘makeNoise’: function() {
                audio.getChannel(`vo`).playSound(LUX_LAUGH_URL);
            }
        };
    });
});

 

From this basic stanza onwards, it’s really all Just Web - you could initialize a complex Javascript application, use NPM modules, start up a data service or web worker, provide facilities for other plugins to use, or some combination of the above. Let’s take a look at a handful of things we could do from here to make our feature real.

Write an Ember Component or use NPM Modules

Ember has a truckload of super convenient features for developers (computed properties ftw), but it also squats the global namespace - a huge no-no in plugin land. To use Ember, we needed to make some modifications.

Firstly, we needed to keep memory usage down. Javascript libraries tend to be much, much larger in memory than their compact little source files would indicate, so we need to find a way to deduplicate them across plugins. For most packages, this is fairly simple as NPM modules can be compiled into plugins using Webpack and then provided to other plugins using an API like so: `const NeatLib = commonLibsPlugin.getNeatLib(‘v1’);`

Secondly, we needed to support multiple Ember versions. League of Legends puts new things in the hands of players every 2 weeks, so we can’t afford to put the client in dry-dock to upgrade our usage of Ember client-wide. Instead we support a small list of Ember versions and obsolete our usage of an old version before adding a new one, keeping our practices up-to-date on an incremental basis.

By default, Ember assigns things to the global scope. If we try to load two versions at the same time, they’ll collide and create many exciting bugs. That means we needed to put Ember in jail. The technique for doing this falls absolutely into the category of “if it’s stupid and it works, it’s not stupid.” Candidly, I feel embarrassed to reveal how we did it. We use a custom Webpack Loader capable of wrapping libraries which have side-effects on the global scope. Here’s what it looks like:

module.exports = function(env) {

  var out = {};

  (function() {

    var globalScope = window;
    var preGlobals = new Set(Object.keys(globalScope));

    // Inject given vals into global scope
    Object.assign(globalScope, env || {});

    {{{source}}}

    var postGlobals = Object.keys(globalScope);
    var newGlobals = postGlobals.filter(function(k) { return !preGlobals.has(k)});

    newGlobals.forEach(function(exported) {
      out[exported] = globalScope[exported];
      delete globalScope[exported];
    });

  })();

  return out;
};

 

Yes, it’s stupid, but it has exactly the effect we need. Once we’ve jailed Ember this way, any plugin can grab a specific version’s instance using the API: `const Ember = emberLibsPlugin.getEmber(‘v2.9’);`.

Data synchronization

In our previous article on the League client update, Andrew mentioned that the two halves of the client communicate over REST. That very easily solves for simple fetching or sending of data, but what about pushing data to the UI to ensure it stays up to date? To do that, we add one little bit of high-voltage magic - a WebSocket that allows the front-end plugins to observe back-end plugins for changes. This socket essentially tells the front-end “Hey - if you had just requested $URL, you would have noticed that the value has changed. Here’s the latest value.”

This design completely removes the overhead of having features talk directly to each other. The need for any kind of shared data layer - exactly the kind of system that could very easily slow down our iteration speed - evaporates completely.

To make this convenient, we created a small library called riotclient-data-binding which looks very much like your favorite REST client, plus support for the REST+WS architecture:

// One-time get
dataBinding(‘/lol-chat’).get(‘/v1/buddies’).then(updateList);
// Continuous observe
dataBinding(‘/lol-chat’).observe(‘/v1/buddies’, updateList);

Animations and Video

Because the League client is patched out to players’ local drives, it doesn’t have the same immediate bandwidth constraints that most web applications have to work with. That opens up opportunities to use HTML5 Video in surprising ways to add some magical polish to the client.

To make implementation of complex video-based elements simpler, we created a state machine library based on Web Components that can be used to create simple groups of video with state-to-state transitions for hover, click, and other events. Unlocking artists to work with video directly in the client is a huge bonus.

<!--  This code defines what happens to the countdown ring when you click 'accept'
      on the readycheck modal. There's a quick interstitial "intro" video which is
      played when entering the "accept" state and a looping "idle" video which plays
      continuously while we wait for other players to accept the matchup. -->
<lol-uikit-video-state state="accept">
  <lol-uikit-video
    type="intro"
    src="/fe/lol-ready-check/timer-accept-intro.webm"
    fade-in="200ms"
    fade-out="300ms"
    preload>
  </lol-uikit-video>
  <lol-uikit-video
    type="idle"
    src="/fe/lol-ready-check/timer-accept-idle.webm"
    fade-in="200ms"
    fade-out="300ms"
    preload>
  </lol-uikit-video>
</lol-uikit-video-state>

Video is great for ethereal, magical effects or highly-detailed mechanical animations, but there are a great many animations in the League client update that are much simpler. For those, we use a combination of CSS Animations and CSS Transitions. For consistency, we distribute a shared Stylus mixin with preset timings and easing functions to give animations that Hextech feel, client-wide.

Providing different easing functions allows animations to convey priority. In the leftmost example, pieces land softly in position - whereas on the right they snap into place to grab your attention and communicate urgency.

Audio

League has a surprisingly complex soundscape, and we wanted to ensure that plugins would make sound in a cooperative manner that respects player preference and sets an appropriately dramatic mood during Champ Select.

To achieve this, we provide a number of purpose-specific audio channels - UI SFX, Notifications, Music, Voiceover, etc. - through a plugin dedicated to managing audio. Using the fantastic Web Audio API we’re able to wire those channels together to ensure that VO always causes background music to duck slightly, that notifications take priority over ambience, and so on. The audio plugin also pays direct attention to the player’s volume settings, removing that burden from individual features and cutting down a potential source of bugs.

Typography and Shared styles

Chromium has excellent type support, and we take full advantage of CSS @font-face declarations to define type throughout the client. A single Plugin injects the appropriate fonts for the region you’re playing in, while a Stylus library allows plugins to apply the right type styles to their UI.

Click to open in a new window.

Stylus sees usage in many other plugins, and is our means of sharing common CSS snippets such as palette colors, content layouts, and more, using Stylus’ mixin system.

Shared components

Shared styles and colors are one thing - but to make a consistent experience client-wide you need a palette of globally-consistent buttons, dropdown menus, tooltips, dialogs, and other complex entities.

We use straight-up native Custom Elements with heavy usage of Shadow DOM (AKA Web Components) for these items. These native APIs are new to the web but are super powerful for creating truly encapsulated, portable UI pieces.

If you’re not familiar with Shadow DOM, it’s a way of creating complex arrangements of HTML and CSS that aren’t affected by other styles in the page. This behavior is perfect for sealing off components and guaranteeing that they will look and behave consistently throughout the client.

As a developer implementing a page, I can type:
	
<lol-uikit-magic-button>
	I am like, super magic
</lol-uikit-magic-button>

But through the magic of Shadow DOM, the browser will render:

<lol-uikit-magic-button>
    <div class="lol-uikit-primary-magic-button-wrapper">
        <div class="button-frame-idle"></div>
        <div class="button-frame-interactive"></div>
        <div class="left-rune-magic"></div>
        <div class="right-rune-magic"></div>
        <div class="radial-container"><div class="radial-effect"></div></div>
        <lol-uikit-animated-border-overlay class="border-animation-container" speed="15000" />

        <div class="lol-uikit-primary-magic-button-text">
            <content>I am like, super magic</content>
        </div>
    </div>
</lol-uikit-magic-button>

The end result of combining these pieces of tech is that we can give developers the ability to invoke complex, custom UI components and add content to them without tying them to a specific template engine, Ember version, or anything else. We can also let devs share components between features that use different underlying technology. We’ve historically worked as close as possible to web features that are standardized or on the standards track, and that approach has (for us) had way more upsides than down.

And more besides!

And that’s it - a very, very broad overview of the League client update UI architecture. There’s definitely a lot more that we’d love to cover in future posts - drop a comment if there's something you'd like us to dive into!

Posted by Jules Glegg