World’s first full-stack tadi website

I made a thing called the Pond of Fame. It’s the world’s first full-stack tadi website.

Here’s how it works.

Pond of Fame

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("");
const heroes = await response.json();

I have a little helper function that simplifies this. It lets me call any 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,

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.

Admin dashboard

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.


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>
  const heroesInput = document.querySelector("#heroes")
  let heroes = null

  const pullHeroes = async () => {
    heroes = await val("todepond.getHeroes")
    heroesInput.value = JSON.stringify(heroes, null, 2)


I can edit the heroes, and then push a button to upload those changes.

<button onclick="handlePushHeroes()">Push heroes</button>
  const handlePushHeroes = async () => {
    const result = await val(

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": "",
  "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!

Supporter dashboard

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()">


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)

Hero designer

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>

To begin with, your hero’s data gets loaded in.

const nameInput = document.querySelector("#name")
const flavourInput = document.querySelector("#flavour")

nameInput.value =
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>

  const handleSave = async () => {
    await val(

Hero preview

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.

  <input type="email" id="email" />
  <button>Send code</button>

When you click on the button, it reveals the hidden input.

const emailForm = document.querySelector("email-form")

const handleNoCode = () => { = "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.

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>
  <button>Add supporter</button>

The email input gets filled in automatically by the search parameter.

const emailInput = document.querySelector("#email")
const params = new URLSearchParams(
const email = params.get("addSupporter")
if (email) {
  emailInput.value = email

I click the button, and it adds you to the Pond!

const handleAddSupporter = () => {


Then, you get an email with your secret code. Again, the email comes with a magic link that lets you login instantly.

Forgot code

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

Get heroes

Easy, just get the blob.

import { blob } from "";

export async function getHeroes() {
  return await blob.getJSON("heroes");

Set 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 };

Get supporters

Similar, but decode the data first.

const supporters = await decrypt(

Set supporters

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(


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;


Val town makes it easy to email people.

import { email } from "";

await email({
  to: "",
  from: "",
  subject: "Secret code request",
  html: `Secret code request from ${address}`

Secret code

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.

  text: JSON.stringify(heroes) + "\n" + JSON.stringify(encryptedSupporters),
  to: "",
  from: "",
  subject: "Pond of fame backup",

The end

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.

Slippy as hell

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.

What about val town though?

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.

But what if they go bad?

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.

Are val town paying you to say this?

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.