Add design and reference docs

This commit is contained in:
Jared Miller 2025-12-04 17:57:00 -05:00
parent fb0850f477
commit 306505346c
2 changed files with 578 additions and 0 deletions

306
docs/design.md Normal file
View file

@ -0,0 +1,306 @@
# 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
```

272
docs/reference.md Normal file
View file

@ -0,0 +1,272 @@
# quick reference
## fixed-point math
32.32 format: 32 integer bits, 32 fractional bits.
```zig
const Fixed = struct { raw: i64 };
// constants
const ONE = Fixed{ .raw = 1 << 32 };
const HALF = Fixed{ .raw = 1 << 31 };
const PI = Fixed{ .raw = 13493037705 }; // pi * 2^32
// from int
Fixed{ .raw = @as(i64, n) << 32 }
// to float (rendering only!)
@as(f32, @floatFromInt(f.raw)) / 4294967296.0
// add/sub: direct
a.raw + b.raw
a.raw - b.raw
// mul: widen to i128
@intCast((@as(i128, a.raw) * b.raw) >> 32)
// div: shift first
@intCast(@divTrunc(@as(i128, a.raw) << 32, b.raw))
```
## trig lookup tables
generate at comptime:
```zig
const TABLE_SIZE = 1024;
const sin_table: [TABLE_SIZE]Fixed = blk: {
var table: [TABLE_SIZE]Fixed = undefined;
for (0..TABLE_SIZE) |i| {
const angle = @as(f64, @floatFromInt(i)) * std.math.pi * 2.0 / TABLE_SIZE;
const s = @sin(angle);
table[i] = .{ .raw = @intFromFloat(s * 4294967296.0) };
}
break :blk table;
};
pub fn sin(angle: Fixed) Fixed {
// angle in radians, normalize to table index
const two_pi = Fixed{ .raw = 26986075409 }; // 2*pi * 2^32
var a = @mod(angle.raw, two_pi.raw);
if (a < 0) a += two_pi.raw;
const idx = @as(usize, @intCast((a * TABLE_SIZE) >> 32)) % TABLE_SIZE;
return sin_table[idx];
}
pub fn cos(angle: Fixed) Fixed {
const half_pi = Fixed{ .raw = 6746518852 }; // pi/2 * 2^32
return sin(Fixed{ .raw = angle.raw + half_pi.raw });
}
```
## networking
### packet format
```
byte 0: packet type
bytes 1-4: frame number (u32 little-endian)
byte 5: player id
bytes 6-9: checksum (u32 little-endian)
bytes 10+: payload
```
### packet types
| type | id | payload |
|------|------|---------|
| INPUT | 0x01 | move(i8), angle_delta(i8), power_delta(i8), fire(u8) |
| SYNC | 0x02 | full GameState blob |
| PING | 0x03 | timestamp(u64) |
| PONG | 0x04 | original timestamp(u64) |
### zig udp basics
```zig
const std = @import("std");
const net = std.net;
// create socket
const sock = try std.posix.socket(std.posix.AF.INET, std.posix.SOCK.DGRAM, 0);
defer std.posix.close(sock);
// bind (server)
const addr = net.Address.initIp4(.{ 0, 0, 0, 0 }, 7777);
try std.posix.bind(sock, &addr.any, addr.getLen());
// send
const dest = net.Address.initIp4(.{ 127, 0, 0, 1 }, 7777);
_ = try std.posix.sendto(sock, &packet_bytes, 0, &dest.any, dest.getLen());
// receive
var buf: [1024]u8 = undefined;
var src_addr: std.posix.sockaddr = undefined;
var src_len: std.posix.socklen_t = @sizeOf(std.posix.sockaddr);
const len = try std.posix.recvfrom(sock, &buf, 0, &src_addr, &src_len);
```
## raylib-zig setup
### build.zig.zon
```zig
.{
.name = "lockstep",
.version = "0.0.1",
.dependencies = .{
.raylib_zig = .{
.url = "git+https://github.com/Not-Nik/raylib-zig#devel",
.hash = "...", // zig fetch will tell you
},
},
}
```
### build.zig
```zig
const std = @import("std");
pub fn build(b: *std.Build) void {
const target = b.standardTargetOptions(.{});
const optimize = b.standardOptimizeOption(.{});
const raylib_dep = b.dependency("raylib_zig", .{
.target = target,
.optimize = optimize,
});
const exe = b.addExecutable(.{
.name = "lockstep",
.root_module = b.createModule(.{
.root_source_file = b.path("src/main.zig"),
.target = target,
.optimize = optimize,
}),
});
exe.root_module.addImport("raylib", raylib_dep.module("raylib"));
exe.linkLibrary(raylib_dep.artifact("raylib"));
b.installArtifact(exe);
const run_cmd = b.addRunArtifact(exe);
run_cmd.step.dependOn(b.getInstallStep());
const run_step = b.step("run", "run the game");
run_step.dependOn(&run_cmd.step);
}
```
### basic window
```zig
const rl = @import("raylib");
pub fn main() !void {
rl.initWindow(800, 600, "lockstep");
defer rl.closeWindow();
rl.setTargetFPS(60);
while (!rl.windowShouldClose()) {
rl.beginDrawing();
defer rl.endDrawing();
rl.clearBackground(rl.Color.black);
rl.drawText("hello", 10, 10, 20, rl.Color.white);
}
}
```
## rendering glow effect
### render texture + shader
```zig
const rl = @import("raylib");
// load shader
const blur_shader = rl.loadShader(null, "shaders/blur.fs");
defer rl.unloadShader(blur_shader);
// create render textures
const game_tex = rl.loadRenderTexture(800, 600);
const blur_tex = rl.loadRenderTexture(800, 600);
defer rl.unloadRenderTexture(game_tex);
defer rl.unloadRenderTexture(blur_tex);
// in game loop:
// 1. draw game to texture
rl.beginTextureMode(game_tex);
rl.clearBackground(.{ .r = 10, .g = 10, .b = 18, .a = 255 });
drawGame(&state);
rl.endTextureMode();
// 2. blur pass (horizontal)
rl.beginTextureMode(blur_tex);
rl.beginShaderMode(blur_shader);
// set direction uniform to (1, 0)
rl.drawTextureRec(game_tex.texture, .{ .x = 0, .y = 0, .width = 800, .height = -600 }, .{ .x = 0, .y = 0 }, .white);
rl.endShaderMode();
rl.endTextureMode();
// 3. blur pass (vertical) + composite
rl.beginDrawing();
// draw original
rl.drawTextureRec(game_tex.texture, .{ .x = 0, .y = 0, .width = 800, .height = -600 }, .{ .x = 0, .y = 0 }, .white);
// additive blend blurred
rl.beginBlendMode(.additive);
rl.beginShaderMode(blur_shader);
// set direction uniform to (0, 1)
rl.drawTextureRec(blur_tex.texture, .{ .x = 0, .y = 0, .width = 800, .height = -600 }, .{ .x = 0, .y = 0 }, .white);
rl.endShaderMode();
rl.endBlendMode();
rl.endDrawing();
```
### drawing lines
```zig
// basic line
rl.drawLine(x1, y1, x2, y2, color);
// thick line
rl.drawLineEx(.{ .x = x1, .y = y1 }, .{ .x = x2, .y = y2 }, thickness, color);
// terrain as connected lines
for (0..SCREEN_WIDTH - 1) |x| {
const y1 = SCREEN_HEIGHT - terrain.heights[x].toInt();
const y2 = SCREEN_HEIGHT - terrain.heights[x + 1].toInt();
rl.drawLine(@intCast(x), y1, @intCast(x + 1), y2, .{ .r = 0, .g = 255, .b = 0, .a = 255 });
}
```
## checksum
```zig
pub fn checksum(state: *const GameState) u32 {
var h = std.hash.Fnv1a_32.init();
h.update(std.mem.asBytes(&state.tick));
h.update(std.mem.asBytes(&state.players));
h.update(std.mem.asBytes(&state.wind.raw));
if (state.projectile) |p| h.update(std.mem.asBytes(&p));
return h.final();
}
```
## common fixed-point constants
```zig
pub const ZERO = Fixed{ .raw = 0 };
pub const ONE = Fixed{ .raw = 1 << 32 };
pub const HALF = Fixed{ .raw = 1 << 31 };
pub const TWO = Fixed{ .raw = 2 << 32 };
pub const PI = Fixed{ .raw = 13493037705 };
pub const TWO_PI = Fixed{ .raw = 26986075409 };
pub const HALF_PI = Fixed{ .raw = 6746518852 };
// game constants
pub const GRAVITY = Fixed{ .raw = 42949673 }; // ~0.01
pub const WIND_FACTOR = Fixed{ .raw = 4294967 }; // ~0.001
pub const MAX_POWER = Fixed{ .raw = 100 << 32 };
```