Inspiration
My inspiration for this project came from a situation many hackers know all too well. I came to Hack the North ready to start running, my brain packed with ideas, only to sit down and realize either they were too large or not at all possible. Nonetheless, I sat in my chair, disappointed, wondering what possibly could make my day go faster. Then it hit me. Microsoft Paint. Although a relic of ancient times (yes, don't shoot the messenger), its pixelated yet so simple interface peaked interest and calmed the minds of many through their roughest times, and I found it only right to port it onto a piece of tech every hacker had: the all new 2026 Hack the North badge. So was born Goose Paint, a spin off of Microsoft Paint.
What it does
Upon opening the app, you are presented with a 12x12 pixel array, in which you can fill however you see fit using any of the provided 17 colors. The controls: D-pad to move around, Start to cycle through colors, B to clear a pixel/set it to white, and A to paint a pixel with the selected color. Press A and B together, and A once again, and your given a scannable 172-character code, of which you can use to forever preserve your piece of work on the Solana blockchain using https://paintgoose.work, free of charge!
How I built it
Badge (Lua): There's no canvas or framebuffer API, so the grid is 144 individual box widgets, built four per tick during a short loading phase to stay inside the callback deadline. Most of the engineering was memory. My first working version held 174 live widget handles, a 144-entry pixel table, a palette table, and three pre-built overlay screens: 45.4 KB of Lua heap. The rewrite finds cells on demand with root:child(n), packs the whole canvas into 24 integers of six 5-bit cells each (a paint is a masked write that allocates nothing), stores the palette as one 48-byte string, builds the menu and code screens only when they're opened, and has on_enter delete itself after it runs. Since iterating on hardware is slow, I also wrote a host harness: a stub of the "badge.*" API that enforces the real contracts (legal style keys, integer coordinates, set_text on labels only, use-after-delete). I ran the old and new versions side by side and compared their screens and internal state after every input.
Mobile-only webpage (Expo): The scanner is one HTML page (camera capture, corner detection, homography-based perspective correction, Tesseract OCR, checksum validation) that runs unmodified inside a WebView on iOS and Android and an iframe on mobile web. The Drawings feed and the scanner are two tabs on that same page.
Backend (Cloudflare + Solana): The gas station is a Cloudflare Pages Function holding one funded keypair, so users never touch a wallet. Each submission is a single transaction: the Noop program logs the 95-byte packed image, and Solana's state-compression program appends its keccak256 hash to a tree sized for 16,384 images, allocated once at a fixed cost however many images follow. Around that I added per-IP rate limiting and an optional global daily cap in KV so nobody can drain the fee balance, a KV cache in front of the feed so it doesn't hammer the RPC provider of my choosing, Helius, on every page load, and a confirmation loop that polls and retries rather than trusting a single RPC response.
Challenges I ran into
While building what I thought was going to be a short and small project, I ran into a challenge that made this turn from a simple, sweet, and short project to an extremely hard and time consuming nightmare: memory limitations with the badge. On paper, this app seemed incredibly easy: a 12x12 grid, a 17-color palette, and a way to transfer data between devices, a problems that's been solved for decades at this point. As I mentioned before, my first working version held 174 live widget handles, a 144-entry pixel table, a palette table, and three pre-built overlay screens, and used 45.4 KB of heap. Getting it down to 14.2 KB with a single retained handle took far longer than writing the app did. After the rewrite, my retained data is 0.2 KB while the compiled code, plus debug info the badge won't let us strip, is 14.0 KB. Collapsing twenty functions into six mattered more than any data structure. Additionally, the badge's Lua uses 32-bit integers, and nothing in the documentation I found says so. My first packing put 12 cells (60 bits) in one integer. On the badge, column 7 kept only the low two bits of each color, and columns 8 to 12 sat past the integer width, where shifts silently return zero, so a third of the canvas couldn't be drawn. When I finally realized what was going on (with the help of Claude of course), I repacked to six 5-bit cells per integer (top bit 29), solving a problem I never thought of encountering.
Accomplishments that I'm proud of
In all honestly, the biggest accomplishment was that I finished this project by the deadline. With the amount of optimizations needed for the app just to run without crashing the badge, I truly had times where I thought this project would not be able to see the light of day. However, in addition to that accomplishment, I'm extremely proud of the optimizations I had done with the memory. Cutting down Lua's heap from an (estimated) staggering 45.4 KB to 14.2 KB felt like a hackathon victory by itself.
What I learned
A lot. First and foremost (there seems to be a trend in this post), embedded systems have limitations, and those limitations, not the feature-set, are what can make or break the project. Also, realizing that with embedded Lua that the code itself is a memory hog was a massive realization. Turning twenty functions into six mattered more than any data structure ever could, and that inverted my instincts about what to optimize. Lastly, in terms of frameworks, this project was the first time I had ever touched: Solana and Helius, Cloudflare Pages & Workers, and even Lua. Although I heard of all of them before attending Hack the North, using them and having hands-on experience was a great learning moment for me, and taught me skills I hope to apply as I advance through my career.
What's next for Goose Paint
The OCR could be heavily improved upon. Due to the nature of how the code is a pseudo-random list of characters, packages like Tesseract OCR, a package trained on reading English, heavily struggle to make any sense of the scanned image. The only remedy for this it would seem is a custom algorithm/model built from the ground up to read my scanned images. In addition, due to the time crunch caused by the badge app optimization, I never had a chance to even try optimizing the mobile-first web app. Given a lot of it was coded by Claude, even with human-supervision, there's bound to be places with the code can generally be improved, either in terms of performance or code readability. (As of Sept 20th 7:57AM, the custom OCR algorithm/model has been deployed, and it is immensely better than the Tesseract OCR shown within the demo video, despite it still making a few minor mistakes and needing human intervention to fix a few mismatched characters)
Built With
- blood-sweat-tears
- claude
- cloudflare
- esp32
- expo.io
- helius
- lua
- solana
Log in or sign up for Devpost to join the conversation.