master
chimchooree 2 years ago
parent a7227d93b0
commit 7b391b84ad

1
.gitignore vendored

@ -3,6 +3,5 @@ venv/
*.gif~ *.gif~
*.jpeg~ *.jpeg~
*.jpeg~ *.jpeg~
*.kra
*.kra~ *.kra~

@ -3,17 +3,16 @@
august 31, 2022<br> august 31, 2022<br>
#webdev <br> #webdev <br>
<br> <br>
<p>Follow along to make your first mini WebSocket application in GDScript (with a little JSON). The client and the server will be two separate projects. I build upon the <a href="http://www.narwalengineering.com/2018/07/01/godot-tutorial-simple-chat-room-using-multiplayer-api/">NetworkedMultiplayerENet chat room tutorial by Miziziziz</a>(dead link) and the <a href="https://docs.godotengine.org/en/stable/tutorials/networking/websocket.html">HTML5 and WebSocket tutorial in the Godot documentation</a>. </p> <p>Follow along to make your first mini WebSocket application in GDScript (with a little JSON). The client and the server will be two separate projects. I build upon the <a href="http://web.archive.org/web/20220928131714/http://www.narwalengineering.com/2018/07/01/godot-tutorial-simple-chat-room-using-multiplayer-api/">NetworkedMultiplayerENet chat room tutorial by Miziziziz</a>(archive link) and the <a href="https://docs.godotengine.org/en/stable/tutorials/networking/websocket.html">HTML5 and WebSocket tutorial in the Godot documentation</a>. </p>
<hr> <hr>
<h2>Why WebSocket over UDP? </h2> <h2>Why WebSocket over UDP? </h2>
<p>UDP is fast but inaccurate. It is best used for real-time action gameplay. </p> <p>UDP is fast but inaccurate. It is best used for real-time action gameplay. TCP is slow but accurate. It is best for sharing data. You can read more about it on the <a href="https://docs.godotengine.org/en/stable/tutorials/networking/high_level_multiplayer.html">Multiplayer doc page</a>. </p>
<p>TCP is slow but accurate. It is best for sharing data. </p> <p>WebSocket uses a TCP connection. Ultimately, I am studying Godot to make a slow-paced adventure browser game, so this is one of the protocols I am considering and the protocol we will use for the tutorial. </p>
<p>In Godot, the TCP stuff is actually UDP. If you want TCP, you should use WebSocket. </p>
<h2>Do I need a dedicated server for testing? </h2> <h2>Do I need a dedicated server for testing? </h2>
<p>Nope! You can test client & server code on your own computer. I will test on both a single computer at home and a VPS on a server stationed in another country. </p> <p>Nope! You can test client & server code on your own computer. I am only testing on a single computer at home for now without accessing an outside VPS, etc. </p>
<hr> <hr>
@ -32,10 +31,9 @@ august 31, 2022<br>
<pre><code> <pre><code>
const PORT = 8080 const PORT = 8080
var _server = WebSocketServer.new() var _server = WebSocketServer.new()
var peers = []
</code></pre> </code></pre>
<p><code>PORT</code> is the port your applications will use. If you are having trouble connecting, try 9001, etc, until you find a free one. <code>peers</code> will let us access everyone currently connected to the server. </p> <p><code>PORT</code> is the port your applications will use. If you are having trouble connecting, try 9001, etc, until you find a free one. </p>
<h3>Build your skeleton. </h3> <h3>Build your skeleton. </h3>
<pre><code> <pre><code>
@ -107,7 +105,7 @@ func _process(_delta):
<img src="/static/img/ent/Chatbox_LabelSizeFlags.png" alt="(Screenshot: VBoxContainer's Size Flags are checked for Horizontal Fill and Expand and Vertical Fill and Expand.)"> <img src="/static/img/ent/Chatbox_LabelSizeFlags.png" alt="(Screenshot: VBoxContainer's Size Flags are checked for Horizontal Fill and Expand and Vertical Fill and Expand.)">
<p>For the ChatLog (TextEdit), check the Show Line Numbers and set Size Flags to Fill and Expand for both Horizontal and Vertical. </p> <p>For the ChatLog (TextEdit), check the Show Line Numbers and Wrap Enabled options then set Size Flags to Fill and Expand for both Horizontal and Vertical. </p>
<p>For the ChatInput (LineEdit), under Placeholder, set the Text to "Enter your message..." and the Size Flags to Fill and Expand Horizontally. </p> <p>For the ChatInput (LineEdit), under Placeholder, set the Text to "Enter your message..." and the Size Flags to Fill and Expand Horizontally. </p>
@ -210,10 +208,487 @@ func _process(_delta):
<p>But however you run, if you run the server first and then the client, you will see your connection in the debug output! You have completed an extremely basic networking application. You can see the full project at this stage on my repo: <a href="https://git.socklab.moe/chimchooree/chatserver/src/commit/95dba37181f2cfa37fe03fe495b1c4b520c91101">server</a> and <a href="https://git.socklab.moe/chimchooree/chatclient/src/commit/a70269e9bcde4cdc66115f14c631b0073cdea91e">client</a>. <p>But however you run, if you run the server first and then the client, you will see your connection in the debug output! You have completed an extremely basic networking application. You can see the full project at this stage on my repo: <a href="https://git.socklab.moe/chimchooree/chatserver/src/commit/95dba37181f2cfa37fe03fe495b1c4b520c91101">server</a> and <a href="https://git.socklab.moe/chimchooree/chatclient/src/commit/a70269e9bcde4cdc66115f14c631b0073cdea91e">client</a>.
<p>Now let's add some chat features. </p> <hr>
<h2>Communicating between Server + Client. </h2>
<p>If we can pass one more hurdle, writing the chat room code will barely even need a tutorial: passing information between the server and client. </p>
<h3>Writing packets in JSON. </h3>
<p>Information will be sent in packets. You can literally pass your chat message strings, etc, as packets without much touchup. Something like <code>put_packet("hey how's it going?".to_utf8())</code> will run, but even a little chat app will be passing lots of different data back and forth. JSON strings are freeform enough to fit any online application's needs. If you don't know JSON syntax, glance over the <a href="https://docs.godotengine.org/en/stable/classes/class_json.html">Godot documentation</a>. It's very close to the dictionary in most languages. </p>
<p>Some examples of packets we'll use are test packets, chat message packets, server message packets, user joining packets, and user leaving packets. I'm going to use the 'type' key to distinguish incoming packets. </p>
<p>My chat message packets look like this: </p>
<pre><code>{'type': 'chat_message', 'name': "Bell", 'message': "hey guys"}</code></pre>
<p>To send them, you need to convert them to JSON strings then convert those strings into UTF-8. You will have to do the reverse on the receiving end. Outgoing packets will look like </p>
<pre><code>
_client.get_peer(1).put_packet(JSON.print(
{'type': 'chat_message', 'name': "Bell", 'message': "hey guys"}).to_utf8()
)
</code></pre>
<p>while incoming messages will need extra preparation: </p>
<pre><code>
var json = JSON.parse(_client.get_peer(1).get_packet().get_string_from_utf8())
var packet = json.result
</code></pre>
<h3>JSON packets in action. </h3>
Let's build up the server's data reception method.
<pre><code>
Server.gd (Node)
func _on_data_received(id):
var original = _server.get_peer(id).get_packet().get_string_from_utf8()
var json = JSON.parse(original)
var packet = json.result
if typeof(packet) != 18:
push_error("%s is not a dictionary" % [packet])
get_tree().quit()
print_debug("Got data from client %d: %s" % [id, packet])
</code></pre>
<p>We don't have to send the <code>id</code> argument manually. The server will understand the source of incoming data automatically. <code>_server.get_peer(id)</code> gives us the source. <code>_server.get_peer(id).get_packet()</code> gives us the packet they are sending. </p>
<p><code>typeof</code> returns 18 for dictionaries. You can see all the types and their enum values in <a href="https://docs.godotengine.org/en/stable/classes/class_@globalscope.html">Godot's @GlobalScope documentation</a>. Packets should <i>always</i> be dictionaries the way I'm writing this, so if anything else is received, the application will push an error and crash. Replace the code with a print_debug warning if you want, but I like the immediacy of a crash. </p>
<p>For now, it will print any valid packet's contents.</p>
<p>The client's data reception method should be similar:</p>
<pre><code>
Client.gd (Node)
func _on_data_received():
var json = JSON.parse(_client.get_peer(1).get_packet().get_string_from_utf8())
var packet = json.result
if typeof(packet) != 18:
push_error("%s is not a dictionary" % [packet])
get_tree().quit()
print_debug("Got data from server: ", packet)
</code></pre>
<p>Let's add a few put_packets to receive, too. The _connected methods on the server and client are the easiest places to test. <p>
<pre><code>
Server.gd (Node)
func _connected(id, protocol):
print_debug("Client %d connected with protocol: %s"
% [id, protocol])
_server.get_peer(id).put_packet(JSON.print(
{'type': 'test', 'message': "test packet from server"}).to_utf8())
</code></pre>
<pre><code>
Client.gd (Node)
func _connected(protocol = ""):
print_debug("Connected with protocol: ", protocol)
_client.get_peer(1).put_packet(JSON.print(
{'type': 'test', 'message': "test packet from client"}).to_utf8())
</code></pre>
<p>Let's see if the server and client can share data now. </p>
<img src="/static/img/ent/Chatbox_ClientCommunicating.png" alt="(Screenshot: Godot's debug info echos both test packets sent from the server and client.)">
<p>Boom. That's enough networking knowledge to get you started. You can see the full project at this stage on my repo: <a href="https://git.socklab.moe/chimchooree/chatserver/src/commit/bea0dd872d6a7aa7e6fd941f16f20d5a89d5e676">server</a> and <a href="https://git.socklab.moe/chimchooree/chatclient/src/commit/6f78065920d5109b98c4823920cfa5a5f4d70247">client</a>.</p>
<hr> <hr>
<h2>Writing that chat room. </h2>
<h3>Writing the UI. </h3>
<p>A chat room is driven by the UI, so UI.gd is the best place to start. The UI needs to let the user join, send messages, and see messages sent by the user and others. Anything beyond showing buttons and letting the user type into fields is beyond the scope of the UI and will be passed to the root node. </p>
<img src="/static/img/ent/Chatbox_Hide.png" alt="(Screenshot: The ChatInput is hidden at the start.)">
<pre><code>
UI.gd (PanelContainer)
func display_message(packet):
if packet['type'] == "chat_message":
chat_log.text += packet['name'] + ": " + packet['message'] + "\n"
elif packet.has("message"):
chat_log.text += packet['message'] + "\n"
func join_chat():
if !username.text:
display_message(
{'type': "alert",
"message": "!!! Enter your username before joining chat."}
)
else:
get_parent().join_chat(username.text)
chat_input.show()
join_button.hide()
username.hide()
func _input(event):
if event is InputEventKey and chat_input.text:
if event.pressed and event.scancode == KEY_ENTER:
get_parent().send_message(chat_input.text)
chat_input.text = ""
func _ready():
join_button.connect("button_up", self, "join_chat")
</code></pre>
<p>Also hide the ChatInput by clicking the eye on the node tree. </p>
<p>The <code>display_message</code> method will lightly format incoming chat messages and messages from other sources then show them as a line in the chat log. If the user clicks the join button but left the username field blank, an alert only visible to the user will be shown in the chat log. Otherwise, the join button and username field are hidden, and the chat input becomes available. <code>join_chat</code> is called off the parent, so the client can handle the rest. The <code>_input</code> method sends messages entered by the user to the root then clears the field for future messages. </p>
<h3>Writing the first part of the client's back end. </h3>
<pre><code>
Client.gd (Node)
export var websocket_url = "ws://127.0.0.1:8080"
var _client = WebSocketClient.new()
onready var UI = $UI
var chat_name
func join_chat(username):
chat_name = username
_client.get_peer(1).put_packet(JSON.print(
{'type': "user_joined", 'name': username}
).to_utf8()
)
func send_message(message):
_client.get_peer(1).put_packet(JSON.print(
{'type': 'chat_message', 'name': chat_name, 'message': message}
).to_utf8()
)
func _error(was_clean = false):
print_debug("Error, clean: ", was_clean)
set_process(false)
func _closed(was_clean = false):
print_debug("Closed, clean: ", was_clean)
set_process(false)
func _connected(protocol = ""):
print_debug("Connected with protocol: ", protocol)
_client.get_peer(1).put_packet(JSON.print(
{'type': 'test', 'message': "test packet from client"}
).to_utf8())
UI.join_button.show()
UI.username.show()
func _on_data_received():
var json = JSON.parse(
_client.get_peer(1).get_packet().get_string_from_utf8()
)
var packet = json.result
if typeof(packet) != 18:
push_error("%s is not a dictionary" % [packet])
get_tree().quit()
print_debug("Got data from server: ", packet)
</code></pre>
<p>When the user clicks the join button, the client saves the username and sends it to the server. We can distinguish this from chat_messages, etc, by marking it as a "user_joined" packet. The client also sends the username and chat message from the UI's <code>_input</code> to the server. Both errors and voluntary or involuntary disconnections will stop the process. That covers everything we need to <i>send</i> messages. We'll cover <i>receiving</i> messages after we finish the server. </p>
<h3>Writing the server. </h3>
<pre><code>
Server.gd (Node)
const PORT = 8080
var _server = WebSocketServer.new()
var IDs = {}
var peers = []
func _ready():
_server.connect('client_connected', self, "_connected")
_server.connect('client_disconnected', self, "_disconnected")
_server.connect('client_close_request', self, "_close_request")
_server.connect("data_received", self, "_on_data_received")
var err = _server.listen(PORT)
if err != OK:
print_debug("Unable to start server")
set_process(false)
print_debug("server started")
func _connected(id, protocol):
print_debug("Client %d connected with protocol: %s" % [id, protocol])
_server.get_peer(id).put_packet(JSON.print(
{'type': 'test', 'message': "Server test packet"}).to_utf8()
)
peers.append(id)
func _close_request(id, code, reason):
print_debug("Client %d disconnecting with code: %d, reason: %s"
% [id, code, reason])
func _disconnected(id, was_clean = false):
print_debug("Client %d disconnected, clean: %s"
% [id, str(was_clean)])
var user = IDs[id]
peers.erase(id)
for p in peers:
_server.get_peer(p).put_packet(JSON.print(
{'type': "server_message", "message": user
+ "(#" + str(id) + ") left the chat."}
).to_utf8())
func _on_data_received(id):
var original = _server.get_peer(id).get_packet().get_string_from_utf8()
var json = JSON.parse(original)
var packet = json.result
if typeof(packet) != 18:
push_error("%s is not a dictionary" % [packet])
get_tree().quit()
print_debug("Got data from client %d: %s ... echoing" % [id, packet])
if packet['type'] == "user_joined":
IDs[id] = packet['name']
for p in peers:
_server.get_peer(p).put_packet(
JSON.print(
{'type': "server_message", "message": packet['name']
+ "(#" + str(id) + ") joined the chat."}
).to_utf8())
elif packet['type'] == "chat_message" or packet['type'] == "server_message":
for p in peers:
_server.get_peer(p).put_packet(JSON.print(packet).to_utf8())
func _process(_delta):
_server.poll()
</code></pre>
<p>Upon a connection, the <code>_connected</code> method adds the id of the connection to the <code>peers</code> array. Upon a disconnection, it is erased. This way, you can access all users at any time by looping through the peers array. </p>
<p>The most dynamic method is <code>_on_data_received</code>, since it will react to a variety of packets. If it's a user_joined packet, it will save the id of his connection and his chosen username to the ID dictionary. Then the server will send a server_message to every connected client to announce the new member. If it is some kind of message, it will also send that to every client so everyone can see the same messages. </p>
<p>Lastly, upon a disconnection, the server will send a server_message to all clients about the departure. </p>
<p>Now let's make sure the client is ready to receive all these messages...and we'll be done! </p>
<h3>Finishing up the client. </h3>
<pre><code>
Client.gd (Node)
func receive_message(packet):
UI.display_message(packet)
func _on_data_received():
var json = JSON.parse(
_client.get_peer(1).get_packet().get_string_from_utf8()
)
var packet = json.result
if typeof(packet) != 18:
push_error("%s is not a dictionary" % [packet])
get_tree().quit()
print_debug("Got data from server: ", packet)
if packet['type'] == "chat_message" or packet['type'] == "server_message":
receive_message(packet)
</code></pre>
<p>If the client receives a chat_message or server_message from the server, it will call <code>receive_message</code>, which passes it to the UI for formatting and display. </p>
<p>Export both your server and client, so you can open lots of clients and fill your room with lots of people. Everyone will be able to see people joining, leaving, and chatting. Just like a real chat app! </p>
<img src="/static/img/ent/ChatClient_Conversation_1Kings22.png" alt="(Screenshot: A little chatplay using Jehoshaphat, Ahab, and Micaiah from 1 Kings 22.)">
<p>So the scrollbar covers some of the messages. And the scrolling is unusably annoying. The oldest messages should either disappear, or the chat should always be locked at the bottom on the latest messages. And there's no way to change your username without totally exiting and rejoining. And there must be some way to disable the pointless graphical window on the server. And there's probably a million more issues in addition to those. </p>
<p>But we're here to learn the basics, so we did an adequately good job. So good job anyway! Have fun improving from here and consider writing TCP guides of your own because UDP dominates the tutorial market. Later.^^ </p>
<hr>
<h2>Check over the finished scripts. </h2>
<p>You can see the finished project on my repo: <a href="https://git.socklab.moe/chimchooree/chatserver">server</a> and <a href="https://git.socklab.moe/chimchooree/chatclient">client</a>. </p>
<h3>Server side. </h3>
<pre><code>
Server.gd (Node)
extends Node
const PORT = 8080
var _server = WebSocketServer.new()
var IDs = {}
var peers = []
func _ready():
_server.connect('client_connected', self, "_connected")
_server.connect('client_disconnected', self, "_disconnected")
_server.connect('client_close_request', self, "_close_request")
_server.connect("data_received", self, "_on_data_received")
var err = _server.listen(PORT)
if err != OK:
print_debug("Unable to start server")
set_process(false)
print_debug("server started")
func _connected(id, protocol):
print_debug("Client %d connected with protocol: %s" % [id, protocol])
_server.get_peer(id).put_packet(JSON.print(
{'type': 'test', 'message': "Server test packet"}).to_utf8()
)
peers.append(id)
func _close_request(id, code, reason):
print_debug("Client %d disconnecting with code: %d, reason: %s"
% [id, code, reason])
func _disconnected(id, was_clean = false):
print_debug("Client %d disconnected, clean: %s"
% [id, str(was_clean)])
var user = IDs[id]
peers.erase(id)
for p in peers:
_server.get_peer(p).put_packet(JSON.print(
{'type': "server_message", "message": user
+ "(#" + str(id) + ") left the chat."}
).to_utf8())
func _on_data_received(id):
var original = _server.get_peer(id).get_packet().get_string_from_utf8()
var json = JSON.parse(original)
var packet = json.result
if typeof(packet) != 18:
push_error("%s is not a dictionary" % [packet])
get_tree().quit()
print_debug("Got data from client %d: %s ... echoing" % [id, packet])
if packet['type'] == "user_joined":
IDs[id] = packet['name']
for p in peers:
_server.get_peer(p).put_packet(
JSON.print(
{'type': "server_message", "message": packet['name']
+ "(#" + str(id) + ") joined the chat."}
).to_utf8())
elif packet['type'] == "chat_message" or packet['type'] == "server_message":
for p in peers:
_server.get_peer(p).put_packet(JSON.print(packet).to_utf8())
func _process(_delta):
_server.poll()
</code></pre>
<h3>Client side. </h3>
<pre><code>
Client.gd (Node)
extends Node
export var websocket_url = "ws://127.0.0.1:8080"
var _client = WebSocketClient.new()
onready var UI = $UI
var chat_name
func join_chat(username):
chat_name = username
_client.get_peer(1).put_packet(JSON.print(
{'type': "user_joined", 'name': username}
).to_utf8()
)
func send_message(message):
_client.get_peer(1).put_packet(JSON.print(
{'type': 'chat_message', 'name': chat_name, 'message': message}
).to_utf8()
)
func receive_message(packet):
UI.display_message(packet)
func _ready():
_client.connect("connection_closed", self, "_closed")
_client.connect("connection_error", self, "_error")
_client.connect("connection_established", self, "_connected")
_client.connect("data_received", self, "_on_data_received")
var err = _client.connect_to_url(websocket_url)
if err != OK:
print_debug("Unable to connect")
set_process(false)
func _error(was_clean = false):
print_debug("Error. Clean break? ", was_clean)
set_process(false)
func _closed(was_clean = false):
print_debug("Closed. Clean break? ", was_clean)
set_process(false)
func _connected(protocol = ""):
print_debug("Connected with protocol: ", protocol)
_client.get_peer(1).put_packet(JSON.print(
{'type': 'test', 'message': "test packet from client"}
).to_utf8())
UI.join_button.show()
UI.username.show()
func _on_data_received():
var json = JSON.parse(
_client.get_peer(1).get_packet().get_string_from_utf8()
)
var packet = json.result
if typeof(packet) != 18:
push_error("%s is not a dictionary" % [packet])
get_tree().quit()
print_debug("Got data from server: ", packet)
if packet['type'] == "chat_message" or packet['type'] == "server_message":
receive_message(packet)
func _process(_delta):
_client.poll()
</code></pre>
<hr>
<pre><code>
UI.gd (PanelContainer)
extends PanelContainer
onready var chat_log = $VBox/ChatLog
onready var chat_input = $VBox/ChatInput
onready var join_button = $VBox/HBox/JoinButton
onready var username = $VBox/HBox/Name
func display_message(packet):
if packet['type'] == "chat_message":
chat_log.text += packet['name'] + ": " + packet['message'] + "\n"
elif packet.has("message"):
chat_log.text += packet['message'] + "\n"
func join_chat():
if !username.text:
display_message(
{'type': "alert", "message": "!!! Enter your username before joining chat."}
)
else:
get_parent().join_chat(username.text)
chat_input.show()
join_button.hide()
username.hide()
func _input(event):
if event is InputEventKey and chat_input.text:
if event.pressed and event.scancode == KEY_ENTER:
get_parent().send_message(chat_input.text)
chat_input.text = ""
func _ready():
join_button.connect("button_up", self, "join_chat")
</code></pre>
<br> <br>
<p>Last updated August 31, 2022 </p> <p>Last updated February 26, 2022 </p>
<br> <br>

@ -341,7 +341,7 @@ def serve_img(filename):
@route('/favicon.ico', method='GET') @route('/favicon.ico', method='GET')
def get_favicon(): def get_favicon():
return static_file('favicon.ico', root='static/img/') return static_file('favicon.ico', root='static/img')
# Serve XML # Serve XML
@route('/static/xml/<filename:path>') @route('/static/xml/<filename:path>')

@ -78,6 +78,11 @@ h2 a:hover,h2 a:active {
h2 { h2 {
font-size: 1.5em; font-size: 1.5em;
} }
hr {
background-color: transparent;
margin-top: 2em;
margin-bottom: 2em;
}
p { p {
line-height: 2em; line-height: 2em;
margin-bottom: 1.5em; margin-bottom: 1.5em;
@ -94,10 +99,17 @@ p {
color: white; color: white;
} }
pre,code { pre,code {
background-color: transparent; background-color: #d5c7d5;
font-size: 0.9em;
margin-top: 1.5em;
margin-bottom: 1.5em;
padding-left: 0.5em;
padding-right: 0.5em;
padding-bottom: 0.1em;
} }
.code { img {
background-color: transparent; margin-top: 1.5em;
margin-bottom: 1.5em;
} }
.verse { .verse {
background-color: transparent; background-color: transparent;

@ -1,6 +1,10 @@
hr { hr {
border: 1px solid black; border: 1px solid black;
} }
img {
display: block;
text-align: center;
}
.content-grid { .content-grid {
grid-area: 3 / 1 / 4 / 4; grid-area: 3 / 1 / 4 / 4;

Binary file not shown.

After

Width:  |  Height:  |  Size: 199 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 189 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 750 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 451 KiB

@ -0,0 +1,38 @@
% import random
% rebase('frame.tpl')
<div class="content-grid">
<h1>lemonland milestones </h1>
<p>This is a list of everything I need to add before the Blessfrey is complete, version by version. I can add more in subsequent updates, but I have to draw the line somewhere. The list is broken into versions. </p>
<br>
<p>Focus and finish the game! <br></p>
<br><hr><br>
<h2>legend </h2>
<p><span class=mundane>nothing at all</span>, <span class=common>designed</span>, <span class=unusual>basic implementation</span>, <span class=rare>intentionally designed, documented, but with known issues</span>, <span class=unique>playtested and polished...or at least as done as it will ever be</span> </p>
<br><hr><br>
<h2><span class=unique>0.0 - ship literally anything at all</span> </h2>
<ul>
<li class="latest">{{random.choice(['.','•','☆','★'])}}&#9;<span class=unique>feature: export, embed</span> </li>
<li class="latest">{{random.choice(['.','•','☆','★'])}}&#9;<span class=mundane>usernames must be unique</span> </li>
<li class="latest">{{random.choice(['.','•','☆','★'])}}&#9;<span class=mundane>pet names must be unique</span> </li>
<li class="latest">{{random.choice(['.','•','☆','★'])}}&#9;<span class=mundane>usernames can't have unexpected characters like あ</span> </li>
<li class="latest">{{random.choice(['.','•','☆','★'])}}&#9;<span class=mundane>pet names can't have unexpected characters like あ</span> </li>
</ul>
<li class="latest">{{random.choice(['.','•','☆','★'])}}&#9;<span class=mundane>accounts have a persistent username </span> </li>
<li class="latest">{{random.choice(['.','•','☆','★'])}}&#9;<span class=mundane>can capture pet and add it to your pet list </span> </li>
<br><hr><br>
</div>
<!--
-->
Loading…
Cancel
Save