306 lines
9.4 KiB
Markdown
306 lines
9.4 KiB
Markdown
# lockstep artillery game - technical design
|
|
|
|
a deterministic lockstep networked artillery game in zig with vector-style glowing line visuals.
|
|
|
|
## why deterministic lockstep?
|
|
|
|
bandwidth scales with input size, not object count. send "fire at angle 45, power 80" instead of syncing projectile positions every frame. replay files are just input logs. certain cheats become impossible since all clients must agree on simulation.
|
|
|
|
the catch: simulation must be bitwise identical across all machines.
|
|
|
|
## architecture overview
|
|
|
|
```
|
|
┌─────────────────────────────────────────────────────────────────┐
|
|
│ game loop │
|
|
├─────────────────────────────────────────────────────────────────┤
|
|
│ 1. collect local input │
|
|
│ 2. send input to peer │
|
|
│ 3. wait for peer input (this is where latency shows) │
|
|
│ 4. apply both inputs deterministically (same order always) │
|
|
│ 5. advance simulation one tick │
|
|
│ 6. render (floats ok here, not in sim) │
|
|
└─────────────────────────────────────────────────────────────────┘
|
|
```
|
|
|
|
for artillery games, input delay is fine - it's turn-based-ish anyway.
|
|
|
|
## fixed-point math
|
|
|
|
floats are non-deterministic across compilers, optimization levels, and platforms. ints are always deterministic. fixed-point stores real numbers as ints.
|
|
|
|
using 32.32 fixed point (64-bit with 32 fractional bits):
|
|
|
|
```zig
|
|
pub const Fixed = struct {
|
|
raw: i64,
|
|
|
|
pub const FRAC_BITS = 32;
|
|
pub const ONE: Fixed = .{ .raw = 1 << FRAC_BITS };
|
|
|
|
pub fn fromInt(n: i32) Fixed {
|
|
return .{ .raw = @as(i64, n) << FRAC_BITS };
|
|
}
|
|
|
|
pub fn toFloat(self: Fixed) f32 {
|
|
// only for rendering, never in simulation
|
|
return @as(f32, @floatFromInt(self.raw)) / @as(f32, @floatFromInt(@as(i64, 1) << FRAC_BITS));
|
|
}
|
|
|
|
pub fn add(a: Fixed, b: Fixed) Fixed {
|
|
return .{ .raw = a.raw + b.raw };
|
|
}
|
|
|
|
pub fn mul(a: Fixed, b: Fixed) Fixed {
|
|
// need i128 intermediate to avoid overflow
|
|
const wide = @as(i128, a.raw) * @as(i128, b.raw);
|
|
return .{ .raw = @intCast(wide >> FRAC_BITS) };
|
|
}
|
|
|
|
pub fn div(a: Fixed, b: Fixed) Fixed {
|
|
const wide = @as(i128, a.raw) << FRAC_BITS;
|
|
return .{ .raw = @intCast(@divTrunc(wide, b.raw)) };
|
|
}
|
|
};
|
|
```
|
|
|
|
trig functions need lookup tables or taylor series with fixed-point - no `@sin()`.
|
|
|
|
## game state
|
|
|
|
minimal, fully serializable for checksums:
|
|
|
|
```zig
|
|
pub const GameState = struct {
|
|
tick: u32,
|
|
current_turn: u8, // 0 or 1
|
|
wind: Fixed, // affects projectile horizontal velocity
|
|
players: [2]Player,
|
|
projectile: ?Projectile,
|
|
terrain: Terrain,
|
|
rng_state: u64, // for any randomness (wind changes, etc)
|
|
};
|
|
|
|
pub const Player = struct {
|
|
x: Fixed,
|
|
cannon_angle: Fixed, // radians, 0 to pi
|
|
power: Fixed, // 0 to 100
|
|
health: i32,
|
|
alive: bool,
|
|
};
|
|
|
|
pub const Projectile = struct {
|
|
x: Fixed,
|
|
y: Fixed,
|
|
vx: Fixed,
|
|
vy: Fixed,
|
|
};
|
|
|
|
pub const Terrain = struct {
|
|
heights: [SCREEN_WIDTH]Fixed, // height at each x pixel
|
|
};
|
|
```
|
|
|
|
## input structure
|
|
|
|
```zig
|
|
pub const Input = struct {
|
|
move: i8, // -1, 0, +1
|
|
angle_delta: i8, // -1, 0, +1 (scaled by some rate)
|
|
power_delta: i8, // -1, 0, +1
|
|
fire: bool,
|
|
};
|
|
|
|
pub const InputPacket = struct {
|
|
frame: u32,
|
|
player_id: u8,
|
|
input: Input,
|
|
checksum: u32, // hash of sender's game state
|
|
};
|
|
```
|
|
|
|
## networking
|
|
|
|
udp for speed. simple protocol:
|
|
|
|
```
|
|
packet types:
|
|
0x01 INPUT - InputPacket
|
|
0x02 SYNC - full GameState for initial sync / recovery
|
|
0x03 PING - latency measurement
|
|
0x04 PONG - ping response
|
|
```
|
|
|
|
connection flow:
|
|
1. host listens, guest connects
|
|
2. host sends SYNC with initial GameState
|
|
3. both start simulation
|
|
4. exchange INPUT packets each frame
|
|
5. compare checksums periodically
|
|
|
|
if checksums diverge = desync = bug to fix during development.
|
|
|
|
## simulation loop
|
|
|
|
```zig
|
|
pub fn simulate(state: *GameState, inputs: [2]Input) void {
|
|
const current = state.current_turn;
|
|
|
|
// apply input (only current player can act)
|
|
applyInput(&state.players[current], inputs[current]);
|
|
|
|
// update projectile if active
|
|
if (state.projectile) |*proj| {
|
|
// gravity (fixed-point constant)
|
|
proj.vy = proj.vy.sub(GRAVITY);
|
|
|
|
// wind affects horizontal
|
|
proj.vx = proj.vx.add(state.wind.mul(WIND_FACTOR));
|
|
|
|
// move
|
|
proj.x = proj.x.add(proj.vx);
|
|
proj.y = proj.y.add(proj.vy);
|
|
|
|
// collision with terrain
|
|
const terrain_y = state.terrain.heightAt(proj.x);
|
|
if (proj.y.lessThan(terrain_y)) {
|
|
handleExplosion(state, proj.x, proj.y);
|
|
state.projectile = null;
|
|
state.current_turn = 1 - current; // switch turns
|
|
}
|
|
|
|
// collision with players
|
|
for (&state.players, 0..) |*player, i| {
|
|
if (player.alive and hitTest(proj, player)) {
|
|
player.health -= DAMAGE;
|
|
if (player.health <= 0) player.alive = false;
|
|
handleExplosion(state, proj.x, proj.y);
|
|
state.projectile = null;
|
|
state.current_turn = 1 - current;
|
|
}
|
|
}
|
|
|
|
// out of bounds
|
|
if (proj.x.lessThan(Fixed.ZERO) or proj.x.greaterThan(SCREEN_WIDTH_FX)) {
|
|
state.projectile = null;
|
|
state.current_turn = 1 - current;
|
|
}
|
|
}
|
|
|
|
state.tick += 1;
|
|
}
|
|
```
|
|
|
|
## rendering (the glow aesthetic)
|
|
|
|
vector/oscilloscope look with glowing lines. two approaches:
|
|
|
|
### approach a: multi-pass bloom (recommended)
|
|
|
|
1. draw lines to offscreen texture (dark background, bright lines)
|
|
2. horizontal gaussian blur pass
|
|
3. vertical gaussian blur pass
|
|
4. composite: original + blurred (additive blend)
|
|
|
|
raylib-zig has shader support. bloom shader:
|
|
|
|
```glsl
|
|
// blur pass (run horizontal then vertical)
|
|
uniform sampler2D texture0;
|
|
uniform vec2 direction; // (1,0) or (0,1)
|
|
uniform float blur_size;
|
|
|
|
void main() {
|
|
vec4 sum = vec4(0.0);
|
|
vec2 tc = gl_TexCoord[0].xy;
|
|
|
|
// 9-tap gaussian
|
|
sum += texture2D(texture0, tc - 4.0 * blur_size * direction) * 0.0162;
|
|
sum += texture2D(texture0, tc - 3.0 * blur_size * direction) * 0.0540;
|
|
sum += texture2D(texture0, tc - 2.0 * blur_size * direction) * 0.1216;
|
|
sum += texture2D(texture0, tc - 1.0 * blur_size * direction) * 0.1945;
|
|
sum += texture2D(texture0, tc) * 0.2270;
|
|
sum += texture2D(texture0, tc + 1.0 * blur_size * direction) * 0.1945;
|
|
sum += texture2D(texture0, tc + 2.0 * blur_size * direction) * 0.1216;
|
|
sum += texture2D(texture0, tc + 3.0 * blur_size * direction) * 0.0540;
|
|
sum += texture2D(texture0, tc + 4.0 * blur_size * direction) * 0.0162;
|
|
|
|
gl_FragColor = sum;
|
|
}
|
|
```
|
|
|
|
### visual elements
|
|
|
|
- terrain: jagged line across bottom
|
|
- players: simple geometric shapes (triangle cannon on rectangle base)
|
|
- projectile: bright dot with trail (store last N positions)
|
|
- explosions: expanding circle that fades
|
|
- ui: angle/power indicators as line gauges
|
|
|
|
color palette:
|
|
- background: near-black (#0a0a12)
|
|
- player 1: cyan (#00ffff)
|
|
- player 2: magenta (#ff00ff)
|
|
- terrain: green (#00ff00)
|
|
- projectile: white/yellow (#ffff00)
|
|
|
|
## desync detection
|
|
|
|
```zig
|
|
pub fn computeChecksum(state: *const GameState) u32 {
|
|
var hasher = std.hash.Fnv1a_32.init();
|
|
// hash deterministic parts only
|
|
hasher.update(std.mem.asBytes(&state.tick));
|
|
hasher.update(std.mem.asBytes(&state.current_turn));
|
|
hasher.update(std.mem.asBytes(&state.wind.raw));
|
|
hasher.update(std.mem.asBytes(&state.players));
|
|
if (state.projectile) |proj| {
|
|
hasher.update(std.mem.asBytes(&proj));
|
|
}
|
|
hasher.update(std.mem.asBytes(&state.rng_state));
|
|
// terrain could be large, maybe hash less frequently
|
|
return hasher.final();
|
|
}
|
|
```
|
|
|
|
exchange checksums every N frames. mismatch = log full state dump for debugging.
|
|
|
|
## what not to do
|
|
|
|
- no floats in game logic (only rendering)
|
|
- no hashmap iteration (order not deterministic)
|
|
- no system time in simulation
|
|
- no `@sin()` / `@cos()` - use lookup tables
|
|
- no uninitialized memory in game state
|
|
- no pointers in serialized state
|
|
|
|
## dependencies
|
|
|
|
- zig 0.15.2
|
|
- raylib-zig (for windowing, input, rendering, shaders)
|
|
|
|
no physics libraries, no other third-party code in simulation.
|
|
|
|
## file structure
|
|
|
|
```
|
|
lockstep/
|
|
├── build.zig
|
|
├── build.zig.zon
|
|
├── src/
|
|
│ ├── main.zig # entry, game loop
|
|
│ ├── fixed.zig # fixed-point math
|
|
│ ├── trig.zig # sin/cos lookup tables
|
|
│ ├── game.zig # GameState, simulation
|
|
│ ├── input.zig # Input handling
|
|
│ ├── net.zig # UDP networking
|
|
│ ├── render.zig # raylib drawing, bloom
|
|
│ └── terrain.zig # terrain generation
|
|
├── shaders/
|
|
│ ├── blur.fs
|
|
│ └── composite.fs
|
|
├── docs/
|
|
│ ├── design.md # this file
|
|
│ └── reference.md # quick reference
|
|
└── TODO.md
|
|
```
|