gridlock
2D top-down tactical shooter built from scratch in Rust. Custom ECS, wgpu rendering, and deterministic client-server networking.
gridlock is a 2D top-down tactical multiplayer shooter written entirely in Rust with no game engine underneath it. No Bevy, no Unity. Just a 7-crate workspace: a custom ECS, a multi-stage wgpu rendering pipeline, a deterministic 60 Hz simulation loop, and a UDP+TCP client-server architecture where the server controls what each client is allowed to see. I built it to understand what game engines do under the hood: rendering, visibility, networked state, and weapon feel. And to see where Rust's ownership model helps and where it adds friction.
Architecture
The codebase is a 7-crate workspace with a strict dependency graph. At the bottom: ecs/ (entity storage and events), net/ (protocol encoding, socket abstraction), and engine/ (rendering, input, asset cache). In the middle: game/, the pure deterministic simulation that imports ecs/ but nothing else. At the top: server/ (tokio async tick loop) and client/ (winit event loop with a background I/O thread).
The key constraint is that game/ has no I/O and no async. Every tick takes a dt and an InputState and returns nothing. All side effects go through an event queue that the caller drains. This makes the simulation layer identical whether it is running headless on the server or locally on the client for prediction.
gridlock/
├── ecs/ # Entity, Component, Event, World
├── net/ # Protocol, bincode encode/decode, NetSocket
├── engine/ # wgpu render pipeline, input, asset cache
├── game/ # Pure simulation — no I/O, no async
│ ├── systems/ # input, movement, AI, combat, projectile
│ ├── entity/ # bundles, weapon stats, attachments
│ ├── ai/ # brain, perception, awareness, search
│ └── world/ # aim cone, spatial scan, sight, level
├── ui/ # HUD, editor
├── server/ # tokio tick loop, session management
└── client/ # winit event loop, client-side predictionCustom ECS
Writing a game on an existing framework like Bevy would have hidden the parts I wanted to understand: how shared mutable state is managed across hundreds of entities per tick, how events flow between systems without coupling them, and where Rust's borrow checker adds friction.
The ECS uses TypeId as the storage key. Each component type gets its own HashMap<Entity, T> behind a Box<dyn ErasedComponentStore>. Querying is a downcast. The trade-off is that you can only iterate one component type at a time. Multi-component queries require explicit loops. For a 60 Hz simulation this is fast enough and keeps the API simple.
The event bus is the only way systems communicate. A system emits DamageEvent and another drains it next tick. No direct calls between systems. The tick loop in game.update() is the only place that controls ordering.
pub struct World {
next_id: u64,
alive: HashSet<Entity>,
components: HashMap<TypeId, Box<dyn ErasedComponentStore>>,
resources: HashMap<TypeId, Box<dyn Any>>,
pub events: EventBus,
}
impl World {
pub fn spawn(&mut self) -> Entity { ... }
pub fn insert<C: Component>(&mut self, e: Entity, c: C) { ... }
pub fn get<C: Component>(&self, e: Entity) -> Option<&C> { ... }
pub fn iter<C: Component>(&self) -> impl Iterator<Item = (Entity, &C)> { ... }
pub fn emit<E: Event>(&mut self, event: E) { ... }
pub fn drain<E: Event>(&mut self) -> Vec<E> { ... }
}Visibility System
Each entity carries a Sight component: a facing direction, a half-angle cone, a maximum range, and a close-range circle radius that grants instant visibility regardless of direction. Every tick the visibility system computes a score per entity. 1.0 if inside the cone and unobstructed by walls, a blended value near the cone boundary, 0.0 otherwise. That score becomes the VisibilityState component, readable by anything downstream.
The server uses that score to build per-client snapshots. Before serializing the game state for a given player, it filters the entity list to only those with a visibility score above zero. Enemies outside your sight cone are not in your packet. There is nothing for a cheating client to extract. Wall geometry and your position are always sent. Everything else is removed.
On the client, the same score drives the fog-of-war render pass. A soft alpha mask is rendered over the scene each frame using the local player's sight cone and close-range circle. Because it is a full GPU pass rather than just hiding sprites, the transition from lit to dark is smooth. You can see the shape of a room before you enter it, but not the players standing in it.
Multiplayer and Client-Side Prediction
The server is a headless Rust binary that runs the full game simulation at 60 Hz. Each connected player has a Session: a position, a loadout, a team assignment, and an input queue. The async receive loop pushes incoming UDP packets into that queue. The synchronous tick loop drains it, steps the simulation, then broadcasts a per-client snapshot to every connected player.
Two transport channels keep the protocol simple. UDP carries all high-frequency data: player inputs at around 60 packets per second and outbound snapshots. Packet loss here is acceptable because the next snapshot replaces the dropped one. TCP carries the control channel: the initial handshake, team assignments, and round events where delivery must be guaranteed. Sessions inactive for more than 60 seconds are removed automatically.
The client runs game.update() locally every frame using the player's own input so movement responds instantly without waiting for a round trip. When a server snapshot arrives, the client reconciles by replacing its entity state with the authoritative version. This is not a full rollback system. No replaying inputs after a mismatch. For a game with low latency requirements it produces smooth, responsive movement without visible correction.
Rendering Pipeline
wgpu gives a Vulkan-level API that runs on Metal, DX12, and WebGPU. That means explicit render passes, manual GPU buffer management, and custom shaders with no engine abstraction.
Five passes run in order each frame. The geometry pass draws walls and props as textured quads. The sprite pass is instance-based: every visible entity's transform, atlas UV coordinates, and per-sprite lighting are packed into a dynamic vertex buffer, uploaded once, and drawn in a single instanced call regardless of entity count. The lighting model is simple: an ambient base plus contributions from nearby light sources computed per sprite.
The fog-of-war pass is its own render target. It rasterizes the player's sight cone and close-range circle as a soft alpha mask, then composites it over the fully-lit scene. A gradient reads as actual limited visibility where a hard mask would look like a UI element. Rooms adjacent to your position are dark, not invisible. You see their shape but not what is in them.
The text pass builds a glyph atlas at startup using guillotiere for packing and cosmic-text for shaping and layout. Glyphs are packed into a single texture. The renderer emits quads with the correct UV per character. A quad pass on top handles HUD elements, debug overlays, and the in-game level editor.
pub fn render_frame(&mut self, frame: &GameFrame) {
let mut encoder = self.device.create_command_encoder(&default());
// 1. Geometry — walls, floors, props
self.geometry.render(&mut encoder, &frame.level);
// 2. Sprites — all entities, single instanced draw call
self.sprites.upload_instances(&frame.entities); // pack into GPU buffer
self.sprites.render(&mut encoder);
// 3. Fog of war — soft visibility mask composited over scene
self.fog.update_cone(frame.player_sight);
self.fog.render(&mut encoder, &self.scene_texture);
// 4. Text — HUD, labels, ammo counter
self.text.render(&mut encoder, &frame.ui_text);
// 5. Debug quads — editor overlays, hit boxes (dev builds only)
#[cfg(debug_assertions)]
self.quads.render(&mut encoder, &frame.debug);
self.queue.submit([encoder.finish()]);
}