<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="3.10.0">Jekyll</generator><link href="https://indigo.spot/feed.xml" rel="self" type="application/atom+xml" /><link href="https://indigo.spot/" rel="alternate" type="text/html" /><updated>2026-05-13T13:40:42+00:00</updated><id>https://indigo.spot/feed.xml</id><title type="html">Indigo’s Blog</title><subtitle>A blog by Indigo Nolan</subtitle><entry><title type="html">Why is audio on Linux such a headache?</title><link href="https://indigo.spot/blog/audio-on-linux" rel="alternate" type="text/html" title="Why is audio on Linux such a headache?" /><published>2026-04-21T08:00:00+00:00</published><updated>2026-04-21T08:00:00+00:00</updated><id>https://indigo.spot/blog/audio-on-linux</id><content type="html" xml:base="https://indigo.spot/blog/audio-on-linux"><![CDATA[<p>Any Linux user will tell you that audio is a nightmare. While Windows and macOS users have spent the last two decades<sup id="fnref:windows" role="doc-noteref"><a href="#fn:windows" class="footnote" rel="footnote">1</a></sup> benefiting from unified, vertically integrated audio (CoreAudio, WASAPI), Linux users have to deal with every distribution managing audio slightly differently, every new laptop requiring a new open-source driver to be acquired, and every year, a newcomer entering the field who thinks their new sound server will fix everything.</p>

<p>The complexity comes from a fundamental architectural challenge - how do you allow multiple applications to share a single piece of hardware (your sound card) without introducing massive lag or stability issues?</p>

<p>Nowadays, we’ve reached a relatively stable era with the widespread adoption of PipeWire<sup id="fnref:1" role="doc-noteref"><a href="#fn:1" class="footnote" rel="footnote">2</a></sup> in the late 2010s and early 2020s<sup id="fnref:2" role="doc-noteref"><a href="#fn:2" class="footnote" rel="footnote">3</a></sup>, but to understand where PipeWire came from, we have to look at the gradual revolution which attempted, in various forms of success, to fix the Linux audio problem decades ago.</p>

<p><strong>ALSA (Advanced Linux Sound Architecture)</strong></p>
<ul>
  <li>Layer: Kernel</li>
  <li>Focus: Hardware Drivers</li>
  <li>Mixing: Hard (dmix)</li>
  <li>Hotplugging: Bad</li>
</ul>

<p><strong>PulseAudio</strong></p>
<ul>
  <li>Layer: Sound Server</li>
  <li>Focus: Desktop Ease</li>
  <li>Mixing: Automatic (software)</li>
  <li>Hotplugging: Good</li>
</ul>

<p><strong>JACK</strong></p>
<ul>
  <li>Layer: Sound Server</li>
  <li>Focus: Low Latency</li>
  <li>Mixing: Manual (Graph)</li>
  <li>Hotplugging: Bad</li>
</ul>

<p><strong>PipeWire</strong></p>
<ul>
  <li>Layer: Multimedia Framework</li>
  <li>Focus: Unified Stability</li>
  <li>Mixing: Automatic + Graph</li>
  <li>Hotplugging: Great</li>
</ul>

<h2 id="the-prehistoric-era-oss">The Prehistoric Era: OSS</h2>

<p>Before we even get to the modern stack, it helps to understand that the “never-ending cycle” of audio servers is a Linux tradition. Before the current foundation existed, there was OSS <a href="https://en.wikipedia.org/wiki/Open_Sound_System">(Open Sound System)</a>. It was the original UNIX sound system and was the standard in the Linux kernel throughout the 1990s.</p>

<p>It was eventually deprecated because its creators made it proprietary in 1998<sup id="fnref:sound" role="doc-noteref"><a href="#fn:sound" class="footnote" rel="footnote">4</a></sup>, which forced the open-source Linux community to scramble and build a replacement from scratch. That replacement was ALSA - Advanced Linux Sound Architecture.</p>

<h2 id="we-start-again-with-alsa">We start again with ALSA</h2>

<p>ALSA is a kernel-level framework which talks directly to the hardware (your sound card).</p>

<p>In the early days (and still technically nowadays), ALSA followed a strict rule of “one pipe, one process”. If your music player was using the audio ‘pipe’, your web browser was locked out. You wouldn’t be able to hear a notification sound if your sound card was occupied with playing some Radiohead. After various attempted framework-level solutions<sup id="fnref:13" role="doc-noteref"><a href="#fn:13" class="footnote" rel="footnote">5</a></sup>, collectives of developers began to build a sprawling and fragile scaffolding of pipes on top of ALSA.</p>

<h2 id="enter-pulseaudio">Enter PulseAudio</h2>

<p>PulseAudio entered the stage as an ambitious project which tried to fix the ‘one user, one pipe’ problem<sup id="fnref:3" role="doc-noteref"><a href="#fn:3" class="footnote" rel="footnote">6</a></sup>. It acted as a middleman, standing in front of ALSA, accepting audio from every app, mixing it together, and then shoving it into the ALSA pipe, pretending it was all one audio stream<sup id="fnref:4" role="doc-noteref"><a href="#fn:4" class="footnote" rel="footnote">7</a></sup>.</p>

<p>On paper, PulseAudio was a revolution. In practice, for at least the first five years, it was a thorn for everybody who used a Linux desktop. It was famous for stuttering, high CPU usage, and the dreaded <code class="language-plaintext highlighter-rouge">Internal Error</code> that would leave your system entirely mute until you gave it a full reboot<sup id="fnref:5" role="doc-noteref"><a href="#fn:5" class="footnote" rel="footnote">8</a></sup>. Sometimes, if you killed PulseAudio after it bugged out, your sound card would be overwhelmed with every queued audio event, and you would be gifted with a loud <code class="language-plaintext highlighter-rouge">SCRMZMNEKEJEIOWJSANNSDMN</code> before you reached for the power button. Another key problem was it would often ‘hang’, which is where a program stops responding, which would often be caused by resource overloading or crashing - but this would often block other programs from running<sup id="fnref:6" role="doc-noteref"><a href="#fn:6" class="footnote" rel="footnote">9</a></sup>.</p>

<h3 id="glitch-free-architecture">Glitch-Free Architecture</h3>

<p>Admittedly, these were early days for PulseAudio - and many bug reports and patches were submitted over the years<sup id="fnref:8" role="doc-noteref"><a href="#fn:8" class="footnote" rel="footnote">10</a></sup> <sup id="fnref:12" role="doc-noteref"><a href="#fn:12" class="footnote" rel="footnote">11</a></sup> which improved the experience massively for the average user<sup id="fnref:7" role="doc-noteref"><a href="#fn:7" class="footnote" rel="footnote">12</a></sup>. The PulseAudio developers hit back at criticism, claiming that it was only natural that new software released to so many people at once would result in many bugs being found<sup id="fnref:9" role="doc-noteref"><a href="#fn:9" class="footnote" rel="footnote">13</a></sup>, especially such complex software as a sound server. The developer, Lennart Poettering, even developed a ‘Glitch-Free’ version of PulseAudio<sup id="fnref:10" role="doc-noteref"><a href="#fn:10" class="footnote" rel="footnote">14</a></sup>. Yes, all software should be glitch-free, you say - a ‘glitch’ in terms of a sound server is a moment where the CPU is busy and the sound card has a moment where the audio stream is interrupted - you may have heard this when your speakers go <code class="language-plaintext highlighter-rouge">pop</code> or a <code class="language-plaintext highlighter-rouge">click</code> or a brief moment of silence. PulseAudio, as of 0.9.11 in 2008<sup id="fnref:10:1" role="doc-noteref"><a href="#fn:10" class="footnote" rel="footnote">14</a></sup>, moved from a simple <code class="language-plaintext highlighter-rouge">push</code> system to a <code class="language-plaintext highlighter-rouge">smart timer</code> system.</p>

<p>Historically, audio worked in ‘fragments’, scheduled via hardware <a href="https://en.wikipedia.org/wiki/Interrupt_request">Interrupt Requests (IRQs)</a>. The playback buffer is divided into a fixed number of ‘fragments’. The sound card plays one fragment, and then sends an interrupt to the CPU to request the next one. The interrupt interval <code class="language-plaintext highlighter-rouge">T_int</code> can be defined as <code class="language-plaintext highlighter-rouge">buffer_size / (number_fragments * rate)</code>. This results in a significant trade-off - to achieve low latency, you have to use smaller and smaller fragments, which increases the number of interrupts per second, forcing the CPU to wake up more frequently and consume more power<sup id="fnref:11" role="doc-noteref"><a href="#fn:11" class="footnote" rel="footnote">15</a></sup>. Poettering’s ‘glitch-free’ model replaces hardware interrupts with high-resolution system timers<sup id="fnref:10:2" role="doc-noteref"><a href="#fn:10" class="footnote" rel="footnote">14</a></sup>. Instead of the sound card asking for data when it’s done, the audio server (PulseAudio in this case) proactively schedules CPU wakeups based off the system clock.</p>

<p>The system configures your sound card with the largest possible buffer (even up to 2 seconds). This provides a massive safety margin for any potential CPU scheduling delays. PulseAudio calculated the ‘time-to-empty’ based on the current playback position, and scheduled a wakeup 10ms before the buffer is projected to empty. If a buffer underrun<sup id="fnref:11:1" role="doc-noteref"><a href="#fn:11" class="footnote" rel="footnote">15</a></sup> occurs, the system will also dynamically increase the safety margin it assigned itself to prevent future glitches, essentially learning how jittery your system typically is, and adapting to it.</p>

<p>Now, you may think that a 2-second buffer would result in 2 seconds of latency in the case of a dropout. However, Glitch-Free architecture allowed for zero-latency interaction via something called buffer rewriting. Whenever a user interacted with the audio (ie hitting pause, or playing a new sound), the server would recalculate exactly which samples have not yet been processed, rewrite the samples in-place, and re-align the buffer to request new data dynamically.</p>

<p>This was a hugely impactful update and moved away from static, hardware-defined timing to dynamic, software-defined scheduling, which improved PulseAudio’s power efficiency (fewer CPU wakeups) while also improving the ability to react to user input with lower latency. These fixes worked, but also introduced a multitude of new bugs and made it less stable overall.</p>

<h2 id="jack">JACK</h2>

<p>While the average user struggled with Pulse, audio professionals opted for <code class="language-plaintext highlighter-rouge">JACK</code>.</p>

<p>JACK built on top of ALSA, to create an experience where the most important thing was <em>zero latency</em>. In JACK, every app is forced to stay in perfect lock-step. If you have a digital guitar pedal and your recording software open, JACK will ensure they are synced down to the microsecond. It allows you to use ‘Patch Cables’ - you can literally route the audio out of one app and into another<sup id="fnref:15" role="doc-noteref"><a href="#fn:15" class="footnote" rel="footnote">16</a></sup>. JACK aimed to prevent <a href="https://unix.stackexchange.com/questions/199498/what-are-xruns">xruns</a> - essentially buffer underflows, which created what we talked about before - ‘glitches’, or pops.</p>

<p>The main problem with JACK was that it was exclusive. It required a real-time kernel, and if you started up JACK to record some music, it would often ‘kick’ PulseAudio off the soundcard, meaning you couldn’t watch YouTube while trying to use your recording software.</p>

<p>But while JACK was inconvenient for daily use, it was vital for people who worked with audio professionally<sup id="fnref:jack" role="doc-noteref"><a href="#fn:jack" class="footnote" rel="footnote">17</a></sup>. Paul Davis, the creator of JACK, is also the creator of <a href="https://ardour.org/">Ardour</a>, a free and very popular open-source ‘digital audio workstation’ (or DAW). Older versions of Ardour even required you to use JACK<sup id="fnref:16" role="doc-noteref"><a href="#fn:16" class="footnote" rel="footnote">18</a></sup>, but modern Ardour works with all sound servers (like PipeWire, which we will get onto soon!)</p>

<p><img src="https://miro.medium.com/v2/1*F4s5_JaQTeLDSE9JzTjujg.png" alt="PulseAudio and JACK and ALSA" /></p>
<h2 id="finally-pipewire">Finally, PipeWire</h2>

<p>The reason we talk about all of this in the past tense is thanks to the introduction of <a href="https://pipewire.org/">PipeWire</a> in 2017. As you can read more about in the 2025 Linux Audio Conference, PipeWire has effectively ‘won’ the audio battle because it is so adaptable<sup id="fnref:conf" role="doc-noteref"><a href="#fn:conf" class="footnote" rel="footnote">19</a></sup>.</p>

<p>PipeWire, originally called Pinos, was actually created to fix video, not audio. With the gradual shift to the Wayland display server (from X11), sharing screens and video suddenly became a problem. Pinos was built to route video streams securely between sandboxed apps. Because audio and video naturally need tight synchronisation, the developer Wim Taymans realised the framework he built for video was perfectly suited to replace PulseAudio and JACK<sup id="fnref:pipewirehackaday" role="doc-noteref"><a href="#fn:pipewirehackaday" class="footnote" rel="footnote">20</a></sup>.</p>

<p>PipeWire acts as a single, unified connector. It has a PulseAudio-compatible interface for your browser and all old platforms, a JACK-compatible interface for your music production software, and it talks to ALSA using modern high-speed logic. It also helps massively with video/audio synchronisation, which you can read more about from the original creator, Wim Taymans<sup id="fnref:wim" role="doc-noteref"><a href="#fn:wim" class="footnote" rel="footnote">21</a></sup>.</p>

<p>Another massive win for PipeWire was finally waking up from the Bluetooth nightmare. PulseAudio historically struggled heavily with modern Bluetooth audio. Getting high-quality codecs (like LDAC or aptX) working without stuttering often required users to install third-party modules or hack configuration files. PipeWire effectively solved Linux Bluetooth audio overnight, supporting all modern codecs out of the box with zero configuration<sup id="fnref:ycomb" role="doc-noteref"><a href="#fn:ycomb" class="footnote" rel="footnote">22</a></sup>. For the average user, this alone was enough to make the switch.</p>

<p>PulseAudio was also a security nightmare on Linux for Flatpaks. Flatpaks are essentially ‘sandboxes’ apps, and they are a popular way to distribute apps on Linux if they don’t need system-wide access or permissions. PulseAudio tended to mess this up by blurring the line between system and apps - PipeWire uses a session manager (like WirePlumber)<sup id="fnref:wire" role="doc-noteref"><a href="#fn:wire" class="footnote" rel="footnote">23</a></sup> to handle routing logic, and a ‘portal’ system (called XDG Portal)<sup id="fnref:xdg" role="doc-noteref"><a href="#fn:xdg" class="footnote" rel="footnote">24</a></sup> to ensure sandboxed Flatpaks can’t access your mic without permission - which finally fits modern Linux security models<sup id="fnref:pipewire" role="doc-noteref"><a href="#fn:pipewire" class="footnote" rel="footnote">25</a></sup>. PipeWire can also switch from ‘power-saving mode’ (a la PulseAudio) to a more high-performance and low-latency mode (JACK style) on the fly, depending on what devices you have plugged in and which programs you have open.</p>

<p>PipeWire adopted JACK’s graph-based approach - it essentially took the ‘everything is a node’ architecture from JACK that allowed users to connect inputs to outputs, and added the stability and adaptability to PulseAudio to make the best system we have today.</p>

<p><img src="https://venam.net/blog/assets/audio_unix/collabora_pipewire.png" alt="PipeWire Daemon Graph" /></p>

<h2 id="the-end">The End</h2>

<p>For the first time in desktop Linux history, the audio stack is boring - and that is a compliment. I no longer have to choose between PulseAudio or JACK - I can have both. Today, I can open up Ardour to record a drum track, hop on a Discord call, and stream a YouTube tutorial, all at the same time.</p>

<div class="footnotes" role="doc-endnotes">
  <ol>
    <li id="fn:windows" role="doc-endnote">
      <p>WASAPI was only introduced in Windows Vista (2006). Before that, Windows audio was actually also chaotic, crash-prone, and suffered from terrible latency. <a href="#fnref:windows" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:1" role="doc-endnote">
      <p>Pipewire was released in 2018 <a href="https://archive.fosdem.org/2019/schedule/event/pipewire/">https://archive.fosdem.org/2019/schedule/event/pipewire/</a> <a href="#fnref:1" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:2" role="doc-endnote">
      <p>In 2023: <a href="https://www.phoronix.com/news/PipeWire-1.0-Released">https://www.phoronix.com/news/PipeWire-1.0-Released</a> <a href="#fnref:2" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:sound" role="doc-endnote">
      <p><a href="https://wiki.freebsd.org/Sound">https://wiki.freebsd.org/Sound</a>. OSS was later put back into the open-source world in around 2008 <a href="#fnref:sound" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:13" role="doc-endnote">
      <p>While ALSA eventually added a software mixer called <code class="language-plaintext highlighter-rouge">dmix</code>, it was difficult to configure and lacked the flexibility required for a modern desktop. <a href="#fnref:13" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:3" role="doc-endnote">
      <p>In 2006: <a href="https://0pointer.net/blog/projects/pulse-release.html">https://0pointer.net/blog/projects/pulse-release.html</a> <a href="#fnref:3" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:4" role="doc-endnote">
      <p>PulseAudio was created by <a href="https://0pointer.net/lennart">Lennart Poettering</a>, the same developer behind the equally controversial <code class="language-plaintext highlighter-rouge">systemd</code>. <a href="#fnref:4" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:5" role="doc-endnote">
      <p>Jeffrey Stedfast wrote extensive criticisms of PulseAudio in 2008. <a href="https://jeffreystedfast.blogspot.com/2008/06/pulseaudio-solution-in-search-of.html">https://jeffreystedfast.blogspot.com/2008/06/pulseaudio-solution-in-search-of.html</a> <a href="#fnref:5" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:6" role="doc-endnote">
      <p><a href="https://jeffreystedfast.blogspot.com/2008/07/pulseaudio-again.html">https://jeffreystedfast.blogspot.com/2008/07/pulseaudio-again.html</a> <a href="#fnref:6" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:8" role="doc-endnote">
      <p><a href="https://bugzilla.gnome.org/show_bug.cgi?id=542391">https://bugzilla.gnome.org/show_bug.cgi?id=542391</a> <a href="#fnref:8" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:12" role="doc-endnote">
      <p><a href="https://0pointer.de/public/foss.in-pulse.pdf">https://0pointer.de/public/foss.in-pulse.pdf</a> <a href="#fnref:12" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:7" role="doc-endnote">
      <p><a href="https://jeffreystedfast.blogspot.com/2008/07/pulseaudio-i-told-you-so.html">https://jeffreystedfast.blogspot.com/2008/07/pulseaudio-i-told-you-so.html</a> <a href="#fnref:7" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:9" role="doc-endnote">
      <p><a href="https://0pointer.de/blog/projects/jeffrey-stedfast.html">https://0pointer.de/blog/projects/jeffrey-stedfast.html</a> <a href="#fnref:9" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:10" role="doc-endnote">
      <p>Also called timer-based audio scheduling, it was released in 2008: <a href="https://0pointer.de/blog/projects/pulse-glitch-free.html">https://0pointer.de/blog/projects/pulse-glitch-free.html</a> <a href="#fnref:10" class="reversefootnote" role="doc-backlink">&#8617;</a> <a href="#fnref:10:1" class="reversefootnote" role="doc-backlink">&#8617;<sup>2</sup></a> <a href="#fnref:10:2" class="reversefootnote" role="doc-backlink">&#8617;<sup>3</sup></a></p>
    </li>
    <li id="fn:11" role="doc-endnote">
      <p>When the sound drops out, this is called a buffer underrun or a ‘dropout’. <a href="https://en.wikipedia.org/wiki/Circular_buffer">https://en.wikipedia.org/wiki/Circular_buffer</a> <a href="#fnref:11" class="reversefootnote" role="doc-backlink">&#8617;</a> <a href="#fnref:11:1" class="reversefootnote" role="doc-backlink">&#8617;<sup>2</sup></a></p>
    </li>
    <li id="fn:15" role="doc-endnote">
      <p>The JACK FAQ - <a href="https://jackaudio.org">https://jackaudio.org</a> <a href="#fnref:15" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:jack" role="doc-endnote">
      <p><a href="https://www.linux-magazine.com/content/download/63041/486886/version/1/file/JACK_Audio_Server.pdf">https://www.linux-magazine.com/content/download/63041/486886/version/1/file/JACK_Audio_Server.pdf</a> <a href="#fnref:jack" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:16" role="doc-endnote">
      <p><a href="https://discourse.ardour.org/t/new-system-configuration-why-do-i-need-jack-and-other-questions/109521">https://discourse.ardour.org/t/new-system-configuration-why-do-i-need-jack-and-other-questions/109521</a> <a href="#fnref:16" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:conf" role="doc-endnote">
      <p><a href="https://hal.science/hal-05194352v1/file/proceedings.pdf">https://hal.science/hal-05194352v1/file/proceedings.pdf</a> <a href="#fnref:conf" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:pipewirehackaday" role="doc-endnote">
      <p><a href="https://hackaday.com/2021/06/23/pipewire-the-newest-audio-kid-on-the-linux-block/">https://hackaday.com/2021/06/23/pipewire-the-newest-audio-kid-on-the-linux-block/</a> <a href="#fnref:pipewirehackaday" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:wim" role="doc-endnote">
      <p><a href="https://blogs.gnome.org/uraeus/2017/09/19/launching-pipewire/">https://blogs.gnome.org/uraeus/2017/09/19/launching-pipewire/</a> <a href="#fnref:wim" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:ycomb" role="doc-endnote">
      <p><a href="https://news.ycombinator.com/item?id=31932818">https://news.ycombinator.com/item?id=31932818</a> <a href="#fnref:ycomb" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:wire" role="doc-endnote">
      <p><a href="https://wiki.archlinux.org/title/WirePlumber">https://wiki.archlinux.org/title/WirePlumber</a> <a href="#fnref:wire" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:xdg" role="doc-endnote">
      <p>https://wiki.archlinux.org/title/XDG_Desktop_Portal <a href="#fnref:xdg" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:pipewire" role="doc-endnote">
      <p><a href="https://docs.pipewire.org/">https://docs.pipewire.org/</a> <a href="#fnref:pipewire" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
  </ol>
</div>]]></content><author><name>Indigo Nolan</name></author><category term="coding" /><summary type="html"><![CDATA[Any Linux user will tell you that audio is a nightmare. While Windows and macOS users have spent the last two decades1 benefiting from unified, vertically integrated audio (CoreAudio, WASAPI), Linux users have to deal with every distribution managing audio slightly differently, every new laptop requiring a new open-source driver to be acquired, and every year, a newcomer entering the field who thinks their new sound server will fix everything. WASAPI was only introduced in Windows Vista (2006). Before that, Windows audio was actually also chaotic, crash-prone, and suffered from terrible latency. &#8617;]]></summary></entry><entry><title type="html">Monetising CashCat: Escaping the Google Tax with Lemon Squeezy</title><link href="https://indigo.spot/blog/monetising-cashcat-lemonsqueezy-supabase" rel="alternate" type="text/html" title="Monetising CashCat: Escaping the Google Tax with Lemon Squeezy" /><published>2026-03-06T08:00:00+00:00</published><updated>2026-03-06T08:00:00+00:00</updated><id>https://indigo.spot/blog/cashcat-lemonsqueezy</id><content type="html" xml:base="https://indigo.spot/blog/monetising-cashcat-lemonsqueezy-supabase"><![CDATA[<p>Building a personal finance app is inherently ironic. You are spending thousands of hours constructing a tool designed to help people save money, and then - eventually - you have to ask those same people to hand some of that saved money over to you. But server costs don’t pay themselves!</p>

<p>I knew from the outset I didn’t want to build a billing engine from scratch (why would you?). Dealing with global tax compliance, VAT MOSS, and currency conversion is a nightmare I wouldn’t wish on anyone. So Stripe wouldn’t cut it - I needed a Merchant of Record (MoR), not just a payment gateway. Enter <a href="https://www.lemonsqueezy.com/">Lemon Squeezy</a>.</p>

<p>Here is a quick dive into how I wired up Next.js, Supabase, Capacitor, and Lemon Squeezy to build “CashCat Pro” - without getting my app nuked from the Google Play Store.</p>

<h2 id="friction-and-free-trials">Friction and Free Trials</h2>

<p>Pricing psychology is a dark art. I eventually settled on a “CashCat Pro” tier priced at £4.99/mo and £39.99/yr. It safely clears the £1.99 “dead zone” (where you get all the support tickets but none of the actual revenue) and severely undercuts the £100/yr VC-funded juggernauts I’m competing against.</p>

<p>But how do you handle onboarding? The standard SaaS playbook dictates a 7-day free trial. However, a time-based trial for a personal finance app is a massive vulnerability. Users can sign up, import three years of historical CSV bank data, let the app generate all their categorical insights, take screenshots of their spending habits, and then cancel on day 6. The classic “hit-and-run.”</p>

<p>Not only that, I want <em>users</em>, not just paying users. I need a solid free plan. So, I implemented a strict freemium model governed by action limits, rather than time limits. New users are granted exactly <strong>2 free CSV imports and 3 free CSV exports</strong>. They are given <strong>unlimited transactions, categories, and budgeting abilities</strong>.</p>

<p>From a technical standpoint, this is incredibly simple to implement. I don’t need complex CRON jobs running every minute to check if trials have expired. I don’t need to juggle timezone offsets. I just increment integers in the database.</p>

<p>It also guarantees the user reaches the “Aha!” moment that I found was so crucial in premium subscriptions. They see the magic of their automated dashboard. But next month, when they need to import their new data, they encounter the friction. The value proposition is already proven; now they just pay for continuity.</p>

<h2 id="supabase-schema">Supabase Schema</h2>

<p>My entire backend relies on Supabase. To handle this new logic, I had to extend my central <code class="language-plaintext highlighter-rouge">profiles</code> table.</p>

<p>I evaluated Lemon Squeezy’s built-in “License Keys” feature, but that’s really designed for downloadable electron apps, not a continuous cloud SaaS. Instead, I wired everything manually by adding three columns to the <code class="language-plaintext highlighter-rouge">profiles</code> schema:</p>
<ul>
  <li><code class="language-plaintext highlighter-rouge">pro</code> (boolean, default: false)</li>
  <li><code class="language-plaintext highlighter-rouge">free_imports_used</code> (integer, default: 0)</li>
  <li><code class="language-plaintext highlighter-rouge">free_exports_used</code> (integer, default: 0)</li>
</ul>

<h3 id="row-level-security-rls">Row Level Security (RLS)</h3>

<p>Of course, securing this is paramount. You can’t just have a client-side update flipping <code class="language-plaintext highlighter-rouge">pro</code> to true. I updated my Supabase Row Level Security (RLS) policies to ensure that these specific columns can only be modified by the <code class="language-plaintext highlighter-rouge">service_role</code> key - meaning only my secure server-side API endpoints can touch them.</p>

<h3 id="the-founder-bypass">The Founder Bypass</h3>

<p>There is nothing worse than deleting a test user during development and accidentally locking yourself out of your own Pro features. It halts development while you scramble to manually edit rows in the Supabase Studio dashboard.</p>

<p>To save my sanity, I added a “Founder Bypass” into my core Next.js utility function:</p>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">export</span> <span class="k">async</span> <span class="kd">function</span> <span class="nx">checkSubscription</span><span class="p">(</span><span class="nx">userId</span><span class="p">:</span> <span class="kr">string</span><span class="p">,</span> <span class="nx">email</span><span class="p">:</span> <span class="kr">string</span><span class="p">)</span> <span class="p">{</span>
  <span class="c1">// The ultimate developer override</span>
  <span class="k">if</span> <span class="p">(</span><span class="nx">email</span> <span class="o">===</span> <span class="dl">"</span><span class="s2">cashcat@indigonolan.com</span><span class="dl">"</span><span class="p">)</span> <span class="p">{</span>
    <span class="k">return</span> <span class="p">{</span> <span class="na">isPro</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span> <span class="na">importsUsed</span><span class="p">:</span> <span class="mi">0</span><span class="p">,</span> <span class="na">exportsUsed</span><span class="p">:</span> <span class="mi">0</span> <span class="p">};</span>
  <span class="p">}</span>

  <span class="kd">const</span> <span class="p">{</span> <span class="nx">data</span><span class="p">,</span> <span class="nx">error</span> <span class="p">}</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">supabase</span>
    <span class="p">.</span><span class="k">from</span><span class="p">(</span><span class="dl">'</span><span class="s1">profiles</span><span class="dl">'</span><span class="p">)</span>
    <span class="p">.</span><span class="nx">select</span><span class="p">(</span><span class="dl">'</span><span class="s1">pro, free_imports_used, free_exports_used</span><span class="dl">'</span><span class="p">)</span>
    <span class="p">.</span><span class="nx">eq</span><span class="p">(</span><span class="dl">'</span><span class="s1">id</span><span class="dl">'</span><span class="p">,</span> <span class="nx">userId</span><span class="p">)</span>
    <span class="p">.</span><span class="nx">single</span><span class="p">();</span>
    
  <span class="c1">// ... handle errors and return state</span>
<span class="p">}</span>
</code></pre></div></div>
<p>It’s a tiny shortcut, but it’s a lifesaver when you’re rapidly wiping the local database.</p>

<h2 id="webhooks-in-nextjs">Webhooks in Next.js</h2>

<p>Integrating the checkout flow wasn’t too bad. I set up an A Record to map <code class="language-plaintext highlighter-rouge">pro.cashcat.app</code> directly to Lemon Squeezy’s IP address (<code class="language-plaintext highlighter-rouge">3.33.255.208</code>), giving me a beautiful, white-labeled checkout page.</p>

<p>The critical requirement during checkout initialization was passing the authenticated Supabase <code class="language-plaintext highlighter-rouge">user_id</code> into Lemon Squeezy’s <code class="language-plaintext highlighter-rouge">custom_data</code> object. When the payment clears, Lemon Squeezy fires a webhook back to my server, carrying that ID with it so I know exactly whose database row to update.</p>

<p>The real headache was securely receiving that webhook. Next.js App Router (<code class="language-plaintext highlighter-rouge">app/api/...</code>) does some aggressive abstraction, especially around raw request bodies. To verify a Lemon Squeezy webhook signature, you <em>must</em> hash the absolute raw body of the request against your signing secret using <code class="language-plaintext highlighter-rouge">crypto.createHmac</code>. If Next.js parses the JSON first, the whitespace changes, the hash fails, and the webhook is rejected.</p>

<p>Building the receiver looked something like this:</p>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// app/api/webhooks/lemonsqueezy/route.ts</span>

<span class="k">import</span> <span class="nx">crypto</span> <span class="k">from</span> <span class="dl">'</span><span class="s1">crypto</span><span class="dl">'</span><span class="p">;</span>
<span class="k">import</span> <span class="p">{</span> <span class="nx">createClient</span> <span class="p">}</span> <span class="k">from</span> <span class="dl">'</span><span class="s1">@supabase/supabase-js</span><span class="dl">'</span><span class="p">;</span>

<span class="k">export</span> <span class="k">async</span> <span class="kd">function</span> <span class="nx">POST</span><span class="p">(</span><span class="nx">req</span><span class="p">:</span> <span class="nx">Request</span><span class="p">)</span> <span class="p">{</span>
  <span class="kd">const</span> <span class="nx">secret</span> <span class="o">=</span> <span class="nx">process</span><span class="p">.</span><span class="nx">env</span><span class="p">.</span><span class="nx">LEMON_SQUEEZY_WEBHOOK_SECRET</span><span class="p">;</span>
  <span class="kd">const</span> <span class="nx">signature</span> <span class="o">=</span> <span class="nx">req</span><span class="p">.</span><span class="nx">headers</span><span class="p">.</span><span class="kd">get</span><span class="p">(</span><span class="dl">'</span><span class="s1">x-signature</span><span class="dl">'</span><span class="p">);</span>
  
  <span class="c1">// You HAVE to get the text exactly as sent</span>
  <span class="kd">const</span> <span class="nx">rawBody</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">req</span><span class="p">.</span><span class="nx">text</span><span class="p">();</span> 
  
  <span class="kd">const</span> <span class="nx">hmac</span> <span class="o">=</span> <span class="nx">crypto</span><span class="p">.</span><span class="nx">createHmac</span><span class="p">(</span><span class="dl">'</span><span class="s1">sha256</span><span class="dl">'</span><span class="p">,</span> <span class="nx">secret</span><span class="p">);</span>
  <span class="kd">const</span> <span class="nx">digest</span> <span class="o">=</span> <span class="nx">Buffer</span><span class="p">.</span><span class="k">from</span><span class="p">(</span><span class="nx">hmac</span><span class="p">.</span><span class="nx">update</span><span class="p">(</span><span class="nx">rawBody</span><span class="p">).</span><span class="nx">digest</span><span class="p">(</span><span class="dl">'</span><span class="s1">hex</span><span class="dl">'</span><span class="p">),</span> <span class="dl">'</span><span class="s1">utf8</span><span class="dl">'</span><span class="p">);</span>
  <span class="kd">const</span> <span class="nx">signatureBuffer</span> <span class="o">=</span> <span class="nx">Buffer</span><span class="p">.</span><span class="k">from</span><span class="p">(</span><span class="nx">signature</span><span class="p">,</span> <span class="dl">'</span><span class="s1">utf8</span><span class="dl">'</span><span class="p">);</span>

  <span class="c1">// Guard against timing attacks</span>
  <span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="nx">crypto</span><span class="p">.</span><span class="nx">timingSafeEqual</span><span class="p">(</span><span class="nx">digest</span><span class="p">,</span> <span class="nx">signatureBuffer</span><span class="p">))</span> <span class="p">{</span>
    <span class="k">return</span> <span class="k">new</span> <span class="nx">Response</span><span class="p">(</span><span class="dl">'</span><span class="s1">Invalid signature</span><span class="dl">'</span><span class="p">,</span> <span class="p">{</span> <span class="na">status</span><span class="p">:</span> <span class="mi">403</span> <span class="p">});</span>
  <span class="p">}</span>

  <span class="kd">const</span> <span class="nx">payload</span> <span class="o">=</span> <span class="nx">JSON</span><span class="p">.</span><span class="nx">parse</span><span class="p">(</span><span class="nx">rawBody</span><span class="p">);</span>
  
  <span class="k">if</span> <span class="p">(</span><span class="nx">payload</span><span class="p">.</span><span class="nx">meta</span><span class="p">.</span><span class="nx">event_name</span> <span class="o">===</span> <span class="dl">'</span><span class="s1">subscription_created</span><span class="dl">'</span><span class="p">)</span> <span class="p">{</span>
    <span class="kd">const</span> <span class="nx">userId</span> <span class="o">=</span> <span class="nx">payload</span><span class="p">.</span><span class="nx">meta</span><span class="p">.</span><span class="nx">custom_data</span><span class="p">.</span><span class="nx">user_id</span><span class="p">;</span>
    
    <span class="c1">// Elevate privileges to bypass RLS</span>
    <span class="kd">const</span> <span class="nx">supabaseAdmin</span> <span class="o">=</span> <span class="nx">createClient</span><span class="p">(</span>
      <span class="nx">process</span><span class="p">.</span><span class="nx">env</span><span class="p">.</span><span class="nx">NEXT_PUBLIC_SUPABASE_URL</span><span class="p">,</span>
      <span class="nx">process</span><span class="p">.</span><span class="nx">env</span><span class="p">.</span><span class="nx">SUPABASE_SERVICE_ROLE_KEY</span>
    <span class="p">);</span>

    <span class="k">await</span> <span class="nx">supabaseAdmin</span>
      <span class="p">.</span><span class="k">from</span><span class="p">(</span><span class="dl">'</span><span class="s1">profiles</span><span class="dl">'</span><span class="p">)</span>
      <span class="p">.</span><span class="nx">update</span><span class="p">({</span> <span class="na">is_pro</span><span class="p">:</span> <span class="kc">true</span> <span class="p">})</span>
      <span class="p">.</span><span class="nx">eq</span><span class="p">(</span><span class="dl">'</span><span class="s1">id</span><span class="dl">'</span><span class="p">,</span> <span class="nx">userId</span><span class="p">);</span>
  <span class="p">}</span>
  
  <span class="k">return</span> <span class="k">new</span> <span class="nx">Response</span><span class="p">(</span><span class="dl">'</span><span class="s1">OK</span><span class="dl">'</span><span class="p">,</span> <span class="p">{</span> <span class="na">status</span><span class="p">:</span> <span class="mi">200</span> <span class="p">});</span>
<span class="p">}</span>
</code></pre></div></div>
<p>Using <code class="language-plaintext highlighter-rouge">crypto.timingSafeEqual</code> is absolutely necessary here to prevent timing attacks. Once the signature clears safely, the server role elevates its privileges and flips the boolean.</p>

<h2 id="dodging-the-google-tax-on-android">Dodging the “Google Tax” on Android</h2>

<p>Here is where the architecture really gets put to the test. As I detailed in my <a href="/blog/cashcat-on-android-and-ios-mobile-capacitor">Capacitor post</a>, CashCat is shipped as an Android app.</p>

<p>The Google Play Store has a draconian policy: if you unlock digital content or features natively inside an app, you <em>must</em> use Google Play Billing. If you route them to an external payment gateway like Stripe or Lemon Squeezy, your app will be banned and permanently removed. They want their 30%. Because I refuse to manage two completely separate billing architectures, at least until I start making profit, I opted for the “Reader App” loophole.</p>

<p>Essentially, you are allowed to have an app where features unlock based on a user’s web subscription, as long as you <em>do not link out</em> to the payment page from within the app itself.</p>

<p>Thanks to Capacitor, this was remarkably elegant. I created a generic helper to detect if the React code was executing inside a native shell.</p>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">export</span> <span class="kd">const</span> <span class="nx">isNativeApp</span> <span class="o">=</span> <span class="p">()</span> <span class="o">=&gt;</span> <span class="k">typeof</span> <span class="nb">window</span> <span class="o">!==</span> <span class="dl">'</span><span class="s1">undefined</span><span class="dl">'</span> <span class="o">&amp;&amp;</span> <span class="o">!!</span><span class="nb">window</span><span class="p">.</span><span class="nx">Capacitor</span><span class="p">;</span>
</code></pre></div></div>

<p>I then wrapped my paywalls and upgrade components in a conditional render. If you hit the import limit on the web version, a beautiful gradient “Upgrade to Pro” button appears, routing you to Lemon Squeezy.</p>

<p>If you hit the exact same limit inside the Android app, <code class="language-plaintext highlighter-rouge">isNativeApp()</code> evaluates to true. The button is completely stripped from the DOM. Instead, a plain text paragraph appears:</p>

<blockquote>
  <p><em>“You have reached your free import limit. To unlock unlimited imports, please manage your subscription by logging into your account at cashcat.app from a web browser.”</em></p>
</blockquote>

<p>No hyperlinks. No buttons. No Google policy violations. A single unified codebase.</p>

<h2 id="the-paywall-ux-and-final-polish">The Paywall UX and Final Polish</h2>

<p>The final piece of the puzzle was the user experience of the paywall itself. “Surprise paywalls” are a dark pattern that instantly destroy user trust.</p>

<p>Instead of a hard block on their 3rd attempt to export data, the UI acts as a constant, transparent dashboard. The Export and Import buttons continuously read the <code class="language-plaintext highlighter-rouge">free_exports_used</code> column from Supabase and display dynamic subtitle labels. Before they even think about exporting a second time, they see <em>“1 of 2 free exports remaining”</em>.</p>

<p>Once that integer hits the limit, the UI morphs dynamically. The action buttons transform into a sleek, dark-mode call-to-action modal. This modal doesn’t just ask for money; it clearly outlines the actual value proposition of CashCat Pro: custom date ranges, complex money flow diagrams, and unlimited data handling.</p>

<p>It converts the user at the exact point of friction, but only after clearly outlining what they stand to gain. Transparent pricing is what I’ve always wanted in a SaaS product, and it’s what I’m trying to build here.</p>

<blockquote>
  <p><em>If you’d like to check out the finished product (and perhaps hit that import limit yourself), visit <a href="https://cashcat.app">cashcat.app</a>.</em></p>
</blockquote>]]></content><author><name>Indigo Nolan</name></author><category term="coding" /><summary type="html"><![CDATA[Building a personal finance app is inherently ironic. You are spending thousands of hours constructing a tool designed to help people save money, and then - eventually - you have to ask those same people to hand some of that saved money over to you. But server costs don’t pay themselves!]]></summary></entry><entry><title type="html">How we ported CashCat from Next.js to Native Mobile</title><link href="https://indigo.spot/blog/cashcat-on-android-and-ios-mobile-capacitor" rel="alternate" type="text/html" title="How we ported CashCat from Next.js to Native Mobile" /><published>2026-02-17T08:00:00+00:00</published><updated>2026-02-17T08:00:00+00:00</updated><id>https://indigo.spot/blog/cashcat-native-android-ios-capacitor</id><content type="html" xml:base="https://indigo.spot/blog/cashcat-on-android-and-ios-mobile-capacitor"><![CDATA[<p>Most of my recent work on CashCat has been working on the web version, the Next.js app deployed at <a href="https://cashcat.app">cashcat.app</a>. Because we’re focusing on a web-first outlook, this has meant super-fast deployments, easy iterations, and fast development, without any reviewers or gatekeepers in our way. In the modern age, with agentic coding and such rapid progressions in technology, this is vital.</p>

<p>To use CashCat on mobile, for quick transaction adding, I originally added PWA (Progressive Web App) support. You may have seen this before it’s when you visit a website on your phone, and it prompts you to add it to your home screen. It’s supported on both Android and iOS, and most major browsers (Firefox, Chrome, etc.). As far as I was concerned, it worked flawlessly, and I used it daily.</p>

<p>But of course, I know that any app which takes itself seriously should have a presence in both the Google Play Store and the Apple App Store. Telling somebody, “No, we do have an Android app, you just have to go to the website, click the three dots, click ‘Add to home screen,’ and then look, it works!” is embarrassing. It’s not the experience I want to offer.</p>

<p>Not only that, but the offline capabilities of PWAs - even with service workers and aggressive <code class="language-plaintext highlighter-rouge">localStorage</code> and <code class="language-plaintext highlighter-rouge">indexedDb</code> caching - are severely limited compared to a native shell.</p>

<p>So, after the MVP of CashCat was shipped to the web, we began exploring the options available to us for mobile apps.</p>

<h2 id="why-not-react-native">Why not React Native?</h2>

<p>At first, we looked into <a href="https://reactnative.dev/">React Native</a>. As the web build is a Next.js app (where the frontend is React), this seemed like the logical next step. However, it quickly became clear that the amount of effort it would take to convert our entire framework into native components would be disproportionate. We wanted to reuse logic, not rewrite UI.</p>

<p>We quickly began searching for easier and more purpose-built alternatives, looking at tools like <a href="https://expo.dev/">Expo</a> and <a href="https://www.pwabuilder.com/">PWABuilder</a>.</p>

<h2 id="capacitor-saved-the-day">Capacitor Saved the Day</h2>

<p>Eventually, I spotted <a href="https://capacitorjs.com/">Capacitor</a> being mentioned in a thread on Reddit. Apparently, it worked perfectly for Next.js apps. The core strategy was simply to wrap your app, with little to no rewriting.</p>

<p>By choosing Capacitor, we decided to wrap the existing Next.js code. This allowed us to save massive amounts of time by reusing our existing TanStack Query logic (which we used for aggressive caching into <code class="language-plaintext highlighter-rouge">localStorage</code>) instead of rewriting the UI in native components. We adopted a “Single Codebase” philosophy, configuring the project to deploy to both Web (Vercel) and Mobile (Google Play) from the exact same git repository.</p>

<p>With Capacitor, you can use Ionic if you want their material UI library, or simply stick with your original web code. We chose the latter.</p>

<p>Here is how we actually pulled it off.</p>

<h3 id="1-the-nextjs-config-shift">1. The Next.js Config Shift</h3>

<p>The first hurdle was getting Next.js to output something a mobile phone understands. Phones don’t run Node.js servers, so standard server-side rendering (SSR) was out.</p>

<ul>
  <li><strong>Static Export:</strong> We switched Next.js to <code class="language-plaintext highlighter-rouge">output: 'export'</code> mode. This generates the static HTML/CSS/JS files required by Capacitor.</li>
  <li><strong>Image Optimization:</strong> We had to disable standard Next.js Image Optimization (<code class="language-plaintext highlighter-rouge">unoptimized: true</code>) because, again, there is no Node.js server on the phone to process and resize images on the fly.</li>
  <li><strong>Conditional Logic:</strong> We didn’t want to lose these features on our web deployment. We updated <code class="language-plaintext highlighter-rouge">next.config.js</code> to only apply these static settings when <code class="language-plaintext highlighter-rouge">process.env.CAPACITOR_BUILD</code> is true.</li>
</ul>

<h3 id="2-the-backend-disconnect-the-biggest-challenge">2. The Backend Disconnect (The Biggest Challenge)</h3>

<p>This was the tricky part. We quickly realized that Next.js API Routes (<code class="language-plaintext highlighter-rouge">/api/...</code>) and Server Actions (<code class="language-plaintext highlighter-rouge">'use server'</code>) crash the mobile build.</p>

<p>To fix this, I had to spend a couple hours trawling through each element.</p>

<ol>
  <li><strong>The URL Fix:</strong> We updated our fetch requests to use absolute URLs pointing to our live production domain, rather than relative paths.</li>
  <li><strong>The Refactor:</strong> We converted Server Actions into standard client-side fetches (using TanStack Query) to communicate with the remote API.</li>
  <li><strong>Component Stubbing:</strong> We created a “swap” mechanism for our <code class="language-plaintext highlighter-rouge">ApiKeyManager</code> component, completely removing it from the mobile build since it was only needed on the web server.</li>
</ol>

<p>But the real “hack” was handling the folder structure. Even with the config changes, Next.js tries to compile server code found in the <code class="language-plaintext highlighter-rouge">api</code> folder. We implemented a strategy to <strong>rename and hide</strong> the <code class="language-plaintext highlighter-rouge">src/app/api</code> folder during the mobile build process specifically to prevent Next.js from trying to touch it. I wanted to have a headache-free build process, and npm’s build scripts allowed me to create this easily.</p>

<h3 id="3-automation-the-buildmobile-script">3. Automation: The <code class="language-plaintext highlighter-rouge">build:mobile</code> Script</h3>

<p>Because the process involved renaming folders and swapping components, doing it manually was a recipe for disaster. I wrote a custom script, <code class="language-plaintext highlighter-rouge">scripts/build-mobile.js</code> (using shelljs), to automate this fragile process.</p>

<p>The script performs the following gymnastics:</p>

<ol>
  <li>Renames/Hides the API folder.</li>
  <li>Stubs out incompatible components.</li>
  <li>Runs <code class="language-plaintext highlighter-rouge">next build</code> with the correct environment variables.</li>
  <li>Restores the API folder and components (so I don’t break the web version while developing).</li>
  <li>Runs <code class="language-plaintext highlighter-rouge">npx cap sync</code>.</li>
</ol>

<p>I added this to my <code class="language-plaintext highlighter-rouge">package.json</code> so I can now run a single command:</p>

<p><code class="language-plaintext highlighter-rouge">npm run build:mobile</code></p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>build-mobile.js

const shell = require('shelljs');

// Config paths
const API_DIR = 'src/app/api';
const API_HIDDEN = 'src/app/_api_ignored';
const COMPONENT_FILE = 'src/app/components/api-key-manager.tsx';
const COMPONENT_BACKUP = 'src/app/components/api-key-manager.tsx.bak';

console.log('Preparing for Mobile Build...');

// We use this to fix the files whether the build succeeds OR fails
function restoreFiles() {
  console.log('Restoring original files...');

  // 1. Restore API folder
  if (shell.test('-d', API_HIDDEN)) {
    shell.mv(API_HIDDEN, API_DIR);
  }

  // 2. Restore Component
  if (shell.test('-f', COMPONENT_BACKUP)) {
    // Delete the dummy stub we created
    if (shell.test('-f', COMPONENT_FILE)) {
      shell.rm(COMPONENT_FILE);
    }
    // Bring back the original code
    shell.mv(COMPONENT_BACKUP, COMPONENT_FILE);
  }
}

console.log('Hiding API routes...');
if (shell.test('-d', API_DIR)) {
  shell.mv(API_DIR, API_HIDDEN);
}

console.log('Swapping ApiKeyManager with a dummy stub...');
if (shell.test('-f', COMPONENT_FILE)) {
  // Backup the real file
  shell.mv(COMPONENT_FILE, COMPONENT_BACKUP);

  shell.ShellString(`
    export default function ApiKeyManager() { 
      return null; 
    }
  `).to(COMPONENT_FILE);
} else {
}

console.log('Building static export...');
// Run the build
if (shell.exec('CAPACITOR_BUILD=true npm run build').code !== 0) {
  console.error('Build failed! Restoring files immediately...');
  restoreFiles(); // &lt;--- Critical: Fix files before exiting
  shell.exit(1);
}

restoreFiles();

console.log('Syncing with Android/iOS...');
shell.exec('npx cap sync');

console.log('Mobile build complete!');
</code></pre></div></div>

<h3 id="4-capacitor--android-setup">4. Capacitor &amp; Android Setup</h3>

<p>Once the code was built, we had to get it running on Android. I installed the Capacitor core and Android platform (<code class="language-plaintext highlighter-rouge">npx cap init</code>) and ran into a few environment issues immediately.</p>

<p>I had to fix <code class="language-plaintext highlighter-rouge">capacitor.config.ts</code> to point <code class="language-plaintext highlighter-rouge">webDir</code> to the <code class="language-plaintext highlighter-rouge">out</code> folder (the result of our static export) instead of the default <code class="language-plaintext highlighter-rouge">public</code>.</p>

<p>Development on Linux also threw a curveball. I kept getting an “Unable to launch Android Studio” error, which I fixed by finally resetting the <code class="language-plaintext highlighter-rouge">CAPACITOR_ANDROID_STUDIO_PATH</code> environment variable in my Fish shell config. I also had to manually download the Gradle distribution zip to solve some nasty connection timeouts in Android Studio.</p>

<p><img src="/assets/imgs/androidstudio.png" alt="Android Studio" /></p>

<h3 id="5-native-polish-for-the-ui">5. Native Polish for the UI</h3>

<p>A wrapped website often feels like… just a website. To make CashCat feel like a native app, we had to apply some specific UI polish:</p>

<ul>
  <li><strong>The Notch Fix:</strong> I added <code class="language-plaintext highlighter-rouge">viewport-fit=cover</code> to the metadata and applied <code class="language-plaintext highlighter-rouge">env(safe-area-inset-top)</code> padding. This prevents our content from hiding behind the status bar or the camera notch.</li>
  <li><strong>Scroll Bounce:</strong> There is nothing that screams “website” more than rubber-banding when you scroll to the top of a page. I added <code class="language-plaintext highlighter-rouge">overscroll-behavior: none</code> to the CSS (and the iOS config) to attempt to stop this. It sometimes works, sometimes doesn’t.</li>
  <li><strong>Zoom Disable:</strong> I locked the viewport scale and set input font sizes to 16px. This stops the OS from zooming in automatically every time you tap a text box.</li>
  <li><strong>The “Traffic Cop”:</strong> I added redirect logic in <code class="language-plaintext highlighter-rouge">page.tsx</code> that acts as a traffic cop. If a user is on mobile, it automatically skips the Landing Page (<code class="language-plaintext highlighter-rouge">/</code>) and sends them straight to the Dashboard (<code class="language-plaintext highlighter-rouge">/budget</code>).</li>
</ul>

<h3 id="6-deployment">6. Deployment</h3>

<p>We configured <code class="language-plaintext highlighter-rouge">.gitignore</code> to track the <code class="language-plaintext highlighter-rouge">android/</code> shell (for permissions and icons) but ignore the heavy build artifacts like <code class="language-plaintext highlighter-rouge">android/app/build</code> and <code class="language-plaintext highlighter-rouge">out/</code>.</p>

<p>For the Play Store, I signed up for the Google Play Console. It cost about £25 as a one-off fee. Totally worth it. I also learned that for every upload, you have to manually increment the <code class="language-plaintext highlighter-rouge">versionCode</code> integer in <code class="language-plaintext highlighter-rouge">build.gradle</code>, or Google rejects the build.</p>

<p>We are currently using the “Internal Testing” track. We generate a signed App Bundle (<code class="language-plaintext highlighter-rouge">.aab</code>), upload it, and use the generic “Join on Web” link to install it on our physical devices.</p>

<p><img src="/assets/imgs/playconsole.png" alt="Google Play Console" /></p>

<p>We even have a draft of a Google Play Store page:</p>

<p><img src="/assets/imgs/playstore.png" alt="Play Store Page" /></p>

<p>I’ve just begun Closed Testing (if you’d like to join, please email me at cashcat<!-- -->@<!-- -->indigonolan<!-- -->.com and I’ll give you access!)</p>

<h2 id="what-about-ios">What about iOS?</h2>

<p>While I handled the Android side, my co-maintainer Josh has been tackling the iOS version. The barrier to entry is higher there - the Apple Developer Program is around $99/year - but for the sake of covering the whole mobile market, it’s worth it. I have fewer updates from his side, and I’d direct you to <a href="https://josh.software/blog/">his own blog</a> anyway.</p>

<p>We are nearly there. The web-first approach allowed us to move fast, and Capacitor allowed us to go native without losing our minds.</p>

<blockquote>
  <p><em>If you’re interested in trying out <a href="https://cashcat.app">CashCat</a>, and you have an Android or iOS device, go sign up on the website, <a href="https://discord.gg/C9mYnEdAQA">join our discord</a>, or email me at cashcat<!-- -->@<!-- -->indigonolan<!-- -->.com to get added to our app tests!</em></p>
</blockquote>]]></content><author><name>Indigo Nolan</name></author><category term="coding" /><summary type="html"><![CDATA[Most of my recent work on CashCat has been working on the web version, the Next.js app deployed at cashcat.app. Because we’re focusing on a web-first outlook, this has meant super-fast deployments, easy iterations, and fast development, without any reviewers or gatekeepers in our way. In the modern age, with agentic coding and such rapid progressions in technology, this is vital.]]></summary></entry><entry><title type="html">The death of Open Banking for hobbyists</title><link href="https://indigo.spot/cashcat-open-banking" rel="alternate" type="text/html" title="The death of Open Banking for hobbyists" /><published>2026-02-10T08:00:00+00:00</published><updated>2026-02-10T08:00:00+00:00</updated><id>https://indigo.spot/cashcat-openbanking</id><content type="html" xml:base="https://indigo.spot/cashcat-open-banking"><![CDATA[<p>Open Banking is a wonderful concept. A more transparent society, boosting competition, allowing immense analytical power of financial data, and only with user’s consent and desire! So why is it collapsing?</p>

<p>I’m currently building a personal budgeting app. It’s going extremely well. The main dashboard works great, transactions are easy to manage and the budget is smooth (<a href="https://cashcat.app">CashCat</a>). Our next big step to join the big players was to add bank integration for automated transaction syncing. We had a master plan for this, a small subscription fee to cover the price we’d researched a year back, it was all loosely planned.</p>

<p>And then, I ran into the brick wall that is the current state of Open Banking.</p>

<h2 id="the-golden-age-that-i-unfortunately-missed">The Golden Age (That I Unfortunately Missed)</h2>

<p>A few years ago, the promise of <a href="https://www.ecb.europa.eu/press/intro/mip-online/2018/html/1803_revisedpsd.en.html">PSD2</a> (the EU regulation that forced banks to open up their data) was that innovation would flourish. Startups like the Latvian Nordigen appeared, offering free API access to bank feeds for developers.</p>

<p>In 2022, Nordigen was acquired by the UK company GoCardless. I saw this when researching a year or so ago when I had the idea for CashCat, and was a bit concerned, but “Fine,” I thought. “GoCardless is a big respectable company, surely they’ll keep the affordable tier for developers.”</p>

<p>Unfortunately, they did not keep the affordable tier for developers.</p>

<p>Six months ago, they shut down the individual access for the Open Banking service. Now, to get access, you need a full business account with them, which is completely trapped behind enterprise sales calls, minimum monthly spends, or strict regulatory requirements that require me to be an AISP (Account Information Service Provider). Which I cannot do. I do not have bank-level cash just sitting around, unfortunately, nor do I have the time or experience to jump thousands of regulatory loopholes.</p>

<p>It’s not just them, though, I went looking for alternatives, trust me. The entire ecosystem has consolidated. Plaid, Yodlee, Truelayer, all exist, but they are all designed for business-to-business. They want to sell to apps with 100,000 users and a legal team - not me building a tool right now with no budget. The “Minimum Monthly Commit” is usually enough to pay for a lifetime subscription to YNAB. I guess the compliance costs are simply too high that they aren’t willing to risk serving hobbyists at a loss anymore.</p>

<p>I looked into Mellio and a few other smaller aggregators, but it’s the same story everywhere.</p>

<p>Some sites (<a href="https://enablebanking.com/">Enable Banking</a>) seem to have potential, but so far only support EU banks and nothing in the UK yet. I will keep an eye on them, and see if support is extended to the UK soon. Otherwise, it always goes like:</p>

<ul>
  <li>
    <p>To access the APIs legally, you often need your own regulatory license or to act as an “agent” of the provider (TrueLayer, Plaid, etc).</p>
  </li>
  <li>
    <p>The “Pay as you go” models for the right to be an agent to the provider are vanishing, replaced with large, and often unaffordable, minimum contracts. That I can’t afford.</p>
  </li>
</ul>

<p>So here I am, I have Cashcat designed, the budgeting system working, the transaction view perfected, but I have no automated pipeline for the transactions.</p>

<p>Don’t get me wrong though, CashCat works fine without Open Banking. I use it daily, with manual transaction entry (and to be honest, it feels even better as a tool that way - you actually have to think about each transaction you’re making, which makes it even more likely you’ll stop in your tracks.) But I do recognise that most people will not have the time or want to put in the effort.</p>

<h2 id="so-what-next-the-return-of-the-csv">So What Next? (The Return of the CSV)</h2>

<p>So, the only option seems to be shamefully and manually exporting a CSV from the banking app every week and dragging it into Cashcat, so we’re working on a easy-to-use CSV import feature.</p>

<p>It does turn out parsing bank CSVs is a bit of a nightmare though. Every bank formats the date differently. Some use DD/MM/YYYY, some use YYYY-MM-DD. Some put the merchant in a column called “Description”, some split it into “Counterparty”, some have two columns for income and outflow, some merge them. It would be great if there was some form of standardisation, with templates provided by regulators. They could be published openly and given to all banks! That would make it much easier to make financial apps. We could call it something like… Open Banking.</p>

<p>So CashCat, I think, will have to lack the magic of ‘it just works’. It feels so weird, in this age of hyper-automation, that I can’t do this simply. It is frustrating and disappointing. I am angry, but I’m not sure where to direct that. For now, I have to adapt.</p>

<blockquote>
  <p><em>If you’re interested in trying out <a href="https://cashcat.app">CashCat</a>, which I’m building with <a href="https://josh.software">Josh Wilcox</a>, go pay us a visit to see more!</em></p>
</blockquote>]]></content><author><name>Indigo Nolan</name></author><category term="coding" /><summary type="html"><![CDATA[Open Banking is a wonderful concept. A more transparent society, boosting competition, allowing immense analytical power of financial data, and only with user’s consent and desire! So why is it collapsing?]]></summary></entry><entry><title type="html">What if we could rate movies on two axes? Introducing Head and Heart</title><link href="https://indigo.spot/headandheart" rel="alternate" type="text/html" title="What if we could rate movies on two axes? Introducing Head and Heart" /><published>2026-02-05T10:00:00+00:00</published><updated>2026-02-05T10:00:00+00:00</updated><id>https://indigo.spot/headandheart</id><content type="html" xml:base="https://indigo.spot/headandheart"><![CDATA[<p><em>This is Part 3 of a 3-part post: <a href="../favourites-me">Part 1</a> - <a href="blog/rating-systems-head-and-heart-and-favourites-me">Part 2</a>.</em></p>

<hr />

<p>If you read <a href="../favourites-me">Part 1</a>, you’ll remember I was adamant about two things: I wanted to own my data, and I didn’t want a server. I wanted everything local, with a hacked-together Google Drive sync. Well, I have a confession to make. I broke my own rules.</p>

<p>It turns out that writing your own conflict-resolution logic for JSON files stored in Google Drive is a unique form of torture that I wouldn’t wish on my worst enemy. So, I did what any reasonable developer does when they hit a wall: I rewrote the entire stack.</p>

<p>Welcome to <strong>Head and Heart</strong>.</p>

<h2 id="the-cloud-sort-of">The Cloud (Sort of)</h2>

<p>I decided to migrate the backend to <strong>Convex</strong>.</p>

<p>For those who haven’t used it, Convex is a backend-as-a-service that handles your database, authentication, and real-time updates. It allows me to keep the app feeling “local” (it’s insanely fast) while actually having the ability to save data cross-device (honestly, who would use the website if that wasn’t a builtin feature.)</p>

<p>This means yes, I had to add authentication. The app now uses <strong>Convex Auth</strong> to handle user sessions. I know, I know. I said “no accounts” previously. But the trade-off is that now you can actually log in from your phone and your laptop and not worry about overwriting your database with an old CSV file.</p>

<h2 id="the-implementation-from-1-3-to-5x5">The Implementation: From 1-3 to 5x5</h2>

<p>In <a href="blog/rating-systems-head-and-heart-and-favourites-me">Part 2</a>, I theorised that a 1-3 scale was perfect. 1 for bad, 2 for okay, 3 for great.</p>

<p>As soon as I started coding the actual grid component, I realised I had made a mistake. 1-3 is too vague. If I rate <em>Interstellar</em> a 3 on Head, where do I put <em>The Menu</em>? I liked it a lot, I thought it was excellently made. Is it also a 3? Probably. But is it the <em>same</em> 3? Is it as good? I don’t think so. I don’t want to give it a 2, because then I’m equating it with worse films, and then I fell into the same pattern again.</p>

<p>So, I decided to expand the grid to 5x5. It gives juust enough precision so you can differentiate “Good” from “Masterpiece” without falling back into the trap of “Is this a 72 or a 73 out of 100?” (which I did a lot when I used favourites.me)</p>

<p>I built this using a custom SVG component. Each cell in the grid represents a coordinate pair: <code class="language-plaintext highlighter-rouge">headRating</code> and <code class="language-plaintext highlighter-rouge">heartRating</code>.</p>

<p>The code for the grid interaction is surprisingly simple, handled by updating local state before firing a mutation to Convex:</p>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// The grid is essentially just a visualization of two numbers</span>
<span class="kr">interface</span> <span class="nx">RatingGridProps</span> <span class="p">{</span>
  <span class="nl">head</span><span class="p">:</span> <span class="kr">number</span><span class="p">;</span>
  <span class="nl">heart</span><span class="p">:</span> <span class="kr">number</span><span class="p">;</span>
  <span class="nl">onChange</span><span class="p">:</span> <span class="p">(</span><span class="nx">head</span><span class="p">:</span> <span class="kr">number</span><span class="p">,</span> <span class="nx">heart</span><span class="p">:</span> <span class="kr">number</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="k">void</span><span class="p">;</span>
<span class="p">}</span>

<span class="c1">// Visual feedback is key - hovering a cell explains the rating</span>
<span class="kd">const</span> <span class="nx">RATING_DESCRIPTIONS</span> <span class="o">=</span> <span class="p">{</span>
  <span class="mi">1</span><span class="p">:</span> <span class="dl">"</span><span class="s2">Flawed / A Slog</span><span class="dl">"</span><span class="p">,</span>
  <span class="c1">// ...</span>
  <span class="mi">5</span><span class="p">:</span> <span class="dl">"</span><span class="s2">Masterpiece / Loved it</span><span class="dl">"</span>
<span class="p">};</span>

</code></pre></div></div>

<h2 id="visualising-the-data">Visualising the Data</h2>

<p>This is the part I’m most excited about. The whole point of tracking this data isn’t just to have a list; it’s to see <em>trends</em>.</p>

<p>In the <code class="language-plaintext highlighter-rouge">Stats.tsx</code> view, I’ve built a scatter plot using SVGs. Since I’m collecting <code class="language-plaintext highlighter-rouge">type</code> (Movie, Book, Game), <code class="language-plaintext highlighter-rouge">head</code>, and <code class="language-plaintext highlighter-rouge">heart</code>, I can plot everything on a single chart.</p>

<p>I added a diagonal line of <code class="language-plaintext highlighter-rouge">y = x</code> to the graph. This is the “Line of Agreement.”</p>

<ul>
  <li><strong>Above the line:</strong> Media that captured my heart more than my head (The “Guilty Pleasures” zone).</li>
  <li><strong>Below the line:</strong> Media that I respect intellectually but didn’t actually enjoy that much (The “Schindler’s List” zone).</li>
</ul>

<p>This means I can create a heatmap (something I’d wanted to visualise for a while) very easily, something that simply wouldn’t look good at all on a 3x3 grid.</p>

<p>It looks something like this:</p>

<p><img src="blog/whyibuilt/imgs/headandheartheatmap.png" alt="Head and Heart Heatmap" /></p>

<h2 id="what-about-the-csvs">What about the CSVs?</h2>

<p>I promised I wouldn’t lock data away, and I stick to that. Even though I’m using a database now, I built a robust Import/Export system.</p>

<p>The <code class="language-plaintext highlighter-rouge">ImportModal</code> component is actually one of the most complex parts of the new app. It parses the CSVs from the old version of favourites.me and maps the fields to the new Convex schema.</p>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// convex/schema.ts</span>
<span class="k">export</span> <span class="k">default</span> <span class="nx">defineSchema</span><span class="p">({</span>
  <span class="na">mediaEntries</span><span class="p">:</span> <span class="nx">defineTable</span><span class="p">({</span>
    <span class="na">userId</span><span class="p">:</span> <span class="nx">v</span><span class="p">.</span><span class="kr">string</span><span class="p">(),</span>
    <span class="na">title</span><span class="p">:</span> <span class="nx">v</span><span class="p">.</span><span class="kr">string</span><span class="p">(),</span>
    <span class="na">type</span><span class="p">:</span> <span class="nx">v</span><span class="p">.</span><span class="kr">string</span><span class="p">(),</span> <span class="c1">// "Movie", "Book", "Game", "TV", "Board"</span>
    <span class="na">headRating</span><span class="p">:</span> <span class="nx">v</span><span class="p">.</span><span class="kr">number</span><span class="p">(),</span>
    <span class="na">heartRating</span><span class="p">:</span> <span class="nx">v</span><span class="p">.</span><span class="kr">number</span><span class="p">(),</span>
    <span class="na">dateWatched</span><span class="p">:</span> <span class="nx">v</span><span class="p">.</span><span class="kr">string</span><span class="p">(),</span>
    <span class="na">notes</span><span class="p">:</span> <span class="nx">v</span><span class="p">.</span><span class="nx">optional</span><span class="p">(</span><span class="nx">v</span><span class="p">.</span><span class="kr">string</span><span class="p">()),</span>
  <span class="p">}).</span><span class="nx">index</span><span class="p">(</span><span class="dl">"</span><span class="s2">by_user</span><span class="dl">"</span><span class="p">,</span> <span class="p">[</span><span class="dl">"</span><span class="s2">userId</span><span class="dl">"</span><span class="p">]),</span>
<span class="p">});</span>

</code></pre></div></div>

<h2 id="final-thoughts">Final Thoughts</h2>

<p>The transition from a spreadsheet to a local React app to a full-stack Convex application has been a bit of a journey. I’ve had to learn a lot about backend mutations and authentication flows (and why <code class="language-plaintext highlighter-rouge">useEffect</code> is sometimes dangerous), but the result is something I actually use every day. I stopped using favourites.me because I had too much choice paralysis on a 100-point scale, but I didn’t want to go back to Letterboxd’s 10-point scale - so here is my new, all-improved, 25-point scale. Revolutionary, I know.</p>

<p>The <strong>Head and Heart</strong> system feels right to me. It frees me from the pressure of “objective” reviews and lets me admit that sometimes, I just really love a dumb action movie, and that’s okay.</p>

<p>The new version is live now. If you have data on the old version, just export your CSV and import it into the new one.</p>

<p>Go rate some stuff!</p>

<p><a href="https://headandheart.app">Check out the new Head &amp; Heart system at headandheart.app</a>!</p>]]></content><author><name>Indigo Nolan</name></author><category term="coding" /><category term="design" /><summary type="html"><![CDATA[This is Part 3 of a 3-part post: Part 1 - Part 2.]]></summary></entry><entry><title type="html">Damaskus: 48 hours and 3 friends</title><link href="https://indigo.spot/damaskus" rel="alternate" type="text/html" title="Damaskus: 48 hours and 3 friends" /><published>2026-02-02T16:30:00+00:00</published><updated>2026-02-02T16:30:00+00:00</updated><id>https://indigo.spot/damaskus</id><content type="html" xml:base="https://indigo.spot/damaskus"><![CDATA[<p><a href="https://damaskus.indigo.spot">Play the game now! (damaskus.indigo.spot)</a></p>

<p>Last year, I participated in the <strong>Global Game Jam</strong> for the first time, and made <a href="https://globalgamejam.org/games/2025/tub-tussle-0">Tub Tussle</a>, a 4-player arcade game! I loved the whole experience this much, that when it came around this year, I was already signed up. For <strong>Global Game Jam 2026</strong>, I teamed up with my good friends <strong><a href="https://indigowg.com">Indigo Wolf-Garraway</a></strong>, <strong><a href="https://josh.software">Josh Wilcox</a></strong>, and <strong><a href="https://dexo.games">Dexter Smith</a></strong>.</p>

<p>The result? <strong>Damaskus</strong>. And we were incredibly honored to win the highly-coveted <strong>People’s Choice Award</strong>!</p>

<p><img src="/assets/imgs/damaskus.webp" alt="Damaskus" /></p>

<h2 id="the-workflow-web-based-level-editing">The Workflow: Web-Based Level Editing</h2>

<p>One of the biggest bottlenecks in a game jam is content creation. We knew we wanted a puzzle game, but building 20+ levels manually in the Godot editor would have been a nightmare of drag-and-drop, which we quickly discovered.</p>

<p>So, Josh built a custom level editor on the web: <strong><a href="https://damaskus.josh.software">damaskus.josh.software</a></strong>.</p>

<p>It allowed us to paint levels quickly in the browser and export them as simple JSON arrays. This decoupled level design from game implementation, meaning we could actually design levels while the core game wasn’t even finished yet.</p>

<p>The output looked something like this - a simple 2D array of integers representing tile IDs:</p>

<div class="language-gdscript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">var</span> <span class="n">campaign_level_layouts</span> <span class="o">=</span> <span class="p">[</span>
    <span class="p">[</span> <span class="c1"># LEVEL 1</span>
        <span class="p">[</span><span class="mi">1</span><span class="p">,</span> <span class="mi">1</span><span class="p">,</span> <span class="mi">1</span><span class="p">,</span> <span class="mi">1</span><span class="p">,</span> <span class="mi">1</span><span class="p">,</span> <span class="mi">1</span><span class="p">,</span> <span class="mi">1</span><span class="p">,</span> <span class="mi">1</span><span class="p">,</span> <span class="mi">1</span><span class="p">,</span> <span class="mi">1</span><span class="p">,</span> <span class="mi">1</span><span class="p">,</span> <span class="mi">1</span><span class="p">,</span> <span class="mi">1</span><span class="p">,</span> <span class="mi">1</span><span class="p">,</span> <span class="mi">1</span><span class="p">],</span>
        <span class="p">[</span><span class="mi">1</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">1</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">8</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">1</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">4</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">1</span><span class="p">],</span>
        <span class="p">[</span><span class="mi">1</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">1</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">2</span><span class="p">,</span> <span class="mi">2</span><span class="p">,</span> <span class="mi">2</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">1</span><span class="p">],</span>
        <span class="c1"># ... more rows ...</span>
    <span class="p">]</span>
<span class="p">]</span>
</code></pre></div></div>

<p>We utilized a similar array for <strong>Masks</strong>, which are the core mechanic of the game.</p>

<h2 id="the-importer-turning-arrays-into-acts">The Importer: Turning Arrays into Acts</h2>

<p>In Godot, we wrote a <code class="language-plaintext highlighter-rouge">LevelGenerator.gd</code> script to parse these arrays. It iterates through the grid coordinates, checks the ID, and instantiates the corresponding scene.</p>

<p>We defined the mapping in a dictionary to keep it clean:</p>

<div class="language-gdscript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># scripts/core/LevelGenerator.gd</span>

<span class="k">var</span> <span class="n">tile_definitions</span> <span class="o">=</span> <span class="p">{</span>
    <span class="mi">1</span><span class="p">:</span> <span class="p">{</span><span class="s2">"scene"</span><span class="p">:</span> <span class="n">wall_scene</span><span class="p">,</span> <span class="s2">"container"</span><span class="p">:</span> <span class="s2">"Walls"</span><span class="p">,</span> <span class="s2">"type"</span><span class="p">:</span> <span class="n">GridManager</span><span class="o">.</span><span class="n">TileType</span><span class="o">.</span><span class="n">WALL</span><span class="p">},</span>
    <span class="mi">2</span><span class="p">:</span> <span class="p">{</span><span class="s2">"scene"</span><span class="p">:</span> <span class="n">water_scene</span><span class="p">,</span> <span class="s2">"container"</span><span class="p">:</span> <span class="s2">"Water"</span><span class="p">,</span> <span class="s2">"type"</span><span class="p">:</span> <span class="n">GridManager</span><span class="o">.</span><span class="n">TileType</span><span class="o">.</span><span class="n">WATER</span><span class="p">},</span>
    <span class="mi">3</span><span class="p">:</span> <span class="p">{</span><span class="s2">"scene"</span><span class="p">:</span> <span class="n">crumbled_wall_scene</span><span class="p">,</span> <span class="s2">"container"</span><span class="p">:</span> <span class="s2">"CrumbledWalls"</span><span class="p">,</span> <span class="s2">"type"</span><span class="p">:</span> <span class="n">GridManager</span><span class="o">.</span><span class="n">TileType</span><span class="o">.</span><span class="n">CRUMBLED_WALL</span><span class="p">},</span>
    <span class="c1"># ...</span>
    <span class="mi">8</span><span class="p">:</span> <span class="p">{</span><span class="s2">"scene"</span><span class="p">:</span> <span class="n">laser_emitter_scene</span><span class="p">,</span> <span class="s2">"container"</span><span class="p">:</span> <span class="s2">"LaserEmitters"</span><span class="p">,</span> <span class="s2">"type"</span><span class="p">:</span> <span class="n">GridManager</span><span class="o">.</span><span class="n">TileType</span><span class="o">.</span><span class="n">LASER_EMITTER</span><span class="p">},</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The generation logic is straightforward. It wipes the previous level and builds the new one tile by tile, registering everything into our <code class="language-plaintext highlighter-rouge">GridManager</code>:</p>

<div class="language-gdscript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">func</span> <span class="nf">_generate_from_arrays</span><span class="p">(</span><span class="n">level_layout</span><span class="p">:</span> <span class="kt">Array</span><span class="p">,</span> <span class="n">mask_layout</span><span class="p">:</span> <span class="kt">Array</span><span class="p">):</span>
    <span class="c1"># ... cleanup old level ...</span>

    <span class="k">for</span> <span class="n">y</span> <span class="ow">in</span> <span class="nb">range</span><span class="p">(</span><span class="n">level_layout</span><span class="o">.</span><span class="n">size</span><span class="p">()):</span>
        <span class="k">for</span> <span class="n">x</span> <span class="ow">in</span> <span class="nb">range</span><span class="p">(</span><span class="n">level_layout</span><span class="p">[</span><span class="n">y</span><span class="p">]</span><span class="o">.</span><span class="n">size</span><span class="p">()):</span>
            <span class="k">var</span> <span class="n">cell_value</span> <span class="o">=</span> <span class="n">level_layout</span><span class="p">[</span><span class="n">y</span><span class="p">][</span><span class="n">x</span><span class="p">]</span>
            <span class="k">var</span> <span class="n">grid_pos</span> <span class="o">=</span> <span class="n">Vector2i</span><span class="p">(</span><span class="n">x</span><span class="p">,</span> <span class="n">y</span><span class="p">)</span>
            
            <span class="k">if</span> <span class="n">cell_value</span> <span class="o">==</span> <span class="mi">1</span><span class="p">:</span> <span class="c1"># WALL</span>
                <span class="k">var</span> <span class="n">wall</span> <span class="o">=</span> <span class="n">wall_scene</span><span class="o">.</span><span class="n">instantiate</span><span class="p">()</span>
                <span class="n">wall</span><span class="o">.</span><span class="n">position</span> <span class="o">=</span> <span class="n">grid_manager</span><span class="o">.</span><span class="n">grid_to_world</span><span class="p">(</span><span class="n">grid_pos</span><span class="p">)</span>
                <span class="n">walls_container</span><span class="o">.</span><span class="n">add_child</span><span class="p">(</span><span class="n">wall</span><span class="p">)</span>
                <span class="n">grid_manager</span><span class="o">.</span><span class="n">set_tile</span><span class="p">(</span><span class="n">grid_pos</span><span class="p">,</span> <span class="n">GridManager</span><span class="o">.</span><span class="n">TileType</span><span class="o">.</span><span class="n">WALL</span><span class="p">)</span>
            
            <span class="k">elif</span> <span class="n">cell_value</span> <span class="o">==</span> <span class="mi">2</span><span class="p">:</span> <span class="c1"># WATER</span>
                <span class="c1"># ... instantiate water ...</span>
</code></pre></div></div>

<h2 id="the-grid-system">The Grid System</h2>

<p>Since <em>Damaskus</em> is a grid-based puzzle game, we didn’t use Godot’s built-in physics engine for movement. Instead, we wrote a deterministic <code class="language-plaintext highlighter-rouge">GridManager</code>.</p>

<p>The <code class="language-plaintext highlighter-rouge">GridManager</code> holds the “truth” of the world in a <code class="language-plaintext highlighter-rouge">Dictionary</code> mapping <code class="language-plaintext highlighter-rouge">Vector2i</code> coordinates to <code class="language-plaintext highlighter-rouge">TileType</code> enums.</p>

<div class="language-gdscript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># scripts/core/GridManager.gd</span>

<span class="k">enum</span> <span class="n">TileType</span> <span class="p">{</span><span class="n">EMPTY</span><span class="p">,</span> <span class="n">WALL</span><span class="p">,</span> <span class="n">CRUMBLED_WALL</span><span class="p">,</span> <span class="n">WATER</span><span class="p">,</span> <span class="n">OBSTACLE</span><span class="p">,</span> <span class="o">...</span><span class="p">}</span>

<span class="k">var</span> <span class="n">grid_data</span><span class="p">:</span> <span class="kt">Dictionary</span> <span class="o">=</span> <span class="p">{}</span>

<span class="k">func</span> <span class="nf">is_solid</span><span class="p">(</span><span class="n">grid_pos</span><span class="p">:</span> <span class="n">Vector2i</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="kt">bool</span><span class="p">:</span>
    <span class="k">var</span> <span class="n">type</span> <span class="o">=</span> <span class="n">get_tile_type</span><span class="p">(</span><span class="n">grid_pos</span><span class="p">)</span>
    <span class="k">return</span> <span class="n">type</span> <span class="o">==</span> <span class="n">TileType</span><span class="o">.</span><span class="n">WALL</span> <span class="ow">or</span> <span class="n">type</span> <span class="o">==</span> <span class="n">TileType</span><span class="o">.</span><span class="n">CRUMBLED_WALL</span> <span class="ow">or</span> <span class="o">...</span>
</code></pre></div></div>

<p>This made implementing mechanics like the <strong>Phase Shift</strong> (Red/Blue walls) extremely easy. The player interacts with the grid, not the colliders.</p>

<h2 id="movement--mask-mechanics">Movement &amp; Mask Mechanics</h2>

<p>The player’s movement logic in <code class="language-plaintext highlighter-rouge">Player.gd</code> queries the <code class="language-plaintext highlighter-rouge">GridManager</code> before every step. This is where the <strong>Mask System</strong> comes in. Masks aren’t just cosmetic; they grant specific “properties” that override collision rules.</p>

<p>For example, the <strong>Water Mask</strong> grants the <code class="language-plaintext highlighter-rouge">FLOAT</code> property, allowing you to walk on water tiles:</p>

<div class="language-gdscript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># scripts/entities/Player.gd</span>

<span class="k">func</span> <span class="nf">can_move_to</span><span class="p">(</span><span class="n">target_pos</span><span class="p">:</span> <span class="n">Vector2i</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="kt">bool</span><span class="p">:</span>
    <span class="k">var</span> <span class="n">tile_type</span> <span class="o">=</span> <span class="n">grid_manager</span><span class="o">.</span><span class="n">get_tile_type</span><span class="p">(</span><span class="n">target_pos</span><span class="p">)</span>

    <span class="k">match</span> <span class="n">tile_type</span><span class="p">:</span>
        <span class="n">GridManager</span><span class="o">.</span><span class="n">TileType</span><span class="o">.</span><span class="n">WATER</span><span class="p">:</span>
            <span class="c1"># Only pass if we have FLOAT property</span>
            <span class="k">if</span> <span class="n">has_property</span><span class="p">(</span><span class="s2">"FLOAT"</span><span class="p">):</span>
                <span class="k">return</span> <span class="bp">true</span>
            <span class="k">return</span> <span class="bp">false</span> 

        <span class="n">GridManager</span><span class="o">.</span><span class="n">TileType</span><span class="o">.</span><span class="n">CRUMBLED_WALL</span><span class="p">:</span>
            <span class="c1"># Only pass if we have BREAK_WALL property</span>
            <span class="k">if</span> <span class="n">has_property</span><span class="p">(</span><span class="s2">"BREAK_WALL"</span><span class="p">):</span>
                <span class="k">return</span> <span class="bp">true</span>
            <span class="k">return</span> <span class="bp">false</span>
            
        <span class="c1"># ... logic for pillars phase walls ...</span>
</code></pre></div></div>

<p>This architecture allowed us to add new masks and mechanics rapidly. Need a mask that 
breaks walls? Just add a <code class="language-plaintext highlighter-rouge">BREAK_WALL</code> property and check for it in <code class="language-plaintext highlighter-rouge">can_move_to</code>.</p>

<h2 id="the-ghost">The Ghost</h2>

<p>A key and very fun feature of <em>Damaskus</em> is the <strong>co-op ghost mechanic</strong>. The Ghost (managed by <code class="language-plaintext highlighter-rouge">NPC.gd</code>) mimics your movement. Because we used a strict grid system, keeping the Ghost in sync was simply a matter of connecting a signal.</p>

<div class="language-gdscript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># scripts/entities/NPC.gd</span>

<span class="k">func</span> <span class="nf">_on_player_moved</span><span class="p">(</span><span class="n">direction</span><span class="p">:</span> <span class="n">Vector2i</span><span class="p">):</span>
	<span class="k">if</span> <span class="ow">not</span> <span class="n">is_active</span><span class="p">:</span> <span class="k">return</span>
	
	<span class="c1"># Buffer the move if we are busy, just like the Player does</span>
	<span class="k">if</span> <span class="n">is_moving</span><span class="p">:</span>
		<span class="n">next_move</span> <span class="o">=</span> <span class="n">direction</span>
	<span class="k">else</span><span class="p">:</span>
		<span class="n">try_move</span><span class="p">(</span><span class="n">direction</span><span class="p">)</span>
</code></pre></div></div>

<p>However, the Ghost <em>also</em> has an inventory slot and can wear masks! This leads to complex puzzles where you might need the Ghost to wear the <strong>Water Mask</strong> to cross a river, while you hold the <strong>Dimension Mask</strong> to open the path for both of you.</p>

<p>The pillars required a checks for <em>both</em> entities:</p>

<div class="language-gdscript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">GridManager</span><span class="o">.</span><span class="n">TileType</span><span class="o">.</span><span class="n">RED_WALL</span><span class="p">:</span>
    <span class="k">var</span> <span class="n">anyone_dim</span> <span class="o">=</span> <span class="n">has_property</span><span class="p">(</span><span class="s2">"DIMENSION_SHIFT"</span><span class="p">)</span> <span class="ow">or</span> <span class="p">(</span><span class="n">target_player</span> <span class="ow">and</span> <span class="n">target_player</span><span class="o">.</span><span class="n">has_property</span><span class="p">(</span><span class="s2">"DIMENSION_SHIFT"</span><span class="p">))</span>
    <span class="k">return</span> <span class="n">anyone_dim</span> <span class="ow">and</span> <span class="n">grid_manager</span><span class="o">.</span><span class="n">is_red_mode</span>
</code></pre></div></div>

<p>If <em>either</em> character holds the Dimension Mask, <em>both</em> can walk through the matching colored pillars. This created some really fun “aha!” moments in testing.</p>

<h2 id="the-result">The Result</h2>

<p>The combination of a custom web-based editor and a robust grid system allowed us to crank out over 20 levels in just 48 hours. It was a chaotic sprint, but <a href="https://indigowg.com">Indo</a>’s incredible pixel art, along with seeing people enjoy the puzzles - and winning the People’s Choice Award - made it all worth it.</p>

<p>It will be polished a little more and it’s ready to play at <a href="https://damaskus.indigo.spot">damaskus.indigo.spot</a>!</p>]]></content><author><name>Indigo Nolan</name></author><category term="coding" /><summary type="html"><![CDATA[Play the game now! (damaskus.indigo.spot)]]></summary></entry><entry><title type="html">Building a gamified election simulator</title><link href="https://indigo.spot/politicalplayground" rel="alternate" type="text/html" title="Building a gamified election simulator" /><published>2026-01-10T11:20:10+00:00</published><updated>2026-01-10T11:20:10+00:00</updated><id>https://indigo.spot/politicalplayground</id><content type="html" xml:base="https://indigo.spot/politicalplayground"><![CDATA[<blockquote>
  <p><strong>TLDR:</strong> I built a tiny (very inaccurate, but fun) political simulation game. You can play it at <a href="https://polplay.indigo.spot">polplay.indigo.spot</a>.</p>
</blockquote>

<hr />

<p>If you’ve spent any time in the niche world of political strategy games, you’ve probably hit the same wall I did. <em>Lawgivers</em> is a great game for mobile, but it’s more focused on the governing, and <em>The Political Process</em> is probably the gold standard for depth on desktop, but is a little <em>too</em> detailed and laser-focused. I wanted a sandbox where I could define the exact demographic makeup of a country and see how different party platforms would actually clash mathematically.</p>

<p>So, I started building <strong>The Political Playground</strong>.</p>

<h2 id="the-iteration-cycle-from-python-to-vite">The Iteration Cycle: From Python to Vite</h2>

<p>The project has gone through three distinct “lives” based on whatever I was learning at the time.</p>

<ol>
  <li><strong>The CLI Era (Python):</strong> This was basically just a bunch of <code class="language-plaintext highlighter-rouge">if/else</code> statements and a bunch of <code class="language-plaintext highlighter-rouge">random</code> calls. It was a text-based script where you’d feed in a list of parties, and it would spit out a final percentage. It developed into a bigger simulation, where it would model individual voters, but this quickly got heavy for Python to process, so I cut down the sample size, and implemented heuristics. I also eventually added <strong>Matplotlib</strong> to generate some bar charts, but it was still more of a mathematical experiment than a game, which was simply a result of my technical skill level at the time, when I was still in school.</li>
  <li><strong>The Web Migration (Next.js):</strong> After a year-long hiatus during my transition from sixth form to uni, I revived it. I initially went with Next.js because I wanted to learn the framework, and converted the whole Python program into Javascript, stripping out all of the bloat I had left in when I wrote my voting simulation logic. I initially hosted it on Vercel, and worked on it in my spare time, then moved to Sherpa.sh to experiment with different deployment pipelines. In about January 2026, Sherpa.sh shut down, and I had to move the project again.</li>
  <li><strong>The Current State (React + Vite):</strong> I realized that because the simulation logic is entirely client-side, I didn’t need a server or SSR (Server-Side Rendering). I stripped out the Next.js boilerplate, migrated to <strong>Vite</strong>, and turned it into a static <strong>React</strong> app. It’s now hosted on <strong>GitHub Pages</strong>, which makes deployment as simple as a <code class="language-plaintext highlighter-rouge">git push</code>.</li>
</ol>

<h2 id="the-logic-for-voter-demographics">The Logic for Voter Demographics</h2>

<p>The main focus of the project is the voter modeling engine. Instead of a simple “liberal vs. conservative” slider, I’ve implemented a <strong>seven-axis system</strong>, along with weighting, trends, voter blocs, salience, and loyalty factors. It is by no means accurate enough for a real-life polling model, but it is accurate enough to be fun.</p>

<p>Every voter “clump” in the simulation is defined by coordinates on these scales:</p>

<ul>
  <li>Progressive &gt; Conservative</li>
  <li>Socialist &gt; Capitalist</li>
  <li>Authoritarian &gt; Liberal</li>
  <li>Religious &gt; Secular</li>
  <li>Environmentalist &gt; Industrialist</li>
  <li>Nationalist &gt; Globalist</li>
  <li>Militarist &gt; Pacifist</li>
</ul>

<p>The initial versions of the game just generated random noise for each voter based off a ‘national average’, but this was boring, not to mention unrealistic. I quickly ran into problems with this - a party could win easily by simply becoming more centrist, and becoming more extreme would never win more votes unless it was going directly towards the national average. So, I added the ability to define voter blocs, which are groups of voters who are more likely to vote for certain parties or vote specific ways. This way, I can avoid the classic bell curve, and include different spikes - for example, voters who are socially conservative but economically left-wing, as well as a large bloc of centre-right secular voters. I also added the ability to define trends, which are factors that can influence voter behavior over time. Finally, I added the ability to define loyalty factors, which are factors that can influence voter behavior over time.</p>

<p><img src="/assets/imgs/ppvoters.png" alt="Bloc distributions" /></p>

<p>For now, I’ve manually defined all of these blocs and voting demographics for each country and each party, which is why I have disclaimers everywhere stating the inaccuracy of the simulation - it is simply a game which I find quite fun.</p>

<p>The game now generates distinct groups for each country (like ‘Urban Professionals’ or ‘Rural Traditionalists’). I use the Box-Muller transform over each bloc to make them feel organic, along with ‘salience weights’ - a Rural Traditionalist might care significantly more about socially conservative views than the economic axis. 
Each of these blocs are displayed in the statistics view while you are campaigning, and you can see which parties they are supporting.</p>

<p><img src="/assets/imgs/ppblocs.png" alt="Blocs" /></p>

<p><img src="/assets/imgs/ppperformancebybloc.png" alt="Blocs" /></p>

<h3 id="how-the-vote-is-calculated">How the “Vote” is Calculated</h3>

<p>The engine uses a distance-based formula to determine voter preference. Essentially, for every voter group V, it calculates the distance to every party P in the n-dimensional space (where n=7).</p>

<p>The simplified logic looks something like this:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>    // Iterate through all 7 political axes (VALUES)
    for (let o = 0; o &lt; VALUES.length; o++) {
      const voterVal = data[o][voterIndex];
      // Calculate squared difference (Euclidean distance component)
      eucSum += Math.pow(voterVal - cand.vals[o], 2);
    }
    
    let eucDist = eucSum;
    
    // Apply party popularity effect (Popularity acts as gravity, reducing distance)
    const popularityEffect = Math.pow(Math.max(0, cand.party_pop) * 3, 1.4);
    eucDist -= popularityEffect;

    // Apply specific election swing/momentum if present
    if (cand.swing) {
      eucDist -= (cand.swing * 5) * Math.abs(cand.swing * 5);
    }
</code></pre></div></div>

<p>I also added a ‘bandwagon’ momentum simulation, for when a party gains vote share because of a positive event, this effect is stretched over multiple weeks, to represent real-world poll bumps:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>// Calculate momentum from recent performance
if (candidate.previous_popularity !== undefined) {
    const momentumChange = candidate.party_pop - candidate.previous_popularity;
    // Momentum factor - carry forward 30% of recent change
    candidate.momentum = (candidate.momentum || 0) * 0.7 + momentumChange * 0.3;
}

// Incumbency effects - popular parties face erosion, struggling ones get recovery
let incumbencyEffect = 0;
if (candidate.party_pop &gt; 10) {
    incumbencyEffect = -0.1 * (candidate.party_pop / 20); // Voter fatigue
} else {
    incumbencyEffect = 0.05; // Small recovery boost for struggling parties
}

// Bandwagon effect - leading parties get small boost
let bandwagonEffect = 0;
if (candidate === leader &amp;&amp; candidate.party_pop &gt; 15) {
    bandwagonEffect = 0.2;
} else if (candidate.party_pop &lt; -10) {
    bandwagonEffect = -0.1; // Additional losses for struggling parties
    }
</code></pre></div></div>

<p>The distance formula above was a good start, but it made voters switch too easily. To fix this, I added two feature - softmax, and apathy. I added a drifting trend feature - to add another level of dynamic voter relationships, where the voters’ opinions change slightly, forcing the player to decide to pander to the news cycle, or stick with their principles and hope the opinions of the electorate shifts back, before they lose their chance to grab extra voters. Instead of a hard binary choice, the voters use probabilistic voting using a Softmax function - this introduces realistic noise and allows for actual upset victories if traditional parties drift too far too quickly.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>export function applyTrendStep(
  trend: ActiveTrend,
  countryValues: PoliticalValues,
  votingData: number[][]
): TrendStepResult {
  // ... shift calculations ...
  const clampedValue = Math.max(-100, Math.min(100, nextValueRaw));
  const actualShift = clampedValue - currentValue;

  // Apply the shift to individual voter data
  if (axisIndex !== -1 &amp;&amp; votingData[axisIndex]) {
    const axisData = votingData[axisIndex];
    for (let i = 0; i &lt; axisData.length; i++) {
      // Add noise so voters don't move as a perfect monolith
      const noise = actualShift === 0 ? 0 : (Math.random() - 0.5) * Math.abs(actualShift) * TREND_VOTER_NOISE;
      const newVal = axisData[i] + actualShift + noise;
      axisData[i] = Math.max(-100, Math.min(100, newVal));
    }
  }
</code></pre></div></div>

<p><img src="/assets/imgs/pptrend.png" alt="Trend example" /></p>

<p>An example of a temporary trend (pro-globalist).</p>

<p>I also had to solve the ‘centrists flip-flop very easily’ problem. To counter this, I added <em>Turnout Gating</em> and <em>Loyalty Inertia</em>. If the least-bad party is still mathematically distant (defined by a <code class="language-plaintext highlighter-rouge">TOO_FAR_DISTANCE</code> constant), then they simply stay home. Voters have memory - the engine tracks <code class="language-plaintext highlighter-rouge">LAST_CHOICES</code> and applies a <code class="language-plaintext highlighter-rouge">LOYALTY_UTILITY</code> bonus. You can’t just flip a voter by changing one policy - you have to break their existing habits and loyalty bit by bit.</p>

<h2 id="news-and-events">News and Events</h2>

<p>To model the event cycle, I decided to lean into the newspaper-style UI I’d already build, and treat the events as news articles.</p>

<p>To make each game feel more dynamic, I have some dynamic elements in the headline generation - again, there’s a massive JSON file with a bunch of headlines and what triggers them, such as the player professing support for a specific policy, or a national trend, with code that looks a bit like this:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>switch (valueKey) {
    case "soc_cap":
    if (voterPosition &gt; playerOldPosition) {
        const votPrefNews = [
        `${nameChoice}'s Pro-Business Stance Boosts Voter Confidence`,
        `Voters Applaud ${nameChoice}'s Economic Growth Agenda`,
        `${nameChoice} Declares: 'Let the Market Decide!'`,
        // ... strings ...
        ];
        voterPreferenceAnalysis.push(votPrefNews[Math.floor(Math.random() * votPrefNews.length)]);
    } else {
        const votPrefNews = [
        `${nameChoice}'s Social Spending Push Resonates with Voters`,
        `Public Backs ${nameChoice}'s Vision for a Fairer Society`,
        `${nameChoice} Promises 'Healthcare for All'—Crowds Erupt in Cheers`,
            // ... strings ...
        ];
        voterPreferenceAnalysis.push(votPrefNews[Math.floor(Math.random() * votPrefNews.length)]);
    }
    break;
</code></pre></div></div>

<p>When I moved from the original single election day simulation to instead simulating the vote polling for each week leading up to the election, I needed to write a game state:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>case 'NEXT_POLL':
    if (state.currentPoll &gt;= state.totalPolls) return state;
    
    const nextPollNum = state.currentPoll + 1;
    // ... trend logic ...

    const { results: newResults, newsEvents } = conductPoll(votingDataRef, state.candidates, nextPollNum);
    
    // ... polling analysis logic ...

    // Combine all news sources into a single array first
    const allNewsItems = [
    ...trendNews,
    ...state.playerEventNews, 
    ...newsEvents, 
    ...partyPollingNews
    ];

    // Sort the combined array by word count in ascending order to create visual variety
    const sortedPoliticalNews = allNewsItems.sort((a, b) =&gt; {
    if (Math.random() &lt; 0.6) {
        return (a.split(' ').length - b.split(' ').length);
    }
    else { return 1;}
    });

    return {
    ...state,
    currentPoll: nextPollNum,
    pollResults: resultsWithChange,
    politicalNews: sortedPoliticalNews, // ... etc
    // ...
    };
</code></pre></div></div>
<p>As you can see, it’s built as a sort of state machine, where <code class="language-plaintext highlighter-rouge">NEXT_POLL</code> takes the game to the next state, and <code class="language-plaintext highlighter-rouge">START_COALITION_FORMATION</code> begins the coalition formation, etc etc.</p>

<p><img src="/assets/imgs/pppolling.png" alt="Example polling over time" /></p>

<h2 id="coalition-algorithms">Coalition Algorithms</h2>

<p>Speaking of coalitions, they actually function very similarly to the voting - I calculate the Euclidean distance between the largest party and its potential coalition partners, but importantly I add extra conditions for opposing viewpoints (for example, a party with a weak nationalist viewpoint would have better compatibility with a party with a strong nationalist viewpoint, than a party with a weak globalist viewpoint.)</p>

<p><img src="/assets/imgs/ppcabinet.png" alt="Example coalition offer" /></p>

<p>I soon added more features on top of simple ‘compatibility’ though - you can form a ‘government’ made up of cabinet positions. Not all ministries are created equal - we assign <em>importance</em> weights for each position, based on the parties’ ideologies - Green parties will demand the Environment Ministry, while Militarists will fight for Defence and Foreign. The engine will dynamically generate these demands, as well as policy requests.</p>

<p><img src="/assets/imgs/ppcoalition.png" alt="A final coalition government" /></p>

<h2 id="features-and-customisation">Features and Customisation</h2>

<p>I’m pretty proud of the flexibility I’ve managed to keep:</p>

<ul>
  <li><strong>JSON-Driven Data:</strong> All the party data is stored in a massive JSON file. This makes it incredibly easy to add new scenarios without breaking the core engine.</li>
  <li><strong>The Campaign Loop:</strong> Instead of an instant result, the game plays week-by-week. You get prompted with events (e.g., an economic scandal or a foreign policy crisis) that shift the coordinates of either the parties or the voters themselves.</li>
  <li><strong>The Custom Party Builder:</strong> I realised this was a must-have to make anybody other than me interested in the game. You can merge parties into a coalition or build a new one from scratch, manually setting their -100 -&gt; 100 values on each of the seven axes.</li>
</ul>

<p>Go check it out at <a href="https://polplay.indigo.spot" target="_blank">polplay.indigo.spot</a>!</p>]]></content><author><name>Indigo Nolan</name></author><category term="coding" /><summary type="html"><![CDATA[TLDR: I built a tiny (very inaccurate, but fun) political simulation game. You can play it at polplay.indigo.spot.]]></summary></entry><entry><title type="html">What on earth is Kresimiria?</title><link href="https://indigo.spot/kresimiria" rel="alternate" type="text/html" title="What on earth is Kresimiria?" /><published>2025-12-22T11:20:10+00:00</published><updated>2025-12-22T11:20:10+00:00</updated><id>https://indigo.spot/kresimiriawiki</id><content type="html" xml:base="https://indigo.spot/kresimiria"><![CDATA[<blockquote>
  <p><strong>TLDR:</strong> I got way too into a Crusader Kings 3 game playing as Croatia, started writing in-character peace treaties with my friend, then wrote an entire religious text, and now I’m writing a full <a href="https://kresimiria.wiki">Wikipedia-style wiki (kresimiria.wiki)</a> about the fictional country using Jekyll. It’s worldbuilding without the (sometimes) annoying parts of actual storytelling.</p>
</blockquote>

<hr />

<p>If you <em>really</em> want the longer story, a couple of years ago, I was playing Crusader Kings 3 with my close friend Theo. He was playing as Austria, and I picked Croatia, playing as King Kresimir IV. If you’ve ever played CK3, or any Paradox game, you might know how these games go - you start with modest ambitions, maybe unite a kingdom or two, and then suddenly you’ve conquered the entire Holy Roman Empire, or at least that’s what I did. I don’t usually play aggressively, and I much prefer roleplaying to the military aspect.</p>

<p>So, here’s where it gets more intricate. I got so attached to the characters and the world we were building that I started writing actual peace treaties with Theo. We are both just as pedantic as each other, and he kept raising problems with the way I was running my realm, and pointing out tiny tiny violations of our verbal agreements - so we agreed to start writing proper formal documents in Microsoft Word. We wrote full-blown treaties, with clauses and signatories and all that diplomatic nonsense. We were roleplaying hard. (Can you tell I wanted to be a lawyer when I was younger?)</p>

<p>Here is a sample of our first peace treaty, an alliance agreement:</p>

<p><img src="/assets/imgs/secundum.png" alt="Our first peace treaty, an alliance agreement" /></p>

<p>Then I took it even further. See, I was making some… let’s say <em>extremely questionable</em> religious choices in the game, and Theo kept giving me grief about them. So naturally, the only reasonable response was to write an entire religious text to justify myself. I called it the Books of Kresimir, and don’t worry, I’m not just talking about a paragraph or two - I wrote many many chapters. I even added scripture formatting, which took <em>way</em> too long in Microsoft word (I guess it’s not a frequently requested template?)</p>

<p>Here’s a random page from the Books: (You’ll notice distinct similarities to Genesis and other chapters of the Bible - I did take inspiration from much of the structure.)
<img src="/assets/imgs/books.png" alt="A random page from the Books." /></p>

<p>Eventually we stopped playing the actual Crusader Kings game, but I wasn’t done with Kresimiria. I wanted to keep writing in this world, so I started creating laws. Government structures. I wrote an entire 1921 constitution, along with a historical precedent for it, a group of constitution signatories (‘founding fathers’ if you will) and a map.</p>

<p>The map of Kresimiria (remember, you can see all of this at <a href="https://kresimiria.wiki">kresimiria.wiki</a>).</p>

<p><img src="/assets/imgs/i.webp" alt="A Map of Kresimiria" /></p>

<p>Eventually I migrated everything to Google Docs where I could actually organize things properly - I set up specific councils, laid out in the Constitution, for different types of laws, sorted everything by topic, the works. But then, often while I was meant to be studying, I started writing electoral history. As in, for each election in Kresimirian history, I wrote who ran, who won, what the percentages were, all of that. I made an Excel spreadsheet tracking senators across ten different districts over time, and I had text documents that looked like this:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>    District V
------------------
Nika Radman (RPP) 49.5% (elected)
Dora Martinovic (RPP) 34.2% (elected)
Karla Vukovic (Indp.) 10.1%
Teo Subotic (Indp.) 6.2%
</code></pre></div></div>

<p>You get the idea. Lists and lists of election results, all sitting in plain text files and spreadsheets.</p>

<p>At some point I looked at this mess of documents and thought “there’s got to be a better way to display this.” I started searching for Wikipedia-style Google Docs templates. Couldn’t find anything that actually worked well. The formatting was always wrong, or the linking was broken, impossible to use, or it just looked terrible. So I kept researching. I tried hosting my own MediaWiki instance. I looked into services like Miraheze that would host it for me. I got to the final step of the Miraheze servers, but they’re free, and so you have to apply for one - I don’t really want all my content controlled by a largely unknown third party. I got bored and frustrated with the whole MediaWiki ecosystem pretty quickly. It may be powerful but it’s too annoying and cumbersome for me to be bothered, and I really didn’t want to host it somewhere.</p>

<p>Then it hit me. I’d just started using Jekyll for this blog you’re reading right now. Jekyll is literally designed for creating websites from Markdown files. And guess what? Wikipedia articles can just be Markdown files. I didn’t need some heavyweight wiki software. I just needed a static site generator and some Wikipedia-looking CSS.</p>

<h2 id="building-kresimiriawiki">Building kresimiria.wiki</h2>

<p>So I spent a couple of days whipping up custom CSS and HTML to make a Wikipedia clone. ‘Borrowed’ some styling ideas (basically copied Wikipedia’s infoboxes almost exactly), tweaked the layout, made it look like a proper encyclopedia. I added some Markdown articles, made a homepage, and voila, proof of concept achieved. (Have a look at <a href="https://kresimiria.wiki">kresimiria.wiki</a>).</p>

<p>And then I just - kept writing. Politicians, businessmen, soldiers, businesses and organizations and companies, legal cases, both landmark and mundane, historical events, cultural movements, sports teams, the whole ecosystem of a functioning country.</p>

<p>Here’s what I love about this project: it’s writing, but it’s <em>only</em> the fun part (in my opinion). There is absolutely something to be said about crafting a detailed plot, witty dialogue, and snappy pacing - but I’m not very good at that. Whenever I’ve tried to write a novel, I always get stuck somewhere, and slowly lose motivation as I drown myself in my world. But I am yet to get writers’ block with this. Sometimes you just want to describe how a tax system works or the results of a regional election without worrying about whether your protagonist cares about it. (Frank Herbert would get this - do you remember when pages and pages of Dune would just be explaining the economics of the spice trade?!)</p>

<p>With Kresimiria, I can just worldbuild forever. I can write an entire article about a minor political figure who was only relevant for six years and never won a seat in parliament. I can detail the career of a businessman who founded a chain of local shops, just for the purpose of making the region feel lived-in. I can write about a legal case establishing precedent for the modern day in the country without having to tie it into anyone’s character development.</p>

<p>The best (or worst?) part is I don’t know if this project will ever be ‘done’. There’s always another business to create, an even more minor politician to write about, another cultural figure to make. And I think I’m fine with that - it’s become my go-to creative outlet as a source of relaxation. I’d recommend it to anyone who is an embarrassingly massive nerd, is creative, but doesn’t have the discipline to finish a novel.</p>

<p>(Remember, go <a href="https://kresimiria.wiki">check it out! Kresimiria dot wiki.</a>)</p>]]></content><author><name>Indigo Nolan</name></author><category term="coding" /><summary type="html"><![CDATA[TLDR: I got way too into a Crusader Kings 3 game playing as Croatia, started writing in-character peace treaties with my friend, then wrote an entire religious text, and now I’m writing a full Wikipedia-style wiki (kresimiria.wiki) about the fictional country using Jekyll. It’s worldbuilding without the (sometimes) annoying parts of actual storytelling.]]></summary></entry><entry><title type="html">I use Arch, btw (yes, on the Framework)</title><link href="https://indigo.spot/blog/framework-laptop-with-fedora-arch-linux-dualboot" rel="alternate" type="text/html" title="I use Arch, btw (yes, on the Framework)" /><published>2025-11-05T11:20:10+00:00</published><updated>2025-11-05T11:20:10+00:00</updated><id>https://indigo.spot/blog/framework-arch-linux</id><content type="html" xml:base="https://indigo.spot/blog/framework-laptop-with-fedora-arch-linux-dualboot"><![CDATA[<p>I’ve been happily running Fedora 42 on my Framework laptop for a couple of weeks, but I recently got that itch that every Linux user gets (it’s always distro-hopping time). Somewhere on the internet I’d seen the <a href="https://github.com/caelestia-dots/caelestia">Caelestia dotfiles</a>, and spun up an Arch Linux VM with the config applied - I was blown away. It looked incredible, and I almost immediately knew I had to have it running on bare metal. This sparked an amazing project that I could distract myself from university assignments with - dual-booting Arch alongside my existing Fedora install. And because I love taking risks, I decided I also wanted a shared data partition so I could share <code class="language-plaintext highlighter-rouge">~/Documents</code> (and importantly <code class="language-plaintext highlighter-rouge">steamapps</code>) between the two systems.</p>

<p>I knew this would mean partitioning my main NVMe drive - so I dutifully ignored all the massive red warning boxes in every tutorial telling me how dangerous this was when it was the only place I had a running linux system. While I could have easily wiped my drive and started fresh, my goal was to perform this surgery without losing my stable Fedora install. I’d heard all about how unstable Arch Linux was, and I ideally wanted to have some system alive and ready to run in case I needed to do, you know, things like Bluetooth, Wifi, use my microphone, external displays, that sort of thing.</p>

<h2 id="the-partitioning-surgery">The Partitioning Surgery</h2>

<p>My first move was to try and partition from <em>within</em> Fedora. This was a non-starter; you can’t resize the foundation while you’re standing in the house. The solution was booting from a dedicated <a href="https://gparted.org/">GParted Live USB</a> to perform the operation from a neutral environment.</p>

<p>I shrank my main <code class="language-plaintext highlighter-rouge">btrfs</code> partition and created two new ones:</p>
<ol>
  <li>An <code class="language-plaintext highlighter-rouge">ext4</code> partition for the Arch root (<code class="language-plaintext highlighter-rouge">/</code>).</li>
  <li>An <code class="language-plaintext highlighter-rouge">ntfs</code> partition for my shared <code class="language-plaintext highlighter-rouge">/data</code> drive. (Pro-tip - using <code class="language-plaintext highlighter-rouge">ntfs</code> is a life-saver, as it avoids all the Linux <code class="language-plaintext highlighter-rouge">uid</code>/<code class="language-plaintext highlighter-rouge">gid</code> permission headaches between two different distros).</li>
</ol>

<h3 id="lets-use-archinstall-right">Let’s use <code class="language-plaintext highlighter-rouge">archinstall</code>, right?</h3>

<p>With my partitions ready, I booted from my Arch Live USB. My secret weapon, I thought, would be the <code class="language-plaintext highlighter-rouge">archinstall</code> script I’d seen on YouTube. I’ve used Linux before but I was doing this in my spare time and I didn’t want to spend hours setting up arch. This script was supposed to automate the whole process.</p>

<p>I ran <code class="language-plaintext highlighter-rouge">archinstall</code>, selected ‘Manual partitioning’ (so it didn’t overwrite my entire Fedora install) and pointed it at the new partitions I’d just made earlier:</p>
<ul>
  <li><code class="language-plaintext highlighter-rouge">/dev/nmve0n1p5</code> (my <code class="language-plaintext highlighter-rouge">ext4</code> partition) - mount point <code class="language-plaintext highlighter-rouge">/</code></li>
  <li><code class="language-plaintext highlighter-rouge">/dev/nvme0n1p1</code> (my existing EFI partition) - mount point <code class="language-plaintext highlighter-rouge">/boot/efi</code></li>
</ul>

<p>I hit install, and … <code class="language-plaintext highlighter-rouge">ValueError: mount point is not specified</code></p>

<p>I went back and did it all again. From the beginning. Fresh Arch install, <code class="language-plaintext highlighter-rouge">archinstall</code>, iterate through. Same error. I dug deeper. I googled a <em>lot</em>. I made <em>sure</em> my EFI partition was marked with the <code class="language-plaintext highlighter-rouge">Esp</code>. I googled even more. The <code class="language-plaintext highlighter-rouge">archinstall</code> script, it turns out, is fantastic for clean installs, but gets very confused when I try to manually partition my drives. While great for clean installs, gets very confused by existing multi-boot setups. Fedora also often maps multiple subvolumes together nowadays, in a way that makes discovery tricky. It was time to do it the real Arch way.</p>

<h3 id="the-real-arch-way">The Real Arch Way™</h3>

<p>I had to abandon the script and go manual. Time to pull up the Arch Wiki.</p>
<ul>
  <li><code class="language-plaintext highlighter-rouge">mount /dev/nvme0n1p5 /mnt</code></li>
  <li><code class="language-plaintext highlighter-rouge">mkdir -p /mnt/boot/efi &amp;&amp; mount /dev/nvme0n1p1 /mnt/boot/efi</code> to mount my partitioned drives</li>
  <li><code class="language-plaintext highlighter-rouge">pacstrap /mnt base linux...</code> to install the core system.</li>
  <li><code class="language-plaintext highlighter-rouge">genfstab -U /mnt &gt;&gt; /mnt/etc/fstab</code> to set up the partitions.</li>
  <li><code class="language-plaintext highlighter-rouge">arch-chroot /mnt</code> to go “inside” my new system.</li>
</ul>

<p>Now I was inside the system. I created my user accounts, set my locale, my passwords, all of that. Now just the bootloader so the next time I logged in it doesn’t just explode my entire system…</p>

<h3 id="grub">GRUB</h3>

<p>I first ran <code class="language-plaintext highlighter-rouge">grub-install</code> - <code class="language-plaintext highlighter-rouge">efibootmgr</code> not found. A quick <code class="language-plaintext highlighter-rouge">pacman -S efibootmgr</code> fixed that, but it was a sign of what was to come.</p>

<p>I had decided to keep GRUB for its <code class="language-plaintext highlighter-rouge">os-prober</code> feature, as I was led to believe this would automatically find my Fedora installation. No such luck, unfortunately.</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">grub-mkconfig</code> failed: <code class="language-plaintext highlighter-rouge">os-prober</code> ran but found… nothing. It turns out, I’m not smart enough to figure out how to get <code class="language-plaintext highlighter-rouge">os-prober</code> to scan Fedora’s <code class="language-plaintext highlighter-rouge">btrfs</code> subvolumes, even with <code class="language-plaintext highlighter-rouge">btrfs-progs</code> installed. I’m still not 100% sure if I had something misconfigured, but I pushed forward anyway.</li>
</ul>

<p>The solution was to stop relying on automation and write a manual chainloader. My first attempt failed, but the final, working solution was adding this to <code class="language-plaintext highlighter-rouge">/etc/grub.d/40_custom</code>:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>menuentry "Fedora" {
    search --no-floppy --fs-uuid --set=root YOU-DONT-NEED-MY-UUID
    chainloader /EFI/fedora/grubx64.efi
}
</code></pre></div></div>

<p>I rebooted my laptop, and the beautiful GRUB menu appeared. I could boot into Arch. Wonderful.</p>

<h3 id="shared-partition">Shared partition</h3>

<p>I rebooted again, this time into Fedora.</p>

<p><code class="language-plaintext highlighter-rouge">sudo mkdir /data, sudo nano /etc/fstab</code> - I added the magic line for my <code class="language-plaintext highlighter-rouge">ntfs</code> drive, gave myself ownership of it. I tried to move some of my documents into <code class="language-plaintext highlighter-rouge">/data</code>. Permission denied.</p>

<p>My old nemesis. I ran <code class="language-plaintext highlighter-rouge">findmnt /data</code> and saw the problem - my root drive hadn’t mounted at all. I looked back at my <code class="language-plaintext highlighter-rouge">fstab</code> - a lovely little typo, <code class="language-plaintext highlighter-rouge">ntfs</code> instead of <code class="language-plaintext highlighter-rouge">ntfs-3g</code>. I fixed it, ran <code class="language-plaintext highlighter-rouge">sudo mount -a</code> and tried again. This time, it worked! I moved all of my documents, game saves, pictures, all that fun stuff, into my data drive, and then scribbled down a little list of software I would need to reinstall on Arch (raw image viewers, VS code, browsers, PDF viewers, Discord, Spotify, I could go on).</p>

<h2 id="caelestia">Caelestia</h2>

<p>I cannot thank the team behind the caelestia dotfiles enough. The Hyprland config, along with the <a href="https://github.com/caelestia-dots/shell">Quickshell config</a>, makes this the prettiest operating system I’ve ever used, with almost no competition.</p>

<p>After less than an hour of installing <code class="language-plaintext highlighter-rouge">fish</code> and all the pre-requisite packages, running the installation scripts, erroring, debugging, rebooting, and tweaking Hyprland config, I was satisfied. Here are some of my custom configs:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>#--# hypr-vars.conf
$browser = firefox
$editor = code
$touchpadDisableTyping = false
$workspaceGaps = 10
$windowGapsIn = 5
$windowGapsOut = 10
$singleWindowGapsOut = 5
$windowOpacity = 0.95
$windowRounding = 10

$kbMoveWinToWs = Super+Shift 
$kbSystemMonitor = Super, Escape
</code></pre></div></div>

<p><img src="/assets/imgs/caelestia.png" alt="My caelestia config" /></p>

<p>Look at this. Could you ever go back?</p>

<h2 id="final-thoughts">Final thoughts</h2>

<p>One of the most surprising things I experienced was when I plugged my laptop into my HDMI cable, fully expecting absolutely nothing to happen, the display to tear, or some horrendously scaled image to appear - only for a beautifully intact, perfectly scaled monitor to materialise in front of my eyes. It actually worked <em>better</em> on Hyprland / Arch than it did on Fedora with GNOME. There’s no going back now.</p>

<p>Yes, it does get a bit tiring connecting to WiFi and Bluetooth devices with my terminal using <code class="language-plaintext highlighter-rouge">nmcli dev wifi connect "eduroam"</code> and <code class="language-plaintext highlighter-rouge">bluetootctl devices</code>, but it’s probably good practice for me to understand more about the operating system anyway - for when it inevitably breaks and I need to fix it on the fly.</p>

<p>Most importantly, I can now, finally, say:</p>

<blockquote>
  <p>“I use Arch, btw”.</p>
</blockquote>

<p>Happy days.</p>]]></content><author><name>Indigo Nolan</name></author><category term="coding" /><summary type="html"><![CDATA[I’ve been happily running Fedora 42 on my Framework laptop for a couple of weeks, but I recently got that itch that every Linux user gets (it’s always distro-hopping time). Somewhere on the internet I’d seen the Caelestia dotfiles, and spun up an Arch Linux VM with the config applied - I was blown away. It looked incredible, and I almost immediately knew I had to have it running on bare metal. This sparked an amazing project that I could distract myself from university assignments with - dual-booting Arch alongside my existing Fedora install. And because I love taking risks, I decided I also wanted a shared data partition so I could share ~/Documents (and importantly steamapps) between the two systems.]]></summary></entry><entry><title type="html">Designing your personal space on the internet</title><link href="https://indigo.spot/blog/designing-a-personal-website" rel="alternate" type="text/html" title="Designing your personal space on the internet" /><published>2025-11-02T07:00:00+00:00</published><updated>2025-11-02T07:00:00+00:00</updated><id>https://indigo.spot/blog/designing-your-personal-website</id><content type="html" xml:base="https://indigo.spot/blog/designing-a-personal-website"><![CDATA[<p>My website used to be a lot flashier. There was a period where I had typewriter effects on almost every textbox, I had flashy animations covering the screen, swooshing shapes, scroll snapping, projects jumping up at you, the works. It was, for all intents and purposes, a tech demo masquerading as a portfolio. It was a digital playground to show off every cool new CSS and JavaScript trick I’d just learned. As every personal site, it evolved over time, and one day I removed my triangular-style header animations, simply because I was practicing making hamburger menus instead.</p>

<p>One day in school, I remember one of my classmates going: ‘Indy, what happened to your website? I liked the trapezium thingy you had…’. Now, while he meant this as a compliment, for me it was a moment of sudden clarity. The ‘trapezium header’ was a complicated, animated header that I’d spent a weekend building. And that was the problem - the single most memorable part of my portfolio site wasn’t anything about me, my projects, or any of my content - it was some gimmicky, and frankly cheap, animation.</p>

<p>Over the most recent summer, I redesigned my site once again, making sure to prioritise accessibility, usability, and reducing the cognitive load of anybody who visits it. Not only was it tons of fun, I learnt quite a lot in the process.</p>

<h2 id="flashiness-is-definitely-tempting">Flashiness is definitely tempting</h2>

<p>Look, let’s be honest - overengineering a personal site is a rite of passage for any developer. It’s your own space with no limits, no product managers, no clients, and no real-world constraints. It’s the perfect place to try out any obscure library or parallax scrolling effect you’ve just seen on social media.</p>

<p>This impulse comes from a very good place - genuine love for the craft and a desire to push your own skills. But it comes, in my opinion, at the cost of the very reason someone visits our site in the first place - to learn who we are, what we’ve built, or read what we’ve written. The flashy animations aren’t communicating anything about us, other than the fact that we had a spare couple of days to wrestle with uncooperative CSS transforms.</p>

<h2 id="evaluating-hidden-taxes">Evaluating ‘hidden taxes’</h2>

<p>Whenever designing anything, it’s important to look for ‘hidden taxes’.</p>

<p>For example, the <strong>accessibility tax</strong>. Let’s take a typewrite effect as an example. Yes, it’s possible to create accessible companion elements for screenreaders - but natively, they will often try and read out each letter as it appears, resulting in an indecipherable mess. The scroll-snapping that feels so slick with a trackpad? It makes keyboard navigation a frustrating battle. Finding someone’s website and discovering they haven’t made it usable on a phone is a frustrating experience, but the main person who loses out is them, not me, as I’ll just immediately close the site.</p>

<p>If your design relies on a single, optimal way of being experienced, it is (for the most part) inherently exclusionary. That said, this doesn’t apply everywhere - for example, it makes sense for VR experiences to not support screen readers, et cetera. But you know what I’m trying to say - experiences that want to be accessible to everybody but aren’t. A website that isn’t navigable by keyboard or comprehensible to a screen reader will ultimately result in a high bounce rate.</p>

<p>There’s also the <strong>performance tax</strong>. I like websites which have background images, I must admit. I’ve had one periodically on my site - there might even be one there while you read this very article. But any vaguely high-resolution image, not to even speak of all the heavy JavaScript libraries, meant my site took an eternity to load on anything less than a high-speed connection. Forcing a visitor to download megabytes of assets just to see my email address is poor communication. A lightweight, fast-loading site is essential to get across who you really are without bothering with the fluff.</p>

<p>Last and possibly the most important is the <strong>cognitive tax</strong>. The entire point of a personal / portfolio site is <em>communication</em>. You are selling yourself. Every unnecessary animation, every unexpected layout shift, every element that vies for attention, actively works against that purpose. It creates <em>cognitive noise</em>, forcing the user’s brain to work harder to filter out the rubbish and find the information they came for. A user shouldn’t have to fight your design to get to the content they want! I’ve always liked websites that are upfront and clear about what exactly they offer.</p>

<h2 id="the-cosy-human-approach">The ‘cosy human’ approach</h2>

<p>So, what is the alternative then, a boring old text document? No, calm down. The opposite of ‘flashy’ isn’t ‘dull and colourless’, it’s ‘thoughtful’.</p>

<p>My goal for my redesign was to create a space where I could store my things on the internet, at the same time as selling myself. I wanted a space that felt human, calm, and welcoming. I’m not just a developer, I’m also a writer, a photographer, and I don’t want to limit myself by creating a website that looks like it came straight out of a 2000s hacker movie.</p>

<p>I swapped out my geometric coding monospace font for a warmer, more handwritten one: <a href="https://fonts.google.com/specimen/Syne+Mono">Syne Mono</a>. I think it strikes a very good balance between something that works for displaying codeblocks, and friendly.</p>

<p>I did the same with the colour palette. I moved away from high-contrast blacks and whites to a softer, pastel-adjacent scheme that I think feels calmer. Hopefully all the text is still readable, but I feel it provides a comfier atmosphere.</p>

<p>I mentioned being ‘upfront and clear about what your site offers’. This is what my landing page attempts to be - a form of main menu, with options prioritised by the order I want users’ eyes to follow. I’ve previously had an ‘About Me’ first, and then a button to access my projects, but as I grew my site, adding various different things, I found myself getting overwhelmed and cramped and not sure where to put everything. A way to solve this is an easy-to-navigate menu, meaning I can add more information to the site later and I simply have to expand the main menu, not refactor the entire site’s design.</p>

<p>Perhaps most importantly, I embraced whitespace. Whitespace is a wonderfully underutilised design tool. My old site was crammed with elements and I was terrified of leaving a single pixel empty. Negative space is not simply ‘empty’ - it’s an active design element that reduces clutter, allows the content to breathe, and guides the user’s eye to what’s important.</p>

<p>Of course, a personal website is one of the few corners of today’s internet that you can <em>really own</em>. It doesn’t need to conform to the engagement-driven design of social media, or the conversion-focused layout of a company’s landing page. It can be simply you. Don’t let me tell you how to build your websites. Let your creativity run wild, by all means. But keep your eye on the goal - a pleasant experience for everybody who stumbles across it.</p>

<p>My old site reflected a version of me that was eager to shout his technical skills (or what I’m learning of them) from the rooftops. I hope, at least, that this new one reflects a version of me that is more interested in thinking thoughtfully and creating a calming space where you can find out more about me and what I do. And while I do occasionally miss the novelty of the huge sliding trapezium I used to have, I’ll take an easy-to-scale, easy-to-maintain, and easy-to-access user experience every time. Look at your own little corner - what is the most memorable part of your site? Is it flashy animations, or is it your personality and your voice?</p>]]></content><author><name>Indigo Nolan</name></author><category term="design" /><summary type="html"><![CDATA[My website used to be a lot flashier. There was a period where I had typewriter effects on almost every textbox, I had flashy animations covering the screen, swooshing shapes, scroll snapping, projects jumping up at you, the works. It was, for all intents and purposes, a tech demo masquerading as a portfolio. It was a digital playground to show off every cool new CSS and JavaScript trick I’d just learned. As every personal site, it evolved over time, and one day I removed my triangular-style header animations, simply because I was practicing making hamburger menus instead.]]></summary></entry></feed>