Compared with traditional cloud computing platforms, Internet computers have many advantages and can provide a more simplified application development experience.
I’m an engineer at DFINITY, but I’m also a software developer, so I wanted to test this premise and evaluate the experience of building on the Internet Computer from a web developer’s perspective.
I chose to build a version of Reversible, a strategy board game for two players, not as a sample application, but as a real application with all the possibilities and details I imagined a multiplayer Reversible game to have.
Before I dive into the technical details behind the scenes, I want to focus on the high-level concept: a virtual environment where Internet applications can connect to each other seamlessly.
I personally believe that as cloud computing grows, infrastructure will become a commodity. In other words, it won't matter who provides the infrastructure.
The important thing is: you wrote an application, and it runs on the Internet.
Programming Model
The experience of developing web applications on the Internet Computer was close to that of the (now defunct) newer platforms like Parse or similar.
The basic premise of such platforms is to hide the complexity of building and maintaining backend services (e.g. HTTP servers, databases, user logins, etc.).
Instead, they provide an abstract virtual environment that simply runs user applications, without the user knowing or having to pay attention to where and how their applications are running.
Viewed in this light, the Internet Computer is both familiar and different.
The fundamental building block of Internet Computer applications is the container, which is conceptually a real-time running process that:
is 100% deterministic (if all inputs and states are the same, the output must be the same)
Transparent persistence (also called orthogonal persistence)
Communicate with users or other containers via asynchronous messaging (remote function calls)
Process one message at a time (following the actor model)
If we think of Docker containers as virtualizing an entire operating system (OS), then containers virtualize a single program, hiding nearly all OS details.
Since it can't run your favorite OS or database, it seems overly restrictive. What is it good for?
I personally prefer to think in terms of disciplines rather than limitations, except to highlight two properties (among many) that make the container model different from regular web services:
Atomicity: Each message jar's state update is atomic (remote function call), the call succeeds and the state is updated, or an error is raised and the state is not touched (as if the call never happened).
Bidirectional messaging: Messages are delivered at most once, and the message caller is always guaranteed a reply, whether success or failure.
Such guarantees are difficult to obtain without limiting the functionality of user programs.
Hopefully, by the end of this article, you’ll agree that the constrained container model can actually accomplish a lot by finding the best combination of efficiency, robustness, and simplicity.
Client:Server Architecture
Multiplayer games require exchanging data between players, and their implementation usually follows a client-server architecture:
The server hosts the actual game and manages communication with the game clients
Two or more clients (each representing a player) get state from the server, render the game UI, and also accept player input to forward to the server
Building a multiplayer game as a web application means that the client must run in the browser, utilizing the HTTP protocol for data communication and using Javascript (JS) to render the game UI as a web page.
For this multiplayer reversal game, I would like to implement the following features:
Any two players can choose to play against each other
Players earn points by winning games, which also count towards their cumulative score
The scoreboard shows the top players
Of course there is the usual game flow: taking input from each player in turn, enforcing only legal moves, and detecting the end of the game to calculate the points
Much of this game logic is about state manipulation, and server-side implementation helps ensure players have a consistent field of view.
Backend Server
In a traditional backend setup, I would have to choose a suite of server-side software, including a database to hold player and game data, a web server to handle HTTP requests, and then write my own application software to connect the two to implement the full set of server-side logic.
In a "serverless" setting, typically the platform already provides web server and database services, and I just need to write application software that calls the platform to use these services.
Despite the misleading term “serverless,” the application will still play the role of a “server” as dictated by the client-server architecture.
Regardless of the backend setup, central to my application design is a set of APIs that control the communication between the game server and its clients.
Developing this application on the Internet Computer was no different, so I started with the following high-level design of the game flow:
After players register, if any two of them express a desire to play with each other, via a start(opponent_name) call, a new game will begin.
The players then take turns placing their next move, and the other player will have to periodically call view() to refresh their view with the latest game state, then make their next move, and so on until the game is over.
As a simple rule of thumb, players can only play one game at any given time.
The server must maintain the following data sets:
List of registered players, their names and scores etc.
A list of games in progress, each game includes the most recent game board, who is playing the black and white game, who is allowed to make the next move, and the final result after completing the game, etc.
I chose to implement the server in Motoko, but in theory any language that can compile to Web Assembly (Wasm) should work just fine as long as it uses the same system APIs to communicate with the internet component. (As of this writing, a Rust SDK is actually coming out soon.)
As a new language, Motoko has some rough edges (e.g. its base libraries are a bit underdeveloped and not yet stable), but it already has package manager and Language Server Protocol (LSP) support in VSCode, which makes the development process quite pleasant (I’m a Vim user, that is).
In this article, I will not discuss the Motoko language itself.
Instead, I will discuss some of the noteworthy features of Motoko and the Internet Computer that make container development exciting.
Stable variables
Orthogonal persistence (OP) is not a new idea.
New generations of computer hardware such as NVRam have largely removed the barriers to persistent storage of all program memory, and access to external storage such as file systems becomes optional for programs.
However, one challenge often mentioned in the OP's literature is about upgrades, namely, what happens when an update must change the data structures or memory layout of a program?
Motoko answered this question with stable variables. They survive upgrades, which in my opinion is ideal for preserving player data, since I don't want players to lose their accounts when I update the container software.
In regular server-side development, I have to store player accounts in files or databases, which is a basic system service of the "serverless" platform.
Only certain types of variables can be made stable, but otherwise they are just like any other variable that stores data on the heap and can be used as such.
That said, there is currently a limitation that prevents using HashMaps as stable variables, so I have to resort to arrays. Here is an example:
I hope that future versions of the DFINITY SDK will remove this limitation so that I can simply use the stable var player without any conversions.
User Authentication
Each container, as well as each client (e.g. dfx command line or browser) will get a principal ID that uniquely identifies them (for clients, such ID is automatically generated from a public/private key pair and the DFINITY JS library manages it, currently located in the browser's local storage).
Motoko allows the container to identify the caller of a "shared" function, which we can use for authentication purposes.
For example, I define the registration and viewing functions as follows:
The expression msg.caller gives the principal ID of the caller of the message. Note that this is different from the caller of the function.
In Motoko, messages sent to actors must be sent to a publicly accessible function, which must have an asynchronous return type.
The code above shows two public functions: register and view, where the latter is a query call, marked by the query keyword.
As we can see, accessing message caller fields must use a special syntax: shared(msg) or shared query(msg), where msg is a formal parameter that refers to the passed in message as a whole.
Currently, the only property it has is caller.
Being able to access the unique ID of the caller (message sender) feels familiar, like an HTTP cookie.
But unlike HTTP, the Internet Computer Protocol actually ensures that the principal ID is cryptographically secure and that user programs running on the Internet Computer can fully trust its authenticity.
Personally, I think that making a program aware of its caller is probably too powerful, and too rigid (e.g., what happens when such an ID has to be changed?).
But for now, it does lead to a very simple authentication scheme that application developers can take advantage of, and I hope to see more development in this area.
Concurrency and Atomicity
Game clients can send messages to the game server at any time, so it is the server's responsibility to handle concurrent requests correctly.
In a conventional architecture, I would have to build some logic to determine the order in which the players move, usually via a messaging queue or a mutex.
With the actor programming model used by the container, this is automatically taken care of without me having to write any code.
Messages are just remote function calls, and the container is guaranteed to process only one message at a time. This leads to simplified programming logic, and I don't have to worry about functions being called concurrently at all.
Because container state is only persisted after a message has been fully processed (i.e., a common function call returns), I don't have to worry about flushing memory to disk, whether an exception would cause corrupted disk state, or anything else related to reliability.
Also note that the atomicity of persistent state changes is per-message.
The public function is free to call any other non-async function, and as long as the entire execution completes without errors, the changed state is preserved (for update calls, more details are provided below).
Finer granularity can be achieved by issuing asynchronous calls instead of synchronous calls, which become new messages to the system to be scheduled rather than executed immediately.
If I were to build this game using a conventional architecture, I would probably also choose an actor framework like Akka for Java, Actix for Rust, etc.
Motoko provides native actor support, joining the family of actor-based programming languages such as Erlang and Pony.
Update call vs. query call
I think this feature could really improve the user experience of Internet Computer applications by bringing them on par with what’s hosted on traditional cloud platforms (and orders of magnitude faster compared to other blockchains).
It's also a simple concept: any public function that doesn't require a change in program state can be marked as a "query" call, otherwise it is treated as an "update" call by default.
The difference between queries and updates is latency and concurrency:
A query call might take only a few milliseconds to complete, while an update call might take about two seconds.
Query calls can be executed concurrently with good scalability, update calls are done sequentially (based on the actor model) and they provide atomicity guarantees.
Just like in the code example above, I was able to mark the view function as a query call because it simply looks up and returns the state of the game the player is playing.
In fact, most of the time when browsing the web, we are making query calls: data is retrieved from the server but not modified.
On the other hand, the register function above is kept as an update call because it has to add the new player to the players list after a successful registration.
Update calls will take longer due to many reasons including data consistency, atomicity, and reliability.
But this is not an inherent problem with Internet computers.
Today, many actions on the web literally take more than two seconds to complete, such as paying with a credit card, placing an order, or logging into a bank account, to name a few.
I think two seconds is the critical point for a good user experience.
Going back to the reverse game, when the player makes their next move, it must also be an update call:
If a game only refreshes its screen two seconds after the player clicks the mouse (or touches the screen), it will feel unresponsive, and no one will want to play a game with such poor timing.
Therefore, I had to optimize this part by reacting to user input directly on the client, without having to wait for the server to respond.
This means that the front-end UI will have to validate the player's moves, calculate which pieces will be flipped and display them on the screen immediately.
This also means that whatever the frontend displays to the player, when it comes back, must be matched by the server's response to the same action, otherwise we risk inconsistencies.
But then again, I believe that any reasonable implementation of a multiplayer two-way or chess game would be able to do this regardless of whether its backend takes 200ms or 2 seconds to respond.
Front-end customers
The DFINITY SDK provides a frontend that loads applications directly into the browser.
However, it is different from normal HTML pages served by a web server.
Communication with the backend container is done via remote function calls, which in the case of a browser are overlaid on top of HTTP.
This is handled transparently by the JS user library, so a JS program can just import the container as a JS object and can call its public functions just like regular asynchronous JS functions of an object.
The DFINITY SDK has a set of tutorials on how to set up a JS frontend, so I won’t go into detail here.
Behind the scenes, the dfx command in the SDK uses Webpack to bundle your assets, including JS, CSS, images, and other files you may have.
You can also combine your favorite JS framework (such as React, AngularJS, Vue.js, etc.) with the DFINITY user library to develop a JS front-end for use in the browser or mobile app.
Main UI components
I'm relatively new to front-end development and only have brief experience with React.
I took the liberty of learning Mithril this time around, as I had heard many good things about Mithril, especially its simplicity.
For simplicity, I also proposed a design with only two screens:
A "Play" screen that allows players to enter their name and the name of their opponent before entering the "Game" screen. It will also display some tips and instructions, a chart of top players, recent players, and more.
A "Game" screen that accepts player input and communicates with the backend container to render a reverse chessboard. It will also display the player's score at the end of the game and then return the player to the "Game" screen.
The following code snippet shows the skeleton of the JS game frontend:
There are a few things to note:
Just like any other JS library, the main backend container reversi is imported. Think of it as a proxy that forwards function calls to the remote server, receives replies, and transparently handles the necessary authentication, message signing, data serialization/deserialization, error propagation, etc.
Another reversi_assets container will also be imported. This is a way to get the necessary assets packaged by Webpack when the backend container is installed. In this case, I have a sound file that will be played when the player places a new piece.
A logo image that goes directly into the image. This must be configured in Webpack using url-loader, which actually embeds the contents of the image as a Base64 string to be used for the image element. Works well for small images, but not for large ones.
The final application is set up using Mithril with two paths /play and /game. The latter takes the player and opponent names as two parameters, which allows the game screen to be reloaded into the browser without interrupting the game.
Loading resources from an asset container
Since I'm new to loading DOM elements asynchronously in JS, I'm struggling a bit with this.
When dfx builds the jar, it also builds a reversi_assets jar, which basically just packages everything in src/reversi_assets/assets/ in it.
I use this to retrieve a sound file, but loading it correctly is not as straight forward as just placing the URL to the mp3 file in the src field of an HTML element.
This is how I load it (if you are a front-end developer you probably already know this):
When the start function is called (from the async context), it will try to retrieve the file "put.mp3" from the remote container.
Upon successful retrieval, it will use the JS tool AudioContext to decode the audio data and initialize the global variable putsound.
If putsound is initialized correctly, the call to playAudio(putsound) will play the actual sound:
Other resources can be loaded in a similar way. I didn’t use any images other than the logo, which is quite small and whose source code can be embedded into Webpack by adding the following configuration to webpack.config.js:
Data exchange format
Motoko's concept is "shareable" data, that is, data that can be sent across container or language boundaries.
Obviously I wouldn't imagine a heap pointer in C to be "shareable", but to me anything that can be mapped to JSON can be "shared".
To this end, DFINITY developed an IDL (Interface Description Language) called Candid for Internet Computer applications.
Candid greatly simplifies the way the frontend communicates with the backend or between containers.
For example, here is a (incomplete) snippet of the backend reversible container described by Candid:
Take the move method as an example:
This is one of the methods exported under the container's service interface.
It takes two integers as input (representing a coordinate) and returns a result of type MoveResult.
MoveResult is a variant (aka enumeration) that represents the possible results and errors that can occur when the player moves.
In each branch of MoveResult, GameOver indicates that the game is complete and takes a ColorCount parameter, which represents the number of black and white pieces on the game board.
The Motoko source code automatically generates a Candid file for each container and is used automatically by the JS user library without developer involvement:
On the Motoko side, each Candid type corresponds to a Motoko type, and each method corresponds to a public function.
On the JS side, each Candid type corresponds to a JSON object, and each method corresponds to a member function of the imported container object.
Most Candid types have a direct JS representation, some require some conversion.
For example, nat is arbitrary precision in both Motoko and Candid, and in JS it is mapped to a bignumber.js integer, so it must be converted to a JS native number type using n.toNumber().
One problem I'm having is with null values in Candid (and Motoko's Option type).
It is represented in JSON as an empty array [] instead of its native null. This is to distinguish cases where we have nested options, such as Option>:
Candid is very powerful, even though on the surface it sounds a lot like Protocolbuf or JSON.
So why is it necessary?
There are many good reasons beyond what's presented here, and I encourage anyone interested in this topic to read the Candid Spec.
Syncing the game state with the backend
As mentioned before, I use a trick to react immediately to valid user input without having to wait for the backend game server to respond.
This means that the frontend only needs confirmation from the game server after a player move (or error handling, if any).
In addition to sending its own moves, the client must also be aware of the other player's moves.
This is achieved by periodically calling the view() function of the game container hosted on the server side.
The implication of this design is that I have to duplicate some of the same game logic in both the backend (Motoko) and the frontend (JS), which is not ideal.
Since Motoko can compile to Wasm, and Wasm can run in the browser, wouldn’t it be great if both the frontend and backend could share the same Wasm module that implements the core game logic? This sharing only shares the code, not the state.
It might require some setup, but I think it's entirely possible and I might try it in a later update.
Especially for reverse play, there are situations where one player may be prevented from taking any actions, so the other player can take two consecutive actions or even more.
In order to show every move made by the player, I chose to implement the game state as a sequence of actions rather than just the latest state of the game board.
This also means that by comparing the list of actions in the frontend's local state with what was returned by calling the view() function, we can easily tell what has changed since the player's last action (when it is that player's turn to make the next move), etc.
SVG Animation
The topic of animation with Scalable Vector Graphics (SVG) probably doesn’t belong in this article, but I really struggled with this one once.
So I want to share the lessons I learned.
The problem I'm having is that when I use the repeatCount setting to only show the animation once, the animation doesn't start.
Most online resources on SVG only provide examples that either repeat infinitely or use a repeatCount setting.
They implicitly assume that if the animation is to be shown only once, then start the animation after the page has loaded (or set some delay).
However, for most one-page application frameworks (like React or Mithril), the page is usually not reloaded, but just re-rendered.
So when I want to show a game clip flipping from white to black or black to white, it has to happen when the page re-renders, not when the page reloads.
I missed this crucial distinction and only discovered it after trying many times.
So this is how I render an animated element (as a child of an SVG) using Mithril where the rx of an ellipse changes from the initial radius to 0 and back.
The explanation is as follows:
begin is set to indeterminate so that the animation can be controlled/started manually
Fill is set to frozen, which means that after the animation ends, its end state will remain unchanged.
The value is set to 4 values, where the first two are repeated as a trick to start the animation after a delay of 0.1s (1/4 of the duration), this is because begin has been set to indefinite
The main point is that the animation should be started manually. I use setTimeout to trigger it with a 0s delay, which is a trick to wait until the new UI element prepared by Mithril is rendered in the browser DOM:
As mentioned above, any animated element whose ID does not begin with "dot" will start immediately.
Development Process
I developed the game on Linux, and the initial setup consisted of installing the DFINITY SDK and following its instructions to create a project.
It's cumbersome to remember all the dfx command lines, so I made a Makefile to help.
Debugging and testing is mostly done in the browser, so lots of console.log() is required.
There is actually a way to write unit tests in Motoko, but I learned about it only after I wrote the game.
Initially, I also developed a terminal based front end using shell scripts and dfx.
I think this would help speed up debugging without having to go through the browser.
But of course, unit testing is a better way to ensure correctness.
play games!
To actually run this game on the Internet Computer, there is now a Tungsten Network that is open to third-party developers.
I encourage you to sign up, clone this project, and deploy the game yourself to get first-hand developer experience.
But apps on Tungsten are not accessible to non-developers because it is not yet public.
So I also hosted it myself using dfx and nginx as a reverse proxy so that I could invite friends to play.
I don't encourage people to do this themselves as the software is still in alpha.
Here is a link to the actual game, it is for demonstration purposes only. My plan is to deploy it on the public internet computer network once it launches later this year.
If you have any questions, feel free to visit the project repository and submit issues, pull requests are also welcome!
Join our developer community and start building at forum.dfinity.org.
IC content you care about
Technology Progress | Project Information | Global Activities
Collect and follow IC Binance Channel
Get the latest news