Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ gem "jekyll", "~> 4.3"
gem "htmlbeautifier"
gem "fastimage"
gem "ferrum", "~> 0.18"
gem "nokogiri", "~> 1.19"

group :jekyll_plugins do
gem "jekyll-sitemap"
Expand Down
22 changes: 22 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -98,9 +98,30 @@ GEM
rb-inotify (~> 0.9, >= 0.9.10)
logger (1.7.0)
mercenary (0.4.0)
mini_portile2 (2.8.9)
nokogiri (1.19.4)
mini_portile2 (~> 2.8.2)
racc (~> 1.4)
nokogiri (1.19.4-aarch64-linux-gnu)
racc (~> 1.4)
nokogiri (1.19.4-aarch64-linux-musl)
racc (~> 1.4)
nokogiri (1.19.4-arm-linux-gnu)
racc (~> 1.4)
nokogiri (1.19.4-arm-linux-musl)
racc (~> 1.4)
nokogiri (1.19.4-arm64-darwin)
racc (~> 1.4)
nokogiri (1.19.4-x86_64-darwin)
racc (~> 1.4)
nokogiri (1.19.4-x86_64-linux-gnu)
racc (~> 1.4)
nokogiri (1.19.4-x86_64-linux-musl)
racc (~> 1.4)
pathutil (0.16.2)
forwardable-extended (~> 2.6)
public_suffix (7.0.2)
racc (1.8.1)
rake (13.3.1)
rb-fsevent (0.11.2)
rb-inotify (0.11.1)
Expand Down Expand Up @@ -175,6 +196,7 @@ DEPENDENCIES
htmlbeautifier
jekyll (~> 4.3)
jekyll-sitemap
nokogiri (~> 1.19)

BUNDLED WITH
2.7.2
27 changes: 7 additions & 20 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,31 +2,18 @@

Personal portfolio and blog built with [Jekyll](https://jekyllrb.com/).

## Setup
## Development

```sh
bundle install
npm install
```

## Run

```sh
bin/run
```

Builds the site and its article PDFs, then serves the result without rebuilding it.
Visit `localhost:4000` to view the site. Additional arguments are passed to
`jekyll serve`.

## Build
Requires Ruby/Bundler, Node.js 22+, and Chrome 131+.

```sh
bin/build
bundle install
bin/run # build and serve at localhost:4000
bin/build # build the site and article PDFs
```

Builds the site and generates a PDF for each blog article with Chrome 131 or newer.
Math is rendered to inline SVG with MathJax during the build, so Node.js 22 or newer is required.
Both commands install npm dependencies. `bin/run` does not rebuild on changes;
restart it to refresh. Arguments go to `jekyll serve` or `jekyll build`, respectively.
Set `BROWSER_PATH` if Chrome or Chromium is installed in a nonstandard location.

## License
Expand Down
1 change: 1 addition & 0 deletions _config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -58,3 +58,4 @@ exclude:
- package.json
- bin/
- design.md
- tmp/
20 changes: 19 additions & 1 deletion _layouts/base.html
Original file line number Diff line number Diff line change
Expand Up @@ -227,8 +227,26 @@
.article-toc li:not([data-level="2"]) { padding-left: 1em; }
.toc-teaser { display: block; margin-left: 1em; }
}
@media print { .article-toc { display: none !important; } }
{% endif %}
@media print {
.article-toc { display: none; }
}

{% assign has_glossary = content | has_glossary %}
{% if has_glossary %}
/* Glossary */
.glossary-term { all: unset; text-decoration: underline dotted; cursor: pointer; }
.glossary-term:focus-visible { outline: auto; }
.article-glossary { margin: 2em 0; }
.article-glossary dt { margin-top: 1em; }
.article-glossary dt, .glossary-popup dt { font-weight: bold; }
.article-glossary dd, .glossary-popup dd { margin: .25em 0 0; }
.glossary-popup { max-width: min(30ch, calc(100vw - 3em)); padding: .75em; }
@supports (position-area: bottom) {
.glossary-popup { inset: auto; margin: .5em; position-area: bottom; position-try-fallbacks: flip-block; }
}
{% endif %}

@media (prefers-color-scheme: dark) {
html { color-scheme: dark; background: #000; }
body { background: #000; color: #fff; }
Expand Down
4 changes: 3 additions & 1 deletion _layouts/post.html
Original file line number Diff line number Diff line change
Expand Up @@ -21,4 +21,6 @@ <h1>{{ page.title }}</h1>
<hr>
</header>

{{ content }}
<div class="article-body" data-glossary-source="{{ page.path | escape }}">
{{ content }}
</div>
168 changes: 168 additions & 0 deletions _plugins/glossary.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
require "cgi"
require "nokogiri"
require "set"

# Post/thread front matter holds small selections for tech-blog readers familiar
# with everyday computing and basic proof vocabulary. Select unfamiliar concepts
# needed for the piece; use canonical singular labels, precise aliases, and brief
# reminders. Keep essential explanations in the prose.
module ArticleGlossary
EXCLUDED_TAGS = %w[a button code pre script style textarea select summary h1 h2 h3 h4 h5 h6 svg math mjx-container video audio].freeze
EXCLUDED_CLASSES = %w[footnotes article-glossary no-glossary].freeze

module_function

def normalize(form)
form.downcase.gsub(/[\s\p{Pd}]+/, " ").strip
end

# Case and ordinary spelling variations belong in matching, not YAML aliases.
# Irregular plurals and alternate names remain explicit aliases.
def matching_forms(form)
[form, form.gsub(/(?<=[a-z])(?=[A-Z])/, " ")].map { |value| normalize(value) }.uniq.flat_map do |value|
plural = if form.match?(/\b[A-Z][A-Z0-9]+\z/)
"#{value}s"
elsif value.match?(/[^aeiou]y\z/)
value.sub(/y\z/, "ies")
elsif value.match?(/(?:s|x|z|ch|sh)\z/)
"#{value}es"
else
"#{value}s"
end
[value, plural]
end.uniq
end

def excluded?(node, root)
node.ancestors.take_while { |ancestor| ancestor != root }.any? do |ancestor|
EXCLUDED_TAGS.include?(ancestor.name) ||
(ancestor["class"].to_s.split & EXCLUDED_CLASSES).any?
end
end

def annotate(root, terms, prefix)
return if terms.empty?

forms = {}
terms.each_with_index do |term, index|
[term.fetch("term"), *term.fetch("aliases", [])].each do |form|
matching_forms(form).each do |normalized|
if forms.key?(normalized) && forms[normalized] != index
raise Jekyll::Errors::FatalException, "Ambiguous glossary form: #{form}"
end
forms[normalized] = index
end
end
end
alternatives = forms.keys.sort_by { |form| -form.length }.map { |form| Regexp.escape(form).gsub("\\ ", "[\\s\\p{Pd}]+") }
pattern = Regexp.new("(?<![\\p{L}\\p{N}_])(?:#{alternatives.join('|')})(?![\\p{L}\\p{N}_])", Regexp::IGNORECASE)
seen = Set.new

root.xpath(".//* | .//text()").to_a.each do |node|
if node.element? && node.name.match?(/\Ah[2-6]\z/) && !excluded?(node, root)
seen.clear
end
next unless node.text? && !excluded?(node, root)

text = node.content
replacement = Nokogiri::XML::DocumentFragment.new(root.document)
cursor = 0
changed = false
text.to_enum(:scan, pattern).each do
match = Regexp.last_match
index = forms.fetch(normalize(match[0]))
next unless seen.add?(index)

replacement.add_child(Nokogiri::XML::Text.new(text[cursor...match.begin(0)], root.document))
button = Nokogiri::XML::Node.new("button", root.document)
button["type"] = "button"
button["class"] = "glossary-term"
button["popovertarget"] = "#{prefix}-#{index}-popup"
button["aria-describedby"] = "#{prefix}-#{index}-definition"
button.content = match[0]
replacement.add_child(button)
cursor = match.end(0)
changed = true
end
next unless changed

replacement.add_child(Nokogiri::XML::Text.new(text[cursor..], root.document))
node.replace(replacement)
end
end

def glossary_html(terms, prefix)
items = terms.each_with_index.map do |term, index|
<<~HTML
<dt>#{CGI.escapeHTML(term.fetch("term"))}</dt>
<dd id="#{prefix}-#{index}-definition">#{CGI.escapeHTML(term.fetch("definition"))}</dd>
HTML
end.join
popovers = terms.each_with_index.map do |term, index|
<<~HTML
<dl class="glossary-popup" id="#{prefix}-#{index}-popup" popover>
<dt>#{CGI.escapeHTML(term.fetch("term"))}</dt>
<dd>#{CGI.escapeHTML(term.fetch("definition"))}</dd>
</dl>
HTML
end.join

<<~HTML
<details class="article-glossary" id="#{prefix}">
<summary>Glossary (#{terms.length})</summary>
<dl>#{items}</dl>
</details>
#{popovers}
HTML
end

def render(page)
return unless page.output&.include?("data-glossary-source")

site = page.site
document = Nokogiri::HTML5.parse(page.output)
changed = false
document.css("[data-glossary-source]").each do |root|
source = root["data-glossary-source"]
root.remove_attribute("data-glossary-source")
doc = site.documents.find { |item| item.relative_path == source }
glossary = doc&.data&.fetch("glossary", nil)
next unless glossary&.any?

terms = glossary.map do |label, term|
if term.fetch("definition").strip.empty?
raise Jekyll::Errors::FatalException, "Missing glossary definition: #{label} in #{source}"
end
{ "term" => label, **term }
end

prefix = "glossary-#{File.basename(source, '.md')}"
annotate(root, terms, prefix)
root.add_child(Nokogiri::HTML5.fragment(glossary_html(terms, prefix)))
changed = true
end
return unless changed

page.output = document.to_html(preserve_newline: true)
end
end

module ArticleGlossaryFilter
def has_glossary(html)
return false unless html.include?("data-glossary-source")

site = @context.registers[:site]
Nokogiri::HTML5.fragment(html).css("[data-glossary-source]").any? do |root|
doc = site.documents.find { |item| item.relative_path == root["data-glossary-source"] }
doc&.data&.fetch("glossary", nil)&.any?
end
end
end

Liquid::Template.register_filter(ArticleGlossaryFilter)

# Low priority runs after BuildTimeMath, keeping equations outside text matching.
# RSS and raw Markdown retain the prose.
Jekyll::Hooks.register [:documents, :pages], :post_render, priority: :low do |page|
ArticleGlossary.render(page)
end
16 changes: 10 additions & 6 deletions _plugins/threads_defaults.rb
Original file line number Diff line number Diff line change
@@ -1,9 +1,16 @@
require "time"

# Set the filename date before Jekyll sorts front-matter documents. Explicit
# front-matter dates replace this default when the document is read.
Jekyll::Hooks.register :threads, :post_init do |doc|
filename_date = doc.basename_without_ext[/\A\d{4}-\d{2}-\d{2}/]
doc.data["date"] ||= Time.parse(filename_date) if filename_date
end

# Threads entries need no front matter. Jekyll reads front-matter-less files
# as static files, so promote them to documents first; then date defaults to
# the YYYY-MM-DD filename prefix and title to that date in site.date_format.
# Front matter, when present, overrides both. Jekyll pre-fills title with a
# as static files, so promote them to documents before setting title defaults.
# The date defaults to the YYYY-MM-DD filename prefix and the title to that
# date in site.date_format. Front matter overrides both. Jekyll pre-fills title with a
# titleized slug ("2026 06 08"), so a title still matching that is treated
# as absent.
Jekyll::Hooks.register :site, :post_read do |site|
Expand All @@ -17,9 +24,6 @@
collection.files.clear

collection.docs.each do |doc|
filename_date = doc.basename_without_ext[/\A\d{4}-\d{2}-\d{2}/]
doc.data["date"] ||= Time.parse(filename_date) if filename_date

auto_title = Jekyll::Utils.titleize_slug(doc.basename_without_ext)
if doc.data["title"].nil? || doc.data["title"] == auto_title
doc.data["title"] = doc.data["date"].strftime(site.config["date_format"])
Expand Down
34 changes: 30 additions & 4 deletions _posts/2024-03-07-bevy-multiplayer.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,30 @@
title: "Multiplayer with Bevy and Renet"
description: "A comprehensive guide to building multiplayer games in Rust using the Bevy game engine and Renet networking library. Learn about Entity-Component-System architecture, client-server synchronization, input handling strategies, and practical implementation of real-time multiplayer features. Perfect for developers wanting to explore game development beyond traditional web technologies."
tags: ["rust", "bevy", "multiplayer", "game"]
# Distinguish ECS data and behavior, then explain the channel guarantees used for synchronization.
glossary:
ECS:
definition: 'Entity-component-system: an architecture where entities identify objects, components hold data, and systems operate on that data.'
aliases: [entity component system]
ECS component:
definition: Data attached to an entity, describing an attribute such as position or marking a role such as player.
aliases: [component]
ECS system:
definition: A scheduled function operating on components and resources; its declared data access lets Bevy safely parallelize compatible systems.
aliases: [bevy system]
ECS resource:
definition: One value per type stored in the ECS world, shared across systems without belonging to an individual entity.
data synchronization:
definition: Keeping copies of shared state consistent across a server and its clients by exchanging updates.
aliases: [synchronization, synchronizing, synchronising]
binary message encoding:
definition: Representing structured data as bytes for transmission or storage.
reliable ordered channel:
definition: Retransmits lost messages until acknowledged and delivers them in send order. A missing message delays later ones, suiting joins and departures that must arrive intact.
aliases: [reliableordered]
unreliable channel:
definition: Sends without retransmission or ordering guarantees. Fresh position updates can replace lost ones without waiting, but receivers must reject stale updates arriving late.
aliases: [unreliable]
---

Here at Renuo, we specialize in web technologies such as Ruby on Rails, React, Angular, and Spring. One of our core company values is continuous learning: we love exploring new technologies even beyond our usual scope of expertise.
Expand Down Expand Up @@ -54,11 +78,13 @@ The [ECS](https://en.wikipedia.org/wiki/Entity_component_system) (Entity Compone

1. **Entities:** Unique identifiers of a group of components (A u32 wrapper in bevy).
2. **Components:** Modular data pieces that represent specific Entity attributes. (A struct that derives the Component macro in bevy)
3. **System:** Logic that operates on entities and their components. (A struct that derives the Resource macro in bevy)
3. **ECS system:** A scheduled function that operates on entities, components, and shared resources.

An **ECS resource** is shared data stored once per type in the world, such as the server's `PlayerLobby`; it is a struct that derives `Resource` in Bevy.

### Systems in Bevy

Systems in Bevy are functions that take various parameters such as queries, EventReaders, assets, and resources and apply logic to them.
ECS systems in Bevy are functions that take various parameters such as queries, EventReaders, assets, and resources and apply logic to them.

One powerful feature of Bevy systems is the Query interface. It allows you to fetch specific data for entities in your project. For instance, if no entity is found, the `single_mut()` function will raise an error. Multiple queries are possible as long as entities do not overlap.

Expand Down Expand Up @@ -200,8 +226,8 @@ cargo run --bin client

To integrate `bevy_renet` into the bevy project, I followed the [Bevy Renet documentation](https://github.com/lucaspoffo/renet/blob/master/bevy_renet/README.md). In my setup, I used these two default channels:

- **Unreliable:** Used for sending and receiving messages for player attribute synchronization. (We don't care about every state change, we can pick the last one)
- **ReliableOrdered:** Used for sending and receiving messages for player actions such as joining and leaving.
- **Unreliable:** Used for player attribute synchronization. Messages are not retransmitted and may arrive out of order. A newer position can replace a lost update without waiting; sequence numbers let a receiver reject older updates that arrive late. The examples below omit that check.
- **ReliableOrdered:** Used for player actions such as joining and leaving. Lost messages are retransmitted, and messages are delivered in send order. Later messages wait for missing earlier ones, so this channel preserves events at the cost of extra delay during loss.

## Synchronising player positions

Expand Down
Loading
Loading