Brickset LEGO Gift Guide

LEGO

Brickset have started publishing their annual Gift Guide, and this year Huw asked for my thoughts to be included. So far, guides for the first two price ranges have been released, for sets under $25 and sets priced between $25 and $50. Click through to see my recommendations!

Review: 71409 Big Spike's Cloudtop Challenge

LEGO Reviews

The last of this round of Super Mario LEGO sets to review, 71409 Big Spike’s Cloudtop Challenge:

LEGO keep on producing Super Mario expansion sets to add to what is clearly a popular theme for them—this year they have released 14 distinct sets in the range so far. 71409 Big Spike’s Cloudtop Challenge is one of the largest, with 540 parts, and packs a decent punch: three opponents, two of which are new, and some fun takes on the interactivity and game play we’ve come to expect with these sets.

Big Spike's Cloudtop Challenge LEGO set

LEGO IDEAS Review Results

LEGO

LEGO have announced the results of the latest IDEAS review (the mechanism by which fan-designed models can get made into real LEGO sets, should they reach 10,000 votes and pass the review). Four projects were accepted this time, and I particularly like the look of the Space Age designs, which will hopefully be an instant buy when it eventually comes out some time in 2023/2024. The other three ideas also look fantastic, and it will be interesting to see how LEGO adapts them to become production-ready sets. I hope they include Chris McVeigh on the team designing the Polaroid, as that is right up his alley!

Tales of the Space Age by John Carter

Tales of the Space Age LEGO IDEAS by John Carter

LEGO Insects by hachiroku24

LEGO Insects Ideas by hachiroku24

Polaroid OneStep SX-70 by Minibrick Productions

Polaroid OneStep SX-70 LEGO IDEAS by Minibrick Productions

The Orient Express, a Legendary Train by LEt.sGO

The Orient Express LEGO IDEAS by LEt.sGO

Darker Sublime Text Plugin

DevelopmentPython

Black is a popular code formatter for Python code, known for its opinionated uncompromising stance. It’s incredibly helpful for teams working on common Python code to write in the same style, and Black makes that easy, without having to maintain a common set of configuration options between the team members. After all, there aren’t any.

However, adding it to an existing codebase is difficult. It wants to reformat every source file, which can be a pain with version history by creating commits that are purely formatting changes, or adding misleading diffs to commits that are intended for something else. To help overcome this, Darker was created, which runs Black but only on the parts of code that have changed since the last commit. This is perfect for running as a post-save hook in your IDE, to consistently keep your code up to style without altering the parts of the source you haven’t changed.

The Darker documentation includes instructions on how to integrate the formatter with PyCharm, IntelliJ IDEA, Visual Studio Code, Vim, and Emacs—but I use Sublime Text. All it took was to write a simple Sublime Plugin, however, and we’re off to the races:

import sublime_plugin
import os
import subprocess


class DarkerOnSave(sublime_plugin.EventListener):
    def on_post_save_async(self, view):
        filename = view.file_name()
        if view.match_selector(0, "source.python"):
            subprocess.call(["darker", filename], cwd=os.path.dirname(filename))

To add this yourself, go to Tools > Developer > New Plugin… from the menubar, and replace the contents of the file with the above. Save the file as something like darker-on-save.py in the same location it was created in (the default in the save dialog box), and now every time you hit Save on a Python file, it’ll ensure that the code you added or altered is up to scratch with your style guide. Simple!

Useful Avrae Aliases

D&DAvrae

Avrae is a Discord bot that makes running and playing D&D play-by-post games much easier—it provides useful tools such as integration with D&D Beyond, powerful dice-rolling options, and combat initiative tracking.

However, there are some things it doesn’t do well natively. For this, it provides a powerful scripting API via aliases and snippets for you to extend its functionality with your own commands. Many people have written their own, and these are a few of the custom commands that we have set up in our games to make life just a little bit easier.

To use any of these aliases, paste the text in the code blocks below into a DM with Avrae (or any channel they are a member of).

Quick Character Switcher

When you link a character from D&D Beyond, Avrae will remember it and allow you to use the character in combat and for ability checks, etc. However, if you’re playing in multiple games, you have to constantly remember to change the active character to the correct one before continuing. We found a handy alias on the Avrae Developer’s Discord for assigning characters to channels, so instead of remembering that “in this channel, I’m Kaith, but in this channel, I’m Elmer”, you can just run !ch to switch to the right character for that game. I’m reproducing that here (tweaked for a recent Avrae update that deprecated a couple of the commands):

!alias ch {{c,id,a,m,n,h,H,w,R=load_json(chars) if exists('chars') else {}, {'c':str(ctx.channel.id),'s':str(ctx.guild.id if ctx.guild else "")} ,&ARGS&[1:],&ARGS&[:1] or 0,'\n','- This Channel','- This Server' ,'',"%x"%roll('1d16777216')}}{{m=(1 if m[0] in 'add'  else 2 if m[0] in 'delete'  else 3 if m[0] in 'roster' else 4 if m[0] in 'help?' else 0) if m else 0}}{{x=not m or ((a[1] if a[1].isdigit() else id.c if a[1] in 'chan' else id.s) if a and len(a)>1 else id.s)}}{{X=not m or ((a[0] if a[0].isdigit() else id.c if a[0] in 'chan' else id.s) if a else id.s)}}{{name=c.get(X)}}{{emb=f' -title "Quick Character Changer" -footer "!ch [help|?] - Bring up the help window" -color {R} '}}{{A=''.join([c[i] for i in c if i==id.c][:1]) or ''.join([c[i] for i in c if i==id.s][:1])}}{{add=not m==1 or not  a or c.update({x:a[0]}) or set_uvar('chars',dump_json(c)) or a[0]}}{{delete=not m==2 or not c.pop(X) or not set_uvar('chars', dump_json(c)) or X}}{{"embed "+emb+(f'-t 10 -desc "Added `{add}` to ID `{x}`."' if (m==1 and a) else f'-t 10 -desc "{"No char found for ID" if not name else f"Removed `{name}` with ID"} `{X}`."' if m==2 else f'-t 20 -f "Roster| {n+(n.join([f"`{i}` - `{c[i]} ` {h if i in id.c else H if id.s and i in id.s else w} " for i in c]) or "*None*")}"' if m==3 else f'-f "!ch|Changes to the appropriate character for the channel/server." -f "!ch roster|View a list of all channel/server id\'s and the character they will load" -f "!ch add <name> [chan⏐id]|Adds `name` to the selected id. Default is server id, `chan` selects the channel id, or you can input the channel/server id manually" -f "!ch delete [chan⏐id]|Deletes the given id. Default is server id, `chan` selects the channel id, or you can input the channel/server id manually" -f "Current ID\'s|`Channel` - `{id.c}`{n}`Server` - `{id.s}`"') if m else (f"char {A}" if A else "embed -t 5 -desc 'Channel not found in list'"+emb)}}

With that alias set, you can run !ch add [character] chan to assign the provided character to the current channel. Now, when you switch channels, you just have to run !ch to activate the right one.

However, remembering to run !ch is easier said than done. How many times have I switched to a channel and run !g lr to give my character a long rest, before realising that I hadn’t switched and had just given one of my other characters a poorly-timed rest instead? To solve this, we put together an alias (worked out mostly by my friend Madi) designed to prefix any command you may want to run, that automatically switches before executing the actual command you asked for:

!alias x multiline
<drac2>
id={'c':str(ctx.channel.id),'s':str(ctx.guild.id if ctx.guild else "")}
c = load_json(chars) if exists('chars') else {}
n ='\n'
A= ''.join([c[i] for i in c if i==id.c][:1]) or ''.join([c[i] for i in c if i==id.s][:1])
return f'!char {A}'
</drac2>
!&*&

For me it’s aliased to x, just to make it quick and easy to type. !x check str will always do a strength check for the right character for the game in this channel. !x g lr will always give the correct character a long rest. In combination with the !ch alias, this is probably the most handy alias I have set up—I only have to remember to always use !x instead of !, and it always just does the right thing.

Feat Spells

The other problem we frequently run into is using spells provided by feats. There are many feats that provide the with character additional spells; one’s they’re able to cast a particular number of times between rest, without using up spell slots. A good example is the Magic Initiate feat:

Choose a class: bard, cleric, druid, sorcerer, warlock, or wizard. You learn two cantrips of your choice from that class’s spell list.

  • In addition, choose one 1st-level spell from that same list. You learn that spell and can cast it at its lowest level. Once you cast it, you must finish a long rest before you can cast it again using this feat.

Avrae only partially understands these. Pulling from D&D Beyond, it creates a custom counter for each spell you’ve learned, with the correct number of “bubbles” for the amount of times you can use it, such as the example in the screenshot below:

Avrae custom counter output showing Magic Initiate - Command counter

Here, Scrat (my Hadozee character) has the Magic Initiate (Cleric): Command counter, that corresponds to his background feat providing that 1-st level spell. Avrae’s also added the spell to Scrat’s spellbook (!sb), meaning Scrat is able to cast it. However, they’re not linked together in any way. Casting the spell uses a spell slot (it shouldn’t) and doesn’t update the counter (it should). This means that instead, there are a number of things I need to remember to do when casting the spell:

To solve this, I wrote a short alias I call !featcast, which can be used in place of !cast (or !i cast) to handle the above three things:

!alias featcast {{a=&ARGS&}}{{H=not a or "help" in a or "?" in a}}{{s,r=H or a[0],H or " ".join(a[1:])}}multiline
<drac2>
if not H:
    ch=character()
    s=s.lower()
    ccs=[x for x in ch.consumables if ":" in x.name and (x.name.split(":")[1].lower().endswith(s.lower()) or x.name.split(": ")[1].lower().startswith(s.lower()))]
    cc=ccs[0] if ccs else None
    if cc:
        t,n=[x.strip() for x in cc.name.split(":")]
        if cc.value > 0:
            i=""
            if combat():
                i="i "
            cc.set(cc.value-1)
            return f"!{i}cast '{n}' -i {r}"
        else:
            return f"!embed -title 'Cannot cast {n}!' -desc '{ch.name} does not have any uses of {n} from the {t} feat available.\n\n**{cc.name}**\n{cc}'"
    else:
        return f"!embed -title 'Could not find a \"{s}\" custom counter' -desc 'Check that your feats for **{ch.name}** are set up correctly.' -thumb <image>"
else:
    return f"!embed -title 'Help for feat-casting' -desc 'Use `!featcast <spell>` to cast a spell provided by a feat, instead of using your spell slots.'"
</drac2>

It relies on the fact that Avrae names the custom counters sensibly, in the format of “Feat Name: Spell Name”. When typing !featcast command -t Target1 -t Target2, it will look for a custom counter named in that way that could possibly match the provided spell name. When it finds one, it checks for a free slot, and casts the spell (with the provided arguments), decrementing the counter.

There are many feats and backgrounds that provide spells, and this alias has made handling them much easier. Hopefully it can be of some use in your games too!

Review: 71406 Yoshi's Gift House

LEGO Reviews

I was given a few more of the LEGO Super Mario sets to review for Brickset, including 71407 Yoshi’s Gift House:

Since I was a child, Yoshi has always been my favourite Super Mario character. I couldn’t put my finger on why, but there’s something very whimsical and fun about the dinosaur-like cartoon creature that’s willing to carry Mario around on his back and help out wherever he can.

He’s an iconic part of Mario lore, and has fittingly appeared in a number of sets so far in the Super Mario line. 71406 Yoshi’s Gift House is the latest to feature the character, and is the largest with him as the sole protagonist.

Yoshi's Gift House LEGO set

The Lost Jungles of Rabbad: Part 2

D&DThe Lost Jungles of Rabbad

This is part two in my ongoing series to summarise the Lost Jungles of Rabbad D&D adventure I am taking part in. I previously wrote up the first part, so I highly recommend reading that before continuing here, if you haven’t already!

overgrown jungle

When we last left our fearless heroes, they had just defeated Dawn Song; on some level, at least—she had turned into a necrodragon, and flown off. Well, Deimos and Qhell weren’t content to leave it at that, and chased after her but were unable to catch up. After licking their wounds, the group turned to examine the items she’d left behind. They found:

The Helm and Staff, together with Sumunar’s Hammer, seem to make up the three of the four items that bind Fulgrin in his tomb. Kosta takes the helm, Elmer takes the staff.

Kosta’s Absence Explained

Kosta disappeared down the mine, and was taken by Pelor to another plane. There, they meet with The Celestial, the patron with which Kosta did a deal previously. The gods appear to be fighting over him and his allegiance, with Pelor attempting to release Kosta from the binding deal he had unwittingly made with Mr C. They come to an arrangement: Mr C shall only demand four more tasks of Kosta, and Pelor will give him “the amulet”.

Kosta is taken to his old city of Fallen Oak, where he is watching the town under attack, two armies clashing. He watches Father Thason, his adopted father, struck down and killed. Kosta is told he has a choice to make: save Father Thason, or save the city from plunder, pillage, and murder. It is not clear whether the attack is happening in real time, the past, or the future, or even real. Kosta chooses the city, to the Celestial’s anger.

Back at the plane with Pelor, as one of his tasks Kosta is made to pick up a rapier by the Celestial that shocks him with a blast of energy and ages him a few decades. For the final task, the Celestial takes Kosta’s right eye, replacing it with a pulsing purple orb, and disappears.

Pelor then opens the heavens, looking down on the battle between the group and Dawn Song below. With a cry, Kosta jumps, and the portal closes behind him.

Qhell’s Backstory

Qhell explains that he has met Dawn Song before:

Several years ago I was taken as a slave, captured by some gang of outlaws. I was a curiosity, sold repeatedly, until I ended up on a ship. I didn’t know where I was or what they intended to do with me, except for that cat. She would come down to the hold every single day to leer at her cargo, torturing one lucky creature each day. She is evil, that’s all I know of her, and I vowed never to be in that position again.

Kosta’s Eye, Father Thason, and Contacting the Gods

Kosta asks Elmer to study the glowing purple orb that has replaced his eye. Elmer can tell that it’s deeply enchanted with powerful magic, beyond that of mortals.

Kosta then heads off to find the clerics, who help him with the scry spell to try and locate Father Thason, but they are unable to. Without a clear direction, he tries to contact Pelor, to no avail, as Deimos also tries in vain to get in touch with Gargauth.

Sankra’s Ice

At Elmer’s suggestion, Nib takes another look through the Celestial book given to them by the General. He finds a passage that mentions:

Moradin’s Might, The Wild Mother’s Clay, Savras’s Eye, at the heart, Sankra’s Ice

The names line up with the four items, leaving Sankra’s Ice to be the Crystal Blade. The group decide to head to Dawn Song’s camp the following day to see what she had left behind.

That night, Nib, Deimos, and Sumunar dream of the black plain again. Around the fire this time is Katrina, the devil, and a pale blue figure. Katrina tells them that the crystal blade is part of the lock, and the group should take the remaining three items back to the door to seal it again, at which point the three magical items they’ve found will be returned to their hiding places.

We Canoe!

Leaving Rafael at the fort, the party take a pair of canoes to cross the river’s mouth and avoid going back through the gulch. After the crocodile fiasco, Kosta is put safely inside a magical ball for the journey. They’re attacked on the way by giant sharks, and fight them off with difficulty, but eventually make it to the other shore. Due to the loss of a canoe, Elmer has to ride Qhell (as a Giant Sea Horse) for the rest of the journey.

Campfire Tales

They camp on the treeline of the beach. Nib tells stories of their excursions north:

Last time we camped by the sea, was much colder than this. Was up north, near Tressmouth. We’d been hired to go track down the rumours of a lost tribe of elves deep in the arctic tundra. We’d sailed on a old ship, The Orphans Rage, north out of Nook, but hit difficulties about two days out of Northwatch. Ship went down, half the crew lost to the dark waves. Those of us that made it, washed up on this thin spit of land. We burnt what wreckage we could, the flames green with the salt, just to stave off the cold.

As dawn broke, we had to head out. Only 12 of us left then. The cold claimed two more that first day, another the next.

We hit a valley, some shelter from the winds, but soon lost in their midst. We turned one bend and there stood our doom, a dark beast of the ice. It feel on us, blood-stained claws tearing into us. We ran but it was faster. The end was upon us as a thick black arrow sailed into the crevasse. Then another, and another, another. Under the hail, the beast fell, and looking up we saw them, The Lost Elves, bows drawn. Their leader nodded at me, and like that, were gone. Just saved our lives and disappeared into the snow.

Sumunar’s Story

“You all know that Jadel and I knew each other,” Sumunar takes up. “We were in nearby villages, and played together sometimes as young orcs. Well, she had a sweet tusk like you would not believe. Always loved a bit of honey or sap-sugar. So one day we were out rambling, and came across a hive high up in a tree. She wanted to raid it, and I was not hard to convince. Well anyways, we got our little selves about halfway up, then realized we couldn’t reach any higher. We tried going back down, but it was at an awkward angle and we couldn’t manage. We must have sat there for three hours, arguing about whose fault it was and yelling for help occasionally. At last I grew so sick of waiting that I jumped down, and naturally broke my wrist. So then she was still stuck, and I was on the ground but holding my wrist and crying. Finally I got it together enough to run to the nearest village, and the grown-ups got Jadel down.” She pauses and laughs fondly. “I forgave her quickly enough, but her nickname was Honey-Tusk for years afterwards.”

A Visit From Elmer’s Father

As Elmer takes watch that night, the figure of his father walks out of the water and berates him for running away, tells him to come home. An argument ensues, and Elmer refuses to go anywhere with him.

Sa’khuna’s Statue

The next day, on their trek through the forest, the group come across a statue of a long-dead god of the hells, Sa’khuna. Coloured keys sit in locks, and as Kosta plays with them, a loud boom explodes from the statue. The roc approaches from the sky, and the party escape with the help of Jul’s Pass Without Trace spell.

Areka

On the outskirts of Dawn Song’s camp, the party rest for the night. Deimos is visited during his watch by a wooden automaton with a black spear. It talks with Deimos, though doesn’t appear threatening. Apparently the roc is called Barda, and it warns of Ghosts to the south, and to stay out of the tunnels.

The Return of Night Bane

During Elmer’s watch, Night Bane turns up, limping, draging a broken leg behind them. He has a conversation with Elmer, saying that Kosta killed him from behind, and that he’d come for him. Deimos and Kosta wake, and Night Bane challenges Kosta to a fight. Instead, Kosta talks him down, and then heals him slightly at his request. He leaves to find his partner, claiming that “Vecna is all, and will be all soon”.

Dawn Song’s Camp

In the morning, the group confront four figures still at the camp, “Brazen Guard of the Golden Stars, servants to the Undying Mistress”. Elmer fireballs them, which sets the camp alight and starts a fight. Defeating the guards and putting the fire out, they find the same books they have a copy of, as well as an updated map of the jungle with more locations marked. There’s also an annotated book, The Travels of Magil the Lost, Vol 5: Mauldold, Illwind, and the Jungle. Her notes also show that they had found the helm at a shrine at the bottom of the river.

Kosta attempts to talk to Pelor for direction, but is blocked by a voice saying “he can’t hear you, you’re on my land now. She knows where you are now. I’ve told her.” Kosta is overwhelmed by twisting energy pulsing from his eye.

Dawn Song the Necrodragon. Again.

In the far distance, Qhell sees Dawn Song lift off from the mountains headed towards the group. Deimos puts Kosta in the ball once more, and lifts off to distract her while the others run for the trees. A brief fight in the air comes to an end when Nib casts Dimension Door to pull Deimos out of the dragon’s way and disappear into the forest.

Safe in the trees, they release Kosta from the ball, returned to normal.

Don’t Smell the Flowers

Heading onward through the forest towards a place marked on Dawn Song’s map as Fulgrin’s Tomb, Kosta stops to sniff some flowers, which turn out to be nasty man-eating plants. Fighting them off, the party have to get creative with methods of removing the corrosive sap from their skin without any water to hand.

The Crashed Balloon

Further on, they come across a crashed hot air balloon with the skeletons of some elves. Their notes show them to be explorers who had set out long ago to find the lost city of Maudold, and crashed here. Elmer takes a ring, intending to return it to their family one day. There’s also a chest there, with an inscription that indicates it can only be opened at midnight. The party bury the elves, and then camp for the night.

During the night, Qhell is visited by another chwinga like the one that came to Sumunar, which has apparently placed leaves on the graves of the elves. He gives Qhell a ball of twine.

At midnight, they open the chest and find a load of gems, four spell scrolls, a wand, a red amulet, an old map, and a pair of glowing stones. The amulet is to stop somebody from being magically tracked, and the wand helps find hidden doors and compartments. The stones are teleportation anchors. Holding one stone lets you teleport to either of the other two (of which the group only found one).

The Endless

Deimos takes a stone, and teleports to the unknown one. He finds himself in a large dusty room, where he is promptly arrested and taken across a large city (Rabbad, later explained by Nib) to talk to The Endless, a tall thin figure with golden skin. He seems to know all about the group, what they have been trying to do, and even talks of the Tabaxi twins sibling, Dusk, who they presume lost. Deimos is given the third stone, and he returns to the others.

Furry Creature Fight

While Deimos is gone, the others move on through the jungle, where they’re set upon by small furry creatures. Just as they manage to fight them off, Qhell scaring a few away as a Black Bear, Deimos pops back into existence with them.

Fulgrin’s Tomb Entrance

More treking through the jungle, and the group eventual stumble on what looks to be the entrance to Fulgrin’s Tomb. A pile of stone juts up out of the ground, with a jet black door set in it. The clearing is patrolled by several figures in black armour. Talking with the figures, they are told to leave. They will not let anybody “disturb the imprisonment” and won’t say any more. Qhell discerns that they are engraved with binding sigils—something is bound magically within each suit of armour. The group retreat for the night.

Areka, Again

Before sleep, Elmer studies the books again and finds mention of “a cave of glass behind a door of nothing” in the Da Dark Under, as well as “the land of their sleep, a black river of death, a battle with The Night”.

That night, Deimos is once again visited by Areka, who tells him and Elmer that the third cat, Dusk, is beneath the jungle, behind that door, attempting to break open the lock without the rest of the key, which he refers to as the Ashes.


As night continues, so does our story. The plan for the morning? To get past the guards, through the black door, and down below to Fulgrin’s Tomb. Are the party nearing the conclusion of their adventure? Are they walking to certain death, and will they all return? Find out… when we do, and I do another write up!

Relay FM for St. Jude

DevelopmentSwiftApps

September is over, and that means the end of another Childhood Cancer Awareness Month. For the last four years, the Relay FM community has used the opportunity to raise money for St. Jude Children’s Research Hospital, an institution dedicated to understanding, treating, and discovering new ways to defeat childhood cancer. Last year, they raised over $700,000. This year, they’re set to match that amount again, taking the total raised in the last four years to over $2 million.

For Stephen, one of the founders of Relay FM, the cause is particularly important. You can read more about his story and why they fundraise over on 512pixels.net.

When the campaign started last year, I had been a part of the Relay FM members Discord for a few months. People were sharing the fundraising total in the Discord in various ways, and a few enterprising individuals came up with a variety of ways to get that total into a iOS Home Screen widget, such as this Scriptable solution by Zach Knox. At the time, I was just starting to play with iOS app development and I thought “we can do better than this. Let’s make a native app and widget!”

A group of us had already gotten together and built a Discord bot for the server, so I had the perfect group of developers to solicit for help. Together, we built a small app that pulled the campaign information from the Tiltify public API (reverse engineered from their website, rather than the official APIs), displayed it in the app, and provided a widget. It could also pull in the campaign’s milestones. We distributed it via TestFlight, so we didn’t have to deal with full-on App Review, and it was a great success (amongst Relay FM members, at least).

It was a brilliant learning experience for me, and greatly increased my knowledge of how a iOS app is built, albeit a very simple one. I am very grateful to all the people who took the time to explain things to me, and review and comment on my code to help me become a better Swift developer.

This year, we dusted off the app and plugged in the new campaign details a few days before September started, in order to get it up and running again. However, the campaign organisers threw us a last-minute curveball—there wasn’t going to be just one campaign this year, but many! Relay would still have their main one, but anybody could set up “subfundraisers”, all of which would add towards the overall total. So how was the app going to handle that?

We spent some time reverse engineering the Tiltify API for the new campaign format, and reworked the app from a simple “here’s a widget” to a more full-featured discovery app, showcasing not only the overall total but also each individual fundraiser and their own goals and rewards. We extended the widgets to allow you to choose which fundraiser it should show, provided a variety of appearance options, and added a share screen to let users post pictures of the fundraiser progress on social media.

Relay FM for St. Jude iOS App Screenshots

And of course, we threw in Lock Screen widgets and donation charts for those who’d braved the iOS 16 beta, or regular people upgrading when it was released publicly half way through the month.

Throughout the project I’ve learned more about Swift, SwiftUI, and iOS app development than I ever would have working on my own with no real goal in mind. I’ve learned how to handle the various iPhone and iPad screen sizes, store data locally with GRDB, and use custom intents to provide widgets of all sizes with dynamic options.

It’s been a lot of fun to have a project to work on that’s reached so many people (despite never leaving TestFlight!) and helped towards raising the absolutely phenomenal amount that the Relay FM community achieved for St. Jude this year. The app is open source, and we will hopefully be resurrecting it next September when the community comes together once more in the fight against childhood cancer.

UserDefaults, @AppStorage, and Data Types

DevelopmentSwiftApps

As I’m starting to play more seriously with iOS app development, Xcode, and Swift, I’m starting to come up with a variety of patterns I use in the various toy apps I mess around with that make working with certain APIs or frameworks easier. One of these is UserDefaults, which provides an easy way to store persistent data between app launches.

The basic way to interact with UserDefaults is to set values by assigning a data type to a particular key (which are strings), and reading that key later:

// Storing a boolean value
UserDefaults.standard.setValue(true, forKey: "hasPerformedInitialSync")

// Retrieving a boolean value
let hasPerformedInitialSync = UserDefaults.standard.bool(forKey: "hasPerformedInitialSync")

There are two problems with this, though:

  1. Using string-based keys is error-prone; it can’t be checked by the compiler, so an overlooked typo can lead to unexpected behaviour that’s difficult to debug.
  2. SwiftUI is based around watching state variables for changes, and redrawing views based upon this; how can we tie UserDefaults into that?

A UserDefaults extension

The first problem is one I’ve started solving by creating an extension to the UserDefaults class. I put this in a new Swift file (usually Extensions/UserDefaults.swift), with code such as the following:

extension UserDefaults {
    enum Key: String {
        case hasPerformedInitialSync
    }
    var hasPerformedInitialSync: Bool {
        get { bool(forKey: Key.hasPerformedInitialSync.rawValue) }
        set { setValue(newValue, forKey: Key.hasPerformedInitialSync.rawValue) }
    }
}

This has promoted the previous string-based keys we were using to an enum: the compiler is now able to check our keys for us and produce errors at compile time if we use one we haven’t defined. An additional computed property on the UserDefaults class hides the get and set logic from us, so in the rest of our code we need only do the following:

// Storing a boolean value
UserDefaults.standard.hasPerformedInitialSync = true

// Retrieving a boolean value
let hasPerformedInitialSync = UserDefaults.standard.hasPerformedInitialSync

Hurray! No more string-based keys to remember. But what about the second problem?

Up until recently, I was syncing UserDefaults changes with SwiftUI view state by storing a related @State variable, and tying it to the corresponding UserDefaults key using onAppear and onChange(of:):

struct ContentView: View {
  @State private var hasPerformedInitialSync: Bool = false
  var body: some View {
        NavigationView {
      // View code
      Button(action: {
        hasPerformedInitialSync = true
      }) {
        Text("Sync Done!")
      }
    }
    .onAppear {
      hasPerformedInitialSync = UserDefaults.standard.hasPerformedInitialSync
    }
    .onChange(of: $hasPerformedInitialSync) { newValue in
      UserDefaults.standard.hasPerformedInitialSync = newValue
    }
  }
}

This works by fetching the value stored in UserDefaults when the view first appears and assigning it to the state variable, and then watching the state variable for changes, and syncing those back to the UserDefaults key. But there are still problems with this:

  1. It’s easy to forget to add the required line to onAppear or the onChange handler for a new variable.
  2. The view won’t react to changes that happen to the UserDefaults key outside of its own interaction, such as if a presented sheet changes the value.

Fortunately, SwiftUI introduced a new property wrapper similar to @State to help us with this.

Introducing @AppStorage

Called @AppStorage, the new property wrapper lets you reference a UserDefaults key directly and bind it to a variable that will automatically sync changes between itself and UserDefaults. It can be used as such:

struct ContentView: View {
  @AppStorage(UserDefaults.Key.hasPerformedInitialSync) private var hasPerformedInitialSync: Bool = false
  var body: some View {
    // View code
  }
}

Note that there’s no longer any need for the value to be read onAppear, or the changes to be observed using onChange. SwiftUI will take care of that for us, automatically syncing data back when actions within the view change the state variable’s value. Very handy!

Supported Data Types

However, there are still limitations with this approach, one of which I ran into today and was banging my head against for quite some time until I realised the issue.

UserDefaults supports the storage of a variety of primitive data types:

It also supports storing arrays or dictionaries containing these primitive types, using the array(forKey:), stringArray(forKey:), and dictionary(forKey:) methods. AppStorage, however, does not support this. Apple’s documentation for AppStorage lists the init methods available, and they can return:

It’s missing Float—I didn’t care about that—and the Array and Dictionary methods—I did care about those. I had created my UserDefaults extension, as normal:

extension UserDefaults {
    enum Key: String {
        case favouriteColours
    }
    var favouriteColours: [String] {
        get { stringArray(forKey: Key.favouriteColours.rawValue) ?? [] }
        set { setValue(newValue, forKey: Key.favouriteColours.rawValue) }
    }
}

That worked fine, as expected. But when I tried to use it with @AppStorage:

@AppStorage(UserDefaults.Key.favouriteColours) private var favouriteColours: [String] = []

the compiler threw up the error No exact matches in call to initializer. Helpful. It turns out that’s because of the lack of support for the collection methods, and I had to resort to using my original onAppear/onChange workaround.

It’s not all bad, though!

Despite this frustrating limitation with AppStorage (which I hope is fixed in a future SwiftUI release), I really like the UserDefaults extension method for working with the API. There’s two more things I want to mention about it. One, it can be used to store or return any data type, as long as you can easily convert it to or from one of the supported types. For example, storing a custom enum is easy. Make the enum inherit from Int or String, and you’re off to the races:

enum Mode: Int {
  case light
  case dark
}

extension UserDefaults {
  enum Key: String {
    case preferredMode
  }
  var preferredMode: Mode {
    get { Mode(rawValue: integer(forKey: Key.preferredMode.,rawValue)) ?? .light }
    set { setValue(newValue.rawValue, forKey: Key.preferredMode.rawValue) }
  }
}

Secondly, if you want to be able to access the data you’re storing in other targets, such as a widget, the extension is a great place to create a static property that uses a group identifier:

extension UserDefaults {
  static let shared = UserDefaults(suiteName: "group.com.myapp.AppName")!
}

Accessing UserDefaults.shared instead of UserDefaults.standard will allow it to be read and written from any targets that have access to the specified group identifier. Additionally, SwiftUI provides two different ways of specifying that you want to use .shared instead of .standard. It can be done on a per-variable basis by passing the store: parameter to @AppStorage:

@AppStorage(UserDefaults.Key.preferredMode.rawValue, store: UserDefaults.shared) private var preferredMode: Mode = .light)

Or it can be made the default for all child views within a hierarchy:

struct MyApp: App {
  var body: some Scene {
        WindowGroup {
            ContentView()
        }
        .defaultAppStorage(UserDefaults.shared)
    }
}

Both of which make working with UserDefaults cross-application much easier.

Anyway, I’m mostly writing this down so that the next time I’m struggling with it I can prompt myself on how it works and why I’ve done things a certain way; but maybe it can help you, too!

Review: 71408 Princess Peach's Castle

LEGO Reviews

My final review of this year’s new Princess Peach-themed Super Mario LEGO sets, this time for Peach’s Castle:

71408 Princess Peach’s Castle is the largest of the Peach-themed additions to the Super Mario line this year, and is in fact the largest of all the game’s sets so far at 1,216 parts. It includes a variety of characters, both new to the range and returning, and commands some impressive space when laid out ready to play. Let’s take a look at what’s inside!