diff --git a/docs/design.md b/docs/design.md new file mode 100644 index 0000000..4c49319 --- /dev/null +++ b/docs/design.md @@ -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 +``` diff --git a/docs/reference.md b/docs/reference.md new file mode 100644 index 0000000..508d915 --- /dev/null +++ b/docs/reference.md @@ -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 }; +```