The Rustby Paradigm - Survey (Part 1 & 2)
🔗(11)📅 2025-07-11 08:48:18 -0700
⏲️🔐 2025-07-11 06:06:19 -0700
✍️ infinivaeria
🏷️[rustby] [ruby] [rust]
(🪟)
🖥️...⌨️
Rustby: Integrating Macroquad and Ruby (Magnus) on Windows 11
Developing a “Rustby” system involves using Rust’s high-performance capabilities together with Ruby’s flexibility, by embedding a Ruby interpreter into a Rust game application. This report explores the Macroquad game library’s features on Windows 11, how to integrate the Magnus crate to run Ruby code in the game loop, and how this Rust–Ruby paradigm can be structured. It also highlights relevant Win32 API crates that ensure compatibility with Windows 11, providing low-level control when needed. Each section includes examples (with placeholder code) to illustrate the concepts.
Macroquad Features and Usage on Windows 11
Macroquad is a simple and easy-to-use game framework for Rust, heavily inspired by Raylib. It emphasizes minimal boilerplate and cross-platform support, allowing the same code to run on Windows, Linux, macOS, Android, iOS, and even WebAssembly in the browser. Macroquad deliberately avoids advanced Rust concepts like lifetimes and borrowing in its API, making it very beginner-friendly for game development. Despite its simplicity, it provides a robust set of features out-of-the-box:
-
Cross-Platform Graphics: Macroquad supports efficient 2D rendering with automatic batching of draw calls, and even basic 3D capabilities (camera control, loading 3D models, primitives). You can draw shapes, textures, text, and models with simple function calls, and the library handles the graphics context behind the scenes. It includes an immediate-mode UI module for quick in-game GUI elements. All platforms use the same code without any
#[cfg]
directives or platform-specific tweaks required. -
Minimal Dependencies: The library is lightweight; a fresh build takes only seconds on older hardware. It does not require heavy external frameworks. On Windows 11, Macroquad runs natively with no special setup — both MSVC and GNU toolchains are supported and no additional system libraries are needed to open a window and render graphics. (For example, it uses OpenGL under the hood on PC platforms, which is available by default on Windows, so you don’t need to install anything extra.)
-
Asynchronous Game Loop: A standout feature of Macroquad is its use of Rust’s
async
/.await
for the game loop. The entry point is defined with an attribute macro that creates an asynchronousmain()
function. This design allows easy handling of non-blocking operations within the game. For instance, you can load resources or perform networking in parallel with rendering. Macroquad integrates Rust’s async runtime so that, for example, you could load a texture withload_texture().await
without freezing the render loop. This keeps the game responsive – asset loading, animations, or networking can happen concurrently with drawing and input handling. -
Input, Audio, and More: Macroquad provides modules for keyboard and mouse input, file I/O, timing and frame rate, sound playback, etc., so you can handle all basic game needs in one crate. For example, checking if a key is pressed is as easy as calling
is_key_down(KeyCode::Space)
from anywhere in the loop. Audio playback is similarly straightforward (e.g.,play_sound()
with a loaded sound). The library’s philosophy is to remain immediate mode and globally accessible – you don’t need to set up an elaborate engine structure or pass around context objects.
Macroquad Basic Example: The following code shows a minimal Macroquad game loop that opens a window and draws some shapes and text on the screen every frame:
use macroquad::prelude::*;
#[macroquad::main("BasicShapes")]
async fn main() {
loop {
clear_background(RED);
draw_line(40.0, 40.0, 100.0, 200.0, 15.0, BLUE);
draw_rectangle(screen_width() / 2.0 - 60.0, 100.0, 120.0, 60.0, GREEN);
draw_circle(screen_width() - 30.0, screen_height() - 30.0, 15.0, YELLOW);
draw_text("Hello, Macroquad!", 20.0, 20.0, 40.0, WHITE);
next_frame().await;
}
}
This snippet creates a window titled “BasicShapes” and enters the game loop. Each iteration clears the screen to red, then draws a blue line, a green rectangle, a yellow circle, and the text “Hello, Macroquad!” in white. The call to next_frame().await
yields control back to Macroquad’s event handler, which processes OS events (like input) and then schedules the next frame. Macroquad’s use of async fn main
and .await
internally ensures the loop can pause efficiently between frames rather than busy-waiting. Also note how little code is needed – Macroquad requires no explicit initialization of a window or graphics context (the #[macroquad::main]
attribute and default settings handle that for us).
Window Configuration: By default, Macroquad opens a window with a standard size (e.g., 1280×720) and title matching the [macroquad::main("Title")]
attribute. You can customize this by providing a Conf
configuration. For example, on Windows 11 you might want to set a specific window size or toggle high-DPI mode. Macroquad lets you do this by writing a fn window_conf() -> Conf
and using it in the attribute. For instance:
fn window_conf() -> Conf {
Conf {
window_title: "My Game".to_owned(),
window_width: 800,
window_height: 600,
high_dpi: true,
..Default::default()
}
}
#[macroquad::main(window_conf)]
async fn main() {
// ...
}
This configures an 800×600 window titled “My Game” and opts in to high DPI rendering on supported displays. (Be aware that enabling high_dpi
may require handling scaled coordinates; in older Macroquad versions there were some issues with window sizing in DPI mode. These have been improved over time.) Aside from such settings, Macroquad abstracts away most platform specifics. The same code running on Windows 11 will also run on other OSes or web with no changes – “write once, run anywhere” is a core goal of Macroquad.
In summary, Macroquad on Windows 11 provides a lightweight, cross-platform game loop with easy rendering and input, and it leverages Rust’s async for smooth performance. This makes it a solid foundation for our Rustby paradigm, where we’ll embed Ruby scripting into this game loop.
Integrating Ruby via the Magnus Crate
To add Ruby scripting to our Macroquad game, we use the Magnus crate. Magnus is a high-level binding to the CRuby interpreter, enabling Rust code to initialize a Ruby VM and execute Ruby code or even define new Ruby classes and methods in Rust. In our context, we’ll embed Ruby into the running game so that we can evaluate Ruby code on the fly. The Magnus crate essentially lets Rust act as a host for Ruby, similar to how one might embed Python or Lua in a game engine.
Initialization (Main Thread): Ruby’s interpreter must be initialized before we can run any Ruby code. With Magnus, this is done by calling magnus::embed::init()
at the start of the program. This function boots up the Ruby VM and returns a Cleanup
guard that will automatically shut the VM down when dropped. It’s important to call this on the main thread and keep the guard alive for the lifetime of the Ruby usage. For example:
use magnus::{embed, eval};
fn main() {
let _ruby = embed::init().unwrap();
// Now the Ruby VM is running and we can evaluate Ruby code.
// ...
}
We store the result in _ruby
(prefixed with underscore to silence unused variable warnings) so that it lives until main
ends, ensuring Ruby stays initialized. Calling embed::init()
on the main thread is critical because Ruby’s C API is not thread-safe to initialize or use from arbitrary threads. The Ruby VM expects to run on a single “Ruby thread,” usually the thread that called ruby_init()
(which Magnus does internally). All interactions with Ruby must happen on that thread. In practice, this means we will only evaluate Ruby code within the main Macroquad loop (which runs on the main thread), and we won’t spawn new OS threads to run Ruby code. Magnus enforces this by only providing a Ruby
handle or letting certain functions be called when it knows you’re on the correct thread.
Evaluating Ruby Code: Magnus provides an eval!
macro that can execute a string of Ruby code and return the result to Rust. For example:
use magnus::{embed, eval};
let _ruby = embed::init().unwrap(); // Initialize Ruby VM
let result: i64 = eval!("100 + 250").unwrap(); // Evaluate a Ruby expression
println!("Ruby says 100 + 250 = {}", result);
In this snippet, eval!("100 + 250")
runs the Ruby code "100 + 250"
and returns a Rust i64
with the value 350. Magnus transparently converts Ruby integers to Rust types (any type implementing Into
and TryConvert
can be passed or returned). The .unwrap()
is used to panic on errors – in a real application you’d handle Result
properly, but for simplicity we assume the Ruby code runs successfully. The eval!
macro can also take additional arguments to set local variables for the code. For instance, eval!("a + b", a = 1, b = 2)
would inject two Ruby locals a
and b
before executing the expression, and yield 3
. This is a convenient way to pass data from Rust to Ruby each time you evaluate a script.
Magnus allows more than just one-off eval calls. You can keep Ruby values around and call methods on them using the API. For example, any Ruby object is represented by magnus::Value
in Rust. If you had a Ruby object (say a string or a custom class instance), you could call Ruby methods on it using Value::funcall
by specifying the method name and arguments. Magnus also lets you define new Ruby methods or classes in Rust: for instance, you can expose a Rust function to Ruby by defining a global function. The crate’s macros make this relatively straightforward – you annotate a Rust function and register it. For example, from Magnus’ documentation, defining a Rust function fib()
and exposing it to Ruby as a global method looks like:
fn fib(n: usize) -> usize {
match n {
0 => 0,
1 | 2 => 1,
_ => fib(n-1) + fib(n-2),
}
}
#[magnus::init]
fn init(ruby: &magnus::Ruby) -> Result<(), magnus::Error> {
ruby.define_global_function("fib", magnus::function!(fib, 1))?;
Ok(())
}
Here, when the Ruby VM starts, our init
function will be called and it uses define_global_function
to make the fib
function available in Ruby’s world. This is more relevant when writing a Ruby extension, but it illustrates that we could, for example, expose a spawn_enemy
or move_player
Rust function to be callable from Ruby scripts.
For our embedded scripting scenario, a simpler approach is to use eval!
either to run snippet scripts or to load entire Ruby files. You could call eval!(r#"require 'script.rb'"#)
to load an external Ruby script, or define Ruby classes and functions inline as shown below. The key thing to remember is all Ruby calls must happen from the main thread (or a Ruby thread created by the VM). We will ensure that by calling eval!
only inside our game loop (which runs on the main thread). Also, Ruby code execution is non-async – when you call eval!
or any Ruby function, it will run to completion before returning control to Rust. This means if the Ruby script takes 5ms to run, your frame will take at least that long. We must design our usage such that Ruby scripts are short or infrequent enough not to degrade frame rates significantly.
To summarize integration steps in our game project:
- Enable Magnus (with embedding) in
Cargo.toml
and initialize the Ruby VM at startup. - Run Ruby code from Rust using
magnus::eval!
or by calling Ruby functions through Magnus. - Keep Ruby execution on the main thread (the Macroquad loop thread); do not use it from background threads.
- (Optionally) Expose some Rust functions or data to Ruby if the Ruby scripts need to call back into the engine – Magnus supports binding Rust methods into Ruby’s world.
- Manage the lifetime of Ruby values carefully. Do not store Ruby
Value
objects in Rust structures that outlive the function (they should remain on the stack or use Magnus’s safe wrappers). This avoids issues with Ruby’s garbage collector.
Next, we will combine these elements into the Rustby paradigm, outlining how a game loop can leverage Ruby for scripting and how to structure such a system.
The “Rustby” Paradigm: Combining Rust & Ruby in a Game Loop
Rustby is the concept of fusing Rust’s performance with Ruby’s ease of use by embedding Ruby into a Rust game engine. In this paradigm, Rust (with Macroquad) handles the low-level and performance-critical tasks – rendering, physics, input, etc. – while Ruby acts as a high-level scripting layer for game logic, configuration, or interactive behaviors. This approach is analogous to how many game engines embed a scripting language (like Lua, Python, or Squirrel) to allow game designers or modders to implement logic without touching the engine’s compiled code. Here, Ruby takes the role of the scripting language, and Rust is the host.
Why Ruby? Ruby is a powerful, expressive language with a rich ecosystem of libraries (gems). If a developer or team is already proficient in Ruby or has existing Ruby code, embedding it can allow reusing that code in a game. Ruby’s syntax might also be more approachable for quick iteration or for writing complex game event logic in a clear, high-level way. For example, one could write enemy AI or level scripts in Ruby, benefiting from Ruby’s readability and dynamic features (like metaprogramming for configuring entities). Additionally, Magnus makes it feasible to integrate Ruby, whereas historically using Ruby in a game engine was challenging. (In the past, C/C++ game developers overwhelmingly chose Lua because it’s lightweight and made for embedding. Ruby’s runtime is larger and was not designed with embedding in mind, so it wasn’t commonly used in games. However, Magnus and similar projects like Rutie have improved the embedding story, making Ruby integration easier for Rust applications.)
Trade-offs: It’s important to acknowledge performance implications. Ruby is an interpreted language with a garbage collector and a global interpreter lock (GVL). Running Ruby code will generally be much slower than equivalent Rust code. In a tight game loop, heavy Ruby scripts could become a bottleneck. One commentary on game scripting notes that Ruby is slower and heavier than Lua or even Python, and integrating it into a game demands a strong reason. Therefore, the Rustby approach is best applied to parts of the game that truly benefit from dynamic scripting and are not extremely time-sensitive. For example, high-level game events, cutscene logic, or configuration of item behavior could be done in Ruby, while inner loops (like physics simulation or rendering calculations) stay in Rust. By judiciously splitting responsibilities, you can avoid the “wall” of overhead between languages from impacting performance-critical code.
On the positive side, embedding Ruby means you can tap into Ruby’s capabilities from within Rust. Need to evaluate a complex expression or use a quick scripting of an algorithm? Instead of coding it in Rust and recompiling, you could feed it into Ruby. This can accelerate development and enable live reloading of game logic. For instance, you might allow the game to load new Ruby scripts at runtime for modding. Some game engines (like the visual novel engine Ren’Py for Python) have shown that using a higher-level language for game logic is viable even if it’s slower, as long as the core engine handles the intensive tasks. Rustby follows a similar philosophy with Ruby.
Paradigm in Practice: How does a Rustby game loop look? Essentially, in each frame of the game, you might: handle input (Rust), update game state (some in Rust, some via Ruby scripts), and render (Rust via Macroquad). The Ruby part could be as simple as calling a function or evaluating a snippet that was defined by a script. One design is to define certain “hook” functions in Ruby that get called every frame or on certain events. For example, a Ruby script might define an update_game
function or an on_collision
function, which the Rust code will invoke at the appropriate time.
Skeleton Framework Example: Below is a skeleton of a Macroquad + Magnus integration – a basic Rustby game loop. It’s a simplified framework illustrating where Ruby code would be evaluated each frame and how the system is structured. We include placeholder comments (TODO
) to indicate where developers can extend the logic. This code would run on Windows 11 (or other platforms) and demonstrate the Rustby paradigm:
use macroquad::prelude::*;
use magnus::{embed, eval};
fn window_conf() -> Conf {
Conf {
window_title: "Rustby Game".to_owned(),
window_width: 800,
window_height: 600,
..Default::default()
}
}
#[macroquad::main(window_conf)]
async fn main() {
// 1. Initialize the Ruby VM (Magnus) on the main thread
let _ruby = embed::init().unwrap();
// 2. (Optional) Define Ruby game logic or load Ruby scripts at startup
eval!(r#"
# Ruby placeholder: define a function for per-frame logic
def update_game(frame)
# Example behavior: print frame number (in real game, update game state)
puts "Ruby logic executed for frame #{frame}"
end
"#).unwrap();
// 3. Main game loop (Macroquad)
let mut frame_count: u64 = 0;
loop {
// Clear screen at the start of each frame
clear_background(BLACK);
// Call the Ruby update function for this frame (executed on main thread)
eval!("update_game(frame)", frame = frame_count).unwrap();
// TODO: Handle user input (Rust) e.g., check is_key_down and update game state
// TODO: Update game state (Rust) e.g., move physics objects, detect collisions
// TODO: Render game objects (Rust) e.g., draw textures, shapes for characters
frame_count += 1;
next_frame().await; // yield to let the frame render and proceed
}
}
Let’s break down what’s happening in this skeleton:
-
Window Setup: We provide a
window_conf
function to configure the Macroquad window (800×600, titled "Rustby Game"). The#[macroquad::main(window_conf)]
attribute uses that, so when the program starts, Macroquad opens the window with those settings. Macroquad then calls ourasync main
function on the GUI thread (which on Windows is the main thread for the process). -
Ruby VM Initialization: We call
embed::init().unwrap()
at the start ofmain()
. This initializes the Ruby interpreter and must be done before any Ruby code is run. We store the returned guard in_ruby
. After this call, Ruby’s global VM is active within our process, and we can execute Ruby code. (If initialization fails,unwrap()
will panic, but typically this only fails if Ruby isn’t properly linked or similar issues.) -
Loading Ruby Script: Next, we use
eval!
to define a Ruby functionupdate_game(frame)
within the Ruby environment. The string insideeval!(r#"..."#)
is a snippet of Ruby code. In this case, it definesdef update_game(frame); ...; end
. In a real application, instead of hard-coding a string, you might read from an external.rb
file or have more complex logic. But this serves as a placeholder – it’s establishing a Ruby function that we plan to call every frame. The content ofupdate_game
here just prints a message with the frame number (using Ruby’sputs
). In a game, this function could contain anything: AI logic, spawning entities, updating scores, etc. The point is that the function’s implementation is in Ruby, and can be easily changed without recompiling Rust. We call.unwrap()
to crash on any Ruby exceptions during this definition; in practice you might handle errors (for example, bad Ruby syntax would raise an error). -
Game Loop: We then enter Macroquad’s
loop
which runs once per frame. At the top of each frame, we clear the screen to black (just a background color). Then, crucially, we call into Ruby:eval!("update_game(frame)", frame = frame_count).unwrap()
. This invokes the Ruby methodupdate_game
that we defined, passing the currentframe_count
as theframe
argument (demonstrating how to pass a Rust variable into the Ruby call). The Ruby code runs (printing the message in our example). This call is synchronous – the Rust loop will wait until the Ruby function returns. We’ve ensured this call happens on the main thread (because we are inside the main loop), which satisfies Ruby’s thread-safety requirement. After returning from Ruby, we proceed with the rest of the frame. -
Placeholders for Rust Logic: The comments
// TODO: ...
indicate where additional game logic would go on the Rust side. Typically, after running the Ruby script for a frame, you might handle player input (read keyboard/mouse and update positions or states), update the physics or game world state (possibly influenced by what the Ruby script decided), and then draw the game objects. In this skeleton, we haven’t implemented these parts – they would be specific to the game you’re making. For example, if the Ruby script set some global variable or called a Rust-exposed function to move a character, you would update the character’s coordinates here in Rust accordingly, then draw the character. -
Frame End: We increment the
frame_count
and callnext_frame().await
, which tells Macroquad to present the frame and schedule the next iteration of the loop. The.await
yields back to the runtime, which allows the OS to process events (like window resize, etc.). Then the loop repeats for the next frame.
Throughout this process, the Rustby framework keeps Ruby and Rust working in tandem. Each frame, Rust and Ruby collaborate: Rust drives the loop and rendering, Ruby can inject custom logic. The design ensures Ruby calls are confined to the main thread and occur in a controlled manner (once per frame in this example). If the Ruby function needs to communicate back to Rust, there are a couple of approaches. One is what we did – have Ruby return a value or modify a global that Rust then reads via Magnus after eval!
returns. Another is to expose Rust functions to Ruby (as discussed with define_global_function
earlier) so that the Ruby script directly calls into Rust to e.g. move an object. For instance, we could have defined a Rust function move_player(x, y)
and exposed it to Ruby; then the Ruby script could call move_player(10, 0)
and our Rust implementation would move the player. Magnus supports such patterns; e.g., ruby.define_module_function("move_player", function!(move_player, 2))
would allow Ruby to call a Rust move_player
. In designing the Rustby paradigm, you’d decide which parts of your game logic are easier to script (those go into Ruby) and which should stay in Rust, and provide an interface between them.
Windows 11 Considerations: Running this on Windows 11 is straightforward – Macroquad creates a native Win32 window for the game, and Magnus initializes the standard CRuby interpreter (which on Windows will use the Ruby installation or embedded Ruby DLL you link). One thing to ensure is that you have a Ruby runtime available. Magnus can either link against a Ruby dynamically or use a static build of Ruby (depending on how it’s configured). On Windows, you might ship the x64-msvcrt
Ruby DLL with your game or link Ruby statically for an all-in-one executable. The Magnus crate’s documentation notes it’s compatible with Ruby MRI 2.6+ and you should ensure the version at runtime matches. Aside from that, there aren’t Windows-specific changes needed for the code – thanks to Macroquad abstracting the OS, and Magnus handling Ruby’s platform specifics, the code above would work identically on Linux or macOS as it does on Windows 11.
Performance tips: If the Ruby script work becomes a performance issue, consider these optimizations in a Rustby setup:
- Do as much as possible in Rust, and limit Ruby to high-level decisions.
- Cache any Ruby objects or methods lookup so you’re not re-evaluating strings every time. (Our example re-calls
eval!
with a string each frame, which re-parses it. We could optimize by retrieving the Ruby method object once and calling it viafuncall
each time.) - Ensure the Ruby GC doesn’t run too often in critical sections – large allocations in Ruby could trigger GC pauses. You might manually hint the GC or tune it if needed.
- If concurrency is needed, remember Ruby MRI has a global lock, so even if you create Ruby threads, they won’t run in parallel on multiple cores. Heavy parallel tasks should remain in Rust (or use Rust threads separate from Ruby, communicating via safe channels and minimal shared state).
Rustby is an unconventional but powerful paradigm. It leverages Rust’s strengths (speed, safety, concurrency) and Ruby’s strengths (expressiveness, rapid development). By keeping the integration boundaries clean (e.g., main-thread only, clear API between Rust and Ruby), one can create a game architecture that is both performant and flexible. Next, we will look at tapping into Windows-specific functionalities, to ensure our framework can fully leverage Windows 11 features when necessary.
Win32 API Crates for Windows 11 Compatibility
While Macroquad handles window creation and input for us, and Magnus brings in Ruby, you might occasionally need to call native Windows APIs for deeper integration or to use Windows 11 specific features. For example, you might want to change the application’s DPI awareness, use Microsoft’s game services, or pop up a native file dialog. Rust has excellent support for calling Win32 APIs through various crates. Here are a couple of useful ones:
-
windows
crate (Rust for Windows): This is the official Microsoft-supported crate that lets Rust code call any Windows API (Win32 or WinRT) in a safe and idiomatic manner. Thewindows
crate covers “past, present, and future” Windows APIs by automatically generating bindings from the metadata Microsoft supplies for Windows SDK. In practice, this means you can import namespaces likeWindows::Win32::UI::WindowsAndMessaging
orWindows::Win32::System::Threading
and call functions such asCreateWindowExW
,DispatchMessageW
,CreateEventW
, etc., as if they were Rust functions. The crate handles all the FFI unsafety internally and presents a RustResult
-based API where errors are Rust errors. For example, using thewindows
crate you could callMessageBoxW
to show a message box to the user, or integrate with Direct3D 12 for advanced graphics, directly from your Rustby application. Thewindows
crate is kept up-to-date with Windows 11; as new APIs are added in Windows, the metadata updates allow Rust developers to use them without waiting for a manual binding. This crate is large (since it can import a lot of APIs) but you can choose which features (API families) to include to keep your binary size in check. It’s the recommended way to interact with Windows at a low level. For instance, if Macroquad lacked some Windows-specific feature, you could usewindows
to fill the gap – e.g., adjusting the window style or registering a raw input device. -
winapi
crate: This is an older but widely-used crate that provides raw FFI bindings to the Win32 API. It’s essentially a direct mapping of C Windows headers into Rustextern
functions and constants. Usingwinapi
requires unsafe code and careful handling of pointers/handles, just like you would in C. For example,winapi
lets you call functions fromuser32.dll
orkernel32.dll
by exposing them in modules likewinapi::um::winuser
(for GUI functions) orwinapi::um::processthreadsapi
(for thread functions). One might usewinapi
if they prefer a lighter-weight, manual approach or if they need something that thewindows
crate’s projection doesn’t yet cover. However, sincewinapi
is just raw bindings, you have to manually manage things like wide string conversion and error codes. As an illustration, to show a message box withwinapi
, you’d call theMessageBoxW
FFI and pass wide (u16
) string pointers; the crate’s documentation shows usingOsStr::encode_wide()
to prepare the string andunsafe { MessageBoxW(...) }
to display it. This crate covers all Win32 functions up through the Windows 10 SDK, and it works on Windows 11 as well (Windows 11 hasn’t fundamentally changed the Win32 API; it mainly adds new functions, whichwinapi
may not have if they were introduced after the last update of the crate). Thewinapi
crate requires enabling feature flags for different API sets (e.g., “winuser” for user32.dll, “ole32” for COM/OLE, etc.). It’s a bit more low-level than most Rust abstractions, but it’s very battle-tested.
Using either of these crates, you can enhance your Rustby framework on Windows 11. For example, if you find that Macroquad doesn’t expose a certain window functionality (perhaps toggling fullscreen or changing the cursor), you could call the appropriate Win32 function via windows
/winapi
. You might also use them to integrate with Windows 11 features like Game Bar APIs, notifications, file pickers, etc. Suppose you wanted to open a Windows file dialog to load a custom Ruby script at runtime – you could use the COM-based file dialog via the windows
crate (calling into the Windows API for common dialogs) and then pass the selected file path to your Magnus eval
to load the script. Another example: to support High-DPI properly, you might call SetProcessDpiAwarenessContext
or set a DPI-aware manifest. Macroquad tries to handle DPI if high_dpi
is true, but if you needed finer control, the Windows API is there.
In summary, Windows API crates provide a safety net and power-ups for Windows 11 development:
- The
windows
crate gives you broad, high-level access to Windows 11’s API surface (from classic Win32 calls likeCreateEventW
to modern WinRT APIs) with minimal fuss. - The
winapi
crate offers low-level bindings for when you need to drop down to C-like API calls directly. - Both can be used alongside Macroquad and Magnus. They operate at the system level, below our game logic. For instance, you could call
windows
API functions during initialization or in response to some event (perhaps triggered by Ruby code asking for an OS interaction).
It’s worth noting that Macroquad itself uses some of these internals indirectly (its miniquad
backend uses platform-specific code under the hood, likely using something akin to winapi
to create the window and GL context on Windows). However, those details are encapsulated. If our Rustby application requires something beyond Macroquad’s scope – like integrating with the Windows clipboard or registry – these crates let us do it seamlessly from Rust.
By combining Macroquad for cross-platform game functionality, Magnus for embedded Ruby scripting, and Windows crates for any platform-specific tailoring on Windows 11, the Rustby framework becomes a versatile foundation. We get the best of both worlds: Rust’s speed and systems access, and Ruby’s dynamic scripting — all running smoothly on Windows 11 with access to native capabilities when needed. With this setup, a developer can build a game engine where core mechanics are in Rust, while gameplay logic can be written or changed on the fly in Ruby, and the whole application can still interact with the operating system at a low level.
This detailed exploration should serve as a starting point or skeleton for implementing Rustby on Windows 11. The provided code and examples highlight how to set up the main loop, integrate Ruby code execution, and where to plug in Windows API calls if required. From here, one can incrementally flesh out the game loop (replacing placeholders with real game logic), write Ruby scripts to drive game behavior, and utilize Windows-specific features as the project demands – creating a unique and powerful Rustby game development experience.
I'll dive deep into researching Rust's Macroquad crate, its nuances, and examples, while focusing on integrating it with the Magnus crate for evaluating Ruby code in the Rustby paradigm. I'll also explore useful crates that include the Win32 API to ensure compatibility with Windows 11. This will take some time, so feel free to step away—I’ll keep working in the background. Your detailed report will be saved in this conversation for easy reference. Stay tuned!
--- part 2 below
Drag-and-Drop File Collage in Macroquad (Rust) with Win32 API and Ruby Integration
Win32 API Integration for Drag-and-Drop
Background: The Rust game library Macroquad does not currently support file drag-and-drop events by itself (an open feature request confirms this limitation). To allow users to drag any file from the OS (e.g. Windows Explorer) onto a Macroquad window, we integrate with the Win32 API. The Win32 API offers two main approaches for accepting dropped files from Explorer: the classic WM_DROPFILES event and the newer OLE COM-based IDropTarget interface. For simplicity, we use the “quick and dirty” WM_DROPFILES method, which is sufficient for our needs.
Enabling Drag-Drop on the Window: First, we must allow the Macroquad window to receive drop events. On Windows, a window needs the WS_EX_ACCEPTFILES
extended style or an explicit call to DragAcceptFiles
to become a drop target. After creating the Macroquad window, we obtain its native handle (an HWND
on Windows) and call the Win32 function DragAcceptFiles with that handle:
// Pseudocode: enable drag-and-drop on the Macroquad window (Windows only)
#[cfg(target_os = "windows")]
unsafe {
let hwnd: HWND = get_macroquad_window_handle(); // obtain the native window handle
windows::Win32::UI::Shell::DragAcceptFiles(hwnd, true.into());
}
This Win32 call registers the window to accept file drops. (In practice, obtaining the HWND
might be done via Macroquad’s internal OS binding or the raw-window-handle
crate if exposed by Macroquad.)
Intercepting the Drop Event: When a file is dropped onto the window, Windows sends a WM_DROPFILES message to the window’s procedure (WndProc). Macroquad’s internal event loop doesn’t automatically expose this, so we inject our own handler. One way is to subclass the window procedure at runtime using SetWindowLongPtrW
to intercept messages (this is analogous to the subclassing technique used to catch drag-drop in subcontrols). Our custom WndProc will listen for WM_DROPFILES
and forward other messages to Macroquad’s original handler.
Once we catch the WM_DROPFILES
message, Windows provides an HDROP
handle (via wParam
) containing information about the dropped files. We use the Shell API to extract the file names and the drop coordinates:
-
Query File List: Call DragQueryFile on the
HDROP
. Passing0xFFFFFFFF
(or-1
) as the index returns the count of files dropped. Then we iterate from0
tocount-1
, callingDragQueryFile(hdrop, i, ...)
to retrieve each file path into a buffer. This yields the full path of each dropped file. We convert the returned wide-character paths to RustString
. (Multiple files can be dropped at once, but our use-case likely involves one file at a time.) -
Get Drop Coordinates: Call DragQueryPoint to get the drop position (client-area coordinates where the drop occurred). This function fills a POINT structure with the x,y coordinates of the drop relative to the window’s client area and indicates whether the drop was in the client area or title bar (non-client). In our case it should be on the client (the game canvas). For example, if a file was dropped near the center of the window,
DragQueryPoint
might return(400, 300)
pixels as the drop location. -
Cleanup: Call DragFinish to release the memory allocated for the drop handle. This tells the system we are done processing the drop.
In code, the Windows message handler (simplified) might look like:
// Pseudocode for handling WM_DROPFILES in the custom window procedure
match msg {
WM_DROPFILES => {
let hdrop = wparam as HDROP;
let file_count = DragQueryFileW(hdrop, 0xFFFFFFFF, None);
let mut drop_point = POINT::default();
DragQueryPoint(hdrop, &mut drop_point);
for i in 0..file_count {
// Allocate buffer for file path
let mut name_buf = [0u16; MAX_PATH];
if DragQueryFileW(hdrop, i, Some(&mut name_buf)) > 0 {
let dropped_path = utf16_buf_to_string(&name_buf);
handle_dropped_file(dropped_path, drop_point.x as i32, drop_point.y as i32);
}
}
DragFinish(hdrop);
},
_ => return CallWindowProcW(original_wnd_proc, hwnd, msg, wparam, lparam),
}
This routine uses Win32 API calls (via the Microsoft windows
crate or winapi
). It obtains each dropped file path and the drop coordinates, then calls our Rust function handle_dropped_file
to pass that information into the game logic. The key Win32 functions used are DragQueryFile and DragQueryPoint to get the file list and drop location, respectively.
Note: Using WM_DROPFILES is a straightforward way to accept file drops without dealing with COM. It’s chosen here for simplicity, as noted by developers. The COM approach (implementing
IDropTarget
) offers more flexibility (e.g. accepting other data formats) but is more complex to implement in Rust. Our approach yields the dropped file paths and the drop position, which is exactly what we need for the next steps.
With the Win32 integration in place, whenever a file is dropped onto the Macroquad window, our Rust code receives the file path and the (x,y) drop coordinates in client pixels. We can now use this data to integrate with the Macroquad application (i.e., place the file into our grid-based scene).
Grid-Based System in Macroquad
The core of our application is a grid-based collage system inside the Macroquad window. The window acts as a canvas divided into uniform grid cells. When a file is dropped (as captured by the Win32 handler above), we determine which grid cell was targeted and “place” the file there. Each grid cell can hold at most one file, and we can arrange multiple files on the canvas grid to create a collage of images or icons.
Grid Representation: We define a grid covering the 2D canvas. For example, the grid could be 100x100 pixels per cell in a large scrolling world. We maintain a data structure for the grid state, such as a 2D array or a hash map of cell coordinates to cell data. Each cell’s data might include: whether it’s occupied, which file (path) is there, and possibly a texture or sprite for rendering that file. For instance:
const CELL_SIZE: f32 = 100.0;
struct CellData {
file_path: Option<String>,
texture: Option<Texture2D>, // loaded texture for image files
kind: FileKind, // e.g. Image, PDF, etc., to decide rendering
}
let grid_width = 50;
let grid_height = 50;
let mut grid: Vec<Vec<CellData>> = vec![vec![CellData::default(); grid_width]; grid_height];
Here, grid[y][x]
would give the cell at column x, row y. Initially all cells are empty. When a new file is dropped, we will mark some cell’s file_path
and possibly load a texture
for it if it’s an image. (If the grid is large or infinite, an alternative is to use a dictionary HashMap<(i32,i32), CellData>
storing only occupied cells.)
Converting Drop Coordinates to Grid Cell: The drop (x,y) coordinates we got from Win32 are in window pixel units (with (0,0) at the window’s top-left corner). We need to translate this into a grid index. If the game world is not larger than the window and there is no camera offset, this is straightforward: grid_x = drop_x / CELL_SIZE
, grid_y = drop_y / CELL_SIZE
(using integer division or floored float division). However, our design allows a camera to move (scroll) the grid, and the grid world may be larger than the view. So we must account for the current camera offset when mapping screen coordinates to the world grid.
We implement a 2D camera using Macroquad’s Camera2D
. Macroquad’s camera allows panning and zooming the view of the game world by specifying a target position and offset. We choose to center the camera on a target point, meaning the camera’s offset
is set to half the screen size (this makes the camera target appear at the center of the window). For example:
use macroquad::prelude::*;
let mut camera_target = vec2(0.0, 0.0); // world coordinate that the camera centers on
...
loop {
// configure camera each frame
set_camera(&Camera2D {
target: camera_target,
offset: vec2(screen_width() / 2.0, screen_height() / 2.0),
zoom: vec2(1.0, 1.0), // no zoom (1:1 scale)
rotation: 0.0,
..Default::default()
});
// ... draw grid and items here ...
set_default_camera(); // reset to screen coordinates for UI (if needed)
next_frame().await;
}
With this setup, when camera_target = (0,0)
, the world origin (0,0) will be at the center of the screen. If we move camera_target
, the view scrolls accordingly (e.g., increasing camera_target.x
moves the camera to the right). To convert a drop coordinate to a world coordinate, we do the inverse of the camera transform. Given a drop point (drop_x, drop_y)
in window pixels, and knowing the camera target and offset, we compute:
world_x = camera_target.x - (screen_width()/2) + drop_x;
world_y = camera_target.y - (screen_height()/2) + drop_y;
This formula takes the top-left corner of the window in world coordinates (camera_target - screen/2
) and adds the drop offset. Now we determine the grid indices:
let grid_x = (world_x / CELL_SIZE).floor() as i32;
let grid_y = (world_y / CELL_SIZE).floor() as i32;
These (grid_x, grid_y)
are the coordinates of the grid cell where the file was dropped. We then mark that cell as occupied by this file. For example, we update our grid state:
if grid_y >= 0 && grid_y < grid_height && grid_x >= 0 && grid_x < grid_width {
grid[grid_y as usize][grid_x as usize].file_path = Some(dropped_path.clone());
grid[grid_y as usize][grid_x as usize].kind = detect_file_kind(&dropped_path);
// If it's an image file, load a texture for rendering:
if grid[grid_y as usize][grid_x as usize].kind == FileKind::Image {
grid[grid_y as usize][grid_x as usize].texture = Some(load_texture(&dropped_path).await.unwrap());
}
}
Here detect_file_kind
is a helper that checks file extension (e.g., .png
, .jpg
=> Image, otherwise maybe Other). Macroquad’s load_texture()
function can load image files (PNG/JPEG) from disk into a Texture2D
asynchronously. In this snippet, if the file is an image, we load it immediately and store the texture; if the file is not an image (say a PDF or text file), we set the kind
so that we know to draw a placeholder for it instead of a texture. (Error handling and file type checks are omitted for brevity.)
Arrow Key Camera Navigation: The grid can be much larger than the visible window, so we allow the user to scroll the view using the arrow keys. We update the camera_target
based on key input each frame. Macroquad’s input module lets us check which keys are down in the game loop. For example:
let camera_speed = 10.0;
if is_key_down(KeyCode::Right) {
camera_target.x += camera_speed;
}
if is_key_down(KeyCode::Left) {
camera_target.x -= camera_speed;
}
if is_key_down(KeyCode::Down) {
camera_target.y += camera_speed;
}
if is_key_down(KeyCode::Up) {
camera_target.y -= camera_speed;
}
This moves our camera’s center by 10 pixels per frame in the respective direction. Because we set the camera each loop with the new camera_target
, the result is that pressing the right arrow pans the view to the right (revealing grid cells with higher x indices), up arrow pans upward (revealing cells with smaller y indices), etc. The movement is smooth and continuous as long as the keys are held. We ensure the camera target doesn’t go out of the world bounds (in a real scenario, clamp camera_target
to [0, world_max]).
Mouse-Based UI Interactions: In addition to dropping files from Explorer, we can handle in-app mouse interactions. Macroquad provides mouse position and button state queries (e.g., mouse_position()
and is_mouse_button_down()
) to enable features like clicking or dragging within the app. For instance, we might allow the user to drag already placed items to a new cell or remove an item with a right-click context menu. As a simple example, we could implement clicking on a placed file to select or highlight it. We could detect a left-click on a cell by checking if the mouse’s world coordinates (which we get by inverting the camera transform similarly to above) fall inside a cell that is occupied, and then perhaps store a “selected” state for that item. Implementing a full UI (menus, etc.) is beyond our skeleton scope, but Macroquad’s immediate-mode UI (root_ui()
) could be used for overlays if needed.
For now, the primary mouse interaction is the external drag-and-drop (which we’ve enabled via Win32). Within the Macroquad app, our focus is on keyboard navigation (arrow keys) and displaying the results. The groundwork is laid for further mouse-driven features, as Macroquad easily allows reading mouse input each frame.
Rendering the Grid and Files: Each frame, after updating input and camera, we draw the grid and the contents of each occupied cell. We can draw a faint grid background for reference – for example, vertical and horizontal lines every CELL_SIZE
units. We might loop from 0 to grid_width and draw a vertical line at x * CELL_SIZE
, and similarly horizontal lines for y, using draw_line()
with a light color. This would result in a tiled appearance on the canvas.
Then we iterate over our grid data structure for occupied cells:
for (y, row) in grid.iter().enumerate() {
for (x, cell) in row.iter().enumerate() {
let world_x = x as f32 * CELL_SIZE;
let world_y = y as f32 * CELL_SIZE;
if let Some(kind) = cell.kind {
match kind {
FileKind::Image => {
if let Some(tex) = &cell.texture {
// Draw the image texture to fill the cell
draw_texture(tex, world_x, world_y, WHITE);
}
}
_ => {
// Draw a placeholder (e.g., a colored rectangle or icon for non-image files)
draw_rectangle(world_x, world_y, CELL_SIZE, CELL_SIZE, DARKGREEN);
draw_text(&cell.file_path.as_ref().unwrap_or(&"<file>".into()),
world_x + 5.0, world_y + CELL_SIZE/2.0, 16.0, YELLOW);
}
}
}
}
}
In this pseudo-code, for each cell:
- If the cell contains an image file and we have a
Texture2D
, we draw it at the cell’s top-left corner. We usedraw_texture(&texture, x, y, WHITE)
to draw the full image with no tint (WHITE). If the image is larger or smaller than the cell, we might want to scale it to fit; Macroquad offersdraw_texture_ex
with parameters (or we could adjust cell size). - If the cell contains a non-image file, we draw a solid rectangle as a placeholder (here DARKGREEN) and then overlay some text (e.g., the file name or type). In the code above, we draw part of the file path or a label at a smaller font size inside the cell. This way, a PDF or text document dropped onto the canvas might appear as a green box with its name.
After drawing all cells and their contents in world space, we call set_default_camera()
to switch back to screen space if we need to draw any UI or text that should not move with the world (for example, an instructions overlay or the camera coordinates for debugging). Macroquad’s drawing is double-buffered, and finally next_frame().await
presents the frame.
By following this approach, the user can drop multiple files onto the window and each will “stick” to a grid location. Using the arrow keys, they can navigate the canvas to view different parts of the collage. We have essentially created a simple 2D level editor for file placements – a visual collage board.
Embedding Ruby with Magnus for Scripting
One powerful extension to our system is the integration of a scripting layer for managing file metadata and persistence. We use the Rust Magnus crate to embed a Ruby interpreter in our application. Magnus allows running Ruby code from Rust and exchanging data between Rust and Ruby easily. By embedding Ruby, we can script behaviors or manage data in a more dynamic way. In our context, we use Ruby to maintain a lightweight database of the placed files (their locations and attributes), and to leverage Ruby’s JSON support for saving/loading.
Initializing Ruby: We include Magnus in our project (with the "embed"
feature enabled) and initialize the Ruby VM at startup. This is typically done once, early in main
. For example:
extern crate magnus;
use magnus::embed;
...
unsafe { embed::init() }; // initialize the Ruby interpreter
The call to magnus::embed::init()
sets up the Ruby VM for us. (It returns a guard object that will clean up the VM on drop; we can store it or let it persist for the program’s lifetime.) After this, we can execute Ruby code or define Ruby data structures.
Creating a Ruby Data Structure: We use Ruby to create a global array that will act as our LineDB (the database of line entries). In Ruby, JSON-like data is naturally represented using arrays of hashes – which fits our use: each file entry can be a Ruby Hash (key/value map) with fields like file
, x
, y
, etc. We’ll create a global Ruby array named $collage_db
to store these. Using Magnus, we can either evaluate a Ruby snippet or use the API to construct objects. For simplicity, we can evaluate Ruby code from Rust:
magnus::eval("require 'json'; $collage_db = []").unwrap();
This does two things: it require
s Ruby’s built-in JSON library (for later use), and creates an empty global array $collage_db
. (We call unwrap()
just to panic on any error initializing Ruby – in a robust app, handle errors properly.) Now the Ruby environment has a global variable ready to store our data.
Storing Dropped File Info in Ruby: When a file is dropped and placed into the grid (inside handle_dropped_file
in our earlier pseudocode), we will add a corresponding record to $collage_db
. Each record can be a Ruby hash with keys for the file path and grid coordinates, e.g. {file: "...", x: 3, y: 5}
. We can create and append this hash in Ruby via Magnus. One approach is to use magnus::Value::funcall
to call the Ruby array’s <<
(append) method, but a simpler route is to use magnus::eval
to execute a Ruby append expression:
fn register_file_in_ruby(path: &str, grid_x: i32, grid_y: i32) {
let ruby_code = format!(
"$collage_db << {{ file: {:?}, x: {}, y: {} }}",
path, grid_x, grid_y
);
magnus::eval(ruby_code.as_str()).expect("Failed to append to Ruby DB");
}
Here we format a string to contain a Ruby snippet like $collage_db << { file: "C:\\Path\\To\\File.png", x: 3, y: 5 }
. This calls Ruby’s array <<
method to append the Hash. We wrap the file path in {:?}
which in Rust will produce a quoted string with proper escaping, so that it’s inserted as a Ruby string literal. After this function runs, the Ruby global $collage_db
will contain a new entry for the dropped file. Repeating this for each drop means Ruby is mirroring the list of placed files.
Now we effectively have two sources of truth for our file placements: the Rust side grid
structure and the Ruby side $collage_db
. This is intentional: the Rust side is used for real-time rendering and interaction, while the Ruby side can be used for scripting, data processing, or persistence. The data is essentially duplicated, but we can minimize inconsistencies by always updating both in tandem. (Alternatively, one could choose to have the Rust side query the Ruby DB for data, but that might be less efficient for real-time rendering. We treat Ruby DB as auxiliary here.)
Using Ruby for Attributes and Logic: By storing the placement data as Ruby hashes, we can easily extend those hashes with additional attributes in Ruby, without changing Rust code. For example, a user could run a Ruby script (via Magnus) to add a label or category to each entry. In Ruby, one can do: $collage_db.last[:label] = "Vacation Photo"
to tag the most recently added file. This flexibility is what we mean by having attributes handled by Ruby – we can leverage Ruby’s dynamic nature to attach arbitrary metadata to our files. The Rust code doesn’t need to know about these extra fields unless we choose to query them back via Magnus.
We could also use Ruby to implement logic or analysis on the collage. For instance, we might have a Ruby script to list all files of a certain type, or to compute some layout statistics. Because the Ruby VM is embedded, we can call such scripts at runtime. (Magnus allows calling Ruby methods from Rust, converting types appropriately. For example, we could call Ruby’s select
or each
on $collage_db
via Magnus if needed, or simply evaluate a snippet of Ruby code that performs the task.)
Example – Adding an Attribute in Ruby: Suppose we want to mark certain files as “important.” We could expose a function in Rust that the user triggers (say by keyboard or menu) which then does something like:
magnus::eval("$collage_db.last[:important] = true").unwrap();
This would set the :important
key on the last added file’s hash to true
. Now, that entry in $collage_db
has an extra attribute. If later we dump the database to JSON, this attribute will appear.
Saving and Loading the Database (JSON): Ruby’s JSON module makes it trivial to convert our $collage_db
(an array of hashes) into a JSON string. We can call JSON.generate($collage_db)
to get a JSON text representing the array. Since the question is about a “mockup JSON database”, we likely want to demonstrate exporting our data to JSON (and potentially reading from JSON to restore state).
To save the current collage to a file, we could do:
magnus::eval(r#"
File.open('collage_data.json', 'w') do |f|
f.write(JSON.pretty_generate($collage_db))
end
"#).unwrap();
This Ruby snippet (passed via eval
) opens a file collage_data.json
and writes the pretty-printed JSON of $collage_db
to it. After this, if we open that JSON file (outside of the app), we might see something like:
[
{
"file": "C:\\Users\\Alice\\Pictures\\photo1.png",
"x": 2,
"y": 1
},
{
"file": "C:\\Users\\Alice\\Documents\\notes.txt",
"x": 5,
"y": 3,
"important": true
}
]
This JSON structure is an array of objects, each object corresponding to one file on our collage. It captures the file path and grid coordinates, as well as any additional attributes (like the important: true
tag we added in Ruby for the text file). The JSON format is easy for other tools or languages to read, and it’s human-readable as well.
To load or restore a previously saved collage, we could read the JSON file and parse it in Ruby, then populate our game state accordingly. For example, one could JSON.parse(File.read('collage_data.json'))
in Ruby to get back an array of hashes, assign it to $collage_db
, and then iterate over it in Rust (via Magnus) to place the files on the grid at the stored coordinates. This would require invoking the Macroquad texture loading for each entry and updating the Rust grid
. Due to time, we outline this process but won’t implement it fully here. The key point is that by using JSON, we have a simple way to persist the collage data and reload it, and Ruby gives us JSON parsing/generation for free.
JSON Database Handling (LineDB / Partitioned Array Concept)
The LineDB/Partitioned Array is a concept for organizing data that inspired our approach. In essence, it refers to managing a large array of records (each record being like a line in a database) efficiently by partitioning it into chunks. The original LineDB Partitioned Array library (a Ruby implementation) is aimed at optimizing array-of-hash data storage and manipulation. For our prototype, we don’t need the full complexity of that system, but we mimic its high-level idea: we use an array of hashes as our in-memory database, stored in Ruby (which closely mirrors a JSON structure of an array of objects).
As noted in the Partitioned Array documentation, the data structure is essentially an “Array of Hashes” database held in memory. Each Hash in our case represents one file placement entry, and the array is the collection of all such entries. This matches Ruby’s natural representation of JSON data. We leverage Ruby to handle the dynamic growth of this array and the flexibility of each entry. If our collage were to grow very large (thousands of files), the Partitioned Array approach would suggest allocating additional chunks for the array to avoid copying everything each time it grows (Ruby’s arrays manage this under the hood by over-allocation and will grow amortized linearly, so we are fine at our scale).
Because our $collage_db
is a plain Ruby array, basic operations like appending and iterating are straightforward. Ruby can handle quite large arrays, but if needed, one could incorporate the actual partitioned_array gem (if available) for more advanced memory management. For example, that library could be required and used to create a managed array that grows in partitions. However, for a mockup, the complexity isn’t necessary – a normal array suffices, and Ruby’s garbage collector will handle memory.
Synchronization Considerations: Since we maintain data on both Rust and Ruby sides, it’s important to keep them in sync. Our design currently appends to the Ruby DB at the same time as updating Rust’s grid. This means the order of entries in $collage_db
corresponds to the order files were placed in the grid. If we removed or moved an item, we should also update the Ruby DB (for instance, removing an entry or changing its x,y). We could do that via additional Magnus calls (e.g., find the hash in $collage_db
with matching file path and update/delete it). Another approach would be to generate the entire $collage_db
from the Rust grid
when needed (e.g., before saving to JSON, clear $collage_db
and repopulate it from the current grid state). This might be simpler to ensure consistency, at the cost of some performance overhead for large data.
For demonstration, we went with a direct update approach on each drop event. In a more elaborate system, one could indeed let Ruby’s data be authoritative and drive the Rust rendering, but that would involve converting Ruby objects to Rust on each frame (using Magnus’s conversion traits or Serde via serde_magnus
). That’s possible (Magnus even provides RArray
and RHash
types to iterate Ruby arrays and hashes in Rust), but not necessary here.
Benefits of the JSON DB approach: The advantage of maintaining the collage data in a JSON-serializable format (i.e., using Ruby hashes/arrays) is that we have a clear separation between the visual representation and the data model. The Rust side takes care of visuals (placing textures on the screen), while the Ruby side maintains a convenient data log. We can imagine the Ruby side being replaced or supplemented by another scripting language or even sending this data to a server. JSON is a universal format, so by structuring our data this way, we make it easy to extend the tool. For example, one could write a Ruby script using $collage_db
to generate an HTML gallery or to perform batch operations on the files (like copying them to a certain folder), all within the running app if desired.
To summarize the skeleton framework we've built:
- Win32 Drag-and-Drop Integration: Using Win32 APIs (DragAcceptFiles, WM_DROPFILES) to capture any file dropped onto the window, obtaining file paths and drop coordinates.
- Macroquad Grid System: A grid of cells on a scrollable 2D canvas. Arrow keys move the camera view over the grid. Files dropped onto the canvas are assigned to the corresponding grid cell based on drop location. The file is represented either by its image (loaded as a texture) or a placeholder shape. Rendering uses Macroquad’s drawing functions (textures for images, shapes/text for others) in the game loop.
- Magnus/Ruby Integration: An embedded Ruby interpreter manages a global array of file records. Each record is a Ruby hash with the file’s path and grid coordinates, mirroring the placement in the Rust grid. We update this Ruby data on each drop. Ruby’s dynamic capabilities allow adding extra metadata to these records without Rust-side changes. We effectively have a live JSON-like database in memory (
$collage_db
). - JSON Persistence: Thanks to Ruby’s JSON module, we can easily export the
$collage_db
to a JSON file, creating a persistent snapshot of the collage (the “LineDB”). This JSON can be re-imported to restore state, enabling a simple save/load feature for our collage.
The combination of these components achieves the goal: you can drag & drop any file onto the Macroquad window, the file’s location and grid position are recorded, and you can script and save those records as needed. The skeleton code framework we outlined can be expanded with more robust error handling, support for more file types (e.g., generating thumbnails for PDFs or icons for unknown types), and a user interface for things like deleting or moving entries. But even in this basic form, we have a functional drag-and-drop collage tool implemented in Rust with the help of the Win32 API and enhanced by Ruby scripting for data management.
Here’s a single-chunk implementation that demonstrates a Rustby skeleton framework integrating:
- 🖼️ Drag-and-drop file support (via Win32 API)
- 🧱 Grid-based placement and camera control
- 🔮 Magnus crate for Ruby scripting
- 📋 Mock JSON-like DB structure stored via Ruby (
$collage_db
) - 🕹️ Arrow key navigation and basic in-game rendering with Macroquad
This version assumes you’ve set up your Cargo.toml
to include:
[dependencies]
macroquad = "0.4"
magnus = { version = "0.6", features = ["embed"] }
windows = { version = "0.52", features = ["Win32_UI_Shell", "Win32_UI_WindowsAndMessaging"] }
Now the code:
use macroquad::prelude::*;
use magnus::{eval, embed};
use windows::Win32::UI::Shell::*;
use windows::Win32::UI::WindowsAndMessaging::*;
use windows::Win32::Foundation::*;
use std::collections::HashMap;
use std::ffi::OsString;
use std::os::windows::ffi::OsStringExt;
// === Configuration ===
const CELL_SIZE: f32 = 100.0;
const CAMERA_SPEED: f32 = 20.0;
// === Grid Data ===
#[derive(Clone)]
struct CellData {
file_path: String,
texture: Option<Texture2D>,
}
type Grid = HashMap<(i32, i32), CellData>;
// === Camera ===
fn make_camera(target: Vec2) -> Camera2D {
Camera2D {
target,
offset: vec2(screen_width() / 2.0, screen_height() / 2.0),
zoom: vec2(1.0, 1.0),
rotation: 0.0,
..Default::default()
}
}
// === Win32 Drag & Drop ===
unsafe fn enable_drop(hwnd: HWND) {
DragAcceptFiles(hwnd, BOOL(1));
}
unsafe fn check_drop_files(hwnd: HWND) -> Vec<(String, I32Point)> {
let mut results = Vec::new();
let mut msg = MSG::default();
while PeekMessageW(&mut msg, hwnd, WM_DROPFILES, WM_DROPFILES, PM_REMOVE).as_bool() {
let hdrop = HDROP(msg.wParam.0 as isize);
let count = DragQueryFileW(hdrop, u32::MAX, None);
let mut pt = POINT::default();
DragQueryPoint(hdrop, &mut pt);
for i in 0..count {
let len = DragQueryFileW(hdrop, i, None);
let mut buf = vec![0u16; len as usize + 1];
DragQueryFileW(hdrop, i, Some(&mut buf));
let filename = OsString::from_wide(&buf[..len as usize]).to_string_lossy().to_string();
results.push((filename, I32Point::new(pt.x, pt.y)));
}
DragFinish(hdrop);
}
results
}
// === Helper Struct ===
#[derive(Clone, Copy)]
struct I32Point {
x: i32,
y: i32,
}
impl I32Point {
fn new(x: i32, y: i32) -> Self {
Self { x, y }
}
}
// === Drop Handler ===
async fn handle_drop(grid: &mut Grid, camera: Vec2, drop: (String, I32Point)) {
let (path, screen_pt) = drop;
let world_x = camera.x - screen_width() / 2.0 + screen_pt.x as f32;
let world_y = camera.y - screen_height() / 2.0 + screen_pt.y as f32;
let grid_x = (world_x / CELL_SIZE).floor() as i32;
let grid_y = (world_y / CELL_SIZE).floor() as i32;
let key = (grid_x, grid_y);
// Try to load texture
let texture = load_texture(&path).await.ok();
grid.insert(key, CellData {
file_path: path.clone(),
texture,
});
// Register in Ruby
let ruby_snippet = format!(
"$collage_db << {{ file: {:?}, x: {}, y: {} }}",
path, grid_x, grid_y
);
eval(&ruby_snippet).unwrap();
}
// === Window Config ===
fn window_conf() -> Conf {
Conf {
window_title: "Rustby Collage".to_owned(),
window_width: 800,
window_height: 600,
high_dpi: true,
..Default::default()
}
}
// === Entry Point ===
#[macroquad::main(window_conf)]
async fn main() {
// === Ruby Init ===
let _ruby = embed::init().unwrap();
eval("require 'json'; $collage_db = []").unwrap();
// === Grid / Camera ===
let mut grid: Grid = HashMap::new();
let mut camera_target = vec2(0.0, 0.0);
// === Win32 Setup ===
#[cfg(target_os = "windows")]
unsafe {
use macroquad::window::get_native_window;
if let Some(hwnd) = get_native_window() {
enable_drop(hwnd);
}
}
// === Game Loop ===
loop {
// === Handle Arrow Keys ===
if is_key_down(KeyCode::Right) { camera_target.x += CAMERA_SPEED; }
if is_key_down(KeyCode::Left) { camera_target.x -= CAMERA_SPEED; }
if is_key_down(KeyCode::Up) { camera_target.y -= CAMERA_SPEED; }
if is_key_down(KeyCode::Down) { camera_target.y += CAMERA_SPEED; }
// === Handle Win32 Drops ===
#[cfg(target_os = "windows")]
unsafe {
use macroquad::window::get_native_window;
if let Some(hwnd) = get_native_window() {
let drops = check_drop_files(hwnd);
for drop in drops {
handle_drop(&mut grid, camera_target, drop).await;
}
}
}
// === Camera ===
set_camera(&make_camera(camera_target));
// === Background & Grid Lines ===
clear_background(DARKGRAY);
let cols = (screen_width() / CELL_SIZE).ceil() as i32 + 2;
let rows = (screen_height() / CELL_SIZE).ceil() as i32 + 2;
let start_x = ((camera_target.x - screen_width() / 2.0) / CELL_SIZE).floor() as i32 - 1;
let start_y = ((camera_target.y - screen_height() / 2.0) / CELL_SIZE).floor() as i32 - 1;
for x in start_x..start_x + cols {
draw_line(
x as f32 * CELL_SIZE,
start_y as f32 * CELL_SIZE,
x as f32 * CELL_SIZE,
(start_y + rows) as f32 * CELL_SIZE,
1.0,
LIGHTGRAY,
);
}
for y in start_y..start_y + rows {
draw_line(
start_x as f32 * CELL_SIZE,
y as f32 * CELL_SIZE,
(start_x + cols) as f32 * CELL_SIZE,
y as f32 * CELL_SIZE,
1.0,
LIGHTGRAY,
);
}
// === Render Grid Items ===
for (&(gx, gy), cell) in &grid {
let px = gx as f32 * CELL_SIZE;
let py = gy as f32 * CELL_SIZE;
match &cell.texture {
Some(tex) => draw_texture_ex(*tex, px, py, WHITE, DrawTextureParams {
dest_size: Some(vec2(CELL_SIZE, CELL_SIZE)),
..Default::default()
}),
None => {
draw_rectangle(px, py, CELL_SIZE, CELL_SIZE, DARKGREEN);
draw_text(&cell.file_path, px + 4.0, py + CELL_SIZE / 2.0, 16.0, YELLOW);
}
}
}
// === Finish Frame ===
set_default_camera();
draw_text("Drop files to create a collage. Arrow keys scroll the view.", 10.0, 30.0, 20.0, WHITE);
next_frame().await;
}
}
🧠 Let me know if you'd like this expanded to support:
- UI buttons for saving/loading JSON from
$collage_db
- Zooming with mouse wheel
- Ruby-based filtering or tagging of placed files
This skeleton is flexible and intentionally designed for iterative growth — like a living codebase powered by spirit and syntax.
Crystal HTTP Server framework
🔗(13)📅 2025-07-12 03:23:37 -0700
⏲️🔐 2025-07-12 03:24:15 -0700
✍️ infinivaeria
🏷️[server] [crystal] [rustby] [rustby-crystal] [rustby-c]
(🪟)
🖥️...⌨️
require "http/server"
# A fundamental concept in HTTP servers is handlers. In Crystal,
# we define handlers as blocks or procs that take an HTTP::Server::Context
# as an argument.
# This handler responds with a simple "hello" message
hello_handler = ->(context : HTTP::Server::Context) do
context.response.content_type = "text/plain"
context.response.print "hello\n"
end
# This handler reads all the HTTP request headers and echoes them
# into the response body
headers_handler = ->(context : HTTP::Server::Context) do
context.response.content_type = "text/plain"
context.request.headers.each do |name, values|
values.each do |value|
context.response.print "#{name}: #{value}\n"
end
end
end
# Create a new HTTP server
server = HTTP::Server.new do |context|
case context.request.path
when "/hello"
hello_handler.call(context)
when "/headers"
headers_handler.call(context)
else
context.response.status_code = 404
context.response.print "Not Found\n"
end
end
# Start the server
address = server.bind_tcp 8090
puts "Listening on http://#{address}"
server.listen
Accessing the last.fm API
🔗(19)📅 2025-07-12 23:53:17 -0700
⏲️🔐 2025-07-12 23:53:40 -0700
✍️ infinivaeria
🏷️[last.fm] [api access] [rustby-c]
(🪟)
🖥️...⌨️
Retrieving the Current “Now Playing” Track from Last.fm API in Rust, Ruby, and Crystal
Last.fm’s API allows you to fetch a user’s currently scrobbling song (“now playing” track) if available. In this guide, we’ll walk through obtaining an API key, calling the Last.fm API for the latest track, and writing the output to a text file. We demonstrate the process in three languages – Rust, Ruby, and Crystal (the “Rustby-C” paradigm) – with step-by-step instructions and code examples for each.
Last.fm API Setup and Key Registration
Before coding, you need to sign up for Last.fm’s API and get an API key:
- Create a Last.fm Account: If you don’t have one, register on Last.fm (or log in if you already have an account).
- Apply for an API Key: Visit the Last.fm API page and click “Get an API account”. Fill out the “Create an API account” form with a name and description for your application (you can use any name/description), and you can leave the callback URL blank. Submit the form.
- Copy Your API Key: After submission, Last.fm will display your new API Key (a string of letters and numbers) on the screen. Copy this key – you’ll use it in your API calls (Last.fm also provides a secret, but for reading public data like now-playing tracks, only the key is needed).
Your API key is essential for authenticating requests to Last.fm’s API. Keep it secure and do not share it publicly.
Identifying the Correct API Method (Now Playing Track)
Last.fm’s API method to retrieve a user’s recent tracks (including the current track) is user.getRecentTracks
. This REST endpoint returns a list of a user’s recently scrobbled tracks. If the user is currently listening to a song, the first track in the list will be marked with a special attribute indicating it’s now playing. Specifically, the JSON/XML includes nowplaying="true"
on that track entry.
- Endpoint:
https://ws.audioscrobbler.com/2.0/
- Method:
user.getRecentTracks
- Required Parameters:
•user
– the Last.fm username whose track you want to fetch.
•api_key
– your API key obtained earlier.
•format
– set tojson
for JSON response (easier to parse in code).
For example, a GET request URL looks like:
https://ws.audioscrobbler.com/2.0/?method=user.getRecentTracks&user=LASTFM_USERNAME&api_key=YOUR_API_KEY&format=json
Replace LASTFM_USERNAME
with the target username and YOUR_API_KEY
with the key you obtained. You can test this URL in a web browser or with a tool like curl
. The response will be a JSON object containing the recent tracks. The currently playing track (if any) will appear as the first track
entry with a @attr.nowplaying
flag set to "true"
. If the user isn’t playing anything at the moment, the first entry will just be the last played track (with a timestamp).
Response structure: The JSON will look roughly like:
{
"recenttracks": {
"track": [
{
"artist": { "#text": "Artist Name", ... },
"name": "Song Title",
"album": { "#text": "Album Name", ... },
"url": "https://www.last.fm/music/Artist+Name/_/Song+Title",
"@attr": { "nowplaying": "true" }
},
{
"artist": { "#text": "Previous Artist", ... },
"name": "Previous Song",
"date": { "uts": "1699478400", "#text": "08 Nov 2023, 10:00" }
},
...
]
}
}
In the above, the first track has "nowplaying": "true"
indicating it’s currently being scrobbled. Our code will need to check for this attribute and retrieve that track’s name and artist. According to Last.fm’s docs, user.getRecentTracks
does not require user authentication for public profiles, so the API key alone is sufficient.
💡 Note: The now playing attribute will only be present if the user’s scrobbler updates Last.fm in real-time. Some music players (or scrobbling apps) update “currently listening” status continuously, while others (like certain older scrobblers) only submit tracks after they finish. If you don’t see a nowplaying flag in the response even when music is playing, it could be due to the scrobbler’s behavior rather than an API limitation.
Implementing the Solution in Rust
In Rust, we can use the reqwest
crate to handle the HTTP GET request and serde_json
to parse the JSON response. This approach lets us easily fetch the data and extract the track information without writing a lot of low-level code. We’ll also use Rust’s file I/O from the standard library to write the output to a text file.
Setting up the Rust Environment
Make sure you have Rust installed (via rustup) and set up a new project (cargo new lastfm_nowplaying
). In your Cargo.toml
, add dependencies for reqwest
(with the json
feature) and serde_json
for JSON parsing, as well as tokio
if using async. For example, you can run:
cargo add tokio -F full
cargo add reqwest -F json
cargo add serde_json
This will include the necessary crates. The reqwest
crate’s JSON feature allows directly parsing the response as JSON. We’ll use the synchronous API here for simplicity (via reqwest::blocking
) to avoid dealing with async in the example.
Rust Code: Fetching and Writing Now Playing Track
Below is a Rust code snippet that retrieves the current track and writes “Artist - Title” to nowplaying.txt
:
use reqwest::blocking;
use serde_json::Value;
use std::fs;
fn main() -> Result<(), Box<dyn std::error::Error>> {
let api_key = "YOUR_LASTFM_API_KEY";
let user = "LASTFM_USERNAME";
// Construct the API URL
let url = format!(
"https://ws.audioscrobbler.com/2.0/?method=user.getRecentTracks&user={}&api_key={}&format=json",
user, api_key
);
// Send GET request to Last.fm API
let resp_text = blocking::get(&url)?.text()?;
// Parse the JSON response
let data: Value = serde_json::from_str(&resp_text)?;
// Navigate to the first track in the JSON structure
let tracks = &data["recenttracks"]["track"];
if tracks.is_null() {
eprintln!("No tracks found for user {}", user);
return Ok(());
}
// Handle case where recenttracks.track might be an object or array
let first_track = if tracks.is_array() {
&tracks[0]
} else {
// If only one track, the API might return a single object instead of an array
tracks
};
// Extract track name and artist
let track_name = first_track["name"].as_str().unwrap_or("Unknown Track");
let artist_name = first_track["artist"]["#text"].as_str().unwrap_or("Unknown Artist");
// Check the nowplaying attribute
let nowplaying_attr = first_track.get("@attr").and_then(|attr| attr.get("nowplaying"));
let is_now_playing = nowplaying_attr.map_or(false, |v| v == "true");
// Prepare output string
let output_line = format!("{} - {}", artist_name, track_name);
// Write to text file
fs::write("nowplaying.txt", &output_line)?;
println!("{}", if is_now_playing {
format!("Currently playing: {}", output_line)
} else {
format!("Last played: {}", output_line)
});
Ok(())
}
How it works: This Rust program builds the request URL with your provided user
and api_key
, then uses reqwest::blocking::get
to fetch the data. The response body (in JSON text) is parsed into a serde_json::Value
structure for easy querying. We then access data["recenttracks"]["track"][0]
to get the first track entry. We retrieve the "name"
and the artist’s "#text"
field (the artist name) from the JSON. We also look for the optional @attr.nowplaying
flag. If nowplaying
is "true"
, we know this track is currently being played. Finally, we format “Artist - Track” as a single line and write it to nowplaying.txt
using Rust’s std::fs::write
function, which conveniently creates/overwrites the file and writes the given text in one call.
The Rust code uses safe unwrapping (
unwrap_or
) in case fields are missing, and it checks for both array and object cases forrecenttracks["track"]
because Last.fm’s JSON can return either an array of tracks or a single track object if there’s exactly one recent track. We handle both for robustness. Writing to the file is done viafs::write
, but you could also manually useFile::create
andwrite_all
if preferred.
Run the program: Build and run (cargo run --release
). The console should print out the track it found, and you should see a new file nowplaying.txt
with the content “Artist - Title” corresponding to the user’s current or last track.
Implementing the Solution in Ruby
Ruby has very convenient built-in libraries for making HTTP requests and handling JSON. We don’t even need an external gem to call the Last.fm API. We’ll use OpenURI (an easy wrapper over Net::HTTP) to fetch the URL and Ruby’s JSON library to parse the response. Then, we use Ruby’s file I/O to save the result.
Ruby Code: Fetching and Writing Now Playing Track
Make sure you have Ruby installed (any recent version 2.x or 3.x). The following script demonstrates the process:
require 'open-uri' # allows opening URLs like files
require 'json' # JSON parsing
api_key = "YOUR_LASTFM_API_KEY"
user = "LASTFM_USERNAME"
url = "https://ws.audioscrobbler.com/2.0/?method=user.getRecentTracks&user=#{user}&api_key=#{api_key}&format=json"
# Fetch the JSON data from Last.fm API
response = URI.open(url).read # OpenURI treats the URL like a file and reads it
data = JSON.parse(response)
# Extract the first track from the response
tracks = data.dig("recenttracks", "track")
first_track = tracks.is_a?(Array) ? tracks.first : tracks # handle array or single object
if first_track.nil?
puts "No track info found for user #{user}"
exit
end
track_name = first_track["name"] || "Unknown Track"
artist_name = first_track.dig("artist", "#text") || "Unknown Artist"
nowplaying = first_track.dig("@attr", "nowplaying") == "true"
output_line = "#{artist_name} - #{track_name}"
File.write("nowplaying.txt", output_line) # writes the string to file (overwrites if exists)
if nowplaying
puts "Currently playing: #{output_line}"
else
puts "Last played: #{output_line}"
end
Explanation: We use URI.open
(from OpenURI) to send a GET request to the Last.fm API endpoint. OpenURI makes it trivial to retrieve the contents of a URL as if it were a local file – the URI.open(...).read
call returns the response body as a string. We then parse that string into a Ruby hash using JSON.parse
. The current track data is nested under ["recenttracks"]["track"]
. In Ruby, this might be either an Array (if multiple recent tracks) or a Hash (if only one track in history), so we check is_a?(Array)
and take the first element if it’s an array. We then pull out the "name"
and "artist"]["#text"]
fields for the track title and artist. To see if it’s now playing, we look for the optional ["@attr"]["nowplaying"]
flag and compare it to "true"
.
Finally, we use File.write
to write the output to a text file. The File.write
method in Ruby is a simple one-liner that opens (or creates) the file, writes the given content, and closes the file automatically. In our case, it will create/overwrite nowplaying.txt
with a line like Artist Name - Song Title
. We also print a message to the console indicating whether it’s the current track or just the last played track.
Note: OpenURI is part of Ruby’s standard library and conveniently handles HTTP redirects and SSL. It’s suitable for simple use cases. For more complex needs or to handle errors, you might use
Net::HTTP
directly or a gem like HTTParty. Here, OpenURI keeps the code concise.
Run the script: Save it as lastfm_nowplaying.rb
and run ruby lastfm_nowplaying.rb
. The script will create/update nowplaying.txt
in the current directory with the now-playing info.
Alternative Ruby approach: Instead of manual requests, you could use the community-provided ruby-lastfm
gem, which wraps Last.fm API methods in Ruby objects. Using that gem, you would initialize a client with your API key and secret, then call something like lastfm.user.get_recent_tracks(user: "name")
to get a Ruby array/hash of tracks. Under the hood it’s doing the same HTTP call. For a short script, using the standard libraries as shown is straightforward and avoids extra dependencies.
Implementing the Solution in Crystal
Crystal is a language with Ruby-like syntax, compiled for performance. It has a standard library that includes an HTTP client and JSON parsing, making the task very similar to the Ruby approach. We’ll use Crystal’s built-in HTTP::Client for the GET request and JSON.parse for parsing.
Crystal Code: Fetching and Writing Now Playing Track
Make sure Crystal is installed on your system. Create a file lastfm_nowplaying.cr
with the following content:
require "http/client"
require "json"
api_key = "YOUR_LASTFM_API_KEY"
user = "LASTFM_USERNAME"
url = "https://ws.audioscrobbler.com/2.0/?method=user.getRecentTracks&user=#{user}&api_key=#{api_key}&format=json"
# Send GET request to Last.fm API
response = HTTP::Client.get(url) # perform HTTP GET and get a Response
if response.status_code != 200
puts "HTTP request failed with code #{response.status_code}"
exit
end
# Parse JSON response body into a dynamic structure (JSON::Any)
data = JSON.parse(response.body)
# Access the recenttracks.track data
tracks = data["recenttracks"]["track"]
first_track = tracks.is_a?(Array) ? tracks[0] : tracks # Crystal JSON::Any allows .is_a?(Array)
if first_track.nil?
puts "No track info found for user #{user}"
exit
end
track_name = first_track["name"].as_s || "Unknown Track"
artist_name = first_track["artist"]["#text"].as_s || "Unknown Artist"
now_playing_attr = first_track["@attr"]?["nowplaying"]? # use nil-safe navigation
is_now_playing = now_playing_attr.as_s? == "true"
output_line = "#{artist_name} - #{track_name}"
File.write("nowplaying.txt", output_line) # write output to file (creates or truncates)
if is_now_playing
puts "Currently playing: #{output_line}"
else
puts "Last played: #{output_line}"
end
Explanation: After requiring http/client
and json
, we construct the request URL similarly to the other languages. We then call HTTP::Client.get(url)
, which returns an HTTP::Client::Response
object synchronously. We check that the status_code
is 200 (OK). The response body is accessible via response.body
as a String
. Crystal’s JSON.parse
returns a JSON::Any
(a type that can hold any JSON structure). We navigate through this JSON structure by keys: data["recenttracks"]["track"]
. This returns another JSON::Any
which could be an Array or single object. We check tracks.is_a?(Array)
to decide how to extract the first track (Crystal’s JSON library provides type query methods like is_a?(Array)
on JSON::Any).
Once we have first_track
, we pull the "name"
and nested "artist"]["#text"]
values. In Crystal, JSON::Any
provides methods like .as_s
to convert to string, and returns nil
if the value is not present or not a string. We use || "Unknown Track"
to default in case of nil. To check the now playing flag, we safely navigate to first_track["@attr"]
and then ["nowplaying"]
using the ?
operator (which yields nil
if any part is missing). We then get the string value and compare to "true"
. The logic is similar to the Ruby version.
Finally, we use File.write("nowplaying.txt", output_line)
to write the result to a file. Crystal’s File.write
is analogous to Ruby’s: it opens (creates if needed) the file, writes the given content, and closes it, in one call. We print out a message indicating whether it’s currently playing or just last played.
Compile and run: Compile the Crystal program with crystal build --release lastfm_nowplaying.cr
. Running the resulting binary will create/update the nowplaying.txt
file with the song info.
Because Crystal compiles to a native binary, this tool can be very fast and suitable for command-line use or cron jobs (e.g., to periodically update a “now playing” text file for a streaming overlay or a website).
Alternate Approaches and Libraries
The above sections show how to directly use the Last.fm REST API in three languages. If the official API did not meet your needs or you prefer not to handle HTTP/JSON manually, there are a few alternatives and third-party libraries to consider:
Higher-level API Wrappers: Many community libraries simplify interactions with Last.fm. For example, in Rust you could use the
lastfm
crate, which provides aClient
abstraction and even anow_playing()
method to get the current track directly. In Ruby, theruby-lastfm
gem (by Youpy) offers methods corresponding to each API call (e.g.,lastfm.user.get_recent_tracks
) and handles the HTTP under the hood. Using these can reduce boilerplate; however, adding a dependency is only worth it if you plan to use many API features. In our simple scenario, the raw HTTP approach is fairly straightforward in each language.Alternate Data Sources: If Last.fm’s API didn’t provide the now playing song, one could consider other means. For instance, some users embed now playing info by using the Spotify API (if they listen via Spotify) or by reading from a local music player’s API/IPC (like MPD or iTunes integrations). Another option is using an open scrobbling service like ListenBrainz which has its own API. Fortunately, Last.fm’s
user.getRecentTracks
does support retrieving the current track as we’ve demonstrated, so usually you won’t need these workarounds.Automation and Integration: With the above code, you can periodically run the script/binary (using a scheduler or cron job) to update the text file whenever you want. If you are displaying this on a website, ensure the site reads the latest contents of
nowplaying.txt
or set up a small server to serve this info. For example, a simple approach could be to have a job update an HTML snippet or JSON file that your webpage can fetch. On a desktop (e.g., streaming setup), you might just load the text file contents directly into your overlay tool.
Remember that the Last.fm API is rate-limited (typically 1 request per second, and a few hundred calls per day for free accounts). For a “currently playing” display, updating once every 60 seconds is more than enough in most cases (or even less frequently, as tracks usually last a few minutes).
Summary of Steps and Tools
The table below summarizes the key steps to achieve this and the tools or libraries used in each language:
Step | Rust Implementation | Ruby Implementation | Crystal Implementation |
---|---|---|---|
1. Get API Key | Register via Last.fm website to obtain API key. | Same process (API key is language-agnostic). | Same process (API key is language-agnostic). |
2. Construct API Request | Use reqwest to build and send GET request (REST URL with query params). |
Use OpenURI (URI.open ) to fetch the URL as text. |
Use HTTP::Client.get from stdlib to send GET request. |
3. Parse JSON Response | Use serde_json to parse into Value (dynamic JSON) and traverse to track info. |
Use built-in JSON.parse to get Ruby Hash/Array. |
Use JSON.parse to get JSON::Any and navigate keys. |
4. Identify Now-Playing Track | Check Value for recenttracks.track[0].@attr.nowplaying == "true" to confirm current track. |
Check first track Hash for "@attr"]["nowplaying"] == "true" . |
Check first track JSON::Any for ["@attr"]["nowplaying"] == "true" . |
5. Extract Song & Artist | Extract track["name"] and track["artist"]["#text"] from JSON Value. |
Extract hash["name"] and hash["artist"]["#text"] from parsed Hash. |
Extract any["name"].as_s and any["artist"]["#text"].as_s from JSON::Any. |
6. Write to Text File | Use Rust std::fs::write or File::create + write_all to save output. |
Use File.write to create/overwrite file with content. |
Use File.write (Crystal) to write string to file. |
Tools/Libraries Used | reqwest HTTP client, serde_json for JSON, Rust std fs for file I/O. |
Ruby stdlib (OpenURI, JSON, File class), no extra gem required. |
Crystal stdlib (HTTP::Client, JSON, File class), no extra shard required. |
Optional: Third-Party Lib | lastfm crate provides a higher-level API (e.g. now_playing() method). |
ruby-lastfm gem wraps the API methods (e.g. user.get_recent_tracks ). |
(No dedicated Last.fm shard known; use HTTP/JSON as above.) |
Each implementation follows the same basic flow: get API key → call Last.fm API → parse JSON → find current track → write to file. By leveraging each language’s strengths (Rust’s type safety and speed, Ruby’s elegance and brevity with open-uri, Crystal’s convenient syntax with compiled performance), you can achieve the goal in whichever environment you prefer. The resulting nowplaying.txt
will contain the track info, ready to be used wherever needed (website, overlay, etc.), updating whenever the script is run.
I'll start researching how to interact with the Last.fm API to fetch the current scrobbling song and write it to a text file, using Rust, Ruby, and Crystal as part of the Rustby-C paradigm. This will include a step-by-step guide for signing up for the API, making the necessary API calls, and implementing the functionality. If the Last.fm API doesn't support this directly, I'll explore alternative methods or third-party libraries that can achieve the same goal. This will take me some time, so feel free to step away—I’ll keep working in the background and save the report in this conversation for you to review later!