Contents

1. Introduction
2. The main loop
3. Locations
4. Objects
5. Inventory
6. Passages
7. Distance
8. North, east, south, west
9. Code generation
10. More attributes
11. Conditions
12. Open and close
13. The parser
14. Multiple nouns
15. Light and dark
16. Savegame
17. Test automation
18. Abbreviations
19. Conversations
20. Combat
21. Multi-player
22. Client-server
23. Database
24. Speech
25. JavaScript

How to program a text adventure in C

by Ruud Helderman <r.helderman@hccnet.nl>

Licensed under MIT License

21. Multi-player

It wasn’t long after the first text adventures appeared, that somebody came with the idea to make it a multi-user event. MUDs flourished on university networks, then gradually found their way to home users through BBSs. Later, widespread internet access paved the way for MMOs, but by that time, attention had already shifted from text-based gaming to 3D graphics. Sure, the whole world is texting, but even among gamers, few are aware you can use that for role-playing.

Multi-player is a rather broad term. It could stand for:

  1. Having multiple player characters in the same game.
  2. Having multiple users together playing within the same game session.

In most cases, both apply, and there will be a one-on-one relationship, i.e. every user controls one player character. But it doesn’t have to be this way: it is very well possible to make a game where a single user is controlling multiple player characters. That may not be everybody’s idea of fun, but technically, these are separate dimensions, largely independent of each other. And since there’s plenty of work to be done, it makes sense to divide it across two chapters. Below, we will introduce the concept of having more than one player character, while at the same time keeping the game a single-user experience. In the next chapter, we will make the game truly multi-user.

In its simplest form, multiple player characters means the user will be given the opportunity to switch between characters, controlling them one at a time. Imagine we have two player characters: Jack and Jill. We could introduce a command ‘play’ that allows the user to take control of either one of those characters. The following four commands would then make Jack pick up the club, and Jill pick up the dagger.

play jack get club play jill get dagger

Multiple player characters could be a nice opportunity to have puzzles in your game that can only be solved by having the characters cooperate in a certain way. For example, one character could be used to distract or lure away the guard, while the other character sneaks into the cave. Puzzles might also demand the user to carefully think about each character’s strengths and weaknesses.

It won’t be until the next chapter that we will benefit from the social aspect of multi-player: being able to really interact (remotely) with other human beings. But since we are expanding our vocabulary anyway (the verb play), we might as well take the opportunity to introduce some ‘social’ commands.

Let’s start by adding the new verbs to our vocabulary.

Sample output
Welcome to Little Cave Adventure.
You are in the waiting room.
You see:
walls all around you

--> play jack
You are Jack. Jack is a fearsome warrior.

--> look around
You are in an open field.
You see:
a silver coin
a burly guard
Jill
a cave entrance to the east
dense forest all around
a lamp
a wooden club
a dagger

--> get club
You pick up a wooden club.

--> play jill
You are Jill. Jill is a vicious valkyrie.

--> look around
You are in an open field.
You see:
a silver coin
a burly guard
Jack
a cave entrance to the east
dense forest all around
a lamp
a dagger

--> say Where am I?
You say: Where am I?

--> whisper to jack I need a weapon.
You whisper to Jack: I need a weapon.

--> get dagger
You pick up a dagger.

--> emote assume a fighting stance.
You assume a fighting stance.

--> attack guard
You hit a burly guard with a dagger.
You are hit by a burly guard with bare hands.

--> play jill
You already are Jill.

--> play jack
You are Jack. Jack is a fearsome warrior.

--> attack guard
You hit a burly guard with a wooden club.

--> quit

Bye!
parsexec.c
  1. #include <ctype.h>
  2. #include <stdbool.h>
  3. #include <stdio.h>
  4. #include "object.h"
  5. #include "misc.h"
  6. #include "match.h"
  7. #include "location.h"
  8. #include "inventory.h"
  9. #include "inventory2.h"
  10. #include "openclose.h"
  11. #include "onoff.h"
  12. #include "talk.h"
  13. #include "attack.h"
  14. #include "social.h"
  15. typedef struct
  16. {
  17. const char *pattern;
  18. int (*function)(void);
  19. } COMMAND;
  20. static int executeQuit(void)
  21. {
  22. return -1;
  23. }
  24. static int executeNoMatch(void)
  25. {
  26. const char *src = *params;
  27. int len;
  28. for (len = 0; src[len] != '\0' && !isspace(src[len]); len++);
  29. if (len > 0) printf("I don't know how to '%.*s'.\n", len, src);
  30. return 0;
  31. }
  32. static int executeWait(void)
  33. {
  34. printf("Some time passes...\n");
  35. return 1;
  36. }
  37. int parseAndExecute(const char *input)
  38. {
  39. static const COMMAND commands[] =
  40. {
  41. { "quit" , executeQuit },
  42. { "look" , executeLookAround },
  43. { "look around" , executeLookAround },
  44. { "look at A" , executeLook },
  45. { "look A" , executeLook },
  46. { "examine A" , executeLook },
  47. { "go to A" , executeGo },
  48. { "go A" , executeGo },
  49. { "get A from B" , executeGetFrom },
  50. { "get A" , executeGet },
  51. { "put A in B" , executePutIn },
  52. { "drop A in B" , executePutIn },
  53. { "drop A" , executeDrop },
  54. { "ask A from B" , executeAskFrom },
  55. { "ask A" , executeAsk },
  56. { "give A to B" , executeGiveTo },
  57. { "give A" , executeGive },
  58. { "inventory" , executeInventory },
  59. { "open A" , executeOpen },
  60. { "close A" , executeClose },
  61. { "lock A" , executeLock },
  62. { "unlock A" , executeUnlock },
  63. { "turn on A" , executeTurnOn },
  64. { "turn off A" , executeTurnOff },
  65. { "turn A on" , executeTurnOn },
  66. { "turn A off" , executeTurnOff },
  67. { "talk with B about A" , executeTalkTo },
  68. { "talk about A with B" , executeTalkTo },
  69. { "talk about A" , executeTalk },
  70. { "talk A" , executeTalk },
  71. { "attack with B" , executeAttack },
  72. { "attack A with B" , executeAttack },
  73. { "attack A" , executeAttack },
  74. { "wait" , executeWait },
  75. { "play A" , executePlay },
  76. { "emote A" , executeEmote },
  77. { "say A" , executeSay },
  78. { "whisper to B A" , executeWhisper },
  79. { "A" , executeNoMatch }
  80. };
  81. const COMMAND *cmd;
  82. for (cmd = commands; !matchCommand(input, cmd->pattern); cmd++);
  83. return (*cmd->function)();
  84. }

The implementation of the new commands is extremely straightforward. Of course, the ‘social’ commands are not very useful yet. Being the only user, you are basically talking to yourself. But it does give us the foundation to actually socialize in the next chapter.

social.h
  1. extern int executePlay(void);
  2. extern int executeEmote(void);
  3. extern int executeSay(void);
  4. extern int executeWhisper(void);
social.c
  1. #include <stdbool.h>
  2. #include <stdio.h>
  3. #include "object.h"
  4. #include "match.h"
  5. #include "noun.h"
  6. #include "reach.h"
  7. #include "location.h"
  8. int executePlay(void)
  9. {
  10. OBJECT *obj = getTopic(params[0]);
  11. if (obj == NULL)
  12. {
  13. printf("I don't understand what character you want to play.\n");
  14. }
  15. else if (obj == player)
  16. {
  17. printf("You already are %s.\n", player->description);
  18. }
  19. else if (obj == jack || obj == jill)
  20. {
  21. player = obj;
  22. printf("You are %s. %s\n", player->description, player->details);
  23. }
  24. else
  25. {
  26. printf("That is not a character you can play.\n");
  27. }
  28. return 0;
  29. }
  30. int executeEmote(void)
  31. {
  32. const char *phrase = params[0];
  33. if (*phrase == '\0')
  34. {
  35. printf("I don't understand what you want to emote.\n");
  36. }
  37. else
  38. {
  39. printf("You %s\n", phrase);
  40. }
  41. return 0;
  42. }
  43. int executeSay(void)
  44. {
  45. const char *phrase = params[0];
  46. if (*phrase == '\0')
  47. {
  48. printf("I don't understand what you want to say.\n");
  49. }
  50. else
  51. {
  52. printf("You say: %s\n", phrase);
  53. }
  54. return 0;
  55. }
  56. int executeWhisper(void)
  57. {
  58. OBJECT *to = reachableObject("who to whisper to", params[1]);
  59. if (to != NULL)
  60. {
  61. const char *phrase = params[0];
  62. if (*phrase == '\0')
  63. {
  64. printf("I don't understand what you want to whisper.\n");
  65. }
  66. else
  67. {
  68. printf("You whisper to %s: %s\n", to->description, phrase);
  69. }
  70. }
  71. return 0;
  72. }

Explanation:

However, we cannot just assign a new value to player (see line 22 above) without turning it into a variable.

object.awk
  1. BEGIN {
  2. count = 0;
  3. obj = "";
  4. if (pass == "c2") {
  5. print "\nstatic bool alwaysTrue(void) { return true; }";
  6. print "\nOBJECT objs[] = {";
  7. }
  8. }
  9. /^- / {
  10. outputRecord(",");
  11. obj = $2;
  12. prop["condition"] = "alwaysTrue";
  13. prop["description"] = "NULL";
  14. prop["tags"] = "";
  15. prop["location"] = "NULL";
  16. prop["destination"] = "NULL";
  17. prop["prospect"] = "";
  18. prop["details"] = "\"You see nothing special.\"";
  19. prop["contents"] = "\"You see\"";
  20. prop["textGo"] = "\"You can't get much closer than this.\"";
  21. prop["gossip"] = "\"I know nothing about that.\"";
  22. prop["weight"] = "99";
  23. prop["capacity"] = "0";
  24. prop["health"] = "0";
  25. prop["light"] = "0";
  26. prop["impact"] = "0";
  27. prop["trust"] = "0";
  28. prop["open"] = "cannotBeOpened";
  29. prop["close"] = "cannotBeClosed";
  30. prop["lock"] = "cannotBeLocked";
  31. prop["unlock"] = "cannotBeUnlocked";
  32. }
  33. obj && /^[ \t]+[a-z]/ {
  34. name = $1;
  35. $1 = "";
  36. if (name in prop) {
  37. prop[name] = $0;
  38. if (/^[ \t]*\{/) {
  39. prop[name] = name count;
  40. if (pass == "c1") print "static bool " prop[name] "(void) " $0;
  41. }
  42. }
  43. else if (pass == "c2") {
  44. print "#error \"" FILENAME " line " NR ": unknown attribute '" name "'\"";
  45. }
  46. }
  47. !obj && pass == (/^#include/ ? "c1" : "h") {
  48. print;
  49. }
  50. END {
  51. outputRecord("\n}, *player = nobody;");
  52. if (pass == "h") {
  53. print "\n#define endOfObjs\t(objs + " count ")";
  54. print "\n#define validObject(obj)\t" \
  55. "((obj) != NULL && (*(obj)->condition)())";
  56. }
  57. }
  58. function outputRecord(separator)
  59. {
  60. if (obj) {
  61. if (pass == "h") {
  62. print "#define " obj "\t(objs + " count ")";
  63. }
  64. else if (pass == "c1") {
  65. print "static const char *tags" count "[] = {" prop["tags"] ", NULL};";
  66. }
  67. else if (pass == "c2") {
  68. print "\t{\t/* " count " = " obj " */";
  69. print "\t\t" prop["condition"] ",";
  70. print "\t\t" prop["description"] ",";
  71. print "\t\ttags" count ",";
  72. print "\t\t" prop["location"] ",";
  73. print "\t\t" prop["destination"] ",";
  74. print "\t\t" prop[prop["prospect"] ? "prospect" : "destination"] ",";
  75. print "\t\t" prop["details"] ",";
  76. print "\t\t" prop["contents"] ",";
  77. print "\t\t" prop["textGo"] ",";
  78. print "\t\t" prop["gossip"] ",";
  79. print "\t\t" prop["weight"] ",";
  80. print "\t\t" prop["capacity"] ",";
  81. print "\t\t" prop["health"] ",";
  82. print "\t\t" prop["light"] ",";
  83. print "\t\t" prop["impact"] ",";
  84. print "\t\t" prop["trust"] ",";
  85. print "\t\t" prop["open"] ",";
  86. print "\t\t" prop["close"] ",";
  87. print "\t\t" prop["lock"] ",";
  88. print "\t\t" prop["unlock"];
  89. print "\t}" separator;
  90. delete prop;
  91. }
  92. count++;
  93. }
  94. }

Explanation:

Up until now, player has been a fixed object. We will get rid of that object, and instead introduce ‘named’ character objects jack and jill.

object.txt
  1. #include <stdbool.h>
  2. #include <stdio.h>
  3. #include "object.h"
  4. #include "toggle.h"
  5. typedef struct object {
  6. bool (*condition)(void);
  7. const char *description;
  8. const char **tags;
  9. struct object *location;
  10. struct object *destination;
  11. struct object *prospect;
  12. const char *details;
  13. const char *contents;
  14. const char *textGo;
  15. const char *gossip;
  16. int weight;
  17. int capacity;
  18. int health;
  19. int light;
  20. int impact;
  21. int trust;
  22. void (*open)(void);
  23. void (*close)(void);
  24. void (*lock)(void);
  25. void (*unlock)(void);
  26. } OBJECT;
  27. extern OBJECT *player, objs[];
  28. - heaven
  29. description "little heaven"
  30. tags "heaven", "little heaven"
  31. details "Everything looks so peaceful here."
  32. gossip "It's where all the good adventurers go."
  33. capacity 9999
  34. light 100
  35. - respawn
  36. description "a respawn portal"
  37. tags "portal", "respawn portal"
  38. location heaven
  39. destination field
  40. details "Looks like a gateway into the unknown."
  41. textGo "A bright flash of light, and you are back in the field."
  42. open isAlreadyOpen
  43. - heavenEWNS
  44. description "nothing but peace and quiet"
  45. tags "east", "west", "north", "south"
  46. location heaven
  47. textGo "In this dimension, there are no directions."
  48. gossip "It's just a compass direction."
  49. - waitingRoom
  50. description "the waiting room"
  51. tags "room", "waiting room"
  52. details "This is where everybody starts. Please enter: play YourName."
  53. gossip "It's where all the bad adventurers have to stay."
  54. capacity 9999
  55. light 100
  56. - waitingRoomWall
  57. description "walls all around you"
  58. tags "wall", "east", "west", "north", "south"
  59. location waitingRoom
  60. details "Written on the wall, it says: play Jack or play Jill."
  61. textGo "You cannot go anywhere."
  62. - nobody
  63. description "nobody"
  64. tags "nobody"
  65. location waitingRoom
  66. details "You are nobody. Please enter: play xxx"
  67. contents "You have"
  68. health 100
  69. capacity 1
  70. - field
  71. description "an open field"
  72. tags "field"
  73. details "The field is a nice and quiet place under a clear blue sky."
  74. gossip "A lot of tourists go there."
  75. capacity 9999
  76. light 100
  77. - cave
  78. description "a little cave"
  79. tags "cave"
  80. details "The cave is just a cold, damp, rocky chamber."
  81. gossip "It's dark in there; bring a lamp!"
  82. capacity 9999
  83. - silver
  84. description "a silver coin"
  85. tags "silver", "coin", "silver coin"
  86. location field
  87. details "The coin has an eagle on the obverse."
  88. gossip "Money makes the world go round..."
  89. weight 1
  90. - gold
  91. description "a gold coin"
  92. tags "gold", "coin", "gold coin"
  93. location openBox
  94. details "The shiny coin seems to be a rare and priceless artefact."
  95. gossip "Money makes the world go round..."
  96. weight 1
  97. - guard
  98. description "a burly guard"
  99. tags "guard", "burly guard"
  100. location field
  101. details "The guard is a really big fellow."
  102. gossip "Easy to bribe..."
  103. contents "He has"
  104. health 100
  105. impact -1
  106. capacity 20
  107. - jack
  108. description "Jack"
  109. tags "jack", "warrior", "man"
  110. location field
  111. details "Jack is a fearsome warrior."
  112. gossip "Jack is a fearsome warrior."
  113. contents "Jack has"
  114. health 100
  115. impact -1
  116. capacity 20
  117. - jill
  118. description "Jill"
  119. tags "jill", "valkyrie", "woman"
  120. location field
  121. details "Jill is a vicious valkyrie."
  122. gossip "Jill is a vicious valkyrie."
  123. contents "Jill has"
  124. health 100
  125. impact -1
  126. capacity 20
  127. - intoCave
  128. condition { return guard->health == 0 || silver->location == guard; }
  129. description "a cave entrance to the east"
  130. tags "east", "entrance"
  131. location field
  132. destination cave
  133. details "The entrance is just a narrow opening in a small outcrop."
  134. textGo "You walk into the cave."
  135. open isAlreadyOpen
  136. - intoCaveBlocked
  137. condition { return guard->health > 0 && silver->location != guard; }
  138. description "a cave entrance to the east"
  139. tags "east", "entrance"
  140. location field
  141. prospect cave
  142. details "The entrance is just a narrow opening in a small outcrop."
  143. textGo "The guard stops you from walking into the cave."
  144. open isAlreadyOpen
  145. - exitCave
  146. description "an exit to the west"
  147. tags "west", "exit"
  148. location cave
  149. destination field
  150. details "Sunlight pours in through an opening in the cave's wall."
  151. textGo "You walk out of the cave."
  152. open isAlreadyOpen
  153. - wallField
  154. description "dense forest all around"
  155. tags "west", "north", "south", "forest"
  156. location field
  157. details "The field is surrounded by trees and undergrowth."
  158. textGo "Dense forest is blocking the way."
  159. gossip "You cannot go there, it is impenetrable."
  160. - wallCave
  161. description "solid rock all around"
  162. tags "east", "north", "rock"
  163. location cave
  164. details "Carved in stone is a secret password 'abccb'."
  165. textGo "Solid rock is blocking the way."
  166. - backroom
  167. description "a backroom"
  168. tags "backroom"
  169. details "The room is dusty and messy."
  170. gossip "There is something of value to be found there."
  171. capacity 9999
  172. - wallBackroom
  173. description "solid rock all around"
  174. tags "east", "west", "south", "rock"
  175. location backroom
  176. details "Trendy wallpaper covers the rock walls."
  177. textGo "Solid rock is blocking the way."
  178. - openDoorToBackroom
  179. description "an open door to the south"
  180. tags "south", "door", "doorway"
  181. destination backroom
  182. details "The door is open."
  183. textGo "You walk through the door into a backroom."
  184. open isAlreadyOpen
  185. close toggleDoorToBackroom
  186. - closedDoorToBackroom
  187. description "a closed door to the south"
  188. tags "south", "door", "doorway"
  189. location cave
  190. prospect backroom
  191. details "The door is closed."
  192. textGo "The door is closed."
  193. open toggleDoorToBackroom
  194. close isAlreadyClosed
  195. - openDoorToCave
  196. description "an open door to the north"
  197. tags "north", "door", "doorway"
  198. destination cave
  199. details "The door is open."
  200. textGo "You walk through the door into the cave."
  201. open isAlreadyOpen
  202. close toggleDoorToCave
  203. - closedDoorToCave
  204. description "a closed door to the north"
  205. tags "north", "door", "doorway"
  206. location backroom
  207. prospect cave
  208. details "The door is closed."
  209. textGo "The door is closed."
  210. open toggleDoorToCave
  211. close isAlreadyClosed
  212. - openBox
  213. description "a wooden box"
  214. tags "box", "wooden box"
  215. details "The box is open."
  216. gossip "You need a key to open it."
  217. weight 5
  218. capacity 10
  219. open isAlreadyOpen
  220. close toggleBox
  221. lock isStillOpen
  222. unlock isAlreadyOpen
  223. - closedBox
  224. description "a wooden box"
  225. tags "box", "wooden box"
  226. details "The box is closed."
  227. weight 5
  228. open toggleBox
  229. close isAlreadyClosed
  230. lock toggleBoxLock
  231. unlock isAlreadyUnlocked
  232. - lockedBox
  233. description "a wooden box"
  234. tags "box", "wooden box"
  235. location backroom
  236. details "The box is closed."
  237. weight 5
  238. open isStillLocked
  239. close isAlreadyClosed
  240. lock isAlreadyLocked
  241. unlock toggleBoxLock
  242. - keyForBox
  243. description "a tiny key"
  244. tags "key", "tiny key"
  245. location cave
  246. details "The key is really small and shiny."
  247. gossip "A small key opens a small lock."
  248. weight 1
  249. - lampOff
  250. description "a lamp"
  251. tags "lamp"
  252. location field
  253. details "The lamp is off."
  254. gossip "Essential in dark areas."
  255. weight 5
  256. - lampOn
  257. description "a lamp"
  258. tags "lamp"
  259. details "The lamp is on."
  260. weight 5
  261. light 100
  262. - club
  263. description "a wooden club"
  264. tags "club", "wooden club"
  265. location field
  266. details "Two feet of solid wood."
  267. weight 5
  268. impact -2
  269. - dagger
  270. description "a dagger"
  271. tags "dagger"
  272. location field
  273. details "The dagger is very sharp."
  274. weight 7
  275. impact -5

Explanation:

Traditionally, descriptive text about the player is always in second person, e.g. “You are in an open field.” But with multiple player characters, that is no longer the case. Second person is still appropriate for the player character that is currently under the user’s control, but any other player characters you meet, should be referenced in third person.

This is already giving trouble in command inventory. The game’s response has always started with “You have”, as specified by attribute contents of the player object. But for Jack and Jill, things are not so straightforward; contents is used not only to inspect yourself (inventory), but also to inspect someone else (e.g. Jill enters: examine Jack).

We will just keep it simple for now: if the object being inspected is currently under the user’s control, then we will output a hardcoded “You have” rather than attribute contents.

misc.c
  1. #include <stdbool.h>
  2. #include <stdio.h>
  3. #include "object.h"
  4. #include "misc.h"
  5. bool isHolding(OBJECT *container, OBJECT *obj)
  6. {
  7. return validObject(obj) && obj->location == container;
  8. }
  9. bool isLit(OBJECT *target)
  10. {
  11. OBJECT *obj;
  12. if (validObject(target))
  13. {
  14. if (target->light > 0)
  15. {
  16. return true;
  17. }
  18. for (obj = objs; obj < endOfObjs; obj++)
  19. {
  20. if (validObject(obj) && obj->light > 0 &&
  21. (isHolding(target, obj) || isHolding(target, obj->location)))
  22. {
  23. return true;
  24. }
  25. }
  26. }
  27. return false;
  28. }
  29. static bool isNoticeable(OBJECT *obj)
  30. {
  31. return obj->location == player ||
  32. isLit(obj) || isLit(obj->prospect) || isLit(player->location);
  33. }
  34. OBJECT *getPassage(OBJECT *from, OBJECT *to)
  35. {
  36. if (from != NULL && to != NULL)
  37. {
  38. OBJECT *obj;
  39. for (obj = objs; obj < endOfObjs; obj++)
  40. {
  41. if (isHolding(from, obj) && obj->prospect == to)
  42. {
  43. return obj;
  44. }
  45. }
  46. }
  47. return NULL;
  48. }
  49. DISTANCE getDistance(OBJECT *from, OBJECT *to)
  50. {
  51. return to == NULL ? distUnknownObject :
  52. !validObject(to) ? distNotHere :
  53. to == from ? distSelf :
  54. isHolding(from, to) ? distHeld :
  55. !isNoticeable(to) ? distNotHere :
  56. isHolding(to, from) ? distLocation :
  57. isHolding(from->location, to) ? distHere :
  58. isHolding(from, to->location) ? distHeldContained :
  59. isHolding(from->location, to->location) ? distHereContained :
  60. getPassage(from->location, to) != NULL ? distOverthere :
  61. distNotHere;
  62. }
  63. OBJECT *actorHere(void)
  64. {
  65. OBJECT *obj;
  66. for (obj = objs; obj < endOfObjs; obj++)
  67. {
  68. if (isHolding(player->location, obj) && obj != player &&
  69. isNoticeable(obj) && obj->health > 0)
  70. {
  71. return obj;
  72. }
  73. }
  74. return NULL;
  75. }
  76. int listObjectsAtLocation(OBJECT *location)
  77. {
  78. int count = 0;
  79. OBJECT *obj;
  80. for (obj = objs; obj < endOfObjs; obj++)
  81. {
  82. if (obj != player && isHolding(location, obj) && isNoticeable(obj))
  83. {
  84. if (count++ == 0)
  85. {
  86. printf("%s:\n", location == player ? "You have"
  87. : location->contents);
  88. }
  89. printf("%s\n", obj->description);
  90. }
  91. }
  92. return count;
  93. }

This was all fairly simple; it did not require a whole lot of source changes. In the next chapter, we will be writing a lot more code, as we are diving into the wonderful world of socket programming.


⭳   Download source code 🌀   Run on Repl.it

Next chapter: 22. Client-server