diff --git a/demo/Blackholio/server-rust/src/lib.rs b/demo/Blackholio/server-rust/src/lib.rs index dd36441f748..f68fca90532 100644 --- a/demo/Blackholio/server-rust/src/lib.rs +++ b/demo/Blackholio/server-rust/src/lib.rs @@ -2,7 +2,7 @@ pub mod math; use math::DbVector2; use rand::Rng; -use spacetimedb::{spacetimedb_lib::ScheduleAt, Identity, ReducerContext, Table, Timestamp, TimeDuration}; +use spacetimedb::{spacetimedb_lib::ScheduleAt, Identity, ReducerContext, Table, TimeDuration, Timestamp}; use std::{collections::HashMap, time::Duration}; // TODO: @@ -134,12 +134,10 @@ pub fn init(ctx: &ReducerContext) -> Result<(), String> { scheduled_id: 0, scheduled_at: ScheduleAt::Interval(Duration::from_millis(500).into()), })?; - ctx.db - .move_all_players_timer() - .try_insert(MoveAllPlayersTimer { - scheduled_id: 0, - scheduled_at: ScheduleAt::Interval(Duration::from_millis(50).into()), - })?; + ctx.db.move_all_players_timer().try_insert(MoveAllPlayersTimer { + scheduled_id: 0, + scheduled_at: ScheduleAt::Interval(Duration::from_millis(50).into()), + })?; Ok(()) } @@ -147,10 +145,7 @@ pub fn init(ctx: &ReducerContext) -> Result<(), String> { pub fn connect(ctx: &ReducerContext) -> Result<(), String> { if let Some(player) = ctx.db.logged_out_player().identity().find(&ctx.sender) { ctx.db.player().insert(player.clone()); - ctx.db - .logged_out_player() - .identity() - .delete(&player.identity); + ctx.db.logged_out_player().identity().delete(&player.identity); // Restore any circles for this player for circle in ctx.db.logged_out_circle().player_id().filter(&player.player_id) { @@ -172,12 +167,7 @@ pub fn connect(ctx: &ReducerContext) -> Result<(), String> { #[spacetimedb::reducer(client_disconnected)] pub fn disconnect(ctx: &ReducerContext) -> Result<(), String> { - let player = ctx - .db - .player() - .identity() - .find(&ctx.sender) - .ok_or("Player not found")?; + let player = ctx.db.player().identity().find(&ctx.sender).ok_or("Player not found")?; let player_id = player.player_id; ctx.db.logged_out_player().insert(player); ctx.db.player().identity().delete(&ctx.sender); @@ -208,23 +198,11 @@ pub fn enter_game(ctx: &ReducerContext, name: String) -> Result<(), String> { fn spawn_player_initial_circle(ctx: &ReducerContext, player_id: u32) -> Result { let mut rng = ctx.rng(); - let world_size = ctx - .db - .config() - .id() - .find(&0) - .ok_or("Config not found")? - .world_size; + let world_size = ctx.db.config().id().find(&0).ok_or("Config not found")?.world_size; let player_start_radius = mass_to_radius(START_PLAYER_MASS); let x = rng.gen_range(player_start_radius..(world_size as f32 - player_start_radius)); let y = rng.gen_range(player_start_radius..(world_size as f32 - player_start_radius)); - spawn_circle_at( - ctx, - player_id, - START_PLAYER_MASS, - DbVector2 { x, y }, - ctx.timestamp, - ) + spawn_circle_at(ctx, player_id, START_PLAYER_MASS, DbVector2 { x, y }, ctx.timestamp) } fn spawn_circle_at( @@ -282,12 +260,7 @@ pub fn suicide(ctx: &ReducerContext) -> Result<(), String> { #[spacetimedb::reducer] pub fn update_player_input(ctx: &ReducerContext, direction: DbVector2) -> Result<(), String> { - let player = ctx - .db - .player() - .identity() - .find(&ctx.sender) - .ok_or("Player not found")?; + let player = ctx.db.player().identity().find(&ctx.sender).ok_or("Player not found")?; for mut circle in ctx.db.circle().player_id().filter(&player.player_id) { circle.direction = direction.normalized(); circle.speed = direction.magnitude().clamp(0.0, 1.0); @@ -324,13 +297,7 @@ fn mass_to_max_move_speed(mass: u32) -> f32 { pub fn move_all_players(ctx: &ReducerContext, _timer: MoveAllPlayersTimer) -> Result<(), String> { // TODO identity check // let span = spacetimedb::log_stopwatch::LogStopwatch::new("tick"); - let world_size = ctx - .db - .config() - .id() - .find(0) - .ok_or("Config not found")? - .world_size; + let world_size = ctx.db.config().id().find(0).ok_or("Config not found")?.world_size; let mut circle_directions: HashMap = ctx .db @@ -341,12 +308,7 @@ pub fn move_all_players(ctx: &ReducerContext, _timer: MoveAllPlayersTimer) -> Re // Split circle movement for player in ctx.db.player().iter() { - let circles: Vec = ctx - .db - .circle() - .player_id() - .filter(&player.player_id) - .collect(); + let circles: Vec = ctx.db.circle().player_id().filter(&player.player_id).collect(); let mut player_entities: Vec = circles .iter() .map(|c| ctx.db.entity().entity_id().find(&c.entity_id).unwrap()) @@ -381,12 +343,8 @@ pub fn move_all_players(ctx: &ReducerContext, _timer: MoveAllPlayersTimer) -> Re } let radius_sum = mass_to_radius(entity_i.mass) + mass_to_radius(entity_j.mass); if distance_sqr > radius_sum * radius_sum { - let gravity_multiplier = - 1.0 - time_before_recombining / SPLIT_GRAV_PULL_BEFORE_RECOMBINE_SEC; - let vec = diff.normalized() - * (radius_sum - distance_sqr.sqrt()) - * gravity_multiplier - * 0.05 + let gravity_multiplier = 1.0 - time_before_recombining / SPLIT_GRAV_PULL_BEFORE_RECOMBINE_SEC; + let vec = diff.normalized() * (radius_sum - distance_sqr.sqrt()) * gravity_multiplier * 0.05 / count as f32; *circle_directions.get_mut(&entity_i.entity_id).unwrap() += vec / 2.0; *circle_directions.get_mut(&entity_j.entity_id).unwrap() -= vec / 2.0; @@ -409,9 +367,7 @@ pub fn move_all_players(ctx: &ReducerContext, _timer: MoveAllPlayersTimer) -> Re let radius_sum = mass_to_radius(entity_i.mass) + mass_to_radius(entity_j.mass); let radius_sum_multiplied = radius_sum * ALLOWED_SPLIT_CIRCLE_OVERLAP_PCT; if distance_sqr < radius_sum_multiplied * radius_sum_multiplied { - let vec = diff.normalized() - * (radius_sum - distance_sqr.sqrt()) - * SELF_COLLISION_SPEED; + let vec = diff.normalized() * (radius_sum - distance_sqr.sqrt()) * SELF_COLLISION_SPEED; *circle_directions.get_mut(&entity_i.entity_id).unwrap() += vec / 2.0; *circle_directions.get_mut(&entity_j.entity_id).unwrap() -= vec / 2.0; } @@ -429,8 +385,7 @@ pub fn move_all_players(ctx: &ReducerContext, _timer: MoveAllPlayersTimer) -> Re let mut circle_entity = circle_entity.unwrap(); let circle_radius = mass_to_radius(circle_entity.mass); let direction = *circle_directions.get(&circle.entity_id).unwrap(); - let new_pos = - circle_entity.position + direction * mass_to_max_move_speed(circle_entity.mass); + let new_pos = circle_entity.position + direction * mass_to_max_move_speed(circle_entity.mass); let min = circle_radius; let max = world_size as f32 - circle_radius; circle_entity.position.x = new_pos.x.clamp(min, max); @@ -454,11 +409,7 @@ pub fn move_all_players(ctx: &ReducerContext, _timer: MoveAllPlayersTimer) -> Re if other_circle.player_id != circle.player_id { let mass_ratio = other_entity.mass as f32 / circle_entity.mass as f32; if mass_ratio < MINIMUM_SAFE_MASS_RATIO { - schedule_consume_entity( - ctx, - circle_entity.entity_id, - other_entity.entity_id, - ); + schedule_consume_entity(ctx, circle_entity.entity_id, other_entity.entity_id); } } } else { @@ -484,16 +435,8 @@ fn schedule_consume_entity(ctx: &ReducerContext, consumer_id: u32, consumed_id: #[spacetimedb::reducer] pub fn consume_entity(ctx: &ReducerContext, request: ConsumeEntityTimer) -> Result<(), String> { - let consumed_entity = ctx - .db - .entity() - .entity_id() - .find(&request.consumed_entity_id); - let consumer_entity = ctx - .db - .entity() - .entity_id() - .find(&request.consumer_entity_id); + let consumed_entity = ctx.db.entity().entity_id().find(&request.consumed_entity_id); + let consumer_entity = ctx.db.entity().entity_id().find(&request.consumer_entity_id); if consumed_entity.is_none() { return Err("Consumed entity doesn't exist".into()); } @@ -526,12 +469,7 @@ pub fn player_split(ctx: &ReducerContext) -> Result<(), String> { .identity() .find(&ctx.sender) .ok_or("Sender has no player")?; - let circles: Vec = ctx - .db - .circle() - .player_id() - .filter(&player.player_id) - .collect(); + let circles: Vec = ctx.db.circle().player_id().filter(&player.player_id).collect(); let mut circle_count = circles.len() as u32; if circle_count >= MAX_CIRCLES_PER_PLAYER { return Ok(()); @@ -564,15 +502,13 @@ pub fn player_split(ctx: &ReducerContext) -> Result<(), String> { } } - ctx.db - .circle_recombine_timer() - .insert(CircleRecombineTimer { - scheduled_id: 0, - scheduled_at: ScheduleAt::Time( - ctx.timestamp + TimeDuration::from(Duration::from_secs_f32(SPLIT_RECOMBINE_DELAY_SEC)) - ), - player_id: player.player_id, - }); + ctx.db.circle_recombine_timer().insert(CircleRecombineTimer { + scheduled_id: 0, + scheduled_at: ScheduleAt::Time( + ctx.timestamp + TimeDuration::from(Duration::from_secs_f32(SPLIT_RECOMBINE_DELAY_SEC)), + ), + player_id: player.player_id, + }); log::warn!("Player split!"); @@ -586,13 +522,7 @@ pub fn spawn_food(ctx: &ReducerContext, _timer: SpawnFoodTimer) -> Result<(), St return Ok(()); } - let world_size = ctx - .db - .config() - .id() - .find(0) - .ok_or("Config not found")? - .world_size; + let world_size = ctx.db.config().id().find(0).ok_or("Config not found")?.world_size; let mut rng = ctx.rng(); let mut food_count = ctx.db.food().count(); @@ -643,21 +573,10 @@ pub fn calculate_center_of_mass(entities: &[Entity]) -> DbVector2 { #[spacetimedb::reducer] pub fn circle_recombine(ctx: &ReducerContext, timer: CircleRecombineTimer) -> Result<(), String> { - let circles: Vec = ctx - .db - .circle() - .player_id() - .filter(&timer.player_id) - .collect(); + let circles: Vec = ctx.db.circle().player_id().filter(&timer.player_id).collect(); let recombining_entities: Vec = circles .iter() - .filter(|c| { - ctx.timestamp - .duration_since(c.last_split_time) - .unwrap() - .as_secs_f32() - >= SPLIT_RECOMBINE_DELAY_SEC - }) + .filter(|c| ctx.timestamp.duration_since(c.last_split_time).unwrap().as_secs_f32() >= SPLIT_RECOMBINE_DELAY_SEC) .map(|c| ctx.db.entity().entity_id().find(&c.entity_id).unwrap()) .collect(); if recombining_entities.len() <= 1 { diff --git a/sdks/csharp/DEVELOP.md b/sdks/csharp/DEVELOP.md index 9f8f56654d2..4d48d4a808e 100644 --- a/sdks/csharp/DEVELOP.md +++ b/sdks/csharp/DEVELOP.md @@ -4,10 +4,7 @@ We are in the process of moving from the `com.clockworklabs.spacetimedbsdk` repo # Notes for maintainers -## `SpacetimeDB.ClientApi` - -To regenerate this namespace, run the `tools~/gen-client-api.sh` or the -`tools~/gen-client-api.bat` script. +First, see the [user-facing docs](https://spacetimedb.com/docs/sdks/c-sharp). ## Developing against a local clone of SpacetimeDB When developing against a local clone of SpacetimeDB, you'll need to ensure that the packages here can find an up-to-date version of the BSATN.Codegen and BSATN.Runtime packages from SpacetimeDB. @@ -21,3 +18,85 @@ dotnet pack ../SpacetimeDB/crates/bindings-csharp/BSATN.Runtime && ./tools~/writ This will create a (`.gitignore`d) `nuget.config` file that uses the local build of the package, instead of the package on NuGet. You'll need to rerun this command whenever you update `BSATN.Codegen` or `BSATN.Runtime`. + +## Internal architecture documentation + +### Code generation +The SDK uses multiple layers of code generation: + +- The `SpacetimeDB.BSATN.Codegen` library, a dependency of the SDK, whose source code lives in the SpacetimeDB repo [here](https://github.com/clockworklabs/SpacetimeDB/tree/master/crates/bindings-csharp). This library provides the `[SpacetimeDB.Type]` annotation. When the C# compiler encounters this annotation, it invokes the library to create [BSATN](https://spacetimedb.com/docs/bsatn) serialization code for the annotated type. This works for any compatible C# type. It does not involve any non-C# code, and the generated code is not visible in the filesystem. +- The codegen performed by the [`spacetimedb-codegen`](https://github.com/clockworklabs/SpacetimeDB/blob/master/crates/codegen/src/csharp.rs) Rust library, which also lives in the SpacetimeDB repo. This library is used by the `spacetime generate` CLI command. It generates code that talks to a SpacetimeDB module over the network. This code is what the user actually sees in the filesystem. The linked SpacetimeDB module can be written in any language, not just C#. + +The code created by `spacetime generate` imports the SpacetimeDB SDK and extends its various classes to create a SpacetimeDB client. It also imports `SpacetimeDB.BSATN.Codegen` for its serialization needs. + +See [`examples~/quickstart-chat/client/module_bindings`](./examples~/quickstart-chat/client/module_bindings/) for an example of what `spacetime generate`d code looks like. + +If you need to debug `SpacetimeDB.BSATN.Codegen`, you can set `true` in the `` in the SDK code, which lives in [`src/SpacetimeDBClient.cs`](./src/SpacetimeDBClient.cs). This is a general pattern. Similar inheritance patterns are used for tables and indexes: the generated code defines a class that inherits most of its behavior from a class in the SDK. + +We require that **a DbConnection is only accessed from a single thread**, which should call the `DbConnection.FrameTick()` method frequently. See [threading model](#threading-model), below. + +In general, the generated code tries to implement as little functionality as possible, leaving most of the behavior to the SDK. This makes updates easier, since it is generally easier to update SDK code instead of the generated code. + +When SDK code needs to refer to generated types, we have two options: +- Make the SDK code generic, and instantiate the generics in the generated code. E.g. `DbConnectionBase<...>` (SDK) is generic, but `DbConnection` (generated) is not. +- Or, just move the code entirely into the generated code. This was done for e.g. `ReducerEventContext`, which no longer lives in the SDK at all. + +The most important generated types are `RemoteTables` -- also known as the **client cache** -- and `RemoteReducers`. `RemoteTables` stores the local view of subscribed data from the database. For a `DbConnection conn`, `conn.Db` is an instance of `RemoteTables`. `RemoteReducers` allows calling reducers on the server, and is accessible at `conn.Reducers`. Types are also generated for all server-side types referred to by tables or modules. + +### Runtime Structure + +Most of the core logic of the SDK lives in [`DbConnectionBase<...>`](./src/SpacetimeDBClient.cs). This handles: +- Spinning up background threads to talk to the network and parse messages +- Receiving updates, updating the client cache, and calling callbacks. + +The user creates a single `DbConnection` (which inherits from `DbConnectionBase<...>`), and from this `DbConnection` creates some number of `SubscriptionHandle`s using the `SubscriptionBuilder` class. Each subscription consists of some number of SQL queries that are tracked by the remote server. The user can also call reducers using this `DbConnection`. + +The server periodically sends updates (via websocket) to the `DbConnection`. The `DbConnection` is responsible for updating its local view of the server state (`conn.Db`) using these messages, and invoking callbacks registered by the user. + +Codegen also generates code for each table implementing the `ITable` interface in [`src/Table.cs`](./src/Table.cs). `DbConnection` only sees tables as `ITable`s -- it does not know anything more about the specific implementation of each table. `RemoteTableHandle<...>` in `src/Table.cs` implements the `ITable` interface in combination with the generated code. It also has a callback structure, `OnInternalInsert` and `OnInternalDelete`, used by generated code to maintain indexes. + +Roughly speaking, code pertaining to specific tables should live in `Table.cs`, and code pertaining to the connection as a whole should live in `DbConnectionBase<...>`. + +### Threading model + +The C# SDK, unlike the [Rust SDK](https://github.com/clockworklabs/SpacetimeDB/tree/master/crates/sdk), **assumes a DbConnection is only accessed from a single thread**. This thread is referred to as the "main thread". The "main thread" is: +- Whichever thread is repeatedly calling `DbConnection.FrameTick()` in a loop. +It is **only safe to call `FrameTick()` from a single thread**. It is **only safe to access the DbConnection from this thread**. +(Note: we should write about this in the public docs!) + +While `DbConnection.FrameTick()` is running, the state of `conn.Db` is not well-defined. At all other times, `conn.Db` is guaranteed to be in a single, well-formed state, matching the state of the server at some time in the past [^1]. **This is only true when RemoteTables is accessed from the main thread**. Accessing `conn.Db` from any other thread may result in inconsistent reads or `ConcurrentModificationException`s. + +In particular, a user of the SDK can never observe a "partially applied" transaction. Transaction updates are atomic, and happen all-at-once. If a transaction modifies multiple rows / tables, the user will never observe a `conn.Db` with only some of these updates applied. (As long as they don't access `conn.Db` from a background thread!) + +Note that `DbConnection.FrameTick()` may invoke user callbacks. We also guarantee that `conn.Db` is in a well-formed state while any callbacks are invoked. + +What we are doing is effectively using the main thread itself as a lock on `conn.Db`. This design makes it difficult to interact with the SDK in a multi-threaded way, but it provides a relatively simple mental model for users. + +[^1]: Strictly speaking, we should say the "causal" past here. It is in the past light-cone of an observer interacting with the SDK; more concretely, the SDK has received a message from the server, and the state of the SDK corresponds to a state of the server at some point before that message was sent.[^2] + +[^2]: Of course, defining things this way only makes sense if the server *has* a single, well-defined state. At time of writing, this is the case, since transactions on the server are totally ordered. But this may change in the future. + +### Network protocol + +The server and client communicate via websocket. They exchange messages encoded with [BSATN](https://spacetimedb.com/docs/bsatn). The specific messages they encode live in the `SpacetimeDB.ClientApi` namespace, which is stored in the [`src/SpacetimeDB/ClientApi`](./src/SpacetimeDB/ClientApi/) directory. + +This namespace is automatically generated from a specification written in Rust. To regenerate this namespace, run the `tools~/gen-client-api.sh` or the +`tools~/gen-client-api.bat` script. + +Note that messages are actually double-encoded. The `SpacetimeDB.ClientApi` messages store various `byte[]`s that must be decoded *again* to get actual table rows, reducer arguments, etc. This unfortunately involves a lot of copying. + +### Overlapping subscriptions + +The user may subscribe to the same row in multiple ways, e.g. `SELECT * FROM students WHERE student.age > 5` and `SELECT * FROM students WHERE student.class = 4`. If the user subscribes to both of these queries, the server will send multiple copies of all students in class 4 of age greater than 5. + +We could deduplicate multiply-subscribed rows server-side, but this represents a large amount of work, so for performance reasons we deduplicate them client-side instead. We rely on the [`MultiDictionary`](src/MultiDictionary.cs) class to do this. This class is like a regular dictionary, but it can store multiple "copies" of a (key, value) pair. See the comments on that class for more information, and [`tests~/MultiDictionaryTests.cs`](./tests~/MultiDictionaryTests.cs) for randomized tests of its behavior. + +There is also a class `MultiDictionaryDelta`. This represents a pre-processed batch of changes to a `MultiDictionary`. We prepare `MultiDictionaryDelta`s on a background thread and `Apply` them on the main thread. This allows us to do at least some work without blocking the main thread. + +Note that if multiple subscriptions are subscribed to a row, when a server-side transaction updates that row, exactly the right number of updates will be sent over the network, in a single `ServerMessage`. `MultiDictionary` and `MultiDictionaryDelta` rely on this guarantee for correct operation, and will throw exceptions in debug mode if it is not met. + diff --git a/sdks/csharp/README.md b/sdks/csharp/README.md index 3f9e10a6ba4..0c8ee2fd49e 100644 --- a/sdks/csharp/README.md +++ b/sdks/csharp/README.md @@ -11,3 +11,6 @@ The Unity SDK uses the same code as the C# SDK. You can find the documentation f There is also a comprehensive Unity tutorial/demo available: - [Unity Tutorial](https://spacetimedb.com/docs/unity/part-1) Doc - [Unity Demo](https://github.com/clockworklabs/SpacetimeDB/tree/master/demo/Blackholio) Repo + +## Internal developer documentation +See [`DEVELOP.md`](./DEVELOP.md).