luisgonc.

← 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 3▰▰▰▱▱Bloatin'

0 words at this level · written by AI, throttled by you

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.

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

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

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

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 map with charted systems, pirate dens, radar rings and convoy routes
Charted systems in yellow, pirate dens as skulls, blue rings where radar reaches, dotted lines where Dominion convoys are crawling, and two open rally beacons at the bottom.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

The whole thing runs on free

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

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

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

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

The one constraint that shaped everything

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

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

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

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

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

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

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

What it actually used

Daily Cloudflare usage from launch, against the free-tier ceilings Both panels start at zero on 15 August, the day before the Worker was deployed. Worker requests peak at 46,200 on 22 August against a 100,000 per day free ceiling. D1 rows written peak at 10,642 on 15 September against the same ceiling. Both panels use the same vertical scale. WORKER REQUESTS / DAY 100k/day free ceiling 0 peak 46,200 a bug I shipped, not players D1 ROWS WRITTEN / DAY 100k/day free ceiling 0 peak 10,642 launch 16 Aug 23 Aug 30 Aug 6 Sep 13 Sep
From the day before launch, when both were still zero. Both panels share the same vertical scale, and both ceilings are the same 100,000 per day. Nothing came close.

From 16 August to 16 September the Worker served 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.

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

Those numbers are real. Across the month the players killed 9,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:

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

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

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

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

Every ship alive in the galaxy at shutdown, by hull Of 9,195 ships across all eighteen empires, 6,919 were corvettes: 75 percent of the fleet. The other seven hulls share the remaining quarter. 9,195 SHIPS AT SHUTDOWN Corvette 6,919 75.2% Courier 915 10.0% Scout 679 7.4% Shadow 438 4.8% Hauler 153 1.7% Freighter 70 Colony ship 12 Anchor 9
Every ship alive in the galaxy when it froze. Eight hulls shipped; one of them is the fleet.

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.

Expedition outcomes: what the balance file intended against what the galaxy delivered Across the 420 expeditions that rolled the outcome table, pirate ambushes fired 30 percent of the time against a designed 18.4 percent, while loot fell from a designed 36.6 percent to 29.3 percent. 420 EXPEDITIONS THAT ROLLED THE TABLE vs design designed actual Loot 36.6% 29.3% -7.3 Ambush 18.4% 30% +11.6 Nothing 24.1% 17.4% -6.7 Derelict 12.6% 14.8% Discovery 8.4% 8.6%
What the balance file intended, against what the galaxy actually handed out. The ambush row is the Dominion leaking into a table it was never part of.

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.

The closing card: 18 commanders, 42 planets held, 65 systems charted, 9195 ships, 1787 battle reports, 13 armadas broken
The card every commander got in their chat this morning.

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.

The Pokegram launch card: the gates are open, Kanto, a party of six
Tonight, 19:00 Lisbon.

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.

The Spaceboy trainer sprite: a pixel-art figure in a white spacesuit
Spaceboy. Eighteen people can wear this and nobody else ever will.

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.