Symbology Tech: Structure

Published Tuesday, May 3, 2022 by Bryan

This post is the beginning of a series of technical writeups of things I learned creating the game Symbology. Some things I demonstrate here may not be good ideas for general app development. Symbology is a game, so I consider it more art than infrastructure. That is to say, read this content as things that can be done, and not necessarily as things that should be done.

I think a good place to start this series might be with the overall structure of Symbology. The app is primarily an SVG styled by CSS and manipulated by Javascript. In fact, the only other code is a little HTML wrapping the SVG to link the three components together in a browser-friendly way.

It seems like a lot of web game tutorial content focuses on using canvas instead of SVG. I've used canvas in other projects, and the "context" model with drawing primitives is familiar from other graphics packages. This can make adaptation from other development environments relatively straightforward. You're still writing basically one language (Javascript, or something that compiles to WebAssembly), and calling a graphics package to do your rendering.

But while initially developing a Symbology prototype almost seven years ago, I was simultaneously trying to re-learn the charting library D3. That caused me to structure the code such that every time the player clicked, I updated a data structure representing the game state, passed that to D3's .data() function, and then added and removed elements in an SVG (via the DOM) to match in my handlers.

This time, I thought I would take this one step further. Why translate from a Javascript data structure to SVG, when the SVG is a data structure in its own right? So in this version of Symbology, the SVG is the app/game state. When a game piece is on a tile, the element that represents the piece is a child of the element that represents the tile. When a tile is unlit, its class is "unlit". Even which screen is displayed is controlled by which "panel"-classed element is also classed "active".

I initially went down the D3 rabbit hole because I liked how it structured my presentation in a functional-programming style. What I've arrived at this time is a presentation layer (roughly half of my code by naive line count) that is defined by declarative programming. Javascript (a mix of procedural and functional programming) receives the player interaction event, and changes the attributes and/or locations of elements in the DOM. Then SVG and CSS (entirely declarative programming) tell the browser how to render it.

This has always been the suggestion for how to structure a webpage, but it hasn't always been a great idea for web applications. It requires quite a bit of interaction with the DOM, and that has been relatively slow (or at least easy to accidentally make slow), historically. I think I have two things working in my favor, though. Number one, none of my manipulations cause page reflows (a major source of performance trouble). Number two, browser software and the hardware it's running on has advanced far enough that this doesn't seem slow anymore.

Another thing that has made this possible is advancement and wide support of HTML5 and CSS3 features, like data attributes on DOM elements. Classes are amazingly powerful for many things, and the lastest classList access to them offers much easier manipulation. But being able to set essentially arbitrary additional key/data values on an element in game logic, and then clue on them for rendering in CSS is amazingly powerful.

How about some real code examples before getting any more abstract? Let's start with this last one - data attributes. Symbology allows you to undo your most recent move, in case you accidentally tapped or saw a better move right after tapping. The button to do it is coded like this in SVG:

<path id="undo"
      d="M -49 0
         l 49 -29
         l 0 58
         z

         M -5 0
         l 49 -29
         l 0 58
         z" />

That path looks like a rewind button that most people are familiar with - two trangles with points facing leftward. Whenever the player makes a move, all of the details about the move are passed to a setUndo function in javascript. When they tap the undo button, after reading the values back out of the data attributes, a clearUndo function is called. These functions look like this:

const undo = document.getElementById("undo");

function setUndo(location, piece, trash, score=0, cleared='',
                 allClearPiece=null) {
    undo.dataset.location = location;
    undo.dataset.piece = encodePiece(piece);
    undo.dataset.score = encodeUndoScore(score);
    undo.dataset.cleared = cleared;

    undo.dataset.trash = ("add" in trash)
        ? "a"+trash.add.map((p) => encodePiece(p)).join('')
        : "r"+trash.remove.map((p) => encodePiece(p)).join('');

    if (allClearPiece) {
        undo.dataset.allClearPiece = encodePiece(allClearPiece);
    }
}

function clearUndo() {
    delete undo.dataset.location;
    delete undo.dataset.piece;
    delete undo.dataset.trash;
    delete undo.dataset.score;
    delete undo.dataset.cleared;
    delete undo.dataset.allClearPiece;
}

Don't worry too much about the encoding. Just notice that this is setting and unsetting data attributes on the #undo path element. Specifically, data-location is set when there is a move that can be reverted, and it is not set when it's not possible to undo a move. CSS is where the magic happens:

#undo {
    fill: #ffffff;
    stroke: none;
    opacity: 0.2;
    transition: transform 0.15s linear, opacity 0.15s linear;
}
#undo[data-location] {
    opacity: 1.0;
}

If there is a data-location attribute on the undo element, it is rendered an opaque white, ready to be tapped. If there is no data-location attribute, the undo element is rendered nearly-transparent, hiding itself away. In addition, when the attribute is added or removed, the button smoothly transitions between the two opacities in 150ms.

Demonstration of undo button highlight, with web inspector showing data attributes and style application

This does add a little overhead. Data attributes only support string values, so everything has to be encoded and decoded in and out of them. It would be more efficient to hold the undo data in a Javascript variable, and just add or remove a "clickable" class on the undo element. But introduces a small bit of complexity: the class and the variable have to be kept in sync. Not a huge task, to be sure, doing away with it entirely is pretty neat.

The trash is a slightly more complex case. There are two visuals going on here that represent the same state. I want to both show a "water line" summary of how full the trash is, and also show the most recent pieces added to the trash. The second bit isn't strictly necessary, but I think it makes things more visually interesting, while also helping to reinforce what the rules about the trash filling and emptying are. So, the SVG code for the trash looks like this:

<g id="trash" class="fill0">
  <path id="trashfill1" class="trashfill"
        d="M -24.67 20
           L -23 40
           L 23 40
           L 24.66 20
           z" />
  <path id="trashfill2" class="trashfill"
        d="M -26.34 0
           L -23 40
           L 23 40
           L 26.33 0
           z" />
  <path id="trashfill3" class="trashfill"
        d="M -28 -20
           L -23 40
           L 23 40
           L 28 -20
           z" />
  <g id="trashPieces"></g>
  <g class="animation">
    <use href="#trashlid" class="trashcan trashlid" />
  </g>
  <use href="#trashcan" class="trashcan" />
  <use href="#trashcanlines" class="trashcan trashcanlines" />
</g>

<!-- later, in defs -->
<path id="trashlid"
      d="M -35 -20
         L 35 -20

         M -10 -21
         L -10 -30
         L 10 -30
         L 10 -21" />

<path id="trashcan"
      d="M -28 -20
         L -23 40
         L 23 40
         L 28 -20" />

<path id="trashcanlines"
      d="M -14 -13
         L -11.5 33
         M 0 -13
         L 0 33
         M 14 -13
         L 11.5 33" />

Order matters in SVG - later elements are drawn overtop earlier elements. So the group starts out with three .trashfill paths that display the "water level". Right on top of that is a group where the recently discarded pieces will go. And then finally three use elements that reference path elements that draw the can outline. The Javascript that manipulates these elements when a piece is discarded looks like this:

const maxTrash = document.getElementsByClassName("trashfill").length;
const trash = document.getElementById("trash");
const trashPieces = document.getElementById("trashPieces");

function addTrashPiece(piece) {
    trash.classList.replace("fill"+trashPieces.children.length,
                            "fill"+(trashPieces.children.length+1));
    trashPieces.append(piece);
    return trashPieces.children.length <= maxTrash;
}

The code does three things. First it replaces the current "fill" class with the next larger "fill" class. This could have been done with a data attribute, but I wrote this code before learning about them, and haven't gone back to redo it. For now, it's an example to show contrast. Which fill class to use is computed by the number of child elements of the discarded list element, #trashPieces. Adding the newly-discarded piece to that list element is the second thing the function does. Finally, the function returns a boolean value indicating whether all the pieces fit in the trash (true) or that the trash has just overflowed, ending the game in a loss (false). Let's look at how the CSS treats this information in parts.

.trashfill {
    stroke: none;
    transition: fill-opacity 0.25s ease-in-out;
    fill-opacity: 0;
}
#trash.fill1 #trashfill1,
#trash.fill2 #trashfill2,
#trash.fill3 #trashfill3,
/* loss looks just like full, while the end-game dialog pops up */
#trash.fill4 #trashfill3 {
    fill-opacity: 0.5;
}

Here are the rules for the "water line" elements. They are all transparent by default. But, when the "fillN" class climbs above zero, the water line element matching that fill level becomes half-opaque. The transition attribute makes that an even cross-fade between the two levels. Additional rules that I've omitted here for brevity key on the fill0-4 classes to apply color to the outline, pieces, and fill for each level (white, yellow, orange, red).

#trash .piece.shape {
    stroke-opacity: 0.25;
    transition: transform 0.5s ease-in-out;
}

#trashPieces .piece:nth-child(1) {
    transform: translate(-10px, 26px) scale(0.35);
}
#trashPieces .piece:nth-child(2) {
    transform: translate(11px, 8px) scale(0.35) rotate(6deg);
}
#trashPieces .piece:nth-child(3) {
    transform: translate(-12px, -8px) scale(0.35) rotate(-10deg);
}
/* hide the final, losing piece */
#trashPieces .piece:nth-child(4) {
    display: none;
}

Meanwhile, the pieces that have been discarded into the trash are placed in the can in a sort of messy stack atop each other. The placement, visually, in the can depends on the placement, structurally, in the element, using another CSS3 feature - the :nth-child pseudo-class selector.

When a player places a piece on a game board tile while having a non-zero trash level, the level lowers one notch. A neat aspect of using position in the element to determine visual position is that it allows the element to be used like a queue. Newly discarded pieces are added to the end. The element that is removed during a tile placement is the element at the start. When that piece is removed, each remaining piece moves forward one position in the element, and therefore gets a different CSS rule applied to it, giving it a different visual position. Because of the "transition" directive, its change in visual position occurs smoothly across 500ms.

Demonstration of trash fill and unfill, with web inspector showing classes, elements, and style application

And this is why I'm so excited about all of my rendering code being in a declarative style. I didn't tell those pieces to move. The rules applied to them changed, and the browser moved them to match for me. This is what computers are meant to do! Give them rules to apply, and they apply them. Don't even tell them how to do the application - let other software figure out the best way to do that.

Having Javascript both add the discarded piece and set the fill class is trivial code. But, is there any way these could be tied together in CSS alone? I think it would be neat if just adding the piece to the list element triggered the water-level change. If you see a way to do it, please let me know!

Before I sign off on this post, I want to mention one more thing. All of the code quoted here is taken directly from the Symbology source. I haven't put that source in an open repository yet, but I'm also not minifying or otherwise obscuring any of it on the live site. So if you want to see it in situ, load up https://symbology.app/ and open your web inspector. I'll warn you that it's barely documented, but it's also hand-written (as opposed to being generated), so there's at least some human context there to help guide you.

But now I will end this post before I dig into any more examples that might force me to explain topics that I'd rather cover in more depth in another post. Thanks for reading!

Categories: Development