I made a thing called the Pond of Fame. It’s the world’s first full-stack tadi website.
Here’s how it works.
The Pond of Fame is basically a still image of a bunch of cute creatures.
There’s a blob of data out there in the cloud. It’s a list of all the creatures that appear in the Pond of Fame. I call them HEROES.
Anyone can see the blob! There’s nothing private in it. Here’s how you get it:
const response = await fetch("https://api.val.town/v1/run/todepond.getHeroes");
const heroes = await response.json();
I have a little helper function that simplifies this. It lets me call any val.town function.
const heroes = await val("todepond.getHeroes")
Each hero object contains some information. Here’s an example:
{
"name": "Berd",
"tier": "flappy",
"flavour": "fire",
"supporter": 123
}
The name
, tier
, and flavour
properties determine how the hero should look in the Pond of Fame. The supporter
property tells me which supporter added the hero. I’ll get to that later.
The Pond of Fame itself is a mini canvas engine. It has a big list of ENTITIES. Every frame, it draws every entity to the screen.
When the Pond of Fame loads, it fetches the list of heroes. Then it creates an entity for each one. Here’s a snippet from that code:
createEntity(`Colours/${hero.tier}/${COLOUR_MAP[hero.flavour]}.png`, {
x: 5675 - i * 24,
y: 240 - i * 656,
text: hero.name,
});
I use the hero’s tier
and flavour
to determine which image to load. The images were originally drawn by Flora Caulton. I tweaked their size and colour in photoshop.
I also add the hero’s name
as a text label. And I position the hero dynamically, based on where it appears in the list.
I have an admin dashboard that lets me edit and manage the blob.
It’s password-protected. There’s a password input at the top of the page.
<input
type="password"
id="password"
oninput="handlePasswordInput()"
/>
The password gets stored to local storage.
const passwordInput = document.querySelector("#password")
const handlePasswordInput = () => {
localStorage.setItem("password", passwordInput.value)
};
I automatically load the saved password so that I don’t need to type it in each time.
passwordInput.value = localStorage.getItem("password") ?? ""
The list of heroes gets loaded into a big textbox.
<textarea id="heroes"></textearea>
<script>
const heroesInput = document.querySelector("#heroes")
let heroes = null
const pullHeroes = async () => {
heroes = await val("todepond.getHeroes")
heroesInput.value = JSON.stringify(heroes, null, 2)
}
pullHeroes()
</script>
I can edit the heroes, and then push a button to upload those changes.
<button onclick="handlePushHeroes()">Push heroes</button>
<script>
const handlePushHeroes = async () => {
const result = await val(
"todepond.setHeroes",
JSON.parse(heroesInput.value),
heroes,
passwordInput.value,
)
}
</script>
This time, I have to send a password
along too.
I also send along a copy of what I think the current heroes are. It lets the server check if I’m up-to-date. If I’m out-of-date, it rejects me because I might be overriding newer data.
Surprise! There’s another blob of data. This one’s private: It’s a list of paying SUPPORTERS (that’s you).
A supporter looks like this:
{
"id": 123,
"email": "todepond@gmail.com",
"secret": "abcde-fghijklmn-etc"
}
The id
property lets me identify you without referring to any personal information. The email
lets me contact you. The secret
is something you can use to edit your hero. I’ll get to that later!
Getting and setting the supporters blob works similarly to the heroes blob. The only difference is that I need to send the password even when I’m just fetching it.
const supporters = await val("todepond.getSupporters", password)
There are also some differences with how it’s stored in the cloud, for additional security. I’ll get to that later too!
You can edit your hero in the supporter dashboard.
You’re only allowed to edit your own hero, of course. So there’s a password input at the top of the screen, similar to the admin dashboard.
<form action="#" onsubmit="handleLogin()">
<input
type="password"
id="secret"
oninput="saveSecret()"
/>
<button>Login</button>
</form>
You paste in your SECRET, and the dashboard fetches your ID.
const secretInput = document.querySelector("#secret")
const handleLogin = async () => {
const id = await val("todepond.loginSupporter", secretInput.value)
}
The dashboard uses your ID to get your HERO.
const heroes = await val("todepond.getHeroes")
const hero = heroes.find(v => v.supporter === id)
There are some inputs for setting your hero’s name and flavour.
<input type="text" id="name" />
<select id="flavour">
<option value="fire">Fire</option>
<option value="water">Water</option>
<option value="air">Air</option>
<option value="sand">Sand</option>
<option value="wood">Wood</option>
<option value="flower">Flower</option>
<option value="pink sand">Pink sand</option>
<option value="metal">Metal</option>
<option value="poison">Poison</option>
<option value="leaf">Leaf</option>
<option value="cloud">Cloud</option>
</select>
To begin with, your hero’s data gets loaded in.
const nameInput = document.querySelector("#name")
const flavourInput = document.querySelector("#flavour")
nameInput.value = hero.name
flavourInput.value = hero.flavour
Make your changes and then hit the “Save hero” button! Your secret gets sent along to grant you permission.
<button onclick="handleSave()">Save hero</button>
<script>
const handleSave = async () => {
await val(
"todepond.setHero",
secretInput.value,
nameInput.value,
colourInput.value
);
}
</script>
I show you a preview of what your hero will look like on a canvas.
<canvas width="800" height="800"></canvas>
Every time you edit something, I redraw the preview.
const canvas = document.querySelector("canvas")
const context = canvas getContext("2d")
const drawPreview = () => {
const image = IMAGE_MAP[hero.tier][flavourInput.value]
context.clearRect(0, 0, canvas.width, canvas.height)
context.drawImage(image, 0, 0)
context.fillStyle = "white"
context.font = "bold 60px sans-serif"
context.textAlign = "center"
context.fillText(name, canvas.width / 2, canvas.height - 120)
}
Your account is managed via email.
There’s a button for getting your secret code.
<button onclick="handleNoCode()">I don't have a code</button>
There’s also a hidden email input.
<form
id="email-form"
style="display:none"
action="#"
onsubmit="handleEmail()"
>
<input type="email" id="email" />
<button>Send code</button>
</form>
When you click on the button, it reveals the hidden input.
const emailForm = document.querySelector("email-form")
const handleNoCode = () => {
emailForm.style.display = "flex"
}
When you register, I get sent an email notification. I check if you’re on the list of members. If you are, I click the magic link in the email.
The magic link has your email address as a search parameter.
todepond.com/fame/dashboard/admin?addSupporter=todepond@gmail.com
The link takes me to the admin dashboard, where there’s a form for adding new supporters.
<form action="#" onsubmit="handleAddSupporter()">
<input type="email" id="email" />
<select id="tier">
<option value="froggy">Froggy</option>
<option value="flappy">Flappy</option>
<option value="beepy">Beepy</option>
</select>
<button>Add supporter</button>
</form>
The email input gets filled in automatically by the search parameter.
const emailInput = document.querySelector("#email")
const params = new URLSearchParams(window.location.search)
const email = params.get("addSupporter")
if (email) {
emailInput.value = email
}
I click the button, and it adds you to the Pond!
const handleAddSupporter = () => {
val(
"todepond.addSupporter",
emailInput.value,
tierInput.value,
passwordInput.value
)
}
Then, you get an email with your secret code. Again, the email comes with a magic link that lets you login instantly.
todepond.com/fame/dashboard?secret=abcde-fghijk-etc
If you forget or lose your code, you can click on the “I don’t have a code” button. It’ll send you a new one.
All those API calls get handled by val.town.
Easy, just get the blob.
import { blob } from "https://esm.town/v/std/blob";
export async function getHeroes() {
return await blob.getJSON("heroes");
}
Similar, but check the password first.
if (password !== process.env.FAME_ADMIN_PASSWORD) {
return { success: false, error: "Wrong password" };
}
And make sure you aren’t out-of-sync.
const actualHeroes = await blob.getJSON("heroes");
if (JSON.stringify(actualHeroes) !== JSON.stringify(heroes)) {
return { success: false, error: "Conflict" };
}
Then set the blob!
await blob.setJSON("heroes", heroes);
return { success: true };
Similar, but decode the data first.
const supporters = await decrypt(
encryptedSupporters,
process.env.FAME_SUPPORTER_ENCRYPTION_KEY
);
Encrypt the data before saving it. This means your email won’t be exposed if there’s a data breach, or if I mess up somehow.
const encryptedSupporters = await encrypt(
JSON.stringify(supporters),
process.env.FAME_SUPPORTER_ENCRYPTION_KEY
);
To do the encryption, I copy-pasted some code from stack overflow.
Check if your secret code is valid, and give back your id
const supporters = await getSupporters(process.env.FAME_ADMIN_PASSWORD);
const supporter = supporters.find((s) => s.secret === secret);
if (!supporter) {
return null;
}
return supporter.id;
Val town makes it easy to email people.
import { email } from "https://esm.town/v/std/email";
await email({
to: "todepond@gmail.com",
from: "todepond.com@valtown.email",
subject: "Secret code request",
html: `Secret code request from ${address}`
});
Generate a secret code.
let secret = crypto.randomUUID();
while (isCollision(secret, supporters)) {
secret = crypto.randomUUID();
}
Don’t really need to check for duplicate codes, but why not eh.
function isCollision(secret, supporters) {
return supporters.some((s) => s.secret === secret);
}
I get daily backups via email by running one of my vals on a very long interval.
email({
text: JSON.stringify(heroes) + "\n" + JSON.stringify(encryptedSupporters),
to: "todepond@gmail.com",
from: "todepond.com@valtown.email",
subject: "Pond of fame backup",
});
That’s everything! That’s all you need.
Listed out, it seems like quite a lot. But that’s only because I went through in close detail. I hope that nothing was left out as ‘assumed knowledge’.
The number of individual concepts was actually quite low. Everything was written with fairly boring html and javascript. There was no build step, no environment setup, no extra tooling. JUST HTML FILES AND JAVASCRIPT FUNCTIONS. BEAUTIFUL BORING SIMPLICITY.
All of the code is portable. I’m not locked into any stack or ecosystem, like vercel, deno, supabase, react, next, github, node, blah blah.
It’s just HTML FILES. And JAVASCRIPT FUNCTIONS. Though I may have said that already.
Val town uses deno and r2 behind-the-scenes. But it provides a wrapper around them that is highly portable. It’s a way of using these closed-off ecosystems in a way that doesn’t lock you in. Because of this, I’m more than happy to pay for val town for higher rate limits.
Their recent changes made val town even more portable, which I’m pleased about.
I trust the people behind val town. I’ve met André Terron in person, and I admire Steve Krouse’s work.
But if they DO go bad, or sell out… I know I could pull out all my code and it’ll work somewhere else (with a little bit of tweaking).
And it’s all simple enough that I could probably rebuild it from scratch. I didn’t have to spend weeks learning a whole new messy pile of systems (like most other providers). I built the whole thing in 3 days. Not because I’m smart, but because I stayed simple.
So… for me, it’s a no-brainer to pay for val town right now. It helps me stay slippy.
No but I do like the people behind it, so maybe I’m biased. Take with a grain of salt.
The pond of fame is all open-source! And my vals are all either public or unlisted on my val town.
If you want to be part of the pond of fame, sign up on my patreon.
Tell me what you think! If anything seems unclear, or this helped you, or you have any thoughts at all, please let me know! My email is on todepond dot com.