I'm always surprised when distilling a year into a single post just how many
things take place over those 365 days. When I'm in the thick of it I'm rarely
thinking about the details. Events and projects come and go, rarely do I take a
step back and properly register their impact or my feelings. So forgive me a
moment of catharsis.
Game development
I made a game! It's a little game, but I'm proud of it. It received second place
in a game jam and I think it's pretty good (only 25-ish entries in the jam so
reign in the enthusiasm). At the very least, it contains my current best attempt
at level design. Play it for free:
Kat's Ghost.
Unsurprisingly the game is a block-pushing puzzle game similar to Sokoban. I say
unsurprisingly because the Sokoban-like has been one of my favorite subgenres of
puzzle games ever since
Stephen's Sausage Roll
(which I haven't even finished because it's devilishly hard). The Sokoban-like
is the platonic ideal of a puzzle game: all logic, simple controls, simple
constraints.
I also got into crossword construction this year, releasing two midi-sized
American-style crosswords. Both of which are Dungeons and Dragons themed:
I tried (and failed) to get the first of those puzzles accepted into
Puzzmo during their open submission period.
Here's hoping my next submission does better.
2024 was a big year for puzzles. The availability of free online puzzle games
like Minute Cryptic,
Blockables, and the mainstays of Puzzmo or NYT
have made puzzle-solving a daily exercise. We're living in the golden ages of
snackable puzzle games. My morning routine has suffered.
This year also marks the release of
Braid Anniversary Edition,
released 16 years after the original. It includes the most in-depth commentary
I've ever seen for a video game, talking game design, programming, art, and
music. It offers a ton of wisdom and has inspired me to create. It's also just a
phenomenal game.
Start (and end) Emacs
Late 2023 and early 2024 I spent quite a bit of time on
Crafted Emacs with the goal
of helping folks get started with Emacs. I've always felt that most of the
starter kits pack too much extra stuff into the base Emacs installation, making
for a very complicated or cumbersome first experience. Ditto for distributions
like DOOM or Spacemacs that effectively hijack the built-in Emacs configuration
tools in favor of custom ones (e.g. layers). Crafted Emacs felt like a nice,
intermediate step.
That said, there was still something about Crafted Emacs that prevented me from
recommending it to folks that were interested in switching to Emacs. For one,
the README is that particular breed of verbosity that old-school Emacs hackers
are so fond of. Heavy on the philosophy, light on the examples. For two, the
module system is just inherently complicated. I really wanted to push new Emacs
users towards a single-file configuration, just like how I started.
And so I created Start Emacs. It's
basically just a "better defaults" setup for Emacs with some packages that align
the Emacs and VSCode experience. I'm particularly happy with the
extension guide
guide, which moves a lot of the optional configuration into a handful of
recipes.
During the making of Start Emacs I moved back to Windows as my primary dev
machine and was absolutely hating the experience. Emacs mostly worked, but
mainstays like Magit were horribly slow and many packages assumed access to
standard Linux utilities like diff or grep. I spent so many hours messing
around with different Windows development kits (MSYS2,
w64devkit, etc.) but couldn't find
something I was happy with. Finally I gave up and
swapped over to WSL.
This period of Windows hacking had me switching back and forth a few different
text editors while I troubleshooted Emacs, finally motivating me to try out
Helix. The
vim-ish keybindings
definitely threw me for a loop, sitting in that awkward area of close enough to
vim that it feels familiar, yet far enough away that I'm constantly invoking the
wrong commands. But after I garnered enough experience with it I grew to like it
so much that I started questioning my motivations. Why am I spending so much
time setting up Emacs when I have a capable editor already working?
I switched and haven't looked back.
I've tried writing a blog post about my new setup but I can't motivate myself
because it's so banal. I use Helix for editing text, tmux to manage terminal
windows (which works excellent in the Windows Terminal, surprisingly), and have
replaced all of my usual Emacs power features with CLI tools like ripgrep or
Awk. I'm probably not as productive since I still lack familiarity with my
tools, but I've really been enjoying leveraging a console workflow instead of
relying on a GUI editor.
Am I done with Emacs? Probably. Do I still think Emacs is a great tool?
Absolutely! Don't let my experience dissuade you from trying it out.
Ruby on Rails
This year felt like a great one for
Ruby on Rails.
The release of Rails 8 brings a bunch of awesome improvements, including
built-in authentication, full-stack SQLite, and zero-build frontend development.
Folks are talking about Rails again and they're doing so with a ton of
enthusiasm.
Coincidentally all of this Rails enthusiasm lines up with a job change for
myself, taking on a new role that does a lot more traditional Rails development.
I'm thankful that I have the opportunity to work with Ruby everyday.
That said, I've never worked at a Rails shop that actually used Rails for the
frontend. Every single app that I've worked on professionally with Rails has
been an Rails JSON API paired with a SPA frontend, usually React. With SSR
making a big comeback this year (thanks to Hotwire, HTMX, among others) I'm
eager to dive into the new suite of Rails tools.
Books
This year continues a reading trend from the past few years: an exploration into
Japanese literature through Haruki Murakami. Since then I've expanded to another
Japanese-borne author, Kazuo Ishiguro, and am dabbling in the works of Yukio
Mishima. But Murakami still reigns as my most-read author for the third year in
a row.
He's especially notable this year thanks to the release of The City and Its
Uncertain Walls in November. Let's just say the Murakami excitement was high.
Here are some of my reading highlights for this year:
The City and Its Uncertain Walls
by Haruki Murakami. I just finished this one last week so it's fresh in my
memory. I was surprised at how much of this book rehashes content from
Hard-boiled Wonderland, with the exploration of consciousness as a town
surrounded by a wall. Despite that, I enjoyed the deeper exploration into the
shadow-self. "My real self isn't here. It's somewhere else. The me that's here
looks like me, but is nothing more than a shadow projected onto the ground and
walls..." Quite a few aspects of this novel parallel 1Q84, particularly the
protagonist who searches for a long-lost love that rules his heart. The City
and Its Uncertain Walls is an exploration of the self and how it relates to
the world around us.
Anathem by
Neal Stephenson. I've seen the name Neal Stephenson on many a massive tome at
my local bookstore but haven't read any until this year. Now I'm hooked.
Anathem is a slow novel in every category, but its exploration of
philosophical topics is thorough and endlessly interesting for a layperson
like me. Underpinning the novel is an exploration of realism and nominalism,
depicted through manufactured names created for the world of Anathem. Just
don't come to Anathem looking for plot.
1Q84 by Haruki
Murakami. It's long, ponderous, and contains one too many Proust references,
but aspects of the work feel cohesive in a way that Murakami's other novels
don't. I'm also a sucker for a story about a writer. I am not prepared for a
literary analysis of 1Q84 though, I was mostly sailing on vibes.
Never Let Me Go
by Kazuo Ishiguro. I was introduced to Ishiguro from his most latest novel,
Klara and the Sun, which I found to be an enjoyable exploration of empathy,
if a bit superficial on the Sci-Fi implications of an Android protagonist.
Never Let Me Go has similar themes but delivers on them more successfully.
But man, is this book a bummer. Where Klara and the Sun is light and
forgiving, Never Let Me Go is oppressive and unyielding.
I also wanted to shout-out
The Awk Programming Language
which had a second edition release late last year that I finished in February.
It's unexpectedly one of the best programming books that I've read recently for
a language that I had no prior experience with. I bought the book expecting
perl-ish one-liners for simple problems, but stayed for its profound analysis of
DSLs and Awk as a toolkit for building them. Incredible stuff. These days I have
too much enjoyment searching for problems that I can solve using little Awk
scripts.
Movies
Over the last couple years I've met with a group of friends every weekend to
discuss a movie that one of us picked. A kind of movie-book-club.
The result has been great. I'm thinking more critically about the media I
consume and my relationship to it. I'm exposed to other perspectives that
reflect experience I would've never gathered myself. I'm thankful to have the
opportunity to meet and talk with others about this kind of stuff.
Notable films that I watched this year:
Perfect Days. I would
describe this film as a personification of Taoism. It follows the daily ritual
of a janitor for
The Tokyo Toilet, an artsy
urban development project distilled into fancy toilets. The movie is slow and
contemplative and well worth the watch.
Vertigo. Lately I've been on a
little Hitchcock kick, Vertigo being the first of the bunch that I haven't
already seen. Unsurprisingly, it's great. It's a bit slow, but the twists are
worth it.
Evil Does Not Exist.
2021's Drive My Car is one of my favorite films, period. So I went into
Evil Does Not Exist with high expectations. Unfortunately this one did not
do much for me. There's some allegorical storytelling underpinning this movie,
filling in the lines between some light plot elements and nature
cinematography. And while that cinematography is gorgeous, I couldn't shake a
sense of boredom at the many extended pauses between beats. Normally
contemplative movies are a hit for me, but this movie didn't spark any
thoughts with its storytelling that were worthy of the thoughtful moments.
Autumn Sonata. Speaking of
thoughtful moments. Look, Ingmar Bergman makes excellent movies. Autumn
Sonata is no exception. There's a scene in this movie that is a slow pan onto
the face of Liv Ullmann, broadcasting an entire life's worth of emotions into
a mere thirty seconds.
Games
I was so starved for puzzles after beating Braid that I followed it up by
playing through all of The Talos Principle and about a forth of the sequel. But
neither of those games came out this year, so here's a short list of a few
others that sparked my interest.
Braid: Anniversary Edition.
Already mentioned above. Do yourself a favor and pick it up, both for the game
and the commentary.
The Rookery. You
have to be some kind of Chess sicko to get a kick out of this game, but if you
are, it will suck up a ton of your time. It's effectively Chess: the
roguelike, but executed incredibly well. It lacks the presentational details
of something like Balatro (another great game this year) but still offers a
tight gameplay loop.
UFO 50. An incredible
achievement that is an easy recommendation for anyone remotely interested in
game design. There are so many ideas in this game (well, at least 50) that
twist well-known game mechanics in compelling ways. When I first heard about
this game years ago I thought it was going to be a Warioware-like collection
of minigames. Imagine my surprise when almost every one of the 50 games is
about the length of an original NES title. The fact that this game was ever
finished is an achievement. That it includes so many great games is nothing
short of amazing.
Animal Well. I
have played many metroidvanias over the years but have finished almost none of
them. Animal Well is an exception. It wasn't my favorite game to play in 2024
but it was certainly my favorite one to talk about. There was a general sense
of excitement around this title that was infectious, helped along by some
devilish secrets.
Looking ahead
Not mentioned in this post are a couple months that I spent working on a Chess
engine, or other numerous side projects that have been tabled, resumed, and
tabled again. I'm thinking a lot about my
reading stack,
for lack of a better term. I've been noodling on a few ideas for building my own
Goodreads alternative that doesn't have any of the AI cruft from Storygraph,
focused purely on reading and notetaking. We'll see where it goes.
I'm also attempting to break into the world of longform writing, in the way of
nonfiction. In other words, I'm writing a book. Well, several. Most of my
attempts have suffered the same fate as the average side project, with myself
working furiously until interest wanes, then promptly abandoning the idea.
Eventually one of my many book ideas will make its way into a finished product,
and when that happens I hope those of you still reading this post will enjoy the
result.
I've blogged before about why I really dislike apps like Notion for
taking quick notes since they're so slow to
open. The very act of opening the app to take said note often takes 10 or more
seconds, typically with a whole bunch of JavaScript-inflicted loading states and
blank screens. By the time I get to the note, I've already lost my train of
thought.
As it turns out, this painpoint is a perfect candidate for the iOS Shortcuts
app. I can create an automated workflow that captures my text input instantly
but pushes to Notion in the background, allowing me to benefit from Notion's
database-like organization but without dealing with the pitiful app performance.
This year I've substantially buffed up my crosswording skills. Mon-Wed on the
NYT pose no threat, and I can even occasionally solve the Thu/Fri without
checking an answer. Saturday remains befuddling.
One reason for my skill improvement is repetition. The more puzzles I solve, the
more I recognize clue patterns and common words. Drill those puzzles frequently
enough and skill inevitably trickles in.
In reality, repetition only explains a small sliver of my improvement. The bulk
of my newfound skill doesn't come from training crossword puzzles out in the
wild, but from
making my own.
Building a crossword puzzle requires activating a whole bunch of underused brain
wrinkles that remain latent when solving. Thinking of a theme and filling a
bunch of words into a grid is just one small part of the equation. How do I
measure difficulty so solvers don't get stuck? How do I compromise in a tradeoff
between word quality and theme? Why does the software keep suggesting I use
Australian birds?
The construction of quality reveals the heart of the puzzle. The very same
questions I ask myself when endeavoring to make a good puzzle help reveal the
construction of puzzles created by other people. For example, I now come
equipped with a backlog of words that appear frequently thanks to their helpful
vowels (OPAL, EMU, ERODE, ...). Difficult corners are made easier when I
consider that the uncommon words are probably grouped with more common words.
Themes are easier to spot now that I have thought of a few of my own.
This same skill applies to other puzzle genres, like the humble
block-pushing puzzle game. Building
interesting levels is a tough job that requires the constructor to think deeply
about the constraints of their game. I don't know about other gamedevs, but I
start by fiddling around with a random level layout, paring things back again
and again until a single core concept is revealed to be interesting. I take that
concept and build three or four levels around it, tutorializing it, expanding
it, and remixing it.
This thought process has me thinking about other block-pushing puzzle games in a
completely different way. Now when I get stuck on
Patrick's Parabox
I take a step back and attempt to reverse engineer the mechanic at play. Why did
the constructor choose this level layout? What mechanic are they trying to
showcase? What am I supposed to take away?
I suppose this same skill applies to programming, in the way of framework
design. As a user of React, I may get frustrated at the hook APIs and the design
of useEffect. But if I pare back the layers and think about what the framework
is fundamentally accomplishing (that is, virtual DOM rendering with a JSX
backend) the thought process of re-renders and useEffect dependencies starts
to reveal itself. Without going out and building my own virtual DOM framework
(something like snabbdom is a great
start) it's hard to recognize the tradeoffs.
Will constructing crossword puzzles make you a better developer? Almost
certainly not. But it's a ton of fun regardless.
The React homepage promises that "learning React is learning programming" and I
think the framework somewhat delivers on it. At the very least you don't need to
learn a new templating language thanks to JSX.
That said, don't be completely fooled by this promise. Like every other
JavaScript framework, React is full of subtle complexities and esoteric nuances
that have nothing to do with the language it's programmed in. In vanilla
JavaScript there's no such thing as "the rules of hooks" or the need to avoid
mutable variables in favor of useState.[1]
The subject of this post is one such piece of esoteric knowledge that I see
newcomers trip up against when learning React (spoilers: it's useEffect). It's
a great demonstration of the subtle complexities of React, where the promises of
JavaScript-ness meet the reality of framework design.
The problems of syncing async state
A classic point of friction is the introduction of asynchronous code. You have
some data from the server and you want to render it in your component to
populate the initial values of a form. That last bit is where the bug arises,
forms usually use controlled components which hold onto their values via
useState calls. Attempting to populate the initial value of useState hooks
from asynchronous code inevitably runs into a tricky issue. It's easiest to
demonstrate by example.
Here's a simple component that wraps an HTML input and captures its value:
This is all fine and dandy. The input correctly initializes with the value of
initialText when passing a string and correctly handles user input.
The problem arises when initialText is asynchronous, as is often the case when
dealing with forms that are populated with data from a server. For example,
introducing a new function getTextFromServer that simulates a 300ms response
time:
constgetTextFromServer=(ms =300)=>newPromise((resolve)=>{setTimeout(()=>{resolve('text from server')}, ms)})constApp=()=>{const[asyncText, setAsyncText]=useState('')useEffect(()=>{constfetch=async()=>{const text =awaitgetTextFromServer()setAsyncText(text)}fetch()},[])return(<form><MyInputinitialText={asyncText}/></form>)}
A routine operation in React code: wrap an async fetch call with a useEffect
and monitor the async state with useState. However, run this code and you'll
find a bug. Can you spot it in the code?
Here's the problem: the initial value of MyInput is never populated with the
value of asyncText. It remains blank, even after the getTextFromServer
promise resolves.
Naturally the first step is to log out what's going on with initialText. Is
the prop not being updated?
Well, actually this looks right. On the first render pass, the value is "",
the initial value of the useState in the parent. After getTextFromServer
responds with the string "text from server", that useState is updated and
the child component, MyInput, is re-rendered. It receives the new value of
"text from server" from props.
Well then, how come MyInput is blank?
This is where the most common React mistake is introduced. At this point in
debugging, a new developer searches for a framework solution to this problem. We
just encountered one such solution for handling async state by using
useEffect, what if we were to use it again?
Now when the value of the initialText prop updates asynchronously, MyInput
updates to match. The useEffect monitors the dependency change in
initialText and calls setText in response. No more blank input!
Generally when I see this kind of code appear in the wild, it's accompanied by
the text "for some reason React isn't updating MyInput with the new value of
initialText so I put in a useEffect to keep things in sync." That "for some
reason" is revealing: something is happening in React-land that I don't really
understand, but at least I solved it using a React-like solution.[2]
Here's the rub: sure, this code solves the problem. But it's also incredibly
brittle. This solution isn't obviously incorrect because developer machines are
fast and we're usually dealing with sub-100ms response times from whatever API
we're working with. In other words, because of quick response times, a developer
might not notice the pop-in when MyInput is updated with the asynchronous
value.
The thing is slow connections (e.g. mobile phones accessing your application,
server saturation, etc.) will experience increasingly worse pop-in because of
this useEffect change. In the worst-case scenario, a user could type text into
MyInput and have that text cleared away by the useEffect after asyncText
is loaded! Try increasing getTextFromServer to 3000 and see the result
yourself.
The other problem with this kind of code is that we've effectively doubled the
number of renders of the MyInput component. Sure, in this contrived example
more renders is not doing any harm, but you can imagine that for particularly
complicated components that set 10s of hundreds of different pieces of state,
additional renders are to be avoided. State-syncing code of the kind in this
example often leads to more state-syncing due to extraneous render passes, a
problem that keeps on giving as your application grows.[3]
So what's actually happening with the MyInputuseState? Why isn't it picking
up the new value of initialText from the component prop? The answer is hidden
away in the React documentation (emphasis mine):
useState Parameters:
initialState: The value you want the state to be initially. It can be a
value of any type, but there is a special behavior for functions. This
argument is ignored after the initial render.
"Ignored after the initial render", meaning even though the prop initialText
is updated correctly, the useState that wraps text doesn't care. It's
memoized such that any additional renders of the component will have no effect
on the state variable it encapsulates.
If you think about it, this behavior makes sense. In 90% of cases, you wouldn't
want your state variables to be blown away by component re-renders. When you use
useState you expect it to hold onto a value until setState is called, and
the memoization achieves that goal.
Now that we know more about how useState works behind the scenes, we can find
a different solution for the problem of handling asynchronous initial state.
Solution: handle the pending state
So what should you do instead? The easiest solution is to have the parent
component own the loading state:
MyInput goes back to its original form: a single useState that accepts
initialText as an argument. Because MyInput is only rendered when
asyncText has been fetched from the server (determined via isLoading in the
parent component) the resulting useState is called once with an initial value
of "text from server". There's no longer any need to sync state because the
initial render of the component has the desired state.
I'll argue that thinking about loading states is actually the power of avoiding
useEffect to solve these kinds of problems. By moving control of the loading
state up the component hierarchy, developers need to put more thought into the
async nature of their application and how the UI will handle it.
Going back into the discussion of React complexity and the burden of frameworks,
the whole counter-intuitive nature of useState discarding its argument after
the first render is a mind-bender for the beginner. I could imagine spending a
few hours on this problem and getting nowhere because it's hard to conceptualize
that the cause is actually within the framework itself, buried in the
implementation detail of memoization in the useState hook. It takes time to
encounter these kinds of issues in React, but spend enough time with it and they
will inevitably rise to the surface.
Although of course there's the vanilla JS alternative of needing to
re-render the DOM when you update application state, but that's neither here
nor there. ↩︎
I want to re-emphasize that I don't think the developer is at fault here.
They encountered a subtle problem that is super confusing and solved it
using the tools React gives them. I think it's a very natural way of
thinking about things. ↩︎
State-syncing begets more state-syncing because the lifecycle of state
values becomes hard to reconcile, and the only solution is to set state
again to ensure everything is the most recent. ↩︎
Type predicates
have been around but today I found a particularly nice application. The
situation is this: I have an interface that has an optional field, where the
presence of that field means I need to create a new object on the server, and
the lack of the field means the object has already been created and I'm just
holding on to it for later. Here's what it looked like:
The intersection type Thing & { blob: File } means that uploadNewThings only
accepts things that have the field blob. In other words, things that need to
be created on the server because they have blob content.
However, TypeScript struggles if you try to simply filter the list of things
before passing it into uploadNewThings:
Argument of type 'Thing[]' is not assignable to parameter of type '(Thing & { blob: File; })[]'.
Type 'Thing' is not assignable to type 'Thing & { blob: File; }'.
Type 'Thing' is not assignable to type '{ blob: File; }'.
Types of property 'blob' are incompatible.
Type 'File | undefined' is not assignable to type 'File'.
Type 'undefined' is not assignable to type 'File'.
The tl;dr being that despite filtering things by thing => !!thing.blob,
TypeScript does not recognize that the return value is actually
Thing & { blob: File }.
But casting is bad! It's error-prone and doesn't really solve the problem that
TypeScript is hinting at. Instead, use a type predicate:
const hasBlob =(t: Thing): t is Thing &{ blob: File }=>!!t.blob
uploadNewThings(things.filter(hasBlob))
With the type predicate (t is Thing & ...) I can inform TypeScript that I do
in fact know what I'm doing, and that the call to filter results in a
different interface.