luisgonc.

← blog ·  September 16, 2026  ·  18 min read

I built a galaxy for eighteen people

Spacegram is an OGame-style strategy game living inside Telegram and running entirely on Cloudflare's free tier. A month of real traffic fits inside a single day of the free allowance. Getting people to play was the hard part.

I could open this with the architecture and never get round to the numbers. The numbers go first.

Commanders on Spacegram, 16 August to 16 September 2026 Signups climb to 18 in the first week and then stop. Commanders active in the last seven days peak at 10 on 29 August and fall to 1 by 16 September. 0 5 10 15 20 16 Aug 23 Aug 30 Aug 6 Sep 13 Sep 18 signed up ever 1 active last 7 days peak 10 COMMANDERS
Every commander who ever opened Spacegram, against the number who did anything at all in the previous seven days. Signups stop after ten days. The red line is the honest one.

Eighteen people signed up. Seven of them opened it once, looked around, and never took a single action. The eleven who did play peaked at ten active in a single week, on 29 August. By the middle of September the previous seven days contained exactly one of them.

That is the whole story of a small game and I would rather put it at the top than bury it. The rest of this is how the thing was built, and why a month of a live multiplayer game fit inside one day of Cloudflare’s free tier.

What it is

Spacegram is OGame in a chat app. You get a homeworld, you queue mines and power plants and a shipyard, you research a tech tree, you build ships, and then you start sending fleets out into a procedurally generated galaxy that nobody has charted yet. Timers run in real time. You close the app and the bot messages you when the building finishes or the fleet comes home.

The galaxy map with charted systems, pirate dens, radar rings and convoy routes
Charted systems in yellow, pirate dens as skulls, blue rings where radar reaches, dotted lines where Dominion convoys are crawling, and two open rally beacons at the bottom.

The galaxy is generated from a single world seed, so a system only becomes a database row the first time somebody flies to it. Everything else is a hash of the coordinates, computed identically on the server and in the browser. The map you are panning around is mostly local arithmetic.

The homeworld screen with resource bar, colony tabs, buildings and construction queue
The homeworld, three colonies deep, with the build queue running.

That screenshot is a late empire. The mines are at level 16 and the planet is dry on all three resources, which is the thing I am proudest of in the whole design: planets hold a finite, surveyed amount of alloy, crystal and fuel, and when you have dug it out it is gone forever. Homeworlds get no exemption. Either you expand or you starve.

A pirate ambush report folded open to a round by round damage table
Every battle folds open into damage dealt and hulls lost per round.

Combat is a rounds based simulation seeded from the mission id, so it is deterministic and testable, and every battle folds open into damage dealt and hulls lost per round. You can tell a rout from a near thing.

The rest of it is the usual furniture of the genre: a tech tree, a shipyard, a fleet screen with whatever you have in flight, a guild market whose prices swing between minus 75% and plus 300% and move every time somebody trades.

The tech tree, fully researched The fleet screen with three missions in flight The guild market with prices mid boom
Research, fleet and market. Alloy is up 68% this cycle, fuel is down 45% and the guilds are pushing it back up.

Why Telegram is the best onboarding funnel I have ever shipped against

I have shipped web apps. The funnel is: read the tweet, click the link, land on a marketing page, click sign up, pick a password, check your email, confirm, log in, and now finally you are looking at the product. Every one of those steps is a place to lose somebody.

Telegram’s funnel is: tap a link in a chat, and you are playing.

There is no install. A Mini App is a web view inside a chat client the player already has open. No app store, no review queue, no 40MB download. My deploy is wrangler deploy and everybody has the new version on their next tap.

Authentication comes for free, and it is real authentication. Telegram hands the web view a signed initData blob. The server verifies an HMAC of it with the bot token on every single request, which takes one Web Crypto call and no session storage, and out the other side comes a verified user id and display name. There is no login screen in Spacegram because there is nothing to log in to. First verified request creates your homeworld.

The timers only work because the bot can interrupt you. An async strategy game is nothing but timers, and a timer is worthless if the player has to remember to come back on their own. On the web that means push permissions, service workers, and a browser that throttles you anyway. Here the Durable Object that just resolved your events calls sendMessage and the player gets a normal chat message from a bot they already talk to. No permission prompt, no infrastructure.

🏗 [Homeworld] Alloy Mine reached level 16
🛰 Expedition returned with loot (1,842🔩 623💎 368🛢)
🏴‍☠️ [Homeworld] Pirates spotted, ~68 raiders inbound (from 9:-1), striking in ~22m!

The rule I wrote down early and never broke: reports are the truth, messages are a courtesy. Delivery is best effort, it retries with backoff and gives up after three attempts, and nothing in the game depends on a message arriving. The in-app report always exists.

There is no invite system, because a chat app already has one. You forward the bot to a group. That is the entire growth mechanic, and it is also exactly why the graph at the top looks the way it does, but I will come back to that.

Release notes go straight into the chat. Three times I wrote a changelog and ran a script that pushed it to every player as a formatted message. No app store review, no in-app update modal, no email list. node scripts/broadcast.mjs docs/changelog/rev3.html --send and everyone who had ever played knew the pirates were now a faction.

The thing Telegram does not solve is retention. It gets a person into the game at a cost of one tap, which means it also gets people in who were never going to play. Seven of my eighteen.

The whole thing runs on free

One Worker. It serves the API, the bot webhook, the cron trigger and the static assets of the React app. One Durable Object class, EmpireDO, with one instance per player keyed by Telegram user id. One D1 database.

The whole system: a Telegram mini app talks to one Cloudflare Worker, which fans out to one Durable Object per player and one D1 database, and the Durable Object messages the player back through the bot. PLAYER IN A CHAT Mini App React · PixiJS · signed initData One Worker API · bot webhook · 5 min cron · static assets EmpireDO one per player state · events · alarm D1 galaxy · reports standings · dens one batched flush per alarm sendMessage best effort BOT MESSAGE IN THE SAME CHAT
Every box is on the free plan. The Worker is the only thing with a public address.

The split between the two storage layers is the design decision everything else falls out of. The Durable Object answers “what is this player’s exact state right now”: resources, building levels, tech levels, queues, fleets in flight, pending events. It is authoritative and hot. D1 answers questions that cross players: the galaxy index, the reports inbox, the standings, the bounty board, where the pirate dens are. D1 is a projection of Durable Object state, refreshed when something meaningful happens, not a backup that anything depends on.

The load bearing rule underneath both: everything that decides a game outcome lives in a pure TypeScript package with zero Cloudflare imports. Resource accrual, cost curves, the combat simulation, the expedition resolver, galaxy generation. The Worker only orchestrates. That is why the balance tests run in plain vitest, why the client can render the galaxy with the same function the server uses, and why I could tune numbers without touching a single request handler.

The one constraint that shaped everything

D1’s free plan allows 100,000 row writes per day. Not requests, rows. That single number decided the entire tick architecture before I wrote a line of it.

The naive version of a game like this has a loop that runs every minute and updates every player’s resources. With 100 players that is 144,000 writes a day doing nothing but incrementing counters, and you are over the limit before anybody has played. So Spacegram never ticks.

Resources are stored as a snapshot plus a timestamp plus a rate. Your current alloy is snapshot + rate × elapsed, computed on demand, by the same function on the server and in your browser, which is also why the numbers in the resource bar count up smoothly while you sit there doing nothing. Nothing is written.

Writes happen when an event resolves or you commit an action. Every action computes its completion time and appends an event, and the Durable Object sets a single alarm to the earliest one. When the alarm fires, the object accrues resources to now, pops every event that is due, resolves each through the pure engine, persists its own state, and then flushes one batched transaction to D1: the planet snapshot, techs and points, any report rows. Then it sends the Telegram messages and sets the next alarm.

The alarm loop: accrue resources to now, resolve every due event through the pure engine, persist state, flush one batched transaction to D1, notify and set the next alarm. ALARM FIRES Accrue resources to now snapshot + rate × elapsed Resolve every due event pure engine · seeded · idempotent Persist object state durable · no D1 involved One batched D1 flush planet · points · report rows Notify, set next alarm min(due) · or none at all EMPTY QUEUE · HIBERNATES AT ZERO COST
The whole tick. An idle player with an empty queue has no alarm at all, and their object hibernates at zero cost.

The universe does not run for absent players. It catches up when somebody addresses it. The only global actor is a cron trigger every five minutes that reads a bounded slice of recently active players, decides who the pirates are going to hit, and pokes a handful of Durable Objects.

The arithmetic I did up front said a hyperactive player generates roughly 100 events a day, which is 200 to 300 row writes, which puts about 300 daily active players under the ceiling before I would have to think about it.

What it actually used

Daily Cloudflare usage against the free-tier ceilings Worker requests peak at 46,200 on 22 August against a 100,000 per day free ceiling. D1 rows written peak at 10,642 on 15 September against the same ceiling. Both panels use the same vertical scale. WORKER REQUESTS / DAY 100k/day free ceiling 0 peak 46,200 D1 ROWS WRITTEN / DAY 100k/day free ceiling 0 peak 10,642 16 Aug 23 Aug 30 Aug 6 Sep 13 Sep
Both panels share the same vertical scale, and both ceilings are the same 100,000 per day. Nothing came close.

From 16 August to 16 September the Worker served 108,107 requests. The free plan allows 100,000 a day. The entire life of the game, a month of it, is roughly one day of the allowance.

The busiest single day was 22 August, at 46,200 requests, which is the day rally beacons shipped and everybody was on the map at once. That is 46% of one day’s limit, at the peak, with the most people who ever played playing at the same time.

D1 wrote 155,664 rows in the month, against a ceiling of 100,000 per day. The worst day was 10,642, which is a tenth of one day’s budget. Reads were 9.94 million over the month against 5 million per day. Durable Objects handled 87,653 requests. The Worker made 32,122 outbound subrequests.

Resource Free plan Busiest day Whole month
Worker requests 100,000 / day 46,200 108,107
D1 rows written 100,000 / day 10,642 155,664
D1 rows read 5,000,000 / day 972,818 9,942,051
Durable Object requests 100,000 / day 14,340 87,653

One number did move in an interesting direction. Median CPU time per invocation was under 2ms during launch week and had climbed past 25ms by mid September, while daily requests fell from 46,200 to under 300. Nothing got slower. The mix changed: once the players stopped opening the app, almost the only traffic left was the five minute cron pulse, which is the single most expensive thing the Worker does. When the humans leave, the average request is a robot doing bookkeeping.

There is a loose end in that. The free plan allows 10ms of CPU per invocation, for HTTP requests, cron triggers and Durable Object alarms alike, and those medians are well past it. The Worker also logged 653 errors over the month, none of them before 29 August and all of them in small daily clusters after it, which is roughly when the Dominion started running hundreds of pirate convoys and the pulse got heavy. I have not chased it down. It has not cost anything visible, because event resolution is idempotent and a failed pulse just means the pirates are late, but if this were a real product that is the thread I would pull first.

Four revisions in ten days

The game that shipped on 16 August was the plan: build, research, explore, get raided by pirates. Everything interesting happened afterwards, in four revisions over the following ten days.

Revision 1, the Anchor. A ship with no guns and no cargo hold whose only job is to plant a rally beacon that the whole galaxy can see. You pick the target and a window of 6, 12 or 24 hours, the anchor parks at a staging point halfway there, and anyone can tap the beacon on the map and send ships. When the window closes everyone flies the last leg together at the slowest hull’s pace and fights as one fleet. It turned a solo game into one where people talked to each other.

Revision 2, finite worlds. The deposits change. Planets hold a surveyed amount of each resource and run dry permanently. Along with it: move your capital to any safe colony, a Tracking Station that watches a zone for other commanders’ fleets, a stealth escort that hides a convoy from exactly that, and guild trades that stopped being instant swaps and became convoys you can intercept.

Revision 3, the Dominion. The pirates stopped being a spawn table and became a faction. Every den near settled space is a garrison that grows, climbs tiers, and launches its own raids, so the den nearest you is the one hitting you. Raze it and its raids stop. Dens reinforce each other with convoys that crawl between systems in the open, and you can race one to an ambush point and take its cargo. A colony sharing a system with a living den pays tribute into its hoard, and the hoard goes to whoever lands the killing blow.

The bounty board listing raiders killed, dens razed and the fattest known pirate hoards
Everything ever taken off the Dominion, and where the fattest hoards are still sitting.

Those numbers are real. Across the month the players killed 9,723 raiders, razed 74 dens and charted 65 systems, while the Dominion moved 5,021 supply convoys between its own.

Revision 4, quiet beacons. Mostly a polish pass. Joining a rally stopped consuming one of your mission slots, beacons could be private, and strike odds started reading the live garrison instead of an estimate.

The interface moved with it. The same screen, on the Revision 3 build and on today’s:

The planet identity card before and after: a run-on sentence became chips you can scan

The shipyard learned to tell you how many of each hull are actually parked at this planet

Both of those came out of playing it on a phone and being annoyed. The planet header was a paragraph you had to read; now it is a row of chips you can take in at a glance. The shipyard would happily let you queue a fifth Anchor without mentioning that four were already docked ten metres away. And the navigation bar grew text labels under the icons, because six emoji in a row is a guessing game.

What the graph actually says

The infrastructure was never the problem. That is the honest summary of the free tier section: I designed carefully around a 100,000 row per day write budget and then used 10% of it on the worst day I ever had. The constraint was real, it made the architecture better, and it was never once close to binding.

The problem is the red line.

Eighteen people is what you get when the only distribution is the share button in a chat app. Seven of them never played, which is the cost of a funnel so cheap that opening it is not a signal of anything. The eleven who did play stayed for a median of eleven days, and five of them were still launching fleets more than two weeks after they joined.

For a game with no marketing and no app store listing, I am not going to pretend that is a failure. The loop works. People built empires, formed rallies, fought each other four times, and one commander finished with 23,491 points and four planets. The thing is finished enough to play and cheap enough to leave running forever, which is more than most side projects get.

But if you take one thing from this: Telegram gives you a one tap funnel and Cloudflare gives you a free backend, and neither of them gives you players. Those two problems look adjacent and they are not remotely the same problem.


Screenshots are from a local instance running a copy of the live galaxy, with commander names changed.