logo
All projects
2024 · Active

gridlock

2D top-down tactical shooter built from scratch in Rust. Custom ECS, wgpu rendering, and deterministic client-server networking.

RustwgpuTokiowinitECS
View on GitHub
Overview

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.

Key features
01Per-client visibility culling on the server. Each snapshot only includes entities within the player's sight cone or close-range area. Enemies outside that range are absent from the packet entirely, not just hidden on the client.
02Soft fog-of-war rendered as a dedicated wgpu pass. A smooth alpha mask shaped by the player's sight cone and circle radius is composited over the scene, so the transition between visible and dark is gradual.
03Deterministic 60 Hz simulation. The game crate is pure and side-effect-free, making it runnable identically on server and client for prediction. The client reconciles against server snapshots by replacing state.
04Weapon spread modeled as three composable parts: base accuracy, movement penalty, and per-shot recoil with independent decay. Each weapon stat is tuned separately so guns feel distinct without special cases.

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.

Workspace layout
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 prediction

Custom 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.

ecs/src/lib.rs
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.

Vision cone and spatial visibility projection diagram
Sight cone with dynamic occlusion and spatial visibility projection

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.

Network simulation and client-server tick loop diagram
UDP snapshot broadcast and client-side prediction loop

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.

engine/src/render/pipeline.rs
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()]);
}