Architecture and Tech Stack Conversation
A conversation with Claude Code on November 1, 2026, after the design docs were written. It covers how the game runs (ticks, frames, commands), syncing state, the tech stack, entity-component-system architecture, Bevy and performance. Tool calls and their output are left out; only the messages are kept.
The conclusions are in runtime-and-sync.md, simulation-architecture.md, tech-research.md and the Tech stack section of player-and-platform.md. Later statements override earlier ones: for example, the client was first said never to import the sim, and a pending state was suggested for unconfirmed actions; both were reversed. Check the docs before treating anything here as current.
You
This is my first time building a game like this. Can you give me a brief, overview? We don’t need to capture this into a document. I just want you to create a temporary artifact that describes what the simulation versus the updates versus the player interaction model looks like for a game like this, and also what kind of architecture and structure you would impose on the simulation code versus the player code.
Claude
The overview is up as a private, temporary artifact: Tick, Frame, Command. Nothing was added to the repo.
The short version:
- Three separate loops:
- The simulation runs at a fixed 20 ticks per second and is the only place the world’s state changes.
- The client draws at around 60 frames per second from its own copy of the state, smoothing motion between ticks.
- Commands are small requests like “place a building” or “give this Depot a route”. They’re the only way a player changes anything, and they’re applied at the start of the next tick.
- The player asks and the simulation decides. A tap becomes a command. The screen can show a “pending” ghost building, but the real change only appears when the next state update comes back. The camera, the current selection and open panels never touch the simulation.
- Code structure:
sim/holds the game rules and has no rendering, network, clock or unseeded randomness. The world is plain data tables, and each tick runs a fixed list of steps in order.protocol/holds the message types.host/runs the timing loop and sends each player what they’re allowed to see.client/handles input, UI and rendering.- Dependencies point one way, so the client never imports the simulation.
- What the split buys you: you can run the real game code without a screen for balance sweeps like the Python simulations you’ve already done. Replays work, and single player and the server run the same simulation.
I based it on decisions already in your design docs: the server owns the tick, the phone hosts single player, players give missions to buildings, and items made on one tick can’t be used until the next. The folder names and the order of steps inside a tick are my suggestions, and the page labels them that way.
You
I’m curious about the added input lag of this round trip and if the clients assume an action will take place most of the time and pre-start the movement or construction in the rendering.
Also, does the client perform some level of command validation? If there isn’t enough basalite to build, how does the client know that building is unavailable? We don’t want to automatically place a ghost and remove it every time, that causes odd behavior. Or is the very availability of the command sent to the client?
Claude
Your docs already settle the Basalite case, and in a way that makes it simpler than you’d expect.
Input lag
From tap to seeing the simulation’s result:
| Delay | Single player | Multiplayer |
|---|---|---|
| Waiting for the next tick | 0–50 ms | 0–50 ms |
| Network round trip | none | ~50–150 ms on mobile |
| Drawing one tick behind (smoothing) | ~50 ms | ~50 ms |
| Total | ~50–100 ms | ~125–250 ms |
That’s fine for this game. With no avatar and no unit control, nothing the player does should cause motion right away. A tap places a ghost or gives a mission. The visible result, a crew driving out or a truck leaving, plays out over seconds. 200 ms of delay before a truck starts a 30-second route can’t be noticed.
Do clients assume success and start early?
There are two very different things people call “prediction”:
- Running the simulation ahead on the client and rolling back if the server disagrees. Shooters and fighting games do this because aiming has to feel instant. You don’t need it, and it’s a lot of complexity. Don’t start trucks or construction early. If the server disagrees, the truck snaps back, which looks worse than a short delay.
- Instant feedback on the screen. Do this. The tap sound, the highlight and the “Route set” label appear right away, with no simulation involved. For construction, your ghost is already the thing the player places, so the client can draw it instantly. It becomes real about 100 ms later, and the player can’t see the difference.
Does the client validate?
Yes. The client checks first so the button is greyed out or the footprint turns red before anything is sent. The server checks again because it has the final say.
To keep the two checks from drifting apart, write the checks once as pure functions, such as canPlace(view, cmd), in a shared module. Both the client and the simulation import it. The client runs it on its copy of what it can see: terrain, occupied tiles, fog, unlocked tech, stock totals. This corrects the primer, which says the client never imports sim/. The game rules should still stay out of the client, but these check functions are meant to be shared.
The server then rarely rejects a command, so ghosts don’t pop in and out. Rejections only happen in races. For example, your co-op partner might place something on the same tile in the same tick. The ghost then disappears with a short reason, which is fine because it’s rare.
Not enough Basalite
In your design, placing a ghost is free and decided. Resources are paid when the crew builds it (resources-and-economy.md:80-87). So not having enough Basalite doesn’t make placement invalid. The ghost is placed and shows “waiting for materials” until the crew can build it. That’s also what lets players plan ahead with ghosts and blueprints.
What does block placement:
- the tile is occupied
- the tile is in fog
- the terrain is wrong
- the building isn’t unlocked
The client knows all of these from its own state. In the build menu, an unaffordable building could show its cost in red, such as “short 40 Basalite”, while still being placeable.
Do you send the client which commands are available?
Usually not. The server sends state, and the client works out what’s available from it. Sending a list of available actions for every building doesn’t scale, and it arrives just as late as the state anyway. One exception: if something depends on information the client isn’t allowed to see, like a fogged tile, the client’s check is a best guess and the server’s answer wins.
Should I add a section on latency and validation to the primer, and fix the shared-checks rule there, or is this answer enough?
You
So give an example of a state update from server to client
Claude
Here’s one message from the server, using JSON so it’s readable. A real game would use a compact binary format, but the fields are the same.
Setup: it’s tick 4812. A moment ago the player tapped to place a Smelter at tile (12, 7). That command was their 17th, so it carries sequence number 17. The command reached the server, and this tick applied it.
{ "tick": 4812, "acks": [ { "seq": 17, "result": "ok", "id": 311 } ], "added": [ { "id": 311, "type": "Smelter", "x": 12, "y": 7, "rot": 0, "state": "ghost" } ], "changed": [ { "id": 77, "mission": { "type": "build", "target": 311 }, "path": [[3,4], [8,5], [12,7]], "departTick": 4812, "arriveTick": 4892 }, { "id": 150, "craft": { "item": "Ferrite", "startTick": 4810, "endTick": 4870 } } ], "removed": [], "events": [ { "type": "crewDispatched", "unit": 77, "target": 311 } ]}How the client uses each part:
ackslinks the result to the player’s command. The client was drawing a pending ghost for command 17. Now it swaps that for the real ghost with id 311, and the player sees no change. A rejection looks like{ "seq": 18, "result": "rejected", "reason": "tileOccupied" }, and the client removes the pending ghost and shows the reason.added/changed/removedlist only what changed this tick, and only what this player can see. An alien moving in fog isn’t sent at all.- The crew (id 77) gets a path and two ticks, not a position. The server says “leave at tick 4812 along this path, arrive at 4892”. The client works out where the crew is on every frame by itself. The server sends nothing more about it for 80 ticks unless the plan changes, for example if the crew is attacked or re-routed.
- The Smelter crafting Ferrite (id 150) gets a start and end tick, not a progress value every tick. The client fills the progress bar from the current tick. That’s one message per craft instead of 60.
eventsare for sounds, notifications and advisor lines. They don’t change the client’s copy of the world.
On most ticks there’s nothing to send. Sending plans instead of positions is why: a base with 200 running machines and 30 moving trucks may only produce a few changes per second. Your design helps here, since there are no belts carrying thousands of items.
Three more things come up in practice:
- On join or reconnect, the client gets a full snapshot of what it can see, stamped with a tick. Change messages continue after that. If the client sees a gap in tick numbers, it asks for a fresh snapshot.
- Detailed data is sent only while someone’s looking at it. Opening Depot 204’s panel tells the server “I’m watching 204”. Its stack-by-stack contents then come with each update until the panel closes. The rest of the time the client only gets coarse data, such as total Basalite, a few times per second.
- Fast-changing numbers are sent as rates. Total Basalite changes almost every tick while Harvesters unload. Instead of each new value, the server can send
{ "Basalite": 160, "perMin": 42, "asOfTick": 4812 }now and then. The client counts up between messages.
Sending a plan instead of a position is also the safe kind of early start you asked about. The client never guesses what the server will decide. It only fills in between points the server has already confirmed.
You
So it is essentially a diff of the full game state, with some values offering predictive hints for fast moving changes so that less data can be sent per tick? And on login you download the full game state (or the portion you can see?)
It seems like we keep getting closer to lockstep? It seems like the client still has to:
- verification through logic functions
- rendering
- predictive, order-based changes
- directional changes, like basalite dropping quickly, so it can render
Are these all best practices in the industry?
Claude
Yes on both counts. On login you get the part you’re allowed to see, which is a bit more than what’s on screen: everything currently in your vision, plus what you remember of explored areas, like terrain and buildings you saw last time. Unexplored and fogged-out areas aren’t sent. After that it’s diffs, with plans and rates instead of per-tick values.
Is this drifting toward lockstep?
It looks that way, but the difference is who decides, not how much code the client runs.
- In lockstep, every client runs the game rules and its copy is the real game. Every device must reach a bit-identical world. If one phone computes a slightly different number on one tick, the worlds split apart and keep diverging, and it can’t be fixed without stopping and resyncing. That’s a desync.
- Here, the client never decides anything. It only fills in between things the server has already decided, or checks things in advance. If a client’s math is wrong, the result is cosmetic: a progress bar a few frames off, or a counter that jumps by 3 when the next update arrives. The next message from the server corrects it. Nothing builds up, and nothing desyncs.
A useful rule for where the line sits: the client may fill in anything the server has already decided, and may check anything in advance, but never decides. A truck following a confirmed path is the client filling in. A truck choosing its path is the client deciding, and that stays on the server.
The other difference is how hard the code is. Lockstep needs the whole simulation to give identical results on every CPU, browser and build, including floating-point math. The client work here is ordinary math such as straight-line interpolation and multiplying by a rate. It doesn’t need to match anything exactly.
Are these four standard practice?
Yes, all four.
| What the client does | How common | Where you see it |
|---|---|---|
| Checks commands before sending (advisory) | Universal | Every online game, and every web form: check in the browser, check again on the server. Sharing the check code is common when client and server use the same language. |
| Rendering and smoothing between updates | Universal | Server-owned shooters and survival games all draw slightly in the past and blend between the last two updates. |
| Following confirmed plans (path plus start and arrival ticks) | Very common | Called dead reckoning or sending trajectories. It dates back to military network simulations and is standard in MMOs and strategy games. |
| Counting up at a rate between updates | Very common | Mobile base builders do this for resources: the server stores an amount, a rate and a timestamp, and the client works out the number to show. |
Costs to know about
- Plans and rates are hints, and they go stale when something changes. If storage fills up, Basalite stops rising. The server then sends a new rate, and the client either jumps to the right number or blends to it. For a displayed number, a brief error of a few units is acceptable. Never feed a client’s guess back into a decision, such as “can I afford this?” Your build menu only marks costs in red, and the server checks anyway, so this is safe.
- There are two code paths for some logic. The shared check functions cover validation. Interpolation is a small amount of client-only code.
- The server has to notice when a plan changes and send the new one. A truck re-routed by an alien attack needs a new path message right away, or the client keeps drawing it on its old route. In practice that’s the main kind of bug in this model, and it’s visible and easy to fix, unlike a desync.
You
Can we describe 2 players playing: 1 is watching the count of basalite go up slowly, deciding what to do. He shuts off a machine producing some higher tier wall material, which changes the rate and quantity. THe other player at around the same time places a wall, which subtracts some basalite.
Describe what each client and the server sees, step by step, so I understand what is going back and forth
Claude
Here’s the scenario with numbers from your docs. Player A shuts off a machine that uses Basalite. Player B places 10 Stone Wall tiles, which cost 3 Basalite each, so 30 in total. Your docs don’t have a machine that makes a wall material (the Reinforced Wall uses Basalite and Iron directly), so I’ve treated it as a made-up machine that consumes 12 Basalite a minute.
One detail from your design shapes the whole sequence. Placing a wall doesn’t subtract Basalite. Ghosts are free. The Basalite leaves the network when an Engineering Crew loads it to go and build. So “B places a wall” and “Basalite drops” are two separate moments, and only the server knows when the second one happens.
Starting point: tick 6000
The server sends both players the same Basalite line:
{ "network": 1, "Basalite": { "amount": 160, "perMin": 30, "asOfTick": 6000 } }The net +30/min is Harvesters bringing in 42 a minute, minus the machine’s 12. Both clients work out the displayed number every frame: 160 + 30 × (currentTick − 6000) / 1200. The server sends nothing more about Basalite while the rate holds.
Step 1: both players act (about tick 6010)
Player A taps the machine’s switch to Off.
- Client A: the switch flips at once, drawn as pending (dimmed, with a small spinner). The Basalite counter keeps its old rate, because the client doesn’t guess the new one.
- Sends:
{ "seq": 41, "kind": "toggle", "id": 150, "on": false }
Player B drags out 10 wall tiles.
- Client B: runs
canPlaceon each tile. They’re free, explored and unoccupied, so it draws 10 pending ghosts right away. It doesn’t subtract 30 Basalite. Whether and when a crew loads the Basalite is the server’s decision. - Sends:
{ "seq": 88, "kind": "place", "building": "StoneWall", "tiles": [[40,12], [41,12], …] }
Neither client knows about the other player’s action yet.
Step 2: the server applies both on tick 6014
Both commands arrived before the tick 6014 boundary, so they go into the same tick. The server applies them in a fixed order, such as arrival order. Nothing conflicts: they’re two entries in one queue, and “around the same time” just means “the same tick”. The tick then runs its phases:
- Apply commands: machine 150 is switched off. 10 wall ghosts (ids 400–409) are created.
- Hand out stock: machine 150 no longer asks for Basalite. What’s already in its input buffer stays there.
- Move units: an idle crew (id 77) at the Depot takes the wall job and loads 30 Basalite. The network drops from 160 to 130, and the crew sets off.
- Net rate is now +42/min, since only the Harvesters are left.
Step 3: the server sends one message to each player
The two messages are identical except for the acks. Each player only gets the result of their own commands.
{ "tick": 6014, "acks": [ { "seq": 41, "result": "ok" } ], // Player A's copy // "acks": [ { "seq": 88, "result": "ok", "ids": [400, …, 409] } ], // Player B's copy "added": [ { "id": 400, "type": "StoneWall", "x": 40, "y": 12, "state": "ghost" } // …and 401–409 ], "changed": [ { "id": 150, "on": false, "status": "off" }, { "network": 1, "Basalite": { "amount": 130, "perMin": 42, "asOfTick": 6014 } }, { "id": 77, "mission": { "type": "build", "targets": [400, …, 409] }, "carrying": { "Basalite": 30 }, "path": [[22,9], [31,10], [40,12]], "departTick": 6014, "arriveTick": 6094 } ], "events": [ { "type": "switchedOff", "id": 150, "by": "playerA" }, { "type": "crewDispatched", "unit": 77, "job": "build" } ]}Step 4: what each player sees, about 100–200 ms after tapping
| Player A | Player B | |
|---|---|---|
| Own action | Switch goes from pending to confirmed | Pending ghosts become real ghosts |
| Other player’s action | 10 wall ghosts appear (they’re in A’s view) | The machine’s status light goes to Off. A notification might say “Player A switched off machine 150” |
| Basalite counter | Jumps from ~160 to 130, then rises faster | Same |
| Crew | Leaves the Depot, drawn along its path | Same |
The jump down to 130 is correct, because something real happened: a crew loaded 30 Basalite. The client can show it, for example with a “−30” floating up from the Depot. For the 200 ms before the message arrived, both clients counted up at the old rate. That’s an error of about 0.1 Basalite, which nobody sees.
Step 5: after that, the server mostly stays quiet
- Ticks 6015–6093: nothing about Basalite or the crew. Both clients draw the crew along its path and count Basalite up at +42.
- Tick 6094: the crew arrives. The server sends
{ "id": 400, "buildStartTick": 6094, "buildEndTick": 6114 }for each tile, all in one message. A wall tile takes 1 s, which is 20 ticks. The clients animate each tile filling in. - Tick 6294: the walls are done. The server sends 10 “ghost → built” changes and a new path for the crew to return home.
What changes if timing is a little different
- B’s command arrives just after the boundary. A’s change goes out on tick 6014 and B’s on 6015. The Basalite counter changes rate first, then drops 50 ms later. Nothing breaks.
- No crew is free. The ghosts are placed, Basalite stays at 160, and the walls wait. This is why Client B must not subtract the 30 itself. It would have been wrong.
- Basalite is short. The ghosts are still placed and show “waiting for materials”. The server sends the crew dispatch whenever there’s enough.
So players never negotiate with each other. Each one sends requests, the server puts everything happening in the same moment in one order, and everyone gets the same result.
You
drawn as pending (dimmed, with a small spinner). NO! That seems totally weird and artificial. Games don’t work like this ever.
Claude
You’re right, and I was wrong to suggest it. Games don’t mark individual actions as pending.
What games actually do: anything the player touches responds instantly and completely. The switch flips and clicks, and the wall ghosts appear exactly as they would offline. The client simply assumes the command will succeed, because it nearly always does. The world’s reaction, like the machine’s light going off or the crew leaving, comes with the next server update about 100–200 ms later. Players read that as the machine taking a moment to power down, not as lag.
Strategy games hide the round trip the same way. Units play a “yes, sir” voice line or a click marker the instant you give an order, then start moving a moment later.
When the server says no, which is rare (your co-op partner took the same tile on the same tick), the client undoes the action and shows a short reason. Because it’s rare, it doesn’t feel odd. The only visible “waiting” state in these games is a single “Reconnecting…” banner when the connection drops, never a spinner on each action.
So in the two-player example:
- Player A taps Off. The switch flips at once with a click. About 150 ms later the machine’s light goes out and the Basalite counter changes.
- Player B drags out walls. The ghosts appear normally at once. About 150 ms later the crew leaves and the counter drops by 30.
The rest of the walkthrough, including the messages, the single tick that applies both commands and what each player sees afterwards, stays the same.
I updated the primer to match: Tick, Frame, Command. Its player-interaction section no longer describes a pending ghost. It also no longer lists “no materials” as a reason the server rejects a placement, since ghosts are free in your design.
You
I feel like a lot of these things would be pushed through some sort of server framework. Obviously, logic needs to be done custom methods, but a framework would enable the diffing and all of the pieces much more easily than a sort of one-off thing that you’re describing. And maybe you’re not suggesting that, but the point is I’d be shocked if people were doing a lot of construction of this type of stuff by hand rather than either using some sort of third-party framework or building their own. The key observation, I think, is that doing diffing is when you have common things, is like a common action. All of these things are not too dissimilar. Resource dropping and rate changes are happening across tons of different types. And so there’d be commonality, you’d have commonality to them, and projection code and verification code and things like that could all be designed to be common. I’m curious what people have done there and if that’s not uncommon to write a diffing framework sort of instead of some sort of manual thing. And also, I just want to say, it appears that the suggestion you were making earlier is that So, why don’t you talk me through some of that?
Claude
You’re right. Nobody writes diffing by hand for each feature. The JSON I showed was what goes over the network, not code you’d write for walls and Basalite separately. I should have said that. In practice there’s a generic replication layer, either from a framework or built once in-house, and gameplay code never touches messages.
How a generic replication layer works
Every framework I know of breaks into the same parts:
- You declare which state is synced, with types. You mark fields like
amount: int,on: boolandpath: Pathas replicated. Commands are declared the same way. - The framework tracks what changed. It does this in one of two ways:
- Dirty flags: setting a field marks it changed. Cheap, and good when most things sit still, like sleeping machines.
- Comparison: the framework compares the current state with the last snapshot each client confirmed it got, and sends the difference. This is the Quake 3 model. It’s robust to lost packets but costs CPU.
- It filters per client. The framework runs the filtering. Your game supplies the rule, such as “visible to this player” (fog) or “this player has this Depot’s panel open”.
- It encodes changes compactly. Field ids instead of names, binary, small numbers packed tightly.
- It handles delivery. Sequence numbers, acks, a full snapshot on join or reconnect, and resending what was lost.
- The client gets a mirrored copy of the state with
onAdd,onChangeandonRemovehooks that the renderer and UI subscribe to.
What people actually use
- Engine-built-in: Unreal’s property replication, which Unreal-based games use; Unity’s Netcode for GameObjects, Mirror, FishNet and Photon Fusion; Godot’s
MultiplayerSynchronizer. You mark fields as replicated, and the engine diffs, filters and sends them. - Colyseus (TypeScript / Node): authoritative rooms plus
@colyseus/schema. You declare state classes with typed fields, and it sends binary diffs and supports filtering per client. It fits a web or React Native phone client well. - SpacetimeDB: the server is a database. State is tables, commands are functions that change the tables, and clients subscribe to queries and receive row-level diffs. It matches the “plain data tables plus systems” structure in the primer almost exactly. It’s newer and less proven than the others.
- Custom, built once: large studios often write their own generic layer, especially for big simulations. It’s still one system that works for every type, not code per feature.
One caution from factory-style games: comparison-based replication across thousands of mostly idle buildings costs a lot of CPU. Unreal added the Replication Graph and later the Iris system largely because the default checked every replicated property of every object regularly. With a world as large as yours, prefer dirty flags.
Your point about common patterns goes further than diffing
Plans and rates can be field types, not per-feature code. Define a few kinds of time-dependent field once:
Linear { value, perTick, asOfTick }for Basalite totals and anything that fills or drains steadilyTimed { startTick, endTick }for crafting, building and repairsPath { points, departTick, arriveTick }for trucks, crews and drones
The sim writes these. The replication layer diffs them like any other field: one message when the plan changes, nothing in between. The client has one function, valueAt(field, tick), that every progress bar, counter and moving unit uses. Adding a new machine or unit type then needs no networking code at all.
There’s one catch. Some sim values really do change every tick. Inventories change with each handout, for example. For those, the host computes a display value, such as a network total with a smoothed rate, and sends it with a policy like “at most every 20 ticks, shown as a Linear”. That policy is declared per field too, not written per feature.
Command checks share one pipeline. Each command type declares its fields and a validate(view, cmd) function. The framework handles sequence numbers, acks, rejection reasons and undoing on the client. The checks are built from shared pieces like tileFree, explored, unlocked and ownsBuilding, so a new building type mostly reuses them.
How this affects the primer’s structure
The sim/client split stays the same. The new decision is where the synced state lives:
- Keep the sim in its own plain tables and add a replication layer that reads them each tick. The sim stays free of framework code and runs headless for balance sweeps. The cost is a copy or comparison step.
- Make the framework’s state classes the sim’s tables. There’s no copy, but the sim is now tied to the framework.
For a game whose balance testing depends on running the sim headless, I’d start with the first and switch only if the copy shows up in profiling.
Your design docs don’t name an engine or language yet, and that choice decides which framework fits. What are you leaning toward? Your message also stopped mid-sentence at “the suggestion you were making earlier is that”. What were you going to say?
You
Right. I’m not sure because this is a first game and because I’m not sure exactly what I’m going to do with it. I likely will never build it, but I’d love to prototype it to see how it will feel. I recognize that the prototype may not be something that fits the end-all be-all solution. So, the clients are going to impact this. So, one of the first client one idea is to run one of the clients in the browser, given that how capable browsers are today. That fits the prototype idea really well. Another idea would be to make iOS the first client, as I’m very familiar with working in the iOS ecosystem, and I have plenty of iOS devices to work on, and I could also make a Mac app work the same way. However, with some of the base choices there, like Swift or other things, don’t necessarily always cover over, reach the other platforms as well. Or if they technically do, they aren’t a common choice by everyone. So frameworks and other things won’t be available in them. So there’s the sort of the decision to make here is the prototype, and then the final direction. And I haven’t decided what to build this in. I know a lot of game developers just choose an engine because of the massive amount of work it takes to build a game. Having an engine that can handle the visuals plus the netcode plus anything else could be hugely valuable. And it also exposes you to more platforms at once because it’s straightforward to port to multiple platforms. I want to get into that right now and just to discuss options, because as a new game developer or as a novice non-game developer, but as an experienced software engineer who’s worked on iOS apps for most of my career, I definitely know that most people build in a game frameworks, and for good reason. But I’m kind of excited by this idea that I’ve been building as an idea, as a brainstorm. It’s been super fun for me to just brainstorm it. And I also think it’d be super fun to sort of vibe code it with an AI assistance. So being able to use Claude Code to write it would be killer. And I’ve been reading a lot about people making somewhat complex web games. However, many of those aren’t as sophisticated as having multiplayer or simulations or servers or other things. So let’s talk in the next turn. Let’s talk about language and frameworks next. Language and game engines, rather than just like a framework I would use within a language.
Claude
This is the biggest decision you’ll make. Your design and background point fairly clearly in one direction for the prototype, so I’ll end with a recommendation.
What your game needs from a tech stack
Most engine comparisons are written for action games. Yours is different in ways that change the trade-offs:
- The simulation is the core, and it doesn’t need an engine. It’s pure rules over data tables: no physics, no animation, no rendering. It has to run in three places: on a server, on the phone in single player, and headless for balance sweeps. Any language can do that, but the client’s language decides whether the sim can run inside the client without being rewritten.
- Built-in engine networking helps less than you’d expect. Engine netcode is built for action games: it syncs the positions and properties of a few dozen objects for a short match. You need a huge persistent world, filtering by fog, and plans and rates instead of positions. You’d likely build most of that sync layer yourself on any engine. An engine’s real value to you is rendering, input, UI and shipping to many platforms.
- It’s a UI-heavy game on a 2D grid. Factory games are mostly panels, overlays, status lights and menus, drawn over a grid. A good UI toolkit matters more than a strong 3D renderer. If you want 3D visuals, that changes things somewhat.
- AI-assisted coding works best when everything is a text file (no editor-only setup), the run loop is fast, the APIs are stable and widely used, and Claude can run the game and see it. In this app, Claude can open web pages in the built-in browser and drive the iOS Simulator.
The realistic options
| Web: TypeScript | Godot | Unity | Swift (SpriteKit / Metal) | |
|---|---|---|---|---|
| Language | TypeScript everywhere | GDScript (Python-like) or C# | C# | Swift |
| Rendering | PixiJS (fast 2D WebGL) or Phaser (a fuller 2D engine) | Built-in, strong 2D | Built-in, strong 2D and 3D | SpriteKit (Apple hasn’t invested much lately) or Metal |
| UI for panels and overlays | HTML/CSS over the game canvas, the best UI tooling there is | Good built-in UI system | Workable, often clunky | SwiftUI, excellent |
| Sim runs on client and server | Same code in a browser worker and in Node | GDScript in headless Godot, or C# as a plain library | C# library on a .NET server | Swift package on a Linux server; client is Apple-only |
| Networking | Colyseus, or WebSockets plus your own sync layer | Built-in multiplayer API | Many options (Netcode for GameObjects, Mirror, FishNet, Photon) | Mostly build it yourself |
| Platforms | Any browser, including your iPhone. Wrap with Capacitor for app stores | iOS, Android, Mac, Windows, Linux, web | All of them | iOS and Mac |
| AI-assisted coding | Best: plain text, huge training data, Claude can run and look at it | Good: scenes are text files. Watch for Godot 3 vs 4 API mix-ups | Weaker: lots of setup happens by clicking in the editor | Good for code, and Claude can drive the Simulator |
| Main risk | Performance limits on big late-game bases. Feels less native | Smaller ecosystem. Last I knew, C# projects couldn’t export to the web in Godot 4 | Editor-heavy workflow. Trust hit from the 2023 pricing change (later cancelled) | Locked to Apple. Few game-dev resources |
I’ve left out Unreal: it’s overkill for a 2D mobile grid game, and its visual scripting isn’t text. I’ve also left out Rust with the Bevy engine: it’s a great fit for simulations, but its API changes often, so AI help is often out of date.
My recommendation
Build the prototype as a web game in TypeScript. Your stated goal is to find out how it feels, quickly, mostly with Claude Code. The web is the best fit for that:
- Your architecture comes almost for free. The sim is a plain TypeScript package. In single player it runs in a Web Worker, which gives you a real client/host boundary from day one. When you want multiplayer, the same package runs in Node behind WebSockets. Balance sweeps run the same code from the command line.
- The fastest feedback loop. Save, refresh, play. Open it on your iPhone over your home network to feel the touch controls on day one, with no build, signing or TestFlight.
- Panels and overlays in HTML/CSS instead of fighting a game UI toolkit, which matters for a game that’s mostly panels.
- Claude can test it. It can load the page, click through it and take screenshots, which closes the loop when you’re coding with AI.
- The sim is in the language Claude writes best, and the sim is where your interesting design (due ticks, netting, noise) lives.
For rendering, start with PixiJS: a fast 2D renderer that leaves the game structure to you, which suits the sim/client split. Phaser gives you more out of the box but pushes its own way of organizing a game.
What about later?
Be honest with yourself here: a prototype stack often becomes the final stack, and that’s fine. Plenty of shipped games are web code wrapped for the app stores. If the prototype proves the game and you outgrow the web, the sim is the part worth keeping. The realistic paths:
- Stay on the web and wrap it for iOS and Android. The least work, and good enough for many 2D games.
- Move the client to Godot. The sim would need porting from TypeScript to GDScript or C#. A clean, pure sim is the easiest thing in a game to port, and by then your balance tests will check the port.
- Go native Swift for an Apple-only version. This plays to your strengths, but it’s a dead end for Android.
When Godot would be the better first choice instead: if what excites you is learning game development the standard way, with an engine, a scene tree and an editor, and you’re fairly sure you’d want it on the app stores. It’s the best engine for a solo developer making a 2D mobile game, and it works well with Claude Code because almost everything is text.
Swift is the comfortable choice, and it would get you to “feel” fastest in week one. But it gives up the web, Android and most game-dev resources, so I’d only pick it if you’re sure this stays an Apple-only hobby.
What do you want the prototype to look like: flat 2D top-down tiles, or something with depth like isometric or 3D? That affects the renderer choice more than anything else left.
You
restate all of that message to me, but I’m going to read it. I’m going to listen to it in my phone instead of, so don’t give tables or things that come out weird. I’m going to play it out loud. So it has to be spoken.
Claude
Here’s the same answer, written to be listened to.
This is the biggest decision you’ll make, and your design and your background point fairly clearly in one direction for the prototype. I’ll go through what your game needs, then the main options, then what I’d pick.
First, what your game needs, because most engine comparisons are written for action games and yours is different.
The simulation is the heart of the game, and it doesn’t need an engine at all. It’s pure rules over tables of data. There’s no physics, no animation, no rendering. It has to run in three places: on a server, on the phone in single player, and on its own with no screen for balance testing. Any language can do that. What matters is that the client’s language decides whether the sim can run inside the client without being rewritten.
Second, built-in engine networking helps less than you’d expect. Engine netcode is built for action games. It syncs the positions of a few dozen objects for a short match. You need a huge persistent world, filtering by fog of war, and plans and rates instead of positions. You’d probably build most of that sync layer yourself on any engine. So an engine’s real value to you is drawing, touch input, menus, and shipping to many platforms, not networking.
Third, it’s a game with lots of menus, on a flat grid. Factory games are mostly panels, overlays, status lights and menus on top of a map. A good user interface toolkit matters more than a powerful 3D renderer. If you want 3D visuals, that changes things a little.
And fourth, coding with AI works best when everything lives in plain text files instead of being set up by clicking around in an editor. It also helps when you can run the game quickly, when the tools are popular and stable, and when Claude can run the game and look at it. In this app, Claude can open web pages in its built-in browser and drive the iPhone simulator, so either of those works.
Now the options. There are four realistic ones.
Option one is a web game written in TypeScript. You’d use the same language for the simulation, the client and the server. For drawing, there’s a library called Pixi JS that’s very fast at 2D, or a fuller game framework called Phaser. Menus and panels would be ordinary web pages laid over the game, and web pages are the best tool there is for that kind of interface. The sim would run in a background thread in the browser for single player, and the exact same code would run on a Node server for multiplayer. It runs in any browser, including on your iPhone, and you can wrap it later to put it in the app stores. It’s the best fit for AI-assisted coding, because it’s all plain text, it’s the kind of code Claude has seen the most of, and Claude can open it and see it working. The main risks are a performance limit when late-game bases get very large, and that it feels a little less native than an app.
Option two is Godot. It’s a free, open-source game engine that’s very popular with solo developers. You write code in its own language, called GD Script, which feels a lot like Python, or in C sharp. Its 2D drawing is strong, and its menu system is good. It exports to iPhone, Android, Mac, Windows, Linux and the web, and it has multiplayer support built in. It works well with AI coding, because almost everything, including its scene files, is plain text. One thing to watch is that a lot of older material online is for Godot 3, and AI tools sometimes mix up the old and new versions. Also, last I knew, projects written in C sharp couldn’t be exported to the web in the current version, so you’d want to use GD Script if the web matters. Its ecosystem is smaller than Unity’s.
Option three is Unity. It’s the engine behind a huge share of mobile games, it uses C sharp, it’s strong in both 2D and 3D, and it has many networking add-ons to choose from. It runs on every platform. The downside for you is that a lot of the work happens by clicking around in the editor rather than in text files, so AI coding is less smooth. Unity also lost some developers’ trust in 2023 with a pricing change that it later cancelled.
Option four is native Swift, using Apple’s SpriteKit or Metal for drawing. This plays to your strengths, and SwiftUI is excellent for menus. The sim could be a Swift package that also runs on a Linux server. But it locks you to iPhone and Mac. There are very few game development resources for it, Apple hasn’t put much into SpriteKit lately, and you’d be building most of the networking yourself.
I left out Unreal, because it’s far more than a 2D grid game on a phone needs, and its visual scripting isn’t text. I also left out Rust with an engine called Bevy. It’s a great fit for simulations, but its programming interface changes so often that AI help tends to be out of date.
So here’s my recommendation. Build the prototype as a web game in TypeScript.
Your goal is to find out how the game feels, quickly, mostly by coding with Claude, and the web is the best fit for that. The architecture we talked about comes almost for free. The sim is a plain TypeScript package. In single player it runs in a background thread, which gives you a real boundary between client and host from the first day. When you want multiplayer, the same package runs on a server behind a web socket connection, and balance tests run the same code from the command line.
The feedback loop is also the fastest you can get. You save, refresh and play. You can open it on your iPhone over your home network and feel the touch controls on day one, with no builds, no code signing and no TestFlight. You’d build the menus and overlays as ordinary web pages instead of fighting a game engine’s menu system, which matters a lot in a game that’s mostly menus. Claude can load the page, click through it and take screenshots, so it can check its own work. And the sim, which is where all your interesting design lives, like the due-tick rule, the netting and the noise, would be written in the language Claude writes best.
For drawing, I’d start with Pixi JS. It’s a fast 2D renderer that stays out of the way and leaves the structure of the game to you, which suits the split between sim and client. Phaser gives you more out of the box, but it pushes its own way of organizing a game.
Now, what about later? Be honest with yourself here. A prototype’s tech often becomes the final tech, and that’s fine. Plenty of shipped games are web code wrapped up for the app stores. If the prototype proves the game and you outgrow the web, the sim is the part worth keeping. You’d have three realistic paths.
One, stay on the web and wrap it for iPhone and Android. That’s the least work, and it’s good enough for many 2D games.
Two, move the client to Godot. The sim would need to be ported from TypeScript to GD Script or C sharp. But a clean, self-contained sim is the easiest part of a game to port, and by then your balance tests would check that the port behaves the same.
Three, go native Swift for an Apple-only version. That plays to your strengths, but it shuts out Android.
There’s one case where Godot would be the better first choice: if what excites you is learning game development the standard way, with an engine, a scene tree and an editor, and you’re fairly sure you’d want to put it on the app stores. It’s the best engine for one person making a 2D mobile game, and it works well with Claude because almost everything is text.
Swift is the comfortable choice, and it might get you to something you can feel fastest in the first week. But it gives up the web, Android and most game development resources, so I’d only pick it if you’re sure this stays an Apple-only hobby.
One question back to you. What do you want the prototype to look like? Flat 2D tiles seen from above, or something with depth, like an angled isometric view or full 3D? That affects the choice of drawing library more than anything else that’s left.
You
What did Factorio use?
Claude
Factorio was built in C++ on its own custom engine, not on Unity or any other off-the-shelf engine. The studio, Wube Software in Prague, started it around 2012. Early on it used a library called Allegro for graphics and input, and later the team replaced that with its own rendering layer. Lua is used for mods and scenario scripts, but the game itself is all C++.
For multiplayer, it uses deterministic lockstep, the model we compared earlier. Every player’s computer runs the whole simulation, 60 ticks a second, and only commands go over the network. To hide the delay that adds, Factorio shows your own actions straight away, the same instant feedback we talked about, and calls this latency hiding.
The reason it went custom matters for you. Factorio’s whole identity is enormous scale, with millions of items moving on belts. Off-the-shelf engines couldn’t simulate that fast, and the developers spent years tuning every byte of memory. Those belts are also why it uses lockstep, because sending that many moving items over a network would be impossible.
Your design removed both pressures. There are no belts, and items move between buildings without travelling. Every machine shares out stock each tick instead of things moving along a line. That’s why a server-owned tick fits your game, and why you don’t need an engine built for speed above everything.
So Factorio is a great example of choosing tech to match the design. It isn’t a template for a first prototype. A small team of very strong C++ programmers spent years on it, and its needs aren’t yours.
You
Was it a 2D or 3D game?
Claude
Factorio is 2D. You look down on the world from above, with a slight tilt so you can see the fronts of buildings.
Its art looks three-dimensional, though, because the artists build each building and vehicle as a 3D model and then render it into flat images from a fixed angle. Each frame of every animation is a picture rendered ahead of time. The game only ever draws those flat pictures, which is a big part of why it can show so much at once.
That’s a common trick in 2D games, and it would suit yours well. You get rich, solid-looking buildings on a simple grid, and the game only has to draw flat images. A fast 2D renderer like Pixi JS handles that easily, even on a phone. It’s also a good answer to the question I asked you: you can have the look of depth while keeping the simplicity of a flat 2D game.
You
Oh, that’s interesting. I wonder if, like, one of the things I’ve just been hearing is that the AI is a lot less good at drawing sprites than it is at rendering 3D, which is sort of surprising, but yet noticeable, because it looks, things look kind of, it’s sort of a trick of the eye, a lower resolution 3D model, but that has, that is built as 3D objects rather than using an LLM image generator, is really effective. So for prototyping, I don’t have an artist. I won’t have an artist. I never will. This game is like not real. It’s a prototype. It’s an idea. It’s a thing to prove. It’s a thing to have fun with. I don’t want to emphasize, it’s too easy to emphasize what it would be like when I had 20 million players, but honestly, the real number is probably going to be like 5 players. So like, I don’t want to, you know, it’s much more fun for me to get to the point where I build the game, build tier 1, build tier 2, whatever, and play it with a couple of friends and get there. Now, while I’m familiar with Swift, not just familiar with, but I’m an expert in Swift, and I think that makes sense, it doesn’t necessarily, to me, scream like the right choice, not just because of the client lock-in. I’d love to be able to, you know, friends could play on PCs or whatever they wanted, but Android doesn’t matter to me much, but PCs might a little bit. But on top of that, it’s that I feel like Claude Code isn’t going to do quite as good a job writing the Swift as it would JavaScript. Also, while it’s I think one pro that it has is it’s an incredibly efficient language, probably more than TypeScript. So there’s that. That’s improvement for this simulation. But it limits things like not just what I can run the client on, but also what I can run the server on, because maybe I don’t, you know, there are little workers and tools and things that can be run all over the place in Node that is super duper common everywhere. So that has an advantage. So I’m thinking, if I don’t want to get ahead of my, if you’re thinking I don’t want to get ahead of myself here, where would you go? Does your recommendation stay the same? Is this just a web game that like gets a little iOS wrapper around it? And also consider the fact that my 3D versus 2D thing, one of the things that’s interesting there is that I think I’d like it to be, I think going that Factorio style where I do 3D renderings of the models, but not allow the world to pivot. You know, still don’t do a true 3D world, but do 2D world with 3D rendered models to give it that that feel of the same type of feel of Factorio. But if it’s the 3D, what I’m wondering is maybe what I could do is build a pipeline where the AI produces 3D models. We demo them in the browser so you can see them rendered right in the browser in 3D. That would be a tool that would be a part of this. And then those exported models would get screenshot, would get, you know, turned into images as basically like screenshots, like a little pipeline would turn the various needed necessary angles and animations into images.
Claude
Yes, my recommendation stays the same, and what you just said makes it stronger. Five friends, one or two tiers, a game you play rather than ship: that’s exactly what a TypeScript web game is good at.
Here’s what it would look like. The game is a web page. Your friends on PCs open a link in their browser, and that’s the whole install. On your iPhone, you can play it in Safari at first. When you want it to feel like an app, you’re a Swift expert, so a thin native shell around a web view takes you an afternoon. That’s the iOS wrapper you described, and it’s a perfectly normal way to do it. The server is a small Node process. For five players it can run on your Mac or on the cheapest cloud server you can find, and the sim and every little tool around it are the same TypeScript.
You’re right that Swift would run the sim faster. But at your scale TypeScript is plenty fast. Browser JavaScript engines compile hot code to machine code, and a sim built on whole numbers over flat arrays runs well in them. If it ever becomes a bottleneck, that’s a good problem to have, and it’s far down the road.
Your art pipeline idea is a good one, and it plays to what AI is good at. The trick is to have Claude build the models as code, not as images. Each building is a small program that assembles boxes, cylinders and simple shapes, with colors and a bit of detail. That’s text, and Claude is much better at text than at drawing. It also gives you a consistent chunky low-poly look, and that consistency stands in for an art director.
The tool would be a page in the browser using a 3D library called Three JS. It has two modes. In the first, you spin a model around and inspect it, and Claude can open the same page in its built-in browser and look at screenshots to judge and fix its own work. In the second, the bake mode, the tool locks the camera to the game’s fixed angle, and renders each building from the four directions it can be placed in. It renders moving things like trucks and drones from eight or sixteen directions, and renders a loop of frames for animations like a drill turning. It then packs all of those images into one big sheet with a small file describing where each frame is. Pixi JS loads that kind of sheet directly.
A few details make the difference between looking like Factorio and looking odd. Use an orthographic camera, which has no perspective, so every building lines up exactly with the grid no matter where it sits on screen. Use one shared lighting setup for every model, so they all look like they belong in the same world. Render shadows as a separate image from the building itself, so a shadow can fall across the neighboring tile without the building’s picture overlapping its neighbor. And because your design has day and night, plan to do night by darkening the whole screen and adding glowing light images on top. That’s how Factorio does it, and it means you never have to re-bake anything for lighting.
There’s one alternative worth knowing about. Instead of baking images, you could draw the Three JS models live in the game, with the camera locked in place so the game still plays like 2D. That skips the pipeline. But you’d give up the Pixi renderer’s simplicity, and phones would have to draw real 3D every frame. Baking lets you use expensive lighting once, on your computer, and the phone only ever draws flat pictures. For the look you’re after, I’d go with baking.
Last, the advice about not getting ahead of yourself. Build the art pipeline second, not first. The first milestone should be colored squares on a grid with numbers on them: a Harvester bringing in Basalite, a crew building a Depot, the counter going up. Play that until the sim feels right. It’s the part of the game nobody else can make for you, and it’s where your design work lives. Once it feels good, the pipeline turns those squares into buildings. It’ll also be more fun to build then, because you’ll know exactly which sprites the game needs.
You
Yeah, that tracks. I feel like it would start off where you would have squares or really rudimentary placeholder art that would just, no pipeline, nothing crazy. Just like you could even, just to make it a slightly nicer, you could make it like, you know, have the AI generate a set of images. So the pipeline would be like a prototype pipeline, an early version prototype pipeline that would just be producing like some AI images if you wanted to do it that way. Or you just say, you just literally put the word on the box. So it says harvester, and like, you know, an H moves around for harvester, you know, as a stand-in, a little box moves around for a harvester that says H, with a little H on it. And that’s a harvester. And, you know, you just use a lot of color and, you know, for differentiation. But you keep the UI strong so that you panels and things, when you click on things, you can still identify them very clearly. And all the numbers and everything feel good and feel right.
Claude
That’s the right starting point, and letters on colored boxes go further than you’d think. Plenty of great games were played for months like that before any art existed.
A few small choices now make the later switch to real art painless.
Keep one list of every building and unit type that says what it looks like: its color, its letter, its size on the grid. The renderer never decides how anything looks by itself. It looks the type up in that list. When real sprites arrive, you change that one list and nothing else.
Use shape to separate kinds of things, and color to separate types within them. For example, buildings are squares, moving units are circles, and ghosts are outlines of whatever they’ll become. Then you can tell a Harvester from a Depot at a glance, even before you read the letter.
Build the status lights early, even as plain colored dots in a corner of each box. They’re part of your design, not decoration. Your docs use overlays and status lights instead of watching items move, so those dots are how a player knows what’s working, what’s starved and what’s blocked. They’ll tell you a lot about whether the game reads well.
And while you’re tuning, add a debug overlay you can toggle that shows what the sim is thinking, like each machine’s buffers and due ticks right on the map. It’s ugly, but it’s the fastest way to see why a factory feels wrong.
As for AI-generated images as a middle step, they’re fine if you want a little more charm, but expect them to look inconsistent with each other. Colored boxes with strong panels will probably feel better than mismatched pictures. You’re also right that the panels matter most: if clicking anything shows clear, well-laid-out numbers, the game will feel real long before it looks real.
You
Yeah, that all tracks. I think as far as engine decision, which as you pointed out is the biggest decision you make, I think right now I would basically say we can log a decision that I’m not, that Swift is not the right direction here, for all the reasons we enumerated. I would also say I’m not so sure on Godot. I mean, maybe I would could be convinced, but it sounds too fringe. Maybe it’s enormous, and I’m a less, I’ve heard of it before, but I it just doesn’t sound like something I’d, that sounds quite right. Unity sounds like an obvious choice, but I’m not, I think the biggest thing for me with Unity, Unity feels like a default choice for a real game developer. I have a friend who’s a strong game developer, and he’s developed all kinds of games, and he uses Unity. And most people do, it seems like. Not most, but you know, many, many, many people do. So it feels like a safe default. But I do agree that my biggest concern with using Unity is the complexity of the tooling system. What seems like a pro for people having all that power at their disposal to be able to make worlds and everything, some of it doesn’t, a lot of it just doesn’t apply to a 2D top-down game that looks like it was made in 1996, you know? That’s the… same. Less, the only thing with the local one would be a lot less latency and a lot less errors.
You
[interrupted]
You
<task-notification> <task-type>artifact-auto-react</task-type> <summary>1 artifact auto-reply subscription paused by the interrupt (comments sent to Claude meanwhile are answered then)</summary> The user's interrupt paused it and the watch is kept; when the user asks you to publish this artifact again, that publish resumes it, or resume_replies if the user asks, as does the user's next typed message — each only on hosts that pass typed input through as the user's, and comments sent to Claude in the meantime are answered then; publishing without being asked, while handling a notification or a wake-up, leaves it paused. Do not republish or resume just to re-enable auto-replies unless the user asks. </task-notification>
Claude
Your message got cut off at the end. The last sentence, about the local one having less latency and fewer errors, didn’t come through clearly. Were you comparing local AI tools, or running the game locally against a server? Tell me what you meant and I’ll pick it up.
On the rest: Godot isn’t fringe. It’s smaller than Unity, but it’s a big, active open-source engine, and a lot of developers moved to it after Unity’s 2023 pricing scare. Several well-known indie hits were made in it, including Brotato, Dome Keeper, Cassette Beasts and Buckshot Roulette. Being less known than Unity is a fair reason to hesitate, but it’s a mainstream choice for small 2D games.
Your read on Unity is right. It’s the safe default for someone making games for a living, and your friend’s experience is worth a lot. But most of its power goes to 3D worlds, physics, animation and big editor-driven pipelines, and a top-down grid game that looks like 1996 uses almost none of that. You’d pay for the tooling and get little back, and AI-assisted coding is clumsier there because so much of the work happens in the editor.
So my reading of where you’ve landed is: Swift is out, Unity and Godot are possible but don’t feel right for this, and the TypeScript web game is the leading option.
Before I add this to the decision log, two questions. Should I log only “Swift is ruled out”, or also mark the TypeScript web prototype as tentative? And should it go in the decision log alone, or also in the player and platform doc, where the server and tick decisions already live?
You
Somehow you lost some of my my words there in the middle. I was just, the part I was trying to make at the very end after I talked about a game about 1996 was that it is a latency, that we would want to have the hard separation as you described. So the hard separation of server and client, even in the single player game, is a critical design choice that I would do right away. And I would write everything in Node and in TypeScript, and everything would run. And so even if I started by developing the single player game, every design decision would be treating the server like a server, even though technically it was running in a different process in the browser.
Claude
Got it, and I agree. Keeping the client and the host separate from the very first single-player build is the most important structural choice. If the host is treated as a server from day one, multiplayer later means changing how messages travel, not redesigning the game.
I’ve logged that and the rest of what we settled in the design docs.
The player and platform doc has a new section called Tech stack, marked decided. It says TypeScript everywhere: a web client, a Node server, and one simulation package they share. In single player, the host runs in a background thread in the browser, called a Web Worker. It talks to the client only through commands and state updates, just as a remote server would. Friends on PCs open a link, and on iPhone it runs in Safari, with a thin native app shell later if you want one. The section also lists, as tentative, the 2D renderer with a flat grid that never rotates, the colored placeholder shapes with letters, and the later baked 3D sprites. It ends with why you chose the web, and why Swift, Unity and Godot weren’t chosen. I marked Godot as open to revisit, since you said you could be convinced.
The decision log has three new entries in its platform section: TypeScript everywhere with Swift rejected, the client and host split from the first build, and the placeholder art plan as tentative.
The prototype scope doc now names the tech stack under simulation, and placeholder art under content.
The concept index check passes. Nothing is committed yet. Do you want me to commit these doc changes now, or leave them for you to look over first?
You
go ahead and commit them.
Claude
I committed the three doc changes as one commit on the concept index branch. The message is “Log the tech stack: TypeScript web client, Node server, shared sim”, and it covers the platform doc, the decision log and the prototype scope. Following your instructions, it has no co-author line. Nothing has been pushed.
You
So now that we’ve got a tentative, early prototype tech stack planned, I feel comfortable with the choices we’ve made, at least as an initial version. And if, you know, as I see the realities of this game, I’ll see if I need to revisit. I can always do that without losing very much, because honestly, I’m just prototyping. And most of the decisions work are going to come in the decisions we’re making right here in these documents. They’re the core choices of game design, not the code itself. You know, there will be the code, the complicated code of the engine. It does matter, but it just seems to work. But so what I’m getting at is there no engine we would use for the simulation? Like, that would all still be DIY. Like, everything would be DIY, and the netcode sort of would also be DIY, but maybe utilizing something like protobuffers or something else, you know, a really fast binary format for transferring the data, as opposed to bulky JSON for efficiency and speed of parsing. I’m wondering if that’s, I mean, that’s fine. I’m just, one of the things that I’d have to build early would be the sort of diffing system we talked about, and the sort of we’d have to build all of that. We’d have to build, before we really got to prototyping the game, we’d have to build the basics of, we didn’t even need to build much about the, you know, the rendering loop. We could move things choppily as ticks, even just to prototype. It could just move once per tick. We don’t even need interpolation although that’s probably pretty easy, you know, quite easy to interpolate the things. What I’m more thinking about is at the simulation level, and as the simulation, and what I’m thinking of is the network code and the synchronization. That’s the side of things that is make or break, I think, for this system. It’s like, needs to be perfect, and it needs to be decided sort of early once, and that structure continues across all of the types we make in the game. Like you mentioned, tons and tons of shared types. So we’d be basically building our own little framework that would say, here’s how you describe all of the pieces of this game. Here are the model, the basic model types, the basic, the basic rules and everything. And then within those, here’s how you input control, and how you input like hooks, you know, calculation, you know, simulation calculation hooks and things like that, to be able to figure out to do the actual logic of each unit. So, you know, maybe we have, you know, there’d be a type in the system called harvester, but the framework doesn’t know that. It just knows that there are moving types, you know, that move around in the world, and harvester happens to be one of them. And harvester has custom code that does certain things, like picking up resources and things like that. Does that track?
Claude
Yes, that tracks. The structure you describe is how most games of this kind are built. There’s no off-the-shelf engine for a simulation like yours. Factory sims, colony sims and strategy games almost always write their own, because the rules are the game. So the simulation is yours to build, and so is most of the sync layer. I’d push back on one thing, though: it doesn’t need to be perfect before you start prototyping. What needs to be right early is the shape of it, not the implementation.
Here’s what I mean. The part that’s expensive to change later is the boundary and the vocabulary. The boundary is commands going in and state updates coming out. The vocabulary is the small set of building blocks every game type is made from, and the field types like the steady rate, the timed span and the path. If the game code only ever talks through those, everything behind them can start crude and improve later without touching game code.
So the first version of the sync layer can be very simple. Send the whole visible state on every tick, as plain JSON. In single player it’s just a message between the worker and the page, with no network, and at prototype scale that’s fine. Later you swap in real diffing behind the same interface, then a binary format behind that. Each step is an optimization, not a redesign. Your instinct to let things jump once per tick at first is the same idea applied to rendering.
On binary formats: Protocol Buffers would work, but I’d wait. JSON is much easier to debug, because you can read every message in the browser’s developer tools, and that matters a lot while the sync layer is young. When you measure a real cost, the easiest step is usually MessagePack, which is a binary drop-in for JSON with no schema files or code generation. After that comes a custom encoding that uses your own field types, which beats any general format. Keep the encoding as the very last step of the pipeline so it’s easy to swap.
One thing worth checking before building all of it yourself. The Colyseus server can’t run inside a browser worker, which your single-player plan needs, but its state library might. That library is the part that declares typed fields, tracks changes and encodes binary diffs. I believe it can be used on its own, but I haven’t verified that it runs in a worker. If it does, it could save you the diffing and encoding work. If not, building your own is a reasonable amount of work at this scale.
On the framework shape, one refinement. Rather than the framework knowing about “moving types” with the Harvester as one kind of mover, build each type from pieces. A Harvester isn’t a subclass of something. It’s an entity that has a position, a path it’s moving along, a cargo hold, an owner building and a footprint when deployed, plus a Harvester behavior. A Depot has a position, a footprint, storage and unit slots. The framework knows how to sync, save and filter each piece. Behaviors like “Harvester gathers” or “crew builds” are functions that run in the tick’s phases and work on entities with the right pieces. This is the entity-component-system pattern, and it suits factory games because types share pieces in messy, overlapping ways. A truck and a Harvester both move and carry cargo, but only one deploys. A turret and a Depot both have storage, but only one fights. With pieces, a new type is mostly a new combination plus a small behavior, and it syncs automatically.
The last bit of advice is to avoid building the framework in the abstract. Build the first three types, say the Harvester, the Depot and the Engineering Crew, directly and concretely, while sticking to the boundary rules. Then pull out the common pieces once you can see them. Frameworks designed before the third real example usually guess wrong about what’s common.
If you want, the next useful step would be a short architecture doc in the design folder. It would cover the boundary, the field types, the first set of entity pieces, the tick phases, and that order of building it.
You
I think the best point you just made was to not over-optimize initially. And I think you’re right there. For tier one and tier zero and tier one, we can assume that people are on a very good internet connection because this is just an early prototype. Everybody’s on these high speed, we’d say don’t even bother with your mobile, just do it on a high speed, your home internet. Everyone’s got gigabit networks, right? Like crazy fast. So like that’s fine, throughput’s fine. We send the whole game state on every tick, and even though that turns out to be fairly large, it represents a, we don’t have to design the optimization, so we can of the diffing or anything else like that. We would still design, I would think, how instructions would be, actions and instructions would be communicated, because that part would be, you would say, I would like to change, I would like to do this, I would like to, this object wants to do this, the user did this action with this unit kind of thing. That would be, we try to establish a very basic set of communication layer there. And then the server just responds every tick with, here’s the state of the game, here’s the state of the game, here’s the state of the game. The rendering layer just, and then the rendering layer just shows that initially. And then we build up from there. Initially, we could have lag. Initially, we could have, no smoothing, no animation smoothing. It could just be really blocky and janky. But then all of that can come later, mostly at the client side, and then eventually at the optimization of the network code or of the, encoding and all of those other pieces. I really like that, as long as the general thing is right. And I think what you said about behaviors and also you mentioned a server framework that I didn’t hear you mention earlier. I’m a little bit confused by that. I didn’t understand that that we’d be using a server framework. Would the idea of a server framework be the sort of structure around live WebSocket kind of things with multiple connections and managing all of that, or is it something different? I don’t know what that’s managing, but so I want to know about that. But the other part I want you to talk a little bit more is about the pattern. I think you’re right. Most games are established in a set of, rather than an object-oriented tree, they’re set up as all of your units, all of your things in the game, all both your moving and your non-moving things, every building, every piece that a player puts into the world, every harvester, every sword bot, all of the pieces get our basic entity with different behaviors attached. So a sword bot gets the ability to move as a behavior, and some properties around that can be specified, so that we’re only designing moving once, and then lots and lots and lots of types can inherit, not inherit in the classic object-oriented way, but they can attach that behavior, and that knows how to modify the type itself, that knows, or modify the values correctly, or modify the state. Does that make sense? Tell me a bit more about that as well.
Claude
Yes, that makes sense, and your plan for the prototype is right. Let me take the server framework first, since I caused that confusion.
When I mentioned Colyseus, I meant a library that runs on your Node server and handles the plumbing around live connections. It accepts WebSocket connections, groups players into rooms (for you, one room per world), handles players joining and leaving, reconnects someone whose phone dropped off Wi-Fi, and sends each connected player their messages. It also comes with its own state sync library that does diffing and binary encoding.
With the plan you just described, you don’t need it. If the host sends the whole state every tick and you’re putting off diffing, Colyseus’s biggest feature goes unused. The connection handling is small at this scale: a plain WebSocket server in Node is a few dozen lines. And Colyseus can’t host single player inside a browser worker anyway. So skip it for now. If reconnection and rooms start to hurt later, look again.
Two small things are worth building into the communication layer from the start, because they’re cheap and they shape everything else.
First, give every command a sequence number, and have each state message say which commands were applied or rejected. That’s how the client knows its tap went through, and it costs almost nothing.
Second, even though you’re sending everything, have the host build each player’s message through a function like “the view for this player”, which at first just returns the whole world. When fog filtering matters later, you change that one function.
One correction on wording. You said “this object wants to do this”. In your design, only the player sends commands, and the commands go to buildings, not units: “Depot 3, run this route”, “place a wall here”. What a unit wants to do, like a Harvester deciding to head home because it’s full, is decided inside the simulation and never crosses the boundary.
Now the entity pattern. You have the core of it, with one refinement that matters a lot.
What you described is behavior attached to an entity, where the behavior knows how to change the thing it’s attached to. That’s one real version of the pattern. It’s roughly how Unity’s classic components work, where each component has its own update method. The stricter version, called entity component system, splits it into three parts, and I’d recommend it for your game.
An entity is just an id number. It has no code and no type of its own.
A component is plain data attached to an entity, with no logic at all. For movement, a Mover component holds a speed, the current path, a departure tick and an arrival tick. A Cargo component holds a capacity and what’s being carried. A Footprint component holds which tiles the entity occupies.
A system is the logic, and it lives outside the entities. The movement system runs once per tick, finds every entity that has both a position and a Mover, and advances them all. A Sword Bot and a Harvester both get a Mover, one with speed two and one with speed one. Movement is written once, in one place, and nothing is inherited.
Why split data from logic instead of putting update methods on components? Three reasons, and they’re all specific to your game.
One, syncing and saving become trivial. Since components are just data, the host can send or save any entity the same way, no matter what kind of thing it is. That’s what makes “send the whole state every tick” a few lines of code.
Two, the tick phases stay in order. Your design requires that every machine gets its stock handed out before any machine crafts, so nothing gets used on the same tick it’s made. With systems, that’s just the order you run them in: handout system, then crafting system. With per-object update methods, each object runs its whole update in turn, and enforcing that order becomes awkward.
Three, adding or removing a component switches behavior on or off. When a Harvester deploys, it gains a Footprint, and the systems that care about occupied tiles start seeing it. When it packs up, the Footprint is removed.
So where does Harvester-specific logic go? In a system that only applies to entities with a Harvester brain component. And the systems cooperate through the data, not by calling each other. When a Harvester’s cargo is full, the Harvester brain system writes a new path home into its Mover. The movement system, which knows nothing about Harvesters, moves it there. When it arrives, an unloading system moves the cargo into the Depot’s storage. Each piece of logic is small and only knows about its own components.
Finally, each type in the game, like Harvester, Sword Bot or Depot, becomes a recipe: a list of components with starting values. The Harvester recipe is a Mover at one tile per second, a Cargo hold of a set size, an owner building, and the Harvester brain. Those recipes are just data, which means your balance numbers, like speeds, costs, capacities and build times, all live in one table. When the design docs change a number, you change one line.
You
Okay, one thing you just said that I was a little bit confused about in your example. So, so you end up with a system, and then you have this thing called a harvester brain component. So the harvester brain component would be the component, if I understood you correctly, is just stored data. So that would be numbers that would be very specific only to the harvester itself, not that have nothing about any other shared attributes. And then the system, the harvester specific logic, would be a system that we would only put on those entities. And but then what you then later said, this the things it doesn’t, it said it doesn’t, each piece of logic in Spawn only knows about its own components. That part of it is where I got confused, because how does a harvester brain component know about the mover component? You said that the mover the harvester brain component can say go home when cargo is full. That’s the brain component talking to both the cargo and the mover.
Claude
You’re right, I said that loosely. Here’s the precise version.
Components never know anything. They’re just data, like a row in a table. The Harvester brain component doesn’t talk to anything. It holds the Harvester’s own state: which Depot is home, which patch it’s assigned to, and what it’s doing right now, like heading out, gathering or heading home. So yes, your understanding of that part is right. It holds only what’s specific to Harvesters.
All the talking is done by systems, and a system can read and write as many components as it needs. Each system declares up front which components it uses. That’s what I should have said instead of “only knows about its own components”: each system only touches the components it asks for.
Take the Harvester brain system. It asks for entities that have a Harvester brain, a Cargo hold and a Mover. Each tick, for each of them, it reads the Cargo to see whether it’s full and reads the brain to see where home is. If it’s full, it writes a new path home into the Mover and updates the brain’s state to “heading home”. So that one system touches three components, and that’s fine.
The movement system asks only for entities with a Position and a Mover. It moves things along their paths, and that’s all. It has no idea Harvesters, brains or cargo exist. A Harvester heading home and a Sword Bot on patrol look identical to it.
So the knowledge only goes one way. The specific system, the Harvester brain, knows about the shared components and uses them as tools. It knows that writing a path into a Mover makes something move. The shared system, movement, knows nothing about the specific ones. That’s why you can add a new unit type without touching movement: its brain system writes paths into its Mover, and movement takes it from there.
One more small correction: you don’t put a system on an entity. A system runs over every entity that has the components it asks for. Adding the Harvester brain component to an entity is what makes the Harvester brain system pick it up. The component is both the data and the signal that this entity takes part.
You
Oh, I see. So you attach whatever components as data to these types, and then basically the very first thing when a system runs is it says, give me, it does a filter and says, give me all of the components that have all of these specific types. So the movement system just says, just picks up types that can move, that have like a can move. All the entities in our world that can move, that are marked with that component. That component could have. Well, can you give it some more? By the way, I’m not listening anymore. So you can show tables and stuff again. Can you give a more concrete explanation here of what that might look like? Let’s just stay focused on the harvester, because one of the things I’m a little bit confused about is how the harvester brain system. That system is going to look for the harvest for the components that have a harvester brain attached to them, and that are also moving. So that’s going to be all the harvesters, and then it’s going to say, okay, that has cargo, and it’s going to say, okay, you’re full, head home. But what does head home mean with regards to the movement system? Does it directly change the data in the movement component, or does it change, like, how do they interoperate between each other if all of these different systems are running? If the movement system is the thing that knows how to move something across the map, then how does the harvester brain system interact with the movement system?
Claude
Yes, the brain system writes directly into the Mover component’s data. That’s the whole interaction. Systems never call each other. They share components, and the Mover component works as a contract between them: some of its fields are requests that brains write, and some are results that the movement system writes.
I need to refine one thing I said earlier. The brain shouldn’t write a path. It should write a goal: “get me to this tile”. Pathfinding belongs to the movement system. Otherwise every brain would need to know how to find paths.
The components (plain data)
type Position = { x: number; y: number } // tile coordinates
type Mover = { ticksPerTile: number // 20 = 1 tile/s at 20 ticks per second. Set by the recipe. goal: Tile | null // REQUEST: a brain writes "take me here" path: Tile[] // RESULT: movement fills this in nextStepTick: number // RESULT: when the next step happens status: 'idle' | 'moving' | 'arrived' | 'noPath' // RESULT: brains read this}
type Cargo = { capacity: number; item: Item | null; amount: number }
type HarvesterBrain = { home: EntityId // its Depot patch: Tile // the 2×2 site it gathers from state: 'toPatch' | 'gathering' | 'toHome' | 'unloading'}Each component is a table keyed by entity id, such as world.mover: Map<EntityId, Mover>. A query like query(world, 'harvesterBrain', 'cargo', 'mover') returns the ids that are in all three tables, sorted so the order is the same every tick.
The two systems
function harvesterBrainSystem(w: World) { for (const id of query(w, 'harvesterBrain', 'cargo', 'mover')) { const brain = w.harvesterBrain.get(id)! const cargo = w.cargo.get(id)! const mover = w.mover.get(id)!
switch (brain.state) { case 'gathering': if (cargo.amount >= cargo.capacity) { mover.goal = tileOf(w, brain.home) // the request brain.state = 'toHome' } break case 'toHome': if (mover.status === 'arrived') brain.state = 'unloading' break // 'toPatch' and 'unloading' follow the same pattern } }}
function movementSystem(w: World) { for (const id of query(w, 'position', 'mover')) { const pos = w.position.get(id)! const m = w.mover.get(id)!
if (m.goal) { // a new request m.path = findPath(w, pos, m.goal) m.goal = null m.status = m.path.length ? 'moving' : 'noPath' m.nextStepTick = w.tick + m.ticksPerTile } else if (m.status === 'moving' && w.tick >= m.nextStepTick) { const step = m.path.shift()! pos.x = step.x; pos.y = step.y if (m.path.length === 0) m.status = 'arrived' else m.nextStepTick = w.tick + m.ticksPerTile } }}The movement system never mentions Harvesters. A Sword Bot’s brain or a truck’s route logic writes mover.goal the same way and gets the same movement.
Who writes and reads each field
| Mover field | Written by | Read by |
|---|---|---|
ticksPerTile | the recipe, when the entity is created | movement |
goal | any brain (Harvester, crew, truck, Sword Bot) | movement |
path, nextStepTick | movement | movement, and the client for drawing |
status | movement | brains |
A good rule: every field has one owner system that writes it. The goal field is the one deliberate exception, because it’s a mailbox for requests. When something goes wrong, you know exactly which system to look in.
One trip home, tick by tick
Each tick runs the brain systems first, then movement.
| Tick | Harvester brain system | Movement system |
|---|---|---|
| 5000 | Cargo is full, so it sets goal to the Depot’s tile and sets state to toHome | Sees the goal, finds a 12-tile path, and sets status to moving |
| 5001–5019 | State is toHome but status is still moving, so it does nothing | Not time for the next step yet |
| 5020 | Nothing | Takes step 1 |
| … | Nothing | One step every 20 ticks |
| 5240 | Nothing | Takes the last step and sets status to arrived |
| 5241 | Sees arrived and sets state to unloading | Status is arrived, so it does nothing |
Notice that the brain sees the arrival one tick after it happens, because the brain runs before movement. That’s the same principle as your “no same-tick chaining” rule: a result produced on one tick is acted on the next. It keeps every tick’s behavior predictable no matter how many systems you add.
Two smaller points
- Re-routing works for free. If a co-op player recalls the Harvester mid-trip, the command handler sets its brain state and writes a new
goal. Movement sees a goal and replaces the path. Nothing else needs to know. - Some components hold no data. A component can just be a marker, like
Deployedwhile a Harvester is set up on its patch. Systems that care about occupied tiles query for it, and packing up removes it.
You
I think you model cargo incorrectly there. You seem to have modeled it as a single slot. You seem to have modeled as a single slot.
Claude
You’re right. Your design uses slots everywhere, and each slot holds one stack of one resource. My Cargo with a single item and amount only happens to fit the Harvester, which has one 50-unit slot. It’s wrong for trucks (2 slots) and crews (100 materials, so two stacks), and it misses the bigger point: vehicles and buildings should use the same storage component.
Corrected components
type Stack = { item: Item amount: number // never more than STACK_SIZE[item], e.g. ore = 50 sinceTick: number // when it arrived; buildings move out the oldest stack first}
type Storage = { slots: (Stack | null)[] // fixed length = slot count; null = empty slot}
const STACK_SIZE: Record<Item, number> = { Basalite: 50, Colite: 50, /* … */ }The rules from your docs then live in a few shared functions that every system uses:
// Can this storage take any more of this item? Either a partial stack of it// or an empty slot. A full slot can't take more, and stacks never mix.function canAccept(s: Storage, item: Item): boolean { return s.slots.some(st => st === null || (st.item === item && st.amount < STACK_SIZE[item]))}
function add(s: Storage, item: Item, amount: number, tick: number): number // returns the overflowfunction takeOldest(s: Storage): Stack | null // first in, first outThe brain check becomes !canAccept(storage, brain.patchItem) instead of cargo.amount >= cargo.capacity. That means “full” for a Harvester gathering Basalite is “no slot can take more Basalite”. That’s the same test a truck uses, which lets your docs’ “partial stacks stuck in truck slots” problem show up naturally.
One component, many recipes
| Recipe | Storage slots | Source |
|---|---|---|
| Harvester | 1 (one stack of ore = 50) | balance-and-simulations.md |
| Logistics Truck | 2 | units-and-missions.md, Tentative |
| Engineering Crew | 2 (100 materials per trip) | tier0-proposals.md, Tentative |
| Crashed Ship | about 6 | base-network-and-inventory.md, Tentative |
| Depot | 6 | base-network-and-inventory.md, Decided |
| Storage Silo | about 24 | base-network-and-inventory.md, Tentative |
This is where the pattern pays off. Unloading, a truck loading at a Depot, a crew restocking at home and the per-tick handout all move stacks between Storage components. So the stacking rules, first-in-first-out order and “never mix stacks” are each written once. Silo filters and per-slot truck loading rules would be separate components, such as SiloFilter and LoadingRules, sitting next to Storage. Only the systems that care about them would read them.
A naming collision to settle early
Your docs use “slots” for two different things: storage slots, which hold stacks, and unit slots in a Depot or Barracks, which hold vehicles (a Depot has 3). In the code these should be clearly separate components, for example Storage and UnitSlots. Otherwise “slot” will mean different things in different files.
You
Okay, that doesn’t matter much what you just talked about. I just was calling out that correction. But the point is that systems, one of the things I’m seeing about systems and one of the risks of them is you said generally a good rule of thumb is to have each component field be typically writable by only one system with the exception of goal. That’s the part that could become messy, right? Because now you have possible conflicts. This sort of less enforced rule is that maybe you’d have like a bunch of brain components. Each one of these brain components, or sorry, brain systems, and you would just attach this would be sort of by convention in your system, you might say brains are responsible for editing the goal fields. Otherwise, you potentially have lots of different systems that are acting on and running into collisions on the same fields, which would mean ordering would matter a huge amount. System ordering would matter a huge amount. Is system ordering a common design choice within ECS to sort of correct some of these things? I’m imagining like if you added to this harvester that some logic to the brain that says, okay, you’re taking damage, do something else, then you have to like add that to every single brain. But if you add some sort of component that some sort of thing that some of your components have flee logic, you know, is able to flee you know, if they’re being attacked, they can flee. And they’re designed to sort of run away, for instance, if they’re being attacked, rather than continue on their mission blindly. Now all of a sudden, that flee logic, you’d want to be able to say, okay, we have to attach that to all kinds of these, which is a great way of using this entity component system. Now you attach a whole different bunch of components to all the movers who can flee. So you’ve got now harvesters can flee and construction crews can flee, for instance. But now all of a sudden that same flee logic runs into conflict with the other logic, with the logic that runs the brain logic. How do you reconcile all of those?
Claude
Yes, you’ve found the real weak spot. The answer has two parts: system ordering is explicit and standard in ECS, and the usual fix for several behaviors fighting over one field is to give the field back to a single writer with an arbiter.
System ordering is standard and explicit
Every ECS framework has it. Unity’s ECS groups systems and lets you declare that one runs before or after another. Bevy has .before() and .after() with ordered sets. In your design it’s simply the list in step(): your tick phases are a system order.
The goal isn’t to avoid ordering. It’s to make it coarse and obvious. Group systems into phases, fix the order of the phases, and design the systems inside a phase so their order doesn’t matter, because they write different data. If two systems in the same phase both write goal, the result depends on which ran last, and that’s the bug to design out.
The fix: proposals plus one arbiter
Instead of every behavior writing mover.goal, each behavior writes its own proposal component, which it alone owns. One arbiter system picks the winner and is the only thing that writes goal.
type MissionIntent = { dest: Tile } // written only by brain systemstype FleeIntent = { dest: Tile } // written only by the flee system
const PRIORITY = { flee: 100, mission: 10 }
function movementArbiter(w: World) { for (const id of query(w, 'mover')) { const flee = w.fleeIntent.get(id) const mission = w.missionIntent.get(id) const winner = flee ?? mission ?? null // highest priority first const m = w.mover.get(id)! if (winner && !sameTile(winner.dest, m.currentDest)) m.goal = winner.dest }}The tick then looks like this:
| Phase | Systems | Writes |
|---|---|---|
| Sense | damage, threat detection | RecentDamage, ThreatNearby |
| Decide | Harvester brain, crew brain, truck brain, flee | each its own intent component |
| Arbitrate | movement arbiter | mover.goal, the only writer |
| Act | movement | path, status, position |
| Resolve | unloading, building, combat | storage, health |
Inside the Decide phase the order doesn’t matter, because each system writes only its own component.
What this buys you in the flee example
- Brains never learn about fleeing. The Harvester brain keeps writing “my mission wants me at the patch” every tick. While a
FleeIntentexists, the arbiter ignores it. - Resuming is free. When the flee system decides it’s safe and removes
FleeIntent, the arbiter sees the mission intent again and sends the Harvester back to work. Nothing needed to remember where it was going. - Fleeing is opt-in by component. Add a
CanFlee { hpThreshold, safeSpot }component to the Harvester, Engineering Crew and truck recipes. The flee system queries for it. A Sword Bot simply doesn’t get it. - New overrides don’t touch old code. “Return home for repair” or the automatic recall in your progression docs becomes another intent component with its own priority. No brain changes.
One gotcha this exposes
With several sources of goals, a brain can’t just check “did I arrive?”. The Harvester may have arrived at the flee spot. Brains should check that they arrived where they asked to go: for example, the mover records arrivedAt, and the brain compares it with its own intent’s destination. That’s an easy mistake to make, and it produces strange behavior, like a Harvester trying to gather in the middle of nowhere.
When this isn’t enough
Proposals plus priority covers simple “this behavior wins over that one” cases. Your units stay simple by design (missions to buildings, no micro), so that should be enough for them. Aliens are where you might need more: targeting with memory, commitment and spreading out. That’s where games reach for utility AI, where each option scores itself and the highest score wins, or for behavior trees. Both still fit the same structure, because they live inside one Decide system and write one intent.
You
<task-notification> <task-type>artifact-auto-react</task-type> <summary>1 artifact auto-reply subscription paused by the interrupt (comments sent to Claude meanwhile are answered then)</summary> The user's interrupt paused it and the watch is kept; when the user asks you to publish this artifact again, that publish resumes it, or resume_replies if the user asks, as does the user's next typed message — each only on hosts that pass typed input through as the user's, and comments sent to Claude in the meantime are answered then; publishing without being asked, while handling a notification or a wake-up, leaves it paused. Do not republish or resume just to re-enable auto-replies unless the user asks. </task-notification>
You
[interrupted]
You
Oh, I think I get it now. So it by your movement arbiter example, just so that I understand, confirm that I understand what you’re saying. In your, when you have a set of you basically end up so that you generalize the components for the proposals or the desire of the intents or the writable fields that the brain’s write. So in your case, you said mission intent and flee intent. And so mission intent might be the brain writing it, might be specialized to the harvester. Flee intent might be the flee system writing it. The point is that these are all part of, known as like grouped as it’s as part of a larger movement system that you’re thinking about. So you don’t have to rewrite or share. This is how you’re sharing it across all these different types. The movement arbiter isn’t the same perhaps as the movement type, but it doesn’t do wayfinding or anything else. But what it is it’s saying, I’ll be the one to write the mover, where the goal. I’ll be the one to I’ll own where to write the movement state, and that matches your 1-to-1 writable field. That only one system writes each field. That tracks for me. That makes sense. One system writes each field. So if you have more than one system trying to write to the same field, you need to inherently write some arbitration code that runs between them. You want to write another system to say, okay, a simple system to be able to say, I’m aware of all three of these. I can figure out which one wins. And in each of those intents, there might be like, in each of those intents, in the flee intent, it might be that like we include like the percentage damage taken or something like that, or the size of the creature attacking it or whatever, something the game might need to understand how much this creature wants to flee or how much the, basically there are any data the arbiter needs to make the decision. It’s not an AI, but it’s like the beginnings of one. It’s a decision tree that you’re setting up for this specific set of conditions whereby you have to make the decision. That’s how the logic comes together. But then the output of that system is what writes movement. And so I see. So in your example too, your movement arbiter looks for all the movements and then it sees if they have these other. So the there’s optionality there that the flea might not be on a component at all. If a flea isn’t attached to that component, sorry, to that entity, if the flea component is not attached to that entity, then the movement arbiter never needs to care about it. It skips over it because it’s never, that type never flees. So that makes perfect sense. So if you add a new type, a new thing that needs to be established between in the arbitration phase, you need to add it to the arbiter type. A new behavior in that decision. But it’s not like you need to go now and add that same possible component to every single movable type unless you want to have every single movable entity, unless you want to have it have that ability.
Claude
Yes, you’ve got it. That’s the pattern exactly. Three small refinements.
1. “Part of a larger movement system” is right as a way of grouping, but they’re separate systems in code. Think of movement as a feature made of several pieces: the intent components, the arbiter, and the movement system that does pathfinding and stepping. They’d sit together in one folder, like sim/movement/, and you’d reason about them together. But each one runs on its own in its phase and talks to the others only through component data.
2. Push the judgment into the intents and keep the arbiter dumb. You’re right that the flee intent should carry whatever the decision needs. I’d have the flee system turn that into a single number, though, rather than handing the arbiter raw facts like damage taken or attacker size. The flee system knows what fleeing means: “I’ve lost 60% of my health and the attacker is big, so my urgency is 80”. The arbiter then just compares numbers. That keeps all knowledge about fleeing in the flee system. It’s also the first step of utility AI, where each option scores itself and the highest score wins.
Taken one step further, this removes even the step you mentioned of editing the arbiter for each new behavior. Give every mover one generic intents component where each behavior owns its own entry:
type MoveIntents = { [source: string]: { dest: Tile; priority: number } }// the Harvester brain writes intents.mission, the flee system writes intents.flee, auto-recall writes intents.recallThe arbiter picks the highest priority without knowing what any of them are. Each behavior still writes only its own entry, so the one-writer rule still holds. For a prototype with two or three intents, the explicit version I showed earlier is easier to read. Switch to the generic one when you add a fourth.
3. Optional components are opt-in, exactly as you said. If an entity has no flee intent, the arbiter skips that branch. If an entity has no CanFlee component, the flee system never even looks at it. A new behavior only reaches the types whose recipes include its component. The Sword Bot never gets fleeing unless you decide it should.
You
I see. Is there a common naming for these? You called it the can flee component. Is it typically like, is there a typical naming for them? So that’s first part. And then second part, I get that generic. I see what you mean by the generic move intents part. Like, you might not want to, you still have an arbiter. But the arbiter becomes incredibly simple, literally pick the value, pick the highest value, sort and pick. So it can be like, you even could have it be like an intent arbiter, and it would just be like it would be really, really simple. Somehow it would be incredibly simple, basically, to be able to say just like, all logic I’m going to do is find the highest value in of this list, and that’s it. I get why that has value, and I also see why you don’t want the movement arbiter to reach too much into the specific data about fleeing, because it starts getting more complex. However, if, as you said, the number is pretty small, then you could write a little more nuanced logic there without it getting too abstract. Like, you would be able to just write it, I guess, like, in the movement arbiter, you’d be able to say, okay, well, is this damage taken, or is this enemy bigger, or whatever. You could use that information, but I can see why it is potentially a code smell or a potential problem down the road as you grow, because it ties the components together in a way that makes them know a lot about each other. If you suddenly want to remove something about change something about fleeing, you need to change something about the arbiter.
Claude
Naming
There’s no single standard, but most ECS codebases settle on the same few patterns. What helps most is picking one convention per kind of component, so the name tells you what the thing is for.
| Kind | Convention | Examples |
|---|---|---|
| Data the entity has | Plain noun | Position, Health, Storage, Mover |
| Settings that switch on a behavior (usually set by the recipe) | …Behavior or …Settings | FleeBehavior { hpThreshold, safeSpot } |
| A state it’s currently in, often with no data | Adjective or past participle | Deployed, Fleeing, Destroyed |
| Marker with no data | Adjective, or a …Tag suffix (common in Unity) | Airborne, PlayerOwnedTag |
| A wish for another system to act on | …Intent or …Request | MoveIntents, BuildRequest |
| Something that happened this tick | …Event | DamageEvent, ArrivedEvent |
| Systems | Verb phrase, or a …System suffix (Unity style) | moveUnits, arbitrateMovement, or MovementSystem |
CanFlee is a name people do use, but FleeBehavior is more common, and it reads better once it has settings in it. So fleeing would be three pieces: FleeBehavior from the recipe (the Harvester and crew have it, the Sword Bot doesn’t), Fleeing while it’s happening, and a flee entry in MoveIntents.
The arbiter
Yes, you’ve got the trade-off exactly. With two or three intents, writing nuanced logic directly in the arbiter is perfectly reasonable, and it’s often clearer than an abstraction. A useful line to draw:
- The arbiter may know the policy: which behaviors exist and what overrides what.
- It shouldn’t know the reasons: what counts as heavy damage, or how big an attacker is.
The warning sign is the one you named: you change how fleeing works and find yourself editing the arbiter. When that happens, move that judgment into the flee system and have it hand over a number.
One more thing in favor of keeping the policy in one place: it’s a game design tool, not just code tidiness. A single table that says flee beats auto-recall beats mission, with base priorities, is something you’ll want to tune and read at a glance. Even with fully generic intents, I’d have each behavior take its base priority from one shared table and add its own urgency on top. Then “what overrides what” stays readable in one place, and the details stay in each behavior.
You
How do games typically deal with the event? You mentioned events as a possible component, and that a component could be potentially added to an entity. I assume that if there’s an event, that components are dynamic, and they can be added to an entity at any time. That wasn’t part of my original understanding, but they could be, when we were talking about fleeing, you said like can flee is a component, but like fleeing as a verb or as a, is like a different thing. It’s sort of like, it means it’s happening now. And you also talked about events happening, but just for one tick. So it seems like, and you also talked about tags. So it seems like components can be attached to entities on the fly by systems, that systems are the things that write what either, the game world or the systems themselves can add components to another type. So, you’ve got an entity that’s a car and it’s driving along, and then, there’s a button in the interface that says transform. And when the thing transforms, it sprouts wings, and all of a sudden it can fly, and now all of a sudden it’s an airplane, and so suddenly it’s flying, and it gets all the relevant systems that would change it from something driving to something that’s flying. So pathfinding would work as a flyer, as a driver, et cetera, et cetera. As I’m learning about this, it sounds like that would happen a lot of componentry changing hands. In what world would a single step event happen and then want to, and in what world, in our perhaps back to our harvester example to keep things sort of under control and understandable, in what world, as we talk about our harvester, when might this happen? And also, if something takes damage, is that going to be more like an event? Is that going to be like it happens to take some damage, and then it gets a type on it, and it gets a component added to its attribute, and then it loses that same loses it one tick later, but that has caused it to alter something significant.
Claude
Yes. Components can be added and removed at any time, and that’s one of the main ways ECS works. Your transform example is exactly right. The transform command removes the GroundMover settings and adds Flyer settings. From the next tick, pathfinding queries see it as a flyer, the road-following system stops picking it up, and nothing else needed to change. An entity’s “type” is just whatever set of components it has right now.
There are two different things here that are easy to blur, though: states and events.
States vs events
| State | Event | |
|---|---|---|
| Meaning | Something that’s true for a while | Something that happened, once |
| Lasts | Until a system removes or changes it | One tick, then it’s gone |
| Examples | Deployed, Fleeing, Health, RecentDamage | “took 12 damage”, “arrived”, “destroyed” |
| Usually stored as | A component on the entity | A message in a queue for the tick |
Most games keep events out of components. Putting a DamageEvent component on an entity runs into trouble quickly. Two aliens hit the same Harvester in one tick, so you’d need a list. Adding and removing components every tick also churns the data structures for no benefit. So events usually go into queues, one list per event type, that systems append to and read from. Bevy calls these Events, and many engines have something similar. The queue is cleared on a fixed schedule.
The key rule is: an event is a message, not memory. If something needs to be remembered, the system that reads the event turns it into state.
Harvester example: taking damage
| Tick | System | What happens |
|---|---|---|
| T | Combat | An alien hits the Harvester. Appends { type: 'damage', target: H, amount: 12, source: alien7 } to the damage queue |
| T+1 | Health | Reads the damage queue, lowers Health (it’s the only writer of health), and updates the RecentDamage { lastTick, attacker, total } component, adding it if missing |
| T+1 | Flee | Sees Health below the threshold in FleeBehavior and a recent attacker. Writes the flee intent and adds Fleeing |
| T+1 | Arbiter, then movement | The flee intent wins, so the Harvester turns for home |
| end of T+1 | Cleanup | The damage event is cleared. Its effects live on in Health, RecentDamage and Fleeing |
| later | Flee | No damage for a while, so it removes the flee intent and Fleeing. The mission intent wins again |
So the damage was an event for one tick. “Was recently attacked” and “is fleeing” are states that last as long as they’re true.
A timing rule worth adopting
Decide once when events can be read. I’d suggest: an event produced on tick T is read on tick T+1 and cleared at the end of T+1. Then the order of systems never decides whether a reaction sees an event, because every system sees every event from the previous tick. It’s the same principle as your “no same-tick chaining” rule, and 50 ms of delay before a reaction is invisible.
Other Harvester moments
- Arriving at its patch: movement emits an
arrivedevent. The brain reads it, addsDeployedand aFootprint(states that last while it gathers), and starts the 30-second deploy. - Packing up when full: the brain removes
DeployedandFootprint. The tile-occupancy systems stop seeing it. - Unloading at the Depot: the stacks move into the Depot’s
Storage(state), plus anunloadedevent. - Destroyed:
Healthreaches zero, which emits adestroyedevent. The respawn system reads it and starts the 30-second rebuild in the Depot’s unit slot. Per your docs, the replacement inherits that slot’s mission and the cargo is lost.
Two practical details
- Don’t add or remove components while a system is looping over a query. Changing the tables mid-loop causes skipped or repeated entities. Engines handle this with a “command buffer”: systems queue structural changes, which are applied between phases. Unity and Bevy both work this way. Your simple maps make the changes cheap, but you still want the same discipline.
- The same event queues feed the client. The
eventslist in the state message we sketched earlier is just these queues, filtered by what each player can see. “Unloaded” becomes a “+50 Basalite” popup, and “destroyed” becomes an explosion sound. The sim emits each event once, and both other systems and the screen react to it.
You
This has all been really fascinating as a little detour, but I feel like we’ve gotten into the weeds. And I want to know how much of this that we just talked about is relevant to the prototyping that I see, the 2D version of the world. How much of this matters, or how much of this is something that I would roll my own on, given the type of situation proposal, or whether an engine would be useful here to do an ACS setup. Because it seems like, while I can see how an engine developer, or even me, could roll my own here, but there are, and it isn’t wildly complicated to roll my own here, there are a lot of choices to be made, and making a mistake in those choices, it can be a pain. So things where adding structure, having a framework to add structure can help by the fact that there’s a convention for you to follow and you’re not rolling your own as an amateur game developer. So here’s what I want for my type, for my game. This is the game. Absolutely, the most important part about this game is the simulation. Nailing it is the most important thing. Everything else is more standard. But this type of game needs the simulation to be known. So what is the right weight for the prototype and for the super simple squares on a web page version of that prototype? How much of this needs to be built, and which portions of it are which portions of it matter at the start?
Claude
Short version: almost everything from the detour is convention, not infrastructure. It costs nothing to follow while you write code, and very little of it needs building before you start on the game itself. The things that matter on day one are a handful of boundaries that are cheap now and painful to add later.
Build on day one (small, and hard to add later)
| What | Why now | Size |
|---|---|---|
| The sim as a pure TypeScript package: no screen code, no clock, seeded randomness, whole numbers | It’s the game. It has to run headless for tests and balance sweeps from the start | A folder and a rule |
Fixed tick with an explicit phase order in step() | Your design depends on phase order (handout before crafting, no same-tick chaining) | One function |
| Entities as plain data with components, plus a recipe table holding the balance numbers | Retrofitting this onto class-based code is a rewrite. Doing it first is free | A small library or about 100 lines |
| The boundary: commands in with sequence numbers, the full state plus events out every tick, the host in a Web Worker | You chose this deliberately. It’s tiny when there’s no diffing | A few dozen lines |
| Tests that drive the sim with no screen | This is how you’ll know the sim is right | Ongoing |
Use as habits (no code to build)
These are the rules from the detour. Follow them when you write each system:
- One system writes each field.
- Brains write intents, not paths.
- Events are read on the next tick and then cleared.
- State lives in components, and events are just messages.
- Don’t add or remove components while looping over a query.
Skip for now
- Diffing, binary encoding and fog filtering. The “view for this player” function just returns everything.
- Smoothing between ticks, and plan and rate field types. Squares can jump once per tick.
- A real server, WebSockets and Colyseus. Single player in a worker is enough until the game is fun.
- The generic intent arbiter. Write it when a second behavior, like fleeing, actually competes for movement.
- Performance work, typed arrays and the art pipeline.
Library or build your own ECS?
Since you’re on the web, no engine is involved. Phaser and Pixi don’t impose ECS. The choice is a small library or your own:
- Miniplex is the one I’d look at first. Entities are plain TypeScript objects, components are optional properties, and queries are things like
world.with('position', 'mover'). Query membership updates automatically as components are added or removed. Plain objects also mean “send the whole state” is basicallyJSON.stringify. It gives you conventions without much framework. Check its current version and status when you set up the project. I haven’t checked recently. - bitECS is much faster, but it stores components as arrays of numbers, which is awkward for nested data like storage slots and paths. That’s not worth it at prototype scale.
- Your own, about 100 lines: a map per component, sorted queries and deferred adds and removes. It’s perfectly viable, and Claude can write it in one go. You’d be choosing conventions yourself, though, which is the risk you named.
Determinism matters less than it sounds. The server owns the tick, so clients can’t desync. You only need determinism for tests and replays, and avoiding the wall clock and unseeded randomness covers that.
Where your effort actually goes
The hard part is the rules your docs already describe: the per-tick netting and due-tick handout, stack placement and first-in-first-out storage, networks merging and splitting, Harvesters, crews building ghosts, then noise and aliens. You’ve also already written the oracle for much of it. scripts/sim/ holds the fair-share cases, the 800-factory sweep, the Tier 0 checks and the Tier 1 pacing sim. Turn those into tests for the TypeScript sim. If its results match the Python ones, the real game behaves the way you designed it.
A sensible order:
- Sim only, no screen. The Crashed Ship, a Harvester, a Basalite patch, Depot storage and a crew building the first Depot. Tests check the 3:20 timing from your docs.
- The plain client. Host in a worker, squares with letters, panels, tap to place a ghost.
- Port the netting and handout rule, tested against the fair-share cases and the sweep.
- The rest of Tier 0, then noise and aliens.
You
Okay, so one, I think while Claude will be very helpful in writing a lot of these things, able to get things fixed up, your estimates of 100 lines here and there seem relatively low. That’s not as important right now. What is important is, while I understand most of our detour into ECS, some of it, I’m still struggling with some pieces of it. Capturing phase order. So we glossed over that quite a bit. And capturing things like queuing and modifications and being able to write a value right in the middle of a phase, you said this could be problematic, but gave no solution for, I mean, maybe you recommended a queuing solution. Every time you do something like that, I hear, oh, wait, that’s a complexity that I could have gotten wrong initially and would have potentially broken the game a little bit. As we worry about weird bugs and behaviors, we might end up bug fixing those and packaging them in weird ways rather than correctly architecting them to handle the correct setup. So that’s what I’m afraid of here. Having a framework in some ways provides the conventions more than even the rules and conventions, rather more than even the actual code itself, even if the code would be relatively simple. I will say that the more I hear about ECS, the more I think it might be a good system for a large-scale factory simulation game because of all the moving pieces. There’s a lot of code that has to be run on every tick. There’s a lot of pieces that have to be queried. Potentially millions of individual things between aliens and other things. At least in the hundreds of thousands. Well, we won’t get to factorial scale because every resource sits on every belt. We won’t be at that level. We can simplify there. And we’ve probably bought ourselves a pretty good amount of savings by having shared base inventories. We still have pathfinding, lots of enemies, and a lot of potential pieces on the screen, and interconnected systems. You said earlier that, for instance, you’re only rendering what’s in view, and that works, given that we don’t have any shared state, shared simulation. But there is a lot of logic then running on the server. The server still is running the entire, everything in the world on every tick. Has to do. Because otherwise you don’t know. You really can’t put anything in a game like this into suspension. Except for the things you explicitly do. And that’s actually something I think the component system would be helpful at. Being able to filter out components because they’re idle would be incredibly helpful in a performance for performance and also data reduction. So while I’m a proponent of ECS, I still feel like maybe there’s a gap in my knowledge. It makes me uncomfortable about authoring it.
Claude
You’re right about the line counts. They were low, especially once you include tests and the edge cases. And your worry is the right one. The danger isn’t that the code is hard. It’s that an unstated rule gets broken and the resulting bug gets patched in an odd place instead of fixed at the source. The answer is to turn the conventions into written rules that the code checks for you. That’s most of what a framework gives you anyway.
Here’s the part I glossed over, done properly.
1. Phase order: a schedule in one place
A schedule is an ordered list of phases, and each phase is an ordered list of systems. It lives in one file, and step() just runs it:
export const schedule: Phase[] = [ { name: 'commands', systems: [applyCommands] }, { name: 'sense', systems: [applyDamage, detectThreats] }, { name: 'decide', systems: [harvesterBrain, crewBrain, truckRoutes, flee] }, { name: 'arbitrate',systems: [arbitrateMovement] }, { name: 'act', systems: [moveUnits, handOutStock, advanceCrafting, buildGhosts] }, { name: 'settle', systems: [placeSurplus, unloadCargo, updateNoise] }, { name: 'cleanup', systems: [clearOldEvents] },]Reading this one file tells you the order of everything that happens in a tick.
2. The three kinds of “write in the middle”, and the rule for each
“Writing mid-phase” isn’t one problem. It’s three, each with a clear rule.
a. Structural changes during a loop (adding or removing components, creating or destroying entities). Changing the tables while a system is looping over them makes it skip or repeat entities. Rule: systems never make structural changes directly. They record them in a command buffer, and the scheduler applies them at the end of the phase.
function harvesterBrain(w: World, cmd: Commands) { for (const e of w.query('harvesterBrain', 'mover')) { if (justArrivedAtPatch(e)) cmd.add(e.id, 'deployed', {}) // takes effect at end of phase }}Unity calls this an EntityCommandBuffer, and Bevy calls it Commands. In both, the point where buffered changes are applied is called a sync point.
b. Two systems in the same phase touching the same field. If flee writes something that harvesterBrain reads in the same phase, the result depends on which runs first. Rule: inside a phase, no two systems write the same component, and no system reads what another system in that phase writes. Data moves forward across phase boundaries, never sideways within one. With that rule, the order of systems inside a phase never matters.
c. Order within one system’s own loop. In the handout, whichever machine is served first gets the stock. That isn’t an architecture problem, it’s a game rule. Rule: every loop iterates in a defined order, by sorted id, or by whatever the design specifies. Your due-tick rule is that specification.
Ordinary value writes are fine. A system changing health or position in place is normal. The rules above only cover the three cases that cause trouble.
3. Have the code check the rules
This is the part that turns conventions into guarantees. Each system declares what it reads and writes:
export const harvesterBrain =
## You
_[interrupted]_
## You
<task-notification><task-type>artifact-auto-react</task-type><summary>1 artifact auto-reply subscription paused by the interrupt (comments sent to Claude meanwhile are answered then)</summary>The user's interrupt paused it and the watch is kept; when the user asks you to publish this artifact again, that publish resumes it, or resume_replies if the user asks, as does the user's next typed message — each only on hosts that pass typed input through as the user's, and comments sent to Claude in the meantime are answered then; publishing without being asked, while handling a notification or a wake-up, leaves it paused. Do not republish or resume just to re-enable auto-replies unless the user asks.</task-notification>
## Claude
I stopped there. The rest of that answer was going to cover:
- **Checking the rules in code:** each system declares the components it reads and writes. A debug-mode check then fails loudly if two systems in one phase write the same thing, or if a system makes a structural change outside the command buffer. Bevy does this kind of check automatically.- **Sleeping entities:** your instinct is right. Idle machines get an `Asleep` marker, so per-tick systems skip them, and a change on the network, like stock of the resource they're waiting for arriving, wakes them.- **Frameworks:** no TypeScript ECS library I know of comes with Bevy's full set of conventions. The practical option is to stay in TypeScript and copy Bevy's ideas: schedules, command buffers, events and declared access. Bevy itself (Rust) gives you all of it built in, but it costs you the web-first workflow.
Do you want me to finish the full answer, or go somewhere else from here?
## You
By the way, I'm back on. You can rephrase that because I'm back on audio only now.
## Claude
Sure, here's the whole answer in spoken form.
First, you're right that my line counts were low, especially once you count tests and edge cases. And your worry is the right one. The danger isn't that the code is hard. It's that an unwritten rule gets broken, and the bug gets patched in some odd place instead of fixed at its source. The answer is to turn the conventions into written rules that the code checks for you. That's most of what a framework gives you anyway.
Let me start with phase order, since I glossed over it. A schedule is just an ordered list of phases, and each phase is an ordered list of systems. It lives in one file, and the step function simply runs it from top to bottom. For your game it might read like this. First, apply the player's commands. Then a sensing phase, where damage is applied and threats are noticed. Then a deciding phase, where the Harvester brain, the crew brain, truck routes and fleeing each write their intents. Then an arbitration phase, where one system picks which intent wins for each unit. Then an acting phase, where units move, stock is handed out, crafting advances and crews build. Then a settling phase, where surplus is placed into storage, cargo is unloaded and noise is updated. And finally a cleanup phase, where old events are cleared. Reading that one file tells you the order of everything that happens in a tick.
Now, writing in the middle of a phase. That isn't one problem, it's three, and each has a simple rule.
The first is structural changes: adding or removing a component, or creating or destroying an entity. If a system changes the tables while it's looping over them, it can skip entities or visit one twice. The rule is that systems never make structural changes directly. Instead they write them down in what's called a command buffer, basically a to-do list, and the scheduler applies the whole list at the end of the phase. So when a Harvester arrives at its patch, the brain doesn't attach the Deployed marker right then. It writes "add Deployed to Harvester 12" to the list, and that happens when the phase ends. Unity and Bevy both work exactly this way, and the moment the list is applied is called a sync point.
The second is two systems in the same phase touching the same data. If the flee system writes something the Harvester brain reads during the same phase, the result depends on which ran first. The rule is that inside a phase, no two systems write the same component, and no system reads what another system in that phase writes. Data moves forward from one phase to the next, never sideways within a phase. Follow that, and the order of systems inside a phase stops mattering.
The third is order inside a single system's own loop. For example, during the handout, whichever machine is served first gets the stock. That isn't an architecture problem, it's a game rule. The rule is that every loop runs in a defined order, either by sorted id or by whatever the design says. Your due-tick rule is exactly that kind of definition.
Ordinary writes, like a system changing a unit's health or position, are completely fine. Those three rules cover only the cases that cause trouble.
Next, how to make the code check those rules, so you don't have to remember them. Each system declares up front which components it reads and which it writes. The Harvester brain would declare that it reads cargo and the mover's status, and writes the Harvester's own brain state and its movement intent. In development builds, the scheduler checks those declarations. If two systems in one phase both write the same component, or if one reads what another writes in that phase, the game refuses to start and tells you which systems clash. And if a system tries to add or remove a component directly instead of through the command buffer, it fails immediately. A broken rule becomes an error on day one instead of a strange bug three weeks later. Bevy does this kind of checking itself, and it's a big part of why people trust it.
Now your point about sleeping. Your instinct is right, and it's one of the biggest performance wins in a factory game. A machine with nothing to do, for example one waiting for Ferrite that isn't coming, gets an Asleep marker. Every per-tick system asks only for entities that aren't asleep, so a sleeping machine costs nothing. The trick is waking it up. Instead of every sleeper checking every tick whether its input arrived, each network keeps a short list per resource of who's waiting for it. When Ferrite lands in that network's storage, the system that placed it wakes the machines on the Ferrite list. Factorio uses the same idea, and your design docs already talk about sleeping drills. The same marker helps networking later too, because sleeping things don't change and so never need to be sent.
The same thinking applies to the whole world. The server has to advance everything every tick, but "advance" can be very cheap for things that are far away and quiet. A hive no player is near doesn't need every alien walking around. It can be simulated as a summary, like how many aliens it has and how fast it's growing, until something comes close. That's sometimes called level-of-detail simulation. Between sleeping, summaries and the shared network inventories instead of belts, hundreds of thousands of entities is realistic. The work goes into the small fraction that's actually active.
Last, frameworks. I don't know of a TypeScript ECS library that comes with all of these conventions built in: schedules, command buffers, events and declared access with checking. The libraries I know of give you entities, components and queries, and leave the rest to you. Bevy, the Rust engine, has all of it, and its documentation is probably the best written explanation of these conventions anywhere. You have two honest options.
The first is to stay in TypeScript and copy Bevy's ideas on purpose. That means a schedule file, a command buffer, event queues read one tick later, and declared reads and writes with the checks I described. That's a small, well-understood core. Once it exists, every system you add follows the same shape, and the checks catch mistakes.
The second is to use Bevy itself. You'd get all of it for free and very fast, and it can run as a headless server and also in the browser. But you'd be learning Rust and a game engine at the same time. Its API changes between versions, so AI help is often out of date, and you'd lose the quick save-and-refresh loop that makes the web nice to prototype in.
I'd take the first option. Build that small core first, as its own step, before any game logic: the schedule, the command buffer, the event queues and the access checks, with tests. Then build the Harvester loop on top of it as the first real example, and read through it with me until it feels natural. That's the best cure for the discomfort you're describing: seeing the rules work on one small, real piece of the game before trusting them with the rest.
If you want, the next step could be writing this down as an architecture doc in the design folder, so the rules are fixed before any code exists. Or we could go straight to building that small core and the Harvester loop as a first spike. Which would you prefer?
## You
I was already an expert in Rust with your recommendation change, Bobby. It seems like that's your strong recommendation. And it seems, I assume, you vetted it to see that people really like it. And performance is fundamentally an important characteristic of this game. So I'm wondering if that would change your opinion a little bit, because while I know that it is still not correct, probably for day one prototype, it is pretty interesting to utilize something so well documented if you're saying it's so good. So I want you to vet it a little bit. First, just briefly vet it. And like, either way, we got to, even if we're copying the ideas, we need to vet the ideas a little bit.
## Claude
One correction before I start: I didn't recommend Bevy itself. I recommended staying in TypeScript and copying Bevy's conventions, and I called Bevy the best written explanation of them. But your question is a fair one, so I looked into both Bevy and the ideas themselves.
The ideas first, because they matter either way. They hold up well. The three most serious ECS designs I know of are Bevy, Unity's ECS and a C library called Flecs. All three independently arrived at the same core conventions we discussed. Each runs systems in an ordered schedule. Flecs even has named built-in phases for update, validate and store, much like our sense, decide and act. All three use a command buffer for adding and removing components, applied at sync points. All three treat two systems that touch the same data without a defined order as the main source of bugs. Bevy can detect that automatically. Its schedule settings have an ambiguity detection option that can warn or fail when two systems have conflicting access but no defined order. It's off by default, and you'd turn it on. Bevy also inserts sync points for you when a system that uses its command buffer is ordered before another. And Bevy's event queues keep each event around for two updates, so a reader that runs before the writer on one tick still sees it on the next. That's essentially our "read events on the next tick" rule. So I'm confident these conventions aren't my invention or a hobbyist pattern. They're where the mature systems converged.
Two cautions came out of the research, and they apply in any language. First, Bevy doesn't promise any particular order when you loop over a query. In practice it follows the order entities were created, so anything where order changes the outcome, like your handout, should sort explicitly. Second, decimal math can give slightly different answers on different machines, such as native code versus the browser, which is one more reason to keep the sim in whole numbers.
Now Bevy itself. The current version is 0.20, released in October 2026. It had 227 contributors and over 800 changes, and it ships about three releases a year, so it's very much alive. It has a fixed-timestep schedule, it runs headless for a server, and it runs in the browser through WebAssembly. For networking, the main library is called Lightyear. It's built for server-authoritative games, uses a replication library called Replicon for syncing state and interest management, and works in the browser. The current Lightyear targets Bevy 0.20. So in features, it fits your architecture remarkably well.
The downsides are real, though. It's still before version one, and every release breaks things. One 2026 guide estimates one to three days of migration work every three or four months. The list of commercial games shipped with it is short. The best-known name associated with it is Tiny Glade, but I couldn't confirm from more than one source how much of Bevy that game uses. Its editor and its UI toolkit are still young, and its UI widgets are still being added release by release. That matters a lot for a game that's mostly panels and overlays. And for coding with Claude, the frequent API changes mean my knowledge of Bevy can lag behind the version you're using. You'd have to pin a version and keep the docs close at hand.
So does it change my recommendation? It depends on one thing: how comfortable you already are in Rust. Your message came through a bit garbled, so I'm not sure if you were saying you are a Rust expert or asking what changes if you were.
If you're not already fluent in Rust, I'd stay in TypeScript and copy the conventions. Learning Rust, Bevy and game architecture at the same time is too much at once for a prototype.
If you are fluent in Rust, there's a middle path I'd seriously consider. You'd write the simulation in Rust using Bevy's ECS library on its own, without Bevy's renderer or UI, and keep the client as a TypeScript web page. The boundary we designed makes this natural, because the client only ever sees commands and state messages, so it never needs to know what language the sim is written in. In single player, the Rust sim compiles to WebAssembly and runs in the browser's worker. On the server, it runs as fast native code. You'd get the enforced conventions, the ambiguity checks and Rust's speed for the part of the game that matters most, while keeping web pages for the menus, where Bevy is weakest. The cost is two languages, slower build times than save and refresh, and the Bevy version churn. That churn mostly affects the renderer and UI, which you wouldn't be using, though not entirely.
So which is it? Are you already comfortable in Rust?
Sources, which you can skip in audio: [Bevy 0.20 release notes](https://bevy.org/news/bevy-0-20/), [Bevy news](https://bevy.org/news/), [Bevy 0.19](https://bevy.org/news/bevy-0-19/), [AlternativeTo on Bevy 0.20](https://alternativeto.net/news/2026/10/bevy-0-20-brings-major-rendering-shading-and-ui-enhancements-to-open-source-game-engine/), [Lightyear on GitHub](https://github.com/cBournhonesque/lightyear), [Lightyear releases](https://github.com/cBournhonesque/lightyear/releases), [ScheduleBuildSettings docs](https://docs.rs/bevy_internal/latest/bevy_internal/ecs/schedule/struct.ScheduleBuildSettings.html), [auto-inserted sync points in bevy_ecs](https://docs.rs/crate/bevy_ecs/0.19.0/source/src/schedule/auto_insert_apply_deferred.rs), [repame-sim notes on Bevy query order](https://docs.rs/crate/repame-sim/0.3.25/source/src/lib.rs), [Strayspark 2026 Bevy indie guide](https://www.strayspark.studio/blog/bevy-rust-game-engine-2026-indie-guide), [Bevy in production list](https://github.com/ThierryBerger/bevy_awesome_prod), [Engine Ranked Bevy review](https://engineranked.com/article/bevy-engine-review-2026/), [Hacker News on shipped Bevy games](https://news.ycombinator.com/item?id=31044582).
## You
I've never written Rust in my life. However, I've written very little TypeScript in my life too. So it's not really here nor there. Although how comfortable Claude is in writing these languages is going to be really important, because honestly, it's going to do almost all the writing. So that's more important than a client thing. As a Swift native, I wonder how you think you feel. I mean, I've looked at other languages. Which one of those do you think maps to Swift more closely? Or thankfully, they're both type-based, right? So that helps a lot. Stricter type language I like. I didn't like JavaScript. I liked TypeScript much more because of that, but it was still a little ugly. But that again, you know, but these days we don't write a lot of code. Thank you, Claude Code.
## Claude
Rust is much closer to Swift, and I don't think it's close.
Swift and Rust are almost cousins. Both have enums that carry data, with exhaustive switch statements where the compiler makes you handle every case. Both handle missing values with optionals instead of null; Swift's Optional is Rust's Option. Both handle errors with result types and a short operator to pass them up, try in Swift and a question mark in Rust. Swift's protocols map closely to Rust's traits. Both have generics with constraints, value-type structs, and immutable variables by default, let in Swift and let without mut in Rust. Swift's designers have openly said Rust influenced them. Reading Rust as a Swift developer, most of it will look familiar.
The one big new idea in Rust is ownership and borrowing: the compiler tracks who owns each value and who's allowed to change it, and refuses to compile if two things could change the same data at once. Swift has been moving the same way with its newer ownership features, so even that won't be completely foreign.
TypeScript, by comparison, is a type system laid over JavaScript, and you can feel that. The types disappear when the code runs, there are escape hatches that switch checking off, and every number is a decimal underneath, with no real integers. That last one matters for a sim built on whole-number ticks and counts. You noticed the ugliness yourself, and it doesn't go away.
Now, how comfortable am I in each? TypeScript is one of the languages I'm strongest in, and the loop is very fast: save, refresh, look. I'm also strong in plain Rust, and Rust has an advantage that matters more than it sounds when the AI writes nearly everything: the compiler is a strict second reviewer. If I write a brain state and forget a case, it won't compile. If two systems could write the same data unsafely, it won't compile. If a value might be missing and I didn't handle it, it won't compile. Its error messages are precise, so I can fix my own mistakes quickly. In TypeScript, more of those mistakes slip through to runtime, where they show up as an odd bug in the middle of a game. You won't be reviewing every line, so a compiler that catches whole categories of bugs is a real safety net.
My weak spot is Bevy specifically, not Rust. Its API changes every few months, so my memory of it can be a version or two behind. But a prototype can pin one version and never upgrade, which takes most of that risk away. And if we use only Bevy's ECS library for the sim, not its renderer or UI, the part I need to get right is smaller and changes less.
So this changes my recommendation. Your new information is that you have no real stake in TypeScript, you prefer strict types, the AI will write almost all of the code, and the simulation is the heart of the game. Given that, I'd now go with the middle path: the simulation in Rust with Bevy's ECS library, compiled natively for the server and the tests, and to WebAssembly for single player in the browser's worker. The client stays a TypeScript web page with Pixi JS for the map and HTML for the panels. The sim gets a strict compiler, real integers, enforced ECS conventions and fast balance sweeps. Your 800-factory runs would take seconds natively. The client keeps the fast, flexible web loop for the parts that are mostly drawing and menus.
What it costs: two languages in one repository, a WebAssembly build step, and slower compiles for sim changes than save-and-refresh. With a small sim crate, though, those compiles should take seconds, not minutes, and most sim work happens in tests anyway.
That would reverse part of what we logged earlier, where TypeScript everywhere is marked as decided. Do you want me to update the platform doc and the decision log to a Rust simulation with a TypeScript web client, or would you rather think it over first?
## You
Ooh, that's really tempting. Sounds really up my alley in terms of what we should do, the compiler can check. That's tricky. What I'm wondering about is Bevy's. A couple things I'm wondering about Bevy's easier and how complicated it is. Like, in other words, are you imperating like 20,000 lines of code, 10,000 lines of code? Like, I don't know. But how much complexity are you imperating through that system? Or is it? Right. I mean, we talk about changing every 2 months. Those are concerns I have, but it's not real, but we can say they're just out of the concerns. You know, they're just concerns that I have. Like, not so much about the change, but like, is the ECS changing much? If it is, why? And how complex is it? And is it a lot of complexity we don't really need? Secondly, I'd love to hear performance numbers on TypeScript versus Rust in WebAssembly. And I'd love to hear kind of a, you know, performance difference we're talking about here. So if you can find some, like, you know, research papers on it, that would be great. I think the convention, you know, the classic is to think Rust is crazy fast and, you know, TypeScript and JavaScript are like super slow, but that's an easy trap to fall into. And if the difference is like 2x and 20x, it may not actually matter much in this game. So the performance benefits, you know, one of my rules, my engineers I work with is never over-optimize for performance. Never pre-optimize for performance. If you assume something's going to be slow, it often isn't. So don't try to add complexity to get performance when it may be that everything runs in near instantaneously and no user would ever be able to tell the difference. So I'd love to know how much distinction we're talking about in these sort of large scale simulations. Because I think while the TypeScript prototype has some advantages with terms of bootstrapping and getting off the ground in single language, if there's a 5 or 10x performance difference between them, then that could be quickly something we'd feel on our own. I think that, you know, again, I'm over-optimizing, but something that I don't want to be on the end of tier one and already be feeling performance issues.
## Claude
Still researching: I've measured Bevy's ECS crate size and am now counting how many ECS changes each recent release made. Then I'll put together the performance numbers.
Here's what I found, in two parts: Bevy's ECS, then performance.
How big is Bevy's ECS? I downloaded the current release of the ECS library and counted. It's about 120,000 lines including documentation and tests, and about 71,000 lines of actual code, with 23 dependencies. It grew about 15 percent this year. So yes, it's a lot of machinery. Most of it is things you wouldn't use for a while: running systems in parallel across CPU cores, change detection, observers and hooks that fire when components are added, built-in parent and child relationships, reflection for editors, and a lot of internal plumbing for speed. The part you'd actually touch is small: entities, components, queries, systems, a schedule with ordered sets, the command buffer, messages for events, and resources for global data like the tick count. That's maybe eight ideas. You'd be adopting a big engine room but working from a small control panel.
How much does the ECS change? I went through the official migration guides for the last four releases and pulled out the entries that touch the ECS. The release a year and a half ago was the big one, with roughly twenty ECS changes. That's when Bevy split its old events into two ideas: buffered messages, which are what we've been calling event queues, and observers, which react the moment something happens. Since then, each of the last three releases has had roughly six or seven ECS changes, and they're mostly renames or changes to advanced corners: how observers receive their data, how internal access tracking is named, entity-allocation details, error-handling cleanups. The core you'd use, meaning components, queries, systems, schedules and commands, has been fairly stable. As for why it changes, it's mostly the ECS growing new features, like relationships, observers and the scene format, and the team tidying the API before a 1.0. For a prototype you can pin one version and never upgrade, which removes the churn problem almost entirely.
Now performance, and your instinct is right: the easy assumption is misleading.
The best rigorous study I found is a 2019 paper from UMass Amherst presented at the USENIX Annual Technical Conference, titled "Not So Fast". The researchers compiled real programs from the standard SPEC CPU benchmark suite to WebAssembly and compared them with native code. WebAssembly was on average about 45 percent slower in Firefox and 55 percent slower in Chrome, and the worst cases were about two to two and a half times slower. Earlier studies that showed near-native speed had only used tiny hundred-line kernels. Browsers have improved since 2019, so the gap today is probably smaller, but call it roughly one and a half times slower than native.
Rigorous research on WebAssembly versus plain JavaScript is thinner. I found one claim that WebAssembly averages about 1.3 times faster than JavaScript on the same SPEC benchmarks, but I couldn't confirm which paper it came from, so treat it loosely. Blog benchmarks of tight number-crunching loops report WebAssembly four to ten times faster than JavaScript, but those are small tests, and one notes JavaScript can win on small workloads because of WebAssembly's startup cost. General language benchmarks usually put native Rust somewhere around two to five times faster than JavaScript on CPU-heavy work.
The most honest summary: Rust is fast by default, and JavaScript is fast if you're careful. Idiomatic TypeScript, meaning lots of small objects, maps and fresh allocations every tick, can easily be five to ten times slower than Rust and adds garbage-collection pauses. TypeScript written carefully, with flat arrays of numbers and no allocations in the hot loop, can come within two or three times of Rust. And since Claude writes most of your code, I'd naturally write the idiomatic style unless told otherwise.
Does any of this matter by the end of Tier 1? I don't think so, and here's the estimate. Your own simulation runs put the Tier 2 unlock at about 36 to 42 minutes, by which point a player has maybe dozens to a couple of hundred buildings, a few dozen units, and some alien activity. Call it a few thousand entities with generous margin. At 20 ticks a second you have 50 milliseconds per tick. Moving a few dozen units, running the handout over a hundred-odd machines and updating noise is well under a millisecond in either language. The one heavier piece is pathfinding on your 640 by 640 map. A long path search there might take a few milliseconds in TypeScript and well under one in Rust, but it only happens when a unit needs a new route, not every tick. So at Tier 1 scale, even a ten times difference means one percent of the budget versus a tenth of a percent. Nobody could tell.
Where the gap would start to matter is much later: big late-game bases, hundreds of aliens hunting by noise, several worlds on one cheap server, or your 800-factory balance sweeps. Rust also uses noticeably less memory, which matters for hosting persistent worlds. That's headroom, not a prototype need.
So applying your own rule, performance isn't a reason to choose Rust for the prototype. The real reasons for Rust are the ones from before: a strict compiler that reviews Claude's code, real whole-number types, exhaustive enums, and an ECS whose conventions are enforced rather than remembered. The real reasons for TypeScript are one language and the fastest loop. Performance is a tie for now, with Rust holding a long-term edge you may never need.
Sources, which you can skip in audio: [Not So Fast, USENIX ATC 2019](https://usenix.org/conference/atc19/presentation/jangda), [the same paper on arXiv](https://arxiv.org/abs/1901.09056v3), [paper summary page](https://ccs.neu.edu/~arjunguha/main/papers/jangda-not-so-fast.html), [Understanding the Performance of WebAssembly Applications, IMC 2021](https://api.openalex.org/works/doi:10.1145%2F3487552.3487827), [Programming language benchmarks, JavaScript vs Rust](https://programming-language-benchmarks.vercel.app/javascript-vs-rust), [Rust WebAssembly vs JavaScript blog benchmark](https://medium.com/@oemaxwell/rust-webassembly-performance-javascript-vs-wasm-bindgen-vs-raw-wasm-with-simd-687b1dc8127b), [another WebAssembly benchmark write-up](https://byteiota.com/rust-webassembly-performance-8-10x-faster-2025-benchmarks/), [JavaScript ECS benchmarks](https://github.com/ddmills/js-ecs-benchmarks), [more JavaScript ECS benchmarks](https://github.com/stasm/js-ecs-benchmarks), [bevy_ecs documentation](https://docs.rs/bevy_ecs/latest/bevy_ecs/), [bevy_ecs used standalone, Hacker News](https://news.ycombinator.com/item?id=26145688), [Bevy migration guide 0.16 to 0.17](https://bevy.org/learn/migration-guides/0-16-to-0-17/), [Bevy migration guide 0.15 to 0.16](https://bevy.org/learn/migration-guides/0-15-to-0-16/). The line counts and the ECS change counts for 0.18 through 0.20 came from the bevy_ecs source on crates.io and the official migration guides on bevy.org.
## You
Okay, that's all very strong and interesting. I think what I find interesting at this point is that, if I hear you correctly, Rust, because we would be running the TypeScript, let me rephrase that. Are we talking about running the TypeScript, you know, compiled to now to JavaScript natively in the browser for the simulation for single player? And then where would be on the server? How would we run it? And then the Rust / Bevy, you know, the simulation backend, when we compare that, that has to run in WebAssembly. So I'm wondering, does the TypeScript ever run in WebAssembly for the server for some reason to keep contained, or is it just still running in a different JavaScript area? I don't know the details of this in the browser. And then on the server, would we be able to have it running on as a native Rust binary as opposed to running in WebAssembly? So maybe what I'm wondering is, like, describe both environments that this would run in and compare TypeScript in WebAssembly and Rust, and only include TypeScript in WebAssembly if it matters at all, if we would ever use it.
## Claude
TypeScript never runs as WebAssembly in this plan. Here's how each option would actually run.
TypeScript first. It doesn't really compile to anything new. The build step checks your types and then strips them out, leaving ordinary JavaScript. That JavaScript runs on a JavaScript engine everywhere. There's a separate language called AssemblyScript that looks like TypeScript and compiles to WebAssembly, but it's a different, more limited language, and we wouldn't use it.
So with TypeScript, single player runs the sim inside a Web Worker. A worker is a separate thread inside the browser with its own JavaScript environment. It can't touch the page, and it talks to the page only by passing messages, which is exactly our boundary between client and host. On an iPhone that's Safari's JavaScript engine, and on a PC it's Chrome's. For multiplayer, the same JavaScript runs on the server in Node, which is essentially Chrome's JavaScript engine running outside a browser. Same code, same kind of engine, in both places. Balance sweeps would also run in Node on your Mac.
Now Rust. Rust compiles to two different targets from the same source.
In the browser, for single player, the sim is compiled to WebAssembly and loaded into that same Web Worker. A thin bit of generated JavaScript glue sits around it: commands go into the Rust sim, and state comes back out each tick for the page to draw. The page and its menus are still TypeScript, exactly as before. The only difference is that the worker's insides are Rust instead of JavaScript.
On the server, there's no WebAssembly at all. The sim compiles to an ordinary native program for Linux, wrapped in a small Rust server that handles the WebSocket connections. No Node needed. Your tests and balance sweeps also run natively on your Mac at full speed.
As a bonus for later, since you know Swift: Rust can also compile natively for iPhone. A native iOS app could someday run the sim directly instead of through WebAssembly in a web view. You don't need that, but the door is open.
Comparing the two environments: in the browser, it's JavaScript against WebAssembly, running in the same browser engine. From the research, WebAssembly is usually faster, anywhere from about a third faster to several times faster depending on the work. It also avoids garbage-collection pauses in the sim, because Rust manages its own memory. Against that, copying the state from WebAssembly out to the page each tick has a cost, and with "send the whole state every tick" that copy is the main overhead early on.
On the server, it's Node against native Rust, and that's where the gap is widest. Native Rust beats its own WebAssembly build by about one and a half times, per the USENIX paper, and typically beats JavaScript by roughly two to five times on heavy computation, while using considerably less memory.
One last point that favors keeping the sim in whole numbers either way. Because your sim avoids decimal math, the native server build and the WebAssembly browser build will give exactly the same results from the same inputs. So a world started in single player could later be uploaded and continued on a server without anything drifting.
## You
I'm back on a screen. Show me a comparison table. Give me an approximate performance difference for all 4 possible environments.
I love the idea that a server could make your game faster. Neato.
## Claude
Here are the four environments, with native Rust as the baseline. These are rough ranges from the research we went through, not measurements of your game.
| Environment | Used for | Language runs as | Relative speed (lower is faster) | GC pauses | Memory | Confidence ||---|---|---|---|---|---|---|| **Rust, native server** | Multiplayer server, tests, balance sweeps | Native machine code | **1×** (baseline) | None | Lowest | Baseline || **Rust → WebAssembly in a browser worker** | Single player | WebAssembly | **~1.3–2×** | None in the sim | Low | Good: [USENIX 2019](https://usenix.org/conference/atc19/presentation/jangda) measured 1.45–1.55× on average, worst case 2.5×. Browsers have improved since || **TypeScript → JavaScript in Node** | Multiplayer server, sweeps | JIT-compiled JavaScript | **~2–3×** written carefully, **~5–10×** written idiomatically | Yes | ~3–10× more | Moderate: general benchmarks plus blog tests, no rigorous study || **TypeScript → JavaScript in a browser worker** | Single player | JIT-compiled JavaScript | About the same as Node: **~2–3×** careful, **~5–10×** idiomatic | Yes | ~3–10× more | Moderate. Chrome uses the same engine as Node. Safari's engine is a similar ballpark |
"Careful" means flat number arrays and no allocations in the hot loop. "Idiomatic" means lots of small objects and maps, which is what code naturally looks like unless you insist otherwise.
**What it means at Tier 1, illustrated.** If the end-of-Tier-1 tick took 0.1 ms in native Rust (a plausible guess for a few thousand entities), the others would be roughly:
| Environment | Illustrative tick | Share of the 50 ms budget ||---|---|---|| Rust native | 0.1 ms | 0.2% || Rust WebAssembly | 0.13–0.2 ms | under 0.5% || JavaScript, careful | 0.2–0.3 ms | under 1% || JavaScript, idiomatic | 0.5–1 ms | 1–2% |
Even the slowest case leaves almost the whole budget unused. The differences only start to matter at late-game scale: big bases, hundreds of aliens searching for noise, several worlds sharing one server, or your balance sweeps.
**On the server making the game faster:** it does, in a specific sense. In multiplayer the phone doesn't run the sim at all, so it only draws and handles input. The heavy work moves to a machine that's both faster and running native code, and the phone saves battery and stays cooler. The trade is the round trip of about 100–200 ms, which your design hides well, since players give orders to buildings rather than steering units directly. It's also one of the reasons your docs gave for choosing a server-owned tick: phones as clients, where battery and heat matter.
## You
All right, it's time to capture all of this into the docs, and I'm trying to understand how to best capture this whole conversation to the docs. In a good way. Can you read it all over and kind of talk me through and ask me a couple of questions about what I should do? Use your question asking tool.
## Claude
Here's what this conversation produced, grouped by how it should land.
**Already in the docs (committed as 9d4db8d)**- The tech stack, logged as "TypeScript everywhere" and marked decided. That's now **out of date**: we've since leaned toward a Rust sim with a TypeScript web client, and you haven't decided.- The client/host split from the first build, and the placeholder-art and baked-sprite plan.
**Not captured anywhere yet**1. **The runtime model.** Tick, frame and command as separate loops. Fixed 20 Hz tick, rendering one tick behind, commands applied at the tick boundary.2. **Player interaction.** Instant feedback with no pending state, undo only on the rare rejection. Shared check functions on client and server. Ghosts are free, so Basalite never blocks placement.3. **Sync.** For the prototype, send the full state every tick as JSON, with sequence numbers and acks, and a "view for this player" function. Later: diffs, plan and rate field types (Linear, Timed, Path), watching a building's panel, binary encoding. The two-player walkthrough is a good worked example.4. **ECS conventions.** Entities, components and systems. Recipes holding the balance numbers. A schedule of phases. One writer per field. Intents plus an arbiter. States versus events, with events read on the next tick. A command buffer for structural changes. Declared reads and writes with checks. Sleeping entities, summary simulation for distant areas, and the naming conventions.5. **Research.** Bevy's current state (0.20, about 71,000 lines of ECS code, how much its ECS changes per release, Lightyear for networking) and the performance comparison across the four environments.6. **Build order.** Sim-only first, with your Python sims as oracle tests. Then the plain client, then the netting port, then the rest of Tier 0.
**One loose end.** The [Tick, Frame, Command](https://claude.ai/artifact/XPu5mwdkRdbEA9Wy8BTYLu) primer still says the client never imports `sim/`. We later changed that to allow the shared check functions. Once the docs exist, the primer can be retired.
A few decisions only you can make:
I'll wait for your message and go from there.
## You
I'm still really torn. Did we add a ton of complexity with Rust and Bevy we shouldn't have?
## Claude
Some, yes. Not a ton, but it's front-loaded: you pay most of it before you've played anything. Here's the honest accounting.
**What Rust plus Bevy's ECS adds**- **Two languages** in one repo, and you've written neither. Rust's ownership rules are a real learning curve, even with Claude writing the code.- **A WebAssembly build** for single player: Rust compiled to WebAssembly, generated JavaScript glue, and a bridge that copies state out to the page each tick.- **A big dependency.** About 71,000 lines of ECS code that you'll mostly never touch but will sometimes have to debug through, plus API drift between versions that I'll occasionally get wrong.- **Slower iteration** than save-and-refresh for every sim change.
**What it buys**- A strict compiler checking Claude's code: exhaustive enums, no nulls, real integers.- Ambiguity checks for systems. Note that these are off by default, and they only catch order conflicts.- Native server speed and lower memory. That's headroom you won't need until well past Tier 1.
**What it doesn't buy:** the thing you were actually worried about. Your concern was getting the conventions wrong: phases, sync points, who writes what. Those are ideas, and they carry over to any language. TypeScript can enforce most of them too. Exhaustive switches can be checked at compile time, a small ECS core can refuse structural changes outside the command buffer, and a debug check can compare each system's declared reads and writes. Rust makes that enforcement stronger. TypeScript makes it good enough.
**The thing that should take the pressure off:** the boundary we designed makes this choice reversible. The client only ever sees commands and state messages, and your Python sims will become tests. So if a TypeScript sim later hits a wall, porting the sim to Rust is a contained job, with tests checking that the port behaves the same. The game design decisions, which are the part you said matters most, don't change either way.
**My recommendation:** settle it with evidence instead of more debate. Do a short, time-boxed bake-off on the smallest real slice: the Crashed Ship, one Harvester, a Basalite patch and Depot storage, with headless tests and squares on screen through a worker. Build it once in TypeScript and once with Rust and Bevy's ECS. That's a day or two of Claude's work each. Then judge:- Which code can you actually read and reason about?- How often did the compiler catch real mistakes?- How much did the WebAssembly build and bridge get in the way?- Which loop felt better to work in?
If you'd rather skip the bake-off, I'd start in TypeScript. Momentum matters most at the start of a first game, and the switch to Rust stays open.
For the docs, I'd record the stack as **open**, with both options, the research and the bake-off as the deciding step. Should I write it up that way?
## You
I think I like the idea of Rust as a learning opportunity, but I don't want too many of those at once! And I do see the benefit: if we follow the bevy convention claude code could convert that to Rust and bevy pretty much for free.
I feel like if I did the TS approach I'd be learning ECS from first principles, though I'd probably need that either way. I think I'm worried about the setup with Bevy, Rust, and being a few hours in and wrangling some dependency or install issue or something weird, rather than working on game logic
## Claude
I think that's the right call, and your worry about setup is well founded. Rust compiled to WebAssembly is exactly where those afternoon-eating problems live. You need the WebAssembly compile target installed, the generated glue's version has to match its library, and getting the bundler to load the module can fight you. Common crates also need special feature flags in the browser; random number generation is the classic one. None of that is hard, but each can cost an hour, and none of it is game logic. A TypeScript setup with Vite and Pixi takes minutes.
On learning ECS from first principles: you'd need that either way, and a small core you can read end to end is the best way to learn it. The trick is to **learn it in Bevy's vocabulary**: schedules with ordered sets, `Commands` for structural changes, `Messages` for events, resources for global data, and queries with with and without filters. Then what you learn transfers directly, and a later port really is mostly mechanical. I'd say much cheaper rather than free: Rust's ownership rules will reshape some code. But with the Python-based tests checking behavior, it's a contained job.
Here's the doc plan, using my defaults for the questions you skipped:
- **Stack:** the committed TypeScript decision stands for the prototype. Added: the sim deliberately follows Bevy's ECS conventions and names. Rust with Bevy's ECS is recorded as the planned path if the sim outgrows TypeScript, or as a later learning step, with the research and trade-offs. Marked open.- **Where:** a few focused docs in `design/`: - runtime and sync: tick, frame and command, player interaction, the prototype's full-state sync and the later steps, and the two-player example - simulation architecture: the ECS rules, the schedule, intents and arbiter, events, command buffer, access checks, sleeping, naming, and the build order with the Python sims as tests - tech research: Bevy's state and the performance table
Plus decision-log entries and README index rows.- **Status:** the architecture rules marked tentative until the first Harvester spike shows they work.- **Transcript:** this conversation exported to `design/transcripts/`, like the original brainstorm.- **Primer:** I'd retire the [Tick, Frame, Command](https://claude.ai/artifact/XPu5mwdkRdbEA9Wy8BTYLu) artifact once the docs cover it.
## You
Yes, write them