<?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-08-10T10:03:55+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">A reading list for 2026</title><link href="https://indigo.spot/blog/reading-list-2026" rel="alternate" type="text/html" title="A reading list for 2026" /><published>2026-07-29T21:00:00+00:00</published><updated>2026-07-29T21:00:00+00:00</updated><id>https://indigo.spot/blog/reading-list-for-2026</id><content type="html" xml:base="https://indigo.spot/blog/reading-list-2026"><![CDATA[<p>The last few years have been bad for my reading. I’ve read a couple of books, but it never became the habit I wanted it to be. Doomscrolling and work mostly took over my life, and I noticeably felt the damage that did to my mental health and attention span.</p>

<p>As part of my perpetually ill-fated resolutions this year, I have begun reading more consistently again! I bought a couple of books for cheap at charity shops, fell down a Julian Symons rabbit hole, and have been thoroughly enjoying it. Below is a tailored list of books I’ve read so far (subject to update) that I would recommend, along with a reason why, and a little plot summary.</p>

<p class="caption"><img src="/assets/imgs/kobo.webp" alt="A Kobo E-reader on a desk" />
My trusty Kobo e-reader, which I use to read most of the books on this list.</p>

<h3 id="the-man-who-killed-himself---julian-symons">The Man Who Killed Himself - Julian Symons</h3>

<p>I would credit this as the book that got me back into reading, after picking it up for £1 in a charity shop. Symons was a crime writer, and one of the things that impressed me most about this book is I genuinely did not know what was going to happen, all the way up until the end. To me that’s Agatha Christie levels of suspense, and Symons does that without making you want to put down the book out of frustration or boredom.</p>

<p>We follow Arthur Brownjohn, an aptly-named meek man who lives in fear of his wife. To cope, he builds a secret second life, under the alter ego ‘Major Easonby Mellon’. You find out early on that Mellon has a wife himself, an unsuspecting woman who believes her husband is secretly a spy. He can’t keep this up for long - and you are constantly left guessing how his life continues to unravel.</p>

<h3 id="the-hitchhikers-guide-to-the-galaxy---douglas-adams">The Hitchhiker’s Guide to the Galaxy - Douglas Adams</h3>

<p>I can’t believe I made it to the age of twenty without ever having read Douglas Adams. I can say that I would recommend this book to any Brit who enjoys dry humour and a light read. After Julian Symons it’s a refreshing pick-me-up and a book that is very easy to read.</p>

<p>Another Arthur leads, coincidentally, and Adams’ Arthur Dent is just as mild-mannered as Symons’ Brownjohn. His house is about to be demolished - and so, as it turns out, is Earth. He is whisked from planetary destruction by his friend Ford, who turns out to be an extra-terrestrial, and taken on a wild adventure across space and time.</p>

<p class="caption"><img src="/assets/imgs/hitch.webp" alt="The original Hitchhiker's TV Show" />
The original Hitchhiker’s TV Show</p>

<h3 id="the-dallergut-dream-department-store---miye-lee">The Dallergut Dream Department Store - Miye Lee</h3>

<p>This was a funky experiment in magic realism, but in a much more accessible way than Gabriel García Márquez’s <em>One Hundred Years of Solitude</em>. If you allow yourself to suspend disbelief for the duration of this book (and its sequel <em>Return to the Dallergut Dream Department Store</em>) I think you are in for a treat.</p>

<p>It takes place in a town set in the collective subconscious, and we follow Penny, a new employee at a department store which sells only dreams - nightmares, daydreams, visions, you name it. It’s light, fun, and gives you something to think about.</p>

<h3 id="life-the-universe-and-everything---douglas-adams">Life, The Universe and Everything - Douglas Adams</h3>

<p>Once you’ve read <em>Hitchhiker’s Guide</em> and enjoyed it, it’s worth getting through <em>The Restaurant at the End of the Universe</em> so you can get to this one.</p>

<p>Life, The Universe and Everything was an absolutely excellent third book in the series, the strongest so far in my view. It picks up the slack where the second book dropped it a little, and keeps you on your toes with a refreshing amount of plot and world-building.</p>

<p>By this point, Arthur has been stuck on prehistoric Earth for ages, until Ford appears, drags him through a rip in space-time, and lands in the middle of a cricket match at Lord’s, two days before Earth’s original destruction. This balloons into a history lesson of a foundational war of the universe, and some light stories about evolution.</p>

<blockquote>
  <p>You may wonder why I haven’t recommended <em>The Restaurant</em> - I thought it was okay, but weaker than its two siblings and a little filled with airless plot. It is nonetheless an enjoyable book, and worth reading to complete the series.</p>
</blockquote>

<h3 id="the-progress-of-a-crime---julian-symons">The Progress of a Crime - Julian Symons</h3>

<p>I read this right after <em>The Man Who Killed Himself</em>. It is a little drier and more biting, with a clearer edge of social commentary. Nevertheless, the writing is engaging.</p>

<p>It follows a local gang of youths who are caught up in a village murder on Guy Fawkes night. Did they kill the man? Or are they being scapegoated by the local police force? That’s for Symons to tell you…</p>

<p>If you’ve enjoyed the first two, and you want another Julian Symons book, I would recommend <em>The End of Solomon Grundy</em>. It’s not quite as good, which is why I haven’t given it a standalone recommendation, but it still lives up to Symons’ standards.</p>

<h3 id="the-invisible-man---hg-wells">The Invisible Man - HG Wells</h3>

<p>I’d heard this was a classic, and I did feel it lived up to its hype. It has the short length that was typical of books of its era, but manages to pack a cynical and atmospheric writing style into relatively concise chapters.</p>

<p>The story is by now well-known, but it follows a scientist who is experimenting on making human flesh invisible, tests and succeeds on himself, but cannot reverse it and is stuck terrorising local villages as an invisible monster.</p>

<h3 id="before-the-coffee-gets-cold---toshikazu-kawaguchi">Before the Coffee Gets Cold - Toshikazu Kawaguchi</h3>

<p>More a collection of short stories than a novel, this made me cry on a car journey. It makes you smile, cry, think about the past and the future, and fall into a nostalgic trap of wondering about life’s transience.</p>

<p>The premise alone should sell you - a Tokyo cafe where you can travel back in time to speak to someone one last time - but under a very strict set of rules. You can’t leave your seat, nothing you do will change the present, and you have to make it back <em>before your coffee gets cold</em>. Each of the four visitors has someone unique they never got to finish a conversation with. It’s a hard book to read if you have any relation to the four scenarios - some of them hit quite close to home.</p>

<h3 id="i-who-have-never-known-men---jacqueline-harpman">I Who Have Never Known Men - Jacqueline Harpman</h3>

<p>A book which has a lot going on thematically, touching on anti-patriarchal social structure, the concept of non-verbal communication, and evolutionary re-building. It was a marginally stressful read, in the sense I was constantly anticipating things to happen, but those things never quite came, and I was never quite put at ease. I would recommend it if you are interested in being challenged and forced to think.</p>

<p>In the book, forty women are being held underground by guards who never speak. One of them, the narrator, is the <em>only one</em> with no memory of the world outside the cage at all. She doesn’t know why she’s there, and from the looks of it, nobody wants to tell her.</p>

<h3 id="the-wonderful-story-of-henry-sugar---roald-dahl">The Wonderful Story of Henry Sugar - Roald Dahl</h3>

<p>While this is again a collection of short stories and not a novel, it deserves an honourable mention simply because of how whimsical it is. I read this first a long long time ago, and re-read it recently and was hit with nostalgia.</p>

<p>My favourite is <em>The Wonderful Story of Henry Sugar</em>, the titular tale and longest piece. Henry Sugar, a gambling addict, reads about a circus performer who is trained to see without his eyes. Sugar immediately thinks of cheating in casinos, and spends three years practising meditation until he can do it too. He promptly cleans up at every local casino, but soon finds it boring, even throwing cash off his balcony on a whim. He decides to use his powers to fund orphanages worldwide, travelling in disguise with an accountant and a makeup artist.</p>

<p><em>The Hitch-Hiker</em> is my second favourite. It tells the tale of a writer who picks up a hitchhiker with particularly long and slim fingers. They get stopped for speeding, and the hitchhiker turns out to be a pickpocket, has already swiped the narrator’s wallet and watch - but somehow manages to get the pair of them out of a speeding ticket.</p>

<p><em>The Mildenhall Treasure</em> is my third recommendation - it is a true story of the ploughman Gordon Butcher who unearths a hoard of Roman silver. His boss tricks him into thinking it’s worthless, and keeps it for himself. An archaeologist eventually recognises the silver for the treasure trove that it was!</p>

<blockquote>
  <p>I have <em>just</em> discovered while writing this that Wes Anderson has made adaptations of two of these short stories, <em>Henry Sugar</em> and <em>The Swan</em>. I am adding those to my watchlist immediately.</p>
</blockquote>

<p class="caption"><img src="/assets/imgs/wes.webp" alt="Benedict Cumberbatch as Henry Sugar" />
Benedict Cumberbatch as Henry Sugar</p>

<h2 id="thats-all-folks">That’s all, folks!</h2>

<p>My attention span has still not reverted, and I still find myself doomscrolling frequently. But I’m never going to be cured, it’s just nice to have a hobby which isn’t social media.</p>

<p>I hope this list is useful, and if it causes just one person out there to pick up a book even for a little while, it has served its purpose. Reading is a joy to share with others.</p>

<p>This list <em>may</em> update until December 2026, and if I enjoy writing it, may be followed by a sequel!</p>

<blockquote>
  <p>Small self-promotion - I use my website <a href="https://headandheart.app">headandheart.app</a> to track my books - see if you like it too!</p>
</blockquote>]]></content><author><name>Indigo Nolan</name></author><category term="essay" /><summary type="html"><![CDATA[The last few years have been bad for my reading. I’ve read a couple of books, but it never became the habit I wanted it to be. Doomscrolling and work mostly took over my life, and I noticeably felt the damage that did to my mental health and attention span.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://indigo.spot/assets/imgs/kobo.webp" /><media:content medium="image" url="https://indigo.spot/assets/imgs/kobo.webp" xmlns:media="http://search.yahoo.com/mrss/" /></entry><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 an 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-14T08:30:00+00:00</published><updated>2026-06-14T08: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 unenforceable 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 wants 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 organisations 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 ‘sandboxed’ 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">Escaping the Google Tax with Lemon Squeezy</title><link href="https://indigo.spot/blog/monetising-cashcat-lemonsqueezy-supabase" rel="alternate" type="text/html" title="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 in 2D?</title><link href="https://indigo.spot/headandheart" rel="alternate" type="text/html" title="What if we could rate movies in 2D?" /><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>I’ve written extensively about 5-star rating systems and their flaws, so where are my alternative solutions? Right here: a 2D grid, two axes, not just one. ‘Head’: the quality of the media, how much you respect it and recognise its craftsmanship; and ‘Heart’: how much you personally enjoyed it, regardless of society’s view on it.</p>

<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>Welcome to <a href="https://headandheart.app">Head and Heart</a>.</p>

<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 using it, 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 just 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 my original system on favourites.me.)</p>

<h3 id="quick-pivot-to-6x6">Quick pivot to 6x6</h3>

<p>After a couple of weeks using the site, I progressed from 5x5 to 6x6. There was a very clear reason for this - not having 0 as an option on a 5x5 scale means you create mathematical bias. The middle is suddenly <code class="language-plaintext highlighter-rouge">3</code>, not <code class="language-plaintext highlighter-rouge">2.5</code>. I fixed this quickly by adding a <code class="language-plaintext highlighter-rouge">0</code> on both scales.</p>

<p>A beneficial side-effect of this is it removes the ‘safe middle’ - you now have to make a slight conscious choice to put your rating on the side of ‘recommend’ or ‘not recommend’ - a good mental push in my view.</p>

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

<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>There’s a statistics page with rating histograms, a summary of your monthly activity, and a comparison of your average ratings compared to society’s (using TMDB and OpenLibrary APIs!)</p>

<p>There’s a scatter plot with a <em>line of agreement</em> at <code class="language-plaintext highlighter-rouge">head=heart</code>. <em>Above the line</em> lives media that I enjoyed more than I know I should have (guilty pleasures). <em>Below the line</em> lives media that I respect intellectually, but just couldn’t get behind (like Schindler’s List).</p>

<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 class="caption"><img src="blog/whyibuilt/imgs/headandheartheatmap.png" alt="Head and Heart Heatmap" />
The Head and Heart Heatmap</p>

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

<p>I promised I wouldn’t lock data away, and I stick to that. You own your data, and you can export, modify, and re-import freely, without any restrictions.</p>

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

<p>The transition from a spreadsheet to a full application has been exciting. 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, 36-point scale.</p>

<p>The two-axis 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 terrible movie (like <em>Click with Adam Sandler</em>), and that’s okay.</p>

<p>The new version is live now. Go track 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="design" /><summary type="html"><![CDATA[I’ve written extensively about 5-star rating systems and their flaws, so where are my alternative solutions? Right here: a 2D grid, two axes, not just one. ‘Head’: the quality of the media, how much you respect it and recognise its craftsmanship; and ‘Heart’: how much you personally enjoyed it, regardless of society’s view on it.]]></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 so 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 honoured 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 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 make voters stick with a party they’ve previously supported.</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 features - 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 built, 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></feed>