Table of Contents
This demo project came about as a result of brainstorming on ideas for making an article to enter into the VS2008 contest! We wanted to experiment with and showcase a few of the great new .NET 3.0 (and 3.5) technologies that have been introduced with the latest version of Visual Studio. Initially, we came up with the concept of a network chat program - we were going to implement the GUI with WPF and the network communication with WCF. After some experimenting around with the new WPF controls, we decided it would be more fun to use the new InkCanvas
control and make a multi-user network drawing demo. DrawMe is the result of our experiments, and in this article, we'll walk through some of the interesting WPF and WCF code features we encountered along the way.
At the highest level, DrawMe uses a client-server architecture with the server hosted on one of the client's computers. When a user starts DrawMe, they are given the choice of creating a new server to locally host ink drawings, or connecting to an existing DrawMe server which can be either local or remote. When a user draws on the ink canvas, the drawing strokes are broadcast to all clients registered with the main DrawMe server. In this way, users can participate in real-time collaborative drawing. Although this concept isn't new, the goal of this article was to see how easy this would be to implement using WPF and WCF.
If you just want to try out the finished product, you can download the demo application using the link at the top of the article. Chances are high that most people will just be testing it out on one computer, in which case, it should work with minimal firewall configuration required. Simply start up a couple of instances of DrawMe.exe and set the first one to be the server. When connecting the second instance, change the type to Client, and specify either localhost, the machine name, or the machine IP address for the server address. If you want to try it out on two or more separate computers on a LAN, you will likely need to allow DrawMe access through any firewall you have running. If you want to try it out on two or more computers across the internet, you will also probably need to port-forward TCP 8000 to your computer from any router you are using, etc. We've successfully tested it in all of the scenarios listed here, so hopefully it should work for you too!
The user interface for DrawMe consists of two main windows:
- The login control - A WPF user control where you can create and/or connect to a DrawMe server
- The main application window - Where all ink drawing takes place
The following sub-sections explore the function and creation of each of these areas of the user interface.
When the user first starts DrawMe, they are presented with a login screen. The purpose of the login screen is to allow the user to either join an existing DrawMe session, or to create a new DrawMe server and also join that session as the first client. The following screenshot shows how the login window looks:
It's a relatively basic UI, but it doesn't need to do much, so we've kept it simple. There's a few nice features that WPF brings to the table that we should point out:
- It's easy to make visually pleasing rounded corners on the rectangular regions of the UI by specifying the
CornerRadius
property for the element - It's easy to specify nice colour gradients by applying a
LinearGradientBrush
to the background of elements
The following XAML code listing shows how we made the login control. We found that working with the raw XAML was the fastest way to experiment and fine tune the design; however, the layout design manager in VS2008 also does a nice job if you want to play around with the controls.
<UserControl x:Class="DrawMe.LoginControl"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Height="300" Width="350"
Loaded="UserControl_Loaded">
<StackPanel>
<Border Height="50" BorderBrush="#FFFFFFFF"
Background="Black" BorderThickness ="2,2,2,0"
CornerRadius="5,5,0,0">
<Label Content="Welcome to DrawMe"
HorizontalAlignment="Center"
VerticalAlignment="Center"
FontSize="20" Foreground="White"/>
</Border>
<Border Height="220" BorderBrush="#FFFFFFFF"
BorderThickness="2,2,2,0" CornerRadius="5,5,0,0">
<Border.Background>
<LinearGradientBrush EndPoint="0.713,0.698" StartPoint="0.713,-0.139">
<GradientStop Color="#FFFFFFFF" Offset="0.933"/>
<GradientStop Color="LightBlue" Offset="0.337"/>
</LinearGradientBrush>
</Border.Background>
<StackPanel Name="infoPanel" Orientation="Vertical" Margin="10,10,10,10">
<StackPanel Name="typePanel" Orientation="Horizontal">
<Label Name="lblChatType" FontSize="20"
Width="120" HorizontalContentAlignment="Right"
VerticalContentAlignment="Center">Type:</Label>
<RadioButton Name="chatTypeServer" FontSize="20"
VerticalAlignment="Center" Margin="0,0,20,0"
Checked="chatTypeServer_Checked"
VerticalContentAlignment="Center">Server</RadioButton>
<RadioButton Name="chatTypeClient"
FontSize="20" VerticalAlignment="Center"
Checked="chatTypeClient_Checked"
VerticalContentAlignment="Center">Client</RadioButton>
</StackPanel>
<StackPanel Name="serverPanel"
Orientation="Horizontal" Margin="0,10,0,0">
<Label Name="lblServer" FontSize="20"
Width="120" HorizontalContentAlignment="Right"
VerticalContentAlignment="Center">Server:</Label>
<TextBox Height="30" Name="txtServer"
Width="160" FontSize="20"
VerticalContentAlignment="Center" />
</StackPanel>
<StackPanel Name="usernamePanel"
Orientation="Horizontal" Margin="0,10,0,10">
<Label Name="lblUserName" FontSize="20"
Width="120"
HorizontalContentAlignment="Right">User Name:</Label>
<TextBox Height="30" Name="txtUserName"
Width="160" FontSize="20"
VerticalContentAlignment="Center" />
</StackPanel>
<StackPanel Name="buttonPanel"
Orientation="Horizontal"
HorizontalAlignment="Center"
VerticalAlignment="Center">
<Button Name="btnLogin" Width="120"
FontSize="20" Margin="10,10,10,10"
Click="btnLogin_Click">Connect</Button>
<Button Name="btnCancel" Width="120"
FontSize="20" Margin="10,10,10,10"
Click="btnCancel_Click">Cancel</Button>
</StackPanel>
</StackPanel>
</Border>
<Border Height="30" Background="#FF2E2E2E"
BorderBrush="#FFFFFFFF"
BorderThickness="2,0,2,2" CornerRadius="0,0,5,5">
<Label Content="DrawMe is using .NET 3.5 (WPF and WCF)"
FontSize="9" Foreground="#FFFFFFFF"
HorizontalAlignment="Center"
VerticalAlignment="Center" Background="#00FFFFFF"/>
</Border>
</StackPanel>
</UserControl>
After the user has logged in to a DrawMe server, the application enables the main DrawMe window where all drawing takes place. The window consists of four major sections:
- Information Bar - A
StackPanel
along the top of the window that displays the connection status (as a fade-in/fade-out animation) as well as information about who has drawn most recently. There is also a sign out button for when a user wants to leave the session. - Client List - A
ListView
along the left of the window that displays user names for all connected clients. - Ink Tool Selection - A
StackPanel
below the Information bar that allows users to select how to interact with the ink canvas. - Ink Canvas - An
InkCanvas
control that handles the display of ink drawings from all connected clients.
The following screenshot shows how the main application window looks:
Again, there are a few nice WPF features in use that we should point out:
- It's possible to specify a
DropShadowBitmapEffect
for XAML elements - notice the drop-shadow around the Client List. - It's easy to animate text via the
DoubleAnimation
element - In the XAML code listing that follows, you can see we've set the connection status animation to cycle between opaque and transparent every 5 seconds. - The
InkCanvas
control is ready to use without alteration - all we had to do was wire up some handlers for the events raised when a stroke is collected or erased. The different interaction modes (Ink, Erase By Stroke, Erase By Point) are standard, included editing modes for the InkCanvas
control. - Attributes on XAML elements can be bound to properties (
DependencyProperty
) in the underlying class - We store the current ink colour in the FillColor
property, which is just a wrapper around a DependencyProperty
. The interesting thing to note here is that when FillColor
is updated programmatically in the code-behind file, no extra effort is required to update the actual displayed colour in the GUI; the update happens automatically once the property has been bound correctly.
When the user clicks on the colour button, a colour picker dialog is displayed. Unfortunately, WPF has no native colour selection dialog. Luckily, we found this colour picker dialog on one of the MSDN blogs. We modified it slightly to be consistent with our colour scheme, but essentially, it's been used as-is.
The following XAML code listing shows how we made the main application window:
<Window
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Name="DrawMeMainWindow"
x:Class="DrawMe.DrawMeWindow"
Title="DrawMeWindow" Height="600" Width="800"
Background="#FF3B3737" Loaded="Window_Loaded"
MinWidth="800" MinHeight="500">
<Grid x:Name="LayoutRoot" >
<Grid.RowDefinitions>
<RowDefinition Height="65" />
<RowDefinition Height="50" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="150" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Border Grid.Column="0" Grid.Row="0"
Grid.ColumnSpan="2" BorderBrush="Gray"
BorderThickness="1,1,1,1" CornerRadius="8,8,8,8">
<StackPanel Name="loginStackPanel"
Orientation="Horizontal" HorizontalAlignment="Left">
<StackPanel Orientation="Vertical" Margin="10,10,20,0">
<TextBlock Name="ApplicationTypeMessage"
Width="120" Height="25"
FontSize="10" Foreground="White"
TextAlignment="Center">
Waiting for connection...
<TextBlock.Triggers>
<EventTrigger RoutedEvent="TextBlock.Loaded">
<BeginStoryboard>
<Storyboard
Name="ApplicationTypeMessageStoryBoard">
<DoubleAnimation
Name="ApplicationTypeMessageAnimation"
Storyboard.TargetName="ApplicationTypeMessage"
Storyboard.TargetProperty="(TextBlock.Opacity)"
From="1.0" To="0.0"
Duration="0:0:5"
AutoReverse="True"
RepeatBehavior="Forever"
/>
</Storyboard>
</BeginStoryboard>
</EventTrigger>
</TextBlock.Triggers>
</TextBlock>
<Button Name="btnLeave" Width="100"
Height="20" FontSize="10"
Click="btnLeave_Click">
Sign Out
</Button>
</StackPanel>
<TextBlock Name="AnimatedMessage" FontSize="35"
FontWeight="Bold" Foreground="White"
VerticalAlignment="Center">
Welcome to DrawMe
</TextBlock>
</StackPanel>
</Border>
<Border Name="BorderUsersList" Grid.Column="0"
Grid.Row="1" Grid.RowSpan="2"
CornerRadius="8,8,8,8" Background="LightBlue"
BorderThickness="4,4,4,4">
<ListView Name="lvUsers"
Margin="10" FontSize="20">
<ListView.BitmapEffect>
<DropShadowBitmapEffect />
</ListView.BitmapEffect>
</ListView>
</Border>
<Border Name="BorderEditingType" Grid.Column="1"
Grid.Row="1" CornerRadius="8,8,8,8"
Background="LightBlue" BorderThickness="0,4,4,4">
<StackPanel Orientation="Horizontal"
VerticalAlignment="Center">
<RadioButton Name="rbInk" Content="Ink"
Margin="15,0,0,0"
VerticalAlignment="Center"
FontSize="20" IsChecked="True"
Tag="{x:Static InkCanvasEditingMode.Ink}"
Click="rbInkType_Checked">
</RadioButton>
<RadioButton Name="rbEraserByStroke"
Content="Erase By Stroke" Margin="15,0,0,0"
VerticalAlignment="Center" FontSize="20"
Tag="{x:Static InkCanvasEditingMode.EraseByStroke}"
Click="rbInkType_Checked">
</RadioButton>
<RadioButton Name="rbEraserByPoint"
Content="Erase By Point" Margin="15,0,0,0"
VerticalAlignment="Center" FontSize="20"
Tag="{x:Static InkCanvasEditingMode.EraseByPoint}"
Click="rbInkType_Checked">
</RadioButton>
<TextBlock Margin="25,0,10,0"
VerticalAlignment="Center"
FontSize="20" >Colour:</TextBlock>
<Button Margin="0,0,0,0" Background="White"
Height="28" Width="64"
Click="OnSetFill">
<Rectangle Width="54" Height="20"
Stroke="Black" StrokeThickness="2">
<Rectangle.Fill>
<SolidColorBrush
Color="{Binding ElementName=DrawMeMainWindow,
Path=FillColor}" />
</Rectangle.Fill>
</Rectangle>
</Button>
</StackPanel>
</Border>
<Border Name="BorderInkCanvas" Grid.Column="1"
Grid.Row="2" Background="LightBlue"
BorderThickness="0,0,4,4" CornerRadius="8,8,8,8" >
<InkCanvas x:Name="inkCanv" Margin="10"
Background="White"
StrokeCollected="inkCanv_StrokeCollected"
StrokeErasing="inkCanv_StrokeErasing"
StrokeErased="inkCanv_StrokeErased">
</InkCanvas>
</Border>
<Canvas Name="loginCanvas" Grid.Column="1"
Grid.Row="2" Width="500" Height="300"
VerticalAlignment="Top" HorizontalAlignment="Center" />
</Grid>
</Window>
To help explain the main runtime scenarios when using DrawMe, we have constructed some UML sequence diagrams to represent the state of the application at various stages.
During login, up to four main events can occur:
- Start Server - If the user is starting a new DrawMe server, the application spawns a thread to run the DrawMeService which will coordinate communication between clients. We've used TCP for the transport protocol, but WCF allows for the protocol to be easily changed.
- Start Client - Construct a
ClientCallBack
(implements IDrawMeServiceCallback
) to provide a means for the server to invoke functions on the client. Also construct a DrawMeServiceClient
to handle TCP communication with the DrawMe server, and use this to connect to the server. - Update Client List - The server utilises the client's callback to update the list of users registered with the server.
- Finalise Login - Close the login control and begin ink-chat mode.
The following code listing shows how we've implemented the login process. Note that for the sake of simplicity, we've disabled all security (see App.config). We've also hard-coded the communication port to 8000. Again, this is just to make things easier for the sake of the demo. In a real-world application, we probably wouldn't do this!
App.config
<bindings>
<netTcpBinding>
<binding name="DrawMeNetTcpBinding">
<security mode="None">
<transport clientCredentialType="None" />
<message clientCredentialType="None" />
</security>
</binding>
</netTcpBinding>
</bindings>
LoginControl.xaml.cs
private void btnLogin_Click(object sender, RoutedEventArgs e)
{
EndpointAddress serverAddress;
if (this.chatTypeServer.IsChecked == true)
{
DrawMe.App.s_IsServer = true;
serverAddress = new EndpointAddress(
"net.tcp://localhost:8000/DrawMeService/service");
}
else
{
DrawMe.App.StopServer();
DrawMe.App.s_IsServer = false;
if (txtServer.Text.Length == 0)
{
MessageBox.Show("Please enter server name");
return;
}
serverAddress = new EndpointAddress(string.Format(
"net.tcp://{0}:8000/DrawMeService/service", txtServer.Text));
}
if (txtUserName.Text.Length == 0)
{
MessageBox.Show("Please enter username");
return;
}
if (DrawMeServiceClient.Instance == null)
{
if (App.s_IsServer)
{
DrawMe.App.StartServer();
}
try
{
ClientCallBack.Instance =
new ClientCallBack(SynchronizationContext.Current, m_mainWindow);
DrawMeServiceClient.Instance = new DrawMeServiceClient
(
new DrawMeObjects.ChatUser
(
txtUserName.Text,
System.Environment.UserName,
System.Environment.MachineName,
System.Diagnostics.Process.GetCurrentProcess().Id,
App.s_IsServer
),
new InstanceContext(ClientCallBack.Instance),
"DrawMeClientTcpBinding",
serverAddress
);
DrawMeServiceClient.Instance.Open();
}
catch (System.Exception ex)
{
DrawMe.App.StopServer();
DrawMeServiceClient.Instance = null;
MessageBox.Show(string.Format("Failed to connect " +
"to chat server, {0}", ex.Message),this.m_mainWindow.Title);
return;
}
}
if (DrawMeServiceClient.Instance.IsUserNameTaken(
DrawMeServiceClient.Instance.ChatUser.NickName))
{
DrawMeServiceClient.Instance = null;
MessageBox.Show("Username is already in use");
return;
}
if (DrawMeServiceClient.Instance.Join() == false)
{
MessageBox.Show("Failed to join chat room");
DrawMeServiceClient.Instance = null;
DrawMe.App.StopServer();
return;
}
this.m_mainWindow.ChatMode();
}
Once the client has connected to a server, the application is ready to send and receive ink strokes. The two main events that can happen at this stage are:
SendInkStrokes
- The user draws on the canvas and the resulting strokes are sent to the server for relay to all the clients.OnInkStrokesUpdate
- Another user has drawn, and the server then uses the registered callbacks to update the canvas for all clients.
All ink strokes in DrawMe are sent as MemoryStream
objects (or the underlying byte array representation). Note that we are not being very smart about how we are sending the strokes; we send the entire contents of the ink canvas rather than the most recent update. For demonstration purposes, this is OK, since it makes the erasing code simple to handle (it's the same as the drawing code!). We have plans in place to optimise the sending of ink strokes, but unfortunately, we couldn't implement this in time for this article. The following code listing shows how we implemented this functionality in the client:
private void SaveGesture()
{
try
{
MemoryStream memoryStream = new MemoryStream();
this.inkCanv.Strokes.Save(memoryStream);
memoryStream.Flush();
DrawMeServiceClient.Instance.SendInkStrokes(memoryStream);
}
catch (Exception exc)
{
MessageBox.Show(exc.Message, Title);
}
}
Once the strokes are sent to the server, the following code is executed to update all the registered clients. Notice how we call GetBuffer()
on the memory stream passed in when we are sending the stroke updates back to each client. Initially, we were just passing the MemoryStream
object around, but we soon ran into problems with the object being garbage collected before we could use it. This is because each client needs to ensure that all user interface updates occur on the main GUI thread, and so we use an anonymous delegate to post an asynchronous call to the GUI thread. By the time the GUI thread processes the update, it's possible that the MemoryStream
has already been garbage collected. This seems obvious now, but at the time, it had us baffled for a few minutes!
public class DrawMeService : IDrawMeService
{
public void SendInkStrokes(MemoryStream memoryStream)
{
IDrawMeServiceCallback client =
OperationContext.Current.GetCallbackChannel<idrawmeservicecallback>();
foreach (IDrawMeServiceCallback callbackClient in s_dictCallbackToUser.Keys)
{
if (callbackClient !=
OperationContext.Current.GetCallbackChannel<idrawmeservicecallback>())
{
callbackClient.OnInkStrokesUpdate(
s_dictCallbackToUser[client], memoryStream.GetBuffer());
}
}
}
...
}
When a user logs off, the application contacts the server and notifies it that the client should be removed from the registered clients list. If the user is also hosting the server, then all clients are disconnected and returned to login mode.
Here's the code that gets executed on the server when a client leaves:
public void Leave(ChatUser chatUser)
{
IDrawMeServiceCallback client = OperationContext.Current.GetCallbackChannel<idrawmeservicecallback />();
if (s_dictCallbackToUser.ContainsKey(client))
{
s_dictCallbackToUser.Remove(client);
}
foreach (IDrawMeServiceCallback callbackClient in s_dictCallbackToUser.Keys)
{
if (chatUser.IsServer)
{
if (callbackClient != client)
{
callbackClient.ServerDisconnected();
}
}
else
{
callbackClient.UpdateUsersList(s_dictCallbackToUser.Values.ToList());
}
}
if (chatUser.IsServer)
{
s_dictCallbackToUser.Clear();
}
}
So far we haven't really talked too much about how we used WCF to implement the communication between instances of the DrawMe application. In this section, we provide an overview of the key WCF features that we used. There were three main problems that we needed to solve to get the communication working:
- Serialize custom objects - Provide a means to send our class instance objects over the network
- Define service contract - Specify an interface that the server will implement
- Provide client callback functions - Specify a callback interface that the server can utilise to call methods on each client instance
WCF provides solutions to all of these problems!
Many in-built types in .NET are serializable by default. This means that they can be represented in a standard manner for passing over network connections. However, when you define a new class, it is not serializable by default. To store information about each DrawMe client user, we created a ChatUser
class. In order to pass ChatUser
objects over a network connection, we need to specify that they are serializable.
We have setup the ChatUser
class to use the WCF System.Runtime.Serialization
- [DataContract]
attribute. Applying this attribute to a class indicates that we are interested in serializing it. To serialize a specific member of the class, we need to apply the [DataMember]
attribute. This is because data contracts have been designed with an "opt-in" programming model. I.e., anything that is not explicitly marked with the DataMember
attribute is not serialized. The following code snippet shows how we applied these attributes to the ChatUser
class. See ChatUser.cs for the whole implementation.
[DataContract]
public class ChatUser
{
...
[DataMember]
public string NickName
{
get { return m_strNickName; }
set { m_strNickName = value; }
}
...
}
In order for each DrawMe client to be able to communicate with the server, a contract needs to be established. The purpose of the contract is to publish the interface that the server will implement so that clients know what methods are available on the server. In WCF, a contract can be specified by applying the ServiceContract
attribute to an interface. When applying this attribute, it is also possible to specify a CallbackContract
which represents a callback interface that the client will implement. You can see how we used the attribute in the following code:
[
ServiceContract
(
Name = "DrawMeService",
Namespace = "http://DrawMe/DrawMeService/",
SessionMode = SessionMode.Required,
CallbackContract = typeof(IDrawMeServiceCallback)
)
]
public interface IDrawMeService
{
[OperationContract()]
bool Join(ChatUser chatUser);
[OperationContract()]
void Leave(ChatUser chatUser);
[OperationContract()]
bool IsUserNameTaken(string strUserName);
[OperationContract()]
void SendInkStrokes(MemoryStream memoryStream);
}
Each client only needs to know about the IDrawMeService
interface; however, the server needs to contain the implementation. When providing the implementation, it is possible to specify a ServiceBehavior
attribute. The DrawMe server uses the following service behaviour:
ConcurrencyMode
- Single. The service will only handle one incoming call at a time.InstanceContextMode
- Single. Only one DrawMeService
object is used for all incoming calls, and is not recycled subsequent to the calls. If the DrawMe service object does not exist, one is created. This is effectively a singleton.
Here is how we have applied the ServiceBehavior
attribute to the DrawMeService
implementation:
[
ServiceBehavior
(
ConcurrencyMode = ConcurrencyMode.Single,
InstanceContextMode = InstanceContextMode.Single
)
]
public class DrawMeService : IDrawMeService
{
...
}
DrawMe has a IDrawMeServiceCallback
interface which allows the DrawMe server to send messages back to the client application. For example, when a new user has joined the chat room, the server uses the callback mechanism to notify all other users. The callback interface is defined in a shared DrawMeInterfaces.dll; the implementation is located at the client side - see ClientCallBack.cs.
DrawMe clients implement three callback functions:
UpdateUsersList
- The DrawMe server notifies all active clients when a new user has joined the chat roomOnInkStrokesUpdate
- For the DrawMe server to send the latest ink strokes to all active clientsServerDisconnected
- This function is used to notify all active clients when the server disconnects
It's possible to specify an OperationContract
attribute on each callback function. In DrawMe, we have chosen to implement the callbacks with the IsOneWay=true
attribute, i.e., the operations don't relay any information back to the server about whether or not they were successful.
public interface IDrawMeServiceCallback
{
[OperationContract(IsOneWay = true)]
void UpdateUsersList(List<chatuser /> listChatUsers);
[OperationContract(IsOneWay = true)]
void OnInkStrokesUpdate(ChatUser chatUser, byte[] bytesStroke);
[OperationContract(IsOneWay = true)]
void ServerDisconnected();
}
This article has hopefully given you an overview of some of the available features in WCF. In terms of meeting our goal of implementing a collaborative drawing program, we've demonstrated that this is not only possible but relatively easy to do using some of the cool new WCF functionality. Actually, we think we probably spent more time writing this article than we did writing the code for it, so that should give you some indication of how powerful the WCF framework is (assuming we're not terrible writers!). Please feel free to download the source code and delve into the structure, and leave your comments or questions below!
During the design and coding stages of this small project, we wanted a way to collaborate without having to keep going to each other's house every time we wanted to work on the project. Using a web-based free source control system was the obvious solution. We decided to try out CodePlex (http://www.codeplex.com/) which is Microsoft's Open Source project hosting web site. We found CodePlex to be a very useful tool in terms of coordinating the work effort and keeping track of what still needed to be implemented. What's more, CodePlex has a very intuitive user interface, and neither of us had any difficulty in using it.
The backend of CodePlex uses a system of Team Foundation Server (TFS) databases to store all the community projects on. Given that VS2008 integrates tightly with TFS, we originally planned to use Team Explore 2008 as the source control client. Team Explore 2008 is a free, simplified TFS client from Microsoft that can integrate with the VS2008 development environment directly. Unfortunately, Team Explore 2008 doesn't work with VS2008 Beta 2 (we found this out the hard way after downloading 387 MB). But in the end, it didn't really matter because we were able to use TortoiseSVN (a Subversion client for Windows) to access the TFS that our project was stored on. Information on how to do this is readily available in the CodePlex FAQ.
Once we got the source control access sorted out, it was very easy for us to collaborate on the project. The thing we really liked about CodePlex was the integrated Issue Tracker; raising issues was painless and easy, as was assigning issues to each other for work. In summary, if you're thinking about starting up an Open-Source project with more than one developer, then using CodePlex is definitely an option worth exploring.
If you are interested in "checking out" the DrawMe project on CodePlex, head on over to http://www.codeplex.com/drawme and take a look around. The "Issue Tracker" and "Source Code" tabs are probably the most interesting in terms of seeing the workflow process we went through.