I gave this as a talk at Bootcamp 1.0. The slides were short, so this is the long version. It has the code, the numbers, and the parts I had to skip on stage.
It is not a talk about how to write code. It is about approach: owning what you build from the first screen to the last edge case. I use Winterfell as the example all the way through, because I built it end to end and I know where every mistake is.
01The AI era
AI can now copy almost anything. Design, frontend, backend, you name it. There is a loud debate online about whether we still need human engineers, or whether AI has made the whole field pointless.
So what is left for us?
My answer: the code is getting cheap, but judgement is not. A model can write a sign-in page in ten seconds. It will not ask what happens when a Google user wants to export to GitHub. It will not notice the tab eating two gigabytes of memory. Someone has to care about those things, and that care is what people feel when they use your app.
02The 1% difference
Most apps that fail do not fail on the big feature. They fail on a hundred small things that nobody checked. Each one is about 1% of the experience. Alone, none of them matters. Together, they decide if someone comes back. That is the butterfly effect: tiny details, big result.
Here are the six I care about most, each with a real example from Winterfell.
Edge cases
Every user gets 3 contracts a day. The easy version of this is a 429 error and the message “limit reached”. That is correct, and also useless. The user's next question is “ok, when can I try again?”, and the server already knows the answer.
So the server finds your oldest contract inside the last 24 hours. Your next slot opens exactly 24 hours after it. That time goes back with the error, and the chat box shows “You have reached your daily limit, try again at 4:12 PM”.
const window_start = new Date(Date.now() - DailyRateLimit.WINDOW_MS);
const contracts = await prisma.contract.findMany({
where: { userId: user.id, createdAt: { gte: window_start } },
orderBy: { createdAt: 'asc' },
});
if (contracts.length >= limit.CONTRACTS_PER_DAY) {
const oldest = contracts[0].createdAt;
const allowed_time = new Date(oldest.getTime() + DailyRateLimit.WINDOW_MS);
ResponseWriter.custom(res, 429, {
success: false,
message: 'Limit reached',
meta: { next_allowed_time: allowed_time.toISOString() },
});
return;
}Notice this is a rolling window, not a reset at midnight. If it reset at midnight, someone could use 3 contracts at 11:58 PM and 3 more at 12:01 AM. With a rolling window, “3 a day” really means any 24 hours.
There is a second limit too: 3 messages per contract. When you hit it, the box does not just say no. It offers “start new chat” and carries your template over to the new chat, so you do not lose what you were doing.
Micro-interactions
Generating a contract takes a while. A spinner for 40 seconds feels broken. So the loader tells you what is happening right now: “Analyzing Request”, then “Generating Code”, then “Creating Files”. While a file is being written, it shows that file's name and icon.
Nothing got faster. But the wait feels shorter, because you can see progress. People forgive slow. They do not forgive silent.
Performance
This is my favourite bug. While a contract streamed in, the Chrome tab froze and then crashed with “Aw, Snap!” (out of memory). Small contracts were fine. Big ones killed the tab.
Here is how the stream works. The server sends events, one per line. The browser gets the data in small chunks, adds each chunk to a buffer, and splits the buffer on newlines to find full events.
The problem: the last event holds every generated file, so it is one line of several megabytes with no newline until the very end. On every new chunk, the old code split the whole buffer again from the start. A 2 MB event in 1 KB chunks means about 2,000 chunks, and each one re-reads everything that came before. The work grows with the square of the size. That added up to 2 GB of memory churn on the main thread.
The fix is one idea: remember where you already looked. Anything before that point cannot contain a newline, so never read it again.
buffer += decoder.decode(value, { stream: true });
let nl = buffer.indexOf('\n', search_from);
while (nl !== -1) {
handle_line(buffer.slice(0, nl));
buffer = buffer.slice(nl + 1);
nl = buffer.indexOf('\n');
}
// nothing before this point can contain a newline, so never rescan it
search_from = buffer.length;Same output, byte for byte. In a test that copies the real loop, memory churn went from 2.00 GB to 0.002 GB, and the time went from 1626 ms to 35 ms.
Thoughtful defaults
A default is a decision you make so the user does not have to. Enter sends the message and Shift + Enter adds a new line, because that is what every chat app taught people.
Builds are a less visible example. Building an Anchor program is slow. If you press build twice without changing anything, the second build is wasted. So before each build, the server hashes the code and compares it to the hash saved at the last build.
static check_build_cache(contract: Contract) {
if (contract.lastBuildStatus === 'NEVER_BUILT') return false;
const old_contract_hash = contract.codeHash;
const current_state_hash = this.create_hash(contract.code);
return old_contract_hash === current_state_hash;
}If they match, the terminal answers “Build retrieved from cache” in a moment instead of a minute. The user never sees this decision. They just feel that the app is fast.
The do-it-now mindset
When you notice a small problem, fix it now. Not next sprint. “Later” is where small problems go to become the reason people leave. Most of the fixes above took less than an hour. Writing a ticket about them would have taken almost as long.
UI/UX and DX
DX means developer experience. Winterfell's users are developers, so the app should feel like their own tools. It has a real terminal (Cmd/Ctrl + J), file search (Cmd + K) and a shortcut sheet (Cmd + /). The terminal even handles typos like a shell does.
03Project vs product
These two look the same while you are building them. They stop looking the same the week after you ship.
Project
done when it works
- 1Built to show what it does.
- 2Bugs and improvements are “next sprint” problems.
- 3Shortcuts are taken to hit the deadline.
Product
never finished
- 1Built so real users can come in and smash it.
- 2You own quality at every level, and refine it until it truly serves people.
- 3Thinks about scale, maintenance and what comes next.
A project is done when it works. A product is done when people keep using it, which means it is never really done. A product mindset means spending time on details other people would skip. Most of this case study is those details.
0423 days: the Winterfell timeline
Winterfell is like Lovable, but for Solana smart contracts. You type “create a counter program”, and it writes the Anchor code, sets up the files, and builds it for you in the browser.
Early Sep 2025
The spark
I went to a Solana event in Mumbai run by Superteam and Apex. I saw teams building deep Web3 infrastructure, and some had grown from small projects to billion-dollar revenue. That changed what I thought was possible.
9 Oct, 7:30 PM
The idea lands in class
In a 100x class we were taking apart how Lovable is built. Harkirat said that if someone built Lovable for Anchor smart contracts, it would be a good fit for the Colosseum and Superteam hackathons. The class ended at 7:30.
9 Oct, 8:30 PM
The repo is live
One hour later the repo was live, the database was set up, and sign-in worked. The first commit is from 8:27 PM.
21 Oct
First production deploy
The first fixes for the production deploy on Vercel land.
31 Oct
23 days in
23 days and 264 commits after the first one, the core app is built. Winterfell went on to win a $1000 prize in a Superteam hackathon. I kept going after that: 260 more commits in November and 215 in December.
How the parts fit together
Shipping fast does not mean one big file. Winterfell is a monorepo with four apps: web (Next.js), server (Express, Prisma and Postgres), socket (WebSockets), and kubernetes (builds). Each part does one job.
Generating. Your prompt goes to a planning model first. It decides the contract name, the plan and which files will change. Then a coding model streams the code, and a small model at the end writes the IDL (the description of your program that clients use). Each stage is sent to the browser as it happens, using Server-Sent Events, a one-way stream from server to browser. That is what drives the loader from chapter 2.
Building. Building Rust takes minutes and a lot of CPU, so it must not run on the web server. The browser sends “build” over a WebSocket. The socket server puts a job on a queue (BullMQ on Redis). A worker starts a Kubernetes Job, which is a short-lived container just for your build. It runs anchor build and sends the logs back through Redis to your terminal.
One build failing or hanging cannot slow down anyone else. Each job has a 15-minute limit and is cleaned up after it finishes.
05Understand the user journey
Before building screens, I map the path a user takes, from hearing about the app to trusting it. Every stage has its own way to lose them.
- 1
Entry
Users find Winterfell in places developers already spend time.
- 2
Getting in
Sign in with Google or GitHub. GitHub is needed to export code.
- 3
Discovery
Clear navigation shows who we are and what the app does.
- 4
Playground
Where the real value is. Loading, skeletons and retries live here.
- 5
Retention
Did they get what they came for? If they feel stuck once, they leave.
Getting in: two buttons, one hidden problem
Our users are developers, and the best feature is pushing your contract to your own GitHub repo. To do that, Winterfell needs GitHub access with the repo scope. So GitHub sign-in is important. But not everyone wants to start with GitHub, so Google sign-in is there too.
That creates a problem. Someone signs in with Google, builds a contract, then clicks export. Now they need GitHub. If they sign in with GitHub, the easy code would create a second account, and their contract would be stuck in the first one.
So “Connect GitHub” sets a short cookie (it lasts 5 minutes) with the current user's id before starting the GitHub sign-in. When GitHub sends the user back, the server sees the cookie and attaches GitHub to the existing account instead of creating a new one. If that GitHub account already belongs to someone else, it refuses.
Playground: plan for the bad network
Even a small edge case here can ruin the whole experience. The terminal runs over a WebSocket, and connections drop: laptop sleeps, Wi-Fi switches, server restarts. If the app just gives up, the user's terminal is dead and they do not know why.
So the client reconnects by itself. It waits 1 second, then 2, then 4, doubling up to 30 seconds. This is called exponential backoff. If thousands of clients lose the server at once, they do not all hammer it at the same moment when it comes back. After 5 tries it keeps trying every 5 seconds. And if the user closed the connection on purpose, it does not reconnect at all.
private attempt_reconnect() {
if (this.is_manually_closed) return;
this.reconnect_attempts++;
let delay: number;
if (this.reconnect_attempts <= this.max_reconnect_attempts) {
delay = this.reconnect_delay;
this.reconnect_delay = Math.min(this.reconnect_delay * 2, this.max_reconnect_delay);
} else {
delay = this.persistent_reconnect_delay;
this.reconnect_delay = 1000;
}
this.reconnect_timeout = setTimeout(() => {
if (!this.is_manually_closed) this.initialize_connection();
}, delay);
}The same thinking is on the server. Jobs on the queue retry up to 3 times with growing delays. And if you send a second prompt while the first is still generating, the server says no (409) instead of running two at once and mixing up your files.
06The iceberg principle
This example is from another app I built, not Winterfell. It had a simple feature: add collaborator. One input, one button. It looks like a ten-minute job.
As developers, we usually skip what is under the water. But should we? Ask three questions:
- Does this email belong to a real user on the platform?
- If not, what happens? Show an error, send an invite, or keep it pending?
- If yes, do we add them right away, or only after they accept?
Here is how I would answer them, and why.
Always send an invite, even to existing users. Adding someone to a project without asking is a way to spam them, and it gives you two code paths to maintain. With invites, there is one path: create an invite, then the person accepts it.
Do not tell the inviter whether the email has an account. If the app says “no user with this email”, anyone can type emails to find out who uses your platform. So the message is always “Invite sent”. Only the email itself is different: “join the project” or “create an account to join”.
Give the invite a state. An invite is pending, then accepted, declined or expired. Saving that state is what lets you show “Pending” in the list, resend, or cancel.
enum InviteStatus {
pending
accepted
declined
expired
}
model Invite {
id String @id @default(uuid()) @db.Uuid
projectId String @db.Uuid
email String
invitedBy String @db.Uuid
status InviteStatus @default(pending)
token String @unique
expiresAt DateTime
createdAt DateTime @default(now())
// one open invite per person per project:
// "projectId:email" while pending, null after.
// nulls never clash, so old invites don't block new ones
openKey String? @unique
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
inviter User @relation(fields: [invitedBy], references: [id])
}That openKey field is easy to miss. Without it, a double click on “Invite” sends two emails and creates two rows. With it, the database refuses the second one, even if two requests arrive at the same moment. When the invite is accepted, declined or expired, it goes back to null, so the person can be invited again later.
Accepting needs care too. The link might be old. It might be opened by a different account than the one invited. And creating the member and updating the invite must happen together, so they go in one transaction (all steps succeed, or none do).
export async function acceptInvite(token: string, userId: string) {
return db.$transaction(async (tx) => {
const invite = await tx.invite.findUnique({ where: { token } });
if (!invite || invite.status !== "pending") return { ok: false, reason: "invalid" };
if (invite.expiresAt < new Date()) {
await tx.invite.update({ where: { id: invite.id }, data: { status: "expired", openKey: null } });
return { ok: false, reason: "expired" };
}
const user = await tx.user.findUnique({ where: { id: userId } });
if (user?.email.toLowerCase() !== invite.email.toLowerCase()) {
return { ok: false, reason: "wrong-account" };
}
await tx.member.create({ data: { projectId: invite.projectId, userId, role: "editor" } });
await tx.invite.update({ where: { id: invite.id }, data: { status: "accepted", openKey: null } });
return { ok: true };
});
}Each reason becomes a clear screen: “This invite has expired, ask for a new one” or “You are signed in as a different account”. None of this is visible on the surface. All of it is what makes onboarding feel smooth.
07Good design feels obvious
If a user has to think too much, the design has failed.
Spacing
Use more spacing, not more content. Good websites do not feel crowded. Even on one screen, the layout should feel clean, text should be easy to read, and elements should not fight for attention.
Minimal design is a personal choice. But minimal does not mean empty. It means only what is needed. White space is not wasted space. It helps people focus on one thing at a time.
Typography and colour
Typography is order. It decides what people see first, what they see next, and so on. You control that with four things: font size, font weight, colour and position.
Keep the colour palette small. It keeps the mood consistent across the site. And remember that colour pulls the eye before text does, so use your strongest colour only on the one thing you want people to do.
Keep animations few. Think of them as communication, not decoration. An animation should tell you something: this opened, this moved, this is loading.
Reading the Winterfell landing page
Here is the landing page with those rules marked. Follow the numbers in the order your eyes move.
- 1Seen firstThe headline. Large, bold and light-coloured on a dark scene, so it wins.
- 2Seen secondA big input box with a clear example inside. You know what to type without reading docs.
- 3Everything in one placeFeatures, pricing, FAQ and about, grouped in one small bar.
- 4The one key buttonSign in is the only filled purple button up top, so it is easy to find.
- 5Support, not shoutingExplore Playground and the docs link sit quietly in the corner for people who want more.
08Taste is not personal preference
At the talk I asked the room a question: is taste just personal preference?
If taste is just personal preference, then everyone's is already perfect.
That cannot be true, because some apps clearly feel better than others. So taste is something you can get better at. You cannot download it, and nobody can force it on you. You train it with intent.
Start with one habit: look at websites as a developer, not only as a user. When something feels good, stop and ask why. Then save it for your next project.
- 1
Inspiration
Scroll real products, not side projects.
- 2
Decode
Work out why they behave the way they do.
- 3
Internalize
Rebuild those interactions yourself.
- 4
Refine
Remove what is not needed and make it yours, even if you copied it first.
You do not always need a Figma file to start. A design in your head is just imagination. The real work happens when the design is built, made fast, and holds up with real users.
09Write it with your bare hands
Learn to write clean code, not just UIs. And when you are learning something, write it yourself. Stop using Cursor or any agent tool for that part.
Writing code by hand is becoming a rare skill. I have seen projects so heavily generated that one file had about 2,800 lines, with emojis in the comments.
Every line of code is a liability. It has to be read, tested and changed later. AI is great when you already know what good code looks like. When you do not, it just helps you make more of the bad kind, faster.
The hard work of thinking can't be outsourced to AI, only amplified by it.
UI is the voice of your application. It is how all the work underneath speaks to people. So the work underneath must be solid, and the UI must say it well.
10Remember this
In a world of scarcity, we treasure tools. In a world of abundance, we treasure taste.
Today almost everyone has the same powerful tools. What sets people and products apart is not the tools. It is the judgement, design sense and decisions behind how they are used.
Or, as I said at the end of the talk: “Quality ke peeche bhago, design jhak maar ke peeche aayegi.” Chase quality, and good design will follow you whether it wants to or not.
The code from the class is on GitHub at kant-github/winterfell-class.