← blog · September 17, 2026 · 28 min read
I built a galaxy for eighteen people
Spacegram was an OGame-style strategy game living inside Telegram, vibecoded on a vacation and running entirely on Cloudflare's free tier. It closed this morning after thirty-two days. The archive turned out to be a harsher design review than anything I wrote while building it.
$ bloat--level 3Bloatin'
Spacegram closed this morning. It ran for thirty-two days, carried eighteen commanders, and the servers went quiet at 10:17 UTC with every empire archived and nothing deleted. I could open this with the architecture and never get round to the numbers. The numbers go first.
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.
One more thing worth saying up front, because it explains the pace: I did not really write this. Spacegram was vibecoded, start to finish, with Opus 5 and Fable 5, over a vacation. Design doc on the fifteenth of August, first commander on the sixteenth, four revisions in the ten days after that, closed a month later. The engine, the galaxy generator, the combat simulation, the Durable Object alarm loop, the tests, the art pipeline and the handbook all came out of that. I picked the constraints and the mechanics and said no a lot. The typing was not the bottleneck.
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 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.
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.
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.
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 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 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
From 16 August to 16 September the Worker served 107,712 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, and I would love to tell you that was rally beacons shipping and everybody crowding onto the map at once. It was a bug I introduced that day, hammering the API in a loop. That one day is 42.9% of every request the game served in its life.
Take it out and the shape is honest: the busiest day of actual play was 8,700 requests on 21 August, which is 8.7% of a single day’s allowance, and the whole month minus my bug comes to 61,512. Real players never got within an order of magnitude of the ceiling. The only thing that ever threatened the free tier was me.
D1 wrote 157,807 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 10.04 million over the month against 5 million per day. Durable Objects handled 89,240 requests. The Worker made 32,130 outbound subrequests.
| Resource | Free plan | Busiest day | Whole month |
|---|---|---|---|
| Worker requests | 100,000 / day | 46,200 | 107,712 |
| D1 rows written | 100,000 / day | 10,642 | 157,807 |
| D1 rows read | 5,000,000 / day | 972,818 | 10,037,969 |
| Durable Object requests | 100,000 / day | 14,340 | 89,240 |
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 594 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.
Those numbers are real. Across the month the players killed 9,841 raiders, razed 74 dens and charted 65 systems, while the Dominion moved 5,167 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:


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.
The post mortem
Closing it meant dumping it. The archive is 23 tables and 7,533 rows of D1, plus the Durable Object storage for all eighteen empires, which is the half D1 never mirrored: fleet compositions, queues, in-flight missions, surveyed systems, every player’s event log. The order mattered more than I expected. I took a dump at 01:22, deployed the shutdown, and discovered two commanders had played in between, so the first dump was already wrong. Deploy the freeze first, dump second.
Then I read it, and the archive turned out to be a much harsher design review than anything I wrote in the ten days I was building it.
One ship won
Eight hulls shipped. Each one had a job: the scout boosts discovery, the courier runs blockades, the hauler and freighter carry, the shadow cloaks a convoy, the anchor plants a beacon, the colony ship settles. Three quarters of every ship alive at the end was a corvette.
The top commander finished with 3,027 corvettes out of 4,306 ships. Not a fleet: a wall. The corvette is the cheapest thing that can fight, and in a galaxy where the main verb is “send ships at pirates and get them back”, cheap and fights is the whole specification. Everything else I balanced was a rounding error against that.
I do not think the fix is nerfing the corvette. The fix is that nothing else was ever required. There was no fight that a stack of corvettes could not answer, so there was never a reason to bring anything specific, and eight hulls collapsed into one.
The galaxy quietly rewrote my balance file
Expeditions roll an outcome table: loot, nothing, an ambush, a derelict to salvage, a discovery. I set those weights per duration in balance/expeditions.ts and tested the distribution against the configured numbers, which passed.
Across the 420 expeditions that actually rolled that table, ambushes fired 30% of the time against a designed 18.4%. On long expeditions it was 47.8% against a designed 25%, and loot on those same long runs collapsed from 30% to 9.0%.
Nothing was wrong with the roll. Two multipliers I wrote deliberately did this: pirateRiskMultiplier weights ambushes 2.5 times higher when the destination has pirate activity, and pirateCargoLure scales ambush weight with how fat the convoy is. Then Revision 3 turned the pirates into a faction that grows and founds new dens, so as the month went on, more and more of the map counted as “has pirate activity”. Long expeditions send the biggest fleets the furthest, which means they were the most cargo-heavy and passed the most dens.
The balance file said one thing and the world state said another, and the world state won. My distribution test could never have caught it, because it tested the table in isolation against an empty galaxy.
Nobody cut a single convoy
Revision 3’s headline was supply lines you can sever. Dens reinforce each other with convoys that crawl between systems out in the open, radar shows them, and you race one to an ambush point to take its cargo before the receiving den gets it. I wrote a paragraph of changelog about it. I drew the interception flow twice.
The Dominion moved 5,167 supply convoys. 5,166 of them arrived. One was still in flight when the world froze. Not one was ever intercepted by anybody, and destroyed_by is null on every row in the table.
It is not the only thing I built that nobody touched:
| Feature | Shipped in | Used |
|---|---|---|
| Pirate supply convoys | Revision 3 | 0 of 5,167 cut |
| Player versus player raids | pre-launch | 4 battles, all month |
| Standing order board | pre-launch | 5 orders, 3 filled |
| Rally beacons | Revision 1 | 9 beacons, 2 commanders each |
| Trade Hub building | pre-launch | built on 27 planets, never upgraded past level 1 |
| Finite deposits | Revision 2 | 2 planets ever drained dry |
The rally number is the one that stings, because Revision 1 was the revision I was most pleased with. Nine beacons went up across the month and they drew eighteen participants in total, which is two commanders per beacon: the owner, and one friend who showed up.
Meanwhile the guild exchange, which is a vending machine with no other human in it, took 145 trades. Given the choice between coordinating with a person and trading with a wall, eighteen people picked the wall thirty times more often.
The King outlived the game
The Pirate King was meant to be the season finale. He rises once the Dominion holds three or more strongholds, eats one planet at a time, heals as he moves, and collapses every den to 40% strength when he falls.
He rose at Vellox-322 on 19 August with a strength of 500. Rallies drove at him twice and were driven back both times. When I froze the world a month later he was still holding court, worn down to 453, which is to say the entire galaxy managed to chip 9% off him and then stopped trying.
He is in the archive with defeated_at still null. The finale never fired.
Ten million units of nothing to spend
The other end of the same problem. At shutdown the planets held 3.72 million alloy, 3.80 million crystal and 2.52 million fuel, sitting in stockpiles with nowhere to go.
The tell is in the buildings. Mines reached level 17 and averaged level 8.9 across 41 planets. Research labs averaged level 2.2. The mines were the thing that made the number go up, so the mines got everything, and by the back half of the month the economy was producing far more than the fun could absorb. A stack of corvettes does not cost enough to drain three million alloy.
Nine of the eighteen finished with zero ships and under 150 points. Of the rest, one commander launched 299 of the galaxy’s 880 missions, a third of everything that ever flew. Seven never launched one.
And for the last six days the game produced exactly eleven reports a day, every one of them a pirate raid resolving against an empire whose owner had stopped opening the app. The cron kept pulsing, the dens kept growing, the raids kept landing, and the reports kept filing themselves into inboxes nobody read. At the freeze, 149 dens were alive with 8,134 raiders garrisoned between them, against 9,195 player ships that were mostly not being flown. The pirates were winning on both sides of the ledger: they were growing, and we had stopped showing up.
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,488 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.
Kanto
So I closed it. Not because it broke, and not because it cost anything: it cost nothing, and it would have run on that free tier until Cloudflare changed their pricing. I closed it because the last six days of it were a cron job raiding empty planets, and because the interesting part of the experiment was over and I wanted to run the next one.
Every empire is archived. The handbook stays up as a record of how the galaxy worked. Every commander got the numbers above as a card in their chat, which is the one part of building on Telegram I will never stop being pleased about: the game ends with a message in the same thread where it started.
And the next one opens tonight at 19:00 Lisbon. Pokégram is the same bot-sized shape pointed at a much more famous world: Kanto, a party of six, every trainer between you and the Champion. Same stack, same free tier, same vibecoded-on-a-whim origin, one twist I am not going to spoil here.
Spacegram’s eighteen are not starting from nothing. Service is remembered, so all eighteen can pick the Spaceboy suit on the trainer-look picker, locked to everybody who never flew.
It is not only a skin. Wearing it, Mew can turn up in the wild, on any route, in any grass, on any water, until you catch one. Eighteen people have a sprite nobody else can wear and a legendary nobody else can meet, because they spent a month sending corvettes at pirates for me.
The bot is @pokegram_gbot. Thank you for flying. See you in Kanto.
Screenshots are from a local instance running a copy of the live galaxy, with commander names changed. The numbers come from the shutdown archive.
spacegram closed this morning. ran 32 days, 18 commanders, servers went quiet at 10:17 UTC with every empire archived and nothing deleted. numbers first, architecture after
18 people signed up. 7 opened it once and never did a single thing. the 11 who actually played peaked at 10 active in a week on aug 29. by mid sept the last 7 days had exactly one of them in it
thats the whole story of a small game. rest of this is how it works
worth saying up front because it explains the pace: i didnt really write this. spacegram was vibecoded start to finish with opus 5 and fable 5, on a vacation. design doc aug 15, first commander aug 16, four revisions in the ten days after, closed a month later. engine, galaxy gen, combat sim, the DO alarm loop, the tests, the art pipeline, the handbook, all out of that. i picked the constraints and the mechanics and said no a lot. typing wasnt the bottleneck
what it is
ogame in a chat app. homeworld, queue mines and power plants and a shipyard, tech tree, build ships, send fleets into a galaxy nobody has charted. real time timers. you close the app, the bot pings you when the building lands or the fleet gets home
galaxy comes out of one world seed so a system only becomes a db row when somebody actually flies there. everything else is a hash of the coords, same function server and client. the map youre panning is mostly local maths
thats a late empire. mines at 16, planet dry on all three. favourite bit of the whole design honestly: planets hold a finite surveyed amount of alloy/crystal/fuel and once its dug out its gone. homeworlds get no exemption. expand or starve
combat is rounds based, seeded off the mission id, so deterministic and testable. every battle folds open to damage and hulls lost per round. you can tell a rout from a near thing
rest is the usual furniture. tech tree, shipyard, fleet screen, guild market that swings -75% to +300% and moves every time somebody trades
why telegram
web app funnel is: read tweet, click link, marketing page, sign up, password, check email, confirm, log in, ok now youre finally looking at the thing. every step loses somebody. telegram funnel is: tap link in chat, youre playing
no install. mini app is a web view inside a client they already have open. no app store, no review queue, no download. i deploy with wrangler deploy and everyone has it on the next tap
auth is free and real. telegram hands the webview a signed initData blob, server hmacs it with the bot token on every request. no session storage, no login screen, nothing to log into. first verified request builds your homeworld
the timers only work because the bot can interrupt you. an async game is just timers and a timer is worthless if the player has to remember to come back. on the web thats push permissions and service workers and a browser that throttles you anyway. here the DO that resolved your events calls sendMessage and you get a normal chat message
🏗 [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!
rule i wrote down early and never broke: reports are the truth, messages are a courtesy. best effort, retries with backoff, gives up after 3, nothing depends on a message landing
no invite system either, chat app already has one. you forward the bot to a group. thats the entire growth mechanic which is also exactly why that graph looks like that
changelogs go straight in the chat too. wrote 3, ran a script, everyone who ever played got a formatted message
what telegram doesnt fix is retention. one tap in means you also get people in who were never going to play. 7 of my 18
the whole thing is free
one worker. api, bot webhook, cron, static assets of the react app. one DO class EmpireDO, one instance per player keyed on telegram user id. one d1
the storage split is the decision everything else falls out of. DO knows “what is this players state right now”, authoritative and hot. d1 answers cross player stuff, galaxy index, reports, standings, dens. d1 is a projection, not a backup anything depends on
load bearing rule: anything that decides an outcome lives in a pure ts package with zero cloudflare imports. worker only orchestrates. balance tests run in plain vitest and the client renders the galaxy with the same function the server uses
the one constraint
d1 free gives you 100k row writes a day. rows, not requests. that one number decided the tick architecture before i wrote any of it
naive version loops every minute updating everyones resources. 100 players = 144k writes a day just incrementing counters, over the limit before anyone has played. so spacegram never ticks
resources are a snapshot + timestamp + rate. current alloy is snapshot + rate × elapsed computed on demand, same function server and browser, which is why the bar counts up while you sit there. nothing written
writes happen when an event resolves or you commit an action. action computes its finish time, appends an event, DO sets one alarm to the earliest. alarm fires, accrue to now, pop everything due, resolve through the pure engine, persist, then one batched transaction to d1. then telegram, then next alarm
universe doesnt run for absent players, it catches up when someone addresses it. only global actor is a cron every 5 min that picks who the pirates hit and pokes a handful of DOs
maths up front: hyperactive player does ~100 events a day = 200-300 row writes = about 300 daily actives before i have to think about it
what it actually used
aug 16 to sept 16 the worker served 107,712 requests. free plan is 100k a day. the whole life of the game is about one day of the allowance
busiest day was aug 22 at 46,200. wasnt players though, was a bug i shipped that day hammering the api in a loop. that one day is 42.9% of every request the game ever served
take it out and its honest: busiest real day was 8,700 on aug 21, 8.7% of one days allowance, and the whole month minus my bug is 61,512. players never got near the ceiling. only thing that ever threatened the free tier was me
d1 wrote 157,807 rows in the month against 100k a day. worst day 10,642
| Resource | Free plan | Busiest day | Whole month |
|---|---|---|---|
| Worker requests | 100,000 / day | 46,200 | 107,712 |
| D1 rows written | 100,000 / day | 10,642 | 157,807 |
| D1 rows read | 5,000,000 / day | 972,818 | 10,037,969 |
| Durable Object requests | 100,000 / day | 14,340 | 89,240 |
one weird one though. median cpu was under 2ms in launch week and past 25ms by mid sept while daily requests fell from 46,200 to under 300. nothing got slower, the mix changed. once players stop opening the app the only traffic left is the cron pulse, the most expensive thing the worker does. when the humans leave the average request is a robot doing bookkeeping
loose end: free plan is 10ms cpu per invocation, cron and alarms included, and those medians are well past it. also 594 errors over the month, none before aug 29, all in small clusters after, roughly when the dominion started moving hundreds of convoys. havent chased it. resolution is idempotent so a failed pulse just means the pirates are late. if this were a real product thats the thread id pull first
four revisions in ten days
aug 16 shipped the plan: build, research, explore, get raided. everything interesting came after, in four revisions over ten days
rev 1, the anchor. ship with no guns and no hold whose only job is planting a rally beacon the whole galaxy can see. pick a target and a 6/12/24h window, anchor parks halfway, anyone taps the beacon and sends ships. window closes, everyone flies the last leg together at the slowest hulls pace and fights as one fleet. turned a solo game into one where people talked to each other
rev 2, finite worlds. planets hold a surveyed amount and run dry permanently. plus capital relocation, a tracking station watching a zone for other commanders fleets, a stealth escort that hides a convoy from exactly that, and guild trades that became convoys you can intercept
rev 3, the dominion. 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 crawling between systems in the open, race one to an ambush point and the cargo is yours. a colony sharing a system with a living den pays tribute into its hoard, and the hoard goes to whoever lands the killing blow
those numbers are real. across the month the players killed 9,841 raiders, razed 74 dens, charted 65 systems, while the dominion moved 5,167 supply convoys between its own
rev 4, quiet beacons. mostly polish. rally joins stopped eating a mission slot, beacons could be private, strike odds started reading the live garrison instead of an estimate
interface moved with it. same screen on rev 3 and today


both came out of playing it on a phone and being annoyed. planet header was a paragraph you had to read, now its chips. shipyard would happily let you queue a fifth anchor without mentioning four were already docked ten metres away. nav bar grew text labels because six emoji in a row is a guessing game
post mortem
closing it meant dumping it. archive is 23 tables and 7533 rows of d1 plus the DO storage for all 18 empires, which is the half d1 never mirrored. fleets, queues, missions in flight, surveyed systems, every event log
order mattered more than i thought. dumped at 01:22, deployed the shutdown, found out two commanders had played in between so the first dump was already wrong. freeze first, dump second
then i read it and the archive turned out to be a harsher design review than anything i wrote while building the thing
one ship won
8 hulls shipped. scout boosts discovery, courier runs blockades, hauler and freighter carry, shadow cloaks, anchor plants beacons, colony ship settles. three quarters of every ship alive at the end was a corvette
top commander finished with 3027 corvettes out of 4306 ships. not a fleet, a wall. corvette is the cheapest thing that can fight and in a galaxy where the main verb is “send ships at pirates and get them back” thats the whole spec
fix isnt nerfing it. fix is nothing else was ever required. no fight a stack of corvettes couldnt answer, so no reason to bring anything specific, so 8 hulls collapsed into 1
the galaxy rewrote my balance file
expeditions roll an outcome table. loot, nothing, ambush, derelict, discovery. i set the weights per duration in balance/expeditions.ts and tested the distribution against them. passed
across the 420 expeditions that actually rolled the table, ambushes fired 30% against a designed 18.4%. on long runs 47.8% against 25%, and loot on those collapsed from 30% to 9%
nothing wrong with the roll. two multipliers i wrote did it: pirateRiskMultiplier weights ambushes 2.5x when the destination has pirate activity, pirateCargoLure scales with how fat the convoy is. then rev 3 made the pirates a faction that grows and founds dens, so more and more of the map counted as “has pirate activity”. long expeditions send the biggest fleets the furthest so they ate it
balance file said one thing, world state said another, world state won. my test couldnt catch it, it tested the table against an empty galaxy
nobody cut a single convoy
rev 3’s headline was supply lines you can sever. radar shows the convoys, you race one to an ambush point, take the cargo before the den gets it. wrote a changelog paragraph about it. drew the flow twice
the dominion moved 5167 convoys. 5166 arrived. one was still flying when the world froze. not one was ever intercepted and destroyed_by is null on every row
not the only thing nobody touched:
| Feature | Shipped in | Used |
|---|---|---|
| Pirate supply convoys | Revision 3 | 0 of 5,167 cut |
| Player versus player raids | pre-launch | 4 battles, all month |
| Standing order board | pre-launch | 5 orders, 3 filled |
| Rally beacons | Revision 1 | 9 beacons, 2 commanders each |
| Trade Hub building | pre-launch | built on 27 planets, never upgraded past level 1 |
| Finite deposits | Revision 2 | 2 planets ever drained dry |
rally number stings most because rev 1 was the one i was proudest of. 9 beacons went up all month, 18 participants total, so 2 commanders per beacon. the owner and one friend
meanwhile the guild exchange, a vending machine with no human in it, took 145 trades. given the choice between coordinating with a person and trading with a wall, 18 people picked the wall 30x more often
the king outlived the game
pirate king was the season finale. rises once the dominion holds 3+ strongholds, eats a planet at a time, heals as he moves, collapses every den to 40% when he falls
rose at vellox-322 on aug 19 with strength 500. rallies drove at him twice, driven back both times. when i froze the world a month later he was still there, worn to 453. whole galaxy chipped 9% off him then stopped trying
hes in the archive with defeated_at still null. finale never fired
10 million units of nothing to spend
other end of the same problem. at shutdown the planets held 3.72m alloy, 3.80m crystal, 2.52m fuel, sitting there with nowhere to go
tell is in the buildings. mines hit 17 and averaged 8.9 across 41 planets. research labs averaged 2.2. mines made the number go up so mines got everything, and by the back half of the month the economy produced more than the fun could absorb. a stack of corvettes doesnt cost enough to drain 3m alloy
9 of 18 finished with 0 ships and under 150 points. of the rest, one commander launched 299 of the galaxy’s 880 missions, a third of everything that ever flew. 7 never launched one
and for the last 6 days the game produced exactly 11 reports a day, every one a pirate raid resolving against an empire whose owner had stopped opening the app. cron kept pulsing, dens kept growing, reports kept filing themselves into inboxes nobody read. at the freeze 149 dens were alive with 8134 raiders between them against 9195 player ships that mostly werent being flown. pirates were winning on both sides: they were growing and we had stopped showing up
what the graph says
infra was never the problem. i designed carefully around a 100k row a day write budget and 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
18 people is what you get when the only distribution is the share button in a chat app. 7 never played, which is what a funnel that cheap costs you. the 11 who did stayed a median of 11 days, 5 were still launching fleets two weeks in
no marketing, no app store listing, so im not going to pretend thats a failure. the loop works. people built empires, formed rallies, fought each other 4 times, one commander finished on 23,488 points and 4 planets. finished enough to play and cheap enough to leave running forever, which is more than most side projects get
one thing to take: telegram gives you a one tap funnel, cloudflare gives you a free backend, neither gives you players. those look adjacent and theyre not the same problem
kanto
so i closed it. not because it broke and not because it cost anything, it cost nothing and would have run on that free tier forever. closed it because the last 6 days were a cron job raiding empty planets and the interesting part of the experiment was done
every empire archived, handbook stays up as a record of how the galaxy worked. every commander got those numbers as a card in their chat, which is the part of building on telegram i’ll never stop liking: the game ends with a message in the same thread it started in
next one opens tonight 19:00 lisbon. pokégram, same bot sized shape pointed at a much more famous world. kanto, party of six, every trainer between you and the champion. same stack, same free tier, same vibecoded on a whim, one twist im not spoiling here
spacegram’s 18 arent starting from nothing. service is remembered so all 18 can pick the spaceboy suit on the trainer look picker, locked to everybody who never flew
not just a skin either. wearing it mew can turn up in the wild, any route, any grass, any water, until you catch one. 18 people have a sprite nobody else can wear and a legendary nobody else can meet, because they spent a month sending corvettes at pirates for me
bot is @pokegram_gbot. thanks for flying. see you in kanto
screenshots are from a local instance running a copy of the live galaxy, commander names changed. numbers are from the shutdown archive
Spacegram closed this morning. It ran for thirty-two days, carried eighteen commanders, and the servers went quiet at 10:17 UTC with every empire archived and nothing deleted. It was an OGame-style strategy game running as a Telegram Mini App on Cloudflare’s free tier. Here is what a month of it looked like, and what the shutdown archive says about it.
Eighteen people signed up. Seven never took a single action. The eleven who played peaked at ten active in a week on 29 August, and by mid September the previous seven days contained one of them.
One thing worth stating up front, because it explains the pace: I did not write most of this. Spacegram was vibecoded from start to finish with Opus 5 and Fable 5, over a vacation. Design document on 15 August, first commander on the 16th, four revisions across the following ten days, closed a month later. The engine, galaxy generator, combat simulation, Durable Object alarm loop, tests, art pipeline and handbook all came out of that process. My contribution was choosing the constraints and the mechanics, and rejecting a great deal.
What it is
You get a homeworld, queue mines and power plants and a shipyard, research a tech tree, build ships, and send fleets into a procedurally generated galaxy. Timers run in real time, and the bot messages you when a building finishes or a fleet returns.
The galaxy is generated from one world seed. A system becomes a database row only when somebody first flies to it; everything else is a hash of the coordinates, computed by the same function on the server and in the browser.
Planets hold a finite, surveyed amount of alloy, crystal and fuel. Mines draw it down, and when a resource reaches zero that planet is dry on it permanently. Homeworlds are not exempt.
Combat is a rounds-based simulation seeded from the mission id, so it is deterministic and testable. Each report folds open into damage dealt and hulls lost per round.
The remaining screens are a tech tree, a shipyard, a fleet view, and a guild market whose prices move between minus 75% and plus 300% and shift with every trade.
Why Telegram is the best onboarding funnel I have ever shipped against
A web app funnel runs: link, marketing page, sign up, password, email confirmation, log in, product. Each step loses people. The Telegram funnel is one tap in a chat.
There is no install. A Mini App is a web view inside a client the player already has, so there is no app store, no review queue and no download. Deploying is wrangler deploy, and everybody has the new build on their next tap.
Authentication is included. Telegram passes the web view a signed initData blob, and the server verifies an HMAC of it with the bot token on every request. That is one Web Crypto call and no session storage, and it yields a verified user id and display name. Spacegram has no login screen. The first verified request creates the player’s homeworld.
Notifications carry the game loop. An async strategy game is made of timers, and a timer is useless if the player has to remember to return. The Durable Object that resolves a player’s events calls sendMessage directly, and the player receives an ordinary chat message.
🏗 [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: reports are the truth, messages are a courtesy. Delivery is best effort, it retries with backoff and stops after three attempts, and nothing in the game depends on a message arriving.
There is no invite system, because the chat client provides one. Forwarding the bot to a group is the whole distribution mechanism.
Release notes ship as messages. Three changelogs went out through a script that sent each one to every player as formatted Telegram HTML, with no review process and no mailing list.
Telegram does not solve retention. A one-tap funnel also admits people who were never going to play: seven of the eighteen.
The whole thing runs on free
One Worker serves the API, the bot webhook, the cron trigger and the app’s static assets. One Durable Object class, EmpireDO, has one instance per player keyed by Telegram user id. One D1 database.
The two storage layers answer different questions. The Durable Object holds the player’s exact current state: resources, building levels, tech levels, queues, fleets in flight and pending events. D1 answers anything that crosses players: the galaxy index, reports, standings, the bounty board and den state. D1 is a projection of Durable Object state, refreshed on meaningful events, and nothing depends on it as a backup.
Underneath both: every rule that decides a game outcome lives in a pure TypeScript package with no Cloudflare imports. That covers resource accrual, cost curves, the combat simulation, the expedition resolver and galaxy generation. The Worker only orchestrates. As a result the balance tests run in plain vitest, the client renders the galaxy with the same function the server uses, and tuning numbers never touches a request handler.
The one constraint that shaped everything
D1’s free plan allows 100,000 row writes per day. That number decided the tick architecture.
A loop that updates every player’s resources once a minute costs 144,000 writes a day at 100 players, purely to increment counters, which exceeds the limit before anyone has played. Spacegram therefore has no tick.
Resources are stored as a snapshot, a timestamp and a rate. Current alloy is snapshot + rate × elapsed, computed on demand by the same function on both sides, which is also how the resource bar counts up without any request. Nothing is written.
Writes occur when an event resolves or a player commits an action. An action computes its completion time and appends an event, and the object sets a single alarm to the earliest one. On the alarm the object accrues resources to now, pops every due event, resolves each through the pure engine, persists its own state, and flushes one batched D1 transaction covering the planet snapshot, techs, points and report rows. It then sends notifications and sets the next alarm.
An idle player with an empty queue has no alarm and the object hibernates at no cost. The only global actor is a cron trigger every five minutes that reads a bounded slice of recently active players, applies the pirate threat rules and pokes a handful of objects.
The budget estimate before building: a hyperactive player produces around 100 events a day, or 200 to 300 row writes, which allows roughly 300 daily active players under the ceiling.
What it actually used
Between 16 August and 16 September the Worker served 107,712 requests against a ceiling of 100,000 per day. The whole month is about one day of the allowance.
The busiest day was 22 August at 46,200 requests, which was not load: it was a bug I introduced that day, calling the API in a loop. That single day accounts for 42.9% of every request the game served.
Excluding it, the busiest day of real play was 8,700 requests on 21 August, or 8.7% of a single day’s allowance, and the month totals 61,512. Player traffic never approached the ceiling.
D1 wrote 157,807 rows over the month, with a worst day of 10,642, a tenth of one day’s budget. Reads totalled 10.04 million against 5 million per day. Durable Objects handled 89,240 requests and the Worker made 32,130 outbound subrequests.
| Resource | Free plan | Busiest day | Whole month |
|---|---|---|---|
| Worker requests | 100,000 / day | 46,200 | 107,712 |
| D1 rows written | 100,000 / day | 10,642 | 157,807 |
| D1 rows read | 5,000,000 / day | 972,818 | 10,037,969 |
| Durable Object requests | 100,000 / day | 14,340 | 89,240 |
One figure moved unexpectedly. Median CPU per invocation was under 2ms during launch week and above 25ms by mid September, while daily requests fell from 46,200 to under 300. The workload did not slow down; its composition changed. Once players stopped opening the app, nearly all remaining traffic was the five-minute cron pulse, which is the Worker’s most expensive operation.
That leaves an open question. The free plan documents 10ms of CPU per invocation for HTTP requests, cron triggers and Durable Object alarms alike, and those medians exceed it. The Worker also logged 594 errors across the month, none before 29 August and all in small daily clusters afterwards, which is approximately when the Dominion began moving hundreds of convoys per pulse. I have not investigated it. Event resolution is idempotent, so a failed pulse only delays the pirates.
Four revisions in ten days
The 16 August build was the planned scope: build, research, explore, and defend against pirate raids. Four revisions followed over ten days.
Revision 1 added the Anchor, a ship with no weapons and no cargo hold whose only function is planting a rally beacon visible to the whole galaxy. The owner picks a target and a window of 6, 12 or 24 hours. The anchor parks at a staging point halfway to the target, other commanders send ships to the same point, and when the window closes the combined force flies the final leg together at the slowest hull’s speed and fights as one fleet.
Revision 2 made worlds finite. It also added capital relocation to any safe colony, a Tracking Station that watches a zone for other commanders’ fleets, a stealth escort that conceals a convoy from exactly that, and guild trades that became interceptable convoys instead of instant swaps.
Revision 3 turned the pirates into a faction. Each den near settled space is a garrison that grows, climbs tiers and launches its own raids, so the nearest den is the one attacking you, and razing it stops those raids. Dens reinforce each other with convoys that travel visibly between systems, and a player can race one to an ambush point and take its cargo. A colony sharing a system with a living den pays tribute into that den’s hoard, and the hoard transfers to whoever destroys it.
Over the month the players killed 9,841 raiders, razed 74 dens and charted 65 systems, while the Dominion moved 5,167 supply convoys between its own dens.
Revision 4 was mainly polish: joining a rally no longer consumes a mission slot, beacons can be private, and strike odds read the live garrison rather than an estimate.
The interface changed alongside it. The same screen on the Revision 3 build and on the current one:


Both changes came from playing on a phone. The planet header was a paragraph and became a row of chips. The shipyard would accept a fifth Anchor without mentioning the four already docked at that planet. The navigation bar gained text labels under the icons.
The post mortem
Closing the game meant dumping it. The archive is 23 tables and 7,533 rows of D1 plus the Durable Object storage for all eighteen empires, which is the half D1 never mirrored: fleet compositions, queues, in-flight missions, surveyed systems and every player’s event log. The order matters. I took a dump at 01:22, deployed the shutdown, and found that two commanders had played in between, so the first dump was already stale. Deploy the freeze first, dump second.
Reading it produced a harsher design review than anything I wrote while building.
One ship won
Eight hulls shipped, each with a distinct job: the scout boosts discovery, the courier runs blockades, the hauler and freighter carry cargo, the shadow cloaks a convoy, the anchor plants a beacon, the colony ship settles. Three quarters of every ship alive at shutdown was a corvette, and the top commander held 3,027 of them out of 4,306 ships.
The corvette is the cheapest hull that can fight, and the game’s main verb was sending ships at pirates and getting them back. The problem is not that the corvette is too strong. It is that no encounter ever required anything else, so there was never a reason to bring a specific hull, and eight options collapsed into one.
The galaxy rewrote my balance file
Expeditions roll an outcome table: loot, nothing, an ambush, a derelict, a discovery. The weights are set per duration in balance/expeditions.ts and the distribution test passed against them.
Across the 420 expeditions that rolled the table, ambushes fired 30% of the time against a designed 18.4%. On long expeditions it was 47.8% against 25%, and loot on those runs fell from 30% to 9.0%.
The roll was correct. Two deliberate multipliers caused it: pirateRiskMultiplier raises ambush weight 2.5 times at destinations with pirate activity, and pirateCargoLure scales it with cargo. Revision 3 then made the pirates a faction that grows and founds new dens, so an increasing share of the map qualified as having pirate activity, and long expeditions carry the most cargo the furthest. The test could not catch this, because it evaluated the table against an empty galaxy.
Nobody cut a single convoy
Revision 3’s headline feature was severable supply lines: dens reinforce each other with convoys visible on radar, and a player can race one to an ambush point and take its cargo.
The Dominion moved 5,167 convoys. 5,166 arrived, one was in flight at the freeze, and none were ever intercepted; destroyed_by is null on every row.
It was not the only unused system:
| Feature | Shipped in | Used |
|---|---|---|
| Pirate supply convoys | Revision 3 | 0 of 5,167 cut |
| Player versus player raids | pre-launch | 4 battles, all month |
| Standing order board | pre-launch | 5 orders, 3 filled |
| Rally beacons | Revision 1 | 9 beacons, 2 commanders each |
| Trade Hub building | pre-launch | built on 27 planets, never upgraded past level 1 |
| Finite deposits | Revision 2 | 2 planets ever drained dry |
Nine rally beacons went up across the month and drew eighteen participants in total, which is two commanders per beacon. Meanwhile the guild exchange, which involves no other player, took 145 trades.
The King outlived the game
The Pirate King was the intended finale: he rises once the Dominion holds three strongholds, consumes a planet at a time, heals as he moves, and collapses every den to 40% strength when defeated.
He rose at Vellox-322 on 19 August at strength 500. Two rallies attacked and both were driven back. At the freeze a month later he was still in place at 453, so the galaxy removed 9% of him and stopped. His row has defeated_at null.
Ten million units of nothing to spend
At shutdown the planets held 3.72 million alloy, 3.80 million crystal and 2.52 million fuel with nothing to spend it on. Mines reached level 17 and averaged 8.9 across 41 planets; research labs averaged 2.2. The mines drove the score, so the mines took the investment, and production outran anything the game could absorb.
Nine of the eighteen finished with zero ships and under 150 points. One commander launched 299 of the galaxy’s 880 missions; seven launched none. For the final six days the game produced exactly eleven reports a day, all of them pirate raids resolving against empires nobody was opening. At the freeze, 149 dens held 8,134 raiders against 9,195 largely idle player ships.
What the graph actually says
The infrastructure was never the constraint in practice. The write budget shaped the architecture for the better, and the worst day used a tenth of it.
The retention curve is the real result. Eighteen signups is what a share button in a chat app produces. Seven never played, which is the cost of a funnel cheap enough that opening it signals nothing. The eleven who played stayed a median of eleven days, and five were still launching fleets more than two weeks after joining.
For a game with no marketing and no app store listing, that is not a failure. Players built empires, formed rallies, fought each other four times, and the top commander finished on 23,488 points across four planets. The game is complete enough to play and cheap enough to leave running indefinitely.
The conclusion worth keeping: Telegram provides a one-tap funnel and Cloudflare provides a free backend. Neither provides players, and those are separate problems.
Kanto
I closed it because the last six days were a cron job raiding empty planets, not because it broke or cost anything. It cost nothing and would have kept running.
Every empire is archived and the handbook stays up as a record of how the galaxy worked. Each commander received those numbers as a card in their chat, which is the part of building on Telegram I like most: the game ends in the same thread where it started.
The next one opens tonight at 19:00 Lisbon. Pokégram is the same bot-sized shape pointed at a more famous world: Kanto, a party of six, and every trainer between you and the Champion. Same stack, same free tier, one twist I am not spoiling here.
Spacegram’s eighteen carry something over. Service is remembered, so all eighteen can pick the Spaceboy suit on the trainer-look picker, locked to everybody who never flew.
It is not only a skin: wearing it, Mew can appear in the wild on any route until you catch one. The bot is @pokegram_gbot.
Screenshots are from a local instance running a copy of the live galaxy, with commander names changed. The numbers come from the shutdown archive.
Spacegram closed this morning. It ran for thirty-two days in total, carried eighteen commanders, and the servers went quiet at 10:17 UTC with every empire archived and absolutely nothing deleted. I could quite easily have opened this piece with an extended discussion of the architecture and never actually arrived at the numbers. It is important to note, however, that the numbers are the more honest part of the story, so the numbers will go first.
Eighteen people signed up in total. Seven of them, somewhat soberingly, opened the application exactly once, looked around at the evolving landscape of an unfamiliar galaxy, and never took a single action of any kind. The eleven who did engage peaked at ten active within a single week on 29 August, and by the middle of September the preceding seven days contained precisely one of them.
That is, in essence, the complete story of a small game, and it felt more intellectually honest to place it at the top rather than allowing it to be buried beneath a lengthy technical discussion. What follows is an exploration of how the thing was actually built, and why a full month of a live multiplayer game ultimately fit comfortably inside a single day of Cloudflare’s free tier.
One further thing is worth saying up front, largely because it goes some way towards explaining the pace: I did not, in any meaningful sense, write this. Spacegram was vibecoded from start to finish with Opus 5 and Fable 5, over the course of a vacation. Design document on the fifteenth of August, first commander on the sixteenth, four revisions across the ten days that followed, and closed a month later. The engine, the galaxy generator, the combat simulation, the Durable Object alarm loop, the tests, the art pipeline and the handbook all emerged from that process. I picked the constraints and the mechanics and said no a very great deal. The typing, it must be acknowledged, was never the bottleneck.
What it is
Spacegram is, at its core, OGame reimagined within a chat application. You are granted a homeworld, you queue up mines and power plants and a shipyard, you delve into a research tree, you construct ships, and you then begin dispatching fleets outward into a procedurally generated galaxy that nobody has yet charted. Timers run in real time. You close the application entirely, and the bot messages you when the building finishes or the fleet returns home.
The galaxy is generated from a single world seed, which means that a system only ever becomes a database row the first time somebody actually flies to it. Everything else is simply a hash of the coordinates, computed identically on both the server and in the browser. The map you find yourself panning around is, in reality, mostly local arithmetic.
That particular screenshot depicts a fairly late-stage empire. The mines have reached level 16 and the planet has run dry on all three resources, which is arguably the design decision I remain most pleased with across the entire project: planets hold a finite, surveyed quantity of alloy, crystal and fuel, and once you have extracted it, it is gone permanently. Homeworlds receive no special exemption whatsoever. You either expand or you starve.
Combat is a rounds-based simulation seeded from the mission id, which ensures it is both deterministic and testable, and every battle folds open to reveal damage dealt and hulls lost on a per-round basis. You can genuinely distinguish a rout from a near thing.
The remainder is the familiar furniture of the genre: a tech tree, a shipyard, a fleet screen displaying whatever you currently have in flight, and a guild market whose prices swing between minus 75% and plus 300% and shift with every single trade that occurs.
Why Telegram is the best onboarding funnel I have ever shipped against
I have shipped a number of web applications over the years. The funnel, in each case, runs approximately as follows: read the tweet, click the link, land on a marketing page, click sign up, select a password, check your email, confirm, log in, and now, finally, you are looking at the actual product. Every one of those steps represents an opportunity to lose somebody.
The Telegram funnel, by contrast, is this: tap a link in a chat, and you are playing.
There is no install whatsoever. A Mini App is fundamentally a web view operating inside a chat client that the player already has open. There is no app store, no review queue and no 40MB download to contend with. My deployment process is wrangler deploy, and everybody receives the new version on their next tap.
Authentication comes for free, and it is genuine authentication. Telegram hands the web view a signed initData blob. The server then verifies an HMAC of that blob using the bot token on every single request, a process which requires precisely one Web Crypto call and no session storage of any kind, and what emerges at the other end is a verified user id and display name. There is no login screen in Spacegram because there is fundamentally nothing to log in to. The first verified request creates your homeworld.
The timers only function because the bot has the ability to interrupt you. An asynchronous strategy game is, when you get down to it, nothing but timers, and a timer is essentially worthless if the player is required to remember to come back of their own accord. On the open web this necessitates push permissions, service workers, and a browser that will throttle you regardless. Here, the Durable Object that has just resolved your events simply calls sendMessage, and the player receives an ordinary chat message from a bot they already converse with. No permission prompt, no additional 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 committed to early and never subsequently broke: reports are the truth, messages are a courtesy. Delivery is best effort, it retries with backoff and ultimately gives up after three attempts, and crucially nothing in the game depends on a message actually arriving. The in-application report always exists.
There is no invite system, for the simple reason that a chat application already provides one. You forward the bot to a group. That is the entirety of the growth mechanism, and it is also, it must be acknowledged, precisely why the graph at the top of this page looks the way it does.
Release notes go directly into the chat. On three separate occasions I wrote a changelog and ran a script which pushed it out to every player as a formatted message. No app store review, no in-application update modal, no email list to maintain. A single invocation of node scripts/broadcast.mjs docs/changelog/rev3.html --send, and everybody who had ever played understood that the pirates had become a faction.
The thing Telegram does not, unfortunately, solve is retention. It delivers a person into the game at a cost of a single tap, which necessarily means it also delivers people who were never realistically 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 application. One Durable Object class, EmpireDO, with one instance per player keyed by Telegram user id. One D1 database.
The split between the two storage layers is ultimately the key design decision from which everything else follows. The Durable Object answers the question of what this player’s exact state is right now: resources, building levels, tech levels, queues, fleets in flight, pending events. It is authoritative and hot. D1, meanwhile, answers questions that cross between players: the galaxy index, the reports inbox, the standings, the bounty board, the locations of the pirate dens. D1 is a projection of Durable Object state, refreshed whenever something meaningful occurs, rather than a backup upon which anything actually depends.
The load-bearing rule underpinning both of these: everything that determines a game outcome resides within a pure TypeScript package carrying zero Cloudflare imports. Resource accrual, cost curves, the combat simulation, the expedition resolver, galaxy generation. The Worker exists solely to orchestrate. This is precisely why the balance tests run in plain vitest, why the client is able to render the galaxy using the identical function the server uses, and why I was able to tune numbers without touching a single request handler.
The one constraint that shaped everything
D1’s free plan permits 100,000 row writes per day. Not requests. Rows. That single number effectively decided the entire tick architecture before I had written a single line of it.
The naive implementation of a game of this nature features a loop that runs every minute and updates every player’s resources accordingly. With 100 players that amounts to 144,000 writes per day accomplishing nothing beyond incrementing counters, and you have exceeded the limit before anybody has actually played. Spacegram, therefore, never ticks at all.
Resources are stored as a snapshot plus a timestamp plus a rate. Your current alloy is snapshot + rate × elapsed, computed on demand, by the very same function on the server and within your browser, which is incidentally also why the numbers in the resource bar count upward smoothly while you sit there doing absolutely nothing. Nothing whatsoever is written.
Writes occur when an event resolves or when you commit an action. Every action computes its own completion time and appends an event, and the Durable Object sets a single alarm to the earliest of them. When that alarm fires, the object accrues resources to now, pops every event that has become due, resolves each of them through the pure engine, persists its own state, and then flushes one batched transaction to D1: the planet snapshot, techs and points, and any report rows. It subsequently sends the Telegram messages and sets the next alarm.
The universe does not run for absent players. It catches up when somebody addresses it. The only global actor is a cron trigger firing every five minutes, which reads a bounded slice of recently active players, determines who the pirates are going to target, and pokes a handful of Durable Objects accordingly.
The arithmetic I performed up front suggested that a hyperactive player generates roughly 100 events per day, which translates to 200 to 300 row writes, which in turn places approximately 300 daily active players beneath the ceiling before I would be obliged to think about it any further.
What it actually used
From 16 August through to 16 September the Worker served 107,712 requests in total. The free plan permits 100,000 per day. The entire life of the game, a full month of it, amounts to roughly one day of the allowance.
The busiest single day was 22 August, at 46,200 requests, and I would genuinely like to attribute that to rally beacons shipping and everybody finding themselves on the map simultaneously. It was, regrettably, a bug I introduced that same day which proceeded to hammer the API in a loop. That single day represents 42.9% of every request the game served across its entire life.
Remove it and the picture becomes considerably more honest. The busiest day of actual play was 8,700 requests on 21 August, which amounts to 8.7% of a single day’s allowance, and the whole month minus my own bug comes to 61,512 requests. Real players never came within an order of magnitude of the ceiling. The only thing that ever genuinely threatened the free tier was me.
D1 wrote 157,807 rows across the month, against a ceiling of 100,000 per day. The worst individual day came to 10,642, which is a tenth of a single day’s budget. Reads totalled 10.04 million over the month against 5 million per day. Durable Objects handled 89,240 requests. The Worker made 32,130 outbound subrequests.
| Resource | Free plan | Busiest day | Whole month |
|---|---|---|---|
| Worker requests | 100,000 / day | 46,200 | 107,712 |
| D1 rows written | 100,000 / day | 10,642 | 157,807 |
| D1 rows read | 5,000,000 / day | 972,818 | 10,037,969 |
| Durable Object requests | 100,000 / day | 14,340 | 89,240 |
One number did move in a genuinely interesting direction. Median CPU time per invocation sat under 2ms during launch week and had climbed past 25ms by the middle of September, while daily requests simultaneously fell from 46,200 to under 300. Nothing became slower. The mix changed. Once the players stopped opening the application, almost the only traffic remaining was the five-minute cron pulse, which happens to be the single most expensive thing the Worker does. When the humans depart, the average request becomes a robot performing bookkeeping.
There is, admittedly, a loose end in all of that. The free plan permits 10ms of CPU per invocation, applying to HTTP requests, cron triggers and Durable Object alarms alike, and those medians are comfortably past it. The Worker additionally logged 594 errors across the month, none of them prior to 29 August and all of them arriving in small daily clusters thereafter, which corresponds roughly to the point at which the Dominion began running hundreds of pirate convoys and the pulse consequently grew heavy. I have not chased it down. It has not cost anything visible, given that event resolution is idempotent and a failed pulse merely means the pirates arrive late, but were this a genuine commercial product, that is unquestionably the thread I would pull first.
Four revisions in ten days
The game that shipped on 16 August was essentially the plan as written: build, research, explore, and get raided by pirates. Everything genuinely interesting happened afterwards, across four revisions over the following ten days.
Revision 1, the Anchor. A ship possessing no guns and no cargo hold whose sole purpose is to plant a rally beacon that the entire galaxy can observe. You select the target and a window of 6, 12 or 24 hours, the anchor parks itself at a staging point halfway to its destination, and anybody at all can tap the beacon on the map and dispatch ships. When the window closes, everyone flies the final leg together at the pace of the slowest hull and fights as one unified fleet. It transformed a solo game into one where people actually spoke to one another.
Revision 2, finite worlds. The deposits change fundamentally. Planets hold a surveyed quantity of each resource and run dry permanently. Arriving alongside it: the ability to relocate your capital to any safe colony, a Tracking Station that watches a zone for other commanders’ fleets, a stealth escort designed to hide a convoy from precisely that, and guild trades which ceased being instantaneous swaps and became convoys that can be intercepted.
Revision 3, the Dominion. The pirates ceased being a spawn table and became a genuine faction. Every den situated near settled space is a garrison that grows, climbs tiers, and launches its own raids, which means the den nearest to you is the one actually hitting you. Raze it and its raids stop entirely. Dens reinforce one another using convoys that crawl between systems out in the open, and you are able to race one to an ambush point and take its cargo. A colony sharing a system with a living den pays tribute into that den’s hoard, and the hoard goes in its entirety to whoever lands the killing blow.
Those numbers are entirely real. Across the month the players killed 9,841 raiders, razed 74 dens and charted 65 systems, while the Dominion moved 5,167 supply convoys between its own.
Revision 4, quiet beacons. Largely a polish pass. Joining a rally ceased consuming one of your mission slots, beacons became capable of being private, and strike odds began reading the live garrison rather than working from an estimate.
The interface moved in step with all of it. The same screen, first on the Revision 3 build and then on today’s:


Both of those emerged from playing the game on a phone and becoming annoyed. The planet header was a paragraph you had to read through; it is now a row of chips you can absorb at a glance. The shipyard would quite happily permit you to queue a fifth Anchor without bothering to mention that four were already docked ten metres away. And the navigation bar acquired text labels beneath the icons, for the straightforward reason that six emoji in a row constitutes a guessing game.
The post mortem
Closing the game necessarily meant dumping it. The resulting archive amounts to 23 tables and 7,533 rows of D1, alongside the Durable Object storage for all eighteen empires, which constitutes the half that D1 never mirrored: fleet compositions, queues, in-flight missions, surveyed systems, and every individual player’s event log.
The ordering mattered rather more than I had anticipated. I took a dump at 01:22, deployed the shutdown, and subsequently discovered that two commanders had played in the intervening window, which meant the first dump was already incorrect. Deploy the freeze first, and dump second.
And then I read it, and the archive ultimately turned out to constitute a considerably harsher design review than anything I had written across the ten days I spent building the thing.
One ship won
Eight hulls shipped, and each one of them had a clearly defined job. The scout boosts discovery, the courier runs blockades, the hauler and the freighter carry cargo, the shadow cloaks a convoy, the anchor plants a beacon, and the colony ship settles a planet. Three quarters of every ship alive at the end was, nonetheless, a corvette.
The top commander finished with 3,027 corvettes out of a total of 4,306 ships. That is not really a fleet so much as a wall. The corvette is the cheapest thing available that is capable of fighting, and in a galaxy where the principal verb is “send ships at pirates and get them back again”, cheap and fights is essentially the entire specification. Everything else I painstakingly balanced amounted to a rounding error against that reality.
I do not, however, believe the appropriate fix is nerfing the corvette. The fix is that nothing else was ever genuinely required. There existed no encounter that a sufficiently large stack of corvettes could not answer, which meant there was never any reason to bring something specific, and eight hulls duly collapsed into one.
The galaxy quietly rewrote my balance file
Expeditions roll an outcome table: loot, nothing, an ambush, a derelict to salvage, or a discovery. I set those weights per duration in balance/expeditions.ts and tested the resulting distribution against the configured numbers, and that test passed.
Across the 420 expeditions that actually rolled that table, ambushes fired 30% of the time against a designed 18.4%. On long expeditions specifically it reached 47.8% against a designed 25%, while loot on those same long runs collapsed from 30% down to 9.0%.
Crucially, nothing was wrong with the roll itself. Two multipliers that I wrote entirely deliberately produced this outcome: pirateRiskMultiplier weights ambushes 2.5 times higher whenever the destination has pirate activity, and pirateCargoLure scales ambush weight according to how fat the convoy happens to be. Revision 3 then turned the pirates into a faction that grows and founds new dens, which meant that as the month progressed, an ever-increasing proportion of the map counted as having pirate activity. Long expeditions send the largest fleets the furthest distances, which makes them simultaneously the most cargo-heavy and the most den-exposed.
The balance file said one thing, the world state said another, and the world state won. My distribution test could never have caught it, for the simple reason that it tested the table in isolation against an entirely empty galaxy.
Nobody cut a single convoy
Revision 3’s headline was supply lines that you could sever. Dens reinforce one another using convoys that crawl between systems out in the open, radar reveals them, and you race one to an ambush point in order to take its cargo before the receiving den ever gets it. I wrote an entire paragraph of changelog about it. I drew the interception flow twice.
The Dominion moved 5,167 supply convoys. 5,166 of them arrived. One was still in flight at the moment the world froze. Not a single one was ever intercepted by anybody at all, and destroyed_by is null on every row in the table.
It is not, regrettably, the only thing I built that nobody ever touched:
| Feature | Shipped in | Used |
|---|---|---|
| Pirate supply convoys | Revision 3 | 0 of 5,167 cut |
| Player versus player raids | pre-launch | 4 battles, all month |
| Standing order board | pre-launch | 5 orders, 3 filled |
| Rally beacons | Revision 1 | 9 beacons, 2 commanders each |
| Trade Hub building | pre-launch | built on 27 planets, never upgraded past level 1 |
| Finite deposits | Revision 2 | 2 planets ever drained dry |
The rally number is the one that genuinely stings, because Revision 1 was the revision I had been most pleased with. Nine beacons went up across the month and they drew eighteen participants in total, which works out at two commanders per beacon: the owner, and one friend who showed up.
Meanwhile the guild exchange, which is functionally a vending machine with no other human being in it, took 145 trades. Given a choice between coordinating with a person and trading with a wall, eighteen people chose the wall roughly thirty times more frequently.
The King outlived the game
The Pirate King was intended to serve as the season finale. He rises once the Dominion holds three or more strongholds, consumes one planet at a time, heals as he moves between them, and collapses every den to 40% strength upon his defeat.
He rose at Vellox-322 on 19 August with a strength of 500. Rallies drove at him on two occasions and were driven back both times. When I froze the world a month later he remained in place, holding court, worn down to 453, which is to say that the entire galaxy collectively managed to chip 9% off him and then stopped trying altogether.
He sits in the archive with defeated_at still null. The finale never fired.
Ten million units of nothing to spend
This is the opposite end of precisely the same problem. At shutdown the planets held 3.72 million alloy, 3.80 million crystal and 2.52 million fuel, sitting in stockpiles with absolutely nowhere to go.
The tell is in the buildings. Mines reached level 17 and averaged level 8.9 across 41 planets. Research labs averaged level 2.2. The mines were the thing that made the number go up, so the mines received everything, and by the back half of the month the economy was producing considerably more than the available fun could possibly absorb. A stack of corvettes does not cost nearly enough to drain three million alloy.
Nine of the eighteen finished with zero ships and fewer than 150 points. Of the remainder, one commander launched 299 of the galaxy’s 880 missions, which is a third of everything that ever flew. Seven never launched a single one.
And for the final six days the game produced exactly eleven reports per day, every one of them a pirate raid resolving against an empire whose owner had long since stopped opening the app. The cron kept pulsing, the dens kept growing, the raids kept landing, and the reports kept dutifully filing themselves into inboxes that nobody was reading. At the freeze, 149 dens were alive with 8,134 raiders garrisoned between them, set against 9,195 player ships that were largely not being flown at all. The pirates were, in effect, winning on both sides of the ledger simultaneously: they were growing, and we had stopped showing up.
What the graph actually says
The infrastructure was never, at any point, the problem. That is the honest summary of the free tier section above: I designed carefully around a budget of 100,000 row writes per day and subsequently used 10% of it on the worst day I ever experienced. The constraint was real, it genuinely improved the architecture, and it was never once close to actually binding.
The problem is the red line.
Eighteen people is what you ultimately get when the sole distribution mechanism is the share button within a chat application. Seven of them never played, which represents the cost of a funnel so inexpensive that opening it fails to signal anything at all. 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 had joined.
For a game carrying no marketing and no app store listing, I am not going to pretend that constitutes a failure. The loop works. People built empires, formed rallies, fought each other four separate times, and one commander finished with 23,488 points across four planets. The thing is finished enough to play and cheap enough to leave running indefinitely, which is considerably more than most side projects ever achieve.
But if you take a single thing away from this: Telegram provides you with a one-tap funnel and Cloudflare provides you with a free backend, and neither of them provides you with players. Those two problems appear adjacent and they are not remotely the same problem.
Kanto
So I closed it. Not because it broke, and certainly not because it cost anything: it cost nothing whatsoever, and it would have continued running on that free tier until Cloudflare eventually changed their pricing. I closed it because the last six days of its existence consisted of a cron job raiding empty planets, and because the genuinely interesting portion of the experiment was over and I wanted to run the next one.
Every empire has been archived. The handbook remains up as a record of how the galaxy worked. Every commander received the numbers above as a card in their chat, which is the one aspect of building on Telegram that I will never stop being pleased about: the game ends with a message in the very same thread where it began.
And the next one opens tonight at 19:00 Lisbon. Pokégram is the same bot-sized shape pointed at a considerably more famous world: Kanto, a party of six, and every trainer standing between you and the Champion. Same stack, same free tier, same vibecoded-on-a-whim origin, and one twist that I am not going to spoil here.
Spacegram’s eighteen are not, however, starting from nothing. Service is remembered, which means all eighteen of them can select the Spaceboy suit on the trainer-look picker, locked permanently to everybody who never flew.
And it is not merely a skin. Wearing it, Mew can turn up in the wild, on any route, in any grass, on any water, until such time as you catch one. Eighteen people now possess a sprite that nobody else can ever wear and a legendary that nobody else can ever meet, purely because they spent a month sending corvettes at pirates on my behalf.
The bot is @pokegram_gbot. Thank you for flying. See you in Kanto.
Screenshots are from a local instance running a copy of the live galaxy, with commander names changed. The numbers come from the shutdown archive.
🚀 In today’s fast-paced, hyper-competitive landscape of mobile gaming, have you ever stopped to ask yourself one truly profound question: what if the next great multiplayer strategy game didn’t need an app store at all? Not metaphorically. Literally. What if it lived inside a chat window you already have open, cost you absolutely nothing to run, and messaged you personally when your fleet came home? 🤯
Well, friends, buckle up, because I did exactly that. And this morning, after thirty-two days, eighteen commanders and 10:17 UTC on the clock, I turned it off. 🌌
Eighteen people signed up. Let that sink in for a moment. Eighteen human beings, each one a commander with a homeworld of their very own. Seven of them, and this is the part that genuinely humbled me, opened the game exactly once, gazed out at the vast procedural cosmos I had lovingly crafted for them, and then never took a single action ever again. 😅
The eleven who stayed? They peaked at ten active in a single glorious week on 29 August. And by the middle of September, the previous seven days contained exactly one of them. One! But here’s the thing: that single commander is still out there, still flying. And honestly? That means everything. ✨
And now for the confession that makes all of this slightly more unhinged: I did not really write this. 🤖
Spacegram was vibecoded from start to finish with Opus 5 and Fable 5, on a vacation, as a fun side project. Design doc on 15 August. First commander on the 16th. Four revisions in the ten days after that. Closed a month later. The engine, the galaxy generator, the combat simulation, the Durable Object alarm loop, the tests, the art pipeline, the entire handbook: all of it came out of that. My job was picking the constraints, choosing the mechanics, and saying no a LOT. The typing was never the bottleneck. ⚡
🎮 What Is Spacegram, Really?
Let’s take a moment to truly appreciate what we’re dealing with here. Spacegram is OGame, that legendary browser strategy titan of the early 2000s, completely reimagined for the chat era. Here’s the core loop:
- A homeworld of your own: Queue mines, power plants and a shipyard. Watch your empire rise from nothing! 🏗️
- A research tree to delve into: Unlock ships, sensors and secrets across ten branches of science.
- Real-time timers: Close the app. Live your life. The bot will find you. ⏰
- An uncharted procedural galaxy: Nobody has been there. Nobody. You are genuinely first. 🌌
And here’s a detail that I find absolutely mind-blowing every single time I think about it: the entire galaxy is generated from one world seed. ONE. A system only becomes a database row the very first time somebody actually flies there. Everything else? Pure mathematics, a hash of the coordinates, computed identically on the server and in your browser. That map you’re panning around is mostly local arithmetic. Is that elegant? I genuinely believe it is. 💎
Now, that screenshot shows a late-stage empire, and I need you to look closely, because there’s something beautiful and brutal happening there. The mines are at level 16. And the planet is dry. Completely, permanently dry on all three resources.
This is, without question, the design decision I am proudest of in the entire project. Every planet holds a finite, surveyed quantity of alloy, crystal and fuel. Mines draw it down. And when it hits zero, that planet is dry on it forever. No respawn. No regeneration. No mercy. Homeworlds get absolutely no exemption whatsoever. 🪨
Expand or starve. That’s not a tagline. That’s the economy.
Combat deserves its own moment of appreciation. It’s a rounds-based simulation seeded from the mission id, which means it’s fully deterministic and fully testable, and every single battle folds open into a gorgeous round-by-round table of damage dealt and hulls lost. You can tell a rout from a near thing. That distinction matters! ⚔️
And the rest? The rest is the beloved furniture of the genre, executed with care:
- A tech tree: Ten branches, from Energy Systems to Stealth.
- A shipyard: Eight hulls, each with a job.
- A fleet screen: Everything you have in flight, live.
- A guild market: Prices swing from minus 75% to plus 300% and move with every single trade. Boom and bust, baby! 📈
📱 Why Telegram Is the Greatest Onboarding Funnel I Have Ever Shipped Against
Okay. Deep breath. This is the section I genuinely cannot stop thinking about.
I have shipped web apps. We all have. And we all know the funnel, don’t we? 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, at long last, you are looking at the product. Every single one of those steps is a place where a human being quietly gives up and closes the tab. 😢
The Telegram funnel? Tap a link in a chat. You are playing.
That’s it. That’s the whole thing. Let’s break down why this is such a game-changer:
- 🚫 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 deployand everybody has the new version on their next tap. Revolutionary! - 🔐 Authentication comes for free, and it is real authentication. Telegram hands the web view a signed
initDatablob. The server verifies an HMAC of it with the bot token on every single request. One Web Crypto call. Zero session storage. 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. The first verified request creates your homeworld. Chills! 🥶 - 🔔 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
sendMessageand the player gets a normal chat message from a bot they already talk to. No permission prompt. No infrastructure. Just magic. ✨
🏗 [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!
And here is the rule I wrote down early and never, ever broke, a principle I’d tattoo on my forearm if I were braver: reports are the truth, messages are a courtesy. Delivery is best effort. It retries with backoff. It gives up after three attempts. And nothing, absolutely nothing, in the game depends on a message arriving, because the in-app report always exists. That’s not just engineering. That’s respect for your players. 🙏
But wait, there’s more!
- 📤 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 yes, dear reader, that is also exactly why the graph at the top looks the way it does. We’ll get there. 😬)
- 📜 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 beautifully formatted message. No app store review. No in-app update modal. No email list. Just
node scripts/broadcast.mjs docs/changelog/rev3.html --sendand suddenly everyone who had ever played knew the pirates were now a faction. Tell me that isn’t the future! 🎺
But here’s the hard truth, and I want to be vulnerable with you for a second: 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, ever going to play. Seven of my eighteen. 💔
☁️ The Whole Thing Runs on Free (Yes, Really!)
One Worker. One Durable Object class. One D1 database. That’s the entire production footprint of a live multiplayer strategy game. Let’s marvel at it together:
The split between the two storage layers is the design decision from which absolutely everything else falls out, and understanding it is understanding the whole system:
- The Durable Object answers one question and one question only: what is this player’s exact state right now? Resources. Building levels. Tech levels. Queues. Fleets in flight. Pending events. It is authoritative. It is hot. It is the empire. 👑
- D1 answers everything that crosses players: the galaxy index, the reports inbox, the standings, the bounty board, where the pirate dens are lurking. And crucially, D1 is a projection of Durable Object state, refreshed when something meaningful happens, never a backup that anything depends on. 🗂️
And beneath both of them, the load-bearing rule, the single most important architectural commitment in the entire codebase: 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. All of it. The Worker only orchestrates.
Why does this matter so much? Because it means the balance tests run in plain vitest. It means the client renders the galaxy with the exact same function the server uses. And it means I could tune numbers all day without touching a single request handler. That’s not just clean code. That’s freedom. 🕊️
💾 The One Constraint That Shaped Absolutely Everything
Here’s a number that changed my life: 100,000.
D1’s free plan allows 100,000 row writes per day. Not requests. Rows. And that single number decided the entire tick architecture before I had written one line of it.
Think about the naive version for a moment. A loop that runs every minute and updates every player’s resources. With just 100 players, that’s 144,000 writes a day accomplishing literally nothing except incrementing counters, and you are over the limit before a single human being has played your game. Catastrophic! 💥
So Spacegram never ticks. At all. Ever.
Instead, 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. (This is also, delightfully, why the numbers in the resource bar count up smoothly while you sit there doing nothing.) And nothing, absolutely nothing, is written. 🎯
Writes happen only when an event resolves or you commit an action. Here’s the sequence, and it’s genuinely beautiful:
Every action computes its completion time and appends an event, and the Durable Object sets a single alarm to the earliest one. When that alarm fires: accrue resources to now, pop every event that’s due, resolve each one through the pure engine, persist state, and then flush one batched transaction to D1 covering the planet snapshot, techs, points and report rows. Then the Telegram messages go out. Then the next alarm gets set.
And the best part? The universe does not run for absent players. It catches up when somebody addresses it. An idle player with an empty queue has no alarm at all and their object hibernates at literally zero cost. The only global actor in the entire game 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 objects. That’s it! 🌙
My arithmetic up front: 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’d need to think about it again.
📉 So What Did It Actually Use?
Prepare yourself. 🍿
From 16 August to 16 September, the Worker served 107,712 requests. The free plan allows 100,000 per day. Read that again. The entire life of the game, a full month of it, is roughly one day of the allowance. 🤯
The busiest single day was 22 August at 46,200 requests, and oh, how I wanted that to be the glorious day rally beacons shipped and everybody flooded onto the map at once. Reader, it was not. It was a bug. My bug. Shipped by me, that very day, cheerfully hammering the API in a loop like it was being paid by the request. That one day is 42.9% of every request the game ever served. 🤦
But take it out and the truth is almost funnier: the busiest day of real human play was 8,700 requests on 21 August. That is 8.7% of a single day’s allowance. The entire month, minus my own incompetence, comes to 61,512 requests. My players never got within an order of magnitude of that ceiling. The only thing that ever threatened the free tier was me. 😅
D1 wrote 157,807 rows in the month against a ceiling of 100,000 per day. The worst day was 10,642, a tenth of a single day’s budget. Reads came to 10.04 million over the month against 5 million per day. Durable Objects handled 89,240 requests. The Worker made 32,130 outbound subrequests.
| Resource | Free plan | Busiest day | Whole month |
|---|---|---|---|
| Worker requests | 100,000 / day | 46,200 | 107,712 |
| D1 rows written | 100,000 / day | 10,642 | 157,807 |
| D1 rows read | 5,000,000 / day | 972,818 | 10,037,969 |
| Durable Object requests | 100,000 / day | 14,340 | 89,240 |
But one number moved in a direction I genuinely did not expect, and I love it. 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 happens to be the single most expensive thing the Worker does. When the humans leave, the average request is a robot doing bookkeeping. Poetry, honestly. 🤖
And in the spirit of total transparency, there is a loose end here that I’m not going to hide from you. The free plan allows 10ms of CPU per invocation, for HTTP requests and cron triggers and Durable Object alarms alike, and those medians are well past it. The Worker also logged 594 errors over the month, none 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 cost nothing visible, because event resolution is idempotent and a failed pulse just means the pirates are late. But if this were a real product? That’s the very first thread I would pull. 🧵
🌟 Four Revisions in Ten Days: The Glow-Up
The game that shipped on 16 August was the plan: build, research, explore, get raided by pirates. Solid! Functional! But everything genuinely interesting happened afterwards, in four revisions over the following ten days. Let’s go on that journey together. 🗺️
⚓ Revision 1: The Anchor. Picture a ship with no guns and no cargo hold whose only job in the entire universe is to plant a rally beacon 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 then? Anyone can tap that 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 single fleet. It turned a solo game into one where people actually talked to each other. Community, unlocked! 🤝
⛏️ Revision 2: Finite Worlds. The deposits change forever. Planets hold a surveyed amount of each resource and run dry permanently. And it brought friends: capital relocation 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. Every single one of those features creates a decision. 🧠
☠️ Revision 3: The Dominion. This one still gives me goosebumps. The pirates stopped being a spawn table and became a genuine faction. Every den near settled space is a living garrison that grows, climbs tiers, and launches its own raids, which means the den nearest you is the one hitting you. Raze it and its raids stop. Dens reinforce each other with convoys crawling between systems out in the open, and you can race one to an ambush point and take its cargo for yourself. A colony sharing a system with a living den pays tribute into that den’s hoard, and the hoard goes entirely to whoever lands the killing blow. Emergent! Reactive! Alive! 🔥
And those numbers are real, every single one of them. Across the month the players killed 9,841 raiders, razed 74 dens and charted 65 systems, while the Dominion moved 5,167 supply convoys between its own. A whole war happened in there. 🏆
🤫 Revision 4: Quiet Beacons. Mostly a polish pass, but polish matters! 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.
And the interface? It moved right along with it. Here is the same screen on the Revision 3 build and on today’s:


Both of those came out of playing the game on a phone and being annoyed, which is genuinely the best product research methodology ever invented. The planet header was a paragraph you had to read; now it is a row of chips you 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. Small changes. Enormous impact. 💫
🔬 The Post Mortem: What the Archive Told Me
Buckle up, because this is the part where the data looks me dead in the eye and tells me things I did not want to hear. 😬
Closing the game meant dumping the game. The archive is 23 tables and 7,533 rows of D1, plus the Durable Object storage for all eighteen empires, which is the half that D1 never mirrored: fleet compositions, queues, in-flight missions, surveyed systems, every player’s event log. Nothing deleted. Nothing lost. 💾
And here’s a hard-won operational lesson, free of charge: deploy the freeze first, dump second. I took a dump at 01:22, deployed the shutdown, and then discovered two commanders had played in between, which made my beautiful first dump instantly wrong. Order matters!
Then I read it. Reader, the archive turned out to be a far harsher design review than anything I wrote during the ten days I was building the thing. 📉
🚀 One Ship Won. Just One.
Eight hulls shipped. EIGHT. And every single one had a job I was proud of:
- The scout: boosts discovery on any expedition it joins. 🔭
- The courier: the blockade runner, fast and cargo-light.
- The hauler and freighter: the logistics backbone. 🚚
- The shadow: cloaks the convoy it escorts. Pure spy-thriller stuff! 🫥
- The anchor: plants a rally beacon the whole galaxy can see. ⚓
- The colony ship: founds new worlds. 🏳️
And three quarters of every ship alive at the end was a corvette. 😭
The top commander finished with 3,027 corvettes out of 4,306 ships. That is not a fleet. That is a wall. The corvette is the cheapest thing that can fight, and in a galaxy whose main verb is “send ships at pirates and get them back”, cheap and fights is the entire specification. Everything else I lovingly balanced was a rounding error.
But here’s the insight I actually want you to take: the fix is not nerfing the corvette. The fix is that nothing else was ever required. There was no fight a stack of corvettes could not answer, so there was never a reason to bring anything specific, and eight beautiful hulls collapsed into one boring one. Variety needs teeth, not just stats. 🦷
📊 The Galaxy Quietly Rewrote My Balance File
This one is my favourite finding in the whole archive, and it is genuinely humbling.
Expeditions roll an outcome table: loot, nothing, an ambush, a derelict, a discovery. I set those weights per duration in balance/expeditions.ts. I wrote a distribution test. The test passed. I felt great. ✅
Across the 420 expeditions that actually rolled that table, ambushes fired 30% of the time against a designed 18.4%. On long expeditions it hit 47.8% against a designed 25%, while loot on those same runs collapsed from 30% to a miserable 9.0%. 😱
And nothing was wrong with the roll! Two multipliers I wrote entirely on purpose did this:
pirateRiskMultiplier: weights ambushes 2.5× higher when the destination has pirate activity.pirateCargoLure: scales ambush weight with how fat the convoy is.
Then Revision 3 turned the pirates into a living faction that grows and founds new dens. So as the month went on, more and more of the map counted as “has pirate activity”. And long expeditions? They send the biggest fleets the furthest, which makes them the most cargo-heavy and the most den-exposed things in the game. 🎯
The balance file said one thing. The world state said another. The world state won. My test could never have caught it, because it tested the table in glorious isolation against a completely empty galaxy. Emergent systems do not respect your unit tests. 🤯
🚛 Nobody Cut a Single Convoy
Okay. Deep breath. This is the one that hurts. 💔
Revision 3’s absolute headline was supply lines you can sever. Dens reinforce each other with convoys that crawl between systems out in the open. Radar reveals them. You race one to an ambush point and take its cargo before the receiving den ever sees it. I wrote a whole paragraph of changelog about it. I drew the interception flow TWICE.
The Dominion moved 5,167 supply convoys. 5,166 arrived safely. One was still in flight when the world froze. And the number that were ever intercepted by a human being? Zero. destroyed_by is null on every single row. 🫠
And it was not the only thing I built that nobody touched:
| Feature | Shipped in | Used |
|---|---|---|
| Pirate supply convoys | Revision 3 | 0 of 5,167 cut |
| Player versus player raids | pre-launch | 4 battles, all month |
| Standing order board | pre-launch | 5 orders, 3 filled |
| Rally beacons | Revision 1 | 9 beacons, 2 commanders each |
| Trade Hub building | pre-launch | built on 27 planets, never upgraded past level 1 |
| Finite deposits | Revision 2 | 2 planets ever drained dry |
The rally number is the one that truly stings, because Revision 1 was the revision I was proudest of in the entire project. Nine beacons went up across the month. They drew eighteen participants total. That is two commanders per beacon: the owner, and one friend who showed up out of loyalty. 🥲
Meanwhile? The guild exchange, which is functionally a vending machine with no other human in it, took 145 trades. Given the choice between coordinating with a person and trading with a wall, eighteen people picked the wall thirty times more often. There is a whole essay about multiplayer game design hiding in that one ratio.
👑 The King Outlived the Game
The Pirate King was supposed to be the season finale. He rises once the Dominion holds three or more strongholds. He eats one planet at a time. He heals as he moves. And when he falls, every den in the galaxy collapses to 40% strength.
He rose at Vellox-322 on 19 August with a strength of 500. Rallies drove at him twice. He drove them back both times. And when I froze the world a month later, he was still holding court, worn down to 453, which means the entire galaxy collectively chipped 9% off him and then quietly gave up. 😅
He is sitting in the archive right now with defeated_at still null. The finale never fired. The villain won by simply outlasting everybody, which is honestly the most pirate thing imaginable. 🏴☠️
💰 Ten Million Units of Nothing to Spend
And here is the other end of the exact same problem, gift-wrapped.
At shutdown the planets held 3.72 million alloy, 3.80 million crystal and 2.52 million fuel. Just sitting there. In stockpiles. With absolutely nowhere to go. 🏦
The tell is in the buildings, and it is brutal:
- Mines: reached level 17, averaged 8.9 across 41 planets. 📈
- Research labs: averaged level 2.2. 📉
The mines were the thing that made the number go up, so the mines got everything, and by the back half of the month the economy was producing far more than the fun could absorb. A stack of corvettes simply does not cost enough to drain three million alloy.
Nine of the eighteen finished with zero ships and under 150 points. Of the rest, ONE commander launched 299 of the galaxy’s 880 missions, a full third of everything that ever flew. Seven never launched a single one. 📮
And for the last six days, the game produced exactly eleven reports a day. Every single one a pirate raid resolving against an empire whose owner had stopped opening the app. The cron kept pulsing. The dens kept growing. The raids kept landing. The reports kept filing themselves into inboxes nobody would ever read. 🌑
At the freeze: 149 dens alive with 8,134 raiders garrisoned between them, against 9,195 player ships that mostly were not being flown. The pirates were winning on both sides of the ledger at once. They were growing, and we had stopped showing up.
💭 What the Graph Actually Says
Alright. Let’s get real for a moment, because you deserve honesty and not a victory lap.
The infrastructure was never the problem. That is the honest summary of everything above: I designed carefully around a budget of 100,000 row writes a day, 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.
And you know what? 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. People formed rallies. People fought each other four separate times. One commander finished with 23,488 points across four planets. The thing is finished enough to play and cheap enough to leave running forever, which is more than most side projects ever get. 🥹
🌠 Kanto Awaits
So I closed it. 🌌
Not because it broke. Not because it cost anything, because it cost nothing and would have happily run on that free tier until Cloudflare changed their pricing. I closed it because the last six days of it were a cron job raiding empty planets, and because the genuinely interesting part of the experiment was over, and because I wanted to run the next one. 🚀
Every empire archived. The handbook stays up forever as a record of how the galaxy worked. And every commander got those numbers as a card in their chat, which is the one thing about building on Telegram I will never, ever stop being delighted by: the game ends with a message in the same thread where it started. No app store deprecation notice. No sunset email nobody opens. Just a message from a bot you already talk to. 💌
And the next one opens tonight at 19:00 Lisbon.
Pokégram is the same bot-sized shape pointed at a much more famous world: Kanto, a party of six, and every trainer between you and the Champion. Same stack. Same free tier. Same vibecoded-on-a-whim origin. One twist I am absolutely not going to spoil here. 😏
And Spacegram’s eighteen are not starting from nothing, because service is remembered:
All eighteen of them can pick the Spaceboy suit on the trainer-look picker, locked forever to everybody who never flew. And it is not just a look! Wearing it, Mew can turn up in the wild, on any route, in any grass, on any water, right up until you catch one. 🧬
Eighteen people now own a sprite nobody else can ever wear and a legendary nobody else can ever meet, purely because they spent a month sending corvettes at pirates for me. That feels like the right exchange rate. ⚖️
The bot is @pokegram_gbot. Thank you for flying. See you in Kanto. 🫡
🎬 Conclusion: Two Problems That Only Look Like One
So there you have it, fellow builders: a complete multiplayer strategy game, living inside a chat app, running on a free tier, with a galaxy that generates itself from a single number and an economy where the ground genuinely runs out beneath you.
But if you take away just one insight from this entire journey, let it be this: Telegram gives you a one-tap funnel. Cloudflare gives you a free backend. And neither one of them gives you players. Those two problems look adjacent. They are not remotely the same problem. Solving distribution and solving demand are different disciplines, and no amount of elegant architecture will ever paper over the gap between them. 🌠
The galaxy is still up there. The cron still pulses every five minutes. The pirates are still growing. And somewhere out there, one commander is still flying. Maybe that’s enough. Maybe that’s everything. 🚀
Screenshots are from a local instance running a copy of the live galaxy, with commander names changed. The numbers come from the shutdown archive. 😉