diff --git a/TODO.md b/TODO.md index dc0e070..2fadf23 100644 --- a/TODO.md +++ b/TODO.md @@ -4,14 +4,14 @@ survivor-like optimized for weak hardware. finding the performance ceiling first ## phase 1: sandbox stress test -- [ ] create sandbox.zig (separate from existing game code) -- [ ] entity struct (x, y, vx, vy, color) -- [ ] flat array storage for entities -- [ ] spawn entities at random screen edges -- [ ] update loop: move toward center, respawn on arrival -- [ ] render: filled circles (4px radius, cyan) -- [ ] metrics overlay (entity count, frame time, update time, render time) -- [ ] controls: +/- 100, shift +/- 1000, space pause, r reset +- [x] create sandbox.zig (separate from existing game code) +- [x] entity struct (x, y, vx, vy, color) +- [x] flat array storage for entities +- [x] spawn entities at random screen edges +- [x] update loop: move toward center, respawn on arrival +- [x] render: filled circles (4px radius, cyan) +- [x] metrics overlay (entity count, frame time, update time, render time) +- [x] controls: +/- 100, shift +/- 1000, space pause, r reset ## phase 2: find the ceiling diff --git a/build.zig b/build.zig index bb3b08b..c3d9359 100644 --- a/build.zig +++ b/build.zig @@ -32,6 +32,30 @@ pub fn build(b: *std.Build) void { const run_step = b.step("run", "run the game"); run_step.dependOn(&run_cmd.step); + // sandbox executable + const sandbox_exe = b.addExecutable(.{ + .name = "sandbox", + .root_module = b.createModule(.{ + .root_source_file = b.path("src/sandbox_main.zig"), + .target = target, + .optimize = optimize, + }), + }); + + sandbox_exe.root_module.addImport("raylib", raylib_dep.module("raylib")); + sandbox_exe.linkLibrary(raylib_dep.artifact("raylib")); + + b.installArtifact(sandbox_exe); + + const sandbox_run_cmd = b.addRunArtifact(sandbox_exe); + sandbox_run_cmd.step.dependOn(b.getInstallStep()); + if (b.args) |args| { + sandbox_run_cmd.addArgs(args); + } + + const sandbox_step = b.step("sandbox", "run the sandbox stress test"); + sandbox_step.dependOn(&sandbox_run_cmd.step); + // test step (doesn't need raylib) const test_step = b.step("test", "run unit tests"); @@ -40,6 +64,7 @@ pub fn build(b: *std.Build) void { "src/trig.zig", "src/terrain.zig", "src/game.zig", + "src/sandbox.zig", }; for (test_files) |file| { diff --git a/src/sandbox.zig b/src/sandbox.zig new file mode 100644 index 0000000..bdcc0f1 --- /dev/null +++ b/src/sandbox.zig @@ -0,0 +1,280 @@ +// sandbox stress test +// measures entity count ceiling on weak hardware + +const std = @import("std"); + +pub const SCREEN_WIDTH: u32 = 1280; +pub const SCREEN_HEIGHT: u32 = 1024; +const CENTER_X: f32 = @as(f32, @floatFromInt(SCREEN_WIDTH)) / 2.0; +const CENTER_Y: f32 = @as(f32, @floatFromInt(SCREEN_HEIGHT)) / 2.0; +const RESPAWN_THRESHOLD: f32 = 10.0; +const ENTITY_SPEED: f32 = 2.0; + +pub const Entity = struct { + x: f32, + y: f32, + vx: f32, + vy: f32, + color: u32, +}; + +pub const MAX_ENTITIES: usize = 100_000; + +pub const Entities = struct { + items: [MAX_ENTITIES]Entity, + count: usize, + + pub fn init() Entities { + return .{ + .items = undefined, + .count = 0, + }; + } + + pub fn add(self: *Entities, rng: *std.Random) void { + if (self.count >= MAX_ENTITIES) return; + self.items[self.count] = spawnAtEdge(rng); + self.count += 1; + } + + pub fn remove(self: *Entities, n: usize) void { + if (n >= self.count) { + self.count = 0; + } else { + self.count -= n; + } + } + + pub fn reset(self: *Entities) void { + self.count = 0; + } +}; + +pub fn update(entities: *Entities, rng: *std.Random) void { + for (entities.items[0..entities.count]) |*entity| { + // apply velocity + entity.x += entity.vx; + entity.y += entity.vy; + + // check if reached center + const dx = entity.x - CENTER_X; + const dy = entity.y - CENTER_Y; + const dist = @sqrt(dx * dx + dy * dy); + + if (dist < RESPAWN_THRESHOLD) { + // respawn at random edge + entity.* = spawnAtEdge(rng); + } + } +} + +pub fn spawnAtEdge(rng: *std.Random) Entity { + // pick random edge: 0=top, 1=bottom, 2=left, 3=right + const edge = rng.intRangeAtMost(u8, 0, 3); + const screen_w = @as(f32, @floatFromInt(SCREEN_WIDTH)); + const screen_h = @as(f32, @floatFromInt(SCREEN_HEIGHT)); + + var x: f32 = undefined; + var y: f32 = undefined; + + switch (edge) { + 0 => { // top + x = rng.float(f32) * screen_w; + y = 0; + }, + 1 => { // bottom + x = rng.float(f32) * screen_w; + y = screen_h; + }, + 2 => { // left + x = 0; + y = rng.float(f32) * screen_h; + }, + 3 => { // right + x = screen_w; + y = rng.float(f32) * screen_h; + }, + else => unreachable, + } + + // velocity toward center + const dx = CENTER_X - x; + const dy = CENTER_Y - y; + const dist = @sqrt(dx * dx + dy * dy); + const vx = (dx / dist) * ENTITY_SPEED; + const vy = (dy / dist) * ENTITY_SPEED; + + return .{ + .x = x, + .y = y, + .vx = vx, + .vy = vy, + .color = 0x00FFFF, // cyan + }; +} + +// tests + +test "Entity struct has correct size" { + // 5 fields: x, y, vx, vy (f32 = 4 bytes each) + color (u32 = 4 bytes) + // 20 bytes total as per plan + try std.testing.expectEqual(@as(usize, 20), @sizeOf(Entity)); +} + +test "Entity can be created with initial values" { + const entity = Entity{ + .x = 100.0, + .y = 200.0, + .vx = 1.5, + .vy = -0.5, + .color = 0x00FFFF, + }; + + try std.testing.expectEqual(@as(f32, 100.0), entity.x); + try std.testing.expectEqual(@as(f32, 200.0), entity.y); + try std.testing.expectEqual(@as(f32, 1.5), entity.vx); + try std.testing.expectEqual(@as(f32, -0.5), entity.vy); + try std.testing.expectEqual(@as(u32, 0x00FFFF), entity.color); +} + +test "Entities init starts empty" { + const entities = Entities.init(); + try std.testing.expectEqual(@as(usize, 0), entities.count); +} + +test "Entities add increases count" { + var entities = Entities.init(); + var prng = std.Random.DefaultPrng.init(12345); + var rng = prng.random(); + + entities.add(&rng); + try std.testing.expectEqual(@as(usize, 1), entities.count); + + entities.add(&rng); + try std.testing.expectEqual(@as(usize, 2), entities.count); +} + +test "Entities remove decreases count" { + var entities = Entities.init(); + var prng = std.Random.DefaultPrng.init(12345); + var rng = prng.random(); + + // add 5 + for (0..5) |_| entities.add(&rng); + try std.testing.expectEqual(@as(usize, 5), entities.count); + + // remove 2 + entities.remove(2); + try std.testing.expectEqual(@as(usize, 3), entities.count); + + // remove more than count + entities.remove(100); + try std.testing.expectEqual(@as(usize, 0), entities.count); +} + +test "Entities reset clears all" { + var entities = Entities.init(); + var prng = std.Random.DefaultPrng.init(12345); + var rng = prng.random(); + + for (0..10) |_| entities.add(&rng); + try std.testing.expectEqual(@as(usize, 10), entities.count); + + entities.reset(); + try std.testing.expectEqual(@as(usize, 0), entities.count); +} + +test "spawnAtEdge creates entity on screen edge" { + var prng = std.Random.DefaultPrng.init(12345); + var rng = prng.random(); + + // spawn several and check they're on edges + for (0..20) |_| { + const entity = spawnAtEdge(&rng); + const on_left = entity.x == 0; + const on_right = entity.x == @as(f32, @floatFromInt(SCREEN_WIDTH)); + const on_top = entity.y == 0; + const on_bottom = entity.y == @as(f32, @floatFromInt(SCREEN_HEIGHT)); + + try std.testing.expect(on_left or on_right or on_top or on_bottom); + } +} + +test "spawnAtEdge velocity points toward center" { + var prng = std.Random.DefaultPrng.init(12345); + var rng = prng.random(); + + for (0..20) |_| { + const entity = spawnAtEdge(&rng); + + // after one step, should be closer to center + const dist_before = @sqrt((entity.x - CENTER_X) * (entity.x - CENTER_X) + + (entity.y - CENTER_Y) * (entity.y - CENTER_Y)); + + const new_x = entity.x + entity.vx; + const new_y = entity.y + entity.vy; + const dist_after = @sqrt((new_x - CENTER_X) * (new_x - CENTER_X) + + (new_y - CENTER_Y) * (new_y - CENTER_Y)); + + try std.testing.expect(dist_after < dist_before); + } +} + +test "spawnAtEdge velocity has correct speed" { + var prng = std.Random.DefaultPrng.init(12345); + var rng = prng.random(); + + const entity = spawnAtEdge(&rng); + const speed = @sqrt(entity.vx * entity.vx + entity.vy * entity.vy); + + try std.testing.expectApproxEqAbs(ENTITY_SPEED, speed, 0.001); +} + +test "update moves entities by velocity" { + var entities = Entities.init(); + // manually place an entity + entities.items[0] = .{ + .x = 100.0, + .y = 100.0, + .vx = 2.0, + .vy = 3.0, + .color = 0x00FFFF, + }; + entities.count = 1; + + var prng = std.Random.DefaultPrng.init(12345); + var rng = prng.random(); + + update(&entities, &rng); + + try std.testing.expectApproxEqAbs(@as(f32, 102.0), entities.items[0].x, 0.001); + try std.testing.expectApproxEqAbs(@as(f32, 103.0), entities.items[0].y, 0.001); +} + +test "update respawns entity at edge when reaching center" { + var entities = Entities.init(); + // place entity very close to center (within threshold) + entities.items[0] = .{ + .x = CENTER_X + 1.0, + .y = CENTER_Y + 1.0, + .vx = -1.0, + .vy = -1.0, + .color = 0x00FFFF, + }; + entities.count = 1; + + var prng = std.Random.DefaultPrng.init(12345); + var rng = prng.random(); + + // after update, entity moves to center and should respawn + update(&entities, &rng); + + // should now be on an edge + const entity = entities.items[0]; + const on_left = entity.x == 0; + const on_right = entity.x == @as(f32, @floatFromInt(SCREEN_WIDTH)); + const on_top = entity.y == 0; + const on_bottom = entity.y == @as(f32, @floatFromInt(SCREEN_HEIGHT)); + + try std.testing.expect(on_left or on_right or on_top or on_bottom); +} diff --git a/src/sandbox_main.zig b/src/sandbox_main.zig new file mode 100644 index 0000000..29bef47 --- /dev/null +++ b/src/sandbox_main.zig @@ -0,0 +1,130 @@ +// sandbox stress test entry point +// measures entity count ceiling on weak hardware + +const std = @import("std"); +const rl = @import("raylib"); +const sandbox = @import("sandbox.zig"); + +const SCREEN_WIDTH = sandbox.SCREEN_WIDTH; +const SCREEN_HEIGHT = sandbox.SCREEN_HEIGHT; + +// colors +const BG_COLOR = rl.Color{ .r = 10, .g = 10, .b = 18, .a = 255 }; +const CYAN = rl.Color{ .r = 0, .g = 255, .b = 255, .a = 255 }; + +pub fn main() !void { + rl.initWindow(@intCast(SCREEN_WIDTH), @intCast(SCREEN_HEIGHT), "lofivor sandbox"); + defer rl.closeWindow(); + rl.setTargetFPS(60); + + var entities = sandbox.Entities.init(); + var prng = std.Random.DefaultPrng.init(@intCast(std.time.timestamp())); + var rng = prng.random(); + + var paused = false; + + // timing + var update_time_us: i64 = 0; + var render_time_us: i64 = 0; + + while (!rl.windowShouldClose()) { + // controls + handleInput(&entities, &rng, &paused); + + // update + if (!paused) { + const update_start = std.time.microTimestamp(); + sandbox.update(&entities, &rng); + update_time_us = std.time.microTimestamp() - update_start; + } + + // render + const render_start = std.time.microTimestamp(); + + rl.beginDrawing(); + rl.clearBackground(BG_COLOR); + + // draw entities as filled circles + for (entities.items[0..entities.count]) |entity| { + rl.drawCircle( + @intFromFloat(entity.x), + @intFromFloat(entity.y), + 4, + CYAN, + ); + } + + // metrics overlay + drawMetrics(&entities, update_time_us, render_time_us, paused); + + rl.endDrawing(); + + render_time_us = std.time.microTimestamp() - render_start; + } +} + +fn handleInput(entities: *sandbox.Entities, rng: *std.Random, paused: *bool) void { + const shift = rl.isKeyDown(.left_shift) or rl.isKeyDown(.right_shift); + const add_count: usize = if (shift) 1000 else 100; + + // add entities: = or + + if (rl.isKeyPressed(.equal) or rl.isKeyPressed(.kp_add)) { + for (0..add_count) |_| { + entities.add(rng); + } + } + + // remove entities: - or _ + if (rl.isKeyPressed(.minus) or rl.isKeyPressed(.kp_subtract)) { + entities.remove(add_count); + } + + // reset: r + if (rl.isKeyPressed(.r)) { + entities.reset(); + } + + // pause: space + if (rl.isKeyPressed(.space)) { + paused.* = !paused.*; + } +} + +fn drawMetrics(entities: *const sandbox.Entities, update_us: i64, render_us: i64, paused: bool) void { + var buf: [256]u8 = undefined; + var y: i32 = 10; + const line_height: i32 = 20; + + // entity count + const count_text = std.fmt.bufPrintZ(&buf, "entities: {d}", .{entities.count}) catch "?"; + rl.drawText(count_text, 10, y, 16, rl.Color.white); + y += line_height; + + // frame time + const frame_ms = rl.getFrameTime() * 1000.0; + const frame_text = std.fmt.bufPrintZ(&buf, "frame: {d:.1}ms", .{frame_ms}) catch "?"; + rl.drawText(frame_text, 10, y, 16, rl.Color.white); + y += line_height; + + // update time + const update_ms = @as(f32, @floatFromInt(update_us)) / 1000.0; + const update_text = std.fmt.bufPrintZ(&buf, "update: {d:.1}ms", .{update_ms}) catch "?"; + rl.drawText(update_text, 10, y, 16, rl.Color.white); + y += line_height; + + // render time + const render_ms = @as(f32, @floatFromInt(render_us)) / 1000.0; + const render_text = std.fmt.bufPrintZ(&buf, "render: {d:.1}ms", .{render_ms}) catch "?"; + rl.drawText(render_text, 10, y, 16, rl.Color.white); + y += line_height; + + // paused indicator + if (paused) { + y += line_height; + rl.drawText("PAUSED", 10, y, 16, rl.Color.yellow); + } + + // controls help (bottom) + const help_y: i32 = @intCast(SCREEN_HEIGHT - 30); + rl.drawText("+/-: 100 shift+/-: 1000 space: pause r: reset", 10, help_y, 14, rl.Color.gray); +}