Daniel Junghyun Son

Dallok

One color a day for a small group of friends.

What it is

A game for a group of friends. Every morning the whole group gets the same color, at 08:00 in the group's time zone by default. Each member has until midnight to take one photo of something that matches it, with the camera inside the app. At midnight everyone's photo lands in one collage, each with a score for how close it came, and the group argues about it in a thread underneath. Anyone who hasn't played that day can't see the collage until they do.

Groups are invite-only: up to 12 people, joined with an invite code. It's an iPhone and iPad app in English, Korean and Japanese.

It isn't on the App Store yet. App Review rejected build 8 on two points, the camera permission prompt and iPad support. Build 9 fixes both and is ready to resubmit. dallok.app still says "coming soon".

A day's collage: nine photos of ochre, each with its match score, and the thread underneath

Seeded demo data. Photos from Unsplash, used under the Unsplash License.

How it's built

Architecture: the iPhone and iPad app, built with Expo SDK 57 and React Native 0.86, calls the dallok-api Worker on api.dallok.app over HTTPS with a session token. The Worker, written with Hono, handles auth, profile, groups, days, photos, comments, reports, blocks and push tokens. Its photo upload route checks membership, an open day and a JPEG of up to 6 MiB, has the Images binding make a 64 by 64 thumbnail, scores it with CIELAB, k-means and CIEDE2000, refuses anything under 40 and stores the rest in R2. A cron trigger every 15 minutes closes days, opens today's color and sends pushes. The Worker uses D1 database dallok, R2 bucket dallok-photos and the Images binding, verifies Apple identity tokens against Apple's sign-in keys with jose, and sends notifications through Expo's push service. A separate dallok-web Worker serves the dallok.app landing, privacy, terms and support pages.

The app, the API Worker and the services it calls.

The API is a Cloudflare Worker written with Hono. D1 holds groups, days and submissions, R2 stores the photos, and the Images binding makes the small thumbnail the color scorer reads. A cron trigger runs every 15 minutes and moves every group through its day: it opens the day's color once the group's publish hour has passed, closes the day at the group's local midnight, and sends push notifications through Expo's push service.

Sign-in is Sign in with Apple. The API verifies Apple's identity token itself, against Apple's published keys, checking the issuer and audience, using jose.

The app is Expo SDK 57 on React Native 0.86, with file-based routing through expo-router. Game photos can only come from the in-app camera. The photo library is used for profile pictures and nothing else.

About 2,500 lines of TypeScript in the API, covered by 42 tests across 5 test files, and about 5,100 lines in the app, which has no tests yet. 108 commits between 2 and 11 September 2026.

Two decisions behind it

The day belongs to the group's clock

The leader picks the group's time zone when creating it, and everything about "today" runs on that clock. The color publishes at 08:00 there and the day closes at midnight there. A member travelling abroad still plays on the group's day.

There is no single server midnight. Each cron run checks every group against its own zone, so on an ordinary day a group sees its color up to about 15 minutes after 08:00. A group created after 08:00 gets today's color straight away. The tests cover finding the local date in a zone, the next local midnight, the daylight-saving change in New York, half-hour offsets and rejecting invalid zone names. The same color can't come back to a group within 30 days.

One contract, copied by hand

Request and response types live in one TypeScript file, api/src/contract.ts, and the app keeps a verbatim copy at app/src/api/contract.ts. Both sides compile against the same shapes. Today the two files are byte-for-byte identical, but nothing enforces that: no script or test compares them. The next step is a check that fails the build when they drift apart.

The hard problem: is this photo actually ochre?

Every submission gets a score from 0 to 100 for how well it matches the day's color, and a photo that scores under 40 is refused before it's stored. The score has to agree with what a person sees, give the same answer for the same photo every time, and run inside a Worker on every upload.

The simple approaches fail in ways anyone can picture. Averaging the pixels turns an ochre leaf on grey pavement into a muddy brown that matches nothing. Measuring distance in RGB rates some pairs the eye sees as nearly identical as far apart, and some obviously different pairs as close.

So the scorer works in steps:

  1. The Images binding shrinks the photo to a 64×64 JPEG.
  2. jpeg-js decodes those 4,096 pixels inside the Worker.
  3. Each pixel is converted to CIELAB, a color space built so that equal distances look roughly equally different.
  4. k-means groups the pixels into five color clusters, starting from fixed rather than random centers.
  5. Clusters covering less than 5% of the image are dropped, so a speck of the right color in a corner doesn't count. If every cluster is under 5%, all of them are kept.
  6. The closest remaining cluster is compared with the day's color using CIEDE2000, and that distance becomes the score: 100 × (1 − ΔE / 50), floored at zero.
export const scorePixels = (pixels: readonly Rgb[], targetHex: string): number => {
  const target = rgbToLab(hexToRgb(targetHex));
  const labs: Lab[] = pixels.map(rgbToLab);
  const clusters = kmeans(labs, CLUSTERS);
  const eligible = clusters.filter((c) => c.share >= MIN_CLUSTER_SHARE);
  const pool = eligible.length === 0 ? clusters : eligible;
  const best = pool.reduce((min, c) => Math.min(min, deltaE2000(c.center, target)), Number.POSITIVE_INFINITY);
  return Number.isFinite(best) ? scoreFromDeltaE(best) : 0;
};

CIEDE2000 is easy to get subtly wrong, with hue angles that wrap around and several weighting terms. The tests check the implementation against Sharma, Wu and Dalal's published test pairs to four decimal places.1 They also check that an exact match scores 100, that a region under 5% is ignored while one over 5% counts, that the same pixels always get the same score, and that a real JPEG thumbnail decodes.

In the app, a refused photo reads as a playful nudge to keep looking rather than as an error.

What it still misses. The photo is cropped to a square before it's shrunk (fit: "cover"), so whatever falls outside that square in a tall shot is never scored, however strongly it shows the color. Five clusters is a fixed choice, and a busy scene can merge the target color into a neighbour. The 40-point bar is one constant for every color, not tuned per hue. And nothing stops someone photographing a screen that shows the right color; the product requirements accept that as not worth fighting.


  1. Gaurav Sharma, Wencheng Wu and Edul N. Dalal, "The CIEDE2000 color-difference formula: Implementation notes, supplementary test data, and mathematical observations", Color Research & Application 30(1), 21–30, 2005. doi:10.1002/col.20070

All work