# Implementing a New Block Type
Blocks are the core content elements in Rapua that players interact with. Each block type provides different functionality, from displaying text to interactive elements that require player input. This guide walks through the process of implementing a new block type in Rapua.
# Block Architecture Overview
Blocks in Rapua follow a consistent pattern:
- Block Interface Implementation - Each block must implement the
Block
interface defined inblocks/block.go
- Block Registration - Blocks must be registered in the system to be available for use
- Templates - Each block needs admin and player view templates
- Block-specific Logic - Implement validation, player input handling, and scoring
# Block Implementation Steps
# 1. Create the Block Struct
Start by creating a new Go file in the /blocks
directory. Name it according to your block type, e.g., sorting_block.go
.
Define your block struct, extending the BaseBlock:
// YourBlock is a description of what your block does
type YourBlock struct {
BaseBlock
// Add block-specific fields here
Content string `json:"content"`
// ... other fields
}
# 2. Implement the Block Interface
Every block must implement the Block interface. Here are the key methods to implement:
# Basic Attribute Getters
// Basic Attributes Getters
func (b *YourBlock) GetName() string { return "Your Block Name" }
func (b *YourBlock) GetDescription() string {
return "Description of what your block does."
}
func (b *YourBlock) GetIconSVG() string {
return `<svg>...</svg>` // SVG icon markup for your block
}
func (b *YourBlock) GetType() string { return "your_block_type" }
func (b *YourBlock) GetID() string { return b.ID }
func (b *YourBlock) GetLocationID() string { return b.LocationID }
func (b *YourBlock) GetOrder() int { return b.Order }
func (b *YourBlock) GetPoints() int { return b.Points }
func (b *YourBlock) GetData() json.RawMessage {
data, _ := json.Marshal(b)
return data
}
# Data Operations
// Data operations
func (b *YourBlock) ParseData() error {
return json.Unmarshal(b.Data, b)
}
func (b *YourBlock) UpdateBlockData(input map[string][]string) error {
// Parse points
pointsInput, ok := input["points"]
if ok && len(pointsInput[0]) > 0 {
points, err := strconv.Atoi(pointsInput[0])
if err != nil {
return errors.New("points must be an integer")
}
b.Points = points
} else {
b.Points = 0
}
// Update block-specific fields
if content, exists := input["content"]; exists && len(content) > 0 {
b.Content = content[0]
}
// Process other fields...
return nil
}
# Validation and Points Calculation
// Validation and Points Calculation
func (b *YourBlock) RequiresValidation() bool {
// Return true if the block needs player input validation
return true
}
func (b *YourBlock) ValidatePlayerInput(state PlayerState, input map[string][]string) (PlayerState, error) {
newState := state
// Parse current player data if it exists
var playerData YourBlockPlayerData
if state.GetPlayerData() != nil {
if err := json.Unmarshal(state.GetPlayerData(), &playerData); err != nil {
return state, fmt.Errorf("failed to parse player data: %w", err)
}
}
// Process player input
// ...
// Update player data
newPlayerData, err := json.Marshal(playerData)
if err != nil {
return state, fmt.Errorf("failed to save player data: %w", err)
}
newState.SetPlayerData(newPlayerData)
// Determine if the block is complete and calculate points
// ...
newState.SetComplete(true)
newState.SetPointsAwarded(calculatedPoints)
return newState, nil
}
# 3. Create Player Data Structure
If your block needs to track player progress or state, define a player data structure:
// YourBlockPlayerData stores player progress
type YourBlockPlayerData struct {
Attempts int `json:"attempts"` // Number of attempts made so far
IsCorrect bool `json:"is_correct"` // Whether the current answer is correct
// ... other fields specific to your block
}
# 4. Register the Block
Add your block to the registered blocks list in blocks/block.go
:
var registeredBlocks = Blocks{
&MarkdownBlock{},
&DividerBlock{},
// ... other blocks
&YourBlock{},
}
Also add a constructor function:
func NewYourBlock(base BaseBlock) *YourBlock {
return &YourBlock{
BaseBlock: base,
}
}
And register it in the CreateFromBaseBlock
function:
func CreateFromBaseBlock(baseBlock BaseBlock) (Block, error) {
switch baseBlock.Type {
// ... other cases
case "your_block_type":
return NewYourBlock(baseBlock), nil
default:
return nil, fmt.Errorf("block type %s not found", baseBlock.Type)
}
}
# 5. Create Block Templates
Create a new template file in /internal/templates/blocks/your_block.templ
with admin and player views:
package blocks
import (
"fmt"
"github.com/nathanhollows/Rapua/v3/blocks"
"github.com/nathanhollows/Rapua/v3/models"
)
// Admin view
templ yourBlockAdmin(settings models.InstanceSettings, block blocks.YourBlock) {
<form
id={ fmt.Sprintf("form-%s", block.ID) }
hx-post={ fmt.Sprint("/admin/locations/", block.LocationID, "/blocks/", block.ID, "/update") }
hx-trigger={ fmt.Sprintf("keyup changed from:(#form-%s textarea) delay:500ms, change from:(#form-%s input) delay:500ms", block.ID, block.ID) }
hx-swap="none"
>
<!-- Admin settings form -->
<label class="form-control w-full">
<div class="label">
<span class="label-text font-bold">Content</span>
</div>
<textarea
name="content"
rows="3"
class="textarea textarea-bordered w-full"
placeholder="Block content here..."
>{ block.Content }</textarea>
</label>
<!-- Points settings if enabled -->
if settings.EnablePoints {
<label class="form-control w-full mt-4">
<div class="label">
<span class="label-text font-bold">Points</span>
</div>
<input
name="points"
type="number"
class="input input-bordered w-full"
placeholder="Points"
value={ fmt.Sprint(block.Points) }
/>
</label>
}
<!-- Other block-specific settings -->
</form>
}
// Player view
templ yourBlockPlayer(settings models.InstanceSettings, block blocks.YourBlock, data blocks.PlayerState) {
<div
id={ fmt.Sprintf("player-block-%s", block.ID) }
class="indicator w-full"
>
<!-- Points badge -->
if settings.EnablePoints && block.Points > 0 {
<span class="indicator-item indicator-top indicator-center badge badge-info">{ fmt.Sprint(block.GetPoints()) } pts</span>
}
<!-- Completion badge -->
@completionBadge(data)
<div class="card prose p-5 bg-base-200 shadow-lg w-full">
<!-- Block content -->
@templ.Raw(stringToMarkdown(block.Content))
<!-- Interactive elements -->
if !data.IsComplete() {
<form
id={ fmt.Sprintf("form-%s", block.ID) }
hx-post={ fmt.Sprint("/blocks/validate") }
hx-target={ fmt.Sprintf("#player-block-%s", block.ID) }
>
<input type="hidden" name="block" value={ block.ID }/>
<!-- Player interaction elements -->
<div class="flex justify-end mt-4">
<button class="btn btn-primary">Submit</button>
</div>
</form>
} else {
<div class="alert alert-success">
<span>Success message here...</span>
</div>
}
</div>
</div>
}
# 6. Update the Block Rendering
Add your block to the rendering functions in /internal/templates/blocks/blocks.templ
:
func RenderAdminEdit(settings models.InstanceSettings, block blocks.Block) templ.Component {
switch block.GetType() {
// ... other cases
case "your_block_type":
b := block.(*blocks.YourBlock)
return yourBlockAdmin(settings, *b)
}
return nil
}
func RenderPlayerView(settings models.InstanceSettings, block blocks.Block, state blocks.PlayerState) templ.Component {
switch block.GetType() {
// ... other cases
case "your_block_type":
b := block.(*blocks.YourBlock)
return yourBlockPlayer(settings, *b, state)
}
return nil
}
func RenderPlayerUpdate(settings models.InstanceSettings, block blocks.Block, state blocks.PlayerState) templ.Component {
switch block.GetType() {
// ... other cases
case "your_block_type":
b := block.(*blocks.YourBlock)
return yourBlockPlayer(settings, *b, state)
}
return nil
}
# 7. Write Tests
Create a test file (e.g., /blocks/your_block_test.go
) to test your block implementation:
package blocks
import (
"encoding/json"
"testing"
"github.com/stretchr/testify/assert"
)
func TestYourBlock_UpdateBlockData(t *testing.T) {
block := &YourBlock{
BaseBlock: BaseBlock{
ID: "test-id",
Type: "your_block_type",
},
}
input := map[string][]string{
"content": {"Test content"},
"points": {"100"},
// Other test inputs
}
err := block.UpdateBlockData(input)
assert.NoError(t, err)
assert.Equal(t, "Test content", block.Content)
assert.Equal(t, 100, block.Points)
// Other assertions
}
func TestYourBlock_ValidatePlayerInput(t *testing.T) {
block := &YourBlock{
BaseBlock: BaseBlock{
ID: "block-id",
Type: "your_block_type",
Points: 100,
},
// Initialize with test data
}
// Test correct input
state := &mockPlayerState{blockID: "block-id", playerID: "player-id"}
input := map[string][]string{
"your_input_field": {"correct_value"},
}
newState, err := block.ValidatePlayerInput(state, input)
assert.NoError(t, err)
assert.True(t, newState.IsComplete())
assert.Equal(t, 100, newState.GetPointsAwarded())
// Test incorrect input
// ...
}
# 8. Build and Test
Run the following commands to build and test your block:
# Generate templ files
make templ-generate
# Build the application
make build
# Run tests
make test
# Example: Sorting Block Implementation
Here’s a simplified example of the sorting block implementation:
# 1. Block Struct (sorting_block.go)
// SortingBlock allows players to sort items in the correct order
type SortingBlock struct {
BaseBlock
Content string `json:"content"`
Items []SortingItem `json:"items"`
ScoringScheme string `json:"scoring_scheme"`
ScoringPercent int `json:"scoring_percent"`
}
// SortingItem represents an individual item to be sorted
type SortingItem struct {
ID string `json:"id"`
Description string `json:"description"`
Position int `json:"position"` // The correct position (1-based)
}
// SortingPlayerData stores player progress
type SortingPlayerData struct {
PlayerOrder []string `json:"player_order"` // List of item IDs in player's submitted order
ShuffleOrder []string `json:"shuffle_order"` // Shuffled order shown to player initially
Attempts int `json:"attempts"` // Number of attempts made so far
IsCorrect bool `json:"is_correct"` // Whether the current order is correct
}
# 2. Player Input Validation
func (b *SortingBlock) ValidatePlayerInput(state PlayerState, input map[string][]string) (PlayerState, error) {
newState := state
// Parse player data from the existing state
var playerData SortingPlayerData
if state.GetPlayerData() != nil {
if err := json.Unmarshal(state.GetPlayerData(), &playerData); err != nil {
return state, fmt.Errorf("failed to parse player data: %w", err)
}
}
// Get player's ordering from input
itemOrder, exists := input["sorting-item-order"]
if !exists || len(itemOrder) == 0 {
return state, errors.New("sorting order is required")
}
// Store the player's order and increment attempts
playerData.PlayerOrder = itemOrder
playerData.Attempts++
// Calculate points based on scoring scheme
points := b.calculatePoints(playerData.PlayerOrder)
// Check if order is correct
isOrderCorrect := points == b.Points
playerData.IsCorrect = isOrderCorrect
// Marshal the updated player data
newPlayerData, err := json.Marshal(playerData)
if err != nil {
return state, fmt.Errorf("failed to save player data: %w", err)
}
newState.SetPlayerData(newPlayerData)
// Handle different scoring schemes for completion status
switch b.ScoringScheme {
case "retry_until_correct":
// Only mark as complete when correct
if isOrderCorrect {
newState.SetComplete(true)
newState.SetPointsAwarded(points)
} else {
newState.SetComplete(false)
newState.SetPointsAwarded(0)
}
default:
// For other schemes, mark as complete and award proportional points
newState.SetComplete(true)
newState.SetPointsAwarded(points)
}
return newState, nil
}
# 3. Templates (sorting.templ)
Admin view:
templ sortingAdmin(settings models.InstanceSettings, block blocks.SortingBlock) {
<form
id={ fmt.Sprintf("form-%s", block.ID) }
hx-post={ fmt.Sprint("/admin/locations/", block.LocationID, "/blocks/", block.ID, "/update") }
hx-trigger={ fmt.Sprintf("keyup changed from:(#form-%s textarea) delay:500ms, click from:(#form-%s button) delay:100ms", block.ID, block.ID) }
hx-swap="none"
>
<!-- Points setting -->
if settings.EnablePoints {
<label class="form-control w-full">
<div class="label">
<span class="label-text font-bold">Points</span>
</div>
<input name="points" type="number" class="input input-bordered w-full" value={ fmt.Sprint(block.Points) }/>
</label>
}
<!-- Scoring scheme selection -->
<label class="form-control w-full mt-5">
<div class="label">
<span class="label-text font-bold">Scoring Scheme</span>
</div>
<select name="scoring_scheme" class="select select-bordered w-full">
<option value="all_or_nothing" selected?={ block.ScoringScheme == "all_or_nothing" }>All or Nothing</option>
<option value="correct_item_correct_place" selected?={ block.ScoringScheme == "correct_item_correct_place" }>Correct Item, Correct Place</option>
<option value="runs_percentage" selected?={ block.ScoringScheme == "runs_percentage" }>Percentage of Correct Items</option>
<option value="retry_until_correct" selected?={ block.ScoringScheme == "retry_until_correct" }>Retry Until Correct</option>
</select>
</label>
<!-- Content textarea -->
<label class="form-control w-full mt-5">
<div class="label">
<span class="label-text font-bold">Instructions</span>
</div>
<textarea
name="content"
rows="2"
class="textarea textarea-bordered w-full"
placeholder="Markdown content here..."
>{ block.Content }</textarea>
</label>
<!-- Sorting items -->
<div class="form-control w-full">
<div class="label font-bold flex justify-between">
Sorting Items
<button class="btn btn-outline btn-sm" type="button" onclick="addSortingItem(event)">
Add Item
</button>
</div>
<div id="sorting-items" class="joining join-vertical">
<!-- Existing items -->
for _, item := range block.Items {
@sortingItem(item)
}
<!-- Empty slots for new items -->
for i := 0; i < (2 - len(block.Items)); i++ {
@sortingItem(blocks.SortingItem{})
}
</div>
</div>
</form>
}
# Best Practices
- Consistent Interface: Follow the established patterns for blocks.
- Error Handling: Provide clear error messages when input validation fails.
- Responsive UI: Ensure your block looks good on both desktop and mobile.
- Test Coverage: Write comprehensive tests for your block.
- Player Experience: Consider the player experience and how feedback is provided.
- Documentation: Document your block’s functionality and configuration options.
# Common Patterns
- Player State: Use the PlayerState interface to track player progress.
- HTMX Integration: Use HTMX attributes for dynamic updates without page reloads.
- Form Validation: Validate input on both client and server sides.
- Points Calculation: Implement a clear scoring system based on player performance.
# Conclusion
Creating new block types is a powerful way to extend Rapua’s functionality. By following this guide, you can create interactive, engaging blocks that enhance the user experience. Remember to test thoroughly and maintain a consistent user interface.
The block system in Rapua is designed to be extensible, allowing for a wide variety of interactive elements to be created. If you have any questions or need further guidance, please reach out on GitHub or contact the maintainers.