The UI is minimal and comprises just a handful of screens and elements.
The title screen.
The "You Win" screen.
The "You Die" screen.
The "Wanted" screen displayed when the player talks to the sheriff.
The "Store" screen displayed when the player talks to the outfitter.
The score overlay display during gameplay.
The UI is text-only. It was inspired by old newspapers and advertising leaflets from the 19th century.
One feature which really stands out when browsing those old references is that they use a lot of different font faces and font sizes. Sometimes, virtually every line is typeset with a different font face! We tried to reflect this style in Backcountry, too.
We leveraged system fonts which are available on most computers: Impact for big bold headers, and the default serif font for most of other text.
The font size is set in vmin unit, which is the smaller of vw and vh, which in turn are percentages of the viewport's width and height, respectively. This gave us responsive text size which changes to fit the current viewport size.
The UI elements are laid out using CSS, relative to the viewport, using position: absolute;.
The UI is built with functions returning stringified HTML. They are similar to components in modern front end libraries like React: they can be composed into a tree-like hierarchy, they can pass and receive parameters ("props"), and they can interpolate logic and data into their output.
The top-level component function receives the state object which it can then pass down to children components. It also acts as a controller and decides which UI screen to render given the current state of the game.
export function App(state: GameState) {
if (state.WorldFunc == world_intro) {
return Intro();
}
if (state.WorldFunc == world_store) {
return Store(state);
}
if (state.WorldFunc == world_wanted) {
return Wanted(state);
}
if (state.PlayerState == PlayerState.Victory) {
return Victory();
}
if (state.PlayerState == PlayerState.Defeat) {
return Defeat(state);
}
return Playing(state);
}
Component functions can take parameters ("props") and must be idempotent, i.e. they must produce the same output given the same arguments.
import {Action, GameState} from "../actions.js";
export function Defeat(state: GameState) {
return `
<div style="
width: 66%;
margin: 5vh auto;
text-align: center;
">
YOU DIE
<div style="
font: italic 5vmin serif;
">
You earned $${state.Gold.toLocaleString("en")}.
</div>
</div>
<div onclick="$(${Action.EndChallenge});" style="
font: italic bold small-caps 7vmin serif;
position: absolute;
bottom: 5%;
right: 10%;
">
Try Again
</div>
`;
}
State variables can be interpolated inside the produced HTML, like state.Gold above.
Interactions are handled through the dispatch function, bound to window.$ so that it's accessible from event handlers defined as HTML attributes, e.g. onclick="$(..)". The event handlers emit actions which are then processed by a big switch statement which contains most of the UI logic of the game in one place.
Each frame, the sys_ui system creates a new stringified HTML representation of the entire UI component tree, passing in the current game state into the top-level App component. If the produced string output differs from the previous one, the entire UI is replaced and updated through a single innerHTML assignment.
import {Game} from "../game.js";
import {App} from "../ui/App.js";
let prev: string;
export function sys_ui(game: Game, delta: number) {
let next = App(game);
if (next !== prev) {
game.UI.innerHTML = prev = next;
}
}
This may sound horrible, but it works very well for our use-case.
The UI in the game is very lightweight and there isn't much data to present, nor logic to run during UI "renders". The App(game) call is thus very fast.
The innerHTML assignment and parsing is so fast in modern browsers that it's completely unnoticeable to the user.
Thanks to the next !== prev optimization, the UI updates only when it actually differs from the previous frame. This happens during scene transitions, or when the player collects gold. Both of these things happen relatively rarely.
There aren't any forms nor input elements in the UI, so we don't have the problem of these elements losing focus and their internal state when the HTML is replaced.
Building the UI in this manner allows us to keep all the UI logic in one place (the dispatch function), and to easily interpolate data into HTML without obtaining nor storing any references to existing DOM elements, or to elements inside <template> elements.