Writing /

Project retrospective: unacProject retrospective: unac

Published on ⋅ 20min read

Playback settings

These are my thoughts, learnings and reflections after finishing my side project, unac, a game written in Go using Datastar.

You can check out the live website here: https://unac.threadgold.nz

# What is unac?

Unac, short for Ultimate Noughts & Crosses (or Ultimate Tic Tac Toe, if you prefer), is a modified version of the classic game. Instead of playing in one 3x3 grid, you play 9 games simultaneously; that is, 9x(3x3) grids. Whenever a player selects a cell, they not only claim that cell for their own, but also choose which game the next player must play within. Each cell of an individual game maps to the larger 3x3 game grid, respectively.

I first learned about this game back in 2015 while travelling by slow boat down the Mekong river in Laos. Since then, I've coded the game several times, each using different languages/frameworks (previous iterations included React and Elm). I have found it to be a great starter project, as the scope is neither too large nor too trivial — unlike my previous article. It usually enables me to really get a feel for, and understanding of, the technology with which I am using to build it.

Before you read further, you may want to have a play around on the live website to see how it functions, or perhaps peruse the codebase on Github. While I will do my best to explain the context around code snippets that I present here, this is not intended to be a tutorial, so you may need to refer to the source code, or relevant documentation, to fill in any blanks.

That said, let's dig in!

# Embracing hypermedia with Datastar

Throughout my experience building a simple spellchecker component with Datastar, I found myself still thinking very much in a "React-minded way". Since then, I've dabbled a lot more, lurked on the Datastar Discord server and ruminated on all the insights and advice other members have shared. This time, I tried to shed the shackles of the SPA-mindset that React has instilled in me over the years and fully embrace hypermedia with Datastar.

What did that mean, in practice? PatchElements all the way!

Yep, right from the outset I completely disregarded using reactive signals and storing any client-side state. Since the game is turn-based, every user action could just be a button that would trigger a @post action immediately to the server. I used URL path parameters to indicate the relevant game/grid/cell information — no need for signals to convey what is, basically, static information, once the server has rendered the HTML.

By letting the server drive the experience, I could maintain the entire game state there and not worry about syncing with the client. At any point I could re-render the entire game view correctly, whether that be after a user interaction, such as a button click, or a page refresh. So, to "update" the game, I do just that: re-render the entire game view and push the whole game board back down to the client and let Datastar's PatchElements "outer" morph mode handle updating the relevant DOM nodes for me.

This meant that I could also limit the control surface available to the end user for any given render, i.e. only expose actions to a user when they are actually possible. For example, if a "cell" is selectable, it will render a <button>, or otherwise use a <div>:

 1if game.IsSelectable(b, c) {
 2	<button
 3		class="cell"
 4		id={ fmt.Sprintf("cell-%d-%d", b.Id, c.Id) }
 5		data-on:click={ fmt.Sprintf("@post('/%s/%d/%d')", game.Id, b.Id, c.Id) }
 6	></button>
 7} else {
 8	<div class={ "cell", c.State.String() } id={ fmt.Sprintf("cell-%d-%d", b.Id, c.Id) }>
 9		{ c.State }
10	</div>
11}

If you're unsure of what you're looking at above, I used Templ to do my templating.

You may ask, "what's so special about that?" It seems pretty straightforward. And it is. A key point here is that for any given update, the server is only ever returning the latest projection of the game state as HTML — and nothing more. The client itself doesn't, nor never needed to, know how to handle all UI states that may be possible during the lifetime of a game.

For example, if you compared this with React: your React component may have a similar logic gate, but the data for how to render both logic branches would need to be sent down to the browser in your JS bundle and it would be decided during runtime, on the client, which branch to render. With the hypermedia approach, you're just declaring to the browser: show this, now. No messing about with client state and rendering lifecycles. It's a refreshingly simple paradigm to follow.

While Datastar allows you to add data-on:click to basically anything, I stuck with <button> elements so I could benefit from the accessibility features that come inherently with semantic HTML, such as keyboard controls, tab indexing, etc.

Furthermore, I found that by having all my state on the backend, I was able to centralise my game logic on the game data structure — this helped prevent the game logic from leaking into my templates. When using React in the past, while I have always liked JSX as a templating language, there was an inevitable blending of state, its manipulation, and logic operations over it, that, unless properly managed, made my application more difficult to reason about as complexity grew. In my experience, it was common to end up with little pockets of localised state hidden at various levels in the UI component-tree.

With Go and Templ, however, I could pass my entire game state struct into the template and just invoke the methods associated with that struct. A bit more object-oriented, I suppose, but it felt nice to keep the domain logic close to the data structure definition itself and not squirrelled away in random/disconnected functions.


In the end, I did find myself using one signal. I noticed that, if you're fast enough (or on a particularly poor connection), it would be possible to double-submit a turn by clicking multiple "cells" in quick succession. Since each turn is meant to direct the next player to a different game, it was a little bit problematic as playing twice in a row within the same game could be breaking the rules.

Using Server-sent Events (SSE) with Datastar, this was trivial to control from the server. When the user clicks a button, I set a $clicked signal to true; this will disable all other buttons locally on the page until the server has an opportunity to respond, no matter the latency. The server first sends a PatchElements event down with the new HTML state, which will trigger Datastar to morph the game board into the next turn state; followed immediately by a PatchSignals event, which will re-enable all the buttons on the page, so that the player may now click one of the newly-available cells.

And if the user refreshes the page at any point, they always get the HTML for the latest game state, so persisting the $clicked signal is irrelevant.

There's more on the implementation details of this later.

# Leveraging modern CSS

I also took the opportunity on this project to try out some "newer" CSS features that I hadn't gotten around to using yet, either for lack of need, time, or browser support.

If you haven't tried out playing the game yet, this is where I highly recommend that you do, in order to see the effect in action and better understand this next section.

The first feature I wanted to implement with CSS was the "cell hover effect", i.e. when you hover your mouse over a cell (or focus it with your keyboard), it will highlight the board that the next player will be forced to play on. Yes, that hover animation effect was done entirely with CSS. Praise be to the :has() and :where() pseudo-classes!

The "logic" of the highlighting is basically as follows:

  1. If the player hovers over cell X (where X may be 1-9), highlight all playable cells in board X;
  2. If a cell in board X is already selected by a player, do not highlight;
  3. If board X is no longer playable (i.e. it is won, or a draw, and thus is a "wildcard board"), highlight all other boards;
  4. Repeat Step 2 for all other boards.

Warning, the following CSS rules are pretty hideous.

1#game:has(form.cell:nth-child(1):where(:hover, :focus-visible))
2  .board:nth-child(1):not(.unplayable),
3#game:has(form.cell:nth-child(1):where(:hover, :focus-visible)):has(.board.unplayable:nth-child(1))
4  .board:not(.unplayable)

There are two rules here to make it work (but only for cell/board 1). We'll break them down, but first let's just look at the (simplified) HTML structure to have an idea of the heirarchy:

 1<div id="game">
 2  <div class="grid">
 3    <div class="board">
 4      <form class="cell"></form>
 5			<div class="cell X">X</div>
 6			<!-- there are 7 more cells -->
 7		</div>
 8		<!-- there are 8 more boards -->
 9	</div>
10</div>

Each .board element contains 9 .cell elements, which will be either a form (if you can select them), or a div (if they've been selected). I omitted a lot of attributes and nested elements for brevity, but you can always go inspect the page if you want to see how the full structure looks.

When a .board has either been won by a player, or all of its cells were selected and it resulted in a draw, it will be given the .unplayable class.


Now, starting with the first CSS rule: the objective of this one is to highlight the board in the same position as the cell that is currently being hovered/focused, but only when it is playable.The rule looks like this:

1#game:has(form.cell:nth-child(1):where(:hover, :focus-visible))
2  .board:nth-child(1):not(.unplayable),

This first line of the rule narrows the scope for the second line of the selector. The ultimate target of the rule is an element with the .board class, because we want to end up applying styles to that, but since the cells are inside of the board, we start our selector at a higher-level in the DOM, i.e. the #game wrapper element, so we can later target the board.

We leverage :has() here to identify whether a child within #game meets our criteria without actually selecting that element; the criteria is the selector within the :has() pseudo-class. In this case, we're only wanting to proceed when there is a form element with the .cell class as the first child of its parent. The :where() pseudo-class is then used on that form element to check if it matches multiple states, i.e. whether it is being hovered or focused with the keyboard.

Why form.cell and not just .cell? Well, .cell would be too generic, and trigger the highlighting when you hover the cell when it is in a non-interactive state. The CSS rule benefits from the fact that we change the DOM structure from div elements to form elements containing a button (as discussed in the section above). With just a little bit more specificity, we can easily rule out a whole swathe of :hover/:focus-visible cases.

The second line of the rule specifies the element to which we will end up applying our styles. It says to select the first child with the .board class, where the element also does not possess the unplayable class. That is, it selects the first board, if it can still be played on.


The objective for the second rule is to detect when the board the next player will be playing on is no longer playable (i.e. it has become a "wildcard board") and instead of highlighting that board, highlight all other playable boards.

Taking a look at the second CSS rule, we can see it starts almost exactly the same way as the first:

1#game:has(form.cell:nth-child(1):where(:hover, :focus-visible)):has(.board.unplayable:nth-child(1))
2  .board:not(.unplayable)

However, you may notice there's another :has() pseudo-class appended to it. That selector is fairly self-explanatory, so I won't expound further.

An important thing to know about :has() is that while you cannot nest them, you can chain them to mimic "AND" conditional logic. This adds a second, distinct set of criteria to our selector that must also be satisfied.

So, when any cell in position 1 is being hovered or focused, and the first board is also unplayable, we do... what? Well, simply target all other .board elements that do not have the .unplayable class and apply the styles!

Finally, we need to duplicate both those rules 8 times, and adjust them slightly so that they check for every board/cell in the game, i.e. :nth-child() 1 through 9. You can view the final CSS here, if you're curious.

And that's all there is to it!

...whew, that was rather a lot. I hope that explanation makes sense to someone, at least.

# Over-complicating the background

Next up on the list of CSS topics is: over-complicating the background animation!

I'm sure you noticed the background colour changes to match the player whose turn it currently is, but did you happen to notice that it also moves? (Albeit very slowly, so as not to be distracting.)

The background is actually 4 separate div elements overlaid on top of each other, each with a linear-gradient background image and a reduced opacity; that creates this nice, layered effect with varying shades of red or blue. Each element is animated to slide back and forth, at slightly different rates, using the transform: translate() property.

With all the subtle movement and "dynamic colour shading" (as different layers transformed over each other), it felt very out-of-place when clicking a cell to have this sudden, jarring change from one player's colour to the next. It was so abrupt that it felt like it drew too much attention to itself and pulled focus away from the actual game. Not quite the effect I was after. I wondered if animating more slowly between the colours could help with this? After all, I was using a linear-gradient with CSS variables to specify the colours; this seemed like a prime opportunity to try out the @property rule to define some custom colour properties which can be animated via the transition property.

So, I did just that! Here's the gist of it:

 1@property --bg-a {
 2  syntax: "<color>";
 3  inherits: true;
 4  initial-value: #1d81c0;
 5}
 6
 7@property --bg-a {
 8  syntax: "<color>";
 9  inherits: true;
10  initial-value: #005793;
11}
12
13/*
14Switch the colours based on whose turn it is;
15the attribute value gets updated every turn.
16*/
17:has([data-player="X"]) .background {
18  --bg-a: #1d81c0;
19  --bg-b: #005793;
20}
21
22:has([data-player="O"]) .background {
23  --bg-a: #c21919;
24  --bg-b: #900000;
25}
26
27.background {
28  /* ... */
29  background-image: linear-gradient(-60deg, var(--bg-a) 50%, var(--bg-b) 50%);
30	transition:
31    	--bg-a 1s ease-out,
32    	--bg-b 1s ease-out;
33}

And it did not go well.

The cross-fade looked ugly, as red transitioned through brown into blue. The slow-fade was actually more distracting than the instant switch. Performance tanked. Chrome choked hard on my Macbook Pro and caused the whole viewport to constantly flicker white as it struggled to handle the colour properties, opacity, and overlapping transformed elements. I could hear the laptop's fans spin up as I just sat there watching my div elements slide back and forth.

It was unacceptable, but I was still not content with the original state. What else could be done? This is when I remembered that Datastar comes with built-in View Transition API support.

If you're not familiar with View Transitions, I will briefly summarise what happens: the browser will take a "snapshot" of the page before/after and animate between those two states for you. This isn't limited to the entire page; individual elements can be given the view-transition-name property. Transitions can happen within the same document, or across documents (i.e. after page navigation).

This will not be a tutorial on how to use the View Transitions API; if you wish to know more I would encourage you to read about it elsewhere. This Chrome developer docs article provides a good introduction.

So I added the bare minimum CSS to enable view transitions, which is a basic cross-fade effect. Howevew, since my game view was updating via PatchElements HTML responses rather than page navigations, I needed to use "same-document" transitions, not "cross document" transitions. This usually requires some Javascript to wire up but fortunately Datastar has first-class support for this out of the box!

All I needed to do was include the datastar-use-view-transition: true header with my text/html response to any Datastar requests and Datastar would automatically handle starting a same-document view transition before any DOM morphing occurs. And just like that, I had a simple cross-fade effect that not only looked better but was also performant!

# Changing tack

During my time lurking on Datastar's Discord server, I had repeatedly seen mention of people utilising "CQRS" with long-lived SSE connections to great effect. I had never heard of the term CQRS before, and up until this point I'd been following the familiar request -> response pattern that is so prevalent on the web. However, some of the supposed benefits piqued my curiosity and since this project was intended to be a journey of learning and discovery, I decided to change tack and embrace this different pattern.

CQRS stands for "Command Query Responsibility Segregation"; in less fancy words, separate the writer from the reader. If this interests you also, I encourage you to read more about it by someone who'll do far better than I at explaining it.

How did this affect the way I was doing things though? Well, I'd still be sending requests when the user interacted with the page; clicking a cell is a "command", in CQRS terminology. But each of those requests receives a 204 response. The DOM and signal update events instead come via a long-lived SSE connection that is opened on page load.

This meant I needed to create a new server endpoint that would respond with a text/event-stream response type for SSE and basically never close the connection. Using a basic pub/sub approach, I could register a callback function in my game state that would trigger a re-render of the game template, any time the game state changed, and then emit a PatchElements event to the client. Fortunately, Datastar provides a server-side SDK (available in many languages!) that makes it trivial to respond with SSE instead of HTML.

This new endpoint ended up looking something like this (with some code omitted for brevity):

 1router.Get("/{gameId}", func(w http.ResponseWriter, r *http.Request) {
 2	game, _ := GetGame(chi.URLParam(r, "gameId"))
 3
 4	sse := datastar.NewSSE(w, r, datastar.WithCompression(datastar.WithBrotli()))
 5
 6  id := nanoid.Must(21)
 7
 8	game.Subscribe(id, func(state *Game) {
 9		sse.PatchElementTempl(
10      templates.Game(state),
11      datastar.WithUseViewTransitions(true)
12    )
13		sse.PatchSignals([]byte("{clicked: false}"))
14	})
15  defer game.Unsubscribe(id)
16
17	for {
18		select {
19		case <-r.Context().Done():
20			return
21		}
22	}
23})

In the end, this refactor turned out to be rather simple to execute. So simple, in fact, that I wondered why I even bothered with the request -> response approach to begin with at all. Out of habit and familiarity, I guess.

It was really only after I'd made the change that I began to realise the benefits of this architecture...

# Performance

Keen-eyed readers will have noticed that, when initialising the SSE stream via the Datastar SDK, I included Brotli compression:

1sse := datastar.NewSSE(w, r, datastar.WithCompression(datastar.WithBrotli()))

I'd read and heard many stories of Brotli's potential (compared with, say, gzip) when used with text/event-stream responses, but it wasn't until I saw it working myself that I truly believed it.

While you can't cache text/event-stream responses with HTTP's Cache-Control headers, adding Brotli compression to your response stream unlocks compression caching. That is, while your stream remains open, until the compression window size is full, anything you previously sent will effectively not need to be transferred to the client again, since it is re-usable by the Brotli decompression algorithm on the client's side of the connection.

In other words, if you're sending down the same, or very similar, HTML content over and over again in the same connection... you end up not really sending anything at all. The compression ratios are unbelievably good.

For example, with unac, the initial HTML page load is 1.5KB (gzipped). After each "turn" request, I'm sending the HTML for entire game board — that's 5.3KB of raw, uncompressed text — and after taking 30 turns in the game the compressed SSE stream will have only transferred 3.3KB in total.

Hot damn.

You're basically only sending single TCP packets at that point, informing the decompression algorithm which cache entries to use, in order to update your page. Given the connection is already established and so few packets need be transferred, I could imagine that the SSE event (even when reacting to a separate "command request") could end up being processed on the client before a standard text/html response would have (if, say, following the usual "request -> response" approach).

DISCLAIMER: that's pure speculation on my part, as I've not yet benchmarked it.

Needless to say, SSE+Brotli lived up to everything I'd seen and heard. Just writing this, I'm still amazed by it.


With the "wind in my sails" after witnessing the SSE compression first hand, I became momentarily fixated on other ways to improve performance.

One of the perks of Datastar's internal architecture is that it is built in a plugin-based fashion. The core "engine" of the library is actually only ~3KB and none of the data-* attributes and action plugins that form its public API are actually necessary. This means you can remove any plugins that you don't use and compile your own bundle, potentially shaving off a few more kilobytes.

So, I did just that.

I stripped out all the plugins that I wasn't using and ended up with a Datastar bundle that was a mere 10.4KB gzipped, with effectively no impact on functionality (just artificially limiting which parts of Datastar's API I could make use of).

# Multiplayer... for free?

Up until now, I'd been working under the assumption that 2 players would share the same browser session and take turns controlling the mouse. Since my previous iterations of this project were built as Single-Page Applications, I'd never really thought beyond the boundaries of a single browser session. However, another benefit of refactoring to the CQRS architecture is that it basically unlocked the ability for multiple clients to participate simultaneously in the same game, seamlessly.

Previously, with "request -> response", there was no mechanism to detect when another client would have taken their turn. The user could have manually refreshed the page to get the latest state, or I could have added polling to check for updates, or opened a web socket connection with the server, but that was all additional work that I had yet to do.

However, after the refactor, since I now had an open connection to the server, it could immediately push down updates to all clients when the game state changed.

Multiplayer capabilities just "fell out of" this architecture without me even trying. Neat.

In no previous iteration of this project, using other frameworks/languages, did I ever have multiplayer support; now I think I'll make it a new baseline requirement!


I've previously worked with websockets on a React project and I must say, SSE with Datastar is so much simpler. For starters, it's just a standard HTTP connection; SSE is a response type, not a different protocol. Datastar prescribes some event type formats that it understands, so all you really need to do is ensure your events conform to those.

Second, there's no requirement for it to be a long-lived connection. You can also just respond with a single event and close the connection, like you would a normal text/html response. This flexibility gives you options, like sticking with a "request -> response" paradigm (if that's what you want) while being able to patch elements and patch signals in the same response.

Third, unlike websockets, you don't need to worry about sending heartbeats; just set a Connection: keep-alive header (if using HTTP/1.1) and you're gravy.

# Considering graceful degradation

Finally, one aspect that has irked me for the last 10 years (while building React applications professionally), are the concepts of "graceful degradation" / "progressive enhancement". The web is an ever-evolving platform, and while browsers these days are self-updating regularly, the nature of the platform is such that there are always unforeseen issues that may occur. These design philosophies have always been something that resonated with me. They effectively boil down to: trying to ensure you're delivering the most resilient, accessible experience to the user.

For example, what if the user is unable to load the necessary Javascript files? Websites built with React generally just fall over if there's no Javascript. The React approach is so tightly coupled to a Javascript runtime that it feels like you're actively going against the framework if you try to make it work without it. But how would Datastar fare? It's a Javascript library, after all.

So, I tested it out.

My goal was not to reproduce all features 1:1, as that is not true to the "graceful degradation" design philosophy. Rather, I wanted to ensure that the core gameplay was preserved and that the game was entirely playable with Javascript disabled.

Fortunately for me, the game was driven primarily through click-based interactions. Where I had bare <button data-on:click="..."> elements, I could instead wrap these in <form>s, remove the data-on:click attribute from the button and add data-on:submit to the form. This allowed me to asynchronously post data to the server when the button was clicked. When using data-on:submit, Datastar automatically prevents the form submission for you in favour of the Datastar expression. However, when no Javascript is active, the default form submission kicks in. That meant I needed to add an appropriate action attribute to the form and a new endpoint to receive the form's POST request. This endpoint was essentially the same as my existing Datastar POST handler, except instead of returning a 204, it would now return a fresh render of the page with the latest game state, after having applied the "turn" data.

And that was about it. With that minor change, the core gameplay loop also worked without any Javascript. The "feel" didn't change much, as the CSS hover effects on the game board still functioned, showing you the preview of the user's next available moves, and cross-document view transitions are unaffected by the lack of Javascript.

Of course, I lost some features along the way.

  • The SSE compression benefits were lost; without Javascript, it incurs a full-page reload every turn, which uses more data over the lifetime of the game (even accounting for the lack of Javascript download size). Given the HTML page is a mere 1.5KB, this is still going to result in far less data transfer than most React bundles! I wasn't too concerned about it.
  • It also loses the real-time multiplayer ability; users on other clients can still refresh the page manually to see the latest state, but they won't be informed when to refresh. If you're sharing a single browser session (per my original project specifications), this is non-factor. A potential "polling" workaround could be to add a <meta http-equiv="Refresh" content="30" data-init="el.remove()" /> tag to the page header. This would make the browser forcibly refresh every 30 seconds, but would be removed automatically by Datastar, if loaded. Perhaps a suitable compromise?

Overall, I found it to be an interesting experiment. It certainly enforces some heavy constraints on how you structure the DOM and use Datastar. If I were building a mostly-static page with more "traditional" CRUD-like interactions, I might follow the "graceful degradation" design philosophy again. However, the allure of Datastar is the simplicity in which you can create highly interactive and dynamic web experiences, driven by the backend through a mostly-declarative API. Do I really want to limit myself and the experiences I can create because of strict adherence to such a philosophy? I'm not so sure. In the last decade of my professional work, this has never really arisen as an issue or been given as a priority. I think it's mostly web-idealism on my part...

# Closing thoughts

Phew, that was a lot. If you've made it this far in the article, I thank you for your attention!

Perhaps it's just infatuation at this early stage in our relationship, but I think I am in love with Datastar. I've not been this excited about a web-related technology since... well, since building some of my first "dynamic" websites with PHP and jQuery. It really hearkens back to those earlier days in my career, modernising the "good old ways" of doing things while leveraging the latest developments the web platform has to offer. It feels so nice to be working with the web platform, not alongside / against it (as I have so often felt when using React and other SPA-frameworks).

I might be looking back on my earlier web development days with rose-tinted glasses, but to me Datastar tastes like a brand-new cocktail, with just a dash of sweet nostalgia.

This is, undoubtedly, the best iteration of my unac project yet.