Resumed to project. INIT

This commit is contained in:
2026-06-17 08:23:13 +02:00
commit fd02ed6d8b
51 changed files with 5067 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
# Rust build artifacts
/target
# Editor / OS cruft
*.swp
*~
.DS_Store
Generated Executable
+2419
View File
File diff suppressed because it is too large Load Diff
Executable
+3
View File
@@ -0,0 +1,3 @@
[workspace]
members = ["shared", "client", "server"]
resolver = "2"
Executable
BIN
View File
Binary file not shown.
+260
View File
@@ -0,0 +1,260 @@
GIMP Palette
Name: RGB332
Columns: 16
#
0 0 0 Index 0
255 0 0 Index 1
0 0 170 Index 2
0 0 255 Index 3
0 36 0 Index 4
0 36 85 Index 5
0 36 170 Index 6
0 36 255 Index 7
0 72 0 Index 8
0 72 85 Index 9
0 72 170 Index 10
0 72 255 Index 11
0 109 0 Index 12
0 109 85 Index 13
0 109 170 Index 14
0 109 255 Index 15
0 145 0 Index 16
0 145 85 Index 17
0 145 170 Index 18
0 145 255 Index 19
0 182 0 Index 20
0 182 85 Index 21
0 182 170 Index 22
0 182 255 Index 23
0 218 0 Index 24
0 218 85 Index 25
0 218 170 Index 26
0 218 255 Index 27
0 255 0 Index 28
0 255 85 Index 29
0 255 170 Index 30
0 255 255 Index 31
36 0 0 Index 32
36 0 85 Index 33
36 0 170 Index 34
36 0 255 Index 35
36 36 0 Index 36
36 36 85 Index 37
36 36 170 Index 38
36 36 255 Index 39
36 72 0 Index 40
36 72 85 Index 41
36 72 170 Index 42
36 72 255 Index 43
36 109 0 Index 44
36 109 85 Index 45
36 109 170 Index 46
36 109 255 Index 47
36 145 0 Index 48
36 145 85 Index 49
36 145 170 Index 50
36 145 255 Index 51
36 182 0 Index 52
36 182 85 Index 53
36 182 170 Index 54
36 182 255 Index 55
36 218 0 Index 56
36 218 85 Index 57
36 218 170 Index 58
36 218 255 Index 59
36 255 0 Index 60
36 255 85 Index 61
36 255 170 Index 62
36 255 255 Index 63
72 0 0 Index 64
72 0 85 Index 65
72 0 170 Index 66
72 0 255 Index 67
72 36 0 Index 68
72 36 85 Index 69
72 36 170 Index 70
72 36 255 Index 71
72 72 0 Index 72
72 72 85 Index 73
72 72 170 Index 74
72 72 255 Index 75
72 109 0 Index 76
72 109 85 Index 77
72 109 170 Index 78
72 109 255 Index 79
72 145 0 Index 80
72 145 85 Index 81
72 145 170 Index 82
72 145 255 Index 83
72 182 0 Index 84
72 182 85 Index 85
72 182 170 Index 86
72 182 255 Index 87
72 218 0 Index 88
72 218 85 Index 89
72 218 170 Index 90
72 218 255 Index 91
72 255 0 Index 92
72 255 85 Index 93
72 255 170 Index 94
72 255 255 Index 95
109 0 0 Index 96
109 0 85 Index 97
109 0 170 Index 98
109 0 255 Index 99
109 36 0 Index 100
109 36 85 Index 101
109 36 170 Index 102
109 36 255 Index 103
109 72 0 Index 104
109 72 85 Index 105
109 72 170 Index 106
109 72 255 Index 107
109 109 0 Index 108
109 109 85 Index 109
109 109 170 Index 110
109 109 255 Index 111
109 145 0 Index 112
109 145 85 Index 113
109 145 170 Index 114
109 145 255 Index 115
109 182 0 Index 116
109 182 85 Index 117
109 182 170 Index 118
109 182 255 Index 119
109 218 0 Index 120
109 218 85 Index 121
109 218 170 Index 122
109 218 255 Index 123
109 255 0 Index 124
109 255 85 Index 125
109 255 170 Index 126
109 255 255 Index 127
145 0 0 Index 128
145 0 85 Index 129
145 0 170 Index 130
145 0 255 Index 131
145 36 0 Index 132
145 36 85 Index 133
145 36 170 Index 134
145 36 255 Index 135
145 72 0 Index 136
145 72 85 Index 137
145 72 170 Index 138
145 72 255 Index 139
145 109 0 Index 140
145 109 85 Index 141
145 109 170 Index 142
145 109 255 Index 143
145 145 0 Index 144
145 145 85 Index 145
145 145 170 Index 146
145 145 255 Index 147
145 182 0 Index 148
145 182 85 Index 149
145 182 170 Index 150
145 182 255 Index 151
145 218 0 Index 152
145 218 85 Index 153
145 218 170 Index 154
145 218 255 Index 155
145 255 0 Index 156
145 255 85 Index 157
145 255 170 Index 158
145 255 255 Index 159
182 0 0 Index 160
182 0 85 Index 161
182 0 170 Index 162
182 0 255 Index 163
182 36 0 Index 164
182 36 85 Index 165
182 36 170 Index 166
182 36 255 Index 167
182 72 0 Index 168
182 72 85 Index 169
182 72 170 Index 170
182 72 255 Index 171
182 109 0 Index 172
182 109 85 Index 173
182 109 170 Index 174
182 109 255 Index 175
182 145 0 Index 176
182 145 85 Index 177
182 145 170 Index 178
182 145 255 Index 179
182 182 0 Index 180
182 182 85 Index 181
182 182 170 Index 182
182 182 255 Index 183
182 218 0 Index 184
182 218 85 Index 185
182 218 170 Index 186
182 218 255 Index 187
182 255 0 Index 188
182 255 85 Index 189
182 255 170 Index 190
182 255 255 Index 191
218 0 0 Index 192
218 0 85 Index 193
218 0 170 Index 194
218 0 255 Index 195
218 36 0 Index 196
218 36 85 Index 197
218 36 170 Index 198
218 36 255 Index 199
218 72 0 Index 200
218 72 85 Index 201
218 72 170 Index 202
218 72 255 Index 203
218 109 0 Index 204
218 109 85 Index 205
218 109 170 Index 206
218 109 255 Index 207
218 145 0 Index 208
218 145 85 Index 209
218 145 170 Index 210
218 145 255 Index 211
218 182 0 Index 212
218 182 85 Index 213
218 182 170 Index 214
218 182 255 Index 215
218 218 0 Index 216
218 218 85 Index 217
218 218 170 Index 218
218 218 255 Index 219
218 255 0 Index 220
218 255 85 Index 221
218 255 170 Index 222
218 255 255 Index 223
255 0 0 Index 224
255 0 85 Index 225
255 0 170 Index 226
255 0 255 Index 227
255 36 0 Index 228
255 36 85 Index 229
255 36 170 Index 230
255 36 255 Index 231
255 72 0 Index 232
255 72 85 Index 233
255 72 170 Index 234
255 72 255 Index 235
255 109 0 Index 236
255 109 85 Index 237
255 109 170 Index 238
255 109 255 Index 239
255 145 0 Index 240
255 145 85 Index 241
255 145 170 Index 242
255 145 255 Index 243
255 182 0 Index 244
255 182 85 Index 245
255 182 170 Index 246
255 182 255 Index 247
255 218 0 Index 248
255 218 85 Index 249
255 218 170 Index 250
255 218 255 Index 251
255 255 0 Index 252
255 255 85 Index 253
255 255 170 Index 254
255 255 255 Index 255
Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

+17
View File
@@ -0,0 +1,17 @@
Mini Medieval v2.3.1
--------------------
Enjoy my assets? Keep up to date by following me on itch or x.
https://v3x3d.itch.io/
https://x.com/_V3X3D
You can also support me on Patreon for just a $1 each month.
Your support helps me keep making assets and provides you with rewards.
https://www.patreon.com/V3X3D
Cheers,
-- VEXED
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

BIN
View File
Binary file not shown.
+7
View File
@@ -0,0 +1,7 @@
with open("rgb332.gpl", "w") as f:
f.write("GIMP Palette\nName: RGB332\nColumns: 16\n#\n")
for i in range(256):
r = ((i >> 5) * 255) // 7
g = (((i >> 2) & 0x7) * 255) // 7
b = ((i & 0x3) * 255) // 3
f.write(f"{r:3d} {g:3d} {b:3d} Index {i}\n")
+260
View File
@@ -0,0 +1,260 @@
GIMP Palette
Name: RGB332
Columns: 16
#
0 0 0 Index 0
0 0 0 Index 1
0 0 170 Index 2
0 0 255 Index 3
0 36 0 Index 4
0 36 85 Index 5
0 36 170 Index 6
0 36 255 Index 7
0 72 0 Index 8
0 72 85 Index 9
0 72 170 Index 10
0 72 255 Index 11
0 109 0 Index 12
0 109 85 Index 13
0 109 170 Index 14
0 109 255 Index 15
0 145 0 Index 16
0 145 85 Index 17
0 145 170 Index 18
0 145 255 Index 19
0 182 0 Index 20
0 182 85 Index 21
0 182 170 Index 22
0 182 255 Index 23
0 218 0 Index 24
0 218 85 Index 25
0 218 170 Index 26
0 218 255 Index 27
0 255 0 Index 28
0 255 85 Index 29
0 255 170 Index 30
0 255 255 Index 31
36 0 0 Index 32
36 0 85 Index 33
36 0 170 Index 34
36 0 255 Index 35
36 36 0 Index 36
36 36 85 Index 37
36 36 170 Index 38
36 36 255 Index 39
36 72 0 Index 40
36 72 85 Index 41
36 72 170 Index 42
36 72 255 Index 43
36 109 0 Index 44
36 109 85 Index 45
36 109 170 Index 46
36 109 255 Index 47
36 145 0 Index 48
36 145 85 Index 49
36 145 170 Index 50
36 145 255 Index 51
36 182 0 Index 52
36 182 85 Index 53
36 182 170 Index 54
36 182 255 Index 55
36 218 0 Index 56
36 218 85 Index 57
36 218 170 Index 58
36 218 255 Index 59
36 255 0 Index 60
36 255 85 Index 61
36 255 170 Index 62
36 255 255 Index 63
72 0 0 Index 64
72 0 85 Index 65
72 0 170 Index 66
72 0 255 Index 67
72 36 0 Index 68
72 36 85 Index 69
72 36 170 Index 70
72 36 255 Index 71
72 72 0 Index 72
72 72 85 Index 73
72 72 170 Index 74
72 72 255 Index 75
72 109 0 Index 76
72 109 85 Index 77
72 109 170 Index 78
72 109 255 Index 79
72 145 0 Index 80
72 145 85 Index 81
72 145 170 Index 82
72 145 255 Index 83
72 182 0 Index 84
72 182 85 Index 85
72 182 170 Index 86
72 182 255 Index 87
72 218 0 Index 88
72 218 85 Index 89
72 218 170 Index 90
72 218 255 Index 91
72 255 0 Index 92
72 255 85 Index 93
72 255 170 Index 94
72 255 255 Index 95
109 0 0 Index 96
109 0 85 Index 97
109 0 170 Index 98
109 0 255 Index 99
109 36 0 Index 100
109 36 85 Index 101
109 36 170 Index 102
109 36 255 Index 103
109 72 0 Index 104
109 72 85 Index 105
109 72 170 Index 106
109 72 255 Index 107
109 109 0 Index 108
109 109 85 Index 109
109 109 170 Index 110
109 109 255 Index 111
109 145 0 Index 112
109 145 85 Index 113
109 145 170 Index 114
109 145 255 Index 115
109 182 0 Index 116
109 182 85 Index 117
109 182 170 Index 118
109 182 255 Index 119
109 218 0 Index 120
109 218 85 Index 121
109 218 170 Index 122
109 218 255 Index 123
109 255 0 Index 124
109 255 85 Index 125
109 255 170 Index 126
109 255 255 Index 127
145 0 0 Index 128
145 0 85 Index 129
145 0 170 Index 130
145 0 255 Index 131
145 36 0 Index 132
145 36 85 Index 133
145 36 170 Index 134
145 36 255 Index 135
145 72 0 Index 136
145 72 85 Index 137
145 72 170 Index 138
145 72 255 Index 139
145 109 0 Index 140
145 109 85 Index 141
145 109 170 Index 142
145 109 255 Index 143
145 145 0 Index 144
145 145 85 Index 145
145 145 170 Index 146
145 145 255 Index 147
145 182 0 Index 148
145 182 85 Index 149
145 182 170 Index 150
145 182 255 Index 151
145 218 0 Index 152
145 218 85 Index 153
145 218 170 Index 154
145 218 255 Index 155
145 255 0 Index 156
145 255 85 Index 157
145 255 170 Index 158
145 255 255 Index 159
182 0 0 Index 160
182 0 85 Index 161
182 0 170 Index 162
182 0 255 Index 163
182 36 0 Index 164
182 36 85 Index 165
182 36 170 Index 166
182 36 255 Index 167
182 72 0 Index 168
182 72 85 Index 169
182 72 170 Index 170
182 72 255 Index 171
182 109 0 Index 172
182 109 85 Index 173
182 109 170 Index 174
182 109 255 Index 175
182 145 0 Index 176
182 145 85 Index 177
182 145 170 Index 178
182 145 255 Index 179
182 182 0 Index 180
182 182 85 Index 181
182 182 170 Index 182
182 182 255 Index 183
182 218 0 Index 184
182 218 85 Index 185
182 218 170 Index 186
182 218 255 Index 187
182 255 0 Index 188
182 255 85 Index 189
182 255 170 Index 190
182 255 255 Index 191
218 0 0 Index 192
218 0 85 Index 193
218 0 170 Index 194
218 0 255 Index 195
218 36 0 Index 196
218 36 85 Index 197
218 36 170 Index 198
218 36 255 Index 199
218 72 0 Index 200
218 72 85 Index 201
218 72 170 Index 202
218 72 255 Index 203
218 109 0 Index 204
218 109 85 Index 205
218 109 170 Index 206
218 109 255 Index 207
218 145 0 Index 208
218 145 85 Index 209
218 145 170 Index 210
218 145 255 Index 211
218 182 0 Index 212
218 182 85 Index 213
218 182 170 Index 214
218 182 255 Index 215
218 218 0 Index 216
218 218 85 Index 217
218 218 170 Index 218
218 218 255 Index 219
218 255 0 Index 220
218 255 85 Index 221
218 255 170 Index 222
218 255 255 Index 223
255 0 0 Index 224
255 0 85 Index 225
255 0 170 Index 226
255 0 255 Index 227
255 36 0 Index 228
255 36 85 Index 229
255 36 170 Index 230
255 36 255 Index 231
255 72 0 Index 232
255 72 85 Index 233
255 72 170 Index 234
255 72 255 Index 235
255 109 0 Index 236
255 109 85 Index 237
255 109 170 Index 238
255 109 255 Index 239
255 145 0 Index 240
255 145 85 Index 241
255 145 170 Index 242
255 145 255 Index 243
255 182 0 Index 244
255 182 85 Index 245
255 182 170 Index 246
255 182 255 Index 247
255 218 0 Index 248
255 218 85 Index 249
255 218 170 Index 250
255 218 255 Index 251
255 255 0 Index 252
255 255 85 Index 253
255 255 170 Index 254
255 255 255 Index 255
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
+9
View File
@@ -0,0 +1,9 @@
[package]
name = "client"
version = "0.1.0"
edition = "2024"
[dependencies]
shared = { path = "../shared" }
pbio = { git = "https://codeberg.org/irrlicht/rust-pbio.git" }
bytemuck = { version = "1.24.0", features = ["derive"] }
+72
View File
@@ -0,0 +1,72 @@
use std::fs::File;
use std::io::Read;
pub struct Image {
pub width: u32,
pub height: u32,
pub pixels: Vec<u8>,
}
impl Image {
/// Load an 8-bit indexed TGA (type 1 or type 9) from disk.
pub fn from_tga(path: &str) -> Self {
let mut file = File::open(path).unwrap();
let mut data = Vec::new();
file.read_to_end(&mut data).unwrap();
let image_type = data[2];
let width = u16::from_le_bytes([data[12], data[13]]) as u32;
let height = u16::from_le_bytes([data[14], data[15]]) as u32;
let colormap_entries = u16::from_le_bytes([data[5], data[6]]) as usize;
let colormap_entry_bytes = data[7] as usize / 8;
let pixel_offset = 18 + colormap_entries * colormap_entry_bytes;
let pixel_count = (width * height) as usize;
let mut pixels = vec![0u8; pixel_count];
match image_type {
1 => pixels.copy_from_slice(&data[pixel_offset..pixel_offset + pixel_count]),
9 => decode_rle(&data[pixel_offset..], &mut pixels),
_ => panic!("unsupported TGA image type: {}", image_type),
}
Image { width, height, pixels }
}
/// Split this image into 8×8 tiles, row-major. Tile ID = flat index into the Vec.
pub fn to_tileset(&self) -> Vec<[u8; 64]> {
let w = self.width as usize;
let cols = w / 8;
let rows = self.height as usize / 8;
let mut tiles = Vec::with_capacity(rows * cols);
for row in 0..rows {
for col in 0..cols {
let mut tile = [0u8; 64];
for tr in 0..8 {
for tc in 0..8 {
tile[tr * 8 + tc] = self.pixels[w * (row * 8 + tr) + col * 8 + tc];
}
}
tiles.push(tile);
}
}
tiles
}
}
fn decode_rle(src: &[u8], dst: &mut [u8]) {
let mut si = 0;
let mut di = 0;
while di < dst.len() {
let header = src[si];
si += 1;
let count = (header & 0x7f) as usize + 1;
if header & 0x80 != 0 {
dst[di..di + count].fill(src[si]);
si += 1;
} else {
dst[di..di + count].copy_from_slice(&src[si..si + count]);
si += count;
}
di += count;
}
}
+124
View File
@@ -0,0 +1,124 @@
mod pixelhelper;
use crate::assets::Image;
use crate::input::{GameAction, InputState};
use crate::net::{EntityInfo, NetClient, NetEvent};
use shared::{chunk_id, player_action};
pub enum GameSignal {
Quit,
}
pub struct Game {
#[allow(dead_code)]
tileset: Vec<[u8; 64]>,
entity_tileset: Vec<[u8; 64]>,
net: NetClient,
player_entity_id: u32,
player_pos: (i32, i32),
entities: Vec<EntityInfo>,
}
impl Game {
pub fn start() -> Self {
let image = Image::from_tga("assets/tilesets/overworld.tga");
let tileset = image.to_tileset();
let entity_tileset = Image::from_tga("assets/tilesets/entities.tga").to_tileset();
let server_addr = "127.0.0.1:7777".parse().unwrap();
Game {
tileset,
entity_tileset,
net: NetClient::new(server_addr),
player_entity_id: 0,
player_pos: (16, 16),
entities: Vec::new(),
}
}
pub fn update(&mut self, render_frame: &mut [u8], _dt: usize, input: &InputState)
-> Option<GameSignal>
{
if input.button_held(GameAction::Up) { self.net.send_action(player_action::NORTH); }
if input.button_held(GameAction::Down) { self.net.send_action(player_action::SOUTH); }
if input.button_held(GameAction::Left) { self.net.send_action(player_action::WEST); }
if input.button_held(GameAction::Right) { self.net.send_action(player_action::EAST); }
if input.button_pressed(GameAction::Confirm) {
self.net.send_ping();
println!("ping sent");
}
for event in self.net.poll() {
match event {
NetEvent::Pong { rtt_ms } => println!("pong! rtt = {rtt_ms} ms"),
NetEvent::State { tick, player_entity_id } => {
println!("state tick={tick} player_entity_id={player_entity_id}");
self.player_entity_id = player_entity_id;
}
NetEvent::Chunk { chunk_id } => println!("chunk cached id={chunk_id}"),
NetEvent::Entity { entities } => {
self.player_pos = entities.iter()
.find(|e| e.id == self.player_entity_id)
.map(|e| (e.pos_x as i32, e.pos_y as i32))
.unwrap_or(self.player_pos);
self.entities = entities;
}
NetEvent::Disconnected => println!("disconnected from server"),
}
}
if input.button_pressed(GameAction::Cancel) {
println!("Goodbye!");
return Some(GameSignal::Quit);
}
self.render_viewport(render_frame);
None
}
fn render_viewport(&self, frame: &mut [u8]) {
const WALL: u8 = 0x00;
const W: usize = 320;
frame.fill(0);
let ox = self.player_pos.0 - 15;
let oy = self.player_pos.1 - 15;
for vy in 0..30i32 {
for vx in 0..30i32 {
let wx = ox + vx;
let wy = oy + vy;
let cid = chunk_id(wx.div_euclid(32) as i16, wy.div_euclid(32) as i16);
let tile_idx = wy.rem_euclid(32) as usize * 32 + wx.rem_euclid(32) as usize;
let tile_id = self.net.chunk_cache.get(&cid)
.map(|c| c.tiles[tile_idx])
.unwrap_or(0);
let px = vx * 8;
let py = vy * 8;
if let Some(tile) = self.tileset.get(tile_id as usize) {
pixelhelper::blit_tile(frame, W, px, py, tile);
} else {
for dy in 0..8 {
for dx in 0..8 {
pixelhelper::set_pixel(frame, W, px + dx, py + dy, WALL);
}
}
}
}
}
// Entity pass
let ox = self.player_pos.0 - 15;
let oy = self.player_pos.1 - 15;
for e in &self.entities {
let vx = e.pos_x as i32 - ox;
let vy = e.pos_y as i32 - oy;
if vx < 0 || vx >= 30 || vy < 0 || vy >= 30 { continue; }
if let Some(tile) = self.entity_tileset.get(e.type_id as usize) {
pixelhelper::blit_tile(frame, W, vx * 8, vy * 8, tile);
}
}
}
}
+71
View File
@@ -0,0 +1,71 @@
/// Write a single palette index. Out-of-bounds coordinates are silently ignored.
#[allow(dead_code)]
pub fn set_pixel(dst: &mut [u8], dst_w: usize, x: i32, y: i32, color: u8) {
let dst_h = (dst.len() / dst_w) as i32;
if x < 0 || x >= dst_w as i32 || y < 0 || y >= dst_h {
return;
}
dst[dst_w * y as usize + x as usize] = color;
}
/// Copy a rectangle out of `src` into a new Vec (for building sprites/glyphs).
#[allow(dead_code)]
pub fn extract(src: &[u8], src_w: usize, x: usize, y: usize, w: usize, h: usize) -> Vec<u8> {
let mut out = vec![0u8; w * h];
for row in 0..h {
let s = src_w * (y + row) + x;
let d = w * row;
out[d..d + w].copy_from_slice(&src[s..s + w]);
}
out
}
/// Blit `src` (width `src_w`, height derived from src.len()) into `dst` at (dx, dy).
/// Clips against all four edges of `dst`; the destination position may be negative.
#[allow(dead_code)]
pub fn blit(dst: &mut [u8], dst_w: usize, dx: i32, dy: i32, src: &[u8], src_w: usize) {
let dst_w_i = dst_w as i32;
let dst_h = (dst.len() / dst_w) as i32;
let src_h = (src.len() / src_w) as i32;
for row in 0..src_h {
let y = dy + row;
if y < 0 || y >= dst_h {
continue;
}
// Clip the horizontal span once per row, then copy it in one shot.
let x0 = dx.max(0);
let x1 = (dx + src_w as i32).min(dst_w_i);
if x0 >= x1 {
continue;
}
let d = dst_w * y as usize + x0 as usize;
let s = (row * src_w as i32 + (x0 - dx)) as usize;
let len = (x1 - x0) as usize;
dst[d..d + len].copy_from_slice(&src[s..s + len]);
}
}
/// Blit an 8×8 tile with transparency: index 0 is skipped.
/// Clips against all four edges of `dst`; the destination position may be negative.
pub fn blit_tile(dst: &mut [u8], dst_w: usize, dx: i32, dy: i32, tile: &[u8; 64]) {
let dst_w_i = dst_w as i32;
let dst_h = (dst.len() / dst_w) as i32;
for row in 0..8i32 {
let y = dy + row;
if y < 0 || y >= dst_h {
continue;
}
for col in 0..8i32 {
let x = dx + col;
if x < 0 || x >= dst_w_i {
continue;
}
let px = tile[(row * 8 + col) as usize];
if px != 0 {
dst[dst_w * y as usize + x as usize] = px;
}
}
}
}
+83
View File
@@ -0,0 +1,83 @@
use pbio::Key;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GameAction {
Up,
Down,
Left,
Right,
Confirm,
Cancel,
}
pub struct InputMap {
bindings: Vec<(Key, GameAction)>,
}
impl InputMap {
pub fn new() -> Self {
Self {
bindings: vec![
(Key::Up, GameAction::Up),
(Key::Down, GameAction::Down),
(Key::Left, GameAction::Left),
(Key::Right, GameAction::Right),
(Key::W, GameAction::Up),
(Key::S, GameAction::Down),
(Key::A, GameAction::Left),
(Key::D, GameAction::Right),
(Key::Enter, GameAction::Confirm),
(Key::Escape, GameAction::Cancel),
],
}
}
pub fn translate(&self, key: Key) -> Option<GameAction> {
self.bindings.iter().find(|(k, _)| *k == key).map(|(_, a)| *a)
}
}
pub struct InputState {
pressed: Vec<GameAction>,
held: Vec<GameAction>,
released: Vec<GameAction>,
}
impl InputState {
pub fn new() -> Self {
Self { pressed: Vec::new(), held: Vec::new(), released: Vec::new() }
}
pub fn push(&mut self, action: GameAction) {
self.pressed.push(action);
if !self.held.contains(&action) {
self.held.push(action);
}
}
pub fn release(&mut self, action: GameAction) {
self.held.retain(|a| *a != action);
if !self.released.contains(&action) {
self.released.push(action);
}
}
pub fn button_pressed(&self, action: GameAction) -> bool {
self.pressed.contains(&action)
}
#[allow(dead_code)]
pub fn button_held(&self, action: GameAction) -> bool {
self.held.contains(&action)
}
#[allow(dead_code)]
pub fn button_released(&self, action: GameAction) -> bool {
self.released.contains(&action)
}
pub fn clear(&mut self) {
self.pressed.clear();
self.released.clear();
}
}
+60
View File
@@ -0,0 +1,60 @@
use pbio::{Event, Platform, PlatformConfig};
use std::time::{Duration, Instant};
mod assets;
mod game;
mod input;
mod net;
fn main() {
let mut plat = Platform::new(PlatformConfig {
title: "Roguelike".into(),
window_size: (800, 600),
framebuffer_size: (320, 240),
aspect_ratio: Some(4.0 / 3.0),
palette: pbio::palette::RGB332,
vsync: true,
mouse_capture: false,
mouse_visible: true,
});
let input_map = input::InputMap::new();
let mut input_state = input::InputState::new();
let mut last_update = Instant::now();
let mut game = game::Game::start();
while !plat.should_close() {
plat.poll_events(Some(Duration::from_millis(20)));
let events: Vec<Event> = plat.drain_events().collect();
for ev in events {
match ev {
Event::Key { key, pressed: true, .. } => {
if let Some(action) = input_map.translate(key) {
input_state.push(action);
}
}
Event::Key { key, pressed: false, .. } => {
if let Some(action) = input_map.translate(key) {
input_state.release(action);
}
}
Event::CloseRequested => plat.request_close(),
_ => {}
}
}
let dt = last_update.elapsed().as_millis();
if dt < 16 { continue; }
last_update = Instant::now();
if let Some(game::GameSignal::Quit) =
game.update(plat.framebuffer_mut(), dt as usize, &input_state)
{
plat.request_close();
}
input_state.clear();
plat.present();
}
}
+208
View File
@@ -0,0 +1,208 @@
use std::collections::HashMap;
use std::mem::size_of;
use std::net::{SocketAddr, UdpSocket};
use std::time::{Duration, Instant};
use bytemuck::bytes_of;
use shared::{ActionPacket, ChunkEntry, ChunkPacket, EntityPacket,
Header, PingPacket, PongPacket, StatePacket,
MAGIC, VERSION, packet_type};
pub struct CachedChunk {
pub version: u16,
pub tiles: Box<[u16; 1024]>, // decoded global tile IDs
}
pub struct EntityInfo {
pub id: u32,
pub type_id: u16,
pub pos_x: i16,
pub pos_y: i16,
pub hp: u16,
pub hp_max: u16,
pub flags: u8,
}
pub enum NetEvent {
Pong { rtt_ms: u64 },
State { tick: u32, player_entity_id: u32 },
Chunk { chunk_id: u32 },
Entity { entities: Vec<EntityInfo> },
Disconnected,
}
const FNV_BASIS: u32 = 2_166_136_261;
const FNV_PRIME: u32 = 16_777_619;
pub struct NetClient {
socket: UdpSocket,
ping_sent_at: Option<Instant>,
action_sequence: u32,
last_action_sent: Instant,
last_state_received: Option<Instant>,
pub chunk_cache: HashMap<u32, CachedChunk>,
last_state_chunks: Option<[ChunkEntry; 9]>,
pending_entities: Vec<EntityInfo>,
last_entity_checksum: u32,
last_state_tick: u32,
entity_checksum_accum: u32,
}
impl NetClient {
pub fn new(server_addr: SocketAddr) -> Self {
let socket = UdpSocket::bind("0.0.0.0:0").expect("bind failed");
socket.connect(server_addr).expect("connect failed");
socket.set_nonblocking(true).expect("set_nonblocking failed");
Self {
socket,
ping_sent_at: None,
action_sequence: 0,
last_action_sent: Instant::now(),
last_state_received: None,
chunk_cache: HashMap::new(),
last_state_chunks: None,
pending_entities: Vec::new(),
last_entity_checksum: 0,
last_state_tick: 0,
entity_checksum_accum: FNV_BASIS,
}
}
pub fn send_action(&mut self, player_action: u16) {
self.action_sequence += 1;
let packet = ActionPacket {
header: Header::new(packet_type::ACTION),
auth_token: 0,
sequence: self.action_sequence,
cache: {
let mut cache = [ChunkEntry { chunk_id: 0, version: 0 }; 9];
if let Some(slots) = self.last_state_chunks {
for (i, slot) in slots.iter().enumerate() {
let cid = slot.chunk_id;
let ver = self.chunk_cache.get(&cid)
.map(|c| c.version).unwrap_or(0);
cache[i] = ChunkEntry { chunk_id: cid, version: ver };
}
}
cache
},
player_action,
};
self.socket.send(bytes_of(&packet)).ok();
self.last_action_sent = Instant::now();
}
pub fn send_ping(&mut self) {
let ping = PingPacket { header: Header::new(packet_type::PING), timestamp_ms: 0 };
self.ping_sent_at = Some(Instant::now());
self.socket.send(bytes_of(&ping)).ok();
}
pub fn poll(&mut self) -> Vec<NetEvent> {
const KEEPALIVE: Duration = Duration::from_secs(2);
const TIMEOUT: Duration = Duration::from_secs(10);
if self.last_action_sent.elapsed() >= KEEPALIVE {
self.send_action(shared::player_action::NOOP);
}
let mut events = Vec::new();
let mut buf = [0u8; 1200];
loop {
match self.socket.recv(&mut buf) {
Ok(n) => {
if n < size_of::<Header>() { continue; }
let header: Header = *bytemuck::from_bytes(&buf[..size_of::<Header>()]);
if header.magic != MAGIC || header.version != VERSION { continue; }
match header.packet_type {
packet_type::PONG if n >= size_of::<PongPacket>() => {
if let Some(sent_at) = self.ping_sent_at.take() {
let rtt_ms = sent_at.elapsed().as_millis() as u64;
events.push(NetEvent::Pong { rtt_ms });
}
}
packet_type::STATE if n >= size_of::<StatePacket>() => {
let pkt: StatePacket = *bytemuck::from_bytes(&buf[..size_of::<StatePacket>()]);
self.last_state_received = Some(Instant::now());
self.last_state_chunks = Some(pkt.chunks);
self.last_entity_checksum = pkt.entity_checksum;
self.last_state_tick = pkt.tick;
self.pending_entities.clear();
self.entity_checksum_accum = FNV_BASIS;
events.push(NetEvent::State { tick: pkt.tick, player_entity_id: pkt.player_entity_id });
}
packet_type::ENTITY if n >= size_of::<EntityPacket>() => {
let pkt: EntityPacket = *bytemuck::from_bytes(&buf[..size_of::<EntityPacket>()]);
if pkt.tick != self.last_state_tick {
// stale datagram from a previous state cycle
} else {
for i in 0..pkt.entity_count as usize {
let e = pkt.entities[i];
for byte in bytemuck::bytes_of(&e) {
self.entity_checksum_accum ^= *byte as u32;
self.entity_checksum_accum =
self.entity_checksum_accum.wrapping_mul(FNV_PRIME);
}
self.pending_entities.push(EntityInfo {
id: e.id,
type_id: e.type_id,
pos_x: e.pos_x,
pos_y: e.pos_y,
hp: e.hp,
hp_max: e.hp_max,
flags: e.entity_flags,
});
}
if pkt.packet_flags & 1 == 0 {
// last datagram in sequence
if self.entity_checksum_accum != self.last_entity_checksum {
self.send_action(shared::player_action::NOOP);
}
events.push(NetEvent::Entity {
entities: std::mem::take(&mut self.pending_entities),
});
}
}
}
packet_type::CHUNK if n >= size_of::<ChunkPacket>() => {
let pkt: ChunkPacket = *bytemuck::from_bytes(&buf[..size_of::<ChunkPacket>()]);
let id = pkt.chunk.chunk_id;
let version = pkt.chunk.version;
let palette = pkt.palette;
let raw = pkt.tiles;
let mut tiles = Box::new([0u16; 1024]);
for i in 0..256usize {
let b0 = raw[i * 3];
let b1 = raw[i * 3 + 1];
let b2 = raw[i * 3 + 2];
let ia = (b0 & 0x3F) as usize;
let ib = ((b0 >> 6) | ((b1 & 0x0F) << 2)) as usize;
let ic = ((b1 >> 4) | ((b2 & 0x03) << 4)) as usize;
let id_ = (b2 >> 2) as usize;
tiles[i * 4] = palette[ia];
tiles[i * 4 + 1] = palette[ib];
tiles[i * 4 + 2] = palette[ic];
tiles[i * 4 + 3] = palette[id_];
}
self.chunk_cache.insert(id, CachedChunk { version, tiles });
events.push(NetEvent::Chunk { chunk_id: id });
}
_ => {}
}
}
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => break,
Err(_) => break,
}
}
if let Some(t) = self.last_state_received {
if t.elapsed() >= TIMEOUT {
self.last_state_received = None;
events.push(NetEvent::Disconnected);
}
}
events
}
}
+68
View File
@@ -0,0 +1,68 @@
# Input Manager
## Current state
Input is fully decoupled from the windowing backend. `client/src/input.rs` owns all input
types. `main.rs` translates `pbio::Event::Key` events into `GameAction` values each frame and
stores them in an `InputState`. `game::update()` receives a `&InputState` and queries it with
three distinct semantics. (The platform/windowing layer lives in the external `pbio` crate;
game code only ever sees `pbio::Key`, never a raw backend type.)
## Architecture
### `GameAction` — `client/src/input.rs`
Logical actions the game cares about. Game code never sees backend key types.
```rust
pub enum GameAction { Up, Down, Left, Right, Confirm, Cancel }
```
### `InputMap` — `client/src/input.rs`
Translates a `pbio::Key` to an `Option<GameAction>`. All key bindings live here.
| Key | Action |
|------------------|---------|
| Up / W | Up |
| Down / S | Down |
| Left / A | Left |
| Right / D | Right |
| Enter | Confirm |
| Escape | Cancel |
### `InputState` — `client/src/input.rs`
Three internal buffers, populated from `pbio::Event::Key` events in `main.rs`:
| Buffer | Lifetime | Populated by |
|------------|----------------|---------------------------|
| `pressed` | current frame | key-down event |
| `held` | until released | key-down; cleared on up |
| `released` | current frame | key-up event |
`clear()` is called after `game::update()` each frame. It clears `pressed` and `released`;
`held` persists until the corresponding key-up event arrives.
#### Query API
```rust
input.button_pressed(action) // true only on the frame the key went down
input.button_held(action) // true every frame the key is physically held
input.button_released(action) // true only on the frame the key was released
```
## Data flow per frame
```
pbio::Event::Key
→ InputMap::translate(key)
→ InputState::push / InputState::release
→ game.update(&mut framebuffer, dt, &input_state) -> Option<GameSignal>
→ input_state.clear()
```
## Open questions
- Should modifier keys (Shift, Ctrl) produce distinct actions, or be handled in the mapping?
- How do we prepare for key rebinding?
+35
View File
@@ -0,0 +1,35 @@
# Asset Loader
## Current state
TGA loading is implemented in `client/src/assets.rs`. `Image::from_tga(path)` reads the file,
parses the 18-byte header, skips the colormap block, and returns an `Image` — supporting
type 1 (uncompressed) and type 9 (RLE) via the private `decode_rle()` function in the same
module. `game.rs` uses it directly: `Image::from_tga("assets/tilesets/overworld.tga")`.
`Image` also provides `to_tileset() -> Vec<[u8; 64]>`, which splits the image into 8×8
tiles row-major. Every tile is emitted; tile ID is the flat row-major index into the Vec,
so tiles are never skipped or reordered. (Transparency is a per-pixel concern handled at
blit time by `blit_tile`, not by the splitter.)
`AssetStore` (a named registry) is **not yet implemented**. Assets are currently loaded
inline in `Game::start()`.
## Goal
Add an `AssetStore` so that game code can ask for an asset by name rather than loading
files directly at the call site.
## Design notes
- `Image` struct: `width: u32`, `height: u32`, `pixels: Vec<u8>` — done.
- A simple `AssetStore` could be a `HashMap<&'static str, Image>` loaded at startup.
- No streaming needed — the whole game is small enough to load everything upfront in `start()`.
- Palette index 0 is transparent when blitting sprites. This convention is enforced by
`blit_tile()` in `pixelhelper.rs`; the loader itself does not need to handle it.
- Pixel assets are stored as flat `Vec<u8>` of palette indices, row-major, width × height bytes.
- Future: consider embedding assets with `include_bytes!` to produce a single binary.
## Open questions
- Should `AssetStore` hold pre-split tilesets too, or just raw `Image` values?
+31
View File
@@ -0,0 +1,31 @@
# Text Renderer
## Current state
No text rendering exists yet. No font loading or glyph blitting code exists.
## Goal
Draw ASCII text into the `u8` framebuffer using a bitmap font, respecting the palette-index
color model (one u8 per pixel, no RGBA).
## Design notes
- A bitmap font is the right fit: each glyph is a small tile of palette indices, blitted
directly into the framebuffer using the existing blit primitive in `pixelhelper.rs`.
- Likely approach: a fixed 8×8 or similar glyph grid packed into a TGA or raw binary,
one glyph per ASCII codepoint starting at 0x20 (space).
- The font image itself should be indexed: foreground pixels carry a nonzero palette index
(caller supplies the color), background pixels are index 0 (transparent/skip).
- API sketch:
```rust
fn draw_text(frame: &mut [u8], font: &Font, x: i32, y: i32, color: u8, text: &str);
```
- `Font` holds the glyph sheet as an `Image` plus glyph width/height.
## Open questions
- How do we handle proportional spacing of different characters?
- How do we store font data in memory?
- How do we handle font color rendering?
- How do we handle special characters like Ä, Ö, Ü, ß, etc.?
+36
View File
@@ -0,0 +1,36 @@
# UI Renderer
## Current state
No UI primitives exist. The framebuffer is currently filled by the game world only
(`render_viewport` in `game.rs`: tile pass + entity sprite pass). `pixelhelper.rs` has
low-level pixel/blit helpers but nothing higher-level.
## Goal
Draw common roguelike UI elements (panels, borders, HUD bars, etc.) procedurally into the
`u8` framebuffer, composited on top of the game world.
## Design notes
The virtual resolution is 320×240, so UI layout should be designed in those pixel units.
Likely primitives needed (built on top of `pixelhelper.rs`):
- `fill_rect(frame, x, y, w, h, color)` — solid filled rectangle
- `draw_rect(frame, x, y, w, h, color)` — 1-pixel border rectangle
- `draw_border_box(frame, x, y, w, h, tileset)` — box drawn with corner/edge tiles from
a tileset (classic roguelike panel look)
- `draw_hbar(frame, x, y, w, value, max, fg, bg)` — horizontal progress/HP bar
Panels are typically fixed regions of the screen (e.g. a status bar at the bottom 40px,
a message log on the right). Hard-coding these regions first is fine; extract to a layout
system only if needed.
The UI layer draws after the world layer so it always appears on top. Draw order within
the UI should be back-to-front (backgrounds before text).
## Open questions
- Tileset-based borders vs. line-drawing: which look are we going for?
- Does the message log need scrolling? Probably not at first.
+117
View File
@@ -0,0 +1,117 @@
# Hybrid Tick Loop
Single 24 Hz base tick; subsystems self-select frequency via stride scheduling. Replaces the old
3-tier (20/10/1 Hz buckets) description from roadmap item 06.
---
## Stride scheduling
All subsystems hang off one monotonic `tick: u32` counter. Each subsystem fires when its
stride condition is met. **Current implementation:**
```
tick % 1 == 0 → input processing, inbound recv (main.rs)
tick % 2 == send_phase → broadcast state to clients, phase-staggered (net.rs, 12 Hz/client)
tick % 4 == 0 → entity_tick() — movement / collision (sim.rs, 6 Hz)
tick % 8 == 0 → (reserved) slower AI routines, pathfinding refresh
tick % 192 == 0 → (reserved) world simulation (8-sec cycle: weather, daylight, respawns)
```
24 Hz is highly composite (divisors: 1, 2, 3, 4, 6, 8, 12, 24), giving flexible stride
options without resorting to co-prime tricks. `tick >> 3` yields 3 Hz pulses; world-sim
at `% 192` is exactly an 8-second cycle.
**Why `entity_tick` runs at 6 Hz, not 12 Hz:** without the action-point model (below), one
`entity_tick` = one tile of movement, so the tick rate *is* the movement rate. 6 Hz yields the
target ~6 tiles/sec. Once `energy`/`speed` are reintroduced, `entity_tick` can move up to 12 Hz
(`% 2`) and per-entity `speed` sets the effective movement rate instead — decoupling sim rate
from movement rate. The broadcast rate (12 Hz/client) is deliberately higher than the movement
rate: it keeps position latency low and adds redundancy against packet loss.
---
## Action-point model (entity AI)
> **Status: not yet implemented.** Removed from `entity.rs` as premature (no NPCs need it yet).
> Until it returns, `entity_tick` runs at 6 Hz to set the movement rate directly (see above).
> Reintroduce alongside roadmap item 08 (NPCs).
Layered on top of `entity_tick()`. Replaces fixed per-entity cooldown timers.
Each entity carries:
- `energy: i32` — accumulates each tick
- `speed: u8` — added to energy every `entity_tick()`
An entity acts when `energy >= ACTION_COST`, then pays the cost:
```rust
// inside entity_tick()
entity.energy += entity.speed as i32;
if entity.energy >= ACTION_COST {
entity.act(&mut world);
entity.energy -= ACTION_COST;
}
```
Fast entities (`speed >= ACTION_COST`) act every tick. Slow entities act every N ticks
naturally without any scheduler involvement. Haste and slow effects become simple `speed`
modifiers — no special-case scheduling needed.
---
## Simulation rate vs network rate
Sim and net rates are independent. The current targets:
| Layer | Rate | Stride |
|-------|------|--------|
| Base loop | 24 Hz | every tick |
| Network broadcast (per client, phase-staggered) | 12 Hz | `tick % 2 == send_phase` |
| Entity tick (movement / collision) | 6 Hz | `tick % 4 == 0` |
Clients receive a fresh `EntityPacket` every ~83 ms. For tile-based movement at 5–6 tiles/sec
this is sufficient with comfortable headroom.
Rough bandwidth per client at 12 Hz:
- `StatePacket` 72 B × 12 = 864 B/s
- `EntityPacket` ~1200 B × 12 = 14 400 B/s
- Total: ~15 KB/s outbound per client
---
## Tick-offset broadcasting (phase staggering)
Rather than flushing all clients on the same tick, assign each client a `send_phase` at
connection time and send only when the client's phase matches:
```rust
// ClientState gains:
send_phase: u8, // assigned as entity_id % BROADCAST_STRIDE at creation
// broadcast() skips clients whose phase doesn't match current tick:
if tick % BROADCAST_STRIDE as u32 != cs.send_phase as u32 { continue; }
```
Benefits:
- Outbound NIC load is flat across ticks instead of spiking every 2nd tick
- Scales with player count without architectural changes
- Each client still receives state at the same effective rate
---
## Why higher tick rate doesn't help high-ping players
```
Perceived latency ≈ RTT + tick_processing_delay + state_interval
LTE @ 100ms RTT, 24Hz sim, 12Hz net:
worst case: 100 + 42 + 83 = 225ms ← RTT-dominated
Same at 64Hz sim, 20Hz net:
worst case: 100 + 15 + 50 = 165ms ← 60ms gain, negligible for roguelike
```
RTT is the dominant term. Higher tick rates yield diminishing returns and increase server
CPU load for minimal perceived benefit. 24 Hz is sufficient for deliberate tile-based input
and remains acceptable at LTE latencies (~100ms RTT) and remote locations (~200ms RTT).
+310
View File
@@ -0,0 +1,310 @@
# Protocol
Wire format for client-server communication.
## Transport
- UDP (`std::net::UdpSocket`, non-blocking)
- Hard limit: **1200 bytes per datagram** (safe internet MTU; avoids IP fragmentation)
- All integers: little-endian
- Server streams `StatePackets` continuously; client sends `ActionPackets` only when needed
---
## Header
Every datagram begins with a 6-byte universal header.
| Offset | Size | Type | Field | Notes |
|--------|------|------|---------------|-----------------------------------------|
| 0 | 2 | u16 | `magic` | Fixed: `0x524C` ("RL") — rejects noise |
| 2 | 1 | u8 | `version` | Protocol version — server rejects mismatch |
| 3 | 1 | u8 | `packet_type` | See packet type table below |
| 4 | 2 | u16 | `client_type` | 0 = official client; others registered |
`client_type` allows the server to distinguish official clients, registered bots, and
forks for analytics and access control, without affecting the protocol logic.
---
## Packet types
| Packet type | id | Flow | Size |
|---------------|----|------------------|------------------|
| State | 0 | server → client | 72 bytes |
| Action | 1 | client → server | min. 74 bytes |
| Chunk Data | 2 | server → client | max. 909 bytes |
| Entity | 3 | server → client | max. 1200 bytes |
| Entity Query | 4 | client → server | 18 bytes |
| Entity Detail | 5 | server → client | max. 1200 bytes |
| Ping | 6 | client → server | 14 bytes |
| Pong | 7 | server → client | 14 bytes |
---
### StatePacket — server → client
Sent by the server at 10 Hz regardless of client activity.
Currently carries the chunk manifest for the 3×3 neighbourhood around the player.
| Offset | Size | Type | Field | Notes |
|--------|------|------------|---------------------|-------------------------------------------------|
| 0 | 6 | Header | `header` | packet_type = 0 |
| 6 | 4 | u32 | `tick` | Server tick counter |
| 10 | 54 | ChunkEntry | `chunks[9]` | 9 chunk entries |
| 64 | 4 | u32 | `player_entity_id` | Global entity ID of the receiving client |
| 68 | 4 | u32 | `entity_checksum` | FNV-1a over all EntityPacket payloads this tick |
**Total: 72 bytes**
The `entity_checksum` lets the client detect a lost `EntityPacket` without a dedicated
ACK: if the checksum differs from the one computed over the last received entity update,
the client knows to retransmit an `ActionPacket` (sequence preserved, no-op action) to
prompt the server to re-send the current entity state.
---
### ActionPacket — client → server
Sent by the client on player action or on a chunk cache miss.
| Offset | Size | Type | Field | Notes |
|--------|------|--------------|-----------------|--------------------------------|
| 0 | 6 | Header | `header` | packet_type = 1 |
| 6 | 8 | u64 | `auth_token` | Token of the current session |
| 14 | 4 | u32 | `sequence` | Monotonically increasing |
| 18 | 54 | ChunkEntry | `cache[9]` | Versions client currently holds |
| 72 | 2 | PlayerAction | `player_action` | Derived from user input |
| 74 | ? | ActionData | `action_data` | Dependent on PlayerAction |
**Minimum: 74 bytes** (no ActionData)
**ChunkEntry (6 bytes)**
| Offset | Size | Type | Field |
|--------|------|------|------------|
| 0 | 4 | u32 | `chunk_id` |
| 4 | 2 | u16 | `version` |
`chunk_id` encodes the chunk grid position: `(x as u16) | ((y as u16) << 16)`,
where x and y are signed chunk coordinates (i16 each).
**PlayerAction (u16)**
| Value | Action |
|-------|---------|
| 0 | No-Op (cache update only) |
| 1 | North |
| 2 | East |
| 3 | South |
| 4 | West |
$TODO — additional actions (interact, etc.)
---
### ChunkPacket — server → client
Sent by the server for each chunk the client is missing or has stale.
One chunk per datagram.
The world is divided into **32×32 tile chunks**.
- At most **4 chunks (2×2)** are visible at once.
- The 3×3 neighbourhood (9 chunks) covers all prefetch needs.
- Each chunk carries a **local palette** of up to 64 tile types.
- Tiles are encoded as 6-bit palette indices (4 tiles per 3 bytes, no padding).
| Offset | Size | Type | Field | Notes |
|-------------------|----------|--------------|-------------|---------------------------------|
| 0 | 6 | Header | `header` | packet_type = 2 |
| 6 | 6 | ChunkEntry | `chunk` | ID and version of this chunk |
| 12 | 1 | u8 | `pal_count` | Number of palette entries (≤64) |
| 13 | max. 128 | u16[] | `palette` | Global tile IDs, pal_count × 2 |
| 13 + pal_count×2 | 768 | packed u6[] | `tiles` | 1024 tiles, 6-bit indices |
**Maximum: 6 + 6 + 1 + 128 + 768 = 909 bytes**
**Palette** — up to 64 entries, each a global tile ID (u16) mapping local 6-bit index → world tile type.
**Tiles** — 1024 tiles packed as 256 groups of 3 bytes (4 tiles × 6 bits = 24 bits per group).
---
### EntityPacket — server → client
Sent by the server each tick, immediately after the `StatePacket`.
Contains the bulk entity update for all entities within the visible **30×30 tile viewport**.
Positions are **absolute world tile coordinates**.
If the entity count exceeds what fits in one datagram, the server sends multiple
`EntityPacket`s on the same tick; `packet_flags` bit 0 signals that more follow.
| Offset | Size | Type | Field | Notes |
|--------|----------------|-------------|----------------|--------------------------------|
| 0 | 6 | Header | `header` | packet_type = 3 |
| 6 | 4 | u32 | `tick` | Matches the `StatePacket` tick |
| 10 | 1 | u8 | `entity_count` | Entities in this datagram |
| 11 | 1 | u8 | `packet_flags` | bit 0: more packets follow |
| 12 | entity_count×… | EntityEntry | `entities` | Variable-length entries |
**Maximum: 1200 bytes** — without metadata: ⌊(1200 − 12) / 18⌋ = **66 entities per datagram**.
---
#### EntityEntry
| Offset | Size | Type | Field | Notes |
|--------|------------|------|-----------------|-----------------------------------------|
| 0 | 4 | u32 | `id` | Server-assigned entity ID (persistent) |
| 4 | 2 | u16 | `type_id` | Entity type; 0 = player |
| 6 | 2 | i16 | `pos_x` | Absolute world tile X |
| 8 | 2 | i16 | `pos_y` | Absolute world tile Y |
| 10 | 2 | u16 | `hp` | Current HP |
| 12 | 2 | u16 | `hp_max` | Max HP |
| 14 | 2 | u16 | `elo` | Elo rating (acts as level) |
| 16 | 1 | u8 | `entity_flags` | See flag table below |
| 17 | 1 | u8 | `meta_len` | Byte length of the TLV metadata block |
| 18 | `meta_len` | u8[] | `meta` | TLV metadata (0 bytes if none) |
**Base size: 18 bytes** (plus `meta_len` bytes of metadata).
**entity_flags:**
| Bit | Meaning |
|-----|-------------------------------------------------------|
| 0 | `is_static` — does not move (chest, sign, item stack) |
| 1 | `is_hostile` |
| 2 | `is_interactable` — show interaction prompt |
| 3–7 | reserved |
---
#### Inline metadata — TLV format
Inline metadata is a sequence of TLV (type–length–value) fields appended directly after
the fixed `EntityEntry` fields. `meta_len = 0` means no metadata is present.
Each TLV field:
| Offset | Size | Type | Field |
|--------|------|------|---------|
| 0 | 1 | u8 | `tag` |
| 1 | 1 | u8 | `len` |
| 2 | len | u8[] | `value` |
**Defined tags:**
| Tag | Name | Value format | Notes |
|------|------------------|--------------|--------------------------------|
| 0x01 | `status_effects` | u16 bitmask | Active status effects |
| 0x02 | `weapon` | u16 type_id | Currently equipped weapon |
| 0x03 | `armor` | u16 type_id | Currently equipped armor |
Large metadata (chest inventory, sign text, dialogue trees) is not included inline.
Request it via `EntityQueryPacket` (type 4) on player interaction.
---
### EntityQueryPacket — client → server
Sent when the client needs detailed metadata for a specific entity (on interaction,
hover, or similar trigger). The server responds with an `EntityDetailPacket`.
| Offset | Size | Type | Field | Notes |
|--------|------|--------|--------------|------------------------------|
| 0 | 6 | Header | `header` | packet_type = 4 |
| 6 | 8 | u64 | `auth_token` | Token of the current session |
| 14 | 4 | u32 | `entity_id` | Entity to query |
**Total: 18 bytes**
---
### EntityDetailPacket — server → client
Full metadata response for a queried entity. Sent in reply to an `EntityQueryPacket`.
| Offset | Size | Type | Field | Notes |
|--------|------------|--------|--------------|------------------------------|
| 0 | 6 | Header | `header` | packet_type = 5 |
| 6 | 4 | u32 | `tick` | Server tick at time of query |
| 10 | 4 | u32 | `entity_id` | Entity being described |
| 14 | 2 | u16 | `meta_len` | Byte length of metadata |
| 16 | `meta_len` | u8[] | `meta` | Full TLV metadata block |
**Maximum: 1200 bytes** (up to 1184 bytes of metadata).
---
### PingPacket / PongPacket — round-trip measurement
Liveness and RTT probe. The client sends a `PingPacket`; the server echoes the payload back
unchanged as a `PongPacket`. Both share the same layout.
| Offset | Size | Type | Field | Notes |
|--------|------|--------|----------------|--------------------------------------|
| 0 | 6 | Header | `header` | packet_type = 6 (Ping) / 7 (Pong) |
| 6 | 8 | u64 | `timestamp_ms` | Echoed verbatim by the server |
**Total: 14 bytes**
The client currently measures RTT locally via the elapsed time since the ping was sent, so
`timestamp_ms` is sent as 0; the field is reserved for server-stamped timing if needed later.
---
## Protocol flow
```
── every tick (10 Hz) ────────────────────────────────────────────────────────
server → client StatePacket (tick N, chunk manifest, entity_checksum)
server → client EntityPacket (tick N, entities 0–73)
server → client EntityPacket (tick N, entities 74–N, more=0) ← if needed
↓ client verifies entity_checksum
↓ checksum mismatch → retransmit ActionPacket (no-op)
↓ chunk cache miss on chunk X →
client → server ActionPacket (auth_token, seq, cache state, player_action)
server → client ChunkPacket (chunk X, full data)
server → client ChunkPacket (chunk Y, full data) ← if multiple misses
── on player interaction ─────────────────────────────────────────────────────
client → server EntityQueryPacket (auth_token, entity_id)
server → client EntityDetailPacket (entity_id, full TLV metadata)
```
The server reads the `ActionPacket`'s cache list, computes the diff against current
chunk versions, and sends one `ChunkPacket` per missing or stale chunk.
---
## Reliability
UDP is unreliable. The protocol handles loss without a dedicated ACK mechanism:
**Lost ActionPacket** — the server never learns of the cache miss and keeps streaming
StatePackets. The client retransmits the ActionPacket after **200–300 ms** if no
ChunkPacket has arrived.
**Lost ChunkPacket** — the server has already processed the ActionPacket and will not
retransmit spontaneously. The client's timeout fires (200–300 ms), it resends the
ActionPacket with its updated cache state (listing only still-missing chunks), and
the server sends the missing chunks again.
**Duplicate ActionPackets** — the server handles these idempotently. The cache list is
self-describing state; the server reads it, diffs against current versions, and sends
whatever is still missing. No deduplication logic is required.
**Lost StatePacket** — the next tick delivers the same chunk manifest. No retry needed;
the client simply waits one tick (~100 ms).
**Lost EntityPacket** — detected via the `entity_checksum` in the next `StatePacket`.
The client retransmits a no-op `ActionPacket` (same sequence number, no-op action,
current cache state); the server treats this as a normal diff request and re-sends the
full entity update for the tick.
**Lost EntityQueryPacket / EntityDetailPacket** — the client retransmits the query
after a 200–300 ms timeout if no `EntityDetailPacket` has arrived.
+122
View File
@@ -0,0 +1,122 @@
# Roadmap
Workspace layout: `shared/` (wire types), `server/` (authoritative sim), `client/` (render
terminal). The platform layer — window, GPU, input, RGB332 palette — lives in the external
`pbio` crate (git dependency), not in this repo.
---
## Achieved milestones
- [x] TGA loader: `Image`, `from_tga`, `to_tileset`, `decode_rle` in `client/src/assets.rs`
- [x] Pixel helpers: `set_pixel`, `extract`, `blit`, `blit_tile` in `client/src/game/pixelhelper.rs`
— signed `i32` offsets, clip against all four framebuffer edges, index 0 = transparent
- [x] Input system: `GameAction`, `InputMap`, `InputState` in `client/src/input.rs` (over `pbio::Key`)
- [x] Protocol spec: `notes/protocol.md` — Header, State, Action, Chunk, Entity, EntityQuery,
EntityDetail, Ping/Pong; reliability model
- [x] Shared crate: packet structs as `bytemuck::Pod` with compile-time size assertions,
`player_action` / `packet_type` constants, `chunk_id` / `chunk_coords`
- [x] Server binary: 24 Hz tick loop, 3×3-chunk test world (`build_world`), wall collision,
per-client action queue, `ChunkPacket` dispatch on cache miss, stale-client eviction
- [x] Client net: `NetClient` in `client/src/net.rs`, non-blocking UDP recv,
`StatePacket` / `EntityPacket` / `ChunkPacket` / `PongPacket` dispatch, entity-checksum retransmit
- [x] End-to-end loop: player moves on server, position reflected in `EntityPacket`, rendered on client
- [x] Tile rendering: chunk-based world rendering from received `ChunkPacket` data (6-bit unpack)
- [x] Tick-based movement: movement resolved in `entity_tick`, currently every 4th tick (`sim.rs`)
- [x] Sprite-based entity rendering: entity `type_id` → tile in `entities.tga`, blitted with transparency
- [x] Phase-staggered broadcast: per-client `send_phase`, flat outbound load across ticks
- [x] Ping/Pong RTT measurement (`PingPacket` / `PongPacket`)
- [x] Connection timeout: client emits `Disconnected` after 10 s without a `StatePacket`;
server evicts clients unseen for 10 s
---
## Upcoming work
### 06 — Hybrid tick loop *(partially done)*
Design of record: **`notes/06-hybrid-tick-loop.md`** (24 Hz base tick + stride scheduling).
This supersedes the original 20/10/1 Hz three-tier sketch.
- [x] 24 Hz base loop (`server/src/main.rs`)
- [x] Phase-staggered broadcast at 12 Hz/client (`send_phase` in `server/src/net.rs`)
- [x] Stride layout settled: `entity_tick` at `%4` (6 Hz, sets movement rate), broadcast at
`%2` (12 Hz/client). The `06` note now documents this and why.
- [ ] Action-point model (`energy` / `speed` per entity, act when `energy >= ACTION_COST`).
Removed from `entity.rs` for now as premature — reintroduce when NPCs (08) actually need it.
Then `entity_tick` can move to `%2` (12 Hz) and `speed` sets the effective movement rate.
---
### 07 — Tiled map loader
Replace the hardcoded `build_world` wall loop with real level data.
- Parse Tiled TMX (XML) or JSON export — only the subset actually used
- Define the tile vocabulary: `TileKind` variants map 1:1 to Tiled tile IDs
- Load a hand-authored starting area as the first real level
- Procedural generation comes later; hand-authored first
---
### 08 — Basic NPC entity + AI budget
One dumb wandering enemy. Validates the simulation architecture before complexity accumulates.
Depends on the action-point model from 06 being reintroduced.
- Add `EntityKind::Npc` with a `think() -> u32` method returning budget cost
- Per sim-tick: distribute `think_budget = BASE / (clients + 1)` across entities ordered by player proximity
- Complex entities consume more budget; simple ones less. Loop breaks at zero — natural load shedding.
- No framework. No trait objects yet. A match on `EntityKind` is fine.
```rust
let mut budget: u32 = BASE_BUDGET / (client_count + 1).max(1);
for entity in entities_by_player_proximity() {
if budget == 0 { break; }
budget = budget.saturating_sub(entity.think(&mut world));
}
```
---
### 09 — Client camera *(sprite rendering already done)*
Sprite rendering already landed (see Achieved). What remains is the camera:
- Smooth camera: lerp between last known and current server position; do not snap
(currently `player_pos` snaps hard to the server position in `game.rs`)
- Store previous + current position per entity, interpolate on render
- Camera math is architectural — affects how entity state is stored. Do it before UI.
---
### Later — UI / HUD
- Bitmap font renderer: 8×8 glyph sheet TGA, `draw_text(frame, font, x, y, color, text)` — see `notes/04`
- UI primitives in `client/src/ui.rs`: `fill_rect`, `draw_rect`, `draw_hbar` — see `notes/05`
- HUD layout (320×240): HP bar + player name bottom 16px, message log right panel
- A real clip-rect on the blit primitives belongs here — once the viewport and HUD panels are
two distinct regions, the abstraction earns its keep. Not before.
---
### Later — Robustness + auth
- Auth token handshake: replace source-address identity (`auth_token` field exists but is unused)
- Multi-datagram `EntityPacket`: server currently truncates at 66 entities (`net.rs` TODO)
- Asset embedding: `include_bytes!` for single-binary distribution
---
### Later — World depth
Once the simulation architecture is stable and a real level exists:
- Doors, interactive objects (server-authoritative state)
- Combat: melee range check, HP, death, respawn
- Inventory: on-demand via `EntityQueryPacket` / `EntityDetailPacket`
- Day/night and weather on the slow tick — affect visibility, spawns
- Procedural dungeon generation (BSP or cellular automata feeding into the same tile format)
---
</content>
+8
View File
@@ -0,0 +1,8 @@
[package]
name = "server"
version = "0.1.0"
edition = "2024"
[dependencies]
shared = { path = "../shared" }
bytemuck = "1.24.0"
+102
View File
@@ -0,0 +1,102 @@
#[derive(Clone, Copy, Default, Debug, PartialEq, Eq)]
pub struct TileFlags(pub u8);
impl TileFlags {
pub const COLLIDABLE: u8 = 1 << 0;
pub const OPAQUE: u8 = 1 << 1;
pub const LIQUID: u8 = 1 << 2;
pub fn collidable(self) -> bool { self.0 & Self::COLLIDABLE != 0 }
pub fn opaque(self) -> bool { self.0 & Self::OPAQUE != 0 }
pub fn liquid(self) -> bool { self.0 & Self::LIQUID != 0 }
}
#[derive(Clone, Copy, Default, Debug, PartialEq, Eq)]
pub struct TileDef { pub tile_id: u16, pub flags: TileFlags }
#[derive(Debug)]
pub struct PaletteFullError;
pub struct Chunk {
pub palette: [TileDef; 64],
pub pal_count: u8,
pub tiles: Box<[u8; 1024]>,
pub version: u16,
}
impl Chunk {
/// Runtime use: all tiles empty, pal_count=1, version=1.
/// Clients with version=0 will always request this chunk.
pub fn new() -> Self {
Self {
palette: [TileDef::default(); 64],
pal_count: 1,
tiles: Box::new([0u8; 1024]),
version: 1,
}
}
/// Find existing palette entry or insert a new one.
/// Index 0 is always the null sentinel; this short-circuits for default TileDef.
/// Returns (palette_index, was_new). Does NOT bump version.
fn find_or_insert_raw(&mut self, def: TileDef) -> Result<(u8, bool), PaletteFullError> {
if def == TileDef::default() {
return Ok((0, false));
}
for i in 1..self.pal_count as usize {
if self.palette[i] == def {
return Ok((i as u8, false));
}
}
if self.pal_count >= 64 {
return Err(PaletteFullError);
}
let idx = self.pal_count;
self.palette[idx as usize] = def;
self.pal_count += 1;
Ok((idx, true))
}
/// World gen path. Calls the closure for every tile coordinate.
/// version=0 so the first StatePacket causes all clients to request it.
pub fn generate(f: impl Fn(u8, u8) -> TileDef) -> Result<Self, PaletteFullError> {
let mut chunk = Self {
palette: [TileDef::default(); 64],
pal_count: 1,
tiles: Box::new([0u8; 1024]),
version: 0,
};
for ly in 0u8..32 {
for lx in 0u8..32 {
let def = f(lx, ly);
let (idx, _) = chunk.find_or_insert_raw(def)?;
chunk.tiles[ly as usize * 32 + lx as usize] = idx;
}
}
chunk.version = 1;
Ok(chunk)
}
/// Set a tile at local coords. Bumps version if tile index or palette changed.
pub fn set_tile(&mut self, lx: u8, ly: u8, def: TileDef) -> Result<(), PaletteFullError> {
let (idx, was_new) = self.find_or_insert_raw(def)?;
let slot = &mut self.tiles[ly as usize * 32 + lx as usize];
let changed = *slot != idx;
*slot = idx;
if changed || was_new {
self.version = self.version.wrapping_add(1);
if self.version == 0 {
self.version = 1;
}
}
Ok(())
}
pub fn get_tile(&self, lx: u8, ly: u8) -> TileDef {
self.palette[self.tiles[ly as usize * 32 + lx as usize] as usize]
}
pub fn tile_flags(&self, lx: u8, ly: u8) -> TileFlags {
self.get_tile(lx, ly).flags
}
}
+19
View File
@@ -0,0 +1,19 @@
pub struct Entity {
pub id: u32,
pub type_id: u16, // 0 = player
pub pos: (i16, i16),
pub hp: u16,
pub hp_max: u16,
}
impl Entity {
pub fn new(id: u32, type_id: u16, pos: (i16, i16), hp: u16) -> Self {
Self {
id,
type_id,
pos,
hp,
hp_max: hp,
}
}
}
+62
View File
@@ -0,0 +1,62 @@
mod chunk;
mod entity;
mod world;
mod sim;
mod net;
use std::thread;
use std::time::{Duration, Instant};
use chunk::{Chunk, TileDef, TileFlags};
use world::World;
use sim::Sim;
use net::Net;
const TICK_HZ: u32 = 24;
const TICK_DURATION: Duration = Duration::from_micros(1_000_000 / TICK_HZ as u64);
fn build_world() -> World {
let mut w = World::new();
for cy in -1i16..=1 {
for cx in -1i16..=1 {
let chunk = Chunk::generate(|lx, ly| {
let (wx, wy) = world::local_to_tile(cx, cy, lx, ly);
let border = wx == -32 || wx == 31 || wy == -32 || wy == 31;
if border {
TileDef { tile_id: 1, flags: TileFlags(TileFlags::COLLIDABLE | TileFlags::OPAQUE) }
} else {
TileDef::default()
}
}).expect("palette overflow during world gen");
w.set_chunk(cx, cy, chunk);
}
}
w
}
fn main() {
let world = build_world();
let mut sim = Sim::new(world);
let mut net = Net::new("127.0.0.1:7777").expect("failed to bind UDP socket");
let mut next_tick = Instant::now() + TICK_DURATION;
let mut tick = 0u32;
loop {
net.drain_recv();
net.evict_stale();
for addr in net.drain_new_clients() {
let entity_id = sim.world.spawn_entity(0, (0, 0), 100);
net.add_client(addr, entity_id);
println!("client connected: {addr} → entity {entity_id}");
}
let actions = net.drain_actions();
sim.tick(tick, &actions);
net.broadcast(&sim.world, tick);
thread::sleep(next_tick.saturating_duration_since(Instant::now()));
next_tick += TICK_DURATION;
tick = tick.wrapping_add(1);
}
}
+241
View File
@@ -0,0 +1,241 @@
use std::collections::HashMap;
use std::mem::size_of;
use std::net::{SocketAddr, UdpSocket};
use std::sync::mpsc::{self, Receiver, SyncSender};
use std::thread;
use std::time::Instant;
use bytemuck::bytes_of;
use shared::{
ActionPacket, ChunkEntry, ChunkPacket, EntityEntry, EntityPacket,
Header, PingPacket, PongPacket, StatePacket,
MAGIC, VERSION, packet_type,
};
use crate::world::World;
pub struct NetClient {
pub entity_id: u32,
pub last_seen: Instant,
pub send_phase: u8,
pub pending_action: Option<u16>,
pub last_cache: [ChunkEntry; 9],
}
pub struct Net {
clients: HashMap<SocketAddr, NetClient>,
new_addrs: Vec<SocketAddr>,
recv_rx: Receiver<(SocketAddr, Vec<u8>)>,
send_tx: SyncSender<(SocketAddr, Vec<u8>)>,
}
impl Net {
pub fn new(addr: &str) -> std::io::Result<Self> {
let socket = UdpSocket::bind(addr)?;
let (recv_tx, recv_rx) = mpsc::sync_channel::<(SocketAddr, Vec<u8>)>(256);
let (send_tx, send_rx) = mpsc::sync_channel::<(SocketAddr, Vec<u8>)>(256);
let recv_sock = socket.try_clone()?;
thread::spawn(move || {
let mut buf = [0u8; 1200];
loop {
match recv_sock.recv_from(&mut buf) {
Ok((n, addr)) => {
if recv_tx.send((addr, buf[..n].to_vec())).is_err() {
break;
}
}
Err(_) => break,
}
}
});
let send_sock = socket.try_clone()?;
thread::spawn(move || {
loop {
match send_rx.recv() {
Ok((addr, data)) => { let _ = send_sock.send_to(&data, addr); }
Err(_) => break,
}
}
});
// socket is dropped here; send/recv threads own their copies
drop(socket);
Ok(Net { clients: HashMap::new(), new_addrs: Vec::new(), recv_rx, send_tx })
}
pub fn add_client(&mut self, addr: SocketAddr, entity_id: u32) {
self.clients.insert(addr, NetClient {
entity_id,
last_seen: Instant::now(),
send_phase: (entity_id % 2) as u8,
pending_action: None,
last_cache: [ChunkEntry { chunk_id: 0, version: 0 }; 9],
});
}
pub fn drain_recv(&mut self) {
loop {
match self.recv_rx.try_recv() {
Ok((addr, data)) => self.handle_packet(addr, data),
Err(_) => break,
}
}
}
fn handle_packet(&mut self, addr: SocketAddr, data: Vec<u8>) {
if data.len() < size_of::<Header>() {
return;
}
let header: Header = bytemuck::pod_read_unaligned(&data[..size_of::<Header>()]);
if header.magic != MAGIC || header.version != VERSION {
return;
}
if header.packet_type == packet_type::ACTION && data.len() == size_of::<ActionPacket>() {
let pkt: ActionPacket = bytemuck::pod_read_unaligned(&data);
if let Some(client) = self.clients.get_mut(&addr) {
client.pending_action = Some(pkt.player_action);
client.last_seen = Instant::now();
client.last_cache = pkt.cache;
} else if !self.new_addrs.contains(&addr) {
self.new_addrs.push(addr);
}
} else if header.packet_type == packet_type::PING && data.len() == size_of::<PingPacket>() {
let pkt: PingPacket = bytemuck::pod_read_unaligned(&data);
let pong = PongPacket {
header: Header::new(packet_type::PONG),
timestamp_ms: pkt.timestamp_ms,
};
let _ = self.send_tx.send((addr, bytes_of(&pong).to_vec()));
}
}
pub fn drain_new_clients(&mut self) -> Vec<SocketAddr> {
std::mem::take(&mut self.new_addrs)
}
pub fn evict_stale(&mut self) {
self.clients.retain(|_, c| c.last_seen.elapsed().as_secs() < 10);
}
pub fn drain_actions(&mut self) -> Vec<(u32, u16)> {
self.clients.values_mut()
.filter_map(|c| {
let action = c.pending_action.take()?;
Some((c.entity_id, action))
})
.collect()
}
pub fn broadcast(&self, world: &World, tick: u32) {
for (addr, client) in &self.clients {
if tick % 2 != client.send_phase as u32 { continue; }
// Step 1 — resolve player position
let Some(entity) = world.entities.get(&client.entity_id) else { continue };
let pos = entity.pos;
let (player_cx, player_cy) = crate::world::tile_to_chunk(pos.0, pos.1);
// Step 2 — build 9-slot chunk manifest
let mut chunks = [ChunkEntry { chunk_id: 0, version: 0 }; 9];
for dy in -1i16..=1 {
for dx in -1i16..=1 {
let slot = ((dy + 1) * 3 + (dx + 1)) as usize;
let cx = player_cx + dx;
let cy = player_cy + dy;
chunks[slot] = ChunkEntry {
chunk_id: shared::chunk_id(cx, cy),
version: world.chunk(cx, cy).map_or(0, |c| c.version),
};
}
}
// Step 3 — build EntityEntry list + checksum
let viewport_entities = world.entities_in_viewport(pos);
let mut entries: Vec<EntityEntry> = viewport_entities.iter().map(|e| EntityEntry {
id: e.id,
type_id: e.type_id,
pos_x: e.pos.0,
pos_y: e.pos.1,
hp: e.hp,
hp_max: e.hp_max,
elo: 0,
entity_flags: 0,
meta_len: 0,
}).collect();
if entries.len() > 66 {
entries.truncate(66); // TODO: multi-datagram
}
let checksum = fnv1a_entities(&entries);
// Step 4 — send StatePacket (72 bytes)
let state = StatePacket {
header: Header::new(packet_type::STATE),
tick,
chunks,
player_entity_id: client.entity_id,
entity_checksum: checksum,
};
let _ = self.send_tx.send((*addr, bytes_of(&state).to_vec()));
// Step 5 — send EntityPacket (1200 bytes)
let mut pkt = <EntityPacket as bytemuck::Zeroable>::zeroed();
pkt.header = Header::new(packet_type::ENTITY);
pkt.tick = tick;
pkt.entity_count = entries.len() as u8;
pkt.packet_flags = 0; // no more-flag; single datagram
for (i, e) in entries.iter().enumerate() { pkt.entities[i] = *e; }
let _ = self.send_tx.send((*addr, bytes_of(&pkt).to_vec()));
// Step 6 — send ChunkPackets for cache misses
for slot in 0..9 {
let current = chunks[slot];
let cached = client.last_cache[slot];
let miss = cached.chunk_id != current.chunk_id || cached.version != current.version;
if miss {
let (cx, cy) = shared::chunk_coords(current.chunk_id);
if let Some(chunk) = world.chunk(cx, cy) {
let mut cpkt = <ChunkPacket as bytemuck::Zeroable>::zeroed();
cpkt.header = Header::new(packet_type::CHUNK);
cpkt.chunk = current;
cpkt.pal_count = chunk.pal_count;
for i in 0..64 { cpkt.palette[i] = chunk.palette[i].tile_id; }
cpkt.tiles = pack_tiles(&chunk.tiles);
let _ = self.send_tx.send((*addr, bytes_of(&cpkt).to_vec()));
}
}
}
}
}
}
fn fnv1a_entities(entries: &[EntityEntry]) -> u32 {
const FNV_BASIS: u32 = 2_166_136_261;
const FNV_PRIME: u32 = 16_777_619;
let mut h = FNV_BASIS;
for e in entries {
for &byte in bytes_of(e) {
h ^= byte as u32;
h = h.wrapping_mul(FNV_PRIME);
}
}
h
}
fn pack_tiles(tiles: &[u8; 1024]) -> [u8; 768] {
let mut raw = [0u8; 768];
for i in 0..256 {
let ia = tiles[i*4] & 0x3F;
let ib = tiles[i*4+1] & 0x3F;
let ic = tiles[i*4+2] & 0x3F;
let id = tiles[i*4+3] & 0x3F;
raw[i*3] = ia | (ib << 6);
raw[i*3+1] = (ib >> 2) | (ic << 4);
raw[i*3+2] = (ic >> 4) | (id << 2);
}
raw
}
+47
View File
@@ -0,0 +1,47 @@
use std::collections::HashMap;
use shared::player_action;
use crate::world::World;
pub struct Sim {
pub world: World,
pending: HashMap<u32, u16>,
}
impl Sim {
pub fn new(world: World) -> Self {
Self { world, pending: HashMap::new() }
}
pub fn tick(&mut self, tick: u32, actions: &[(u32, u16)]) {
for &(entity_id, action) in actions {
self.pending.insert(entity_id, action);
}
if tick.is_multiple_of(4) {
let pending = std::mem::take(&mut self.pending);
self.entity_tick(&pending);
}
}
fn entity_tick(&mut self, actions: &HashMap<u32, u16>) {
for (&entity_id, &action) in actions {
if action == player_action::NOOP {
continue;
}
let pos = match self.world.entities.get(&entity_id) {
Some(e) => e.pos,
None => continue,
};
let (nx, ny) = match action {
player_action::NORTH => (pos.0, pos.1 - 1),
player_action::EAST => (pos.0 + 1, pos.1),
player_action::SOUTH => (pos.0, pos.1 + 1),
player_action::WEST => (pos.0 - 1, pos.1),
_ => continue,
};
let blocked = self.world.tile_flags(nx, ny).map_or(true, |f| f.collidable());
if !blocked {
self.world.move_entity(entity_id, (nx, ny));
}
}
}
}
+126
View File
@@ -0,0 +1,126 @@
use std::collections::HashMap;
use crate::chunk::{Chunk, TileDef, TileFlags, PaletteFullError};
use crate::entity::Entity;
pub fn tile_to_chunk(tx: i16, ty: i16) -> (i16, i16) {
(tx >> 5, ty >> 5)
}
pub fn tile_to_local(tx: i16, ty: i16) -> (u8, u8) {
((tx & 31) as u8, (ty & 31) as u8)
}
pub fn local_to_tile(cx: i16, cy: i16, lx: u8, ly: u8) -> (i16, i16) {
((cx << 5) | lx as i16, (cy << 5) | ly as i16)
}
pub struct World {
chunks: HashMap<(i16, i16), Chunk>,
pub entities: HashMap<u32, Entity>,
chunk_entities: HashMap<(i16, i16), Vec<u32>>,
next_entity_id: u32,
}
impl World {
pub fn new() -> Self {
Self {
chunks: HashMap::new(),
entities: HashMap::new(),
chunk_entities: HashMap::new(),
next_entity_id: 1,
}
}
pub fn chunk(&self, cx: i16, cy: i16) -> Option<&Chunk> {
self.chunks.get(&(cx, cy))
}
pub fn set_chunk(&mut self, cx: i16, cy: i16, chunk: Chunk) {
self.chunks.insert((cx, cy), chunk);
}
pub fn tile_flags(&self, wx: i16, wy: i16) -> Option<TileFlags> {
let (cx, cy) = tile_to_chunk(wx, wy);
let (lx, ly) = tile_to_local(wx, wy);
self.chunks.get(&(cx, cy)).map(|c| c.tile_flags(lx, ly))
}
pub fn set_tile(&mut self, wx: i16, wy: i16, def: TileDef) -> Result<(), PaletteFullError> {
let (cx, cy) = tile_to_chunk(wx, wy);
let (lx, ly) = tile_to_local(wx, wy);
let chunk = self.chunks.entry((cx, cy)).or_insert_with(Chunk::new);
chunk.set_tile(lx, ly, def)
}
pub fn spawn_entity(&mut self, type_id: u16, pos: (i16, i16), hp: u16) -> u32 {
let id = self.next_entity_id;
self.next_entity_id += 1;
let entity = Entity::new(id, type_id, pos, hp);
self.add_entity(entity);
id
}
pub fn add_entity(&mut self, entity: Entity) {
let chunk_key = tile_to_chunk(entity.pos.0, entity.pos.1);
let id = entity.id;
self.entities.insert(id, entity);
self.chunk_entities.entry(chunk_key).or_default().push(id);
}
pub fn remove_entity(&mut self, id: u32) {
if let Some(entity) = self.entities.remove(&id) {
let chunk_key = tile_to_chunk(entity.pos.0, entity.pos.1);
if let Some(list) = self.chunk_entities.get_mut(&chunk_key) {
list.retain(|&eid| eid != id);
}
}
}
pub fn move_entity(&mut self, id: u32, new_pos: (i16, i16)) {
if let Some(entity) = self.entities.get_mut(&id) {
let old_chunk = tile_to_chunk(entity.pos.0, entity.pos.1);
let new_chunk = tile_to_chunk(new_pos.0, new_pos.1);
entity.pos = new_pos;
if old_chunk != new_chunk {
if let Some(list) = self.chunk_entities.get_mut(&old_chunk) {
list.retain(|&eid| eid != id);
}
self.chunk_entities.entry(new_chunk).or_default().push(id);
}
}
}
pub fn entities_in_chunk(&self, cx: i16, cy: i16) -> &[u32] {
self.chunk_entities.get(&(cx, cy)).map_or(&[], Vec::as_slice)
}
/// Returns all entities within a 30×30 tile viewport centered on `center`.
pub fn entities_in_viewport(&self, center: (i16, i16)) -> Vec<&Entity> {
let half: i16 = 15;
let min_x = center.0 - half;
let max_x = center.0 + half - 1;
let min_y = center.1 - half;
let max_y = center.1 + half - 1;
let min_cx = tile_to_chunk(min_x, min_y).0;
let max_cx = tile_to_chunk(max_x, max_y).0;
let min_cy = tile_to_chunk(min_x, min_y).1;
let max_cy = tile_to_chunk(max_x, max_y).1;
let mut result = Vec::new();
for cy in min_cy..=max_cy {
for cx in min_cx..=max_cx {
for &eid in self.entities_in_chunk(cx, cy) {
if let Some(e) = self.entities.get(&eid) {
if e.pos.0 >= min_x && e.pos.0 <= max_x
&& e.pos.1 >= min_y && e.pos.1 <= max_y
{
result.push(e);
}
}
}
}
}
result
}
}
+7
View File
@@ -0,0 +1,7 @@
[package]
name = "shared"
version = "0.1.0"
edition = "2024"
[dependencies]
bytemuck = { version = "1.24.0", features = ["derive", "min_const_generics"] }
+136
View File
@@ -0,0 +1,136 @@
use bytemuck::{Pod, Zeroable};
pub mod player_action {
pub const NOOP: u16 = 0;
pub const NORTH: u16 = 1;
pub const EAST: u16 = 2;
pub const SOUTH: u16 = 3;
pub const WEST: u16 = 4;
}
pub const MAGIC: u16 = 0x524C;
pub const VERSION: u8 = 1;
pub mod packet_type {
pub const STATE: u8 = 0;
pub const ACTION: u8 = 1;
pub const CHUNK: u8 = 2;
pub const ENTITY: u8 = 3;
pub const ENTITY_QUERY: u8 = 4;
pub const ENTITY_DETAIL: u8 = 5;
pub const PING: u8 = 6;
pub const PONG: u8 = 7;
}
/// Encodes signed chunk grid coordinates into the wire chunk_id format.
pub fn chunk_id(cx: i16, cy: i16) -> u32 {
(cx as u16 as u32) | ((cy as u16 as u32) << 16)
}
/// Decodes a wire chunk_id back to signed chunk grid coordinates.
pub fn chunk_coords(id: u32) -> (i16, i16) {
(id as u16 as i16, (id >> 16) as u16 as i16)
}
#[derive(Clone, Copy, Pod, Zeroable)]
#[repr(C, packed)]
pub struct Header {
pub magic: u16,
pub version: u8,
pub packet_type: u8,
pub client_type: u16,
}
impl Header {
pub fn new(packet_type: u8) -> Self {
Self { magic: MAGIC, version: VERSION, packet_type, client_type: 0 }
}
}
#[derive(Clone, Copy, Pod, Zeroable)]
#[repr(C, packed)]
pub struct ChunkEntry {
pub chunk_id: u32,
pub version: u16,
}
#[derive(Clone, Copy, Pod, Zeroable)]
#[repr(C, packed)]
pub struct ActionPacket {
pub header: Header,
pub auth_token: u64,
pub sequence: u32,
/// Slot index = (dy+1)*3 + (dx+1), dx/dy ∈ {-1, 0, 1}.
/// Slot 4 is always the player's current chunk.
pub cache: [ChunkEntry; 9],
pub player_action: u16,
}
const _: () = assert!(std::mem::size_of::<ChunkEntry>() == 6);
const _: () = assert!(std::mem::size_of::<ActionPacket>() == 74);
#[derive(Clone, Copy, Pod, Zeroable)]
#[repr(C, packed)]
pub struct StatePacket {
pub header: Header, // 6 (offset 0)
pub tick: u32, // 4 (offset 6)
/// Slot index = (dy+1)*3 + (dx+1), dx/dy ∈ {-1, 0, 1}.
/// Slot 4 is always the player's current chunk.
pub chunks: [ChunkEntry; 9], // 54 (offset 10)
pub player_entity_id: u32, // 4 (offset 64)
pub entity_checksum: u32, // 4 (offset 68)
} // total: 72 bytes
const _: () = assert!(std::mem::size_of::<StatePacket>() == 72);
#[derive(Clone, Copy, Pod, Zeroable)]
#[repr(C, packed)]
pub struct PingPacket {
pub header: Header,
pub timestamp_ms: u64,
}
#[derive(Clone, Copy, Pod, Zeroable)]
#[repr(C, packed)]
pub struct PongPacket {
pub header: Header,
pub timestamp_ms: u64,
}
#[derive(Clone, Copy, Pod, Zeroable)]
#[repr(C, packed)]
pub struct ChunkPacket {
pub header: Header, // 6 bytes
pub chunk: ChunkEntry, // 6 bytes
pub pal_count: u8, // 1 byte
pub palette: [u16; 64], // 128 bytes
pub tiles: [u8; 768], // 768 bytes — 1024 tiles, 6-bit packed
} // total: 909 bytes
const _: () = assert!(std::mem::size_of::<ChunkPacket>() == 909);
#[repr(C, packed)]
#[derive(Clone, Copy, Pod, Zeroable)]
pub struct EntityEntry {
pub id: u32,
pub type_id: u16,
pub pos_x: i16,
pub pos_y: i16,
pub hp: u16,
pub hp_max: u16,
pub elo: u16,
pub entity_flags: u8,
pub meta_len: u8,
}
const _: () = assert!(std::mem::size_of::<EntityEntry>() == 18);
#[repr(C, packed)]
#[derive(Clone, Copy, Pod, Zeroable)]
pub struct EntityPacket {
pub header: Header,
pub tick: u32,
pub entity_count: u8,
pub packet_flags: u8,
pub entities: [EntityEntry; 66],
}
const _: () = assert!(std::mem::size_of::<EntityPacket>() == 1200);