View Model Architecture in the Kifu
March 31, 2023Good morning and Happy Friday, everyone!
I’ve continued on with a couple of weeks of working on a new Go application (which I currently call Kifu, after the traditional name for a record of a game of Go). I have a set of goals for this application that mean that I have to make a very disciplined architecture, since I want to run the application on both Linux and on Android.
User Stories
Before I get into the topic, I’m going to start out with a short list of the user stories that I’ve written for the application.
- As a player, student, or reviewer, I want to be able to keep and study a database of game records.
- As a player or a reviewer, I want to annotate a game record.
- As a player or game recorder, I want to be able to record a game as it progresses.
- As a player, I want to play against another player on a shared device.
- As a player, I want to play against another player over the network.
- As a player, I want to play against an AI.
- As a player, I want to play against people on OGS and KGS.
This is where I’m going with the game. I’m currently at the stage of getting a basically interactive Goban. Since this is the initial architectural stage, today, I’m going to talk about one of those key decisions, which is the Core and View Models.
Business Logic and User Interface
The idea of keeping a strict division between Business Logic and User Interface is nothing new. What is new here is an architecture that we use at 1Password, and which the folks at Airbnb have used for their website. In these architectures, the user interface actually makes even fewer decisions than normal, being gradually reduced (as much as is viable) to a renderer.
A View Model in this context is a block of code which the core emits which describe a user interface in the abstract. For example, for the playing board above:
#[derive(Clone, Debug)]
pub struct StoneElement {
pub color: Color,
}
#[derive(Clone, Debug)]
pub struct GobanElement {
pub size: Size,
pub spaces: Vec<Option<StoneElement>>,
}
When anything interesting happens, the Core will construct a new copy of this structure and ship it to the UI, which is responsible for rendering it:
pub struct GobanPrivate {
drawing_area: gtk::DrawingArea,
current_player: Rc<RefCell<Color>>,
goban: Rc<RefCell<GobanElement>>,
cursor_location: Rc<RefCell<Addr>>,
}
impl ObjectSubclass for GobanPrivate { ... }
impl WidgetImpl for GobanPrivate {}
impl GridImpl for GobanPrivate {}
glib::wrapper! {
pub struct Goban(ObjectSubclass<GobanPrivate>) @extends gtk::Grid, gtk::Widget;
}
Again, this is nothing particularly controvesial. What becomes more interesting is this:
pub enum Request {
PlaceStone(u8, u8),
PlayingField,
}
pub enum Response {
PlayingField(PlayingFieldView)
}
#[derive(Clone, Copy, Debug)]
pub enum IntersectionElement {
Unplayable,
Empty(Request),
Filled(Color),
}
#[derive(Clone, Copy, Debug)]
pub struct GobanElement {
pub size: Size,
pub spaces: Vec<IntersectionElement>,
}
So, now there’s something new. The Intersection could be a stone, in which case the UI needs to know to just render it; or it could be Empty, in which case there is an action associated with it. This action is the action which the UI must send to the core if the user clicks on that location.
This is important. The UI only knows (and this only by contractual design of the Goban) that an empty intersection point can be clicked upon. On click, there is an opaque request that the UI should send back to the Core.
The UI knows nothing about what is in this request.
While by itself this may seem odd but small, taken to a greater extent we slowly move behavior out of the UI and into the Core. In doing this, suddenly there is less to implement when I add the Android UI. The Android UI, like the GTK UI, needs only to render the Goban and then on click it needs to send the request to Core and render whatever Core returns. Core will handle all of the logic of deciding whether the move was valid and evaluating how the game changes as a result of that move. Do stones get removed from the board? The new view model will no longer have those stones. Is the move suicide, and do the rules forbid suicide moves? The new view model will contain no changes, except maybe a warning to the user.
Intersection Element
So, for a moment, let’s talk about this contract more explicitely. I’ve not documented this anywhere, so I’m figuring it out as we go.
#[derive(Clone, Copy, Debug)]
pub enum IntersectionElement {
Unplayable,
Empty(Request),
Filled(Color),
}
- An
IntersectionElement
only makes sense in the context of a Goban and cannot appear in any other user interface element. - An
IntersectionElement::Unplayable
space cannot be played. The UI must not respond to clicks on this space and should not render a ghost stone on the space. - An
IntersectionElement::Empty
space is playable. The UI should render a ghost stone of the current player color when the cursor is over that space. If the user clicks on the space, the UI should send the Request to Core. - An
IntersectionElement::Filled
space is unplayable. The UI should render a stone of the specified color there.
And thus, the UI is now free to make decisions about all of the details of rendering, and otherwise needs to make no logical decisions whatsoever. In fact, if it really treats the Request as an opaque data structure, we can change the Request to something which contains more information without changing any of the user interfaces.
Keeping it going
As always, the devil is in the details. This model doesn’t work flawlessly everywhere and some logic does need to get encoded into the UI. It is, however, much less than you might think, and you quickly get to reap the benefits of this as soon as you have a second user interface.
The Project
Kifu, though lacking all of the necessary file headers, is a GPL-3 project and available on my Gitea instance. Feel free to tak a look at it, and contact me if you wish to participate in some way.
Let me know, either on Mastadon or on Matrix, if you’re excited to use this app. I would love some positive input and even feature requests.
Happy Friday, and enjoy your weekend.
Contact me
- Matrix: @savanni:luminescent-dreams.com
- Mastadon: @savanni@anarchism.space (NSFW) or @savanni@esperanto.masto.host (Esperanto-only)
- Email: savanni@luminescent-dreams.com