The latest and greatest in postcardware at Spatie
Earlier this year at Spatie, we spend a full day hacking away on fun sideprojects during our hackathon. The goal was to create something fun and functional by the end of the day.
We (Nick and Dries) decided to create a digital version of our postcard wall. You know, the one where we collect and display all of the postcards you sent us!
Our postcard page felt a bit dry and hadn't received a lot of love in the past few months, so we decided to create Kaartje (/ˈkaːr.tʲə/), a digital version of our wall. The concept is very simple. It's a spinning 3D globe with all the postcards placed at the senders' city (Don't worry, we won't expose where you guys live, it's just an approximation.)
While this was already a very cool concept by itself, we pushed it even further. What if we could digitalise the card sending process as well? So we went ahead and built an expo application for internal use that allows us to take a picture and send your postcards into our application. You can see the demo below.
Tech stack
As we are mainly frontend developers, we chose for familiar tools.
- The API is a Bun server, without any framework to keep it as simple and lightweight as possible
- SQLite for the database
- Astro with react for the frontend
- Expo for the native application
- And a shared package for the frontend and expo app with the shared logic and UI elements
We wrapped everything into a monorepo and used npm workspaces to manage everything. A very simple and straightforward setup.
Sharing code
Since we are using react and react-native, we immediately had the idea to create a shareable globe component. Using react-three-fiber made this very easy. We just had to place some guards here and there for web specific code, but once those were in, the globe "just worked" in the expo app.
Role of AI
Since this was a very ambitious project to finish in a singular day, we leveraged AI (Claude) to get us up to speed as soon as possible. Our approach was very methodical with lots of guard rails so Claude wouldn't start hallucinating halfway through a task. That way we kept full control of the process while still being able to get the most out of the day.
We started out by describing in detail what the app should do: requirements, acceptance criteria, dos and don'ts. Together with some handy skills like web-design-guidelines, vercel-react-native-skills and r3f-skills, we got going pretty quickly.
Every phase / milestone of the project was separated from each other so everything could be tested, checked and reviewed where necessary by us. We had the AI stop it's asssistance so we could validate the app and review the interaction s between the different parts.
As it was a hackathon and an internal project, we didn't validate everything in great detail. If it looked okay and worked correctly, we continued without thinking too much about the semantics of the codebase. The scope of the project is small enough to get away with this. After about 4 hours we had something that worked wthout breaking. From that point on we started manually polishing and improving performance, especially on the frontend, where Nick worked some magic.
3D Globe Render performance improvements
Rendering a spinning globe with hunderds of animated cards should be fairly easy for a browser running on a M4 macbook. But we did see some stutters here and there, so we put on our optimisation hat and started looking. Our aim was to get the globe runnign smoothly on most semi-modern devices.
The initial implementation for the postcards was pretty simple: one THREE.Mesh per postcard on the globe. Each postcard had its own geometry, material, texture and draw call. It worked fine with ten cards. At fifty it was starting to feel heavy and after about a hundred cards, it was dropping frames quickly.
The solution we came up with was GPU instancing. This is a technique where you tell the GPU (the graphical brain of your PC) "here is one shape, now draw it 512 times with these different positions, sizes, and textures." This makes it so the browser does not have to calculate what the shape is, but rather it keeps it in memory.
All stamps on the globe are now rendered as a singular THREE.InstancedMesh. One geometry, one material, one draw call. The per-instance data, being the location of each postcard on the globe, how big it is, which image to use as a texture, is passed along as attributes.
That solved geometry and materials, but left one problem: every postcard needs its own image. Sharing a single material usually means sharing a single texture, and that won't work when each card is different. Our solution is a DataArrayTexture, a texture made up of stacked layers, where each layer holds one postcard. Picture a deck of cards: same size, same shape, different faces. The shader reads a layer index per instance and samples the matching image.
Our StampTextureAtlas class manages the pool of 128x86 RGBA layers and hands out indices as cards are loaded:
// Each stamp gets a layer index; the shader samples the right one
const layerIndex = atlas.allocateLayer(card.frontImageUrl);
Smaller decisions that added up
A few other choices worth mentioning, not because they are individually groundbreaking, but because together they contribute to a more solid.
Staggered image loading. When the web page first loads, it fetches postcards in paginated batches of 10 with a 300ms pause between batches. Each batch's images are preloaded before the cards are added to the globe. On the atlas side, initial image loads are staggered with delay = Math.min(index * 30, 3000). This prevents a flood of image requests that could either overwhelm the server or cause the browser to queue and timeout.
Refs as state in the mobile app. The scanning flow spans five screens: camera, picrture cropping, adding details, the postcard preview, a success page. Normally you would use states to share data across these screens, which means every field change would re-render the context consumers. Instead, the PostcardContext stores everything in useRef and exposes getter properties. This allows us to share states with 0 re-renders during the flow. At the end, the final postcard preview screen reads the data when it needs it to send the data to our server.
The location coordinate fallback chain. When a postcard is submitted, the API tries GPS coordinates from the mobile device first. If those are missing (eg. when you do not allow location permissions), it falls back to a centroid lookup for the sender's country. If that fails too, it defaults to our office in Antwerp. This way, every postcard lands somewhere semi-relatable to the sender or to us.
Frame budget protection. The animation loop in the web frontend clamps the time delta (difference) to a maximum of 0.1 seconds. If someone switches browser tabs and comes back after thirty seconds, the globe does not spin thirty seconds worth of rotation in one frame but keeps animating smoothly. On mobile however, the canvas stops rendering entirely when the app is backgrounded so no real fix was needed there.
What we learned
As frontend developers we are usually writing CSS and React to create beautiful and consistent UI's, so venturing out into the world of 3D for once was really fun and we learned a lot. We both have an interest in the intricate workings of shaders, and having a virtual AI colleague (Claude) to help us put our ideas into reality was such an informative experience.
Before this project we had no real idea about GPU instancing, how it worked and how it pretty much could save a project's performance. We did have some knowledge about WebGL shaders, but nowhere near enough to create these custom effects like the ripple effect when a card is floating towards it's location. The fun we had and the opportunity to go outside of our weekly comfort zone made for a fun day interacting in a completely different way with AI, our coworkers and the way we can code.
For those of you who would like to see the final result, we hosted a demo version of the finished project at kaartje.earth. This demo of the project runs with fake postcards as we have not had the time to take pictures of all your wonderfull postcard submissions.