<?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-06-29T19:15:08+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">Montenegro - untamed peaks and coastal sun on a Balkan budget</title><link href="https://indigo.spot/blog/montenegro" rel="alternate" type="text/html" title="Montenegro - untamed peaks and coastal sun on a Balkan budget" /><published>2026-06-23T08:00:00+00:00</published><updated>2026-06-23T08:00:00+00:00</updated><id>https://indigo.spot/blog/montenegro</id><content type="html" xml:base="https://indigo.spot/blog/montenegro"><![CDATA[<p>One morning, we were eating pastries on a balcony looking out on a Mediterranean bay. Two days later, we were standing on a windswept mountain peak at 2300m, looking over the jagged alpine landscape.</p>

<p>That’s Montenegro - it may be smaller than Connecticut and less populous than Glasgow, but it packs incredible variety into a country you can cross in a few hours, and all on a Balkan budget. If you’re only visiting some of these places, our three-week trip had <strong>four main parts</strong>, which you can jump to with these links:</p>

<ul>
  <li>
    <p><a href="#the-bay-of-kotor">Bay of Kotor (5 nights)</a></p>
  </li>
  <li>
    <p><a href="#durmitor-national-park">Durmitor National Park (4 nights)</a></p>
  </li>
  <li>
    <p><a href="#prokletije-national-park">Prokletije National Park (3 nights)</a></p>
  </li>
  <li>
    <p><a href="#skadar-lake">Skadar Lake (3 nights)</a></p>
  </li>
</ul>

<p class="caption"><img src="/assets/imgs/montenegro/map.webp" alt="Rough three-week travel route map of Montenegro" />
Our rough route</p>

<blockquote>
  <p><a href="#know-before-you-go">At the bottom of this page, there’s a Know Before You Go</a> that you can refer back to for a packing list and key tips!</p>
</blockquote>

<p><img src="/assets/imgs/montenegro/broadview.webp" alt="Durmitor Prutas Peak" /></p>

<p class="caption">The view from the top of <a href="#durmitor-ring-and-mount-prutas">Mount Prutas</a></p>

<h2 id="the-bay-of-kotor">The Bay of Kotor</h2>

<p>We flew into Podgorica and picked up our rental car from local car hire company Terrae-Car, who we’d happily recommend. After spending a night in a nearby airport hotel, we woke up and drove north-west across the coast to the Bay of Kotor, where we checked into our AirBnB in the western town of Muo!</p>

<p>On the way, we passed Sveti Stefan, famous for its peninsula island. While the island is reserved for guests of the luxury hotel, you can park at the higher St Sava Church, where the view (and the sea breeze!) is better.</p>

<p><img src="/assets/imgs/montenegro/svetistefan.webp" alt="Sveti Stefan from St Sava Church" /></p>

<p class="caption">Sveti Stefan from St Sava Church</p>

<h3 id="franz-joseph-stairs">Franz Joseph Stairs</h3>

<p>This historic trail zigzags its way up the mountain towards Fort Vrmac, an entirely abandoned Austro-Hungarian fortress. Be prepared for a dramatic change in climate - the sweltering sun of the coast drops off and you are rewarded with a cool sea breeze at the top of the ridge, which acts as a wind tunnel.</p>

<p>You will want to climb this in the morning - this way, you’ll arrive at the peak at about midday, in time to explore the fort. ‘Explore’ is truly the right word - the fort is completely abandoned, no ticket booths or safety rails, which means rusty nails, broken glass, and sharp rocks lying around everywhere. Walking through the dark, empty halls gives you a sense of what an Austro-Hungarian soldier stationed here in the 1800s might have felt.</p>

<p>The ridge also houses a local farm owned by a man called Goran, who you’ll likely discover as you’re greeted by his chickens, pigs, goats, and some farm dogs! While we didn’t go in, he sells eggs, cheese, and milk, so if you want to support local Montenegrin farmers, it’s a stunning location to do so.</p>

<p>We tracked around 25,000 steps this day, a solid start to our hiking journey!</p>

<p><img src="/assets/imgs/montenegro/lizard.webp" alt="Lizard on Franz Joseph Stairs, Montenegro" /></p>

<p class="caption">We were joined by a slithery friend halfway through</p>

<h3 id="kotor-cat-museum">Kotor Cat Museum</h3>

<p>If you are looking for a break in this hiking holiday to buy some souvenirs, Kotor Old Town is the place. It’s probably the most touristy place we’ll visit on this trip - but it’s still worth a visit. We initially thought Kotor felt a bit unfinished, but when you enter the gate into the old town you suddenly get hit by the Western European style old town vibes, old churches, cobbled streets, ice cream stores, and dozens of identical souvenir shops.</p>

<p>The highlight of the old town in my opinion was the Cat Museum - a strange theme throughout the whole town, due to the large numbers of stray cats. The museum takes an entry fee of €1, in cash only, which is negligible and goes towards the upkeep of the local stray cats. For an extra €2.50, you can buy a certificate to sign up your cat to be officially part of the Kotor Cat Society! It’s packed with old stamps, advertisements, cartoon strips, and other cat media throughout history.</p>

<p><img src="/assets/imgs/montenegro/catmuseum.webp" alt="Cat Museum, Kotor Old Town, Montenegro" /></p>

<p>We walked so much, we really don’t know the meaning of ‘rest’ day.</p>

<h3 id="ladder-of-kotor">Ladder of Kotor</h3>

<p>If you are hiking the Ladder of Kotor, try to start early - the middle of the day gets boiling hot and the sun is unforgiving. Unlike the Franz Joseph stairs, the Ladder doesn’t provide any shade until at least three-quarters of the way up, so it’s worth bringing sun hats, suncream and lots of bottles of water. If you want a hike that doubles as a relationship test, go for this one - I’m proud to say we made it out of this one even stronger than before.</p>

<p>The switchbacks seem endless, but the views of the bay opening up beneath you as you climb are well worth the burning calves.</p>

<p><img src="/assets/imgs/montenegro/kotor.webp" alt="Bay of Kotor, Montenegro" /></p>

<p class="caption">The Bay from the top of the Ladder</p>

<blockquote>
  <p>Pro tip - you don’t need to climb to the absolute summit - just before the very top there is a perfect, shady tree. We packed some poppy seed bread from Kordic Bakery and Pastry Shop (for only a euro per slice!), and had a cool and shaded lunch looking out at the water.</p>
</blockquote>

<h3 id="boat-tours-perast-and-the-cable-car">Boat Tours, Perast, and the Cable Car</h3>

<p>For our last day in Kotor, the two main hikes exhausted, we decided to lean fully into tourism.</p>

<p>We booked a boat tour via GetYourGuide, but you could just as easily find one on the day - there are literally dozens of companies waterside shouting ‘boat tour? boat tour’ at you constantly. We opted for the Lady of the Rocks and Perast tour, so our speedboat drove around the artificial Lady of the Rocks island with a church on it, giving us a panoramic view of the old, more picturesque town of Perast, before dropping us off in Perast for an hour of exploration.</p>

<blockquote>
  <p>Some boat tours offer to let you explore the church on the island - in my view, it’s not worth it. You have to pay to enter, cover up if you’re wearing shorts or a t-shirt, and it’s not that impressive inside - the best view is from the water.</p>
</blockquote>

<p>If you want to take the <a href="https://www.kotorcablecar.me/">Kotor Cable Car</a>, I’d recommend getting to the bottom around 5pm, so you can experience sunset on the peak. The cable car takes you to the heights of Mount Lovcen, at 1,348m above the sea-level base station.</p>

<p>The wind chill is intense at the top, so bring a jumper or a coat, even if it’s sweltering in Kotor - I made this mistake and was shivering as the sun set.</p>

<p>We rode the Alpine Coaster for €12 each, and while a very touristy thing to do, evidenced by the half-hour long queue, the views you see of the sea below you as the wind blows in your hair are honestly worth it.</p>

<p>After our coaster ride, we shared a four-cheese pizza and drinks at Forza Kuk, the panoramic balcony restaurant which looks over the entire Bay of Boka below you. We also got a frozen banana at Bob’s Frozen Bananas, a ridiculous and overpriced delicacy which was well worth the price for the whimsical experience of running to the cable car queue brandishing a chocolate-covered banana on a stick.</p>

<p><img src="/assets/imgs/montenegro/alpine.webp" alt="Kotor cable car peak, Montenegro" /></p>

<p class="caption">The views from the Kotor Cable Car are well worth the trip</p>

<h2 id="durmitor-national-park">Durmitor National Park</h2>

<p>Leaving the Bay of Kotor behind, we packed up our Terrae-Car and headed north. If you are following this route, I highly recommend stopping at a VOLI or IDEA supermarket on the way to stock up on snacks and food for dinners when you get into more remote areas.</p>

<blockquote>
  <p>Getting petrol in Montenegro may throw you off if you’re not used to full-service stations! You can just pull up to a pump and say ‘Puno, molim’ for a full tank and the attendant will handle everything for you.</p>
</blockquote>

<p>The drive north from Kotor is an adventure in itself. With the heat of the coast fading and <em>Six</em> the musical blasting in the car, the rolling hills of the Mediterranean were slowly replaced with rugged, dramatic alpine peaks.</p>

<blockquote>
  <p>Quick heads-up as you unpack - while the heat was gone, bugs are ever-present in Durmitor - a good bug spray is vital, and most houses will have bug nets over large windows.</p>
</blockquote>

<h3 id="black-lake">Black Lake</h3>

<p>Black Lake is an entry-level hike - it’s more of a walk, around a 5km round trip around Black Lake (Crno Jezero) in the national park.</p>

<p>The entry fee to enter the Durmitor National Park is €5, paid in cash at the booths by car parks at the entrances to the park - but I would strongly recommend buying the Annual Membership to all Montenegro’s national parks for €13.50, which you can <a href="https://nparkovi.me/sections/1/online-tickets">do online here</a>. If you’re going into any of the national parks (Durmitor, Lovcen, Prokletije, and Skadar Lake all feature on this trip) more than twice it will save you dozens of euros. Buying this pass gives you a QR code on your phone you can show to the guards for them to scan.</p>

<p>The lake itself is almost split into two parts by peninsulas, and features some incredible views with the towering Mount Međed providing a backdrop to the rocky circular loop.</p>

<p>We went through a packet of trail mix and some sunflower seeds and sat on one of the benches for a while listening to the birds and looking at the small fields of alpine buttercups swaying in the breeze. Despite a lot of people being around and on the path, it still felt remote and quiet enough to contemplate the mountainous scene surrounding us.</p>

<p><img src="/assets/imgs/montenegro/black_lake.webp" alt="Black lake, Durmitor National Park, Montenegro" /></p>

<p class="caption">Black Lake, Durmitor National Park</p>

<h3 id="durmitor-ring-and-mount-prutas">Durmitor Ring and Mount Prutas</h3>

<p>If you’re looking for a big day out of driving and hiking, look no further than Mount Prutas, found forty minutes on the 76km Durmitor Ring Road which circles through the Durmitor National Park.</p>

<p>The trail itself is a 8km round-trip, and it took us about 4 hours to complete, 2.5hrs to the top and 1.5hrs down again.</p>

<p>We kicked the day off at 11am from Zabljak, making a pitstop at the local VOLI to get some lunch: traditional flaky pastries (<em>Pita</em>) containing potato and onion (<em>krompiruša</em>), spinach (<em>zeljanica</em>), and cheese (<em>sirnica</em>). They were delicious, and felt absolutely deserved at the peak of the mountain. We also brought 4 litres of water, plus our cameras, suncream, and some trail mix.</p>

<p>We’re driving a Toyota Yaris, which is worth mentioning because the Ring is a narrow road which fits around one and a half cars - you frequently have to pull up on the side or reverse around a corner. A larger SUV would definitely struggle, so go for the smaller cars.</p>

<p>We headed south from Zabljak, and entered the ring <strong>counter-clockwise</strong>, heading to <em>Sedlo Pass</em> on our maps. We drove through this pass, where there is another parking lot, about 10 more minutes down the road to the Dobri Do Valley (Kamp Sarban) parking lot, where you’ll see several campervans parked in the valley below. We parked here, for free, to start our hike, which starts just off the right of the road, in a little rocky scramble up to the ridge.</p>

<blockquote>
  <p>While it doesn’t feel it in the valleys, the peak <em>will</em> be windy and chilly - be sure to bring a raincoat and hiking trousers!</p>
</blockquote>

<p>The top is absolutely worth the climb - from all sides, you overlook massive drops - the deep, glacial valley containing the Skrcko Lakes, the jutting limestone daggers which are famous for their unusual formation, and most importantly, the grassy hills below where you just spent two hours hiking up.</p>

<p><img src="/assets/imgs/montenegro/peakofmountprutas.webp" alt="The summit of Mount Prutas, Durmitor National Park, Montenegro" /></p>

<p class="caption">The summit!</p>

<p>The wildlife is sparse, but watch out for mountain goats (<em>Chamois</em>, a goat-antelope) on the trail - they live there, you’re just passing through! Walk around the trail if possible and keep your distance.</p>

<p><img src="/assets/imgs/montenegro/goatrude.webp" alt="Mountain Goat Chamois Montenegro" /></p>

<p class="caption">Goat-antelope we saw on our way down</p>

<p>We got back in the car at around 4pm - with still around four hours until sunset, we decided to continue driving the Durmitor Ring. 
The drive is well worth it if you are a sucker for views - we drove through plateaus, ridges, over the hills as the sun set across fields of buttercups, through dense forests, and down the dramatic Susica Canyon. It took us through several small villages, including the scattered <em>Lice</em> and the farming village of <em>Trsa</em>. After around two and a half hours of driving, we ended up back in Zabljak for dinner.</p>

<p><img src="/assets/imgs/montenegro/ring.webp" alt="Durmitor Ring, National Park, Drive" /></p>

<p class="caption">The Durmitor Ring</p>

<p>We spent our last day in Durmitor visiting the <a href="https://roadtripster.net/montenegro/curevac-tara-river-canyon">Curevac Viewpoint</a>, which was a twenty-minute drive from Zabljak and offered stunning views of the canyon below. We avoided visiting Tara Bridge, as it was under construction at the time and not accessible via car. If the construction has finished when you’re reading (around 2027) then I would recommend a visit, as it’s only another 30 minutes and is an incredible feat of architecture.</p>

<h2 id="prokletije-national-park">Prokletije National Park</h2>

<p>We packed up our things from the AirBnB and got back on the road, with another stop at a VOLI (in Bijelo Polje) to get food, snacks, pastries, and importantly, withdraw Euros from the ATM. The Prokletije National Park is very rural, and most places (Booking.com, restaurants, and most importantly any guides you book) will only take cash. We withdrew €400, €130 for Ethno Katun Rosi (the camp where we stayed), €80 for meals and snacks, €120 for guides, and €70 for backup cash in case anything went wrong.</p>

<h3 id="volušnica-loop">Volušnica loop</h3>

<p>We opted out of the full three-peak circuit to save our legs, and instead did a modified, 4-hour clockwise out-and-back hike up to the first peak, Volušnica. The trail starts in the Grebaje Valley, just past <strong>Katun Maja Karanfil</strong>. If you’re driving from the north, you can drive past the restaurant and park in a free parking lot just south of the guestrooms, on the grass. You’ll also pass a checkpoint on the way where a guard will check your national park passes.</p>

<p>The trail starts in a dense, shady forest, where you’ll stay for the first forty-five minutes. It’s a steep and grueling start but the shade is forgiving. Breaking out of the tree-line gives you sun and a breeze, leading you into an open meadow with a huge variety of beautiful flowers. We kept left at every turn, to make sure we went clockwise to the first Volušnica peak.</p>

<p>Standing on the edge of the Volušnica peak was a very rewarding view. The sheer jagged limestone ridges of the Karanfili peaks go straight up, looking wild and dramatic. After we stopped to eat our packed snacks and take lots of photos, we turned around and retraced our steps into the forest and back to our car, satisfied with our hike.</p>

<p><img src="/assets/imgs/montenegro/indyprokletije.webp" alt="Me in front of the Volušnica Peak, Prokletije National Park" /></p>

<p class="caption">The Karanfili peaks behind me</p>

<p>We drove through Gusinje on the way back, stopping in the local IDEA to stock up on snacks and drinks. It is a remarkably lovely little town, with a local mosque and a pedestrianised town square.</p>

<h3 id="ethno-katun-rosi">Ethno Katun ROSI</h3>

<p>We cannot recommend <a href="https://www.booking.com/hotel/me/ethno-katun-rosi-agrotourism.html">Ethno Katun Rosi</a> enough. It is run by a big family, and they are incredibly kind and accommodating. The food is cheap and filling, and the breakfast is included and comes with eggs and pancakes! There are also quite a few stray cats and dogs hanging around the camp. While it is important to be careful (especially of rabies!) some are very friendly, including this puppy we called Mak (after the poppy seed bread, <em>Mak pirog</em>) for whom we bought some dog food for the duration of our stay.</p>

<p><img src="/assets/imgs/montenegro/mak.webp" alt="Mak the puppy" /></p>

<p class="caption">Mak the puppy!</p>

<h3 id="horse-riding">Horse Riding</h3>

<p>On a whim after seeing a video online, we decided to see if we could do some horse-riding in the Grebaje valley while we were here! Because we gave less than two days notice, I put out a post on the local Facebook and messaged a bunch of local companies to see who had space. I ended up in contact with <a href="https://ergelaprokletije.com/">Ergela Prokletije</a>, a family-run camp (Eco Jasavic on Google Maps) who offered a 2-hour ride for €60 each. While maybe a bit on the pricey side, we thought it was worth it and agreed to a 5pm sunset ride.</p>

<p>We were complete beginners, but the horses were well-trained and confident. Riding through the alpine meadows on horseback was an unforgettable experience and absolutely worth it in our opinion!</p>

<p><img src="/assets/imgs/montenegro/horseback.webp" alt="Horse ride in Prokletije National Park, Grebaje Valley" /></p>

<p class="caption">The view from horseback!</p>

<p>We were very sad to leave Mak the dog, but we had to hit the road on Saturday morning after a lovely breakfast and coffee.</p>

<h2 id="skadar-lake">Skadar Lake</h2>

<p>A three-hour drive south through Podgorica and another stop for fuel, we arrived by the side of Lake Skadar! We were greeted at our apartment, <a href="https://www.booking.com/hotel/me/plamenatz-studio-apartments.sr.html">Plamenatz Studio Apartments</a>, by a very friendly family, two shots of alcohol and a lovely air-conditioned room.</p>

<p>The apartment offers a dinner menu cooked by the mother, and we had several courses, including trout and chicken, soups, bread, and even <em>homemade wine</em> brewed by the son.</p>

<p class="caption"><img src="/assets/imgs/montenegro/wine.webp" alt="Traditional trout dinner and homemade wine at sunset over Lake Skadar" />
The main course in the sunset</p>

<p>Our schedule for Skadar Lake was relax, relax, and relax. We’d spent two weeks hiking and some resting was on the agenda.</p>

<h3 id="boat-tour">Boat tour</h3>

<p>The only thing we had planned for Skadar was a boat tour on the lake, which we organised through our host family.</p>

<blockquote>
  <p><strong>Ignore</strong> the people coming up to you as you drive into Virpazar. They are <strong>con artists</strong>, they will rinse you for an overpriced boat tour. They force you into the car park, claim it is ‘private property’ (it is <strong>not</strong>, it is a <strong>free car park</strong>) and tell you that if you don’t buy their boat tour, they will damage your car. Instead, book through the many other, cheaper boat operators by the water, or online.</p>
</blockquote>

<p>We went on the lake in a private wooden boat, captained by a very competent English-speaking Montenegrin 18-year old. The tour was a romantic and relaxing 2-hour exploration of the lily-pad covered lake, interspersed with historic facts about the 13th-century monastery, or the abandoned ruin of <em>Grmozur Fortress</em> on the lake. Nicknamed <em>the Montenegrin Alcatraz</em>, it was an island prison built by the Ottomans, and to prevent escape it was populated only by inmates who did not know how to swim.</p>

<p class="caption"><img src="/assets/imgs/montenegro/lake2.webp" alt="Skadar Lake" />
Lily pads on Lake Skadar</p>

<p>We woke up on the last day, reluctantly stuffed our belongings into our check-in bag, packed it into the hire car, and left our last Montenegrin beds! We made a last pit stop at VOLI for a hot lunch, and then drove to Podgorica Airport to return our Toyota to Marko of Terrae-Car scratch-free. We made a dash across the hot car park to the air conditioned terminal, and checked our bag into the Ryanair return flight home!</p>

<p>Landing in London was a deserved ending to this incredible holiday. Note that unlike usual, we landed in 35-degree heat, as London is suffering from a heatwave. Bay of Kotor here we come again!</p>

<h2 id="overall">Overall</h2>

<p>We were very sad to leave Montenegro, as we had become quite attached to VOLIs and the local customs. It is rare to find a destination where you can transition from a coastal Mediterranean paradise to alpine wilderness in a single afternoon. What makes Montenegro so special is its rawness - exploring abandoned fortresses, finding stray puppies, and booking boat tours through local families. It requires adaptability and a wallet full of cash, but for a budget-friendly destination, it delivers a world-class adventure. It was a bittersweet goodbye to a country that had given us the best trip of our lives.</p>

<h2 id="know-before-you-go">Know Before You Go</h2>

<ul>
  <li>
    <p><strong>Rent a small car</strong>: The mountain roads (especially the Durmitor Ring) are incredibly narrow. Also, prepare for full-service petrol stations - just say <em>Puno, molim</em> for a full tank.</p>
  </li>
  <li>
    <p><strong>Cash is king</strong>: While touristy spots and major supermarkets take card, the mountains and anything more local runs entirely on cash. Keep a healthy stack of Euros and visit ATMs whenever you’re in a city.</p>
  </li>
  <li>
    <p><strong>Pack for two climates</strong>: The sweltering heat of the coast and the wind chill of the mountains call for two entirely different wardrobes - pack smartly to combine these outfits. We visited in June, so the temperature by the sea was in the warm high-twenties, which dropped to the mid-teens up in the highest peaks. I’d recommend bringing shorts and a t-shirt for the coastal regions, and hiking trousers and a thin raincoat for the freezing wind-chill higher up!</p>
  </li>
  <li>
    <p><strong><a href="https://nparkovi.me/sections/1/online-tickets">Buy the Annual Parks Pass</a></strong>: At €13.50 (at the time of writing) this pass is well worth it.</p>
  </li>
  <li>
    <p><strong>Suncream and bug spray</strong>: The alpine valleys are stunning, but the UV level is high and the mosquitos are out.</p>
  </li>
</ul>]]></content><author><name>Indigo Nolan</name></author><category term="travel" /><summary type="html"><![CDATA[A complete 3-week Montenegro road trip itinerary. Discover how to explore the Bay of Kotor, hike Durmitor, and relax on Lake Skadar on a budget.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://indigo.spot/assets/imgs/montenegro/broadview.webp" /><media:content medium="image" url="https://indigo.spot/assets/imgs/montenegro/broadview.webp" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">A child-free internet, at what cost?</title><link href="https://indigo.spot/blog/child-free-internet" rel="alternate" type="text/html" title="A child-free internet, at what cost?" /><published>2026-06-15T08:30:00+00:00</published><updated>2026-06-15T08:30:00+00:00</updated><id>https://indigo.spot/blog/social-media-ban</id><content type="html" xml:base="https://indigo.spot/blog/child-free-internet"><![CDATA[<p>When I wrote about the <a href="/blog/online-safety-act">Online Safety Act</a> last summer, I warned that forcing people to hand over their passports to access online content would lead to worse things sooner than later. I argued that the vague definitions of ‘harmful’ content and the reliance on third-party age verification companies would fundamentally break the internet as we know it in the UK.</p>

<p>I’d love to say the situation was different, but the slippery slope was a cliff.</p>

<p>Today Keir Starmer announced at a Downing Street press conference what the government is calling an “Australia Plus” ban on social media for under-16s <sup id="fnref:1" role="doc-noteref"><a href="#fn:1" class="footnote" rel="footnote">1</a></sup>. Becoming law by early next year, this legislation is sweeping, authoritarian, and flawed. It will block anyone under 16 from accessing platforms like YouTube, Instagram, X, TikTok, and Reddit <sup id="fnref:2" role="doc-noteref"><a href="#fn:2" class="footnote" rel="footnote">2</a></sup>.</p>

<h2 id="what-is-australia-plus">What is Australia Plus?</h2>

<p>Australia’s badly thought-out social media ban wasn’t bad enough, Starmer thought, and decided to add in dozens of equally unenforcable provisions. These ‘world-leading’ restrictions are going to weaken internet freedom in the UK and undermine our already-feeble standing in the world’s freedom index. Name a tech company willing to launch or operate in such a hostile environment as the one being created now?</p>

<p>Under the new proposals, the government wants to impose restrictions on gaming apps and messaging platforms, completely removing the ability to chat with strangers. They are also looking into enforcing overnight digital curfews and banning infinite scrolling for anyone under 18.</p>

<p>In Australia, 61-67% of children report still using social media despite the ban <sup id="fnref:3" role="doc-noteref"><a href="#fn:3" class="footnote" rel="footnote">3</a></sup>.</p>

<h2 id="a-life-without-social-media">A Life Without Social Media</h2>

<p>Here is where the nuance enters - I actually believe we <em>would</em> be better off without social media. I think relationships, friendships, and social dynamics would be improved in a world where we aren’t expected to always be online and reachable. I prefer the concept of the Internet being a physical space, like a computer nook or a library, where you have to visit to get online - or a landline, meaning you can only be reached when you’re at home, and going out means you get to have some time away from it all.</p>

<p>Of course, the internet has massively improved life for a vast majority of things - but social media is undeniably toxic and poisonous to teenage brains.</p>

<p><em>Despite this</em>, I do not believe that banning under-16s is the right move. Recognising the problem does not mean we should outsource parenting to Keir Starmer.</p>

<h3 id="parents-vs-the-government">Parents vs the Government</h3>

<p>The epidemic of ‘iPad kids’ is evident everywhere in society. Try going to an airport or a train station, the chances are most children in buggies that you see will be grasping iPads wrapped in clunky Bob the Builder cases, watching Cocomelon or some equivalent brainrot.</p>

<p>Clearly, these devices are detrimental to the cognitive growth of the children <sup id="fnref:4" role="doc-noteref"><a href="#fn:4" class="footnote" rel="footnote">4</a></sup>, and a much healthier balance of boredom and screen-free entertainment would be hugely beneficial to the children of the world. So should the government ban giving devices to children? I don’t think so.</p>

<p>Parents should be allowed to parent their children to an extent. I don’t believe in homeschooling, I’m not an avid libertarian - but I do believe that all parents and all children should not be punished for the irresponsibility of a smaller subset.</p>

<p>Social media for children can be sometimes beneficial - and not only that, ‘social media’ is, once again, a broad and vague definition. This includes YouTube - which means that children taking their GCSEs won’t be able to access educational content, children who are interested in history can’t expand their knowledge on Reddit or TikTok, where despite the stereotypes, there are actually large amounts of educational content <sup id="fnref:5" role="doc-noteref"><a href="#fn:5" class="footnote" rel="footnote">5</a></sup>.</p>

<p>However, I don’t want children raised by algorithms designed to strip them of their attention spans and individuality. I want them offline, playing in the parks, and going to the library after school. But passing a law that outlaws it entirely doesn’t magically rebuild the physical world - it takes away both their worlds, encourages anti-social behaviour, and creates an environment of lawlessness amongst those who attempt to circumvent the ban.</p>

<h3 id="how-do-we-know-theyre-children">How do we know they’re children?</h3>

<p>There is also a fundamental and unavoidable reality that the government and campaigners continue to conveniently avoid:</p>

<blockquote>
  <p>You cannot ban 15-year olds from the internet without forcing every 16, 20, and 60 year old to prove they aren’t 15.</p>
</blockquote>

<p>To enforce the social media ban, tech companies who wish to continue operating will have zero choice but to implement total mandatory age verification for every single British internet user.</p>

<p>Remember Persona, Yoti, and Onfido, the age verification companies who were contracted to implement the Online Safety Act? The companies with the sparse privacy policies who reserve the right to aggregate all your data? They are about to get the biggest payday of their lives.</p>

<p>Every adult in the UK will now be required to upload a government-issued ID, or submit to biometric facial scanning, just to watch a YouTube tutorial or to read a thread on Reddit.</p>

<p>This lame-duck government is forcing a nationwide digital ID system - but one outsourced to private companies. It’s Tony Blair in the pocket of Palantir. Anonymity on the internet is coming to an end.</p>

<h3 id="evading-the-ban">Evading the ban</h3>

<p>The bitter irony of this all is that it won’t even work. I still frequently use VPNs to avoid age verification on websites - I’m not underage, but I am not happy giving up my identity to every site who asks for it!</p>

<p>And, what happens when the mainstream moderated platforms become too much of a hassle to access? As cybersecurity experts and children’s advocates have repeatedly warned, frustrated kids often won’t give up - they will keep looking for the sites which let them in, sites where moderation is non-existent and the content is dangerous.</p>

<h2 id="device-level-photo-scanning">Device-level Photo Scanning</h2>

<p>The social media ban is not the only ban going into effect soon - there is a second ban, hidden in the shadow of the first one, which is coming into place much sooner. It is a ‘nudity-detection’ feature that the government want phone companies to implement at device-level <sup id="fnref:6" role="doc-noteref"><a href="#fn:6" class="footnote" rel="footnote">6</a></sup>. Yes, so VPNs won’t get around this one! It will essentially require every adult to prove they’re an adult in order to use their devices without photo restrictions. Their ultimatum for tech companies to implement this voluntarily is <em>September</em>. In fact, Safeguarding Minister Jess Phillips resigned in May because Starmer wasn’t pushing this hard enough - so perhaps her resignation forced his hand into rushing the legislation through <sup id="fnref:7" role="doc-noteref"><a href="#fn:7" class="footnote" rel="footnote">7</a></sup>.</p>

<p>Are they <em>trying</em> to get Apple and Google to lobby for Reform UK? Any sense of the UK being tech-friendly is evaporating by the second.</p>

<p>The only way it seems to evade this for now is to re-flash your phone and choose a different country at the beginning, and then change all your settings to live in the UK, and that might not even work soon.</p>

<h2 id="can-we-stop-this">Can we stop this?</h2>

<p>I’m still holding out hope that either one of these scenarios will happen:</p>

<ul>
  <li>
    <p>Tech companies like Apple and Google will threaten to pull out of the UK entirely, and the government will quietly shelve these plans.</p>
  </li>
  <li>
    <p>Starmer goes before September, and the new leader quietly shelves the plans.</p>
  </li>
</ul>

<p>Otherwise, I fear the state of the internet and the British tech industry is slowly whirling down a sinkhole into oblivion.</p>

<p>Write to your MP. Support digital rights organizations like the Open Rights Group. I think we should absolutely fight to get kids off social media - through regulation, better public spaces, and cultural shifts. But do not let them pass this ban off as a ‘child safety’ measure when it is, in reality, the largest expansion of mass surveillance this country has ever seen <sup id="fnref:10" role="doc-noteref"><a href="#fn:10" class="footnote" rel="footnote">8</a></sup>.</p>

<div class="footnotes" role="doc-endnotes">
  <ol>
    <li id="fn:1" role="doc-endnote">
      <p><a href="https://www.theguardian.com/media/2026/jun/15/social-media-ban-uk-under-16-starmer">https://www.theguardian.com/media/2026/jun/15/social-media-ban-uk-under-16-starmer</a> <a href="#fnref:1" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:2" role="doc-endnote">
      <p><a href="https://www.bbc.com/news/articles/cr472xry99lo">https://www.bbc.com/news/articles/cr472xry99lo</a> <a href="#fnref:2" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:3" role="doc-endnote">
      <p><a href="https://news.sky.com/story/two-thirds-of-underage-australians-still-have-access-to-social-media-despite-ban-new-research-suggests-13531097">https://news.sky.com/story/two-thirds-of-underage-australians-still-have-access-to-social-media-despite-ban-new-research-suggests-13531097</a> <a href="#fnref:3" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:4" role="doc-endnote">
      <p><a href="https://pmc.ncbi.nlm.nih.gov/articles/PMC10353947/">https://pmc.ncbi.nlm.nih.gov/articles/PMC10353947/</a> <a href="#fnref:4" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:5" role="doc-endnote">
      <p><a href="https://www.engadget.com/social-media/majority-of-australian-kids-are-still-on-banned-social-media-platforms-study-finds-162922768.html">https://www.engadget.com/social-media/majority-of-australian-kids-are-still-on-banned-social-media-platforms-study-finds-162922768.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://www.gov.uk/government/news/new-plans-to-stop-children-taking-sharing-or-viewing-nude-images">https://www.gov.uk/government/news/new-plans-to-stop-children-taking-sharing-or-viewing-nude-images</a> <a href="#fnref:6" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:7" role="doc-endnote">
      <p><a href="https://www.reuters.com/world/uk/uk-safeguarding-minister-resigns-protest-pm-starmers-leadership-2026-05-12/">https://www.reuters.com/world/uk/uk-safeguarding-minister-resigns-protest-pm-starmers-leadership-2026-05-12/</a> <a href="#fnref:7" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:10" role="doc-endnote">
      <p><a href="https://www.openrightsgroup.org">https://www.openrightsgroup.org</a> <a href="#fnref:10" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
  </ol>
</div>]]></content><author><name>Indigo Nolan</name></author><category term="essay" /><summary type="html"><![CDATA[When I wrote about the Online Safety Act last summer, I warned that forcing people to hand over their passports to access online content would lead to worse things sooner than later. I argued that the vague definitions of ‘harmful’ content and the reliance on third-party age verification companies would fundamentally break the internet as we know it in the UK.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://feweek.co.uk/wp-content/uploads/2024/12/Pete-Middleton-social-media-ban.jpg" /><media:content medium="image" url="https://feweek.co.uk/wp-content/uploads/2024/12/Pete-Middleton-social-media-ban.jpg" xmlns:media="http://search.yahoo.com/mrss/" /></entry><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 class="caption">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></feed>